diff --git a/VM1_app/codebase/backend/.env.example b/VM1_app/codebase/backend/.env.example index 76ebed9..14b50f1 100644 --- a/VM1_app/codebase/backend/.env.example +++ b/VM1_app/codebase/backend/.env.example @@ -7,19 +7,23 @@ RELEASE_VERSION=change-me-release LOG_LEVEL=INFO COMPOSE_PROJECT_NAME=han-chat -# Immutable service images built by their owning modules. -API_BACKEND_IMAGE=han-chat-api-backend:local -MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local -BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local -BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local -KEYCLOAK_IMAGE=han-chat-keycloak:local -SMS_SERVICE_IMAGE=han-chat-sms-service:local +# Immutable production images. Replace every example digest during release materialization. +API_BACKEND_IMAGE=registry.example.ru/han-chat/api-backend@sha256:0000000000000000000000000000000000000000000000000000000000000000 +BITRIX_LOCAL_APP_IMAGE=registry.example.ru/han-chat/bitrix-local-app@sha256:0000000000000000000000000000000000000000000000000000000000000000 +FRONTEND_STATIC_IMAGE=registry.example.ru/han-chat/frontend-static@sha256:0000000000000000000000000000000000000000000000000000000000000000 +KEYCLOAK_IMAGE=registry.example.ru/han-chat/keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000 +NGINX_IMAGE=registry.example.ru/han-chat/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000 +OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:0000000000000000000000000000000000000000000000000000000000000000 +OTEL_QUEUE_INIT_IMAGE=alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000 +REDIS_IMAGE=registry.example.ru/han-chat/redis@sha256:0000000000000000000000000000000000000000000000000000000000000000 +SMS_SERVICE_IMAGE=registry.example.ru/han-chat/sms-service@sha256:0000000000000000000000000000000000000000000000000000000000000000 +TOOLBOX_IMAGE=curlimages/curl@sha256:0000000000000000000000000000000000000000000000000000000000000000 # Managed PostgreSQL is external to Compose. Credential-bearing DSNs are secrets. HAN_PG_HOST=managed-pg.private.example HAN_PG_PORT=5433 HAN_PG_DATABASE=han_chat -PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem +PG_CA_HOST_PATH=/etc/han/ca/managed-postgresql-ca.pem # PgBouncer session mode uses database-level search_path; runtime DSNs must not pass options/currentSchema. KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem KEYCLOAK_DB_SCHEMA=keycloak @@ -33,8 +37,8 @@ PUBLIC_AUTH_URL=https://chat.example.ru/auth NGINX_HTTP_PORT=80 NGINX_HTTPS_PORT=443 NGINX_TLS_ENABLED=true -NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem -NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem +NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem +NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem NGINX_HSTS_MAX_AGE=31536000 NGINX_CLIENT_MAX_BODY_SIZE=8m NGINX_RATE_LIMIT_API=60r/m @@ -53,7 +57,7 @@ NGINX_MESSAGE_READ_TIMEOUT_SEC=330 NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32 TRUSTED_PROXY_CIDRS=172.16.0.0/12 BITRIX_FRAME_ANCESTORS=https://*.bitrix24.ru -S3_CONNECT_SRC=https://*.s3.ru-7.storage.selcloud.ru +S3_CONNECT_SRC=https://*.s3.ru-6.storage.selcloud.ru FRONTEND_DEV_PROXY_ENABLED=false # При false значение не используется @@ -85,20 +89,15 @@ IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080 BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox -MESSAGE_SAFETY_URL=http://message-safety:8080 +MESSAGE_SAFETY_URL=https://processing.internal:8443 +# Docker extra_hosts mapping for VM2 private listener: =. +MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4 +MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem +MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2 MESSAGE_SAFETY_POST_TIMEOUT_SEC=5 MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2 MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300 -MESSAGE_SAFETY_TASK_TTL_SEC=900 -MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60 -MESSAGE_SAFETY_RULES_VERSION=2026-01-01 -#Отключение синхронизации (при отключенной синхронизации параметры ниже не работают) -BITRIX_SYNC_ENABLED=false -BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru -BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC=60 -BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC=30 -BITRIX_SYNC_CRM_MAX_CONCURRENCY=2 -BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50 + BITRIX_CLIENT_ID=change-me-client-id BITRIX_CONNECTOR_ID=han_mobile_app BITRIX_CONNECTOR_NAME=HAN Mobile App diff --git a/VM1_app/codebase/backend/api-backend/Dockerfile b/VM1_app/codebase/backend/api-backend/Dockerfile index 0cf92f7..065f4d9 100644 --- a/VM1_app/codebase/backend/api-backend/Dockerfile +++ b/VM1_app/codebase/backend/api-backend/Dockerfile @@ -14,6 +14,8 @@ RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl COPY alembic.ini ./ COPY alembic ./alembic COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint +RUN sed -i 's/\r$//' /usr/local/bin/han-container-entrypoint \ + && /bin/sh -n /usr/local/bin/han-container-entrypoint USER 10001:10001 EXPOSE 8000 ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"] diff --git a/VM1_app/codebase/backend/api-backend/README.md b/VM1_app/codebase/backend/api-backend/README.md index b481476..bc80573 100644 --- a/VM1_app/codebase/backend/api-backend/README.md +++ b/VM1_app/codebase/backend/api-backend/README.md @@ -56,11 +56,12 @@ han-notification-draft-cleanup-worker продюсера Notification Center; в БД синхронизируется только SHA-256 hash; - `OTEL_EXPORTER_OTLP_ENDPOINT` — опциональный endpoint collector. -Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety -не передаются этому контейнеру. В production подключение PostgreSQL должно использовать -TLS. Target `MESSAGE_SAFETY_URL=https://processing.internal:8443`; certificate -проверяется по internal CA, plaintext HTTP запрещён. Текущий Docker hostname -`message-safety` относится только к legacy stub до cutover. +Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN` +сохраняется как caller secret API backend; S3 credentials сервису Safety не +передаются. В production подключение PostgreSQL должно использовать TLS. Target +`MESSAGE_SAFETY_URL=https://processing.internal:8443`, API prefix +`/internal/safety/v2`; certificate проверяется по CA из +`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён. Smoke-сценарий `producer_test`: отправить `POST /internal/notifications/v1/notifications` с `Authorization: Bearer diff --git a/VM1_app/codebase/backend/api-backend/alembic/versions/0011_module07_contract.py b/VM1_app/codebase/backend/api-backend/alembic/versions/0011_module07_contract.py index 8fd8097..f92d533 100644 --- a/VM1_app/codebase/backend/api-backend/alembic/versions/0011_module07_contract.py +++ b/VM1_app/codebase/backend/api-backend/alembic/versions/0011_module07_contract.py @@ -26,37 +26,70 @@ def upgrade() -> None: ADD COLUMN IF NOT EXISTS last_error_code varchar(64), ADD COLUMN IF NOT EXISTS last_error_at timestamptz, ADD COLUMN IF NOT EXISTS completed_at timestamptz, - ADD COLUMN IF NOT EXISTS cancel_reason varchar(255); - + ADD COLUMN IF NOT EXISTS cancel_reason varchar(255) + """ + ) + op.execute( + """ UPDATE han_app.sync_queue SET status = CASE status WHEN 'processing' THEN 'pending' WHEN 'failed' THEN 'retry_wait' ELSE status END - WHERE status IN ('processing', 'failed'); - + WHERE status IN ('processing', 'failed') + """ + ) + op.execute( + """ ALTER TABLE han_app.sync_queue - DROP CONSTRAINT IF EXISTS sync_queue_status_check; + DROP CONSTRAINT IF EXISTS sync_queue_status_check + """ + ) + op.execute( + """ ALTER TABLE han_app.sync_queue ADD CONSTRAINT sync_queue_status_check CHECK ( status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled') - ) NOT VALID; + ) NOT VALID + """ + ) + op.execute( + """ ALTER TABLE han_app.sync_queue - VALIDATE CONSTRAINT sync_queue_status_check; - + VALIDATE CONSTRAINT sync_queue_status_check + """ + ) + op.execute( + """ ALTER TABLE han_app.sync_queue - DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key; - DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next; + DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key + """ + ) + op.execute("DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next") + op.execute( + """ CREATE INDEX IF NOT EXISTS ix_sync_queue_claim - ON han_app.sync_queue(status, next_attempt_at, created_at); + ON han_app.sync_queue(status, next_attempt_at, created_at) + """ + ) + op.execute( + """ CREATE INDEX IF NOT EXISTS ix_sync_queue_expired_lease - ON han_app.sync_queue(locked_until) WHERE status = 'leased'; + ON han_app.sync_queue(locked_until) WHERE status = 'leased' + """ + ) + op.execute( + """ CREATE INDEX IF NOT EXISTS ix_sync_queue_entity_history - ON han_app.sync_queue(entity_type, entity_id, created_at DESC); + ON han_app.sync_queue(entity_type, entity_id, created_at DESC) + """ + ) + op.execute( + """ CREATE UNIQUE INDEX IF NOT EXISTS uq_sync_queue_active_dedup ON han_app.sync_queue(dedup_key) - WHERE status IN ('pending','leased','retry_wait'); + WHERE status IN ('pending','leased','retry_wait') """ ) @@ -173,13 +206,20 @@ def upgrade() -> None: updated_at = now(); RETURN NEW; END; - $$; - + $$ + """ + ) + op.execute( + """ DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles; + """ + ) + op.execute( + """ CREATE TRIGGER trg_profile_contact_sync AFTER INSERT OR UPDATE OF record_status ON han_app.client_profiles - FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync(); + FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync() """ ) diff --git a/VM1_app/codebase/backend/api-backend/alembic/versions/0012_safety_v2_checkpoint.py b/VM1_app/codebase/backend/api-backend/alembic/versions/0012_safety_v2_checkpoint.py index 2e7caab..ea90dcd 100644 --- a/VM1_app/codebase/backend/api-backend/alembic/versions/0012_safety_v2_checkpoint.py +++ b/VM1_app/codebase/backend/api-backend/alembic/versions/0012_safety_v2_checkpoint.py @@ -21,32 +21,58 @@ def upgrade() -> None: ALTER TABLE han_app.messages ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16), ADD COLUMN IF NOT EXISTS safety_config_version bigint, - ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128); - + ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128) + """ + ) + op.execute( + """ ALTER TABLE han_app.message_attachments ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024), - ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024); + ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024) + """ + ) + op.execute( + """ ALTER TABLE han_app.message_attachments - DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check; + DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check + """ + ) + op.execute( + """ ALTER TABLE han_app.message_attachments ADD CONSTRAINT message_attachments_scan_status_check - CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID; + CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID + """ + ) + op.execute( + """ ALTER TABLE han_app.message_attachments - VALIDATE CONSTRAINT message_attachments_scan_status_check; - + VALIDATE CONSTRAINT message_attachments_scan_status_check + """ + ) + op.execute( + """ ALTER TABLE han_app.safety_tasks ADD COLUMN IF NOT EXISTS poll_location varchar(1024), ADD COLUMN IF NOT EXISTS processing_mode varchar(16), ADD COLUMN IF NOT EXISTS config_version bigint, ADD COLUMN IF NOT EXISTS rules_version varchar(128), - ADD COLUMN IF NOT EXISTS expires_at timestamptz; + ADD COLUMN IF NOT EXISTS expires_at timestamptz + """ + ) + op.execute( + """ UPDATE han_app.safety_tasks SET poll_location = '/internal/safety/v2/messages/tasks/' || task_id, expires_at = deadline_at - WHERE poll_location IS NULL OR expires_at IS NULL; + WHERE poll_location IS NULL OR expires_at IS NULL + """ + ) + op.execute( + """ ALTER TABLE han_app.safety_tasks ALTER COLUMN poll_location SET NOT NULL, - ALTER COLUMN expires_at SET NOT NULL; + ALTER COLUMN expires_at SET NOT NULL """ ) @@ -58,14 +84,26 @@ def upgrade() -> None: ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024), ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16), ADD COLUMN IF NOT EXISTS safety_config_version bigint, - ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128); + ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128) + """ + ) + op.execute( + """ ALTER TABLE han_app.client_upload_drafts - DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check; + DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check + """ + ) + op.execute( + """ ALTER TABLE han_app.client_upload_drafts ADD CONSTRAINT client_upload_drafts_scan_status_check - CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID; + CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID + """ + ) + op.execute( + """ ALTER TABLE han_app.client_upload_drafts - VALIDATE CONSTRAINT client_upload_drafts_scan_status_check; + VALIDATE CONSTRAINT client_upload_drafts_scan_status_check """ ) diff --git a/VM1_app/codebase/backend/api-backend/app/integrations.py b/VM1_app/codebase/backend/api-backend/app/integrations.py index 8e5b11d..93e31dc 100644 --- a/VM1_app/codebase/backend/api-backend/app/integrations.py +++ b/VM1_app/codebase/backend/api-backend/app/integrations.py @@ -254,11 +254,23 @@ class SafetyClient: async def ready(self) -> bool: try: response = await self.http.get( - f"{str(self.settings.message_safety_url).rstrip('/')}/health/ready", + f"{str(self.settings.message_safety_url).rstrip('/')}/internal/safety/status", timeout=2, ) - return response.status_code == 200 - except httpx.HTTPError: + if response.status_code != 200: + return False + body = response.json() + capabilities = body.get("capabilities", {}) + return ( + body.get("status") in {"ok", "degraded"} + and body.get("processing_mode") == "standard" + and type(body.get("config_version")) is int + and all( + capabilities.get(name) == "ready" + for name in ("text", "links", "files", "worker") + ) + ) + except (httpx.HTTPError, AttributeError, ValueError): return False diff --git a/VM1_app/codebase/backend/api-backend/app/main.py b/VM1_app/codebase/backend/api-backend/app/main.py index 73999ae..c7ace34 100644 --- a/VM1_app/codebase/backend/api-backend/app/main.py +++ b/VM1_app/codebase/backend/api-backend/app/main.py @@ -138,9 +138,7 @@ async def lifespan(app: FastAPI): app.state.settings = settings app.state.db = Database(settings.database_url) app.state.http = httpx.AsyncClient() - app.state.safety_http = httpx.AsyncClient( - verify=settings.message_safety_ca_file or True - ) + app.state.safety_http = httpx.AsyncClient(verify=settings.message_safety_ca_file) app.state.redis = redis.from_url(settings.redis_url, decode_responses=True) app.state.redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True) app.state.jwks = JWKSValidator(settings, app.state.http) diff --git a/VM1_app/codebase/backend/api-backend/app/services.py b/VM1_app/codebase/backend/api-backend/app/services.py index b2c897c..ae55237 100644 --- a/VM1_app/codebase/backend/api-backend/app/services.py +++ b/VM1_app/codebase/backend/api-backend/app/services.py @@ -74,6 +74,49 @@ def safety_reply_message(dialog_id: uuid.UUID, content_kind: str) -> Message: ) +def safety_task_recovery_at(now: datetime, settings: Settings) -> datetime: + """Keep recovery behind the synchronous poller's worst-case final attempt.""" + return now + timedelta( + seconds=settings.message_safety_task_poll_max_sec + + settings.message_safety_task_poll_interval_sec + + 5 + ) + + +async def ensure_delivery_outbox( + session: AsyncSession, + message_id: uuid.UUID, + external_chat_id: uuid.UUID, + payload_json: dict[str, Any], + next_attempt_at: datetime, +) -> DeliveryOutbox: + """Create the per-message outbox row or return the concurrent winner.""" + statement = ( + insert(DeliveryOutbox) + .values( + id=uuid.uuid4(), + message_id=message_id, + external_chat_id=external_chat_id, + payload_json=payload_json, + next_attempt_at=next_attempt_at, + ) + .on_conflict_do_nothing(index_elements=[DeliveryOutbox.message_id]) + .returning(DeliveryOutbox.id) + ) + outbox_id = (await session.execute(statement)).scalar_one_or_none() + if outbox_id is not None: + outbox = await session.get(DeliveryOutbox, outbox_id) + else: + outbox = ( + await session.execute( + select(DeliveryOutbox).where(DeliveryOutbox.message_id == message_id) + ) + ).scalar_one() + if outbox is None: # pragma: no cover - defensive guard for an invalid DB response + raise RuntimeError("Delivery outbox row was not returned") + return outbox + + class DomainError(Exception): def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None): self.code, self.status, self.message = code, status, message @@ -918,7 +961,9 @@ async def send_message( expires_at=datetime.fromisoformat(verdict["expires_at"].replace("Z", "+00:00")), deadline_at=now + timedelta(seconds=settings.message_safety_task_poll_max_sec + 900), - next_poll_at=now, + # The request owns the first polling window. Recovery starts + # afterwards if the request crashes before completing the task. + next_poll_at=safety_task_recovery_at(datetime.now(UTC), settings), ) session.add(task) await session.commit() @@ -987,7 +1032,8 @@ async def send_message( "bypassed" if verdict["processing_mode"] == "mock" else "clean" ) message.safety_status = "allowed" - outbox = DeliveryOutbox( + outbox = await ensure_delivery_outbox( + session, message_id=message.id, external_chat_id=dialog_id, payload_json={ @@ -1019,7 +1065,6 @@ async def send_message( next_attempt_at=datetime.now(UTC) + timedelta(seconds=settings.bitrix_local_app_http_timeout_sec + 5), ) - session.add(outbox) await session.commit() await publish_message_status(fanout, message, settings) delivery_payload = await fresh_openlines_payload(outbox.payload_json, s3) diff --git a/VM1_app/codebase/backend/api-backend/app/workers.py b/VM1_app/codebase/backend/api-backend/app/workers.py index 13f5186..8f7cf3b 100644 --- a/VM1_app/codebase/backend/api-backend/app/workers.py +++ b/VM1_app/codebase/backend/api-backend/app/workers.py @@ -1,5 +1,7 @@ import asyncio import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta import httpx @@ -26,12 +28,28 @@ from app.integrations import ( from app.notification_models import ClientUploadDraft from app.notification_service import expire_notifications from app.realtime import RealtimeFanout -from app.services import load_settings, publish_dialog_status, publish_message_status +from app.services import ( + ensure_delivery_outbox, + load_settings, + publish_dialog_status, + publish_message_status, +) from app.settings import Settings, get_settings log = structlog.get_logger() +@asynccontextmanager +async def worker_http_clients( + settings: Settings, +) -> AsyncIterator[tuple[httpx.AsyncClient, httpx.AsyncClient]]: + async with ( + httpx.AsyncClient() as openlines_http, + httpx.AsyncClient(verify=settings.message_safety_ca_file) as safety_http, + ): + yield openlines_http, safety_http + + async def delivery_once( db: Database, client: OpenLinesClient, @@ -171,37 +189,38 @@ async def safety_once( else None ) if dialog and user: - session.add( - DeliveryOutbox( - message_id=message.id, - external_chat_id=dialog.id, - payload_json={ - "message_id": str(message.id), - "external_chat_id": str(dialog.id), - "occurred_at": message.occurred_at.isoformat(), - "user": { - "id": str(user.id), - "display_name": user.phone_number, - }, - "message": { - "content_kind": message.content_kind, - "text": message.text, - "files": ( - [{ + await ensure_delivery_outbox( + session, + message_id=message.id, + external_chat_id=dialog.id, + payload_json={ + "message_id": str(message.id), + "external_chat_id": str(dialog.id), + "occurred_at": message.occurred_at.isoformat(), + "user": { + "id": str(user.id), + "display_name": user.phone_number, + }, + "message": { + "content_kind": message.content_kind, + "text": message.text, + "files": ( + [ + { "attachment_id": str(attachment.id), "name": attachment.safe_file_name, "mime_type": attachment.mime_type, "size_bytes": attachment.size_bytes, "_storage_bucket": attachment.storage_bucket, "_object_key": attachment.object_key, - }] - if attachment - else [] - ), - }, + } + ] + if attachment + else [] + ), }, - next_attempt_at=datetime.now(UTC), - ) + }, + next_attempt_at=datetime.now(UTC), ) elif verdict["_status"] == 403 and message: message.safety_processing_mode = verdict["processing_mode"] @@ -270,26 +289,25 @@ async def cleanup_once(db: Database, s3: S3Client, batch_size: int = 100) -> int async def loop(kind: str) -> None: settings = get_settings() db = Database(settings.database_url) - http = httpx.AsyncClient() - safety = SafetyClient(settings, http) - openlines = OpenLinesClient(settings, http) s3 = S3Client(settings) redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True) fanout = RealtimeFanout(redis_rt) worker_id = f"{kind}-{uuid.uuid4()}" try: - while True: - count = 0 - if kind == "delivery": - count = await delivery_once(db, openlines, s3, fanout, settings, worker_id) - elif kind == "safety": - count = await safety_once(db, safety, s3, fanout, settings, worker_id) - else: - count = await cleanup_once(db, s3) - if not count: - await asyncio.sleep(settings.worker_poll_interval_sec) + async with worker_http_clients(settings) as (openlines_http, safety_http): + safety = SafetyClient(settings, safety_http) + openlines = OpenLinesClient(settings, openlines_http) + while True: + count = 0 + if kind == "delivery": + count = await delivery_once(db, openlines, s3, fanout, settings, worker_id) + elif kind == "safety": + count = await safety_once(db, safety, s3, fanout, settings, worker_id) + else: + count = await cleanup_once(db, s3) + if not count: + await asyncio.sleep(settings.worker_poll_interval_sec) finally: - await http.aclose() await redis_rt.aclose() await db.close() diff --git a/VM1_app/codebase/backend/api-backend/docker-compose.yml b/VM1_app/codebase/backend/api-backend/docker-compose.yml index 7ee140e..00ef9e2 100644 --- a/VM1_app/codebase/backend/api-backend/docker-compose.yml +++ b/VM1_app/codebase/backend/api-backend/docker-compose.yml @@ -27,8 +27,8 @@ services: KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL} KEYCLOAK_REALM: ${KEYCLOAK_REALM} KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE} - MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL} - MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2 + MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-https://processing.internal:8443} + MESSAGE_SAFETY_API_PREFIX: ${MESSAGE_SAFETY_API_PREFIX:-/internal/safety/v2} MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5} MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2} diff --git a/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py b/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py index b36a1dd..4213ba5 100644 --- a/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py +++ b/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py @@ -13,6 +13,7 @@ from app.integrations import ( ) from app.realtime import CHANNEL_PREFIX from app.settings import Settings +from app.workers import worker_http_clients def settings() -> Settings: @@ -25,8 +26,9 @@ def settings() -> Settings: "KEYCLOAK_INTERNAL_URL": "http://keycloak:8080", "KEYCLOAK_REALM": "han", "KEYCLOAK_AUDIENCE": "api", - "MESSAGE_SAFETY_URL": "http://safety:8080", + "MESSAGE_SAFETY_URL": "https://processing.internal:8443", "MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token", + "MESSAGE_SAFETY_CA_FILE": "/run/config/message-safety-internal-ca.pem", "BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080", "BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token", "BITRIX_API_INBOX_TOKEN": "inbox-token", @@ -42,6 +44,85 @@ def settings() -> Settings: return Settings.model_validate(common) +@pytest.mark.asyncio +async def test_worker_uses_isolated_tls_client_for_remote_safety(monkeypatch) -> None: + created = [] + + class FakeAsyncClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.closed = False + created.append(self) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + self.closed = True + + monkeypatch.setattr("app.workers.httpx.AsyncClient", FakeAsyncClient) + + async with worker_http_clients(settings()) as (openlines_http, safety_http): + assert openlines_http.kwargs == {} + assert safety_http.kwargs == { + "verify": "/run/config/message-safety-internal-ca.pem" + } + assert openlines_http is not safety_http + + assert all(client.closed for client in created) + + +@pytest.mark.asyncio +async def test_safety_ready_uses_private_status_alias_and_accepts_redis_degradation() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/internal/safety/status" + return httpx.Response( + 200, + json={ + "status": "degraded", + "processing_mode": "standard", + "config_version": 1, + "capabilities": { + "text": "ready", + "links": "ready", + "files": "ready", + "worker": "ready", + }, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: + assert await SafetyClient(settings(), http).ready() is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("processing_mode", "files"), + (("mock", "ready"), ("standard", "unavailable")), +) +async def test_safety_ready_rejects_unsafe_mode_or_unavailable_capability( + processing_mode: str, files: str +) -> None: + async def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "status": "degraded", + "processing_mode": processing_mode, + "config_version": 1, + "capabilities": { + "text": "ready", + "links": "ready", + "files": files, + "worker": "ready", + }, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: + assert await SafetyClient(settings(), http).ready() is False + + def test_realtime_channel_matches_redis_acl_namespace() -> None: assert CHANNEL_PREFIX == "han:rt:dialog:" diff --git a/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py b/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py index 99bf24d..cc9ec4e 100644 --- a/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py +++ b/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py @@ -1,4 +1,7 @@ import uuid +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest from pydantic import TypeAdapter, ValidationError @@ -14,7 +17,12 @@ from app.schemas import ( decode_cursor, encode_cursor, ) -from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message +from app.services import ( + MESSAGE_SAFETY_REPLIES, + ensure_delivery_outbox, + safety_reply_message, + safety_task_recovery_at, +) def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None: @@ -67,6 +75,41 @@ def test_message_safety_business_replies_are_content_specific() -> None: assert reply.delivery_status == "delivered" +def test_safety_recovery_starts_after_synchronous_polling_window() -> None: + now = datetime(2026, 8, 19, tzinfo=UTC) + settings = SimpleNamespace( + message_safety_task_poll_max_sec=300, + message_safety_task_poll_interval_sec=2, + ) + + assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307 + + +async def test_delivery_outbox_returns_concurrent_insert_winner() -> None: + existing = object() + session = SimpleNamespace( + execute=AsyncMock( + side_effect=[ + SimpleNamespace(scalar_one_or_none=lambda: None), + SimpleNamespace(scalar_one=lambda: existing), + ] + ), + get=AsyncMock(), + ) + + result = await ensure_delivery_outbox( + session, + message_id=uuid.uuid4(), + external_chat_id=uuid.uuid4(), + payload_json={"message_id": "test"}, + next_attempt_at=datetime(2026, 8, 19, tzinfo=UTC), + ) + + assert result is existing + assert session.execute.await_count == 2 + session.get.assert_not_awaited() + + def test_fingerprint_is_canonical_and_user_scoped() -> None: user = uuid.uuid4() first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user) diff --git a/VM1_app/codebase/backend/bitrix-local-app/Dockerfile b/VM1_app/codebase/backend/bitrix-local-app/Dockerfile index 1d39317..606afe1 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/Dockerfile +++ b/VM1_app/codebase/backend/bitrix-local-app/Dockerfile @@ -7,6 +7,8 @@ COPY alembic ./alembic COPY alembic.ini pyproject.toml ./ RUN pip install --no-cache-dir . COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint +RUN sed -i 's/\r$//' /usr/local/bin/han-container-entrypoint \ + && /bin/sh -n /usr/local/bin/han-container-entrypoint USER app EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)" diff --git a/VM1_app/codebase/backend/bitrix-sync/Dockerfile b/VM1_app/codebase/backend/bitrix-sync/Dockerfile deleted file mode 100644 index 1d39317..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM python:3.12-slim -ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 -RUN addgroup --system app && adduser --system --ingroup app app -WORKDIR /service -COPY app ./app -COPY alembic ./alembic -COPY alembic.ini pyproject.toml ./ -RUN pip install --no-cache-dir . -COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint -USER app -EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)" -ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"] -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/VM1_app/codebase/backend/bitrix-sync/alembic.ini b/VM1_app/codebase/backend/bitrix-sync/alembic.ini deleted file mode 100644 index 9d3809d..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/alembic.ini +++ /dev/null @@ -1,30 +0,0 @@ -[alembic] -script_location = alembic -prepend_sys_path = . -sqlalchemy.url = postgresql+asyncpg://unused - -[loggers] -keys = root,sqlalchemy,alembic -[handlers] -keys = console -[formatters] -keys = generic -[logger_root] -level = WARN -handlers = console -qualname = -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine -[logger_alembic] -level = INFO -handlers = -qualname = alembic -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/VM1_app/codebase/backend/bitrix-sync/alembic/env.py b/VM1_app/codebase/backend/bitrix-sync/alembic/env.py deleted file mode 100644 index 0a64091..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/alembic/env.py +++ /dev/null @@ -1,38 +0,0 @@ -import asyncio -import os - -from alembic import context -from app.postgres import create_postgres_engine - -config = context.config -database_url = os.environ["BITRIX_SYNC_DATABASE_URL"] -config.set_main_option( - "sqlalchemy.url", - database_url.replace("%", "%%"), -) -target_metadata = None - - -def run_offline() -> None: - context.configure(url=config.get_main_option("sqlalchemy.url"), literal_binds=True) - with context.begin_transaction(): - context.run_migrations() - - -async def run_online() -> None: - engine = create_postgres_engine(database_url) - async with engine.connect() as connection: - await connection.run_sync(do_run) - await engine.dispose() - - -def do_run(connection) -> None: - context.configure(connection=connection) - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_offline() -else: - asyncio.run(run_online()) diff --git a/VM1_app/codebase/backend/bitrix-sync/alembic/versions/0001_connectivity_stub_baseline.py b/VM1_app/codebase/backend/bitrix-sync/alembic/versions/0001_connectivity_stub_baseline.py deleted file mode 100644 index 1362468..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/alembic/versions/0001_connectivity_stub_baseline.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Establish the bitrix-sync connectivity-stub migration baseline.""" - -revision = "0001_sync_baseline" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # The connectivity stub deliberately owns no runtime tables. - pass - - -def downgrade() -> None: - pass diff --git a/VM1_app/codebase/backend/bitrix-sync/app/__init__.py b/VM1_app/codebase/backend/bitrix-sync/app/__init__.py deleted file mode 100644 index 7e2076d..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""HAN bitrix-sync DB connectivity stub.""" diff --git a/VM1_app/codebase/backend/bitrix-sync/app/main.py b/VM1_app/codebase/backend/bitrix-sync/app/main.py deleted file mode 100644 index ad22f27..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/app/main.py +++ /dev/null @@ -1,315 +0,0 @@ -from __future__ import annotations - -import asyncio -import hmac -import random -import time -import uuid -from contextlib import asynccontextmanager, suppress -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import Annotated, Protocol - -import uvicorn -from fastapi import FastAPI, Header, HTTPException, Request -from fastapi.responses import JSONResponse -from pydantic import Field, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine - -from app.postgres import create_postgres_engine - - -def utcnow() -> datetime: - return datetime.now(UTC) - - -class Settings(BaseSettings): - model_config = SettingsConfigDict(extra="ignore") - app_env: str = "production-like" - bitrix_sync_enabled: bool = True - bitrix_sync_database_url: str | None = None - bitrix_sync_service_token: str = Field(min_length=16) - bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05) - bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05) - bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5) - bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05) - bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05) - bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1) - bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5) - bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30) - - @model_validator(mode="after") - def validate_enabled(self) -> Settings: - if self.bitrix_sync_enabled and not self.bitrix_sync_database_url: - raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled") - return self - - -@dataclass -class Snapshot: - state: str - started_at: datetime - last_started_at: datetime | None = None - last_finished_at: datetime | None = None - last_success_at: datetime | None = None - success: bool | None = None - duration_ms: int | None = None - error_code: str | None = None - consecutive_failures: int = 0 - next_check_at: datetime | None = None - worker_running: bool = False - - -class Probe(Protocol): - async def check(self) -> None: ... - async def close(self) -> None: ... - - -class DatabaseProbe: - def __init__(self, engine: AsyncEngine) -> None: - self.engine = engine - - async def check(self) -> None: - async with self.engine.connect() as connection: - result = await connection.scalar(text("SELECT 1")) - if result != 1: - raise RuntimeError("unexpected_result") - - async def close(self) -> None: - await self.engine.dispose() - - -def classify_error(exc: Exception) -> str: - name = type(exc).__name__.lower() - text_value = str(exc).lower() - if isinstance(exc, TimeoutError): - return "db_query_timeout" - if "auth" in name or "password" in text_value: - return "db_auth_failed" - if "ssl" in name or "tls" in text_value or "certificate" in text_value: - return "db_tls_failed" - if str(exc) == "unexpected_result": - return "unexpected_result" - return "db_unavailable" - - -def jsonable(value): - if isinstance(value, datetime): - return value.isoformat().replace("+00:00", "Z") - return value - - -def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI: - cfg = settings or Settings() - - @asynccontextmanager - async def lifespan(app: FastAPI): - app.state.snapshot = Snapshot( - state="disabled" if not cfg.bitrix_sync_enabled else "starting", - started_at=utcnow(), - ) - app.state.stop = asyncio.Event() - app.state.probe = probe - app.state.loop_task = None - if cfg.bitrix_sync_enabled: - if app.state.probe is None: - engine = create_postgres_engine( - cfg.bitrix_sync_database_url or "", - pool_size=cfg.bitrix_sync_db_pool_size, - max_overflow=0, - pool_pre_ping=True, - pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec, - server_settings={"application_name": "han-bitrix-sync"}, - ) - app.state.probe = DatabaseProbe(engine) - await run_probe(app) - app.state.snapshot.worker_running = True - app.state.loop_task = asyncio.create_task( - periodic_loop(app), name="db-connectivity-probe" - ) - yield - app.state.snapshot.state = "stopping" - app.state.stop.set() - if app.state.loop_task: - app.state.loop_task.cancel() - with suppress(asyncio.CancelledError): - await app.state.loop_task - if app.state.probe: - await app.state.probe.close() - - app = FastAPI( - title="HAN Bitrix Sync Connectivity Stub", - version="1.0.0", - lifespan=lifespan, - docs_url=None if cfg.app_env != "test" else "/docs", - ) - app.state.settings = cfg - - @app.middleware("http") - async def request_id(request: Request, call_next): - request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) - response = await call_next(request) - response.headers["X-Request-ID"] = request.state.request_id - return response - - @app.exception_handler(HTTPException) - async def http_error(_: Request, exc: HTTPException): - return JSONResponse(status_code=exc.status_code, content=exc.detail) - - @app.get("/health/live") - async def live(): - return {"status": "live"} - - @app.get("/health/ready") - async def ready(request: Request): - snapshot: Snapshot = request.app.state.snapshot - if not cfg.bitrix_sync_enabled: - return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503) - age = ( - (utcnow() - snapshot.last_success_at).total_seconds() - if snapshot.last_success_at - else None - ) - max_age = max( - cfg.bitrix_sync_ready_max_staleness_sec, - 2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio), - ) - is_ready = ( - snapshot.state == "healthy" - and snapshot.worker_running - and age is not None - and age <= max_age - ) - if is_ready: - return { - "status": "ready", - "mode": "db_connectivity_stub", - "database": { - "status": "ok", - "last_success_at": jsonable(snapshot.last_success_at), - "age_seconds": round(age or 0, 3), - }, - } - reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable" - return JSONResponse( - { - "status": "not_ready", - "reason": reason, - "database": { - "status": "down", - "last_success_at": jsonable(snapshot.last_success_at), - "consecutive_failures": snapshot.consecutive_failures, - }, - }, - status_code=503, - ) - - @app.get("/internal/sync/v1/status") - async def status( - request: Request, - authorization: Annotated[str | None, Header()] = None, - ): - candidate = ( - authorization[7:] if authorization and authorization.startswith("Bearer ") else "" - ) - if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token): - raise HTTPException( - 401, - { - "error": { - "code": "service_unauthorized", - "message": "Service authentication failed", - "request_id": request.state.request_id, - "details": {}, - } - }, - ) - snapshot: Snapshot = request.app.state.snapshot - last_check = None - if snapshot.last_started_at: - last_check = { - "started_at": jsonable(snapshot.last_started_at), - "finished_at": jsonable(snapshot.last_finished_at), - "success": snapshot.success, - "duration_ms": snapshot.duration_ms, - "error_code": snapshot.error_code, - } - next_in = ( - max(0, (snapshot.next_check_at - utcnow()).total_seconds()) - if snapshot.next_check_at - else None - ) - return { - "service": "bitrix-sync", - "enabled": cfg.bitrix_sync_enabled, - "mode": "db_connectivity_stub", - "crm_sync_implemented": False, - "state": snapshot.state, - "started_at": jsonable(snapshot.started_at), - "last_check": last_check, - "last_success_at": jsonable(snapshot.last_success_at), - "consecutive_failures": snapshot.consecutive_failures, - "next_check_in_seconds": round(next_in, 3) if next_in is not None else None, - } - - return app - - -async def run_probe(app: FastAPI) -> None: - cfg: Settings = app.state.settings - snapshot: Snapshot = app.state.snapshot - snapshot.last_started_at = utcnow() - started = time.monotonic() - try: - async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec): - await app.state.probe.check() - except Exception as exc: - snapshot.success = False - snapshot.error_code = classify_error(exc) - snapshot.consecutive_failures += 1 - snapshot.state = "degraded" - else: - snapshot.success = True - snapshot.error_code = None - snapshot.consecutive_failures = 0 - snapshot.last_success_at = utcnow() - snapshot.state = "healthy" - finally: - snapshot.last_finished_at = utcnow() - snapshot.duration_ms = round((time.monotonic() - started) * 1000) - - -async def periodic_loop(app: FastAPI) -> None: - cfg: Settings = app.state.settings - snapshot: Snapshot = app.state.snapshot - try: - while not app.state.stop.is_set(): - if snapshot.consecutive_failures: - cap = min( - cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1), - cfg.bitrix_sync_db_retry_max_sec, - ) - delay = random.uniform(0, cap) - else: - jitter = ( - cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio - ) - delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter) - snapshot.next_check_at = utcnow() + timedelta(seconds=delay) - try: - await asyncio.wait_for(app.state.stop.wait(), timeout=delay) - break - except TimeoutError: - await run_probe(app) - finally: - snapshot.worker_running = False - - -_settings = Settings() -app = create_app(_settings) - - -def run() -> None: - uvicorn.run("app.main:app", host="0.0.0.0", port=8080) diff --git a/VM1_app/codebase/backend/bitrix-sync/app/postgres.py b/VM1_app/codebase/backend/bitrix-sync/app/postgres.py deleted file mode 100644 index 33ce24a..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/app/postgres.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import asyncpg -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine - - -def asyncpg_dsn(url: str) -> str: - if url.startswith("postgresql+asyncpg://"): - return url.replace("postgresql+asyncpg://", "postgresql://", 1) - return url - - -def create_postgres_engine( - url: str, - *, - server_settings: dict[str, str] | None = None, - **engine_options: Any, -) -> AsyncEngine: - dsn = asyncpg_dsn(url) - - async def connect(): - return await asyncpg.connect(dsn=dsn, server_settings=server_settings) - - return create_async_engine( - "postgresql+asyncpg://", - async_creator=connect, - **engine_options, - ) diff --git a/VM1_app/codebase/backend/bitrix-sync/container-entrypoint.sh b/VM1_app/codebase/backend/bitrix-sync/container-entrypoint.sh deleted file mode 100644 index 7e6f3ff..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/container-entrypoint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -set -eu - -for name in ${HAN_SECRET_VARS:-}; do - case "$name" in - ""|[0-9]*|*[!A-Z0-9_]*) - echo "container secrets: invalid variable name" >&2 - exit 64 - ;; - *) ;; - esac - eval "file=\${${name}_FILE:-}" - if [ -z "$file" ] || [ ! -r "$file" ]; then - echo "container secrets: missing file for $name" >&2 - exit 66 - fi - value=$(cat "$file") - export "$name=$value" - unset "${name}_FILE" -done - -unset HAN_SECRET_VARS -exec "$@" diff --git a/VM1_app/codebase/backend/bitrix-sync/openapi.yaml b/VM1_app/codebase/backend/bitrix-sync/openapi.yaml deleted file mode 100644 index 6f97f58..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/openapi.yaml +++ /dev/null @@ -1,46 +0,0 @@ -openapi: 3.1.0 -info: {title: HAN Bitrix Sync Connectivity Stub, version: 1.0.0} -paths: - /health/live: - get: - responses: - "200": - description: Process is live - content: {application/json: {schema: {$ref: "#/components/schemas/Live"}}} - /health/ready: - get: - responses: - "200": {description: Latest PostgreSQL probe is fresh and successful} - "503": {description: Disabled, stale, or database unavailable} - /internal/sync/v1/status: - get: - security: [{BearerAuth: []}] - parameters: - - {name: X-Request-ID, in: header, required: false, schema: {type: string}} - responses: - "200": - description: Connectivity-loop status - content: {application/json: {schema: {$ref: "#/components/schemas/Status"}}} - "401": {description: Service authentication failed} -components: - securitySchemes: - BearerAuth: {type: http, scheme: bearer} - schemas: - Live: - type: object - required: [status] - properties: {status: {const: live}} - Status: - type: object - required: [service, enabled, mode, crm_sync_implemented, state, started_at] - properties: - service: {const: bitrix-sync} - enabled: {type: boolean} - mode: {const: db_connectivity_stub} - crm_sync_implemented: {const: false} - state: {type: string, enum: [starting, disabled, healthy, degraded, stopping]} - started_at: {type: string, format: date-time} - last_check: {type: [object, "null"]} - last_success_at: {type: [string, "null"], format: date-time} - consecutive_failures: {type: integer, minimum: 0} - next_check_in_seconds: {type: [number, "null"]} diff --git a/VM1_app/codebase/backend/bitrix-sync/pyproject.toml b/VM1_app/codebase/backend/bitrix-sync/pyproject.toml deleted file mode 100644 index a8a42a5..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[project] -name = "han-bitrix-sync" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [ - "alembic>=1.16,<2", - "asyncpg>=0.30,<1", - "fastapi>=0.116,<1", - "pydantic-settings>=2.10,<3", - "sqlalchemy[asyncio]>=2.0.41,<3", - "uvicorn[standard]>=0.35,<1", -] - -[project.optional-dependencies] -dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["app"] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] - -[tool.ruff] -target-version = "py312" -line-length = 100 diff --git a/VM1_app/codebase/backend/bitrix-sync/tests/test_service.py b/VM1_app/codebase/backend/bitrix-sync/tests/test_service.py deleted file mode 100644 index a0f6009..0000000 --- a/VM1_app/codebase/backend/bitrix-sync/tests/test_service.py +++ /dev/null @@ -1,65 +0,0 @@ -import os - -os.environ.setdefault("BITRIX_SYNC_ENABLED", "false") -os.environ.setdefault("BITRIX_SYNC_SERVICE_TOKEN", "test-sync-token-32-characters") - -import httpx -import pytest - -from app.main import Settings, create_app - - -class Probe: - def __init__(self, fail=False): - self.fail = fail - self.calls = 0 - - async def check(self): - self.calls += 1 - if self.fail: - raise OSError("down") - - async def close(self): - pass - - -@pytest.mark.asyncio -async def test_disabled_semantics(): - settings = Settings( - bitrix_sync_enabled=False, - bitrix_sync_service_token="test-sync-token-32-characters", - ) - app = create_app(settings) - async with app.router.lifespan_context(app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - assert (await client.get("/health/live")).status_code == 200 - ready = await client.get("/health/ready") - assert ready.status_code == 503 - assert ready.json()["reason"] == "sync_disabled" - status = await client.get( - "/internal/sync/v1/status", - headers={"Authorization": "Bearer test-sync-token-32-characters"}, - ) - assert status.json()["state"] == "disabled" - assert status.json()["crm_sync_implemented"] is False - - -@pytest.mark.asyncio -async def test_initial_probe_and_auth(): - probe = Probe() - settings = Settings( - bitrix_sync_enabled=True, - bitrix_sync_database_url="postgresql://unused/unused", - bitrix_sync_service_token="test-sync-token-32-characters", - bitrix_sync_db_check_interval_sec=60, - ) - app = create_app(settings, probe) - async with app.router.lifespan_context(app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - assert probe.calls == 1 - assert (await client.get("/health/ready")).status_code == 200 - assert (await client.get("/internal/sync/v1/status")).status_code == 401 diff --git a/VM1_app/codebase/backend/deployment/.gitattributes b/VM1_app/codebase/backend/deployment/.gitattributes new file mode 100644 index 0000000..6f753d1 --- /dev/null +++ b/VM1_app/codebase/backend/deployment/.gitattributes @@ -0,0 +1,5 @@ +*.sh text eol=lf +preflight.sh text eol=lf +secrets/han-compose text eol=lf +secrets/han-secrets text eol=lf +*.service text eol=lf diff --git a/VM1_app/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md b/VM1_app/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md index cd6f65a..1d58875 100644 --- a/VM1_app/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md +++ b/VM1_app/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md @@ -1,826 +1,11 @@ -# Подробная инструкция по развертыванию и запуску HAN Chat +# Legacy deployment guide — не использовать в production -Эта инструкция описывает первый запуск **текущего legacy/stub проекта ВМ1** на одной виртуальной -машине с Ubuntu 24.04. Она не разворачивает target ВМ2 Processing и не подтверждает production-готовность Message Safety v2. Все команды предполагают, что проект расположен в -`/opt/han-chat/backend`, а команды Docker Compose выполняются из этого каталога. +Этот файл сохранён только как совместимый указатель для старых ссылок. +Single-VM/stub команды удалены: они выдавали `deploy` root-equivalent доступ к +Docker, изменяли production-каталог и не обеспечивали target cutover ВМ1→ВМ2. -PostgreSQL и Selectel S3 не запускаются в Docker Compose: их необходимо создать -заранее как внешние управляемые сервисы. Из интернета должны быть доступны только -порты 80 и 443 виртуальной машины. +Для fresh production ВМ1 используйте только +[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md). -## 1. Что потребуется до начала работы - -Подготовьте: - -1. Виртуальную машину с Ubuntu 24.04 и минимум 4 vCPU, 8 ГБ RAM и 40 ГБ диска. -2. SSH-доступ к VM пользователем с правом `sudo`. -3. Домен, например `chat.example.ru`, и возможность изменить его DNS. -4. Управляемый PostgreSQL, доступный VM по приватной сети. -5. Три приватных бакета Selectel S3. -6. Учетные данные приложения Bitrix24. -7. При необходимости — удаленный OTLP-бэкенд для телеметрии. -8. Локальную копию каталога `HAN_chat_specification/codebase/backend` либо URL - Git-репозитория, из которого его можно получить. - -Для первого тестового запуска допустимы mock OTP, заглушка Message Safety и -заглушка bitrix-sync. Они не являются полноценными production-реализациями. - -Целевой cutover выполняется по `modules/module-10-deployment-runbook.md`: самостоятельная ВМ2, root Compose/systemd unit, собственный nginx с public exact CRM webhook `80/443` и private Message Safety listener `8443`, раздельные TLS-контуры, secrets/IAM, egress allow-list и local OTEL Collector. Не переносите команды этого single-VM guide на ВМ2 без VM2-specific manifests. - -## 2. Первичный вход на VM - -Подключитесь к созданной VM облачным пользователем: - -```sh -ssh @ -``` - -Проверьте версию ОС: - -```sh -cat /etc/os-release -``` - -Должна использоваться Ubuntu 24.04 или более новая версия. - -## 3. Передача и запуск скрипта настройки VM - -Сначала передайте на VM только подготовительный скрипт. Например, с локального -компьютера: - -```sh -scp deployment/scripts/setup-vm.sh @:/tmp/setup-vm.sh -``` - -На VM выполните: - -```sh -chmod +x /tmp/setup-vm.sh -sudo /tmp/setup-vm.sh -``` - -Скрипт: - -- обновит Ubuntu и установит базовые пакеты; -- создаст пользователя `deploy`; -- установит Docker Engine и Docker Compose; -- настроит UFW, fail2ban и цепочку `DOCKER-USER`; -- откроет только SSH, HTTP и HTTPS; -- создаст `/opt/han-chat/backend`; -- создаст swap; -- включит автоматические обновления безопасности; -- отключит парольный SSH-вход и X11 forwarding; -- заблокирует локальные пароли `root` и `deploy` после проверки SSH-ключей. - -Если `authorized_keys` пользователя `deploy` отсутствует, скрипт остановится до -блокировки паролей. `HARDEN_SSH=true` дополнительно запрещает прямой вход -пользователем `root` и SSH TCP forwarding; включайте этот режим только после -проверки входа пользователем `deploy` по ключу в отдельной сессии. - -Если SSH работает на нестандартном порту или имя внешнего интерфейса известно -заранее, передайте параметры: - -```sh -sudo SSH_PORT=2222 EXTERNAL_IF=ens3 /tmp/setup-vm.sh -``` - -После завершения выйдите из SSH-сессии: членство `deploy` в группе `docker` -начинает действовать только после нового входа. - -```sh -exit -ssh deploy@ -docker version -docker compose version -``` - -## 4. Копирование проекта на VM - -### Вариант A — через Git - -Это предпочтительный вариант: Git применит правило LF для shell-скриптов. - -```sh -git clone /tmp/han-chat-source -cp -a /tmp/han-chat-source/HAN_chat_specification/codebase/backend/. \ - /opt/han-chat/backend/ -cd /opt/han-chat/backend -``` - -Если `HAN_chat_specification` является корнем репозитория: - -```sh -cp -a /tmp/han-chat-source/codebase/backend/. /opt/han-chat/backend/ -``` - -### Вариант B — архивом с локального компьютера - -Создайте архив именно из содержимого каталога `backend`, включая скрытые файлы: - -```sh -tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz . -scp han-chat-backend.tar.gz deploy@:/tmp/ -``` - -На VM: - -```sh -cd /opt/han-chat/backend -tar -xzf /tmp/han-chat-backend.tar.gz - -# Обязательно при копировании с Windows: -find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} + -chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh -``` - -Проверьте наличие точки запуска: - -```sh -test -f /opt/han-chat/backend/docker-compose.yml -test -f /opt/han-chat/backend/.env.example -``` - -## 5. Настройка DNS и сетевого доступа - -Создайте DNS-запись: - -```text -chat.example.ru A <ПУБЛИЧНЫЙ_IP_VM> -``` - -Дождитесь обновления DNS: - -```sh -getent ahostsv4 chat.example.ru -``` - -В облачной группе безопасности VM разрешите входящие подключения: - -- TCP 80 из интернета; -- TCP 443 из интернета; -- SSH только из доверенной сети или с административного IP. - -Не открывайте наружу порты 6379, 4317, 4318, 8000, 8080 и 9000. - -В группе безопасности PostgreSQL разрешите входящий трафик на порт PostgreSQL -только от приватного адреса или группы безопасности VM. - -## 6. Подготовка управляемого PostgreSQL - -Создайте одну базу данных: - -```text -han_chat -``` - -В ней нужны схемы: - -```text -han_app -bitrix_local -bitrix_sync -message_safety -keycloak -``` - -Для текущей MVP-реализации используются следующие пользователи: - -```text -han_app -bitrix_local_app -bitrix_sync_user -message_safety_app -keycloak_user -``` - -Создать пользователей и схемы можно через панель провайдера либо от имени -администратора PostgreSQL. Пример SQL: - -```sql -CREATE ROLE han_app LOGIN PASSWORD ''; -CREATE ROLE bitrix_local_app LOGIN PASSWORD ''; -CREATE ROLE bitrix_sync_user LOGIN PASSWORD ''; -CREATE ROLE message_safety_app LOGIN PASSWORD ''; -CREATE ROLE keycloak_user LOGIN PASSWORD ''; - -CREATE SCHEMA IF NOT EXISTS han_app AUTHORIZATION han_app; -CREATE SCHEMA IF NOT EXISTS bitrix_local AUTHORIZATION bitrix_local_app; -CREATE SCHEMA IF NOT EXISTS bitrix_sync AUTHORIZATION bitrix_sync_user; -CREATE SCHEMA IF NOT EXISTS message_safety AUTHORIZATION message_safety_app; -CREATE SCHEMA IF NOT EXISTS keycloak AUTHORIZATION keycloak_user; - -GRANT CONNECT ON DATABASE han_chat TO - han_app, bitrix_local_app, bitrix_sync_user, message_safety_app, keycloak_user; -``` - -Текущие migration jobs используют те же DSN, что и сервисы. Поэтому владельцы -схем должны иметь право создавать таблицы в своих схемах. Для более строгого -production-разделения migration/runtime ролей потребуется отдельная настройка -DSN и прав, которой в текущем `.env.example` нет. - -Скачайте CA-сертификат PostgreSQL у провайдера и поместите его на VM: - -```sh -mkdir -p /opt/han-chat/backend/secrets/pg -cp /путь/к/ca.pem /opt/han-chat/backend/secrets/pg/ca.pem -chmod 644 /opt/han-chat/backend/secrets/pg/ca.pem -``` - -CA-сертификат не является секретом. Права `644` нужны, чтобы его могли прочитать -контейнеры, работающие не от root. - -Проверьте сетевую доступность: - -```sh -nc -vz 6432 -``` - -Замените `6432` на фактический порт провайдера. - -## 7. Подготовка Selectel S3 - -Создайте три приватных бакета: - -```text -han-chat-quarantine -han-chat-attachments -han-chat-documents -``` - -Создайте две пары ключей: - -1. Ключ API с правом чтения и записи в бакеты. -2. Отдельный ключ Message Safety только с правом чтения карантина. - -Для бакетов запретите публичный доступ. Для браузерной загрузки настройте CORS: - -- Allowed origin: `https://chat.example.ru`; -- Methods: `PUT`, `GET`, `HEAD`; -- Headers: `Content-Type`, `x-amz-*`; -- Expose header: `ETag`. - -Для карантина задайте lifecycle удаления объектов с запасом относительно -`MESSAGE_SAFETY_TASK_TTL_SEC`. - -## 8. Создание файла окружения - -`.env` содержит только несекретную конфигурацию. На VM: - -```sh -cd /opt/han-chat/backend -umask 077 -cp .env.example .env -chmod 600 .env -nano .env -``` - -Замените несекретные адреса `example.*`. Не добавляйте в `.env` пароли, токены, -ключи, credential-bearing DSN или пути `*_FILE`. Установите отдельно проверенный -launcher `deployment/secrets/han-secrets` из ops-пакета. Его интерфейс: - -```sh -deployment/secrets/han-secrets run --config .env -- -``` - -Launcher читает `SECRETS_SOURCE=file|selectel`, устанавливает -`HAN_SECRETS_ACTIVE=1`, выдаёт значения только дочернему процессу и не печатает -их. Рекомендуемый `HAN_RUNTIME_SECRET_MANIFEST` содержит только пары -`SECRET_KEY=/absolute/protected/path`; файлы имеют mode `0400`/`0600`. - -### 8.1. Основные адреса - -Для домена `chat.example.ru`: - -```dotenv -APP_ENV=production-like -RELEASE_VERSION=2026-07-13-1 - -PUBLIC_HOST=chat.example.ru -PUBLIC_WEB_URL=https://chat.example.ru -PUBLIC_API_URL=https://chat.example.ru/api -PUBLIC_AUTH_URL=https://chat.example.ru/auth - -KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth -KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth -KEYCLOAK_REALM=han-chat -KEYCLOAK_AUDIENCE=han-chat-api -``` - -### 8.2. PostgreSQL - -В `.env` укажите только host, port, database и путь к публичному CA: - -```dotenv -HAN_PG_HOST= -HAN_PG_PORT=6432 -HAN_PG_DATABASE=han_chat -PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem - -KEYCLOAK_DB_SCHEMA=keycloak -``` - -Все service DSN, включая JDBC и backup DSN, формирует secret backend. Runtime -validator проверяет `verify-full`, `sslrootcert` и запрет `options/currentSchema` -без вывода строк подключения. - -Порт `5433` используется с PgBouncer в режиме `session`. Не добавляйте -`options=-csearch_path...` или JDBC-параметр `currentSchema`: они передают -startup parameter `search_path`, который Selectel PgBouncer отклоняет. Для -Keycloak схема задаётся отдельно через `KEYCLOAK_DB_SCHEMA`. -Для каждой сервисной роли заранее задайте database-level `search_path`. - -Если пароль содержит `@`, `:`, `/`, `?`, `#` или `%`, его необходимо -URL-кодировать внутри PostgreSQL URL. - -### 8.3. Подготовка runtime-секретов - -Генерируйте секреты вне shell history средствами secret manager. Не выполняйте -`export TOKEN=...` и не вставляйте значения в команды. Имена обязательных -runtime-переменных определены в `scripts/validate-env`; парные токены связываются -в secret backend. - -```sh -openssl rand -hex 32 -``` - -Для `BITRIX_TOKEN_ENCRYPTION_KEY` нужен URL-safe Base64 ключ ровно из 32 байт: - -```sh -python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())' -``` - -Ни один из этих секретов не добавляется в `.env`. Пары проверяются runtime -validator без вывода значений. - -```dotenv -BITRIX_LOCAL_APP_INTERNAL_TOKEN= -BITRIX_INTERNAL_API_TOKEN= - -BITRIX_API_FORWARD_TOKEN= -BITRIX_API_INBOX_TOKEN= -``` - -Остальные токены должны быть разными: - -```dotenv -MESSAGE_SAFETY_SERVICE_TOKEN= -BITRIX_SYNC_SERVICE_TOKEN= -KEYCLOAK_SETTINGS_BRIDGE_TOKEN= -CURSOR_HMAC_SECRET= -KEYCLOAK_OTP_HMAC_KEY= -BITRIX_TOKEN_ENCRYPTION_KEY= -KEYCLOAK_ADMIN_PASSWORD= -``` - -### 8.4. Redis - -Создайте три разных пароля в secret backend; там же сформируйте Redis URL. -Следующий блок описывает логический контракт и не является содержимым `.env`: - -```dotenv -REDIS_API_PASSWORD= -REDIS_SAFETY_PASSWORD= -REDIS_HEALTH_PASSWORD= - -REDIS_URL=redis://api_backend:@redis:6379/0 -REDIS_REALTIME_URL=redis://api_backend:@redis:6379/1 -MESSAGE_SAFETY_REDIS_URL=redis://message_safety:@redis:6379/2 -``` - -### 8.5. Mock OTP - -В текущих deploy-артефактах реализован только mock OTP. Для запуска до controlled SMS rollout: - -```dotenv -KEYCLOAK_OTP_MOCK_ENABLED=true -KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true -``` - -`KEYCLOAK_OTP_MOCK_CODE` хранится только в secret backend. Не используйте mock -как production-механизм доставки OTP. - -Целевой real mode задаёт `modules/module-11-idgtl-sms.md`: Keycloak генерирует и локально проверяет OTP, `sms-service` надёжно записывает заказ/журнал, worker вызывает i-Digital Direct, callback обновляет только delivery journal. Нельзя просто установить `KEYCLOAK_OTP_MOCK_ENABLED=false`. - -До переключения необходимы: schema/role `sms` и migrations/seed, active approved `auth_otp` (`code`, `ttl_min`), согласованный sender, Direct `TOKEN_1`, парные service tokens, отдельные callback credentials, exact nginx callback route, подтверждённый source IP Direct и статический egress IP worker. Сначала deploy при mock=true, затем provider smoke/callback/redaction evidence и только после этого cutover. Rollback возвращает mock без удаления SMS schema/journal. - -### 8.6. S3 - -```dotenv -SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru -SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine -SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments -SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents -``` - -Обе пары S3 credentials хранятся только в secret backend. - -API backend принудительно использует virtual-hosted addressing: -`https://.s3.storage.selcloud.ru/`. Это обязательно для -браузерных presigned PUT и CORS в Selectel; path-style URL для этого сценария не -используйте. DNS и исходящий HTTPS с ВМ должны разрешать поддомены бакетов. - -### 8.7. Bitrix24 - -До установки локального приложения загрузите credentials в secret backend. -В `.env` остаются только несекретные connector/public URL параметры: - -```dotenv -BITRIX_CONNECTOR_ID=han_mobile_app -BITRIX_OPEN_LINE_ID=8 -BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix -``` - -`BITRIX_APPLICATION_TOKEN` сохраняется в secret backend после создания -приложения и никогда не помещается в `.env`. - -### 8.8. TLS и наблюдаемость - -До выпуска сертификата оставьте в `.env` целевые значения: - -```dotenv -NGINX_TLS_ENABLED=true -NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem -NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem -ACME_EMAIL= -``` - -Если удаленный OTLP-бэкенд пока не выбран, укажите временные непубличные значения -и примите ограничение: Collector будет пытаться отправлять телеметрию и сохранять -ее в ограниченной очереди. Перед production-запуском задайте реальный endpoint. - -## 9. Проверка окружения и конфигурации Compose - -Выполните: - -```sh -cd /opt/han-chat/backend -./scripts/validate-env .env -sudo systemctl restart han-secrets@production.service -sudo ./scripts/validate-env .env \ - --runtime-manifest /run/han-chat/secrets/manifest -sudo deployment/secrets/han-compose config --quiet -sudo deployment/secrets/han-compose config --services -python3 -m unittest discover -s tests -v -``` - -Не переходите к следующему шагу, пока все команды не завершатся успешно. - -Посмотрите итоговую конфигурацию портов: - -```sh -docker compose --env-file .env config | grep -n 'published:' -``` - -Публиковаться должны только 80 и 443 у nginx. - -## 10. Сборка образов - -Соберите все локальные образы: - -```sh -cd /opt/han-chat/backend -docker compose --env-file .env build --pull -``` - -Проверьте список: - -```sh -docker compose --env-file .env images -``` - -Сборка Keycloak включает Java OTP SPI, а сборка `frontend-static` экспортирует -тестовый Expo Web frontend. - -## 11. Миграции БД и начальные настройки - -Перед миграциями создайте backup/PITR marker в панели провайдера PostgreSQL. -Затем: - -```sh -cd /opt/han-chat/backend -PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh -deployment/scripts/seed.sh -``` - -Скрипт применит миграции `han_app`, `bitrix_local` и baseline `bitrix_sync`, после -чего загрузит `deployment/app-settings.production-like.yaml`. - -Повторный запуск seed должен быть безопасным: - -```sh -deployment/scripts/seed.sh -``` - -## 12. Запуск внутренних сервисов - -Сначала запустите Redis: - -```sh -docker compose --env-file .env up -d redis -docker compose --env-file .env ps redis -``` - -Затем Keycloak и OpenTelemetry: - -```sh -docker compose --env-file .env up -d keycloak otel-collector -docker compose --env-file .env ps keycloak otel-collector -``` - -Первый запуск Keycloak может занять несколько минут: он создаст свои таблицы и -импортирует realm `han-chat`. - -После готовности Keycloak: - -```sh -docker compose --env-file .env up -d message-safety -docker compose --env-file .env up -d api-backend -docker compose --env-file .env up -d bitrix-local-app bitrix-sync -docker compose --env-file .env up -d \ - delivery-worker safety-recovery-worker cleanup-worker -docker compose --env-file .env ps -``` - -Если сервис не становится healthy: - -```sh -docker compose --env-file .env logs --tail=200 -docker inspect "$(docker compose --env-file .env ps -q )" -``` - -### 12.1. Повторная раскатка upstream при уже работающем nginx - -Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После -`up --build`, `pull`, rollback или `--force-recreate` контейнер может получить -новый IP, а работающий nginx продолжит использовать старый и вернёт `502 -Connection refused`. - -После пересоздания `api-backend`, `keycloak`, `sms-service` или -`bitrix-local-app` обязательно выполните: - -```sh -docker compose --env-file .env up -d --wait \ - api-backend keycloak sms-service bitrix-local-app -docker compose --env-file .env exec -T nginx \ - nginx -t -c /tmp/nginx.conf -docker compose --env-file .env kill -s HUP nginx - -curl -fsS "https://${PUBLIC_HOST}/api/v1/public/app-config" | jq -curl -fsS \ - "https://${PUBLIC_HOST}/auth/realms/han-chat/.well-known/openid-configuration" | - jq -``` - -Не используйте bare-команды `nginx -t` и `nginx -s reload`: рабочая -конфигурация находится в `/tmp/nginx.conf`, PID — в `/tmp/nginx.pid`, а -контейнер использует read-only filesystem. - -## 13. Первоначальный выпуск TLS-сертификата - -Для ACME требуется работающий nginx по HTTP. В `.env` оставьте -`NGINX_TLS_ENABLED=true`, но первый nginx запустите с временным переопределением: - -```sh -NGINX_TLS_ENABLED=false \ - docker compose --env-file .env up -d frontend-static nginx -``` - -Проверьте HTTP: - -```sh -curl -I http://chat.example.ru/ -``` - -Сначала рекомендуется проверить Certbot через staging: - -```sh -docker compose --env-file .env --profile certbot run --rm certbot certonly \ - --staging \ - --webroot -w /var/www/certbot \ - -d chat.example.ru \ - --cert-name chat.example.ru-staging \ - --email \ - --agree-tos --no-eff-email --non-interactive -``` - -После успешного staging-теста выпустите рабочий сертификат с основным cert-name -без `--staging`: - -```sh -docker compose --env-file .env --profile certbot run --rm certbot certonly \ - --webroot -w /var/www/certbot \ - -d chat.example.ru \ - --cert-name chat.example.ru \ - --email \ - --agree-tos --no-eff-email --non-interactive -``` - -Пересоздайте nginx уже с TLS: - -```sh -docker compose --env-file .env up -d --force-recreate nginx -docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf -curl -I https://chat.example.ru/ -``` - -Повторно запустите VM setup, чтобы он обнаружил проект и установил systemd-таймер -продления сертификата: - -```sh -sudo /opt/han-chat/backend/deployment/scripts/setup-vm.sh -systemctl status han-chat-ssl-renew.timer -``` - -## 14. Запуск всего контура - -Теперь можно привести весь проект к состоянию, описанному Compose: - -```sh -cd /opt/han-chat/backend -docker compose --env-file .env up -d -docker compose --env-file .env ps -``` - -Проверьте, что контейнеры не перезапускаются: - -```sh -docker compose --env-file .env ps -docker compose --env-file .env logs --since=10m -``` - -## 15. Публичная проверка - -Запустите smoke-тест: - -```sh -cd /opt/han-chat/backend -deployment/scripts/smoke.sh -``` - -Также вручную проверьте: - -```sh -curl -fsS https://chat.example.ru/api/v1/public/app-config | jq -curl -fsS https://chat.example.ru/api/v1/public/content | jq -curl -fsS \ - https://chat.example.ru/auth/realms/han-chat/.well-known/openid-configuration | jq -``` - -Внутренний API не должен быть опубликован: - -```sh -curl -i https://chat.example.ru/internal/safety/v2/messages/check -``` - -Ожидаемый статус — `404`. - -Откройте в браузере: - -```text -https://chat.example.ru/ -``` - -Для тестовой авторизации получите mock code утверждённым защищённым способом, -не читая его из `.env` и не помещая в shell history. - -## 16. Подключение Bitrix24 - -В настройках локального приложения Bitrix24 задайте HTTPS-адреса: - -```text -Установка: https://chat.example.ru/bitrix/install -Обработчик: https://chat.example.ru/bitrix/handler -Placement: https://chat.example.ru/bitrix/placement -``` - -После установки: - -1. Получите и сохраните application token. -2. Сохраните его как `BITRIX_APPLICATION_TOKEN` в secret backend. -3. Пересоздайте сервис: - -```sh -docker compose --env-file .env up -d --force-recreate bitrix-local-app -docker compose --env-file .env logs --tail=200 bitrix-local-app -``` - -Проверьте коннектор `han_mobile_app` и Открытую линию 8. - -## 17. Включение SSH hardening - -Только после успешного входа пользователем `deploy` по ключу в отдельной сессии: - -```sh -sudo HARDEN_SSH=true \ - /opt/han-chat/backend/deployment/scripts/setup-vm.sh -``` - -Не закрывайте текущую SSH-сессию, пока не проверили новый вход. - -## 18. Обычный перезапуск проекта - -Для штатного запуска после перезагрузки VM: - -```sh -cd /opt/han-chat/backend -docker compose --env-file .env up -d -docker compose --env-file .env ps -``` - -Для перезапуска одного сервиса: - -```sh -docker compose --env-file .env restart api-backend -``` - -После изменения `.env` используйте пересоздание, а не `restart`: - -```sh -docker compose --env-file .env up -d --force-recreate -``` - -## 19. Обновление версии проекта - -Перед обновлением: - -1. Создайте backup/PITR marker PostgreSQL. -2. Сохраните текущие image digests. -3. Получите новый код. -4. Проверьте несекретный `.env` и runtime secret set. -5. Пересоберите образы. -6. Примените миграции и seed. -7. Пересоздайте сервисы. - -Команды: - -```sh -cd /opt/han-chat/backend -./scripts/validate-env .env -docker compose --env-file .env build --pull -PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh -deployment/scripts/seed.sh -docker compose --env-file .env up -d -deployment/scripts/smoke.sh -``` - -## 20. Диагностика - -Состояние сервисов: - -```sh -docker compose --env-file .env ps -``` - -Все логи: - -```sh -docker compose --env-file .env logs --tail=300 -``` - -Логи конкретного сервиса: - -```sh -docker compose --env-file .env logs -f api-backend -``` - -Проверка firewall: - -```sh -sudo ufw status verbose -sudo iptables -L HAN-CHAT-DOCKER -n -v -``` - -Проверка сертификата: - -```sh -openssl s_client -connect chat.example.ru:443 -servername chat.example.ru \ - /dev/null | openssl x509 -noout -subject -issuer -dates -``` - -Проверка свободного места: - -```sh -df -h -docker system df -``` - -Не выполняйте `docker compose down -v`: эта команда удалит именованные volumes. -Не выполняйте Alembic downgrade. Для отката используйте -`deployment/scripts/rollback.sh` и инструкции из `RUNBOOK.ru.md`. - -## 21. Когда развертывание можно считать завершенным - -Проект запущен корректно, если: - -- `docker compose ps` показывает healthy для критических сервисов; -- `deployment/scripts/smoke.sh` завершается успешно; -- открывается тестовый frontend; -- проходит авторизация через mock OTP; -- отправляются текстовые и файловые сообщения; -- внутренние URL возвращают 404 снаружи; -- TLS-сертификат действителен; -- логи не содержат токены, PII и тексты сообщений; -- настроены резервное копирование PostgreSQL и продление TLS. - -Для формальной production-like приемки после этого пройдите контрольные этапы -из `deployment/RUNBOOK.ru.md`. +Production через этот legacy guide или stubs не поддерживается. In-place +обновление legacy VM запрещено. diff --git a/VM1_app/codebase/backend/deployment/RUNBOOK.md b/VM1_app/codebase/backend/deployment/RUNBOOK.md index cb128e7..03d1024 100644 --- a/VM1_app/codebase/backend/deployment/RUNBOOK.md +++ b/VM1_app/codebase/backend/deployment/RUNBOOK.md @@ -1,330 +1,7 @@ -# HAN Chat production-like deployment runbook +# Legacy pointer -This is the executable checklist for the single-VM contour. PostgreSQL and S3 -are managed external services. Never use `docker compose down -v`, an Alembic -downgrade, or a mutable image tag during deployment. +The authoritative executable fresh-production VM1 procedure is: +[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md). -## VM2 Processing is a separate host - -Do not run this backend/VM1 setup script on VM2. VM2 has its own bootstrap: -`codebase/services/deployment/scripts/setup-vm.sh`, and its authoritative -operator checklist is `codebase/services/deployment/RUNBOOK.ru.md`. - -The VM2 ownership boundary is intentionally different from the legacy VM1 -script: `deploy` is **not** a member of the `docker` group. Root owns -`/opt/han-chat/services`, Compose, units, helpers, `.env`, allow-lists and -secret mappings. Deploy may write only to `/var/lib/han-deploy/incoming` and -may invoke exact systemd/safety-mode commands installed in sudoers. -The separate `admin` account is break-glass only: it has its own Ed25519 key -and a separate local sudo password. Root, deploy and admin keys must differ. - -Initial VM2 bootstrap commands: - -```sh -# Local operator workstation: upload only the reviewed setup script. -scp codebase/services/deployment/scripts/setup-vm.sh \ - root@:/root/setup-vm2.sh - -# VM2 root: install host packages/roles/firewalls; this does not start Compose. -chmod 0700 /root/setup-vm2.sh -DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ -ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ -OPS_CIDRS='/32' \ -VM1_PRIVATE_CIDRS='/32' \ -/root/setup-vm2.sh -``` - -Generate and upload the two public keys before this command; never copy the -root key into either account. Set the admin sudo password with `passwd admin`. -Keep the root session open and verify both key-based logins plus `sudo -v` as -admin in separate sessions. Only then rerun as VM2 root with -`HARDEN_SSH=true SKIP_APT_UPGRADE=true` to disable direct root SSH. - -Release transfer is performed as deploy, while activation and installation -remain root operations: - -```sh -# deploy: receive and inspect only. -cd /var/lib/han-deploy/incoming -sha256sum vm2-services-.tar.gz -tar -tzf vm2-services-.tar.gz - -# root: verify the operator-provided digest, activate root-owned files, -# then rerun setup-vm.sh so it installs fixed helpers and systemd units. -printf '%s %s\n' '' \ - /var/lib/han-deploy/incoming/vm2-services-.tar.gz | sha256sum --check - -ARCHIVE=/var/lib/han-deploy/incoming/vm2-services-.tar.gz -if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then exit 1; fi -if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then exit 1; fi -if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then exit 1; fi -STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)" -tar -xzf "$ARCHIVE" \ - -C "$STAGING" --no-same-owner --no-same-permissions -test -f "$STAGING/services/docker-compose.yml" -rsync -a --delete --exclude=.env --chown=root:root --chmod=D755,F644 \ - "$STAGING/services/" /opt/han-chat/services/ -rm -rf -- "$STAGING" -OPS_CIDRS='/32' \ -VM1_PRIVATE_CIDRS='/32' \ -DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ -ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ -HARDEN_SSH=true SKIP_APT_UPGRADE=true \ -/root/setup-vm2.sh -``` - -After root configures `.env`, Selectel encrypted credentials, loader mapping, -TLS and CIDR allow-lists, root synchronizes secrets, runs preflight/migrations -and performs the first start. Subsequent routine operations available to -deploy are limited to: - -```sh -sudo systemctl restart han-secrets-vm2.service -sudo systemctl restart han-processing.service -sudo systemctl --no-pager status han-processing.service -sudo journalctl --no-pager -u han-processing.service -``` - -Exact archive activation, file installation, credential creation, migration -and first-start commands are documented in the VM2 Russian runbook referenced -above. They must not be replaced with direct Docker access for deploy. - -## Gate 0 — decisions and ownership - -- [ ] Release SHA/digests, maintenance window, on-call and rollback owner recorded. -- [ ] RPO/RTO accepted; initial targets are PG RPO <=15 minutes and RTO <=4 hours. -- [ ] Remote OTLP backend selected, or debug-only acceptance limitation accepted. -- [ ] Mock OTP, Safety stub and bitrix-sync stub risks explicitly accepted. - -## Gate 1 — VPC, DNS and security groups - -- [ ] Managed PostgreSQL has only a private endpoint and accepts traffic from VM SG. -- [ ] Internet can reach only VM TCP 80/443; SSH is restricted to VPN/ops CIDR. -- [ ] Ports 6379, 4317/4318, 8000, 8080 and 9000 are denied externally. -- [ ] DNS `A` for `PUBLIC_HOST` points at the VM and outbound HTTPS is available. - -## Gate 2 — VM hardening - -On a fresh Ubuntu 24.04 VM, run: - -```sh -sudo deployment/scripts/setup-vm.sh -``` - -The script disables password SSH and X11 forwarding by default, then locks the -local `root` and `deploy` passwords after checking authorized keys. Before -setting `HARDEN_SSH=true`, which also disables root login and TCP forwarding, -verify key-based deploy access in a separate SSH session. - -- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active. -- [ ] Key-only deploy account works in a second session; root/password SSH is off. -- [ ] UFW/cloud SG and `DOCKER-USER` policy survive reboot. -- [ ] Docker Engine and Compose support `include` and long-form `env_file`. - -## Gate 3 — managed PostgreSQL - -- [ ] Daily backup, PITR, deletion protection, encryption and alerts are enabled. -- [ ] Provider CA is installed at `PG_CA_HOST_PATH`; all DSNs use `verify-full`. -- [ ] Schemas `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak` - have separate migration/runtime roles with tested negative grants. -- [ ] Migration tested against an empty DB and a clone of the previous release. - -## Gate 4 — Selectel S3 - -- [ ] Quarantine, attachments and documents buckets are private and encrypted. -- [ ] API credentials are prefix-scoped; Safety credentials are quarantine read-only. -- [ ] Browser CORS permits exact HTTPS origin and PUT headers only. -- [ ] Quarantine lifecycle exceeds Safety poll/recovery; data retention is approved. - -## Gate 5 — immutable release - -- [ ] Checkout is detached at the approved SHA and working tree is clean. -- [ ] Service images are immutable and scanned; no unresolved critical/high issue. -- [ ] Root `docker-compose.yml` is the only deployment entry point. - -## Gate 6 — environment and secrets - -```sh -cp .env.example .env -# Replace non-secret configuration placeholders only. -./scripts/validate-env .env -sudo systemctl restart han-secrets@production.service -sudo ./scripts/validate-env .env \ - --runtime-manifest /run/han-chat/secrets/manifest -sudo deployment/secrets/han-compose config --quiet -``` - -- [ ] `SECRETS_SOURCE=file|selectel`; `.env` contains no secret keys or credential-bearing DSNs. -- [ ] `deployment/secrets/han-secrets` sets `HAN_SECRETS_ACTIVE=1`, does not log values, - and optionally exposes a paths-only `HAN_RUNTIME_SECRET_MANIFEST`. -- [ ] Runtime token pairs match, PG verifies TLS, public URLs are HTTPS. -- [ ] Mock OTP risk is accepted and runtime secrets are unique >=128-bit values. -- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` is unique and supplied only through secret/env; the `producer_test` source seed stores only its hash. -- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match. - -## Gate 7 — images and static frontend - -```sh -docker compose --env-file .env pull -docker compose --env-file .env build --pull frontend-static nginx redis -docker compose --env-file .env run --rm frontend-static -``` - -- [ ] Frontend export was tested/scanned and copied by `frontend-static` into its named volume. -- [ ] Build artifacts contain no secrets or unintended source maps. -- [ ] At least 30% VM disk remains free. - -## Gate 8 — topology - -```sh -docker compose --env-file .env config --services -python3 -m unittest discover -s tests -v -``` - -- [ ] Exactly nginx publishes `80:80` and `443:443`; no PostgreSQL service exists. -- [ ] Redis AOF/RDB/ACL and OTEL persistent queue volumes are present. -- [ ] `backend` and `observability` are internal networks. - -## Gate 9 — ACME/TLS bootstrap - -Set `NGINX_TLS_ENABLED=false` only for this bootstrap command: - -```sh -NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx -docker compose --profile certbot run --rm certbot certonly \ - --webroot -w /var/www/certbot -d "$PUBLIC_HOST" \ - --cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \ - --agree-tos --no-eff-email --non-interactive -docker compose --env-file .env up -d --force-recreate nginx -docker compose exec -T nginx nginx -t -c /tmp/nginx.conf -``` - -First rehearse with Certbot `--staging`. Install a twice-daily systemd timer for -`deployment/scripts/ssl-renew.sh`; test `certbot renew --dry-run`. Enable HSTS -only after chain, hostname, redirect and TLS 1.2/1.3 checks pass. - -## Gate 10 — migrations and seed - -Create a provider PITR marker, then: - -```sh -PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh -deployment/scripts/seed.sh -``` - -- [ ] Expected Alembic revisions are active and runtime users did not perform DDL. -- [ ] Seed succeeds twice and mandatory settings contain no secret. -- [ ] Schema remains backward-compatible with the previous images. - -## Gate 11 — Keycloak - -```sh -docker compose up -d keycloak -docker compose ps keycloak -``` - -- [ ] Discovery/JWKS issuer is the exact public `/auth` HTTPS URL. -- [ ] Frontend client is public PKCE S256; implicit/password/social flows are off. -- [ ] Wrong/replayed OTP and limits fail safely; settings bridge is fail-closed. -- [ ] Bootstrap admin was removed/rotated and named admin MFA is enabled. - -## Gate 12 — ordered startup and readiness - -```sh -docker compose up -d redis -docker compose up -d keycloak otel-collector -docker compose up -d message-safety -docker compose up -d api-backend -docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \ - notification-expire-worker notification-draft-cleanup-worker -docker compose up -d bitrix-local-app bitrix-sync -docker compose up -d nginx -docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app -docker compose exec -T nginx nginx -t -c /tmp/nginx.conf -docker compose kill -s HUP nginx -docker compose ps -``` - -Nginx resolves Docker upstream names when its configuration is loaded. After -recreating `api-backend`, `keycloak`, `sms-service`, or `bitrix-local-app`, -wait for readiness, validate the active `/tmp/nginx.conf`, and signal the -master process with `HUP` as shown above. Do not use bare `nginx -t` or -`nginx -s reload`: they target the default config/PID under read-only -`/var/run`, not the running Nginx instance. - -- [ ] No restart loop/OOM; critical readiness is green. -- [ ] `notification-expire-worker` runs daily closure with an advisory lock; `notification-draft-cleanup-worker` removes expired drafts/S3 objects. Both entrypoints exist in the installed image. -- [ ] Only documented Bitrix not-installed/sync-stub degradation remains. -- [ ] External `/internal/*` is 404 and OTEL accepts telemetry. - -## Gate 13 — Bitrix24 - -- [ ] Install, handler and placement URLs use the exact public HTTPS paths. -- [ ] Connector `han_mobile_app` is active on Open Line 8; events are bound once. -- [ ] OAuth is encrypted; callback/application/service tokens never enter logs. -- [ ] Outbound and operator reply paths are idempotent; internal status is private. - -## Gate 14 — smoke and E2E - -```sh -deployment/scripts/smoke.sh -``` - -- [ ] Guest, OTP/PKCE/bootstrap/session, refresh and logout paths pass. -- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass. -- [ ] File quarantine/promote/delete, owner-only download and audit pass. -- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass. -- [ ] Closed-network `producer_test` Create/Cancel smoke passes; identical Create returns `200`, changed payload returns `409`, and the external internal route returns `404`. -- [ ] Expire advisory locking and first download of any linked document are verified; hiding is one-time and an existing `date_expired` is preserved. -- [ ] Logs contain no PII, message body, token or presigned query. - -## Gate 15 — observability - -- [ ] Known request ID links nginx, API and downstream trace; UX ID is not a label. -- [ ] Three signals reach the selected backend; SLO queries and alerts are tested. -- [ ] Remote outage fills/drains the bounded persistent queue without business outage. -- [ ] Secret/PII canary is absent. Collector restart/drop/refused metrics are checked. - -For local acceptance only, start the redacted debug collector with: -`docker compose --profile observability-local up -d otel-collector-local`. - -## Gate 16 — open traffic - -- [ ] Gates 0–15 are signed; fresh backup/PITR evidence and previous images exist. -- [ ] HSTS is enabled, release digests/schema/realm versions are recorded. -- [ ] No active page; on-call and product owner accept stub limitations. -- [ ] Observe 5xx/auth/delivery/DB/Redis/OOM/OTEL queue/Bitrix for 60 minutes. - -## Backup and restore - -Provider backup/PITR is authoritative. A supplemental verified logical dump: - -```sh -deployment/scripts/backup.sh /opt/han-chat/backups -``` - -Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests, -do not route production DNS/Bitrix callbacks, run smoke, and record measured RPO/RTO. -Redis may be restored empty; its AOF/RDB is not a business backup. - -## Rollback - -Only roll back to images compatible with the current schema: - -```sh -SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \ - deployment/scripts/rollback.sh -deployment/scripts/smoke.sh -``` - -Rollback reuses the current runtime secret set and non-secret config. Do not -create or restore an environment snapshot. - -## Real SMS rollout addendum - -This runbook remains mock-only until module-11 artifacts exist. An SMS release requires schema/role `sms`, versioned migrations and an active approved `auth_otp` seed, `sms-service`/worker, the exact callback route, paired service tokens, Direct `TOKEN_1`, approved sender/template, separate callback credentials, a reconfirmed callback source IP, and a static worker egress IP. - -Order: App DB OTP seed → SMS schema/migrations/seed → mock Direct tests → production SMS deployment while Keycloak remains in mock mode → Keycloak expand migration/SPI → controlled provider smoke plus callback/redaction evidence → real mode. Roll back by restoring mock mode without deleting the journal/schema; stop new real orders and drain or record in-flight/`uncertain` rows. Downgrade only with proven schema compatibility. - -Never run Alembic downgrade. After a backward-incompatible migration choose a -forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance. -Always verify outbox/inbox/recovery so an ambiguous message is not sent twice. +The former single-VM/stub checklist has been removed. It must not be used for +production deployment or VM1→VM2 cutover. diff --git a/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md b/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md new file mode 100644 index 0000000..9a71e90 --- /dev/null +++ b/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md @@ -0,0 +1,477 @@ +# Fresh production runbook ВМ1 HAN Chat + +Это единственный исполняемый production-runbook ВМ1. Он предназначен только +для новой Ubuntu 24.04 VM: in-place преобразование старой single-VM/stub +инсталляции запрещено. Команды выполняет оператор; repository automation их не +запускает. + +Запрещены: реальные секреты в репозитории, `.env`, argv/history или логах; +доступ `deploy` к Docker; mutable image tags; `docker compose down -v`; +Alembic downgrade; автоматический fallback Selectel → локальный файл. + +## 0. Участники, переменные и stop conditions + +- локальная машина: создаёт проверенный архив и SHA-256; +- `root`: bootstrap, активация release, config/secrets/TLS, миграции и первый + запуск; +- `deploy`: пишет только в `/var/lib/han-deploy/incoming`, затем использует + exact systemd/status/log commands; +- `admin`: персональная break-glass роль с отдельным ключом и sudo-паролем. + +До окна работ зафиксируйте: ``, image digests, ``, +DNS/IP ВМ1, private DNS/SAN ВМ2, ops CIDR, PG/S3 inventory, PITR marker, +RPO/RTO, on-call, approver и предыдущий совместимый release. + +Stop condition: любой placeholder, незакрытый preflight, несовпавший digest, +невалидный TLS, broad SG/sudo, неизвестная Alembic revision, stub/local Safety, +неуспешный negative probe или отсутствие rollback evidence. + +## 1. Bootstrap новой ВМ1 + +На локальной машине создайте две разные Ed25519 key pairs. Private keys не +передаются на VM и не должны совпадать с bootstrap root key: + +```powershell +ssh-keygen -t ed25519 -a 100 -f C:\Users\\.ssh\han_vm1_deploy ` + -C "han-vm1-deploy" +ssh-keygen -t ed25519 -a 100 -f C:\Users\\.ssh\han_vm1_admin ` + -C "han-vm1-break-glass-admin" +``` + +Передайте setup и только public keys во временный root-каталог. В уже открытой +bootstrap root-сессии: + +```sh +install -d -m 0700 -o root -g root /root/bootstrap +install -m 0600 -o root -g root /tmp/han_vm1_deploy.pub /root/bootstrap/deploy.pub +install -m 0600 -o root -g root /tmp/han_vm1_admin.pub /root/bootstrap/admin.pub +install -m 0700 -o root -g root /tmp/setup-vm.sh /root/setup-vm1.sh +DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ +ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ +/root/setup-vm1.sh +passwd admin +``` + +Setup устанавливает host packages, Docker/Compose, UFW, fail2ban, security +updates, `DOCKER-USER`, swap и роли. Он не запускает Compose. Cloud SG должен +разрешать `80/443` из интернета. На bootstrap-этапе UFW временно разрешает SSH +из любой сети; после настройки WireGuard закройте public SSH и разрешите его +только через WireGuard. PG принимает TLS только от SG/private IP ВМ1. `6379`, +`4317/4318`, `8000`, `8080`, `9000` наружу запрещены. + +Не закрывая root-сессию, проверьте в двух новых сессиях: + +```powershell +ssh -i C:\Users\\.ssh\han_vm1_deploy deploy@ +ssh -i C:\Users\\.ssh\han_vm1_admin admin@ +``` + +В admin-сессии: + +```sh +sudo -v +sudo -i +id +exit +``` + +Только после успеха обоих SSH-входов и admin sudo повторите в исходной +root-сессии: + +```sh +DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ +ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ +HARDEN_SSH=true SKIP_APT_UPGRADE=true \ +/root/setup-vm1.sh +``` + +Откройте ещё по одной новой deploy/admin сессии после reload. Подтвердите +`PermitRootLogin no`, `AllowUsers deploy admin`, key-only auth и отсутствие +forwarding. Только затем закрывайте bootstrap root session. Root-only public +key files сохраните до установки helpers из первого release; private keys на +VM отсутствуют. Доступ cloud console/recovery остаётся break-glass. + +## 2. Сборка и передача immutable release + +На локальной машине checkout должен быть exact detached ``, tree — +clean. Архив содержит один корень `backend`, не содержит `.env`, credentials, +caches и private keys: + +```powershell +$Release = "" +tar --exclude=backend/.env ` + --exclude='backend/**/__pycache__' ` + --exclude='backend/**/.pytest_cache' ` + --exclude='backend/**/.ruff_cache' ` + -czf "vm1-backend-$Release.tar.gz" ` + -C .\VM1_app\codebase backend +Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256 +scp "vm1-backend-$Release.tar.gz" ` + deploy@:/var/lib/han-deploy/incoming/ +``` + +Под `deploy` разрешены только inventory/checksum: + +```sh +RELEASE='' +cd /var/lib/han-deploy/incoming +sha256sum "vm1-backend-${RELEASE}.tar.gz" +tar -tzf "vm1-backend-${RELEASE}.tar.gz" +``` + +## 3. Root-активация: SHA и anti-traversal + +Под `admin`, затем `sudo -i`. Не распаковывайте недоверенный архив до всех +проверок: + +```sh +RELEASE='' +EXPECTED_SHA256='' +ARCHIVE="/var/lib/han-deploy/incoming/vm1-backend-${RELEASE}.tar.gz" +printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check - +tar -tvzf "$ARCHIVE" +if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^backend/\.env$)'; then + echo 'unsafe path or .env' >&2; exit 1 +fi +if tar -tzf "$ARCHIVE" | grep -Ev '^backend(/|$)' | grep -q .; then + echo 'archive has files outside backend' >&2; exit 1 +fi +if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then + echo 'symlink/hardlink is forbidden' >&2; exit 1 +fi + +TARGET="/opt/han-chat/releases/${RELEASE}" +test ! -e "$TARGET" +install -d -m 0755 -o root -g root "$TARGET" +tar --extract --gzip --file "$ARCHIVE" --directory "$TARGET" \ + --no-same-owner --no-same-permissions +test -f "$TARGET/backend/docker-compose.yml" +chmod 0755 \ + "$TARGET/backend/deployment/scripts/setup-vm.sh" \ + "$TARGET/backend/deployment/preflight.sh" \ + "$TARGET/backend/deployment/scripts/tls-deploy-hook.sh" \ + "$TARGET/backend/deployment/secrets/han-compose" \ + "$TARGET/backend/deployment/secrets/han-secrets" +test -x "$TARGET/backend/deployment/scripts/setup-vm.sh" +test -x "$TARGET/backend/deployment/preflight.sh" +test -x "$TARGET/backend/deployment/scripts/tls-deploy-hook.sh" +test -x "$TARGET/backend/deployment/secrets/han-compose" +test -x "$TARGET/backend/deployment/secrets/han-secrets" +if find "$TARGET/backend" -type l -print -quit | grep -q .; then exit 1; fi +chown -R root:root "$TARGET" +chmod -R go-w "$TARGET" +ln -s "releases/${RELEASE}" /opt/han-chat/.current-new +mv -Tf /opt/han-chat/.current-new /opt/han-chat/current +``` + +Executable modes поставляет release manifest/git. Нельзя применять blanket +`--chmod=F644` или рекурсивный `chmod`, снимающий executable bit. Сохраните +SHA-256 архива и release SHA в change record. + +Повторите setup из активного release с теми же public key files. Он +установит root-owned launcher, secret unit, stack unit, TLS hook и sudoers, но +не запустит приложение: + +```sh +DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ +ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ +HARDEN_SSH=true SKIP_APT_UPGRADE=true \ +/opt/han-chat/current/backend/deployment/scripts/setup-vm.sh +visudo -cf /etc/sudoers.d/deploy +sudo -l -U deploy +rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub +``` + +Убедитесь, что в выводе нет wildcard, shell/editor/cp/chmod/docker и что +`deploy` не входит в `docker`, `sudo`, `lxd`, `adm`, `systemd-journal`. +Для повторного setup после будущего release заново передайте только проверенные +public keys в root-only временный каталог и удалите их после выполнения. + +## 4. Несекретный config и secrets + +Под root создайте `/etc/han/vm1.env` из reviewed production template. Config +живёт вне immutable release и не меняется при rollback. В нём только +несекретные значения и paths; `APP_ENV=production`, +`SECRETS_SOURCE=selectel`, все images pinned `@sha256:...`, +`MESSAGE_SAFETY_URL=https://:8443`, +`MESSAGE_SAFETY_EXTRA_HOST==`, +`MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem`, +`NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem`, +`NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem`. + +```sh +cd /opt/han-chat/current/backend +install -m 0600 -o root -g root .env.example /etc/han/vm1.env +editor /etc/han/vm1.env +./scripts/validate-env /etc/han/vm1.env +install -m 0600 -o root -g root deployment/secrets/config.example.json \ + /etc/han/secrets/production.selectel.json +editor /etc/han/secrets/production.selectel.json +``` + +Mapping должен содержать только VM1 secrets. Убедитесь, что в нём отсутствуют +legacy local `message-safety`/`bitrix-sync` consumers, Redis DB2 и credentials +сервисов ВМ2; отдельный IAM principal ВМ1 получает read-only только к +перечисленным remote names. Пароли/DSN/token/S3 keys не помещаются в +`/etc/han/vm1.env`. + +Создайте encrypted systemd credential без значения в argv/history: + +```sh +read -rsp 'Selectel VM1 service-user password: ' SELECTEL_PASSWORD; echo +printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \ + --name=selectel-service-user-password - \ + /etc/han/credentials/production.selectel-password.cred +unset SELECTEL_PASSWORD +chown root:root /etc/han/credentials/production.selectel-password.cred +chmod 0600 /etc/han/credentials/production.selectel-password.cred +``` + +Подтвердите token pairs без печати значений средствами validator. Не делайте +`cat` runtime secret files. Emergency file mode — отдельная root-only +процедура без automatic fallback. + +## 5. Managed PostgreSQL и S3 + +Установите provider CA вне release: + +```sh +install -m 0644 -o root -g root /tmp/ \ + /etc/han/ca/managed-postgresql-ca.pem +openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \ + -noout -subject -issuer -dates +rm -f /tmp/ +``` + +Runtime и migration roles разделены для `han_app`, `bitrix_local`, `sms`, +`keycloak`; runtime не имеет DDL/ownership. DSN проверяет hostname и chain. +Включены encryption, deletion protection, backup/PITR и alerts. Перед +миграциями создаётся provider PITR marker. + +S3: private encrypted buckets documents/attachments/quarantine; exact browser +origin CORS; prefix-scoped VM1 key; versioning/lifecycle; public ACL off. +Read-only quarantine key Safety принадлежит IAM ВМ2 и не копируется на ВМ1. +Проверьте negative access к чужому prefix/bucket. + +## 6. Public TLS и internal VM2 CA + +Установите внутренний CA, которым ВМ1 проверяет SAN ВМ2: + +```sh +install -m 0644 -o root -g root /tmp/ \ + /etc/han/ca/vm2-internal-ca.pem +openssl x509 -in /etc/han/ca/vm2-internal-ca.pem \ + -noout -subject -issuer -dates +rm -f /tmp/ +openssl s_client -connect :8443 \ + -servername -verify_hostname \ + -CAfile /etc/han/ca/vm2-internal-ca.pem -verify_return_error /dev/null +unset NGINX_ID +systemctl enable han-secrets@production.service han-stack@production.service +systemctl start han-stack@production.service +``` + +`han-stack@production` становится единственным routine lifecycle interface. +После изменения secret source сначала explicit restart secret unit, затем +stack unit. Обновление active/exited oneshot всегда требует `restart`. + +## 10. Smoke, firewall и cutover + +С внешней машины: + +```sh +curl -sS -o /dev/null -w '%{http_code}\n' http:/// +curl -fsS https:///api/v1/public/app-config +curl -fsS https:///auth/realms/han-chat/.well-known/openid-configuration +curl -sS -o /dev/null -w '%{http_code}\n' \ + https:///internal/safety/v2/messages/check +openssl s_client -connect :443 -servername \ + -verify_hostname -verify_return_error ' \ + --authenticator webroot --webroot-path /var/lib/han-chat/acme +certbot renew --dry-run --run-deploy-hooks +systemctl enable --now certbot.timer +systemctl list-timers certbot.timer +``` + +Hook должен завершаться `0` с пустым stderr на success, атомарно обновлять +staging, выполнять config test и HUP. + +Проверьте logs/metrics/traces, request ID через nginx/API/VM2, alerts для +5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary +token/PII markers и докажите их отсутствие в logs/traces. + +Перед reboot проверьте admin SSH и provider console: + +```sh +systemctl is-enabled docker.service han-chat-docker-firewall.service \ + han-secrets@production.service han-stack@production.service certbot.timer +systemctl reboot +``` + +После reconnect повторите status, stack `ps`, public/private TLS, smoke, +negative ports и firewall counters. Без reboot gate deployment не завершён. + +## 12. Rollback и disaster recovery + +Application rollback допускается только на предыдущий root-owned release, +совместимый с текущей schema. Секреты и `/etc/han/vm1.env` остаются текущими: + +```sh +PREVIOUS='' +test -d "/opt/han-chat/releases/${PREVIOUS}/backend" +ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new +mv -Tf /opt/han-chat/.current-new /opt/han-chat/current +systemctl daemon-reload +systemctl restart han-secrets@production.service +/opt/han-chat/current/backend/deployment/preflight.sh +systemctl restart han-stack@production.service +``` + +Повторите smoke и зафиксируйте digests. Не удаляйте current/previous release, +active images, evidence или volumes. После incompatible migration используйте +forward fix либо согласованный PG PITR + S3/Bitrix reconciliation в maintenance +window; Redis восстанавливается пустым и прогревается из PG. + +Потеря ВМ1: создайте новую VM этим runbook, восстановите DNS/SG, root-owned +approved release, VM1 IAM secrets, managed PG/S3 и public TLS; ВМ2 не +пересоздавайте. До cutover держите traffic закрытым. Ежеквартально делайте +isolated restore rehearsal, не направляя production DNS/Bitrix callbacks, и +фиксируйте фактические RPO/RTO. + +При компрометации host не «очищайте» VM: изолируйте, сохраните evidence, +ротируйте доступные secrets/tokens и reprovision из trusted image. + +## 13. Production acceptance record + +Сохраните без secret values: release/archive SHA-256, image digests, schema +heads, realm/config version, PG PITR marker, TLS fingerprints/expiry, systemd +status, positive/negative firewall probes, public/private smokes, VM2 cutover +approval, observability canary, reboot result, rollback/restore rehearsal и +подписи Operations/Security/Product. diff --git a/VM1_app/codebase/backend/deployment/RUNBOOK.ru.md b/VM1_app/codebase/backend/deployment/RUNBOOK.ru.md index b57d734..c4f567f 100644 --- a/VM1_app/codebase/backend/deployment/RUNBOOK.ru.md +++ b/VM1_app/codebase/backend/deployment/RUNBOOK.ru.md @@ -1,271 +1,9 @@ -# Инструкция по развертыванию HAN Chat в production-like окружении +# Legacy runbook — указатель -Это исполняемый чек-лист для контура на одной виртуальной машине. PostgreSQL и S3 -используются как внешние управляемые сервисы. Во время развертывания запрещено -использовать `docker compose down -v`, откат миграций Alembic и изменяемые теги образов. +Исполняемый fresh production runbook ВМ1: +[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md). -Подробная пошаговая инструкция для первого запуска находится в -`deployment/DEPLOYMENT_GUIDE.ru.md`. - -## Этап 0 — решения и зоны ответственности - -- [ ] Зафиксированы SHA/дайджесты релиза, окно обслуживания, дежурный и ответственный за откат. -- [ ] Согласованы RPO/RTO; начальные цели: RPO PostgreSQL не более 15 минут и RTO не более 4 часов. -- [ ] Выбран удаленный OTLP-бэкенд либо принято ограничение на использование только отладочного контура. -- [ ] Явно приняты риски mock OTP, заглушки Safety и заглушки bitrix-sync. - -## Этап 1 — VPC, DNS и группы безопасности - -- [ ] Управляемый PostgreSQL имеет только приватную точку доступа и принимает трафик от группы безопасности VM. -- [ ] Из интернета доступны только TCP-порты VM 80/443; SSH ограничен VPN или CIDR администраторов. -- [ ] Порты 6379, 4317/4318, 8000, 8080 и 9000 закрыты для внешнего доступа. -- [ ] DNS-запись `A` для `PUBLIC_HOST` указывает на VM; исходящий HTTPS доступен. - -## Этап 2 — защита виртуальной машины - -На новой Ubuntu 24.04 можно выполнить подготовительный скрипт: - -```sh -sudo deployment/scripts/setup-vm.sh -``` - -Скрипт по умолчанию отключает парольный SSH-вход и X11 forwarding, а после -проверки ключей блокирует локальные пароли `root` и `deploy`. Перед включением -`HARDEN_SSH=true`, которое дополнительно запрещает root-вход и TCP forwarding, -обязательно проверьте вход пользователем `deploy` по ключу в отдельной сессии. - -- [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска. -- [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены. -- [ ] Правила UFW/облачной группы безопасности и политика `DOCKER-USER` сохраняются после перезагрузки. -- [ ] Docker Engine и Compose поддерживают `include` и полную форму `env_file`. - -## Этап 3 — управляемый PostgreSQL - -- [ ] Включены ежедневные резервные копии, PITR, защита от удаления, шифрование и оповещения. -- [ ] CA-сертификат провайдера установлен по пути `PG_CA_HOST_PATH`; все DSN используют `verify-full`. -- [ ] Для схем `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak` - созданы отдельные роли миграций и выполнения; запрет лишних прав проверен тестами. -- [ ] Миграции проверены на пустой БД и на клоне БД предыдущего релиза. - -## Этап 4 — Selectel S3 - -- [ ] Бакеты карантина, вложений и документов закрыты от публичного доступа и зашифрованы. -- [ ] Права API ограничены префиксами; учетные данные Safety имеют доступ к карантину только на чтение. -- [ ] CORS бакетов разрешает только точный HTTPS-origin браузера и необходимые заголовки PUT. -- [ ] Срок хранения карантина превышает время Safety polling/recovery; политика хранения данных согласована. - -## Этап 5 — неизменяемый релиз - -- [ ] Репозиторий переключен на утвержденный SHA в detached-режиме; рабочее дерево чистое. -- [ ] Образы сервисов неизменяемы и просканированы; нерешенных критических и высоких уязвимостей нет. -- [ ] Корневой `docker-compose.yml` является единственной точкой запуска. - -## Этап 6 — окружение и секреты - -```sh -cp .env.example .env -# Замените только несекретные placeholders. -./scripts/validate-env .env -sudo systemctl restart han-secrets@production.service -sudo ./scripts/validate-env .env \ - --runtime-manifest /run/han-chat/secrets/manifest -sudo deployment/secrets/han-compose config --quiet -``` - -- [ ] `SECRETS_SOURCE=file|selectel`; `.env` не содержит secret keys и DSN с credentials. -- [ ] `deployment/secrets/han-secrets` устанавливает `HAN_SECRETS_ACTIVE=1`, - не пишет значения в лог и при возможности передаёт paths-only manifest - через `HAN_RUNTIME_SECRET_MANIFEST`. -- [ ] Runtime-пары токенов совпадают, PostgreSQL проверяет TLS. -- [ ] Риск mock OTP принят; runtime-секреты уникальны и содержат не менее 128 бит энтропии. -- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` сгенерирован отдельно, передан только через secret/env; seed `notification_sources.code='producer_test'` содержит только его hash. -- [ ] Установлено `FRONTEND_DEV_PROXY_ENABLED=false`; таймауты Safety и nginx согласованы. - -## Этап 7 — образы и статический frontend - -```sh -docker compose --env-file .env pull -docker compose --env-file .env build --pull frontend-static nginx redis -docker compose --env-file .env run --rm frontend-static -``` - -- [ ] Экспорт frontend проверен и просканирован, затем скопирован сервисом `frontend-static` в именованный volume. -- [ ] Артефакты сборки не содержат секретов и непредусмотренных source map. -- [ ] На диске VM остается не менее 30% свободного места. - -## Этап 8 — топология - -```sh -docker compose --env-file .env config --services -python3 -m unittest discover -s tests -v -``` - -- [ ] Только nginx публикует `80:80` и `443:443`; сервиса PostgreSQL в Compose нет. -- [ ] Присутствуют volumes Redis AOF/RDB/ACL и постоянной очереди OTEL. -- [ ] Сети `backend` и `observability` являются внутренними. - -## Этап 9 — первоначальная настройка ACME/TLS - -Установите `NGINX_TLS_ENABLED=false` только для команды первоначального запуска: - -```sh -NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx -docker compose --profile certbot run --rm certbot certonly \ - --webroot -w /var/www/certbot -d "$PUBLIC_HOST" \ - --cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \ - --agree-tos --no-eff-email --non-interactive -docker compose --env-file .env up -d --force-recreate nginx -docker compose exec -T nginx nginx -t -c /tmp/nginx.conf -``` - -Сначала выполните проверку с параметром Certbot `--staging`. Установите systemd-таймер, -запускающий `deployment/scripts/ssl-renew.sh` дважды в сутки, и проверьте -`certbot renew --dry-run`. Включайте HSTS только после проверки цепочки сертификатов, -имени хоста, перенаправления и поддержки TLS 1.2/1.3. - -## Этап 10 — миграции и начальные данные - -Создайте у провайдера точку восстановления PITR, затем выполните: - -```sh -PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh -deployment/scripts/seed.sh -``` - -- [ ] Активны ожидаемые ревизии Alembic; runtime-пользователи не выполняли DDL. -- [ ] Повторный seed завершается успешно; обязательные настройки не содержат секретов. -- [ ] Схема остается обратно совместимой с образами предыдущего релиза. - -## Этап 11 — Keycloak - -```sh -docker compose up -d keycloak -docker compose ps keycloak -``` - -- [ ] Issuer discovery/JWKS точно совпадает с публичным HTTPS URL `/auth`. -- [ ] Frontend-клиент является публичным PKCE S256; implicit, password и social flows отключены. -- [ ] Неверный или повторно использованный OTP и превышение лимитов безопасно отклоняются; settings bridge работает fail-closed. -- [ ] При `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` initial send и resend требуют свежий SmartCaptcha token; техническая недоступность Yandex подтверждена как fail-open в логах. -- [ ] CSP login-страницы содержит `smartcaptcha.cloud.yandex.ru`/`yastatic.net`, а `/auth/realms/master/protocol/openid-connect/3p-cookies/step2.html` и Admin Console работают без CAPTCHA CSP. -- [ ] Временный администратор удален либо его пароль изменен; для именного администратора включена MFA. - -Если предыдущая попытка сохранила custom CSP в realm, сбросьте только это поле через `kcadm`; `.env` как shell-файл не загружать: - -```sh -docker compose exec -T keycloak sh -lc ' -set -eu -cfg=/tmp/han-kcadm.config -/opt/keycloak/bin/kcadm.sh config credentials --config "$cfg" \ - --server http://127.0.0.1:8080/auth --realm master \ - --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ - --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" -/opt/keycloak/bin/kcadm.sh update realms/han-chat --config "$cfg" \ - -s "browserSecurityHeaders.contentSecurityPolicy=" -rm -f "$cfg" -' -``` - -## Этап 12 — последовательный запуск и готовность - -```sh -docker compose up -d redis -docker compose up -d keycloak otel-collector -docker compose up -d message-safety -docker compose up -d api-backend -docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \ - notification-expire-worker notification-draft-cleanup-worker -docker compose up -d bitrix-local-app bitrix-sync -docker compose up -d nginx -docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app -docker compose exec -T nginx nginx -t -c /tmp/nginx.conf -docker compose kill -s HUP nginx -docker compose ps -``` - -Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После -любого пересоздания `api-backend`, `keycloak`, `sms-service` или -`bitrix-local-app` дождитесь их readiness, проверьте именно рабочий -`/tmp/nginx.conf` и отправьте master-процессу `HUP`, как показано выше. -Обычные `nginx -t` и `nginx -s reload` использовать нельзя: они обращаются к -дефолтному config/PID в read-only `/var/run` и не перезагружают рабочий Nginx. - -- [ ] Нет циклических перезапусков и OOM; критические readiness-проверки успешны. -- [ ] `notification-expire-worker` выполняет ежедневное закрытие с advisory lock; `notification-draft-cleanup-worker` очищает просроченные drafts/S3. Оба entrypoint присутствуют в установленном образе. -- [ ] Сохраняется только документированная деградация: Bitrix не установлен и bitrix-sync работает как заглушка. -- [ ] Внешний запрос `/internal/*` возвращает 404; OTEL принимает телеметрию. - -## Этап 13 — Bitrix24 - -- [ ] URL установки, обработчика и placement используют точные публичные HTTPS-пути. -- [ ] Коннектор `han_mobile_app` активен в Открытой линии 8; события привязаны однократно. -- [ ] OAuth зашифрован; callback-, application- и service-токены не попадают в логи. -- [ ] Исходящие сообщения и ответы оператора идемпотентны; внутренний статус не опубликован наружу. - -## Этап 14 — smoke- и E2E-тесты - -```sh -deployment/scripts/smoke.sh -``` - -- [ ] Успешны сценарии гостя, OTP/PKCE/bootstrap/session, обновления токена и выхода. -- [ ] Проверены Safety allow/deny/pending/timeout и один параллельный медленный poll. -- [ ] Проверены карантин, перенос и удаление файлов, скачивание только владельцем и аудит. -- [ ] Проверены переподключение WS с REST-сверкой, 404 при обращении к чужому ресурсу, идемпотентность и 429. -- [ ] От имени `producer_test` выполнены Create и Cancel через закрытый `/internal/notifications/v1/*`; тот же Create вернул `200`, изменённый payload — `409`, внешний запрос — `404`. -- [ ] Проверены expire job с advisory lock и первое скачивание любого связанного документа: уведомление скрывается один раз, а исходный `date_expired` не перезаписывается. -- [ ] Логи не содержат PII, текстов сообщений, токенов и query-параметров presigned URL. - -## Этап 15 — наблюдаемость - -- [ ] Известный request ID связывает трассировку nginx, API и downstream-сервисов; UX ID не используется как label. -- [ ] Все три сигнала поступают в выбранный бэкенд; SLO-запросы и оповещения проверены. -- [ ] При недоступности удаленного сервиса ограниченная постоянная очередь заполняется и опустошается без остановки бизнес-функций. -- [ ] Тестовые секреты и PII отсутствуют; проверены метрики перезапуска, потерь, отказов и очереди Collector. - -Только для локальной приемки запустите отладочный Collector с удалением чувствительных данных: -`docker compose --profile observability-local up -d otel-collector-local`. - -## Этап 16 — открытие трафика - -- [ ] Этапы 0–15 подписаны; имеются свежие подтверждения backup/PITR и предыдущие образы. -- [ ] HSTS включен; дайджесты релиза, версии схем и realm зафиксированы. -- [ ] Активных инцидентов нет; дежурный и владелец продукта приняли ограничения заглушек. -- [ ] В течение 60 минут контролируются 5xx, auth, доставка, БД, Redis, OOM, очередь OTEL и Bitrix. - -## Резервное копирование и восстановление - -Основным механизмом являются backup/PITR провайдера. Дополнительный проверенный логический дамп: - -```sh -deployment/scripts/backup.sh /opt/han-chat/backups -``` - -Ежеквартально восстанавливайте PostgreSQL и S3 в изолированной VPC, развертывайте те же -дайджесты образов, не направляйте туда production DNS и callbacks Bitrix, выполняйте -smoke-тесты и фиксируйте фактические RPO/RTO. Redis можно восстановить пустым: -его AOF/RDB не является резервной копией бизнес-данных. - -## Откат - -Откатывайтесь только на образы, совместимые с текущей схемой: - -```sh -SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \ - deployment/scripts/rollback.sh -deployment/scripts/smoke.sh -``` - -Откат использует текущие runtime-секреты и текущий несекретный config. Snapshot -старого `.env` не создаётся и не восстанавливается. - -## Дополнение: rollout реальной SMS-авторизации - -Текущий runbook остаётся mock-only, пока артефакты module-11 не реализованы. Для SMS release обязательны: schema/role `sms`, migrations/seed active approved `auth_otp`, `sms-service`/worker, exact callback route, парные service tokens, Direct `TOKEN_1`, согласованные sender/template, отдельные callback credentials, подтверждённый callback source IP и статический egress IP worker. - -Порядок: App DB OTP seed → SMS schema/migrations/seed → test с mock Direct → production SMS deploy при `KEYCLOAK_OTP_MOCK_ENABLED=true` → Keycloak expand migration/SPI → provider smoke и callback/redaction evidence → real mode. Rollback: вернуть mock, не удалять journal/schema, остановить новые real orders и зафиксировать in-flight/`uncertain`; downgrade только при доказанной совместимости. - -Никогда не выполняйте downgrade Alembic. После обратно несовместимой миграции используйте -исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время -технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение -с неопределенным статусом не было отправлено повторно. +Старый single-VM/stub порядок намеренно удалён. Он не является основанием для +production acceptance, rollback или cutover Message Safety на ВМ2. Не +восстанавливайте из истории команды прямого Docker-доступа пользователя +`deploy`. diff --git a/VM1_app/codebase/backend/deployment/docker-compose.jobs.yml b/VM1_app/codebase/backend/deployment/docker-compose.jobs.yml index 5ddd9ea..f72db6b 100644 --- a/VM1_app/codebase/backend/deployment/docker-compose.jobs.yml +++ b/VM1_app/codebase/backend/deployment/docker-compose.jobs.yml @@ -32,8 +32,8 @@ x-api-job-environment: &api-job-environment KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL:-http://keycloak:8080/auth} KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat} KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-han-chat-api} - MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL} - MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2 + MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-https://processing.internal:8443} + MESSAGE_SAFETY_API_PREFIX: ${MESSAGE_SAFETY_API_PREFIX:-/internal/safety/v2} MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5} MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2} @@ -46,9 +46,22 @@ x-api-job-environment: &api-job-environment SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE} TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32} +x-python-job-runtime: &python-job-runtime + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] + pids_limit: 128 + mem_limit: 512m + cpus: 0.5 + ulimits: + core: {soft: 0, hard: 0} + services: migrate-api: - image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local} + <<: *python-job-runtime + image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest} profiles: ["ops"] environment: HAN_SECRET_VARS: DATABASE_URL @@ -60,12 +73,10 @@ services: - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro networks: [backend, egress] restart: "no" - security_opt: ["no-new-privileges:true"] - ulimits: - core: {soft: 0, hard: 0} migrate-bitrix-local: - image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local} + <<: *python-job-runtime + image: ${BITRIX_LOCAL_APP_IMAGE:?BITRIX_LOCAL_APP_IMAGE must be pinned by digest} profiles: ["ops"] environment: HAN_SECRET_VARS: BITRIX_DATABASE_URL @@ -77,29 +88,10 @@ services: - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro networks: [backend, egress] restart: "no" - security_opt: ["no-new-privileges:true"] - ulimits: - core: {soft: 0, hard: 0} - - migrate-bitrix-sync: - image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local} - profiles: ["ops"] - environment: - HAN_SECRET_VARS: BITRIX_SYNC_DATABASE_URL - BITRIX_SYNC_DATABASE_URL_FILE: /run/secrets/bitrix_sync_database_url - secrets: - - bitrix_sync_database_url - command: ["alembic", "upgrade", "head"] - volumes: - - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro - networks: [backend, egress] - restart: "no" - security_opt: ["no-new-privileges:true"] - ulimits: - core: {soft: 0, hard: 0} migrate-sms: - image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local} + <<: *python-job-runtime + image: ${SMS_SERVICE_IMAGE:?SMS_SERVICE_IMAGE must be pinned by digest} profiles: ["ops"] environment: HAN_SECRET_VARS: SMS_DATABASE_URL @@ -111,12 +103,10 @@ services: - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro networks: [backend, egress] restart: "no" - security_opt: ["no-new-privileges:true"] - ulimits: - core: {soft: 0, hard: 0} seed-settings: - image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local} + <<: *python-job-runtime + image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest} profiles: ["ops"] environment: *api-job-environment secrets: *api-job-secrets @@ -133,15 +123,17 @@ services: - ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro networks: [backend, egress] restart: "no" - security_opt: ["no-new-privileges:true"] - ulimits: - core: {soft: 0, hard: 0} toolbox: - image: curlimages/curl:8.11.1 + image: ${TOOLBOX_IMAGE:?TOOLBOX_IMAGE must be pinned by digest} profiles: ["ops"] entrypoint: ["sleep", "infinity"] networks: [backend, observability, egress] restart: "no" + read_only: true + tmpfs: ["/tmp:size=16m,mode=1777"] cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] + pids_limit: 32 + mem_limit: 64m + cpus: 0.25 diff --git a/VM1_app/codebase/backend/deployment/han-stack@.service b/VM1_app/codebase/backend/deployment/han-stack@.service new file mode 100644 index 0000000..b29e8c5 --- /dev/null +++ b/VM1_app/codebase/backend/deployment/han-stack@.service @@ -0,0 +1,33 @@ +[Unit] +Description=HAN Chat VM1 root Compose stack (%i) +Requires=docker.service han-secrets@%i.service +After=docker.service han-secrets@%i.service network-online.target +Wants=network-online.target +ConditionPathIsDirectory=/opt/han-chat/current/backend + +[Service] +Type=oneshot +RemainAfterExit=yes +User=root +Group=root +WorkingDirectory=/opt/han-chat/current/backend +ExecStart=/usr/local/sbin/han-vm1-compose up -d --remove-orphans +ExecReload=/usr/local/sbin/han-vm1-compose up -d --remove-orphans +ExecStop=/usr/local/sbin/han-vm1-compose stop +TimeoutStartSec=600 +TimeoutStopSec=180 +UMask=0077 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +LimitCORE=0 + +[Install] +WantedBy=multi-user.target diff --git a/VM1_app/codebase/backend/deployment/preflight.sh b/VM1_app/codebase/backend/deployment/preflight.sh new file mode 100644 index 0000000..aaae125 --- /dev/null +++ b/VM1_app/codebase/backend/deployment/preflight.sh @@ -0,0 +1,292 @@ +#!/bin/sh +set -eu + +ROOT=${1:-/opt/han-chat/current/backend} +ENV_FILE=${2:-/etc/han/vm1.env} +MANIFEST=${3:-/run/han-chat/secrets/manifest} +failures=0 + +fail() { + echo "FAIL: $*" >&2 + failures=$((failures + 1)) +} + +value() { + awk -F= -v key="$1" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE" +} + +[ "$(id -u)" -eq 0 ] || fail "preflight must run as root" +[ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing" +[ -f "$ENV_FILE" ] || fail ".env is missing" +[ -f "$MANIFEST" ] || fail "runtime secret manifest is missing" +[ -L /opt/han-chat/current ] || fail "/opt/han-chat/current must be a root-controlled release link" +[ "$(/usr/bin/stat -c '%U:%G' /opt/han-chat/current)" = root:root ] || + fail "active release link must be root:root" +nginx_compose="$ROOT/nginx/docker-compose.yml" +[ -f "$nginx_compose" ] || fail "nginx compose fragment is missing" +if [ -f "$nginx_compose" ]; then + grep -Fq '/var/lib/han-chat/public-tls' "$nginx_compose" && + grep -Fq '/run/tls' "$nginx_compose" || + fail "nginx must mount host public-tls staging at /run/tls" + grep -Fq '/var/lib/han-chat/acme' "$nginx_compose" || + fail "nginx must mount host ACME webroot" + ! grep -Fq '/etc/letsencrypt' "$nginx_compose" || + fail "nginx/certbot compose must not mount the root Certbot tree" + ! grep -Eq '^[[:space:]]+certbot:' "$nginx_compose" || + fail "certbot must not be a Compose service" + ! grep -Eq 'nginx-(certs|acme)' "$nginx_compose" || + fail "legacy nginx TLS named volumes are forbidden" +fi +! grep -Eq '^[[:space:]]+nginx-(certs|acme)' "$ROOT/docker-compose.yml" || + fail "legacy nginx TLS named volumes are forbidden" + +if [ -d "$ROOT" ]; then + if find "$ROOT" -type l -print -quit | grep -q .; then + fail "active release contains a symlink" + fi + while IFS= read -r protected; do + [ -f "$protected" ] || { + fail "protected deployment file is missing: $protected" + continue + } + [ "$(/usr/bin/stat -c '%U:%G' "$protected")" = root:root ] || + fail "$protected must be root:root" + mode=$(/usr/bin/stat -c '%A' "$protected") + case "$mode" in + ??????w???|????????w?) fail "$protected is writable by group/other" ;; + esac + done <]+>|change-me|example\.(com|org|net)|\.invalid([:/]|$)' "$ENV_FILE"; then + fail ".env contains placeholders" + fi + [ "$(value APP_ENV)" = production ] || fail "APP_ENV must be production" + [ "$(value SECRETS_SOURCE)" = selectel ] || fail "production SECRETS_SOURCE must be selectel" + [ "$(value FRONTEND_DEV_PROXY_ENABLED)" = false ] || + fail "FRONTEND_DEV_PROXY_ENABLED must be false" + [ "$(value NGINX_TLS_ENABLED)" = true ] || fail "NGINX_TLS_ENABLED must be true" + [ "$(value NGINX_HTTP_PORT)" = 80 ] || fail "nginx must publish host port 80" + [ "$(value NGINX_HTTPS_PORT)" = 443 ] || fail "nginx must publish host port 443" + [ "$(value NGINX_TLS_CERTIFICATE)" = /run/tls/fullchain.pem ] || + fail "nginx certificate must use staged /run/tls/fullchain.pem" + [ "$(value NGINX_TLS_CERTIFICATE_KEY)" = /run/tls/privkey.pem ] || + fail "nginx key must use staged /run/tls/privkey.pem" + safety_url=$(value MESSAGE_SAFETY_URL) + echo "$safety_url" | grep -Eq '^https://[A-Za-z0-9.-]+:8443$' || + fail "MESSAGE_SAFETY_URL must be private HTTPS VM2 :8443" + echo "$safety_url" | grep -Eq '(message-safety|localhost|127\.0\.0\.1)' && + fail "MESSAGE_SAFETY_URL must not point to a local/stub service" + safety_extra_host=$(value MESSAGE_SAFETY_EXTRA_HOST) + if ! python3 - "$safety_url" "$safety_extra_host" <<'PY' +import ipaddress +import socket +import sys +from urllib.parse import urlparse + +host = urlparse(sys.argv[1]).hostname +try: + mapped_host, mapped_ip = sys.argv[2].rsplit("=", 1) + mapped_address = ipaddress.ip_address(mapped_ip) +except (IndexError, ValueError): + raise SystemExit(1) +allowed = ( + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ipaddress.ip_network("fc00::/7"), +) +if ( + not host + or mapped_host != host + or not any(mapped_address in network for network in allowed) +): + raise SystemExit(1) +try: + addresses = { + ipaddress.ip_address(item[4][0]) + for item in socket.getaddrinfo(host, 8443, type=socket.SOCK_STREAM) + } +except OSError: + raise SystemExit(1) +if ( + not addresses + or mapped_address not in addresses + or any(not any(address in network for network in allowed) for address in addresses) +): + raise SystemExit(1) +PY + then + fail "MESSAGE_SAFETY_URL hostname must resolve only to private VPC addresses and MESSAGE_SAFETY_EXTRA_HOST must map the same address" + fi + safety_ca=$(value MESSAGE_SAFETY_CA_HOST_PATH) + [ -n "$safety_ca" ] || fail "MESSAGE_SAFETY_CA_HOST_PATH is required" + [ -f "$safety_ca" ] || fail "VM2 internal CA file is missing" + pg_ca=$(value PG_CA_HOST_PATH) + [ -f "$pg_ca" ] || fail "managed PostgreSQL CA file is missing" + for image_key in \ + API_BACKEND_IMAGE \ + BITRIX_LOCAL_APP_IMAGE \ + FRONTEND_STATIC_IMAGE \ + KEYCLOAK_IMAGE \ + NGINX_IMAGE \ + OTEL_COLLECTOR_IMAGE \ + OTEL_QUEUE_INIT_IMAGE \ + REDIS_IMAGE \ + SMS_SERVICE_IMAGE \ + TOOLBOX_IMAGE + do + image=$(value "$image_key") + echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' || + fail "$image_key must be pinned by sha256 digest" + case "$image" in + registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000) + fail "$image_key still contains the example image/digest" + ;; + esac + done +fi + +resolved_config=$(mktemp) +resolved_services=$(mktemp) +resolved_images=$(mktemp) +trap 'rm -f "$resolved_config" "$resolved_services" "$resolved_images"' EXIT HUP INT TERM +if ! command -v docker >/dev/null 2>&1; then + fail "docker is required to resolve production Compose" +elif [ -f "$ENV_FILE" ]; then + if ! docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \ + config >"$resolved_config"; then + fail "production Compose does not resolve" + else + docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \ + config --services >"$resolved_services" || + fail "cannot enumerate resolved production services" + docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \ + config --images >"$resolved_images" || + fail "cannot enumerate resolved production images" + + while IFS= read -r image; do + [ -n "$image" ] || continue + echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' || + fail "resolved production image is not digest-pinned: $image" + case "$image" in + registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000) + fail "resolved production image still contains an example value: $image" + ;; + esac + done <"$resolved_images" + [ -s "$resolved_images" ] || fail "resolved production image set is empty" + + for forbidden_service in certbot message-safety bitrix-sync otel-collector-local; do + ! grep -Fxq "$forbidden_service" "$resolved_services" || + fail "local/legacy service resolved in production: $forbidden_service" + done + + published_services=$( + awk ' + /^services:$/ { in_services=1; next } + in_services && /^[^ ]/ { in_services=0 } + in_services && /^ [A-Za-z0-9_.-]+:$/ { + service=$1 + sub(/:$/, "", service) + next + } + in_services && /^ ports:$/ { print service } + ' "$resolved_config" + ) + [ "$published_services" = nginx ] || + fail "only nginx may publish production host ports (found: ${published_services:-none})" + fi +fi + +tls_dir=/var/lib/han-chat/public-tls +getent group han-nginx-tls | awk -F: '$3 == 11001 {found=1} END {exit !found}' || + fail "han-nginx-tls group with GID 11001 is missing" +[ "$(/usr/bin/stat -c '%U:%G:%a' "$tls_dir" 2>/dev/null || true)" = root:han-nginx-tls:750 ] || + fail "public TLS directory must be root:han-nginx-tls 0750" +for tls_file in fullchain.pem privkey.pem; do + path="$tls_dir/$tls_file" + [ -s "$path" ] || { + fail "public TLS file is missing: $path" + continue + } + [ "$(/usr/bin/stat -c '%U:%G:%a' "$path")" = root:han-nginx-tls:640 ] || + fail "$path must be root:han-nginx-tls 0640" +done +if [ -s "$tls_dir/fullchain.pem" ] && [ -s "$tls_dir/privkey.pem" ]; then + cert_public=$( + openssl x509 -in "$tls_dir/fullchain.pem" -pubkey -noout 2>/dev/null | + openssl pkey -pubin -outform DER 2>/dev/null | + sha256sum | awk '{print $1}' + ) || cert_public= + key_public=$( + openssl pkey -in "$tls_dir/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null | + sha256sum | awk '{print $1}' + ) || key_public= + [ -n "$cert_public" ] && [ "$cert_public" = "$key_public" ] || + fail "public TLS certificate/private key are invalid or do not match" +fi + +required_secrets=' +DATABASE_URL +REDIS_URL +REDIS_REALTIME_URL +MESSAGE_SAFETY_SERVICE_TOKEN +BITRIX_DATABASE_URL +SMS_DATABASE_URL +KEYCLOAK_DB_PASSWORD +KEYCLOAK_ADMIN_PASSWORD +SELECTEL_S3_ACCESS_KEY +SELECTEL_S3_SECRET_KEY +OTEL_REMOTE_AUTH_HEADER' +if [ -f "$MANIFEST" ]; then + old_ifs=$IFS + IFS=' +' + for name in $required_secrets; do + [ -n "$name" ] || continue + path=$(awk -F= -v key="$name" '$1 == key {print substr($0, index($0, "=") + 1)}' "$MANIFEST") + [ -n "$path" ] || { + fail "manifest is missing $name" + continue + } + [ -f "$path" ] || fail "secret file is missing for $name" + done + IFS=$old_ifs +fi + +if [ -x "$ROOT/scripts/validate-env" ] && [ -f "$ENV_FILE" ] && [ -f "$MANIFEST" ]; then + "$ROOT/scripts/validate-env" "$ENV_FILE" --runtime-manifest "$MANIFEST" || + fail "config/runtime validator rejected the production inputs" +else + fail "config/runtime validator or its inputs are unavailable" +fi + +if [ "$failures" -ne 0 ]; then + echo "preflight: $failures failure(s); VM1 deployment remains closed" >&2 + exit 1 +fi +echo "preflight: static VM1 production gates passed" diff --git a/VM1_app/codebase/backend/deployment/scripts/migrate.sh b/VM1_app/codebase/backend/deployment/scripts/migrate.sh index 8515f53..f9dcb3b 100644 --- a/VM1_app/codebase/backend/deployment/scripts/migrate.sh +++ b/VM1_app/codebase/backend/deployment/scripts/migrate.sh @@ -25,10 +25,8 @@ fi docker compose --env-file "$CONFIG_FILE" config --quiet docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api alembic current docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local alembic current -docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync alembic current docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms alembic current docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local -docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms echo "Migrations completed; record revisions in release evidence." diff --git a/VM1_app/codebase/backend/deployment/scripts/print-frontend-env.sh b/VM1_app/codebase/backend/deployment/scripts/print-frontend-env.sh new file mode 100644 index 0000000..15ca22b --- /dev/null +++ b/VM1_app/codebase/backend/deployment/scripts/print-frontend-env.sh @@ -0,0 +1,189 @@ +#!/bin/sh +# Print frontend build-time env derived from deployment .env. +# Usage: +# ./deployment/scripts/print-frontend-env.sh +# CONFIG_FILE=/opt/han-chat/backend/.env ./deployment/scripts/print-frontend-env.sh --check-oidc +set -eu +cd "$(dirname "$0")/../.." + +CONFIG_FILE=${CONFIG_FILE:-.env} +CHECK_OIDC=0 +FRONTEND_CLIENT_ID=${FRONTEND_CLIENT_ID:-han-chat-frontend} + +for arg in "$@"; do + case "$arg" in + --check-oidc) CHECK_OIDC=1 ;; + -h|--help) + cat <<'EOF' +Usage: print-frontend-env.sh [--check-oidc] + +Reads deployment .env and prints EXPO_PUBLIC_* build args for frontend-test-site. + +Environment: + CONFIG_FILE path to .env (default: .env) + FRONTEND_CLIENT_ID OIDC client id (default: han-chat-frontend) + +Options: + --check-oidc curl OpenID discovery using derived auth base URL +EOF + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 64 + ;; + esac +done + +[ -f "$CONFIG_FILE" ] || { + echo "Config file not found: $CONFIG_FILE" >&2 + exit 66 +} + +env_value() { + python3 - "$CONFIG_FILE" "$1" <<'PY' +import sys +from pathlib import Path + +path, wanted = sys.argv[1:] +found = False +for raw in Path(path).read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if key.strip() == wanted: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + print(value) + found = True + break +if not found: + raise SystemExit(f"missing environment variable: {wanted}") +PY +} + +env_value_or_default() { + python3 - "$CONFIG_FILE" "$1" "$2" <<'PY' +import sys +from pathlib import Path + +path, wanted, default = sys.argv[1:] +for raw in Path(path).read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if key.strip() == wanted: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + print(value or default) + break +else: + print(default) +PY +} + +strip_trailing_slash() { + python3 - "$1" <<'PY' +import sys +print(sys.argv[1].rstrip("/")) +PY +} + +PUBLIC_HOST=$(env_value PUBLIC_HOST) +PUBLIC_WEB_URL=$(strip_trailing_slash "$(env_value PUBLIC_WEB_URL)") +PUBLIC_AUTH_URL=$(strip_trailing_slash "$(env_value_or_default PUBLIC_AUTH_URL "")") +KEYCLOAK_PUBLIC_URL=$(strip_trailing_slash "$(env_value KEYCLOAK_PUBLIC_URL)") +KEYCLOAK_REALM=$(env_value KEYCLOAK_REALM) +APP_ENV=$(env_value_or_default APP_ENV production-like) +FRONTEND_STATIC_IMAGE=$(env_value_or_default FRONTEND_STATIC_IMAGE "") + +if [ -z "$PUBLIC_AUTH_URL" ]; then + PUBLIC_AUTH_URL="$KEYCLOAK_PUBLIC_URL" +fi + +EXPO_PUBLIC_API_BASE_URL="$PUBLIC_WEB_URL" +EXPO_PUBLIC_AUTH_BASE_URL="$PUBLIC_AUTH_URL" +EXPO_PUBLIC_KEYCLOAK_REALM="$KEYCLOAK_REALM" +EXPO_PUBLIC_KEYCLOAK_CLIENT_ID="$FRONTEND_CLIENT_ID" +EXPO_PUBLIC_APP_ENV="$APP_ENV" +OIDC_ISSUER="${EXPO_PUBLIC_AUTH_BASE_URL}/realms/${EXPO_PUBLIC_KEYCLOAK_REALM}" +OIDC_DISCOVERY="${OIDC_ISSUER}/.well-known/openid-configuration" + +errors=0 +warn() { + printf 'WARN: %s\n' "$1" >&2 + errors=$((errors + 1)) +} + +for name in \ + EXPO_PUBLIC_API_BASE_URL \ + EXPO_PUBLIC_AUTH_BASE_URL \ + EXPO_PUBLIC_KEYCLOAK_REALM \ + EXPO_PUBLIC_KEYCLOAK_CLIENT_ID; do + eval "value=\$$name" + case "$value" in + ""|http://localhost:*|https://chat.example.ru*|https://tohin.ru*) + warn "$name looks unset or still uses example/default value: '$value'" + ;; + esac +done + +printf '%s\n' "=== deployment .env (source: ${CONFIG_FILE}) ===" +printf '%s=%s\n' PUBLIC_HOST "$PUBLIC_HOST" +printf '%s=%s\n' PUBLIC_WEB_URL "$PUBLIC_WEB_URL" +printf '%s=%s\n' PUBLIC_AUTH_URL "$PUBLIC_AUTH_URL" +printf '%s=%s\n' KEYCLOAK_PUBLIC_URL "$KEYCLOAK_PUBLIC_URL" +printf '%s=%s\n' KEYCLOAK_REALM "$KEYCLOAK_REALM" +printf '%s=%s\n' APP_ENV "$APP_ENV" +printf '%s=%s\n' FRONTEND_STATIC_IMAGE "${FRONTEND_STATIC_IMAGE:-}" + +printf '\n%s\n' "=== frontend build-time env (EXPO_PUBLIC_*) ===" +printf '%s=%s\n' EXPO_PUBLIC_API_BASE_URL "$EXPO_PUBLIC_API_BASE_URL" +printf '%s=%s\n' EXPO_PUBLIC_AUTH_BASE_URL "$EXPO_PUBLIC_AUTH_BASE_URL" +printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_REALM "$EXPO_PUBLIC_KEYCLOAK_REALM" +printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_CLIENT_ID "$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID" +printf '%s=%s\n' EXPO_PUBLIC_APP_ENV "$EXPO_PUBLIC_APP_ENV" + +printf '\n%s\n' "=== derived OIDC ===" +printf '%s=%s\n' OIDC_ISSUER "$OIDC_ISSUER" +printf '%s=%s\n' OIDC_DISCOVERY "$OIDC_DISCOVERY" +printf '%s=%s\n' AUTH_CALLBACK "${PUBLIC_WEB_URL}/auth/callback" + +printf '\n%s\n' "=== docker build (frontend-test-site) ===" +cat <&2 + exit 1 +fi + +printf '\nOK: frontend build env looks consistent with %s\n' "$CONFIG_FILE" diff --git a/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh b/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh index 8ed8541..a4f94e6 100644 --- a/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh +++ b/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh @@ -1,51 +1,33 @@ #!/usr/bin/env bash -# Первичная подготовка Ubuntu 24.04 для HAN Chat. +# Fresh Ubuntu 24.04 bootstrap for production VM1. This script never starts +# Compose/application containers and never creates DNS, PG, S3, TLS or secrets. +# Run twice: first with HARDEN_SSH=false, then only after independent deploy and +# admin login/sudo checks with HARDEN_SSH=true. # -# Скрипт настраивает только VM: пользователя развертывания, базовые пакеты, -# Docker/Compose, UFW, fail2ban, DOCKER-USER, swap и каталоги проекта. -# PostgreSQL и S3 остаются внешними управляемыми сервисами. Скрипт не создает -# .env, секреты, DNS, S3-бакеты, схемы БД и TLS-сертификаты. +# Required: +# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub +# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub # -# Запуск на свежей VM: -# chmod +x deployment/scripts/setup-vm.sh -# sudo deployment/scripts/setup-vm.sh -# -# Основные параметры: -# DEPLOY_USER=deploy -# DEPLOY_DIR=/opt/han-chat/backend -# SSH_PORT=22 -# TIMEZONE=Europe/Moscow -# SWAP_SIZE_GB=4 -# EXTERNAL_IF=ens3 -# PUBLIC_DOCKER_PORTS=80,443 -# COPY_SSH_KEYS=true -# HARDEN_SSH=false -# LOCK_ACCOUNT_PASSWORDS=true -# HSTS_MAX_AGE_SECONDS=31536000 -# RESET_UFW=false -# SKIP_APT_UPGRADE=false -# -# Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются -# по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно -# запрещает прямой root-вход и SSH TCP forwarding. +# Optional: SSH_PORT=22 EXTERNAL_IF=ens3 SWAP_SIZE_GB=4 HARDEN_SSH=false set -Eeuo pipefail IFS=$'\n\t' DEPLOY_USER="${DEPLOY_USER:-deploy}" -DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/backend}" +ADMIN_USER="${ADMIN_USER:-admin}" +DEPLOY_AUTHORIZED_KEY_FILE="${DEPLOY_AUTHORIZED_KEY_FILE:-}" +ADMIN_AUTHORIZED_KEY_FILE="${ADMIN_AUTHORIZED_KEY_FILE:-}" +DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/current/backend}" +INCOMING_DIR="${INCOMING_DIR:-/var/lib/han-deploy/incoming}" SSH_PORT="${SSH_PORT:-22}" TIMEZONE="${TIMEZONE:-Europe/Moscow}" SWAP_SIZE_GB="${SWAP_SIZE_GB:-4}" EXTERNAL_IF="${EXTERNAL_IF:-}" -PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}" -COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}" HARDEN_SSH="${HARDEN_SSH:-false}" LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}" -HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}" -RESET_UFW="${RESET_UFW:-false}" +RESET_UFW="${RESET_UFW:-true}" SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}" -LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}" +LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm1-setup.log}" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" @@ -73,17 +55,26 @@ require_root() { } validate_parameters() { - [[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER" - [[ "$DEPLOY_DIR" == /* ]] || die "DEPLOY_DIR должен быть абсолютным путем" + [[ "$DEPLOY_USER" == deploy ]] || die "Production unit/sudoers требуют DEPLOY_USER=deploy" + [[ "$ADMIN_USER" == admin ]] || die "Break-glass роль должна называться admin" + [[ "$DEPLOY_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \ + || die "Задайте безопасный абсолютный DEPLOY_AUTHORIZED_KEY_FILE" + [[ "$ADMIN_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \ + || die "Задайте безопасный абсолютный ADMIN_AUTHORIZED_KEY_FILE" + [[ "$DEPLOY_AUTHORIZED_KEY_FILE" != "$ADMIN_AUTHORIZED_KEY_FILE" ]] \ + || die "deploy и admin должны использовать разные key files" + [[ "$DEPLOY_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "Некорректный DEPLOY_DIR" + [[ "$INCOMING_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "Некорректный INCOMING_DIR" + [[ "$DEPLOY_DIR" != "$INCOMING_DIR" ]] || die "Production и incoming должны различаться" [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом" ((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона" [[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом" - [[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \ - || die "HSTS_MAX_AGE_SECONDS должен быть целым числом" - ((HSTS_MAX_AGE_SECONDS >= 31536000)) \ - || die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000" - [[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \ - || die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443" + [[ "$HARDEN_SSH" == true || "$HARDEN_SSH" == false ]] || die "Некорректный HARDEN_SSH" + [[ "$LOCK_ACCOUNT_PASSWORDS" == true || "$LOCK_ACCOUNT_PASSWORDS" == false ]] \ + || die "Некорректный LOCK_ACCOUNT_PASSWORDS" + [[ "$RESET_UFW" == true || "$RESET_UFW" == false ]] || die "Некорректный RESET_UFW" + [[ "$SKIP_APT_UPGRADE" == true || "$SKIP_APT_UPGRADE" == false ]] \ + || die "Некорректный SKIP_APT_UPGRADE" } check_os() { @@ -107,7 +98,7 @@ update_system() { apt-get install -y \ ca-certificates \ curl \ - dos2unix \ + certbot \ fail2ban \ git \ gnupg \ @@ -117,11 +108,10 @@ update_system() { netcat-openbsd \ openssl \ python3 \ - python3-venv \ rsync \ + sudo \ unattended-upgrades \ ufw - apt-get autoremove -y } configure_time() { @@ -130,65 +120,79 @@ configure_time() { timedatectl set-ntp true } -create_deploy_user() { - step "Пользователь развертывания" - if ! id "$DEPLOY_USER" >/dev/null 2>&1; then - useradd --create-home --shell /bin/bash "$DEPLOY_USER" - log "Создан пользователь ${DEPLOY_USER}" - else - log "Пользователь ${DEPLOY_USER} уже существует" - fi +install_authorized_key() { + local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys" + [[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}" + [[ "$(wc -l <"$source")" -eq 1 ]] || die "${source} должен содержать один ключ" + ssh-keygen -l -f "$source" >/dev/null || die "Некорректный SSH key ${source}" + grep -Eq '^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$' "$source" \ + || die "Разрешены только Ed25519 ключи" + install -d -m 0700 -o "$user" -g "$user" "/home/${user}/.ssh" + install -m 0600 -o "$user" -g "$user" "$source" "$target" +} - install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \ - "/home/${DEPLOY_USER}/.ssh" - - local source_user="${SUDO_USER:-}" - local source_keys="" - local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys" - if [[ -n "$source_user" && "$source_user" != "root" ]]; then - source_keys="/home/${source_user}/.ssh/authorized_keys" - elif [[ -s /root/.ssh/authorized_keys ]]; then - source_user="root" - source_keys="/root/.ssh/authorized_keys" - fi - - if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then - install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$source_keys" "$target_keys" - log "SSH-ключи скопированы от ${source_user}" - fi - - if [[ ! -s "$target_keys" ]]; then - log "ПРЕДУПРЕЖДЕНИЕ: у ${DEPLOY_USER} отсутствует authorized_keys" +create_host_roles() { + step "Роли deploy и break-glass admin" + id "$DEPLOY_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$DEPLOY_USER" + id "$ADMIN_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$ADMIN_USER" + install_authorized_key "$DEPLOY_USER" "$DEPLOY_AUTHORIZED_KEY_FILE" + install_authorized_key "$ADMIN_USER" "$ADMIN_AUTHORIZED_KEY_FILE" + local deploy_key admin_key group + deploy_key="$(awk '{print $2}' "$DEPLOY_AUTHORIZED_KEY_FILE")" + admin_key="$(awk '{print $2}' "$ADMIN_AUTHORIZED_KEY_FILE")" + [[ "$deploy_key" != "$admin_key" ]] || die "deploy/admin используют один ключ" + if [[ -s /root/.ssh/authorized_keys ]] && + { grep -Fq "$deploy_key" /root/.ssh/authorized_keys || + grep -Fq "$admin_key" /root/.ssh/authorized_keys; }; then + die "deploy/admin key совпадает с root key" fi + for group in docker sudo lxd adm systemd-journal; do + if getent group "$group" >/dev/null && + id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$group"; then + gpasswd -d "$DEPLOY_USER" "$group" + fi + done + usermod -aG sudo "$ADMIN_USER" + for group in docker lxd; do + if getent group "$group" >/dev/null && + id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx "$group"; then + gpasswd -d "$ADMIN_USER" "$group" + fi + done } configure_account_passwords() { - step "Блокировка локальных паролей привилегированных учетных записей" - if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then - log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены" - return + step "Пароли host-ролей" + if [[ "$LOCK_ACCOUNT_PASSWORDS" == true ]]; then + passwd --lock root + passwd --lock "$DEPLOY_USER" fi - - [[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \ - || die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст" - - passwd --lock root - passwd --lock "$DEPLOY_USER" - log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен" + local status + status="$(passwd --status "$ADMIN_USER" | awk '{print $2}')" + if [[ "$HARDEN_SSH" == true && "$status" != P ]]; then + die "До HARDEN_SSH=true задайте отдельный sudo-пароль: passwd ${ADMIN_USER}" + fi + [[ "$status" == P ]] || log "ПРЕДУПРЕЖДЕНИЕ: задайте sudo-пароль admin" } configure_layout() { - step "Каталоги HAN Chat" - install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR" - install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \ - "${DEPLOY_DIR}/secrets" \ - "${DEPLOY_DIR}/secrets/pg" \ - "${DEPLOY_DIR}/backups" - - local env_file="${DEPLOY_DIR}/.env" - if [[ -f "$env_file" ]]; then - chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file" - chmod 600 "$env_file" + step "Root-owned releases и incoming staging" + install -d -m 0755 -o root -g root /opt/han-chat /opt/han-chat/releases + install -d -m 0755 -o root -g root /var/lib/han-deploy + install -d -m 0750 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$INCOMING_DIR" + install -d -m 0700 -o root -g root /etc/han /etc/han/secrets /etc/han/credentials + install -d -m 0755 -o root -g root /etc/han/ca /var/lib/han-chat/acme + if [[ -f /etc/han/vm1.env ]]; then + [[ "$(stat -c '%U:%G:%a' /etc/han/vm1.env)" == root:root:600 ]] \ + || die "/etc/han/vm1.env должен быть root:root 0600" + fi + if [[ -d "$DEPLOY_DIR" ]]; then + if find "$DEPLOY_DIR" ! -user root -print -quit | grep -q .; then + die "Активный release содержит файлы не root-owned" + fi + if find "$DEPLOY_DIR" -perm /022 -print -quit | grep -q .; then + die "Активный release доступен на запись group/other" + fi fi } @@ -261,7 +265,6 @@ install_docker() { EOF systemctl enable --now docker systemctl restart docker - usermod -aG docker "$DEPLOY_USER" docker compose version >/dev/null \ || die "Docker Compose plugin не установлен" @@ -276,8 +279,7 @@ configure_ufw() { fi ufw default deny incoming ufw default allow outgoing - ufw allow "${SSH_PORT}/tcp" comment 'HAN Chat SSH' - ufw limit "${SSH_PORT}/tcp" comment 'HAN Chat SSH rate limit' + ufw allow "$SSH_PORT"/tcp comment 'HAN VM1 SSH temporary public access' ufw allow 80/tcp comment 'HAN Chat HTTP' ufw allow 443/tcp comment 'HAN Chat HTTPS' ufw logging medium @@ -317,7 +319,6 @@ configure_docker_firewall() { step "Фильтрация опубликованных Docker-портов" cat >/etc/default/han-chat-docker-firewall </usr/local/sbin/han-chat-docker-firewall <<'FIREWALL' @@ -341,14 +342,10 @@ iptables -F HAN-CHAT-DOCKER iptables -A HAN-CHAT-DOCKER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN iptables -A HAN-CHAT-DOCKER -i lo -j RETURN -IFS=',' read -ra ports <<<"$PUBLIC_DOCKER_PORTS" -for port in "${ports[@]}"; do - [[ "$port" =~ ^[0-9]+$ ]] || { - echo "Некорректный порт: $port" >&2 - exit 1 - } - iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp --dport "$port" -j RETURN -done +iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \ + -m conntrack --ctorigdstport 80 -j RETURN +iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \ + -m conntrack --ctorigdstport 443 -j RETURN # Блокируется только новый входящий трафик с внешнего интерфейса в Docker bridge. # Исходящий и межконтейнерный трафик этой цепочкой не затрагивается. @@ -384,7 +381,8 @@ EOF ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall EOF systemctl daemon-reload - systemctl enable --now han-chat-docker-firewall.service + systemctl enable han-chat-docker-firewall.service + systemctl restart han-chat-docker-firewall.service } configure_ssh() { @@ -396,6 +394,9 @@ configure_ssh() { PasswordAuthentication no KbdInteractiveAuthentication no PubkeyAuthentication yes +PermitEmptyPasswords no +AllowAgentForwarding no +AllowTcpForwarding no X11Forwarding no MaxAuthTries 3 ClientAliveInterval 120 @@ -403,13 +404,13 @@ ClientAliveCountMax 2 Port ${SSH_PORT} EOF if [[ "$HARDEN_SSH" == "true" ]]; then - cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF' + cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <>"$env_file" - fi - chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file" - chmod 600 "$env_file" - log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}" - else - log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию" - fi +install_deploy_sudoers() { + step "Exact sudoers для deploy" + cat >/etc/sudoers.d/deploy <<'EOF' +Cmnd_Alias HAN_VM1_UNITS = /usr/bin/systemctl start han-secrets@production.service, /usr/bin/systemctl restart han-secrets@production.service, /usr/bin/systemctl start han-stack@production.service, /usr/bin/systemctl restart han-stack@production.service, /usr/bin/systemctl stop han-stack@production.service +Cmnd_Alias HAN_VM1_STATUS = /usr/bin/systemctl --no-pager status han-secrets@production.service, /usr/bin/systemctl --no-pager status han-stack@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service +deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS +EOF + chmod 0440 /etc/sudoers.d/deploy + visudo -cf /etc/sudoers.d/deploy >/dev/null || die "Некорректный sudoers" } -install_secret_loader_if_possible() { - step "Загрузчик секретов" +install_release_helpers_if_possible() { + step "Root-owned helpers, TLS hook и units" local source_dir="${DEPLOY_DIR}/deployment/secrets" - if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then - log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске" + local deployment="${DEPLOY_DIR}/deployment" + local tls_group=han-nginx-tls tls_gid=11001 + if [[ ! -f "${DEPLOY_DIR}/docker-compose.yml" || + ! -f "${source_dir}/secrets_loader.py" || + ! -f "${deployment}/han-stack@.service" ]]; then + log "Активный релиз отсутствует; повторите после root-активации" return fi - chmod 0750 "${source_dir}/han-secrets" "${source_dir}/han-compose" - - install -d -m 0700 -o root -g root \ - /etc/han \ - /etc/han/secrets \ - /etc/han/credentials - install -d -m 0755 -o root -g root \ - /usr/local/lib/han-secrets \ - /usr/local/share/doc/han-secrets + if find "$DEPLOY_DIR" -type l -print -quit | grep -q .; then + die "Внутри активного release обнаружен symlink" + fi + [[ -x "${deployment}/preflight.sh" ]] || die "preflight.sh не executable" + [[ -x "${deployment}/scripts/tls-deploy-hook.sh" ]] || die "TLS hook не executable" + [[ -x "${source_dir}/han-compose" ]] || die "han-compose не executable" + [[ -x "${source_dir}/han-secrets" ]] || die "han-secrets не executable" + if getent group "$tls_group" >/dev/null; then + [[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \ + || die "han-nginx-tls имеет неожиданный GID" + elif getent group "$tls_gid" >/dev/null; then + die "GID ${tls_gid} уже занят" + else + groupadd --system --gid "$tls_gid" "$tls_group" + fi + install -d -m 0750 -o root -g "$tls_group" /var/lib/han-chat/public-tls + install -d -m 0755 -o root -g root /usr/local/lib/han-secrets /usr/local/share/doc/han-secrets install -m 0750 -o root -g root \ "${source_dir}/secrets_loader.py" \ /usr/local/lib/han-secrets/secrets_loader.py @@ -459,56 +465,23 @@ install_secret_loader_if_possible() { /usr/local/lib/han-secrets/han-secrets install -m 0750 -o root -g root \ "${source_dir}/han-compose" \ - /usr/local/bin/han-compose + /usr/local/sbin/han-vm1-compose install -m 0644 -o root -g root \ "${source_dir}/han-secrets@.service" \ /etc/systemd/system/han-secrets@.service + install -m 0644 -o root -g root \ + "${deployment}/han-stack@.service" \ + /etc/systemd/system/han-stack@.service + install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy + install -m 0755 -o root -g root \ + "${deployment}/scripts/tls-deploy-hook.sh" \ + /etc/letsencrypt/renewal-hooks/deploy/han-chat-nginx install -m 0644 -o root -g root \ "${source_dir}/SELECTEL_RUNBOOK.ru.md" \ /usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md - if [[ ! -e /etc/han/secrets/production.selectel.json.example ]]; then - install -m 0600 -o root -g root \ - "${source_dir}/config.example.json" \ - /etc/han/secrets/production.selectel.json.example - fi systemctl daemon-reload - log "Загрузчик установлен, но не включен: сначала выполните SELECTEL_RUNBOOK.ru.md" -} - -install_ssl_timer_if_possible() { - step "Таймер продления TLS" - local renew_script="${DEPLOY_DIR}/deployment/scripts/ssl-renew.sh" - if [[ ! -x "$renew_script" ]]; then - log "Проект еще не скопирован: таймер TLS будет установлен при повторном запуске" - return - fi - - cat >/etc/systemd/system/han-chat-ssl-renew.service </etc/systemd/system/han-chat-ssl-renew.timer <<'EOF' -[Unit] -Description=Run HAN Chat TLS renewal twice daily - -[Timer] -OnCalendar=*-*-* 03,15:20:00 -RandomizedDelaySec=30m -Persistent=true - -[Install] -WantedBy=timers.target -EOF - systemctl daemon-reload - systemctl enable --now han-chat-ssl-renew.timer + systemctl enable certbot.timer + log "Helpers установлены; application units не запущены" } verify() { @@ -520,7 +493,21 @@ verify() { iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null \ || { log "FAIL: цепочка HAN-CHAT-DOCKER не подключена"; failed=1; } docker compose version >/dev/null || { log "FAIL: Compose недоступен"; failed=1; } - [[ -d "$DEPLOY_DIR" ]] || { log "FAIL: отсутствует ${DEPLOY_DIR}"; failed=1; } + if id -nG "$DEPLOY_USER" | tr ' ' '\n' | + grep -Eq '^(docker|sudo|lxd|adm|systemd-journal)$'; then + log "FAIL: deploy состоит в привилегированной группе"; failed=1 + fi + id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx sudo \ + || { log "FAIL: admin не состоит в sudo"; failed=1; } + [[ "$(stat -c '%U:%G:%a' "$INCOMING_DIR")" == deploy:deploy:750 ]] \ + || { log "FAIL: неверные права incoming"; failed=1; } + local external_if="${EXTERNAL_IF:-}" + [[ -n "$external_if" ]] || external_if="$(ip -4 route show default | awk '{print $5; exit}')" + for port in 80 443; do + iptables -C HAN-CHAT-DOCKER -i "$external_if" -p tcp \ + -m conntrack --ctorigdstport "$port" -j RETURN 2>/dev/null \ + || { log "FAIL: нет original host port ${port}"; failed=1; } + done ((failed == 0)) || die "Базовая проверка VM не пройдена" log "Базовая проверка VM пройдена" } @@ -529,35 +516,17 @@ summary() { step "Настройка VM завершена" cat < -2. Скопируйте содержимое codebase/backend в: - ${DEPLOY_DIR} -3. Поместите CA PostgreSQL: - ${DEPLOY_DIR}/secrets/pg/ca.pem -4. Создайте только несекретный config: - cd ${DEPLOY_DIR} - cp .env.example .env - chmod 600 .env - ./scripts/validate-env .env -5. Настройте Selectel, encrypted bootstrap credential и fallback map: - deployment/secrets/SELECTEL_RUNBOOK.ru.md -6. Выполняйте Compose только через: - sudo deployment/secrets/han-compose -7. Продолжите с Gate 7 в: - deployment/RUNBOOK.ru.md -8. После копирования проекта повторно запустите этот скрипт для установки unit-файлов. - -Важно: членство в группе docker начнет действовать после нового входа в систему. +Продолжайте только по deployment/RUNBOOK.production.ru.md. +Скрипт не запускал Compose и прикладные сервисы. EOF } @@ -570,7 +539,7 @@ main() { check_os update_system configure_time - create_deploy_user + create_host_roles configure_account_passwords configure_layout configure_swap @@ -581,9 +550,8 @@ main() { configure_unattended_upgrades configure_docker_firewall configure_ssh - configure_application_security - install_secret_loader_if_possible - install_ssl_timer_if_possible + install_deploy_sudoers + install_release_helpers_if_possible verify summary } diff --git a/VM1_app/codebase/backend/deployment/scripts/tls-deploy-hook.sh b/VM1_app/codebase/backend/deployment/scripts/tls-deploy-hook.sh new file mode 100644 index 0000000..66bc593 --- /dev/null +++ b/VM1_app/codebase/backend/deployment/scripts/tls-deploy-hook.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +COMPOSE=/usr/local/sbin/han-vm1-compose +TLS_DIR=/var/lib/han-chat/public-tls +TLS_GROUP=han-nginx-tls + +lineage=${RENEWED_LINEAGE:?Certbot did not provide RENEWED_LINEAGE} +test -s "$lineage/fullchain.pem" +test -s "$lineage/privkey.pem" +test -d "$TLS_DIR" +getent group "$TLS_GROUP" >/dev/null + +staging=$(mktemp -d "${TLS_DIR}/.renew.XXXXXX") +trap 'rm -rf -- "$staging"' EXIT HUP INT TERM +install -m 0640 -o root -g "$TLS_GROUP" \ + "$lineage/fullchain.pem" "$staging/fullchain.pem" +install -m 0640 -o root -g "$TLS_GROUP" \ + "$lineage/privkey.pem" "$staging/privkey.pem" + +cert_public=$( + openssl x509 -in "$staging/fullchain.pem" -pubkey -noout | + openssl pkey -pubin -outform DER 2>/dev/null | + sha256sum | awk '{print $1}' +) +key_public=$( + openssl pkey -in "$staging/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null | + sha256sum | awk '{print $1}' +) +[ "$cert_public" = "$key_public" ] || { + echo "renewed certificate and private key do not match" >&2 + exit 1 +} + +mv -f "$staging/fullchain.pem" "$TLS_DIR/fullchain.pem" +mv -f "$staging/privkey.pem" "$TLS_DIR/privkey.pem" +rmdir "$staging" +trap - EXIT HUP INT TERM + +container=$("$COMPOSE" ps --status running --quiet nginx) +[ -n "$container" ] || { + echo "HAN VM1 nginx is not running" >&2 + exit 1 +} +if ! output=$("$COMPOSE" exec -T nginx nginx -t -c /tmp/nginx.conf 2>&1); then + printf '%s\n' "$output" >&2 + exit 1 +fi +/usr/bin/docker kill --signal HUP "$container" >/dev/null diff --git a/VM1_app/codebase/backend/deployment/secrets/SELECTEL_RUNBOOK.ru.md b/VM1_app/codebase/backend/deployment/secrets/SELECTEL_RUNBOOK.ru.md index 0ac7ba9..15c8e75 100644 --- a/VM1_app/codebase/backend/deployment/secrets/SELECTEL_RUNBOOK.ru.md +++ b/VM1_app/codebase/backend/deployment/secrets/SELECTEL_RUNBOOK.ru.md @@ -44,19 +44,19 @@ TLS и redirect policy отключать нельзя. ## 2. Каталог секретов Скопируйте `config.example.json` в -`/etc/han/secrets/production-like.selectel.json` и замените account, username, +`/etc/han/secrets/production.selectel.json` и замените account, username, project, region и `remote`. Каноническое имя слева обязано совпадать с Compose/validator; `remote` — неизменяемый ключ в Selectel. Используйте консервативные provider keys с дефисами, например: - `han-chat-prod-pg-han-app-dsn`, `han-chat-prod-pg-bitrix-local-dsn`, - `han-chat-prod-pg-bitrix-sync-dsn`, `han-chat-prod-pg-sms-dsn`, + `han-chat-prod-pg-sms-dsn`, `han-chat-prod-pg-keycloak-password`, `han-chat-prod-pg-backup-dsn`; -- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-safety-password`, - `han-chat-prod-redis-health-password`, а также три credential-bearing URL; +- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-health-password`, + а также credential-bearing URL только для Redis DB0/DB1; - `han-chat-prod-message-safety-token`, `han-chat-prod-bitrix-internal-token`, - `han-chat-prod-bitrix-forward-token`, `han-chat-prod-bitrix-sync-token`, + `han-chat-prod-bitrix-forward-token`, `han-chat-prod-keycloak-settings-token`, `han-chat-prod-sms-service-token`; - Keycloak bootstrap password, OTP HMAC и mock code только для среды, где mock действительно включён; @@ -79,25 +79,25 @@ S3 и Bitrix sync credentials до появления потребляющего ## 3. Установка на ВМ -Повторный запуск `deployment/scripts/setup-vm.sh` после копирования проекта -устанавливает loader, launcher, systemd template и этот runbook. Вручную: +Повторный запуск `deployment/scripts/setup-vm.sh` после root-активации release +устанавливает loader, launcher, systemd units и этот runbook. Production +установка вручную не поддерживается: точные owner/mode и пути задаёт setup. +Ожидаемые артефакты: ```sh -sudo install -d -m 0700 /etc/han/secrets /etc/han/credentials -sudo install -d -m 0755 /usr/local/lib/han-secrets -sudo install -m 0750 secrets_loader.py han-secrets /usr/local/lib/han-secrets/ -sudo install -m 0750 han-compose /usr/local/bin/han-compose -sudo install -m 0644 han-secrets@.service /etc/systemd/system/ -sudo install -m 0600 config.example.json \ - /etc/han/secrets/production-like.selectel.json +test -x /usr/local/lib/han-secrets/han-secrets +test -x /usr/local/sbin/han-vm1-compose +test -f /etc/systemd/system/han-secrets@.service +test -f /etc/systemd/system/han-stack@.service ``` -Обычный `/opt/han-chat/backend/.env` содержит только несекретные параметры. +`/etc/han/vm1.env` принадлежит root (`0600`) и содержит только несекретные +параметры. Config находится вне immutable release. Штатный режим: ```dotenv SECRETS_SOURCE=selectel -APP_ENV=production-like +APP_ENV=production ``` ## 4. Bootstrap credential @@ -116,23 +116,21 @@ sudo chmod 0600 /etc/han/credentials/production.selectel-password.cred ## 5. Проверка и запуск -Все команды, которым нужны Compose secrets, запускайте от root через wrapper: +Secret sync и preflight запускает root. `deploy` не вызывает Docker/launcher: ```sh -cd /opt/han-chat/backend -sudo ./scripts/validate-env .env -sudo systemctl daemon-reload -sudo systemctl enable han-secrets@production.service -sudo systemctl restart han-secrets@production.service -sudo ./scripts/validate-env .env \ +cd /opt/han-chat/current/backend +./scripts/validate-env /etc/han/vm1.env +systemctl restart han-secrets@production.service +./scripts/validate-env /etc/han/vm1.env \ --runtime-manifest /run/han-chat/secrets/manifest -sudo deployment/secrets/han-compose config --quiet -sudo deployment/secrets/han-compose up -d --wait +deployment/preflight.sh +/usr/local/sbin/han-vm1-compose config --quiet ``` Selectel sync запускается именно unit-файлом: только он предоставляет расшифрованный bootstrap credential через `$CREDENTIALS_DIRECTORY`. -`han-compose` и ops-скрипты используют уже синхронизированный manifest и +`han-vm1-compose` и ops-скрипты используют уже синхронизированный manifest и отказываются работать, если `SECRETS_SOURCE`/loader config не совпадают с runtime state. После смены source, provider version или JSON-карты сначала выполняйте `systemctl restart han-secrets@production.service`. @@ -142,27 +140,17 @@ runtime state. После смены source, provider version или JSON-кар приёмки должна подтвердить отсутствие canary value в `docker inspect`, stdout, json logs, traces и shell history. -На выделенной только под HAN Chat ВМ после canary и проверки file fallback -установите fail-closed ordering: - -```sh -sudo install -d -m 0755 /etc/systemd/system/docker.service.d -sudo install -m 0644 \ - deployment/secrets/docker-han-secrets.conf.example \ - /etc/systemd/system/docker.service.d/han-secrets.conf -sudo systemctl daemon-reload -sudo systemctl restart docker -``` - -После этого проведите reboot rehearsal: materializer должен завершиться до -autorestart контейнеров. Ошибка Selectel намеренно блокирует Docker. На ВМ с -другими workloads такой глобальный `Requires=` запрещён: нужен отдельный Docker +Fail-closed ordering задают `han-stack@production.service` и +`han-secrets@production.service`. Глобальную зависимость Docker daemon от +секретов не устанавливайте. После настройки проведите reboot rehearsal: +materializer должен завершиться до root stack unit. На ВМ с другими workloads +тем более запрещено связывать весь Docker daemon/VM, иначе fail-closed HAN остановит несвязанные системы. ## 6. Явный file fallback Подготовьте отдельную карту -`/etc/han/secrets/production-like.file.json`: скопируйте Selectel-карту, +`/etc/han/secrets/production.file.json`: скопируйте Selectel-карту, установите `"mode": "file"`, удалите `selectel` и `http`, добавьте: ```json @@ -178,7 +166,7 @@ shell: запрещены `export`, substitutions, multiline, неизвестн ключи. Файл — `root:root 0600`. При инциденте доставьте recovery-файл из защищённой офлайн-копии и только затем -явно измените `.env`: +явно измените `/etc/han/vm1.env`: ```dotenv SECRETS_SOURCE=file @@ -198,7 +186,7 @@ validate/recreate через wrappers. После восстановления S 4. Сделайте версию current, удалите pin, снова sync и пересоздайте только потребителей. 5. Для rollback активируйте предыдущую provider version; не храните snapshot - старого `.env`. +старого `/etc/han/vm1.env`. Selectel не позволяет удалить отдельную версию — только секрет целиком. Старые значения должны быть отозваны в PostgreSQL/S3/Bitrix/i-Digital после окна diff --git a/VM1_app/codebase/backend/deployment/secrets/config.example.json b/VM1_app/codebase/backend/deployment/secrets/config.example.json index 0b317cf..a845917 100644 --- a/VM1_app/codebase/backend/deployment/secrets/config.example.json +++ b/VM1_app/codebase/backend/deployment/secrets/config.example.json @@ -26,11 +26,6 @@ "consumers": ["bitrix-local-app", "bitrix-local-migrate"], "max_bytes": 4096 }, - "BITRIX_SYNC_DATABASE_URL": { - "remote": "BITRIX_SYNC_DATABASE_URL", - "consumers": ["bitrix-sync", "bitrix-sync-migrate"], - "max_bytes": 4096 - }, "SMS_DATABASE_URL": { "remote": "SMS_DATABASE_URL", "consumers": ["sms-service", "sms-worker", "sms-migrate"], @@ -68,7 +63,7 @@ }, "BITRIX_TOKEN_ENCRYPTION_KEY": { "remote": "BITRIX_TOKEN_ENCRYPTION_KEY", - "consumers": ["api-backend", "bitrix-local-app", "bitrix-sync"], + "consumers": ["api-backend", "bitrix-local-app"], "max_bytes": 1024 }, "REDIS_API_PASSWORD": { @@ -86,16 +81,6 @@ "consumers": ["api-backend"], "max_bytes": 4096 }, - "REDIS_SAFETY_PASSWORD": { - "remote": "REDIS_SAFETY_PASSWORD", - "consumers": ["redis"], - "max_bytes": 1024 - }, - "MESSAGE_SAFETY_REDIS_URL": { - "remote": "MESSAGE_SAFETY_REDIS_URL", - "consumers": ["message-safety", "safety-recovery-worker"], - "max_bytes": 4096 - }, "REDIS_HEALTH_PASSWORD": { "remote": "REDIS_HEALTH_PASSWORD", "consumers": ["redis", "redis-exporter"], @@ -103,7 +88,7 @@ }, "MESSAGE_SAFETY_SERVICE_TOKEN": { "remote": "MESSAGE_SAFETY_SERVICE_TOKEN", - "consumers": ["api-backend", "message-safety"], + "consumers": ["api-backend"], "max_bytes": 1024 }, "BITRIX_LOCAL_APP_INTERNAL_TOKEN": { @@ -126,11 +111,6 @@ "consumers": ["api-backend", "bitrix-local-app"], "max_bytes": 1024 }, - "BITRIX_SYNC_SERVICE_TOKEN": { - "remote": "BITRIX_SYNC_SERVICE_TOKEN", - "consumers": ["api-backend", "bitrix-sync"], - "max_bytes": 1024 - }, "KEYCLOAK_SETTINGS_BRIDGE_TOKEN": { "remote": "KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "consumers": ["api-backend", "keycloak"], diff --git a/VM1_app/codebase/backend/deployment/secrets/han-compose b/VM1_app/codebase/backend/deployment/secrets/han-compose index 85f9a39..62870a7 100644 --- a/VM1_app/codebase/backend/deployment/secrets/han-compose +++ b/VM1_app/codebase/backend/deployment/secrets/han-compose @@ -1,13 +1,13 @@ #!/bin/sh set -eu -DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/backend} +DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/current/backend} SOURCE_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) [ ! -f "$SOURCE_DIR/../../docker-compose.yml" ] || \ DEPLOY_DIR=$(CDPATH= cd -- "$SOURCE_DIR/../.." && pwd) cd "$DEPLOY_DIR" -CONFIG_FILE=${CONFIG_FILE:-.env} +CONFIG_FILE=${CONFIG_FILE:-/etc/han/vm1.env} LAUNCHER=${HAN_SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets} [ -x "$LAUNCHER" ] || LAUNCHER=deployment/secrets/han-secrets diff --git a/VM1_app/codebase/backend/deployment/secrets/han-secrets@.service b/VM1_app/codebase/backend/deployment/secrets/han-secrets@.service index eb07efd..06e0ef2 100644 --- a/VM1_app/codebase/backend/deployment/secrets/han-secrets@.service +++ b/VM1_app/codebase/backend/deployment/secrets/han-secrets@.service @@ -1,18 +1,19 @@ [Unit] -Description=Materialize HAN service secrets (%i) +Description=Materialize HAN VM1 service secrets (%i) Documentation=file:/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md Wants=network-online.target After=network-online.target Before=han-stack@%i.service +ConditionPathIsDirectory=/opt/han-chat/current/backend [Service] Type=oneshot User=root Group=root UMask=0077 -RuntimeDirectory=han-chat/secrets +RuntimeDirectory=han-chat han-chat/secrets RuntimeDirectoryMode=0700 -ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /opt/han-chat/backend/.env +ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /etc/han/vm1.env LoadCredentialEncrypted=selectel-service-user-password:/etc/han/credentials/%i.selectel-password.cred RemainAfterExit=yes StandardOutput=null diff --git a/VM1_app/codebase/backend/docker-compose.yml b/VM1_app/codebase/backend/docker-compose.yml index d16c2cc..3c0fb68 100644 --- a/VM1_app/codebase/backend/docker-compose.yml +++ b/VM1_app/codebase/backend/docker-compose.yml @@ -21,8 +21,6 @@ networks: volumes: redis-data: - nginx-certs: - nginx-acme-webroot: nginx-cache: frontend-static: otel-queue: diff --git a/VM1_app/codebase/backend/frontend-test-site/.env.example b/VM1_app/codebase/backend/frontend-test-site/.env.example index 6732a88..10972b1 100644 --- a/VM1_app/codebase/backend/frontend-test-site/.env.example +++ b/VM1_app/codebase/backend/frontend-test-site/.env.example @@ -1,5 +1,5 @@ -EXPO_PUBLIC_API_BASE_URL=https://tohin.ru -EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth +EXPO_PUBLIC_API_BASE_URL=https://chat.example.ru +EXPO_PUBLIC_AUTH_BASE_URL=https://chat.example.ru/auth EXPO_PUBLIC_KEYCLOAK_REALM=han-chat EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend EXPO_PUBLIC_APP_ENV=production-like diff --git a/VM1_app/codebase/backend/frontend-test-site/Dockerfile b/VM1_app/codebase/backend/frontend-test-site/Dockerfile index 80574b9..93196c4 100644 --- a/VM1_app/codebase/backend/frontend-test-site/Dockerfile +++ b/VM1_app/codebase/backend/frontend-test-site/Dockerfile @@ -13,6 +13,10 @@ ENV EXPO_PUBLIC_API_BASE_URL=$EXPO_PUBLIC_API_BASE_URL \ EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \ EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \ EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV +RUN test -n "$EXPO_PUBLIC_API_BASE_URL" \ + && test -n "$EXPO_PUBLIC_AUTH_BASE_URL" \ + && test -n "$EXPO_PUBLIC_KEYCLOAK_REALM" \ + && test -n "$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID" RUN npm run build:pwa # One-shot Compose init container copies the immutable export to nginx's volume. diff --git a/VM1_app/codebase/backend/frontend-test-site/README.md b/VM1_app/codebase/backend/frontend-test-site/README.md index be73dab..fcf4f86 100644 --- a/VM1_app/codebase/backend/frontend-test-site/README.md +++ b/VM1_app/codebase/backend/frontend-test-site/README.md @@ -40,8 +40,8 @@ Dockerfile собирает статический OCI-артефакт `/dist` ```bash docker build --target static \ - --build-arg EXPO_PUBLIC_API_BASE_URL=https://tohin.ru \ - --build-arg EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth \ + --build-arg EXPO_PUBLIC_API_BASE_URL="${PUBLIC_WEB_URL}" \ + --build-arg EXPO_PUBLIC_AUTH_BASE_URL="${PUBLIC_AUTH_URL}" \ --build-arg EXPO_PUBLIC_KEYCLOAK_REALM=han-chat \ --build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend . ``` diff --git a/VM1_app/codebase/backend/infra/compose/application.yml b/VM1_app/codebase/backend/infra/compose/application.yml index f9bc625..61f3094 100644 --- a/VM1_app/codebase/backend/infra/compose/application.yml +++ b/VM1_app/codebase/backend/infra/compose/application.yml @@ -28,9 +28,7 @@ x-api-secrets: &api-secrets - cursor_hmac_secret x-api-runtime: &api-runtime - build: - context: ../../api-backend - image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local} + image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest} environment: <<: *api-secret-environment APP_ENV: ${APP_ENV:-production-like} @@ -40,8 +38,8 @@ x-api-runtime: &api-runtime KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL:-http://keycloak:8080/auth} KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat} KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-han-chat-api} - MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL} - MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2 + MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-https://processing.internal:8443} + MESSAGE_SAFETY_API_PREFIX: ${MESSAGE_SAFETY_API_PREFIX:-/internal/safety/v2} MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5} MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2} @@ -65,8 +63,17 @@ x-api-runtime: &api-runtime source: ${MESSAGE_SAFETY_CA_HOST_PATH} target: /run/config/message-safety-internal-ca.pem read_only: true + extra_hosts: + - "${MESSAGE_SAFETY_EXTRA_HOST:?MESSAGE_SAFETY_EXTRA_HOST must map VM2 private DNS to private IP}" networks: [backend, observability, egress] + read_only: true + tmpfs: + - /tmp:size=128m,mode=1777 + cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] + pids_limit: 256 + mem_limit: 1g + cpus: 1.0 ulimits: core: {soft: 0, hard: 0} logging: @@ -96,9 +103,7 @@ x-sms-api-secrets: &sms-api-secrets - idgtl_sms_callback_password x-sms-runtime: &sms-runtime - build: - context: ../../sms-service - image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local} + image: ${SMS_SERVICE_IMAGE:?SMS_SERVICE_IMAGE must be pinned by digest} environment: *sms-api-environment secrets: *sms-api-secrets volumes: @@ -106,7 +111,14 @@ x-sms-runtime: &sms-runtime source: ${PG_CA_HOST_PATH} target: /run/secrets/pg-ca.pem read_only: true + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] + pids_limit: 128 + mem_limit: 512m + cpus: 0.5 ulimits: core: {soft: 0, hard: 0} logging: @@ -115,16 +127,7 @@ x-sms-runtime: &sms-runtime services: frontend-static: - build: - context: ../../frontend-test-site - target: static - args: - EXPO_PUBLIC_API_BASE_URL: ${PUBLIC_WEB_URL} - EXPO_PUBLIC_AUTH_BASE_URL: ${PUBLIC_AUTH_URL} - EXPO_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat} - EXPO_PUBLIC_KEYCLOAK_CLIENT_ID: han-chat-frontend - EXPO_PUBLIC_APP_ENV: ${APP_ENV:-production-like} - image: han-chat-frontend-static:${RELEASE_VERSION:-local} + image: ${FRONTEND_STATIC_IMAGE:?FRONTEND_STATIC_IMAGE must be pinned by digest} volumes: - frontend-static:/output restart: "no" @@ -133,13 +136,14 @@ services: - /tmp:size=8m,mode=1777 cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] + pids_limit: 64 + mem_limit: 128m + cpus: 0.25 ulimits: core: {soft: 0, hard: 0} keycloak: - build: - context: ../../keycloak - image: ${KEYCLOAK_IMAGE:-han-chat-keycloak:local} + image: ${KEYCLOAK_IMAGE:?KEYCLOAK_IMAGE must be pinned by digest} environment: HAN_SECRET_VARS: >- KC_DB_PASSWORD KC_BOOTSTRAP_ADMIN_PASSWORD @@ -164,6 +168,7 @@ services: KC_HEALTH_ENABLED: "true" KC_METRICS_ENABLED: "true" KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL} + PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?PUBLIC_WEB_URL is required for realm import} KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN} KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false} KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ${KEYCLOAK_YANDEX_CAPTCHA_ENABLED:-false} @@ -194,7 +199,12 @@ services: retries: 12 start_period: 60s restart: unless-stopped + cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] + # read_only is intentionally omitted until Keycloak runtime data/provider paths are externalized. + pids_limit: 512 + mem_limit: 2g + cpus: 2.0 ulimits: core: {soft: 0, hard: 0} logging: @@ -242,45 +252,12 @@ services: start_period: 10s restart: unless-stopped - message-safety: - build: - context: ../../message-safety - image: ${MESSAGE_SAFETY_IMAGE:-han-chat-message-safety:local} - environment: - HAN_SECRET_VARS: MESSAGE_SAFETY_REDIS_URL MESSAGE_SAFETY_SERVICE_TOKEN - MESSAGE_SAFETY_REDIS_URL_FILE: /run/secrets/message_safety_redis_url - MESSAGE_SAFETY_SERVICE_TOKEN_FILE: /run/secrets/message_safety_service_token - APP_ENV: ${APP_ENV:-production-like} - MESSAGE_SAFETY_RULES_VERSION: ${MESSAGE_SAFETY_RULES_VERSION:-2026-01-01} - MESSAGE_SAFETY_TASK_TTL_SEC: ${MESSAGE_SAFETY_TASK_TTL_SEC:-900} - secrets: - - message_safety_redis_url - - message_safety_service_token - expose: ["8080"] - networks: [backend, observability, egress] - depends_on: - redis: {condition: service_healthy} - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 30s - restart: unless-stopped - security_opt: ["no-new-privileges:true"] - ulimits: - core: {soft: 0, hard: 0} - logging: - driver: json-file - options: {max-size: "50m", max-file: "5"} - api-backend: <<: *api-runtime expose: ["8000"] depends_on: redis: {condition: service_healthy} keycloak: {condition: service_healthy} - message-safety: {condition: service_healthy} healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"] interval: 10s @@ -308,7 +285,6 @@ services: command: ["han-safety-worker"] depends_on: api-backend: {condition: service_healthy} - message-safety: {condition: service_healthy} healthcheck: test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-safety-worker' in Path('/proc/1/cmdline').read_bytes()"] interval: 30s @@ -348,7 +324,6 @@ services: command: ["han-notification-draft-cleanup-worker"] depends_on: api-backend: {condition: service_healthy} - message-safety: {condition: service_healthy} healthcheck: test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-notification-draft-cleanup-worker' in Path('/proc/1/cmdline').read_bytes()"] interval: 30s @@ -358,9 +333,7 @@ services: restart: unless-stopped bitrix-local-app: - build: - context: ../../bitrix-local-app - image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local} + image: ${BITRIX_LOCAL_APP_IMAGE:?BITRIX_LOCAL_APP_IMAGE must be pinned by digest} environment: HAN_SECRET_VARS: >- BITRIX_DATABASE_URL BITRIX_CLIENT_SECRET @@ -404,47 +377,20 @@ services: retries: 12 start_period: 30s restart: unless-stopped + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] + pids_limit: 128 + mem_limit: 512m + cpus: 0.5 ulimits: core: {soft: 0, hard: 0} logging: driver: json-file options: {max-size: "50m", max-file: "5"} - bitrix-sync: - build: - context: ../../bitrix-sync - image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local} - environment: - HAN_SECRET_VARS: BITRIX_SYNC_DATABASE_URL BITRIX_SYNC_SERVICE_TOKEN - BITRIX_SYNC_DATABASE_URL_FILE: /run/secrets/bitrix_sync_database_url - BITRIX_SYNC_SERVICE_TOKEN_FILE: /run/secrets/bitrix_sync_service_token - APP_ENV: ${APP_ENV:-production-like} - BITRIX_SYNC_ENABLED: ${BITRIX_SYNC_ENABLED:-false} - secrets: - - bitrix_sync_database_url - - bitrix_sync_service_token - expose: ["8080"] - volumes: - - type: bind - source: ${PG_CA_HOST_PATH} - target: /run/secrets/pg-ca.pem - read_only: true - networks: [backend, observability, egress] - depends_on: - redis: {condition: service_healthy} - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"] - interval: 15s - timeout: 5s - retries: 12 - start_period: 30s - restart: unless-stopped - security_opt: ["no-new-privileges:true"] - logging: - driver: json-file - options: {max-size: "50m", max-file: "5"} - secrets: api_database_url: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/DATABASE_URL @@ -476,8 +422,6 @@ secrets: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/IDGTL_SMS_CALLBACK_USERNAME idgtl_sms_callback_password: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/IDGTL_SMS_CALLBACK_PASSWORD - message_safety_redis_url: - file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/MESSAGE_SAFETY_REDIS_URL bitrix_database_url: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_DATABASE_URL bitrix_client_secret: @@ -490,10 +434,6 @@ secrets: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_API_FORWARD_TOKEN bitrix_token_encryption_key: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_TOKEN_ENCRYPTION_KEY - bitrix_sync_database_url: - file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_SYNC_DATABASE_URL - bitrix_sync_service_token: - file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_SYNC_SERVICE_TOKEN keycloak_db_password: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_DB_PASSWORD keycloak_admin_password: diff --git a/VM1_app/codebase/backend/keycloak/.env.example b/VM1_app/codebase/backend/keycloak/.env.example index 0bcb48b..7337bda 100644 --- a/VM1_app/codebase/backend/keycloak/.env.example +++ b/VM1_app/codebase/backend/keycloak/.env.example @@ -1,4 +1,5 @@ -KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth +PUBLIC_WEB_URL=https://chat.example.ru +KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.internal:6432/han_chat?sslmode=verify-full¤tSchema=keycloak&ApplicationName=keycloak KC_DB_URL_PROPERTIES=currentSchema=keycloak KC_BOOTSTRAP_ADMIN_USERNAME=bootstrap-admin diff --git a/VM1_app/codebase/backend/keycloak/Dockerfile b/VM1_app/codebase/backend/keycloak/Dockerfile index a6c18b8..004cece 100644 --- a/VM1_app/codebase/backend/keycloak/Dockerfile +++ b/VM1_app/codebase/backend/keycloak/Dockerfile @@ -7,6 +7,9 @@ RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline COPY src ./src COPY realm ./realm COPY themes ./themes +COPY container-entrypoint.sh . +RUN sed -i 's/\r$//' container-entrypoint.sh \ + && /bin/sh -n container-entrypoint.sh RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build @@ -21,7 +24,7 @@ RUN /opt/keycloak/bin/kc.sh build FROM quay.io/keycloak/keycloak:26.1.4 COPY --from=keycloak-build --chown=keycloak:keycloak /opt/keycloak/ /opt/keycloak/ COPY --chown=keycloak:keycloak realm/han-chat-realm.json /opt/keycloak/data/import/han-chat-realm.json -COPY --chown=keycloak:keycloak --chmod=0555 container-entrypoint.sh /opt/keycloak/bin/han-container-entrypoint +COPY --from=provider-build --chown=keycloak:keycloak --chmod=0555 /build/container-entrypoint.sh /opt/keycloak/bin/han-container-entrypoint USER 1000 EXPOSE 8080 9000 ENTRYPOINT ["/opt/keycloak/bin/han-container-entrypoint"] diff --git a/VM1_app/codebase/backend/keycloak/README.md b/VM1_app/codebase/backend/keycloak/README.md index 29dc8c9..7816458 100644 --- a/VM1_app/codebase/backend/keycloak/README.md +++ b/VM1_app/codebase/backend/keycloak/README.md @@ -45,13 +45,14 @@ The browser token is validated by Keycloak before `OtpFlow.start()`. A provider SmartCaptcha CSP is applied only by nginx to the `han-chat` login and login-action endpoints. Never set a custom `browserSecurityHeaders.contentSecurityPolicy` in the realm: it can break Keycloak Admin Console and third-party cookie iframes. -Before production deployment replace the explicit placeholder entries in `realm/han-chat-realm.json`: +Realm import derives browser redirect, logout and web-origin URLs from the +non-secret `PUBLIC_WEB_URL` environment variable. Keycloak resolves the +`${PUBLIC_WEB_URL}` placeholders in `realm/han-chat-realm.json` during the +initial `--import-realm`. -- `https://APP_LINK_HOST.example/auth/callback` -- `https://APP_LINK_HOST.example/auth/logout` -- `https://APP_WEB_ORIGIN.example` - -Use exact Expo universal/app links and web origins. Do not replace them with wildcards. `https://tohin.ru/auth/callback` and `han-chat://auth/callback` are already allow-listed. +Use exact Expo universal/app links and web origins. Do not replace them with +wildcards. `${PUBLIC_WEB_URL}/auth/callback` and `han-chat://auth/callback` are +allow-listed by the initial realm import. The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`. @@ -109,4 +110,7 @@ Reserve, SMS HTTP order, and activation/order-failure run as separate transactio Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path. -Backups must include the full Keycloak schema (realm signing keys and provider tables). After restore verify issuer `https://tohin.ru/auth/realms/han-chat`, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic. +Backups must include the full Keycloak schema (realm signing keys and provider +tables). After restore verify issuer +`${KEYCLOAK_PUBLIC_URL}/realms/han-chat`, JWKS, client redirects, browser flow +binding, challenge persistence and refresh revocation before opening traffic. diff --git a/VM1_app/codebase/backend/keycloak/docker-compose.yml b/VM1_app/codebase/backend/keycloak/docker-compose.yml index 6fef7bd..259cc24 100644 --- a/VM1_app/codebase/backend/keycloak/docker-compose.yml +++ b/VM1_app/codebase/backend/keycloak/docker-compose.yml @@ -21,7 +21,8 @@ services: KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required} KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME:?KEYCLOAK_DB_USERNAME is required} KC_DB_URL_PROPERTIES: ${KC_DB_URL_PROPERTIES:-currentSchema=keycloak} - KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-https://tohin.ru/auth} + KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:?KEYCLOAK_PUBLIC_URL is required} + PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?PUBLIC_WEB_URL is required for realm import} KC_HOSTNAME_STRICT: "true" KC_HTTP_ENABLED: "true" KC_HTTP_PORT: "8080" diff --git a/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json b/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json index a00200f..d6ed8e6 100644 --- a/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json +++ b/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json @@ -60,15 +60,15 @@ "frontchannelLogout": true, "fullScopeAllowed": false, "redirectUris": [ - "https://chat.han0107.ru/auth/callback", + "${PUBLIC_WEB_URL}/auth/callback", "han-chat://auth/callback" ], "webOrigins": [ - "https://chat.han0107.ru" + "${PUBLIC_WEB_URL}" ], "attributes": { "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "https://chat.han0107.ru/##han-chat://auth/logout", + "post.logout.redirect.uris": "${PUBLIC_WEB_URL}/##han-chat://auth/logout", "oauth2.device.authorization.grant.enabled": "false", "oidc.ciba.grant.enabled": "false", "use.refresh.tokens": "true", diff --git a/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java b/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java index edaee00..06b8cde 100644 --- a/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java +++ b/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java @@ -28,6 +28,9 @@ class RealmContractTest { assertTrue(realm.contains("\"refreshTokenMaxReuse\": 0")); assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]")); assertTrue(realm.contains("\"han-chat://auth/callback\"")); + assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/auth/callback\"")); + assertTrue(realm.contains("\"${PUBLIC_WEB_URL}\"")); + assertFalse(realm.contains("chat.han0107.ru")); } @Test diff --git a/VM1_app/codebase/backend/message-safety/Dockerfile b/VM1_app/codebase/backend/message-safety/Dockerfile deleted file mode 100644 index fd08b64..0000000 --- a/VM1_app/codebase/backend/message-safety/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.12-slim AS runtime -ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 -RUN addgroup --system app && adduser --system --ingroup app app -WORKDIR /service -COPY app ./app -COPY pyproject.toml ./ -RUN pip install --no-cache-dir . -COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint -USER app -EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)" -ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"] -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/VM1_app/codebase/backend/message-safety/app/__init__.py b/VM1_app/codebase/backend/message-safety/app/__init__.py deleted file mode 100644 index 890e9c9..0000000 --- a/VM1_app/codebase/backend/message-safety/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""HAN message-safety service.""" diff --git a/VM1_app/codebase/backend/message-safety/app/main.py b/VM1_app/codebase/backend/message-safety/app/main.py deleted file mode 100644 index 22ad52e..0000000 --- a/VM1_app/codebase/backend/message-safety/app/main.py +++ /dev/null @@ -1,334 +0,0 @@ -from __future__ import annotations - -import hashlib -import hmac -import json -import random -import unicodedata -import uuid -from contextlib import asynccontextmanager -from datetime import UTC, datetime, timedelta -from typing import Annotated, Any, Literal, Protocol - -import redis.asyncio as redis -import uvicorn -from fastapi import Depends, FastAPI, Header, HTTPException, Request -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse -from pydantic import BaseModel, ConfigDict, Field, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - model_config = SettingsConfigDict(extra="ignore") - app_env: str = "production-like" - message_safety_redis_url: str = "redis://redis:6379/2" - message_safety_service_token: str = Field(min_length=16) - message_safety_rules_version: str = "2026-01-01" - message_safety_task_ttl_sec: int = Field(default=900, ge=330) - message_safety_poll_after_ms: int = Field(default=2000, ge=100, le=30000) - safety_stub_worker_mode: Literal["emulated_on_poll"] = "emulated_on_poll" - safety_stub_rng_seed: int | None = None - - @model_validator(mode="after") - def forbid_seed_outside_tests(self) -> Settings: - if self.safety_stub_rng_seed is not None and self.app_env != "test": - raise ValueError("SAFETY_STUB_RNG_SEED is allowed only when APP_ENV=test") - return self - - -class Attachment(BaseModel): - model_config = ConfigDict(extra="forbid") - attachment_id: uuid.UUID - quarantine_object_key: str = Field(min_length=1, max_length=1024) - mime_type: str = Field(min_length=1, max_length=255) - size_bytes: int = Field(ge=0, le=10 * 1024 * 1024) - checksum: str = Field(pattern=r"^sha256:[0-9a-fA-F]{64}$") - - -class CheckRequest(BaseModel): - model_config = ConfigDict(extra="forbid") - message_id: uuid.UUID - content_kind: Literal["text", "file"] - text: str = Field(default="", max_length=10000) - attachment: Attachment | None = None - - @model_validator(mode="after") - def validate_kind(self) -> CheckRequest: - if self.content_kind == "file" and self.attachment is None: - raise ValueError("attachment is required for file content") - if self.content_kind == "text" and self.attachment is not None: - raise ValueError("attachment is forbidden for text content") - return self - - -class TaskStore(Protocol): - async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str: ... - async def poll(self, task_id: str) -> int | None: ... - async def ready(self) -> bool: ... - async def close(self) -> None: ... - - -class RedisTaskStore: - _reserve_lua = """ -local prior = redis.call('GET', KEYS[1]) -if prior then - local sep = string.find(prior, '|', 1, true) - local old_fp = string.sub(prior, 1, sep - 1) - if old_fp ~= ARGV[1] then return {'conflict'} end - return {'existing', string.sub(prior, sep + 1)} -end -redis.call('HSET', KEYS[2], 'schema_version', '1', 'message_id', ARGV[2], - 'created_at_ms', ARGV[3], 'poll_count', '0', 'rules_version', ARGV[5]) -redis.call('EXPIRE', KEYS[2], ARGV[4]) -redis.call('SET', KEYS[1], ARGV[1] .. '|' .. ARGV[6], 'EX', ARGV[4]) -return {'created', ARGV[6]} -""" - _poll_lua = """ -if redis.call('EXISTS', KEYS[1]) == 0 then return nil end -return redis.call('HINCRBY', KEYS[1], 'poll_count', 1) -""" - - def __init__(self, client: redis.Redis, rules_version: str) -> None: - self.client = client - self.rules_version = rules_version - - async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str: - result = await self.client.eval( - self._reserve_lua, - 2, - f"han:safety:task-by-message:{message_id}", - f"han:safety:task:{task_id}", - fingerprint, - message_id, - str(int(datetime.now(UTC).timestamp() * 1000)), - str(ttl), - self.rules_version, - task_id, - ) - status = _decode(result[0]) - if status == "conflict": - raise ValueError("conflict") - return _decode(result[1]) - - async def poll(self, task_id: str) -> int | None: - key = f"han:safety:task:{task_id}" - count = await self.client.eval(self._poll_lua, 1, key) - return int(count) if count is not None else None - - async def ready(self) -> bool: - key = f"han:safety:ready:{uuid.uuid4()}" - try: - await self.client.set(key, "1", ex=5) - return await self.client.get(key) == b"1" - finally: - await self.client.delete(key) - - async def close(self) -> None: - await self.client.aclose() - - -def _decode(value: Any) -> str: - return value.decode() if isinstance(value, bytes) else str(value) - - -def normalize(text: str) -> str: - return unicodedata.normalize("NFKC", text.replace("\r\n", "\n").replace("\r", "\n")).lstrip() - - -def fingerprint(dto: CheckRequest) -> str: - body = dto.model_dump(mode="json") - body["text"] = normalize(dto.text) - encoded = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() - return hashlib.sha256(encoded).hexdigest() - - -def error(code: str, message: str, request_id: str, details: dict[str, Any] | None = None) -> dict: - return { - "error": { - "code": code, - "message": message, - "request_id": request_id, - "details": details or {}, - } - } - - -def create_app( - settings: Settings | None = None, - store: TaskStore | None = None, - rng: random.Random | None = None, -) -> FastAPI: - cfg = settings or Settings() - verdict_rng = rng or random.Random(cfg.safety_stub_rng_seed) - - @asynccontextmanager - async def lifespan(app: FastAPI): - app.state.store = store or RedisTaskStore( - redis.from_url(cfg.message_safety_redis_url, decode_responses=False), - cfg.message_safety_rules_version, - ) - yield - await app.state.store.close() - - app = FastAPI( - title="HAN Message Safety", - version="1.0.0", - lifespan=lifespan, - docs_url=None if cfg.app_env != "test" else "/docs", - ) - app.state.settings = cfg - - @app.middleware("http") - async def request_id_middleware(request: Request, call_next): - request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) - response = await call_next(request) - response.headers["X-Request-ID"] = request.state.request_id - return response - - def authorize( - request: Request, - token: Annotated[str | None, Header(alias="X-Service-Token")] = None, - ) -> None: - if token is None or not hmac.compare_digest(token, cfg.message_safety_service_token): - raise HTTPException( - 401, - error( - "service_unauthorized", - "Service authentication failed", - request.state.request_id, - ), - ) - - @app.exception_handler(HTTPException) - async def http_error(_: Request, exc: HTTPException): - return JSONResponse(status_code=exc.status_code, content=exc.detail) - - @app.exception_handler(RequestValidationError) - async def validation_error(request: Request, _: RequestValidationError): - return JSONResponse( - error("validation_error", "Request is invalid", request.state.request_id), - status_code=400, - ) - - @app.get("/health/live") - async def live() -> dict[str, str]: - return {"status": "live"} - - @app.get("/health/ready") - async def ready(request: Request): - try: - ok = await request.app.state.store.ready() - except Exception: - ok = False - body = { - "status": "ready" if ok else "not_ready", - "components": {"redis": "ok" if ok else "down"}, - } - return JSONResponse(body, status_code=200 if ok else 503) - - @app.post("/internal/safety/v1/messages/check", dependencies=[Depends(authorize)]) - async def check(dto: CheckRequest, request: Request): - text = normalize(dto.text) - common = {"rules_version": cfg.message_safety_rules_version} - if text and text[0] in {"ф", "Ф"}: - return JSONResponse( - { - "verdict": "deny", - "rule_id": "stub.starts_with_cyrillic_ef", - "reason_code": "stub_blocked", - **common, - }, - status_code=403, - ) - if text and unicodedata.category(text[0]) == "Nd": - task_id = str(uuid.uuid4()) - try: - task_id = await request.app.state.store.reserve( - str(dto.message_id), fingerprint(dto), task_id, cfg.message_safety_task_ttl_sec - ) - except ValueError: - return JSONResponse( - error( - "safety_request_conflict", "message_id was reused", request.state.request_id - ), - status_code=409, - ) - except Exception: - return JSONResponse( - error( - "redis_unavailable", "Task storage is unavailable", request.state.request_id - ), - status_code=503, - ) - return JSONResponse( - { - "verdict": "pending", - "task_id": task_id, - "poll_after_ms": cfg.message_safety_poll_after_ms, - "expires_at": ( - datetime.now(UTC) + timedelta(seconds=cfg.message_safety_task_ttl_sec) - ) - .isoformat() - .replace("+00:00", "Z"), - **common, - }, - status_code=203, - ) - return {"verdict": "allow", "rule_id": "stub.default_allow", **common} - - @app.get("/internal/safety/v1/messages/tasks/{task_id}", dependencies=[Depends(authorize)]) - async def task(task_id: str, request: Request): - try: - parsed = str(uuid.UUID(task_id)) - except ValueError: - return JSONResponse( - error("validation_error", "Request is invalid", request.state.request_id), - status_code=400, - ) - try: - count = await request.app.state.store.poll(parsed) - except Exception: - return JSONResponse( - error("redis_unavailable", "Task storage is unavailable", request.state.request_id), - status_code=503, - ) - if count is None: - return JSONResponse( - error("task_not_found", "Task was not found", request.state.request_id), - status_code=404, - ) - outcome = verdict_rng.choice(("pending", "allow", "final_error")) - if outcome == "pending": - return JSONResponse( - { - "verdict": "pending", - "task_id": parsed, - "poll_after_ms": cfg.message_safety_poll_after_ms, - }, - status_code=203, - ) - if outcome == "allow": - return {"verdict": "allow", "task_id": parsed, "rule_id": "stub.random_allow"} - return JSONResponse( - { - "verdict": "deny", - "task_id": parsed, - **error( - "stub_final_error", - "Stub task returned a final negative verdict", - request.state.request_id, - {"terminal": True}, - ), - }, - status_code=400, - ) - - return app - - -app = create_app() - - -def run() -> None: - uvicorn.run("app.main:app", host="0.0.0.0", port=8080) diff --git a/VM1_app/codebase/backend/message-safety/container-entrypoint.sh b/VM1_app/codebase/backend/message-safety/container-entrypoint.sh deleted file mode 100644 index 7e6f3ff..0000000 --- a/VM1_app/codebase/backend/message-safety/container-entrypoint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -set -eu - -for name in ${HAN_SECRET_VARS:-}; do - case "$name" in - ""|[0-9]*|*[!A-Z0-9_]*) - echo "container secrets: invalid variable name" >&2 - exit 64 - ;; - *) ;; - esac - eval "file=\${${name}_FILE:-}" - if [ -z "$file" ] || [ ! -r "$file" ]; then - echo "container secrets: missing file for $name" >&2 - exit 66 - fi - value=$(cat "$file") - export "$name=$value" - unset "${name}_FILE" -done - -unset HAN_SECRET_VARS -exec "$@" diff --git a/VM1_app/codebase/backend/message-safety/openapi.yaml b/VM1_app/codebase/backend/message-safety/openapi.yaml deleted file mode 100644 index 917f282..0000000 --- a/VM1_app/codebase/backend/message-safety/openapi.yaml +++ /dev/null @@ -1,58 +0,0 @@ -openapi: 3.1.0 -info: {title: HAN Message Safety, version: 1.0.0} -paths: - /health/live: - get: {responses: {"200": {description: Live}}} - /health/ready: - get: {responses: {"200": {description: Ready}, "503": {description: Redis unavailable}}} - /internal/safety/v1/messages/check: - post: - security: [{ServiceToken: []}] - parameters: [{$ref: "#/components/parameters/RequestId"}] - requestBody: - required: true - content: - application/json: - schema: {$ref: "#/components/schemas/CheckRequest"} - responses: - "200": {description: Allow} - "203": {description: Pending} - "403": {description: Deny} - "409": {description: Conflicting message id} - "503": {description: Redis unavailable} - /internal/safety/v1/messages/tasks/{task_id}: - get: - security: [{ServiceToken: []}] - parameters: - - {name: task_id, in: path, required: true, schema: {type: string, format: uuid}} - - {$ref: "#/components/parameters/RequestId"} - responses: - "200": {description: Allow} - "203": {description: Pending} - "400": {description: Validation error or terminal stub rejection} - "404": {description: Task not found} -components: - securitySchemes: - ServiceToken: {type: apiKey, in: header, name: X-Service-Token} - parameters: - RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}} - schemas: - Attachment: - type: object - additionalProperties: false - required: [attachment_id, quarantine_object_key, mime_type, size_bytes, checksum] - properties: - attachment_id: {type: string, format: uuid} - quarantine_object_key: {type: string, maxLength: 1024} - mime_type: {type: string, maxLength: 255} - size_bytes: {type: integer, minimum: 0} - checksum: {type: string, pattern: "^sha256:[0-9a-fA-F]{64}$"} - CheckRequest: - type: object - additionalProperties: false - required: [message_id, content_kind] - properties: - message_id: {type: string, format: uuid} - content_kind: {type: string, enum: [text, file]} - text: {type: string, maxLength: 10000, default: ""} - attachment: {anyOf: [{$ref: "#/components/schemas/Attachment"}, {type: "null"}]} diff --git a/VM1_app/codebase/backend/message-safety/pyproject.toml b/VM1_app/codebase/backend/message-safety/pyproject.toml deleted file mode 100644 index c811770..0000000 --- a/VM1_app/codebase/backend/message-safety/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[project] -name = "han-message-safety" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [ - "fastapi>=0.116,<1", - "pydantic-settings>=2.10,<3", - "redis>=6,<7", - "uvicorn[standard]>=0.35,<1", -] - -[project.optional-dependencies] -dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["app"] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] - -[tool.ruff] -target-version = "py312" -line-length = 100 diff --git a/VM1_app/codebase/backend/message-safety/tests/test_service.py b/VM1_app/codebase/backend/message-safety/tests/test_service.py deleted file mode 100644 index 8a029b0..0000000 --- a/VM1_app/codebase/backend/message-safety/tests/test_service.py +++ /dev/null @@ -1,103 +0,0 @@ -import os -import random -import uuid - -os.environ.setdefault("MESSAGE_SAFETY_SERVICE_TOKEN", "test-service-token-32-characters") - -import httpx -import pytest - -from app.main import Settings, create_app, normalize - - -class Store: - def __init__(self): - self.tasks = {} - - async def reserve(self, message_id, fingerprint, task_id, ttl): - prior = self.tasks.get(message_id) - if prior and prior[0] != fingerprint: - raise ValueError("conflict") - if prior: - return prior[1] - self.tasks[message_id] = (fingerprint, task_id) - return task_id - - async def poll(self, task_id): - return 1 if any(value[1] == task_id for value in self.tasks.values()) else None - - async def ready(self): - return True - - async def close(self): - pass - - -class SequenceRandom(random.Random): - def __init__(self): - self.values = iter(("pending", "allow", "final_error")) - - def choice(self, _): - return next(self.values) - - -@pytest.mark.asyncio -async def test_rules_auth_and_independent_poll(): - settings = Settings( - app_env="test", - message_safety_service_token="test-service-token-32-characters", - ) - app = create_app(settings, Store(), SequenceRandom()) - headers = {"X-Service-Token": settings.message_safety_service_token} - message_id = str(uuid.uuid4()) - async with app.router.lifespan_context(app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - assert ( - await client.post( - "/internal/safety/v1/messages/check", - json={ - "message_id": str(uuid.uuid4()), - "content_kind": "text", - "text": " Файл", - }, - headers=headers, - ) - ).status_code == 403 - pending = await client.post( - "/internal/safety/v1/messages/check", - json={"message_id": message_id, "content_kind": "text", "text": "\u00a07 дней"}, - headers=headers, - ) - assert pending.status_code == 203 - task_id = pending.json()["task_id"] - assert [ - ( - await client.get( - f"/internal/safety/v1/messages/tasks/{task_id}", headers=headers - ) - ).status_code - for _ in range(3) - ] == [203, 200, 400] - assert ( - await client.post( - "/internal/safety/v1/messages/check", - json={ - "message_id": str(uuid.uuid4()), - "content_kind": "text", - "text": "документ", - }, - headers=headers, - ) - ).status_code == 200 - assert ( - await client.post( - "/internal/safety/v1/messages/check", - json={"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "ok"}, - ) - ).status_code == 401 - - -def test_normalization(): - assert normalize("\r\n\u00a07 дней") == "7 дней" diff --git a/VM1_app/codebase/backend/nginx/Dockerfile b/VM1_app/codebase/backend/nginx/Dockerfile index 8f3123c..70ca8e7 100644 --- a/VM1_app/codebase/backend/nginx/Dockerfile +++ b/VM1_app/codebase/backend/nginx/Dockerfile @@ -9,6 +9,7 @@ COPY templates /etc/nginx/templates-src/sites COPY snippets /etc/nginx/snippets COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint RUN sed -i 's/\r$//' /usr/local/bin/han-nginx-entrypoint \ + && /bin/sh -n /usr/local/bin/han-nginx-entrypoint \ && chmod 0555 /usr/local/bin/han-nginx-entrypoint \ && find /etc/nginx/templates-src /etc/nginx/snippets -type f -exec chmod 0444 {} + ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"] diff --git a/VM1_app/codebase/backend/nginx/docker-compose.yml b/VM1_app/codebase/backend/nginx/docker-compose.yml index b9514c1..8ffd224 100644 --- a/VM1_app/codebase/backend/nginx/docker-compose.yml +++ b/VM1_app/codebase/backend/nginx/docker-compose.yml @@ -1,8 +1,6 @@ services: nginx: - build: - context: . - image: han-chat-nginx:${RELEASE_VERSION:-local} + image: ${NGINX_IMAGE:?NGINX_IMAGE must be pinned by digest} environment: APP_ENV: ${APP_ENV:-production-like} PUBLIC_HOST: ${PUBLIC_HOST} @@ -33,8 +31,8 @@ services: - "${NGINX_HTTPS_PORT:-443}:443" expose: ["8080"] volumes: - - nginx-certs:/etc/letsencrypt:ro - - nginx-acme-webroot:/var/www/certbot:ro + - /var/lib/han-chat/public-tls:/run/tls:ro + - /var/lib/han-chat/acme:/var/www/certbot:ro - frontend-static:/usr/share/nginx/html:ro - nginx-cache:/var/cache/nginx networks: [public, backend] @@ -44,7 +42,6 @@ services: keycloak: {condition: service_healthy} sms-service: {condition: service_healthy} bitrix-local-app: {condition: service_healthy} - bitrix-sync: {condition: service_healthy} healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/nginx-health/live || curl -fsS http://127.0.0.1/nginx-health/live"] interval: 10s @@ -58,6 +55,9 @@ services: cap_drop: ["ALL"] cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"] security_opt: ["no-new-privileges:true"] + pids_limit: 256 + mem_limit: 512m + cpus: 1.0 extra_hosts: - "host.docker.internal:host-gateway" ulimits: @@ -65,13 +65,3 @@ services: logging: driver: json-file options: {max-size: "50m", max-file: "5"} - - certbot: - image: certbot/certbot:v3.1.0 - profiles: ["certbot"] - volumes: - - nginx-certs:/etc/letsencrypt - - nginx-acme-webroot:/var/www/certbot - networks: [public] - security_opt: ["no-new-privileges:true"] - cap_drop: ["ALL"] diff --git a/VM1_app/codebase/backend/nginx/nginx.conf.template b/VM1_app/codebase/backend/nginx/nginx.conf.template index c307d69..d778b98 100644 --- a/VM1_app/codebase/backend/nginx/nginx.conf.template +++ b/VM1_app/codebase/backend/nginx/nginx.conf.template @@ -68,7 +68,6 @@ http { upstream keycloak_upstream { server keycloak:8080; keepalive 16; } upstream sms_service_upstream { server sms-service:8080; keepalive 8; } upstream bitrix_local { server bitrix-local-app:8080; keepalive 16; } - upstream bitrix_sync_upstream { server bitrix-sync:8080; keepalive 8; } upstream frontend_dev { server ${EXPO_DEV_SERVER_HOSTPORT}; keepalive 8; } include /etc/nginx/generated/site.conf; diff --git a/VM1_app/codebase/backend/observability/docker-compose.yml b/VM1_app/codebase/backend/observability/docker-compose.yml index 010e38d..d4670f1 100644 --- a/VM1_app/codebase/backend/observability/docker-compose.yml +++ b/VM1_app/codebase/backend/observability/docker-compose.yml @@ -1,18 +1,22 @@ services: otel-queue-init: - image: alpine:3.21.2 + image: ${OTEL_QUEUE_INIT_IMAGE:?OTEL_QUEUE_INIT_IMAGE must be pinned by digest} command: ["sh", "-c", "chown 10001:10001 /queue && chmod 0700 /queue"] volumes: - otel-queue:/queue networks: [observability] restart: "no" read_only: true + tmpfs: ["/tmp:size=8m,mode=1777"] cap_drop: ["ALL"] cap_add: ["CHOWN", "FOWNER"] security_opt: ["no-new-privileges:true"] + pids_limit: 32 + mem_limit: 32m + cpus: 0.25 otel-collector: - image: otel/opentelemetry-collector-contrib:0.117.0 + image: ${OTEL_COLLECTOR_IMAGE:?OTEL_COLLECTOR_IMAGE must be pinned by digest} command: ["--config=/etc/otelcol/config.yaml"] environment: APP_ENV: ${APP_ENV:-production-like} @@ -41,6 +45,7 @@ services: security_opt: ["no-new-privileges:true"] ulimits: core: {soft: 0, hard: 0} + pids_limit: 128 mem_limit: 512m cpus: 0.5 logging: @@ -48,7 +53,7 @@ services: options: {max-size: "50m", max-file: "5"} otel-collector-local: - image: otel/opentelemetry-collector-contrib:0.117.0 + image: ${OTEL_COLLECTOR_IMAGE:?OTEL_COLLECTOR_IMAGE must be pinned by digest} profiles: ["observability-local"] command: ["--config=/etc/otelcol/config.yaml"] expose: ["4317", "4318", "13133"] diff --git a/VM1_app/codebase/backend/redis/Dockerfile b/VM1_app/codebase/backend/redis/Dockerfile index e7a0964..6e677ea 100644 --- a/VM1_app/codebase/backend/redis/Dockerfile +++ b/VM1_app/codebase/backend/redis/Dockerfile @@ -5,6 +5,7 @@ COPY redis.conf /etc/han-redis/redis.conf COPY users.acl.template /etc/han-redis/users.acl.template COPY scripts/entrypoint.sh /usr/local/lib/han/entrypoint.sh RUN sed -i 's/\r$//' /usr/local/lib/han/entrypoint.sh \ + && /bin/sh -n /usr/local/lib/han/entrypoint.sh \ && chmod 0555 /usr/local/lib/han/entrypoint.sh \ && chmod 0444 /etc/han-redis/* USER redis diff --git a/VM1_app/codebase/backend/redis/docker-compose.yml b/VM1_app/codebase/backend/redis/docker-compose.yml index 5c395bf..01d87e7 100644 --- a/VM1_app/codebase/backend/redis/docker-compose.yml +++ b/VM1_app/codebase/backend/redis/docker-compose.yml @@ -1,14 +1,11 @@ services: redis: - build: - context: . - image: han-chat-redis:${RELEASE_VERSION:-local} + image: ${REDIS_IMAGE:?REDIS_IMAGE must be pinned by digest} environment: REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-384mb} REDIS_EVICTION_POLICY: ${REDIS_EVICTION_POLICY:-volatile-lru} secrets: - redis_api_password - - redis_safety_password - redis_health_password expose: ["6379"] volumes: @@ -27,7 +24,9 @@ services: - /tmp:size=8m,mode=1777 security_opt: ["no-new-privileges:true"] cap_drop: ["ALL"] + pids_limit: 128 mem_limit: 512m + cpus: 0.5 ulimits: core: {soft: 0, hard: 0} nofile: {soft: 65536, hard: 65536} @@ -38,7 +37,5 @@ services: secrets: redis_api_password: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_API_PASSWORD - redis_safety_password: - file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_SAFETY_PASSWORD redis_health_password: file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_HEALTH_PASSWORD diff --git a/VM1_app/codebase/backend/redis/scripts/entrypoint.sh b/VM1_app/codebase/backend/redis/scripts/entrypoint.sh index fd8256d..dc701c6 100644 --- a/VM1_app/codebase/backend/redis/scripts/entrypoint.sh +++ b/VM1_app/codebase/backend/redis/scripts/entrypoint.sh @@ -3,7 +3,6 @@ set -eu for item in \ REDIS_API_PASSWORD:redis_api_password \ - REDIS_SAFETY_PASSWORD:redis_safety_password \ REDIS_HEALTH_PASSWORD:redis_health_password do name=${item%%:*} @@ -21,9 +20,9 @@ do done umask 077 -envsubst '${REDIS_API_PASSWORD} ${REDIS_SAFETY_PASSWORD} ${REDIS_HEALTH_PASSWORD}' \ +envsubst '${REDIS_API_PASSWORD} ${REDIS_HEALTH_PASSWORD}' \ < /etc/han-redis/users.acl.template > /tmp/users.acl -unset REDIS_API_PASSWORD REDIS_SAFETY_PASSWORD REDIS_HEALTH_PASSWORD +unset REDIS_API_PASSWORD REDIS_HEALTH_PASSWORD exec redis-server /etc/han-redis/redis.conf \ --aclfile /tmp/users.acl \ diff --git a/VM1_app/codebase/backend/redis/users.acl.template b/VM1_app/codebase/backend/redis/users.acl.template index e6723ec..e91c5c5 100644 --- a/VM1_app/codebase/backend/redis/users.acl.template +++ b/VM1_app/codebase/backend/redis/users.acl.template @@ -1,4 +1,3 @@ user default off user api_backend on >${REDIS_API_PASSWORD} ~han:api:* ~han:rt:* ~han:coord:* &han:rt:* +@read +@write +@connection +@scripting +publish +subscribe +psubscribe -flushall -flushdb -config -module -keys -user message_safety on >${REDIS_SAFETY_PASSWORD} ~han:safety:* +@read +@write +@connection +@scripting -flushall -flushdb -config -module -keys -publish -subscribe -psubscribe user ops_health on >${REDIS_HEALTH_PASSWORD} +ping +info +client|id -select diff --git a/VM1_app/codebase/backend/scripts/validate-env b/VM1_app/codebase/backend/scripts/validate-env index 2065e6d..b337b1e 100644 --- a/VM1_app/codebase/backend/scripts/validate-env +++ b/VM1_app/codebase/backend/scripts/validate-env @@ -2,6 +2,7 @@ """Validate public deployment config separately from runtime secrets.""" from __future__ import annotations +import ipaddress import os import re import stat @@ -16,15 +17,16 @@ REQUIRED_CONFIG = { "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "KEYCLOAK_REALM", "KEYCLOAK_SMS_SERVICE_URL", "IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL", + "MESSAGE_SAFETY_URL", "MESSAGE_SAFETY_EXTRA_HOST", + "MESSAGE_SAFETY_CA_HOST_PATH", "MESSAGE_SAFETY_API_PREFIX", } REQUIRED_RUNTIME = { - "DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL", + "DATABASE_URL", "BITRIX_DATABASE_URL", "SMS_DATABASE_URL", "KEYCLOAK_DB_PASSWORD", "REDIS_URL", - "REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL", "REDIS_API_PASSWORD", - "REDIS_SAFETY_PASSWORD", "REDIS_HEALTH_PASSWORD", + "REDIS_REALTIME_URL", "REDIS_API_PASSWORD", "REDIS_HEALTH_PASSWORD", "MESSAGE_SAFETY_SERVICE_TOKEN", "BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN", - "BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN", + "BITRIX_API_INBOX_TOKEN", "KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY", "KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN", "IDGTL_SMS_API_KEY", "IDGTL_SMS_CALLBACK_USERNAME", @@ -34,18 +36,15 @@ REQUIRED_RUNTIME = { } OPTIONAL_SECRET_KEYS = { "BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN", - "BITRIX_SYNC_CRM_WEBHOOK_URL", "BITRIX_SYNC_WEBHOOK_TOKEN", "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", "NOTIFICATIONS_TOKEN_PRODUCER_TEST", "OTEL_REMOTE_AUTH_HEADER", "KEYCLOAK_OTP_MOCK_CODE", } FORBIDDEN_CONFIG_KEYS = REQUIRED_RUNTIME | OPTIONAL_SECRET_KEYS | { - "BITRIX_SYNC_APP_DATABASE_URL", "PG_BACKUP_DSN", + "PG_BACKUP_DSN", } DSN_KEYS = { - "DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_APP_DATABASE_URL", - "BITRIX_SYNC_DATABASE_URL", "MESSAGE_SAFETY_DATABASE_URL", - "SMS_DATABASE_URL", "PG_BACKUP_DSN", + "DATABASE_URL", "BITRIX_DATABASE_URL", "SMS_DATABASE_URL", "PG_BACKUP_DSN", } PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I) @@ -148,7 +147,6 @@ def validate_runtime(config: dict[str, str], runtime: dict[str, str], errors: li redis_contract = ( ("REDIS_URL", "api_backend", "REDIS_API_PASSWORD", "/0"), ("REDIS_REALTIME_URL", "api_backend", "REDIS_API_PASSWORD", "/1"), - ("MESSAGE_SAFETY_REDIS_URL", "message_safety", "REDIS_SAFETY_PASSWORD", "/2"), ) for key, username, password_key, database in redis_contract: parsed = urlparse(runtime.get(key, "")) @@ -180,23 +178,47 @@ def validate_shared(env: dict[str, str], errors: list[str]) -> None: ): if env.get(key) and urlparse(env[key]).scheme != "https": errors.append(f"{key}: публичный URL должен использовать https") + public_origin = f"https://{env.get('PUBLIC_HOST', '')}" + expected_public_urls = { + "PUBLIC_WEB_URL": public_origin, + "PUBLIC_API_URL": f"{public_origin}/api", + "PUBLIC_AUTH_URL": f"{public_origin}/auth", + "KEYCLOAK_PUBLIC_URL": f"{public_origin}/auth", + } + for key, expected in expected_public_urls.items(): + if env.get(key, "").rstrip("/") != expected: + errors.append(f"{key}: должен быть производным от PUBLIC_HOST ({expected})") for key in ( "KEYCLOAK_INTERNAL_URL", "KEYCLOAK_SMS_SERVICE_URL", - "MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL", + "BITRIX_LOCAL_APP_BASE_URL", ): parsed = urlparse(env.get(key, "")) if parsed.scheme != "http" or "." in (parsed.hostname or ""): errors.append(f"{key}: ожидается http URL с Docker DNS service name") if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"): errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth") + if env.get("MESSAGE_SAFETY_URL", "").rstrip("/") != "https://processing.internal:8443": + errors.append( + "MESSAGE_SAFETY_URL: ожидается remote TLS endpoint " + "https://processing.internal:8443" + ) + try: + extra_host, extra_ip = env.get("MESSAGE_SAFETY_EXTRA_HOST", "").rsplit("=", 1) + safety_host = urlparse(env.get("MESSAGE_SAFETY_URL", "")).hostname + address = ipaddress.ip_address(extra_ip) + if extra_host != safety_host or not address.is_private: + raise ValueError + except ValueError: + errors.append( + "MESSAGE_SAFETY_EXTRA_HOST: ожидается =" + ) + if env.get("MESSAGE_SAFETY_API_PREFIX") != "/internal/safety/v2": + errors.append("MESSAGE_SAFETY_API_PREFIX: ожидается /internal/safety/v2") try: poll = int(env.get("MESSAGE_SAFETY_TASK_POLL_MAX_SEC", "0")) nginx = int(env.get("NGINX_MESSAGE_READ_TIMEOUT_SEC", "0")) - ttl = int(env.get("MESSAGE_SAFETY_TASK_TTL_SEC", "0")) if nginx < poll + 30: errors.append("NGINX_MESSAGE_READ_TIMEOUT_SEC должен быть >= poll max + 30") - if ttl <= poll + 60: - errors.append("MESSAGE_SAFETY_TASK_TTL_SEC должен превышать poll max + recovery margin") except ValueError: errors.append("Safety/NGINX timeout values должны быть целыми числами") if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true": diff --git a/VM1_app/codebase/backend/sms-service/Dockerfile b/VM1_app/codebase/backend/sms-service/Dockerfile index 1843d28..584b774 100644 --- a/VM1_app/codebase/backend/sms-service/Dockerfile +++ b/VM1_app/codebase/backend/sms-service/Dockerfile @@ -14,6 +14,8 @@ RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl COPY alembic.ini ./ COPY migrations ./migrations COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint +RUN sed -i 's/\r$//' /usr/local/bin/han-container-entrypoint \ + && /bin/sh -n /usr/local/bin/han-container-entrypoint USER 10001:10001 EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"] diff --git a/VM1_app/codebase/backend/tests/test_config.py b/VM1_app/codebase/backend/tests/test_config.py index ff7e64d..e3bd2a9 100644 --- a/VM1_app/codebase/backend/tests/test_config.py +++ b/VM1_app/codebase/backend/tests/test_config.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +import re import subprocess import sys import tempfile @@ -11,6 +12,44 @@ ROOT = Path(__file__).resolve().parents[1] class InfrastructureConfigTests(unittest.TestCase): + def test_migrations_pass_one_top_level_statement_per_execute(self) -> None: + migration_roots = ( + ROOT / "api-backend/alembic/versions", + ROOT / "bitrix-local-app/alembic/versions", + ROOT / "sms-service/migrations/versions", + ) + dollar_quoted = re.compile( + r"\$\$.*?\$\$|\$(?P[A-Za-z_][A-Za-z0-9_]*)\$.*?\$(?P=tag)\$", + re.DOTALL, + ) + single_quoted = re.compile(r"'(?:''|[^'])*'", re.DOTALL) + + for migration_root in migration_roots: + for migration in migration_root.glob("*.py"): + tree = ast.parse( + migration.read_text(encoding="utf-8"), + filename=str(migration), + ) + for call in ast.walk(tree): + if not ( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "execute" + and call.args + and isinstance(call.args[0], ast.Constant) + and isinstance(call.args[0].value, str) + ): + continue + sql = dollar_quoted.sub("DOLLAR_QUOTED_BODY", call.args[0].value) + sql = single_quoted.sub("STRING_LITERAL", sql) + sql = re.sub(r"--[^\n]*|/\*.*?\*/", "", sql, flags=re.DOTALL) + statements = [part for part in sql.split(";") if part.strip()] + self.assertLessEqual( + len(statements), + 1, + f"{migration}:{call.lineno} passes multiple SQL commands to execute", + ) + def test_structlog_event_is_not_passed_twice(self) -> None: log_methods = {"debug", "info", "warning", "error", "exception", "critical"} for source_path in (ROOT / "api-backend/app").rglob("*.py"): @@ -55,7 +94,7 @@ class InfrastructureConfigTests(unittest.TestCase): ) jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8") - self.assertEqual(jobs.count("networks: [backend, egress]"), 5) + self.assertEqual(jobs.count("networks: [backend, egress]"), 4) observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8") self.assertIn("networks: [observability, egress]", observability) @@ -79,6 +118,107 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertIn('NGINX_HTTP_PORT:-80}:80', nginx) self.assertIn('NGINX_HTTPS_PORT:-443}:443', nginx) + def test_production_compose_uses_only_required_digest_images(self) -> None: + image_variables = ( + "API_BACKEND_IMAGE", + "BITRIX_LOCAL_APP_IMAGE", + "FRONTEND_STATIC_IMAGE", + "KEYCLOAK_IMAGE", + "NGINX_IMAGE", + "OTEL_COLLECTOR_IMAGE", + "OTEL_QUEUE_INIT_IMAGE", + "REDIS_IMAGE", + "SMS_SERVICE_IMAGE", + "TOOLBOX_IMAGE", + ) + compose_paths = ( + ROOT / "infra/compose/application.yml", + ROOT / "nginx/docker-compose.yml", + ROOT / "redis/docker-compose.yml", + ROOT / "observability/docker-compose.yml", + ROOT / "deployment/docker-compose.jobs.yml", + ) + combined = "\n".join(path.read_text(encoding="utf-8") for path in compose_paths) + self.assertNotIn("\n build:", combined) + for variable in image_variables: + self.assertIn(f"${{{variable}:?", combined, variable) + + example = (ROOT / ".env.example").read_text(encoding="utf-8") + for variable in image_variables: + match = re.search(rf"^{variable}=(.+)$", example, re.MULTILINE) + self.assertIsNotNone(match, variable) + self.assertRegex(match.group(1), r"@sha256:[0-9a-f]{64}$") + + preflight = (ROOT / "deployment/preflight.sh").read_text(encoding="utf-8") + self.assertIn("config --images", preflight) + self.assertIn("config --services", preflight) + self.assertIn("only nginx may publish production host ports", preflight) + self.assertIn("still contains the example image/digest", preflight) + self.assertIn("socket.getaddrinfo", preflight) + self.assertIn('ipaddress.ip_network("10.0.0.0/8")', preflight) + self.assertIn("hostname must resolve only to private VPC addresses", preflight) + self.assertIn('--runtime-manifest "$MANIFEST"', preflight) + for variable in image_variables: + self.assertIn(variable, preflight) + + def test_legacy_compose_tls_is_removed(self) -> None: + root = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") + combined = f"{root}\n{nginx}" + self.assertNotIn("nginx-certs", combined) + self.assertNotIn("nginx-acme", combined) + self.assertNotIn("\n certbot:", nginx) + self.assertIn("/var/lib/han-chat/public-tls:/run/tls:ro", nginx) + self.assertIn("/var/lib/han-chat/acme:/var/www/certbot:ro", nginx) + + example = (ROOT / ".env.example").read_text(encoding="utf-8") + self.assertIn("NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem", example) + self.assertIn("NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem", example) + + def test_runtime_services_have_compose_hardening(self) -> None: + application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") + for anchor in ("x-api-runtime: &api-runtime", "x-sms-runtime: &sms-runtime"): + start = application.index(anchor) + end = application.index("\n\n", start) + runtime = application[start:end] + for setting in ( + "read_only: true", + 'cap_drop: ["ALL"]', + 'security_opt: ["no-new-privileges:true"]', + "/tmp:size=", + "pids_limit:", + "mem_limit:", + "cpus:", + ): + self.assertIn(setting, runtime, f"{anchor}: {setting}") + + bitrix = application[ + application.index("\n bitrix-local-app:") : application.index("\nsecrets:") + ] + for setting in ( + "read_only: true", + 'cap_drop: ["ALL"]', + 'security_opt: ["no-new-privileges:true"]', + "/tmp:size=", + "pids_limit:", + "mem_limit:", + "cpus:", + ): + self.assertIn(setting, bitrix) + + keycloak = application[ + application.index("\n keycloak:") : application.index("\n sms-service:") + ] + self.assertNotIn("\n read_only:", keycloak) + self.assertIn('cap_drop: ["ALL"]', keycloak) + self.assertIn("pids_limit:", keycloak) + self.assertIn("read_only is intentionally omitted", keycloak) + + jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8") + self.assertEqual(jobs.count("<<: *python-job-runtime"), 4) + for setting in ("read_only: true", 'cap_drop: ["ALL"]', "/tmp:size="): + self.assertIn(setting, jobs) + def test_vm_and_nginx_security_defaults(self) -> None: setup = (ROOT / "deployment/scripts/setup-vm.sh").read_text(encoding="utf-8") env_example = (ROOT / ".env.example").read_text(encoding="utf-8") @@ -87,7 +227,7 @@ class InfrastructureConfigTests(unittest.TestCase): ROOT / "deployment/scripts/ssl-renew.sh" ).read_text(encoding="utf-8") - self.assertIn("LOCK_ACCOUNT_PASSWORDS=true", setup) + self.assertIn('LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"', setup) self.assertIn('passwd --lock root', setup) self.assertIn('passwd --lock "$DEPLOY_USER"', setup) self.assertIn("X11Forwarding no", setup) @@ -163,7 +303,8 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertIn("save 900 1", config) self.assertIn("user default off", acl) self.assertIn("~han:api:*", acl) - self.assertIn("~han:safety:*", acl) + self.assertNotIn("message_safety", acl) + self.assertNotIn("REDIS_SAFETY_PASSWORD", acl) def test_otel_has_redaction_and_persistent_queue(self) -> None: config = (ROOT / "observability/otel-collector.yaml").read_text(encoding="utf-8") @@ -223,7 +364,7 @@ class InfrastructureConfigTests(unittest.TestCase): application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") self.assertIn("\n frontend-static:", application) - self.assertIn("frontend-test-site", application) + self.assertIn("${FRONTEND_STATIC_IMAGE:?", application) self.assertIn("frontend-static:/output", application) for service, command in ( ("sms-worker:", "han-sms-worker"), @@ -246,6 +387,9 @@ class InfrastructureConfigTests(unittest.TestCase): def test_expo_public_environment_uses_static_property_access(self) -> None: config = (ROOT / "frontend-test-site/src/config.ts").read_text(encoding="utf-8") auth = (ROOT / "frontend-test-site/src/auth.ts").read_text(encoding="utf-8") + dockerfile = (ROOT / "frontend-test-site/Dockerfile").read_text(encoding="utf-8") + realm = (ROOT / "keycloak/realm/han-chat-realm.json").read_text(encoding="utf-8") + application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") callback = (ROOT / "frontend-test-site/app/auth/callback.tsx").read_text( encoding="utf-8" ) @@ -254,6 +398,10 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertIn("process.env.EXPO_PUBLIC_AUTH_BASE_URL", config) self.assertIn('path: "auth/callback"', auth) self.assertIn('window.location.assign(url)', auth) + self.assertIn('test -n "$EXPO_PUBLIC_AUTH_BASE_URL"', dockerfile) + self.assertIn("${PUBLIC_WEB_URL}/auth/callback", realm) + self.assertNotIn("chat.han0107.ru", realm) + self.assertIn("PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?", application) self.assertIn("completionStarted.current", callback) self.assertTrue((ROOT / "frontend-test-site/app/auth/callback.tsx").is_file()) @@ -261,7 +409,6 @@ class InfrastructureConfigTests(unittest.TestCase): for relative_path in ( "api-backend/alembic/env.py", "bitrix-local-app/alembic/env.py", - "bitrix-sync/alembic/env.py", "sms-service/migrations/env.py", ): env_script = (ROOT / relative_path).read_text(encoding="utf-8") @@ -295,7 +442,7 @@ class InfrastructureConfigTests(unittest.TestCase): dedup_fix, ) self.assertIn('down_revision: str | None = "0009_chat_message_max"', dedup_fix) - self.assertIn('EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"', main) + self.assertIn('EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"', main) def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None: migration = ( @@ -370,14 +517,41 @@ class InfrastructureConfigTests(unittest.TestCase): for service in ( "api-backend", - "sms-service", - "message-safety", "bitrix-local-app", - "bitrix-sync", "keycloak", + "nginx", + "redis", + "sms-service", ): dockerfile = (ROOT / service / "Dockerfile").read_text(encoding="utf-8") - self.assertIn("han-container-entrypoint", dockerfile, service) + self.assertIn("sed -i 's/\\r$//'", dockerfile, service) + self.assertIn("/bin/sh -n", dockerfile, service) + + frontend = (ROOT / "frontend-test-site/Dockerfile").read_text(encoding="utf-8") + self.assertIn('ENTRYPOINT ["/bin/sh", "-ec"]', frontend) + self.assertNotIn("entrypoint.sh", frontend) + + def test_legacy_stubs_are_absent_and_safety_is_remote_tls(self) -> None: + for stub in ("message-safety", "bitrix-sync"): + self.assertFalse((ROOT / stub / "pyproject.toml").exists()) + self.assertFalse((ROOT / stub / "Dockerfile").exists()) + self.assertFalse((ROOT / stub / "app/main.py").exists()) + + example = (ROOT / ".env.example").read_text(encoding="utf-8") + application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") + jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8") + nginx = (ROOT / "nginx/nginx.conf.template").read_text(encoding="utf-8") + for config in (example, application, jobs): + self.assertIn("https://processing.internal:8443", config) + self.assertIn("/internal/safety/v2", config) + self.assertIn( + "MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4", example + ) + self.assertIn("${MESSAGE_SAFETY_EXTRA_HOST:?", application) + self.assertIn("MESSAGE_SAFETY_CA_HOST_PATH", example) + self.assertNotIn("message-safety:", application) + self.assertNotIn("bitrix-sync:", application) + self.assertNotIn("bitrix_sync_upstream", nginx) def test_env_validator_accepts_materialized_example(self) -> None: example = (ROOT / ".env.example").read_text(encoding="utf-8") diff --git a/VM1_app/codebase/backend/tests/test_secret_hygiene.py b/VM1_app/codebase/backend/tests/test_secret_hygiene.py index 1efa0c3..3bbd42d 100644 --- a/VM1_app/codebase/backend/tests/test_secret_hygiene.py +++ b/VM1_app/codebase/backend/tests/test_secret_hygiene.py @@ -20,16 +20,12 @@ def runtime_values() -> dict[str, str]: { "DATABASE_URL": f"postgresql+asyncpg://han_app:password@pg:5433/han_chat{pg_tail}", "BITRIX_DATABASE_URL": f"postgresql://bitrix:password@pg:5433/han_chat{pg_tail}", - "BITRIX_SYNC_DATABASE_URL": f"postgresql://sync:password@pg:5433/han_chat{pg_tail}", - "MESSAGE_SAFETY_DATABASE_URL": f"postgresql://safety:password@pg:5433/han_chat{pg_tail}", "SMS_DATABASE_URL": f"postgresql+asyncpg://sms:password@pg:5433/han_chat{pg_tail}", "KEYCLOAK_DB_URL": f"jdbc:postgresql://pg:5433/han_chat{pg_tail}", "REDIS_API_PASSWORD": "r" * 32, - "REDIS_SAFETY_PASSWORD": "s" * 32, "REDIS_HEALTH_PASSWORD": "h" * 32, "REDIS_URL": f"redis://api_backend:{'r' * 32}@redis:6379/0", "REDIS_REALTIME_URL": f"redis://api_backend:{'r' * 32}@redis:6379/1", - "MESSAGE_SAFETY_REDIS_URL": f"redis://message_safety:{'s' * 32}@redis:6379/2", "BITRIX_LOCAL_APP_INTERNAL_TOKEN": "b" * 32, "BITRIX_INTERNAL_API_TOKEN": "b" * 32, "BITRIX_API_FORWARD_TOKEN": "f" * 32, @@ -121,6 +117,41 @@ class SecretHygieneTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertNotIn(secret_canary, result.stdout + result.stderr) + def test_validator_enforces_remote_safety_tls_contract(self) -> None: + example = (ROOT / ".env.example").read_text(encoding="utf-8") + cases = ( + ( + "MESSAGE_SAFETY_URL=https://processing.internal:8443", + "MESSAGE_SAFETY_URL=http://message-safety:8080", + "remote TLS endpoint", + ), + ( + "MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem", + "MESSAGE_SAFETY_CA_HOST_PATH=", + "MESSAGE_SAFETY_CA_HOST_PATH", + ), + ( + "MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4", + "MESSAGE_SAFETY_EXTRA_HOST=processing.internal=8.8.8.8", + "MESSAGE_SAFETY_EXTRA_HOST", + ), + ( + "MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2", + "MESSAGE_SAFETY_API_PREFIX=/internal/safety/v1", + "MESSAGE_SAFETY_API_PREFIX", + ), + ) + for original, replacement, expected_error in cases: + with self.subTest(replacement=replacement), tempfile.TemporaryDirectory() as directory: + config = Path(directory) / ".env" + config.write_text( + example.replace(original, replacement), + encoding="utf-8", + ) + result = self.run_validator(config) + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected_error, result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/VM1_app/documentation/README.md b/VM1_app/documentation/README.md index e66b732..5c90959 100644 --- a/VM1_app/documentation/README.md +++ b/VM1_app/documentation/README.md @@ -11,7 +11,13 @@ - [`module-06-bitrix-local-app.md`](module-06-bitrix-local-app.md) — Bitrix24 Open Lines local app. - [`module-08-keycloak.md`](module-08-keycloak.md) — OIDC/OTP/SmartCaptcha. - [`module-09-observability-vm1.md`](module-09-observability-vm1.md) — telemetry ВМ1. -- [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) — runbook ВМ1. +- [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) — production + deployment boundary и cutover ВМ1. +- [`RUNBOOK.production.ru.md`](../codebase/backend/deployment/RUNBOOK.production.ru.md) + — единственный исполняемый fresh production runbook ВМ1. - [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md) — целевой SMS-контур. Message Safety, CRM sync, nginx/Redis/telemetry и runbook ВМ2 находятся в [`VM2_services/documentation`](../../VM2_services/documentation/README.md). После cutover ВМ1 не запускает `message-safety`, `bitrix-sync` или Redis DB2; вызов Safety идёт по private HTTPS `:8443`. + +Legacy single-VM deployment guides и stub runbooks не являются production +инструкциями. In-place перенос legacy VM в production запрещён. diff --git a/VM1_app/documentation/module-10-deployment-vm1.md b/VM1_app/documentation/module-10-deployment-vm1.md index fd3eace..8e64d06 100644 --- a/VM1_app/documentation/module-10-deployment-vm1.md +++ b/VM1_app/documentation/module-10-deployment-vm1.md @@ -1,213 +1,81 @@ -# module-10-vm1. Runbook развёртывания ВМ1 HAN Chat - -> Статус: целевой runbook репозитория ВМ1. -> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md). -> ВМ2 — [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md). Не переносить команды ВМ2 и не шарить Compose/secrets. - -## 1. Границы - -ВМ1 владеет edge nginx `80/443`, `api-backend`, Keycloak, SMS, `bitrix-local-app`, Redis DB0/DB1, Collector. После Safety cutover local Safety/Redis DB2 отсутствуют; `MESSAGE_SAFETY_URL` — private HTTPS ВМ2. - -`` / `` — репозиторий ВМ1. Host ACME — ``. - -## 2. Sizing ВМ1 - -Final sizing — D-TBD2. Disk после pull/build ≥30% free. `PUBLIC_DOCKER_PORTS=80,443`. - -## 3. Hardening - -По arch-10 §5 / arch-06. Пример: - -```bash -sudo DEPLOY_USER=deploy \ - DEPLOY_DIR=/opt/han-chat \ - SSH_PORT= \ - SWAP_SIZE_GB=4 \ - PUBLIC_DOCKER_PORTS=80,443 \ - ./deploy/setup-vm-han-chat.sh -``` - -Gate 2 — arch-10. Break-glass вне VM. - -## 4. Release layout и `.env` ВМ1 - -Checkout exact SHA в `/opt/han-chat/backend`. Структура: root Compose, `nginx`, `keycloak`, `redis`, `observability`, frontend artifact. - -Обязательные группы секретов/config ВМ1: - -- `APP_ENV`, release, log level; -- private PG host/port/database и TLS CA; runtime DSN в secret backend; -- Redis ACL URLs DB0/DB1 только в secret backend; после cutover DB2 нет; -- public web/API/auth URLs; -- Keycloak realm/audience/hostname/bootstrap/provider secrets; -- SMS DB URL, парные Keycloak↔SMS tokens, Direct `TOKEN_1`, callback credentials; -- paired service tokens arch-02; -- Bitrix local app client/application/encryption secrets (не CRM sync webhook ВМ2); -- S3 API credentials (не Safety read-only key ВМ2); -- OTEL exporter secrets; -- nginx/TLS/rate limits; -- frontend public build values; -- после cutover: `MESSAGE_SAFETY_URL=https://:8443` и `MESSAGE_SAFETY_CA_HOST_PATH`. - -Пары: `BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN`, `BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN`, `KEYCLOAK_SMS_SERVICE_TOKEN == SMS_SERVICE_TOKEN`. - -`FRONTEND_DEV_PROXY_ENABLED=false`. Safety timeout согласован с nginx ВМ1. Validation — arch-10 §8; `validate-env` в этом репозитории. - -## 5. Images и frontend - -Pull или build без production secrets. Frontend: - -```bash -cd -npm ci -npm run test -npx expo export --platform web -``` - -Artifact в versioned `frontend-static`. Build env — только public URL/realm/client id. Secret scanner: нет service tokens/mock OTP/S3 keys. - -Image/frontend gate: images по digest; static без secrets; nginx image с request-id/TLS; disk >30% free. - -## 6. Root Compose ВМ1 - -Сервисы: edge `nginx`, `api-backend`, `keycloak`, `sms-service`, `sms-worker`, `bitrix-local-app`, Redis DB0/DB1, local `otel-collector`. После cutover — без `message-safety` и Redis DB2. - -Networks: `public`, `backend`, `egress` (`sms-worker`; Keycloak входит только при `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` и только с destination allow-list SmartCaptcha API), `observability`. `sms-service` без egress при отдельном worker. - -Volumes: `redis-data`, ACME, `frontend-static`, `otel-queue` + `otel-queue-init`. Единственные published mappings — nginx 80/443. - -Compose gate — arch-10 применительно к этому Compose. - -## 7. TLS ВМ1 - -Arch-10 §9 / arch-08, `-d --cert-name `. Private `8443` на ВМ1 нет. - -## 8. Миграции и seed ВМ1 - -Preflight/upgrade: - -```bash -cd -docker compose run --rm api-backend alembic current -docker compose run --rm bitrix-local-app alembic current -# PITR marker, затем: -docker compose run --rm api-backend alembic upgrade head -docker compose run --rm bitrix-local-app alembic upgrade head -``` - -Shared `han_app.sync_queue` мигрирует api-backend **до** sync cutover на ВМ2, но grants `bitrix_sync_user` — после обеих migrations (см. runbook ВМ2). Seed `app_settings` idempotent из этого репозитория. - -SMS schema/migrations выполняет `sms-service` на ВМ1. - -### Controlled rollout real SMS - -1. seed `otp.phone.*`; -2. schema/role `sms`, migrations, seed `sms_setting`/active `auth_otp`; -3. test env с mock Direct; -4. production Direct `TOKEN_1`, sender, template, callback credentials; -5. записать `` из `sms-worker`; -6. deploy `sms-service`/worker и callback route nginx ВМ1, `KEYCLOAK_OTP_MOCK_ENABLED=true`; -7. Keycloak expand migration/SPI; -8. provider smoke на ``; -9. только после evidence — `KEYCLOAK_OTP_MOCK_ENABLED=false`; -10. проверить durable order, resend, limits. - -Rollback SMS: вернуть Keycloak в mock; не удалять schema/journal. Production cutover запрещён при placeholder или нестабильном egress IP. - -## 9. Keycloak bootstrap - -```bash -cd -docker compose up -d keycloak -``` - -Bootstrap admin только на первый запуск, затем MFA named admin и удаление bootstrap. Realm: public client PKCE S256, issuer `https:///auth/realms/han-chat`, без `--import-realm` на живой production без diff. Keycloak gate — discovery/JWKS HTTPS, OTP fail-closed, settings bridge. - -## 10. Ordered startup ВМ1 - -После готовности ВМ2 (arch-10 §10 шаги 1–3): - -1. Redis ВМ1, `otel-queue-init`, Collector; -2. API, SMS, Keycloak, local app; -3. edge nginx последним; после readiness — `nginx -t -c /tmp/nginx.conf` и HUP. -4. `MESSAGE_SAFETY_URL` переключается на ВМ2 **только** после cutover gates runbook ВМ2 и legacy gate §14. - -Не использовать host ports для health curl. Expected: Redis `PONG`; Keycloak ready; Collector health; API core DB/Redis/JWKS/settings ready. S3/Safety/Open Lines могут быть `degraded` без снятия read API из readiness, но send path при недоступном Safety остаётся fail-closed. Local app до install может быть `portal_not_installed`. - -## 11. Bitrix24 local app и Open Lines - -Install/handler/placement URL на `https:///bitrix/...`. Canonical internal path `/internal/openlines/v1/*`, не prototype `/bitrix-internal/*`. Open Lines gate: connector line 8, outbound once, operator reply, duplicate callback безопасен. - -CRM webhook robots **не** настраиваются на ВМ1. - -## 12. Public smoke ВМ1 - -```bash -curl -I http:/// -curl -fsS https:///api/v1/public/app-config -curl -fsS https:///api/v1/public/content -curl -fsS https:///auth/realms/han-chat/.well-known/openid-configuration -curl -i https:///internal/safety/v2/messages/check -openssl s_client -connect :443 -servername -``` - -Expected: 308; public 200; discovery 200; internal 404; valid cert. - -Далее: guest content; 401 без JWT; consent → OTP → PKCE; bootstrap без phone body; `ux_session_id`; silent refresh; logout; wrong OTP. Real SMS mode — по §8. - -Safety E2E **со стороны caller** (правила stub до v2 cutover): `ф` → `422 message_blocked`; allow path; timeout 503/504 без duplicate. Статус Safety `stub` не заменяет production AV. - -Files: presigned PUT quarantine, promote/deny, owner-only download, нет URL в logs. Realtime/ownership/idempotency/429 — module-01. - -Сквозной first-send до Open Lines требует готовую ВМ2; system end-to-end gate закрывается по arch-10 после обоих runbook. - -## 13. Observability ВМ1 - -[`module-09-observability-vm1.md`](module-09-observability-vm1.md) + arch-07. Сквозной `X-Request-ID` до Safety span — совместно с ВМ2. - -## 14. Legacy gate перед cutover Safety - -Перед `MESSAGE_SAFETY_URL` на ВМ2: - -1. validator принимает только `https://:8443`, требует CA path, запрещает Docker hostname и plaintext; -2. internal CA root-owned; read-test UID `api-backend`, negative посторонний UID; -3. local `message-safety`, Redis DB2 и local rules-version env удалены из Compose/validator; -4. root-owned stack unit; `deploy` не в `docker`; -5. `DOCKER-USER` counters через `conntrack --ctorigdstport` после Docker restart и reboot; -6. images digest; rollback по compatible digests; -7. ordered startup этого runbook, не legacy `docker compose up -d`; -8. `han-secrets` и firewall oneshot явно перезапущены; TLS renew success — пустой stderr. - -Ни старый single-VM guide, ни успешный stub Compose не являются evidence. Rollback caller — предыдущий immutable release ВМ1. Уже созданные v2 tasks не down-migrate. - -## 15. Rollback, ops, incidents ВМ1 - -Rollback application-only: previous digests, без Alembic downgrade; при SMS incident — mock OTP, сохранить journal. Redis restore — clean instance, прогрев idempotency из PG ([`module-04-redis-vm1.md`](module-04-redis-vm1.md)). Keycloak restore — проверить issuer/JWKS/PKCE/OTP. - -Routine: health, PG/TLS/disk/OTEL/Redis, Keycloak signing, Bitrix connector desired/observed. - -Incident triage — arch-10 команды в `` ВМ1. Типовое: API 503 (DB/Redis/JWKS/Safety circuit); send timeout — не новый idempotency key; Redis loss — polling. - -Потеря ВМ1: provision в той же VPC, restore secrets из vault, existing PG/S3, TLS ``, Bitrix local app/callbacks verify. Не пересоздавать ВМ2. - -## 16. Definition of Done ВМ1 - -Дополнительно к arch-10 §12: - -- Compose/nginx/Redis/Collector ВМ1 прошли профильные compose/nginx gates; -- Keycloak realm/provider/PKCE/OTP готов; -- SMS либо mock с accepted risk, либо real mode после §8; -- Bitrix connector line 8 проверен; -- auth/text/file/realtime E2E caller-side зелёный; -- observability ВМ1 + redaction; -- после cutover — legacy gate §14 закрыт. - -## 17. TBD ВМ1 - -D-TBD1, D-TBD2 (VM1 sizing/SLO), D-TBD8 CLI, D-TBD9 Keycloak admin VPN. - -## 18. Ссылки - -- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md). -- ВМ2: [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md). -- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md). +# module-10-vm1. Production deployment ВМ1 + +> Канонический исполняемый fresh-VM runbook: +> [`RUNBOOK.production.ru.md`](../codebase/backend/deployment/RUNBOOK.production.ru.md). +> Общие security/deployment контракты: +> [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md) +> и [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md). +> ВМ2: +> [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md). + +## Граница документа + +Этот модуль фиксирует только связь спецификаций и cutover boundary. Команды +bootstrap, release activation, secrets, TLS, migrations, systemd, smoke, +reboot, rollback и DR находятся только в production runbook. + +Production ВМ1 разворачивается на fresh Ubuntu 24.04. In-place hardening +legacy single-VM/stub инсталляции запрещён. Legacy +`deployment/DEPLOYMENT_GUIDE.ru.md`, `RUNBOOK.ru.md` и `RUNBOOK.md` являются +только указателями. + +## Host deployment contract + +- разные Ed25519 keys для `deploy` и break-glass `admin`, не совпадающие с + root bootstrap key; +- `deploy` не входит в `docker`, `sudo`, `lxd`, `adm`, + `systemd-journal`; пишет только в `/var/lib/han-deploy/incoming`; +- active release, Compose, `.env`, scripts/helpers, units, secret mappings, + credentials и sudoers принадлежат root; +- activation проверяет approved SHA-256, project root, absolute/`..` paths, + symlink/hardlink и executable modes; +- lifecycle — `han-secrets@production.service` и + `han-stack@production.service` через exact sudoers; +- public TLS копируется root hook из `/etc/letsencrypt` в + `/var/lib/han-chat/public-tls`; nginx не получает дерево Certbot; +- published Docker ports фильтруются по original destination + `conntrack --ctorigdstport 80/443`, включая restart/reboot gates. + +## Cutover ВМ1 → ВМ2 + +ВМ1 владеет public guest/API/auth/SMS/Bitrix local-app ingress `80/443`. +ВМ2 владеет Message Safety v2 на private HTTPS `:8443` и CRM sync public +webhooks собственного host. Compose, IAM principal и secret bundle между VM не +разделяются. + +До переключения caller на ВМ2: + +1. ВМ2 закрыла TLS SAN/CA, PG migrations, Safety capabilities, S3 read-only, + performance, egress и rollback gates своего runbook. +2. ВМ1 использует + `MESSAGE_SAFETY_URL=https://:8443` и root-owned + `MESSAGE_SAFETY_CA_HOST_PATH`. +3. Internal CA читается фактическим UID API и не читается посторонним UID. +4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops. +5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback + отсутствуют в production Compose/validator. +6. Caller smoke подтверждает allow/deny/pending/timeout, file flow и + fail-closed поведение; status `stub` не принимается. +7. После cutover фиксируются release/image digests, schema/config versions, + firewall counters, traces и rollback approval. + +Rollback caller переключает только на предыдущий schema-compatible immutable +release ВМ1. Уже созданные tasks ВМ2 не down-migrate. Потеря ВМ2 не разрешает +fail-open или возврат local stub. + +Bitrix sync cutover выполняется отдельно по runbook ВМ2 после expand migrations +`han_app` на ВМ1. CRM webhook ВМ2 не маршрутизируется через nginx ВМ1. + +## Связанные модули + +- API caller и Safety v2: + [`module-01-api-backend.md`](module-01-api-backend.md). +- Public edge: + [`module-03-nginx-vm1.md`](module-03-nginx-vm1.md). +- Redis DB0/DB1 и удаление legacy DB2: + [`module-04-redis-vm1.md`](module-04-redis-vm1.md). +- Keycloak: [`module-08-keycloak.md`](module-08-keycloak.md). +- Observability: + [`module-09-observability-vm1.md`](module-09-observability-vm1.md). +- SMS cutover: + [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md). diff --git a/VM1_app/vm1-bug-v1.tar.gz b/VM1_app/vm1-bug-v1.tar.gz new file mode 100644 index 0000000..6b76a44 Binary files /dev/null and b/VM1_app/vm1-bug-v1.tar.gz differ diff --git a/VM1_app/vm1-bug-v2.tar.gz b/VM1_app/vm1-bug-v2.tar.gz new file mode 100644 index 0000000..c580280 Binary files /dev/null and b/VM1_app/vm1-bug-v2.tar.gz differ diff --git a/VM2_services/codebase/services/deployment/RUNBOOK.md b/VM2_services/codebase/services/deployment/RUNBOOK.md index 8792cbd..01b5d52 100644 --- a/VM2_services/codebase/services/deployment/RUNBOOK.md +++ b/VM2_services/codebase/services/deployment/RUNBOOK.md @@ -1,14 +1,22 @@ # VM2 Processing deployment runbook -This directory is the independent VM2 foundation. It does not deploy VM1 or -`codebase/backend`. All commands below are operator commands; repository -creation does not execute them. +This directory is the independent VM2 foundation in the `VM2_services` +repository. It does not deploy VM1 or `VM1_app/codebase/backend`. All commands +below are operator commands; repository creation does not execute them. + +Repository root: `HAN_chat_specification/VM2_services`. Compose and deployment +artifacts live under `VM2_services/codebase/services/`. Local operator commands +assume the current directory is `VM2_services` unless stated otherwise. + +The full step-by-step procedure with gates and copy-paste commands is in +[`RUNBOOK.ru.md`](RUNBOOK.ru.md). ## Production blockers before first start 1. Replace every `.env` placeholder with reviewed non-secret values. Keep `BITRIX_SYNC_ENABLED=false` until migrations, grants, portal fields, robot - contracts and cutover are signed off. + contracts and cutover are signed off. Build and push application images to + the registry first (blocker 2). 2. Fill every `*_IMAGE` variable with a reviewed registry digest. Root Compose rejects missing image references; mutable tags are not production evidence. 3. Install production files as `root:root`; `deploy` must not be in `docker` @@ -38,61 +46,156 @@ creation does not execute them. S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations. Registry/package access exists only during controlled maintenance windows. -## Install +## Who runs what -- Bootstrap a fresh Ubuntu 24.04 VM as root with - `deployment/scripts/setup-vm.sh`, supplying `VM1_PRIVATE_CIDRS`, optional - private/VPN `OPS_CIDRS`, and separate Ed25519 public-key files for deploy and - break-glass admin. SSH is publicly reachable but key-only and protected by - fail2ban; the CIDR variables apply only to private port `8443`. The script - installs host packages/firewalls and roles but never starts Compose. Set a - separate admin sudo password; verify deploy login, admin login and admin sudo - in independent sessions before rerunning with `HARDEN_SSH=true`. - Root/deploy/admin key reuse is rejected. -- Checkout an immutable release under `/opt/han-chat/services`. -- Copy `.env.example` to root-owned mode `0600` `.env`. -- Install `secrets_loader.py` and `han-secrets` under - `/usr/local/lib/han-secrets-vm2/`, root-owned and non-writable. -- Install `han-compose` as `/usr/local/sbin/han-vm2-compose`. -- Install `han-secrets-vm2.service` and `han-processing.service` under - `/etc/systemd/system/`. -- Install `han-message-safety-mode` as root-owned `0755` and the sudoers - template as `/etc/sudoers.d/deploy-message-safety-mode` mode `0440`; validate - with `visudo -cf`. Create the dedicated host group `han-message-safety` with - GID `10001`. Before the first Compose validation, create - `/etc/han-chat/message-safety-mode.env` as - `root:han-message-safety 0640` with all three flags `false` (or invoke the - helper's `standard` transition after the fixed launcher is installed). -- Install loader config using the exact `APP_ENV` suffix. With the committed - example (`APP_ENV=production-like`) the path is - `/etc/han/secrets/vm2-production-like.selectel.json` mode `0600`. For - controlled no-provider recovery use an explicit `file` - config pointing to a root-only `0700` directory containing exactly one file - per configured key. Selectel failure never falls back automatically. +- **Operator workstation:** builds the release archive and transfers it to VM2. + Local examples use PowerShell from `VM2_services`. +- **`root` on VM2:** host bootstrap, verified release activation, root-owned + files, `.env`, secret mapping, credentials, TLS/allow-lists, migrations and + first start. +- **`deploy` on VM2:** accepts releases only in `/var/lib/han-deploy/incoming`, + checks status/logs and runs installed fixed systemd operations through exact + sudo rules. `deploy` must not run `docker`, edit `/opt/han-chat/services` or + join the `docker` group. +- **`admin` on VM2:** personal break-glass role with a separate SSH key and + local sudo password. Not used for routine deploy; not in `docker`/`lxd`; every + login and sudo call is an incident operation. -## Preflight and startup +## Prerequisites (before §1) -Run `deployment/preflight.sh` first. Then, through the approved root units: +This runbook covers operations **on an already provisioned VM2**. Prepare outside +Compose first: -1. synchronize secrets; any missing/oversized/invalid secret blocks startup; -2. validate resolved Compose without storing its output; -3. run the two `ops` migration jobs and create/activate the reviewed initial - Message Safety config before starting either runtime; -4. validate nginx config and both certificate chains; -5. start Redis/Collector, ClamAV, application API/workers, then nginx; -6. verify that only nginx publishes `80`, `443`, and private-bound `8443`; -7. verify all non-exact public paths return `404`, HTTP webhook paths return - `426` without redirect/query reflection, wrong methods fail, and wrong - source CIDRs are rejected before upstream; -8. verify private Safety check/task/status and sync status only from approved - callers; verify public `/internal/*` is `404`; -9. canary telemetry with a fake token marker and prove query, form body, - Authorization, DSN, S3 key and object key are absent from logs/traces. +1. **Selectel infrastructure** — VPC/subnet, SG (public `80/443/22`; private + `8443` only; default-deny egress after bootstrap), sizing (4 vCPU / 8 GB RAM / + 80 GB SSD — see + [`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)), + public and private VM2 IPs, DNS A record for `PROCESSING_PUBLIC_HOST`. +2. **Managed PostgreSQL** — schemas/roles for `message_safety` and + `bitrix_sync`, separate migration/runtime DSNs; see + [`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6. +3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record + immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav, + otel-collector). +4. **Selectel Secrets Manager** — populate all remote names from + `deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys, + `REDIS_SAFETY_ACL`, internal TLS PEM for `8443`). Dedicated VM2 IAM principal + with read-only access to those names only. +5. **S3 quarantine bucket** and SigNoz OTLP endpoint — non-secret values in + `.env`. +6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM + stored in Secrets Manager, not in the release tree. + +Section order: §1–§5 → Gates 1–9 → §7 (post-acceptance). Run +`systemctl enable` and `systemctl start han-processing.service` **only after +Gate 5 succeeds**. + +## 1. Bootstrap a fresh VM2 + +From `VM2_services` on the operator workstation, copy the setup script: + +```powershell +scp -i C:\Users\MI\.ssh\hansel ` + .\codebase\services\deployment\scripts\setup-vm.sh ` + root@:/root/setup-vm2.sh +``` + +Bootstrap as root with separate Ed25519 deploy/admin keys, `VM1_PRIVATE_CIDRS`, +optional `OPS_CIDRS`, admin sudo password, deploy/admin login verification, then +rerun with `HARDEN_SSH=true`. Full commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §1. + +## 2. Release transfer under `deploy` + +From `VM2_services`: + +```powershell +$Release = "" +tar --exclude=services/.env ` + --exclude='services/**/__pycache__' ` + --exclude='services/**/.pytest_cache' ` + --exclude='services/**/.ruff_cache' ` + -czf "vm2-services-$Release.tar.gz" -C .\codebase services +Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256 +scp -i C:\Users\MI\.ssh\hansel "vm2-services-$Release.tar.gz" ` + deploy@:/var/lib/han-deploy/incoming/ +``` + +`deploy` verifies SHA-256 and archive listing only; it must not unpack into +production. Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §2–§3. + +## 3. Activation and root-owned install + +`root` verifies the archive, extracts to staging, rsyncs into +`/opt/han-chat/services`, reruns `setup-vm.sh` to install helpers/units. Details: +[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §3. + +## 4. Non-secret config and Selectel + +Copy `.env.example` → `.env`, install loader config as +`/etc/han/secrets/vm2-.selectel.json`, encrypt Selectel service-user +password with `systemd-creds`, edit nginx allow-lists. Details: +[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4. + +## 5. PostgreSQL CA and initial public TLS + +Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for +`PROCESSING_PUBLIC_HOST`, stage public cert/key for nginx. Details: +[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §5. + +## 6. Gates 1–9: preflight, migrations and first start + +Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable +`han-processing.service` until Gate 5 completes successfully. + +| Gate | Purpose | +| --- | --- | +| 1 | Secrets materialized via `han-secrets-vm2.service` | +| 2 | Static preflight and resolved Compose (`@sha256:` images) | +| 3 | Migrations, initial Message Safety config create/activate | +| 4 | nginx `-t` with TLS and upstream placeholders | +| 5 | Ordered `compose up`; then `systemctl enable/start han-processing` | +| 6 | Host ports, public/private certificate chains, certbot renewal | +| 7 | Public routing smoke (308/404/426/405) | +| 8 | Private Safety/sync API from VM1/ops only | +| 9 | Fake-token canary — no secrets in logs/traces | + +After Gate 5, `deploy` may run: + +```sh +sudo systemctl restart han-secrets-vm2.service +sudo systemctl restart han-processing.service +sudo systemctl --no-pager status han-processing.service +sudo journalctl --no-pager -u han-processing.service +``` + +Full gate commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §6. Do not open webhook traffic while `bitrix-sync` is disabled. A disabled or failed receiver must return retryable `503`/closed routing, never successful `2xx ignored`. +## 7. After Gate 9 — post-acceptance + +Gate 9 completes VM2 technical acceptance but **does not** authorize Message +Safety cutover on VM1 or Bitrix sync enablement. + +1. Verify autostart: `docker`, `han-chat-vm2-docker-firewall`, `han-secrets-vm2`, + `han-processing`, `certbot.timer`. +2. Reboot-gate: `systemctl reboot`, then repeat autostart checks and Gates 6–8 + briefly. +3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot + timer, unit journals — without secret values. +4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV + signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API). +5. Proceed to controlled Message Safety cutover on VM1 — see + [`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13. +6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix + public allow-list remains `deny all;` until + [`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md) + cutover gates are signed off. + +Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7. + ## Failure policy - Safety dependency failure is fail-closed: VM1 must not send/promote content. diff --git a/VM2_services/codebase/services/deployment/RUNBOOK.ru.md b/VM2_services/codebase/services/deployment/RUNBOOK.ru.md index 9edb28a..21cb522 100644 --- a/VM2_services/codebase/services/deployment/RUNBOOK.ru.md +++ b/VM2_services/codebase/services/deployment/RUNBOOK.ru.md @@ -1,8 +1,12 @@ # Ранбук развёртывания Processing на VM2 -Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не -затрагивает `codebase/backend`. Все команды ниже — операторские; создание -репозитория их не выполняет. +Этот каталог — независимая основа VM2 в репозитории `VM2_services`. Он не +разворачивает VM1 и не затрагивает `VM1_app/codebase/backend`. Все команды +ниже — операторские; создание репозитория их не выполняет. + +Корень репозитория ВМ2: `HAN_chat_specification/VM2_services`. Compose и +deployment-артефакты: `VM2_services/codebase/services/`. Локальные команды +ниже предполагают текущий каталог `VM2_services`, если не указано иное. ## Блокеры production перед первым запуском @@ -64,6 +68,33 @@ не входит в `docker`/`lxd`; каждый вход и sudo-вызов считается инцидентной операцией. +## Предварительные условия (до §1) + +Этот runbook описывает операции **на уже созданной VM2**. До bootstrap +подготовьте вне Compose: + +1. **Инфраструктура Selectel** — VPC/subnet, SG (`80/443/22` public; + `8443` только private; egress default-deny после bootstrap), sizing + (4 vCPU / 8 ГБ RAM / 80 ГБ SSD — см. + [`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)), + public и private IP VM2, DNS A-запись `PROCESSING_PUBLIC_HOST`. +2. **Managed PostgreSQL** — schemas/roles для `message_safety` и + `bitrix_sync`, отдельные migration/runtime DSN; см. + [`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6. +3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`; + получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis, + clamav, otel-collector). +4. **Selectel Secrets Manager** — заполнить все remote names из + `deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys, + `REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal + VM2 с read-only доступом только к этим именам. +5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`. +6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2; + PEM хранится в Secrets Manager, не в каталоге релиза. + +Порядок разделов §1–§5 → Gates 1–9 → §7 (post-acceptance). `systemctl enable` +и `systemctl start han-processing.service` — **только после успешного Gate 5**. + ## 1. Bootstrap свежей VM2 На локальном компьютере один раз создайте **два разных** ключа. Закрытые части @@ -85,8 +116,9 @@ break-glass оператору и храниться отдельно от deplo каталог: ```powershell +# текущий каталог: ...\HAN_chat_specification\VM2_services scp -i C:\Users\MI\.ssh\hansel ` - .\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh ` + .\codebase\services\deployment\scripts\setup-vm.sh ` root@:/root/setup-vm2.sh scp -i C:\Users\MI\.ssh\hansel ` C:\Users\MI\.ssh\han_vm2_deploy.pub ` @@ -161,8 +193,8 @@ sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub ``` ## 2. Передача релиза под `deploy` - -На локальном компьютере из каталога `HAN_chat_specification`: + +На локальном компьютере из каталога `VM2_services`: ```powershell $Release = "" @@ -390,71 +422,16 @@ Nginx с primary GID `11001` получает только подготовле `/etc/letsencrypt` остаётся доступен только root/Certbot. Не копируйте private key в каталог релиза и не делайте его world-readable. -## 6. Preflight, миграции и первый запуск под `root` +## 6. Gates 1–9: preflight, миграции и первый запуск под `root` -Сначала синхронизируйте секреты. Затем выполните статический preflight: - -```sh -systemctl start han-secrets-vm2.service -/opt/han-chat/services/deployment/preflight.sh -/usr/local/sbin/han-vm2-compose config --quiet -``` - -До runtime выполните миграции отдельными DB roles и активируйте начальный -Message Safety config: - -Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД -выдаёт Bitrix migration-role временный read-only доступ к legacy mapping: - -```sql -GRANT USAGE ON SCHEMA han_app TO ; -GRANT SELECT ON TABLE han_app.entity_external_mapping - TO ; -``` - -```sh -/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate -/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate - -/usr/local/sbin/han-vm2-compose --profile ops run --rm \ - --entrypoint message-safety-config message-safety-migrate \ - create /app/app/artifacts/seed-config.yaml --version 1 --actor '' -/usr/local/sbin/han-vm2-compose --profile ops run --rm \ - --entrypoint message-safety-config message-safety-migrate \ - activate --version 1 --approved-by '' -``` - -После успешного `bitrix-sync-migrate` администратор БД отзывает временные -права. Право `USAGE` отзывайте только если оно не требуется этой роли для -других согласованных операций: - -```sql -REVOKE SELECT ON TABLE han_app.entity_external_mapping - FROM ; -REVOKE USAGE ON SCHEMA han_app FROM ; -``` - -Первый запуск и enable выполняет `root` только после прохождения gates: -(внутри gate5) - -```sh -systemctl enable han-secrets-vm2.service han-processing.service -systemctl start han-processing.service -systemctl --no-pager status han-processing.service -journalctl --no-pager -u han-processing.service -``` - -Дальнейшие штатные операции может выполнить `deploy`: - -```sh -sudo systemctl restart han-secrets-vm2.service -sudo systemctl restart han-processing.service -sudo systemctl --no-pager status han-processing.service -sudo journalctl --no-pager -u han-processing.service -``` +Выполняйте gates **строго по порядку** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. +Не включайте `han-processing.service` и не делайте `systemctl enable`, пока +Gate 5 не завершился успешно. Установка/редактирование unit, Compose, `.env`, secret mapping, credential, TLS, allow-list и запуск migration jobs остаются операциями `root`. +После Gate 5 штатный restart/status/logs для `deploy` — см. блок +«Дальнейшие штатные операции» ниже. ### Gate 1 — секреты материализованы @@ -490,7 +467,39 @@ deployment/preflight.sh ### Gate 3 — миграции и активный Message Safety config -Команды миграций из предыдущего раздела выполняются под `root`. После них: +Под `root`. Перед первым `bitrix-sync-migrate` владелец `han_app` или +администратор БД выдаёт Bitrix migration-role временный read-only доступ к +legacy mapping: + +```sql +GRANT USAGE ON SCHEMA han_app TO ; +GRANT SELECT ON TABLE han_app.entity_external_mapping + TO ; +``` + +```sh +/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate +/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate + +/usr/local/sbin/han-vm2-compose --profile ops run --rm \ + --entrypoint message-safety-config message-safety-migrate \ + create /app/app/artifacts/seed-config.yaml --version 1 --actor '' +/usr/local/sbin/han-vm2-compose --profile ops run --rm \ + --entrypoint message-safety-config message-safety-migrate \ + activate --version 1 --approved-by '' +``` + +После успешного `bitrix-sync-migrate` администратор БД отзывает временные +права. Право `USAGE` отзывайте только если оно не требуется этой роли для +других согласованных операций: + +```sql +REVOKE SELECT ON TABLE han_app.entity_external_mapping + FROM ; +REVOKE USAGE ON SCHEMA han_app FROM ; +``` + +Проверьте head revision и активную config version: ```sh /usr/local/sbin/han-vm2-compose --profile ops run --rm \ @@ -623,6 +632,16 @@ Message Safety; проверьте её без вывода секретов: systemctl enable han-secrets-vm2.service han-processing.service systemctl start han-processing.service systemctl --no-pager status han-processing.service +journalctl --no-pager -u han-processing.service +``` + +Дальнейшие штатные операции может выполнить `deploy`: + +```sh +sudo systemctl restart han-secrets-vm2.service +sudo systemctl restart han-processing.service +sudo systemctl --no-pager status han-processing.service +sudo journalctl --no-pager -u han-processing.service ``` ### Gate 6 — host ports и сертификаты @@ -811,47 +830,10 @@ unset CANARY упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию, никогда успешный `2xx ignored`. -## Политика отказов +## 7. После Gate 9 — post-acceptance -- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать - контент. -- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую - capability; ошибка сканирования никогда не превращается в allow. -- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником - истины. -- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен - менять вердикты. -- Rollback не понижает схемы, не удаляет durable tasks/mappings и не - запускает `docker compose down -v`. - -## Аварийный MOCK - -Разрешены только эти пять sudo-команд: - -```text -han-message-safety-mode standard -han-message-safety-mode mock --text-free true --file-free true -han-message-safety-mode mock --text-free true --file-free false -han-message-safety-mode mock --text-free false --file-free true -han-message-safety-mode mock --text-free false --file-free false -``` - -Хелпер атомарно пишет только -`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API, -проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет -таймаута: держите high-severity alert активным до явного `standard`, затем -проверьте нормальные text/link/file capabilities и EICAR-canary. - -## Известные исключения по образам - -Образы ClamAV могут потребовать корректировок UID/path после валидации -точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально: -задокументируйте минимальные writable пути для сигнатур/runtime и -компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN -получает только `freshclam`; `clamd` — нет. - - -# Gate 9 завершает техническую приёмку VM2, но не означает production cutover сервисов. +Gate 9 завершает техническую приёмку VM2, но **не** означает production +cutover Message Safety на VM1 и **не** разрешает включать Bitrix sync. Дальнейший порядок: @@ -912,6 +894,237 @@ BITRIX_SYNC_ENABLED=false BITRIX_SYNC_MODE=disabled ``` -Public allow-list — только `deny all;`. Включать Bitrix можно лишь после выполнения gates `module-07`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal. +Public allow-list — только `deny all;`. Включать Bitrix можно лишь после +выполнения gates [`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md): +поля портала, webhooks, migrations, grants, backfill/watermark и rollback +rehearsal. -Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix. \ No newline at end of file +После успешного Gate 9 выполните reboot-gate (п. 2 выше), зафиксируйте +приёмку (п. 3), затем выполните отдельный controlled cutover ниже. Gate 9 сам +по себе не разрешает переключать caller. + +### Controlled cutover Message Safety на VM1 + +Cutover выполняется в согласованное окно совместно с Safety Service, +Rule Pack, Security, Product и Operations. До начала зафиксируйте текущий и +предыдущий schema-compatible immutable release VM1, ответственного за rollback +и stop conditions. `bitrix-sync` в это окно не включается. + +#### 1. Предварительные условия + +До изменения VM1 должны быть выполнены все условия: + +- reboot-gate VM2 и повторные Gates 6–8 успешны; +- Safety работает в `standard`, не в `mock`; +- active config и rules bundle утверждены, Safety v2 migrations находятся на + ожидаемом head; +- `text`, `links`, `files`, `worker` имеют состояние `ready`; +- VM2 имеет только read-only доступ к versioned S3 quarantine objects; +- performance, egress negative tests и redaction Gate 9 закрыты; +- private DNS VM2 резолвится с VM1 только в private VPC address; +- security group/firewall разрешает `8443` от VM1 и approved ops, но не из + интернета; +- на VM1 подготовлен release без local `message-safety`, Redis DB2, local rules + env и stub fallback; +- один и тот же production service token подготовлен в раздельных secret + bundles VM1 и VM2; значение токена не печатается и не копируется в + `/etc/han/vm1.env`. + +На VM1 под `root` повторите проверку TLS и readiness: + +```sh +openssl s_client -connect :8443 \ + -servername \ + -verify_hostname \ + -CAfile /etc/han/ca/vm2-internal-ca.pem \ + -verify_return_error :8443/internal/safety/status +``` + +В TLS-выводе ожидается успешная проверка chain/SAN. В status ожидаются +`processing_mode=standard`, непустой `config_version` и +`text|links|files|worker=ready`. `stub`, `mock`, `not_ready` или недоступная +capability — stop condition. + +#### 2. Прямой pre-cutover smoke с VM1 + +Прямой smoke доказывает route, CA и paired token до перезапуска caller: + +```sh +SAFETY_TOKEN="$(cat )" +MESSAGE_ID="$(uuidgen)" +curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <:8443 +MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem +MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2 +``` + +В root-owned Selectel secret mapping VM1 переменная +`MESSAGE_SAFETY_SERVICE_TOKEN` должна ссылаться на согласованный remote secret. +После review config и mapping: + +```sh +cd /opt/han-chat/current/backend +./scripts/validate-env /etc/han/vm1.env + +systemctl restart han-secrets@production.service +systemctl is-active han-secrets@production.service +journalctl --no-pager -u han-secrets@production.service + +./deployment/preflight.sh +/usr/local/sbin/han-vm1-compose config --quiet +/usr/local/sbin/han-vm1-compose config --services +/usr/local/sbin/han-vm1-compose config --images +``` + +В `config --services` не должно быть local `message-safety`, `bitrix-sync`, +Redis DB2 или test stub. Не сохраняйте resolved Compose в файл и не выводите +secret values. Если preflight успешен, переключите caller: + +```sh +systemctl restart han-stack@production.service +systemctl is-active han-stack@production.service +/usr/local/sbin/han-vm1-compose ps +``` + +#### 4. Post-cutover проверки через caller + +Проверки выполняются через public API/штатный UI VM1, а не только прямым curl к +VM2: + +1. benign text проходит Safety и отправляется ровно один раз; +2. утверждённый deny text не отправляется в Bitrix и возвращает клиенту generic + `message_blocked` без internal `rule_id`; +3. сообщение с безопасной HTTP/HTTPS-ссылкой проходит, запрещённая + private/link-local/metadata ссылка блокируется без HTTP fetch этой ссылки; +4. реальный quarantine file проходит `pending` и terminal result, после allow + продвигается штатным caller flow; deny-файл не продвигается; +5. повтор client/idempotency request не создаёт второе сообщение или второй + Safety task; +6. correlation request ID виден в VM1, VM2 и SigNoz без текста сообщения, + token, object key и других секретов. + +Затем согласованным способом кратко сделайте VM2 недоступной **только для +test request** и подтвердите fail-closed: VM1 не отправляет и не продвигает +контент, возвращает контролируемую retryable ошибку, а local/stub fallback не +активируется. Сразу восстановите доступ и повторите benign smoke. Не имитируйте +отказ остановкой всей VM2, если на ней уже есть другой production traffic. + +#### 5. Rollback rehearsal + +Rollback caller — только на заранее проверенный предыдущий +schema-compatible immutable release VM1 по +[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md) +§12. Он не меняет nginx VM2, не понижает schema и не удаляет уже созданные +Safety tasks: + +```sh +PREVIOUS='' +test -d "/opt/han-chat/releases/${PREVIOUS}/backend" +ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new +mv -Tf /opt/han-chat/.current-new /opt/han-chat/current +systemctl daemon-reload +systemctl restart han-secrets@production.service +/opt/han-chat/current/backend/deployment/preflight.sh +systemctl restart han-stack@production.service +``` + +После rehearsal повторите public smoke, верните approved current release тем же +атомарным способом и снова повторите smoke. Потеря VM2 не разрешает fail-open, +переключение на local stub или обход Safety. При несовместимой migration +rollback запрещён: используйте forward fix либо заранее согласованный recovery +plan. + +#### 6. Фиксация cutover + +Сохраните без secret values: + +- VM1/VM2 release и image digests, Safety schema head; +- private certificate fingerprint/expiry, `config_version` и `rules_version`; +- результаты allow/deny/pending/file/timeout/fail-closed/idempotency checks; +- firewall counters и доказательство недоступности `8443` с запрещённого + source; +- traces/log search и результат redaction canary; +- фактическое время переключения и rollback rehearsal; +- approvals Safety Service, Rule Pack, Security, Product и Operations. + +Только после успешного выполнения всех пунктов Message Safety cutover считается +завершённым. Cutover `bitrix-sync` остаётся отдельным изменением по +[`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md). + +## Политика отказов + +- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать + контент. +- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую + capability; ошибка сканирования никогда не превращается в allow. +- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником + истины. +- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен + менять вердикты. +- Rollback не понижает схемы, не удаляет durable tasks/mappings и не + запускает `docker compose down -v`. + +## Аварийный MOCK + +Разрешены только эти пять sudo-команд: + +```text +han-message-safety-mode standard +han-message-safety-mode mock --text-free true --file-free true +han-message-safety-mode mock --text-free true --file-free false +han-message-safety-mode mock --text-free false --file-free true +han-message-safety-mode mock --text-free false --file-free false +``` + +Хелпер атомарно пишет только +`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API, +проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет +таймаута: держите high-severity alert активным до явного `standard`, затем +проверьте нормальные text/link/file capabilities и EICAR-canary. + +## Известные исключения по образам + +Образы ClamAV могут потребовать корректировок UID/path после валидации +точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально: +задокументируйте минимальные writable пути для сигнатур/runtime и +компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN +получает только `freshclam`; `clamd` — нет. diff --git a/VM2_services/codebase/services/deployment/scripts/setup-tunnel-user.sh b/VM2_services/codebase/services/deployment/scripts/setup-tunnel-user.sh new file mode 100644 index 0000000..57ca157 --- /dev/null +++ b/VM2_services/codebase/services/deployment/scripts/setup-tunnel-user.sh @@ -0,0 +1,588 @@ +#!/usr/bin/env bash +# Создание OS-пользователя tunnel на VM2 для SSH local port forwarding к PostgreSQL/PgBouncer. +# +# Контракт: arch-06 — только local forwarding, PermitOpen allow-list, без sudo/shell. +# На VM2 по умолчанию AllowTcpForwarding=no; исключение только для tunnel. +# +# Использование (на VM2 под root): +# TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \ +# PG_HOST=192.168.0.210 \ +# PG_PORT=6433 \ +# bash setup-tunnel-user.sh +# +# Опционально: +# TUNNEL_USER=tunnel +# TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24" # ограничить источники SSH +# TUNNEL_ALLOW_ANY_SOURCE=true # осознанно разрешить ключу вход с любого адреса +# EXTRA_PERMIT_OPEN="192.168.0.210:5433" # доп. endpoint (direct PG) +# SSHD_MAIN_CONF=/etc/ssh/sshd_config.d/00-han-chat-vm2.conf +# +# По умолчанию TUNNEL_SOURCE_CIDRS обязателен. Разрешение любого источника +# требует явного TUNNEL_ALLOW_ANY_SOURCE=true и должно компенсироваться SG/firewall. + +set -Eeuo pipefail +IFS=$'\n\t' + +TUNNEL_USER="${TUNNEL_USER:-tunnel}" +TUNNEL_AUTHORIZED_KEY_FILE="${TUNNEL_AUTHORIZED_KEY_FILE:-}" +TUNNEL_SOURCE_CIDRS="${TUNNEL_SOURCE_CIDRS:-}" +TUNNEL_ALLOW_ANY_SOURCE="${TUNNEL_ALLOW_ANY_SOURCE:-false}" +PG_HOST="${PG_HOST:-192.168.0.210}" +PG_PORT="${PG_PORT:-6433}" +EXTRA_PERMIT_OPEN="${EXTRA_PERMIT_OPEN:-}" +SSHD_MAIN_CONF="${SSHD_MAIN_CONF:-/etc/ssh/sshd_config.d/00-han-chat-vm2.conf}" +SSHD_TUNNEL_CONF="/etc/ssh/sshd_config.d/50-han-tunnel-user.conf" +AUTHORIZED_KEYS_DIR="/etc/ssh/authorized_keys" +AUTHORIZED_KEYS_FILE="${AUTHORIZED_KEYS_DIR}/${TUNNEL_USER}" + +declare -a PERMIT_OPENS=() + +log() { printf '[setup-tunnel] %s\n' "$*"; } +step() { printf '\n=== %s ===\n' "$*"; } +die() { printf '[setup-tunnel] ERROR: %s\n' "$*" >&2; exit 1; } + +require_root() { + [[ "$(id -u)" -eq 0 ]] || die "Скрипт нужно запускать от root" +} + +require_commands() { + local command + for command in awk chmod cp getent grep id install mktemp mv passwd python3 rm \ + ssh-keygen sshd stat systemctl useradd usermod; do + command -v "$command" >/dev/null 2>&1 || die "Не найдена обязательная команда: ${command}" + done +} + +validate_tunnel_user() { + [[ "$TUNNEL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ + || die "Некорректное имя пользователя: ${TUNNEL_USER}" +} + +validate_source_policy() { + [[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" || "$TUNNEL_ALLOW_ANY_SOURCE" == "false" ]] \ + || die "TUNNEL_ALLOW_ANY_SOURCE должен быть true или false" + + if [[ -n "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then + die "Задайте либо TUNNEL_SOURCE_CIDRS, либо TUNNEL_ALLOW_ANY_SOURCE=true, но не оба" + fi + if [[ -z "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" != "true" ]]; then + die "TUNNEL_SOURCE_CIDRS обязателен; для осознанного отказа задайте TUNNEL_ALLOW_ANY_SOURCE=true" + fi + if [[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then + log "ВНИМАНИЕ: источник SSH-ключа не ограничен; доступ должен ограничиваться SG/firewall" + fi +} + +validate_cidrs() { + local cidrs=$1 + local err normalized + [[ -n "$cidrs" ]] || return 0 + [[ "$cidrs" =~ ^[0-9A-Fa-f:./,[:space:]]+$ ]] \ + || die "TUNNEL_SOURCE_CIDRS содержит недопустимые символы" + + if ! err=$(python3 - "$cidrs" 2>&1 <<'PY' +import ipaddress +import sys + +parts = [raw.strip() for raw in sys.argv[1].split(",")] +if not parts or any(not part for part in parts): + print("TUNNEL_SOURCE_CIDRS содержит пустой элемент", file=sys.stderr) + raise SystemExit(1) +for cidr in parts: + try: + ipaddress.ip_network(cidr, strict=False) + except ValueError as exc: + print(f"Некорректный CIDR: {cidr} ({exc})", file=sys.stderr) + raise SystemExit(1) from exc +PY + ); then + die "${err:-TUNNEL_SOURCE_CIDRS содержит некорректный CIDR}" + fi + + normalized=$(python3 - "$cidrs" <<'PY' +import ipaddress +import sys +print(",".join( + str(ipaddress.ip_network(part.strip(), strict=False)) + for part in sys.argv[1].split(",") +)) +PY + ) + TUNNEL_SOURCE_CIDRS=$normalized +} + +validate_host_port() { + local host=$1 port=$2 label=$3 + [[ -n "$host" ]] || die "${label}: host пустой" + [[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]] \ + || die "${label}: host содержит недопустимые символы" + [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) \ + || die "${label}: некорректный port ${port}" +} + +parse_host_port_item() { + local item=$1 label=$2 + local host port + + item="${item//[[:space:]]/}" + [[ -n "$item" ]] || die "${label}: пустой host:port" + [[ "$item" == *:* ]] || die "${label}: ожидается host:port, получено ${item}" + + host="${item%%:*}" + port="${item##*:}" + [[ "$host" != "$port" ]] || die "${label}: отсутствует port в ${item}" + validate_host_port "$host" "$port" "$label" + printf '%s:%s\n' "$host" "$port" +} + +collect_permit_opens() { + local item host_port existing duplicate + local -a extra=() + + validate_host_port "$PG_HOST" "$PG_PORT" "PG" + PERMIT_OPENS=("${PG_HOST}:${PG_PORT}") + + if [[ -n "$EXTRA_PERMIT_OPEN" ]]; then + IFS=',' read -r -a extra <<< "$EXTRA_PERMIT_OPEN" + ((${#extra[@]} > 0)) || die "EXTRA_PERMIT_OPEN не содержит endpoint" + for item in "${extra[@]}"; do + host_port=$(parse_host_port_item "$item" "EXTRA_PERMIT_OPEN") + duplicate=false + for existing in "${PERMIT_OPENS[@]}"; do + [[ "$existing" == "$host_port" ]] && duplicate=true + done + if [[ "$duplicate" == "false" ]]; then + PERMIT_OPENS+=("$host_port") + fi + done + fi +} + +validate_key_file() { + local file=$1 + local lines + + [[ -n "$file" && -f "$file" && -s "$file" ]] \ + || die "Не найден TUNNEL_AUTHORIZED_KEY_FILE: ${file:-}" + ssh-keygen -l -f "$file" >/dev/null 2>&1 || die "Некорректный SSH public key: ${file}" + + mapfile -t lines < <(grep -Ev '^[[:space:]]*(#|$)' "$file") + ((${#lines[@]} == 1)) || die "Файл ключа должен содержать ровно одну строку без комментариев: ${file}" + [[ "${lines[0]}" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]] \ + || die "Разрешён только чистый ssh-ed25519 без SSH-опций: ${file}" +} + +read_ed25519_public_key() { + local file=$1 + local line key_type key_data ignored_comment + + line=$(grep -Ev '^[[:space:]]*(#|$)' "$file") + IFS=$' \t' read -r key_type key_data ignored_comment <<< "$line" + + [[ "$key_type" == "ssh-ed25519" ]] || die "Разрешён только ssh-ed25519" + [[ "$key_data" =~ ^[A-Za-z0-9+/=]+$ ]] || die "Некорректное тело публичного ключа" + + ED25519_KEY_TYPE=$key_type + ED25519_KEY_DATA=$key_data + # Входной комментарий не влияет на авторизацию и намеренно не переносится. + ED25519_KEY_COMMENT=han-vm2-db-tunnel +} + +build_authorized_keys_line() { + local key_file=$1 + local from_prefix="" options host_port + + read_ed25519_public_key "$key_file" + options='restrict,port-forwarding' + + if [[ -n "$TUNNEL_SOURCE_CIDRS" ]]; then + from_prefix="from=\"${TUNNEL_SOURCE_CIDRS}\"," + fi + + for host_port in "${PERMIT_OPENS[@]}"; do + options+=",permitopen=\"${host_port}\"" + done + + if [[ -n "$from_prefix" ]]; then + printf '%s%s %s %s %s\n' \ + "$from_prefix" "$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT" + else + printf '%s %s %s %s\n' \ + "$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT" + fi +} + +create_or_update_user() { + step "Создание пользователя ${TUNNEL_USER}" + local actual_home actual_shell primary_group home_owner + + if id "$TUNNEL_USER" >/dev/null 2>&1; then + IFS=: read -r _ _ _ _ _ actual_home actual_shell < <(getent passwd "$TUNNEL_USER") + primary_group=$(id -gn "$TUNNEL_USER") + [[ "$actual_home" == "/home/${TUNNEL_USER}" ]] \ + || die "Существующий ${TUNNEL_USER} имеет неожиданный home: ${actual_home}" + [[ "$primary_group" == "$TUNNEL_USER" ]] \ + || die "Существующий ${TUNNEL_USER} имеет неожиданную primary group: ${primary_group}" + [[ "$actual_shell" == "/usr/sbin/nologin" ]] \ + || die "Отказываюсь переоборудовать существующего ${TUNNEL_USER} с shell ${actual_shell}" + [[ -d "$actual_home" && ! -L "$actual_home" ]] \ + || die "Home ${actual_home} отсутствует, не является каталогом или является симлинком" + home_owner=$(stat -c '%U' "$actual_home") + [[ "$home_owner" == "$TUNNEL_USER" ]] \ + || die "Home ${actual_home} принадлежит ${home_owner}, ожидался ${TUNNEL_USER}" + log "Найден ранее созданный выделенный пользователь ${TUNNEL_USER}" + else + getent group "$TUNNEL_USER" >/dev/null 2>&1 \ + && die "Группа ${TUNNEL_USER} уже существует без одноимённого пользователя" + useradd --create-home --user-group --home-dir "/home/${TUNNEL_USER}" \ + --shell /usr/sbin/nologin "$TUNNEL_USER" + fi + + passwd -l "$TUNNEL_USER" >/dev/null + usermod --lock --groups "" "$TUNNEL_USER" + [[ "$(id -nG "$TUNNEL_USER")" == "$TUNNEL_USER" ]] \ + || die "Не удалось удалить дополнительные группы пользователя ${TUNNEL_USER}" +} + +build_authorized_keys_candidate() { + local target=$1 + build_authorized_keys_line "$TUNNEL_AUTHORIZED_KEY_FILE" > "$target" + # sshd читает AuthorizedKeysFile с правами целевого пользователя. + # Файл остаётся root-owned и поэтому 0644 не позволяет tunnel изменить ключ. + chmod 0644 "$target" +} + +build_sshd_tunnel_candidate() { + local target=$1 host_port + + { + printf 'Match User %s\n' "$TUNNEL_USER" + printf ' AuthorizedKeysFile %s/%%u\n' "$AUTHORIZED_KEYS_DIR" + echo ' AllowTcpForwarding local' + echo ' AllowStreamLocalForwarding no' + echo ' GatewayPorts no' + echo ' AllowAgentForwarding no' + echo ' X11Forwarding no' + echo ' PermitTTY no' + echo ' PermitTunnel no' + echo ' PermitUserRC no' + echo ' MaxSessions 0' + printf ' PermitOpen' + for host_port in "${PERMIT_OPENS[@]}"; do + printf ' %s' "$host_port" + done + printf '\nMatch all\n' + } > "$target" + chmod 0644 "$target" +} + +build_main_config_candidate() { + local target=$1 allow_line user + local -a allow_users=() + [[ -f "$SSHD_MAIN_CONF" ]] || die "Не найден ${SSHD_MAIN_CONF}" + [[ ! -L "$SSHD_MAIN_CONF" ]] || die "${SSHD_MAIN_CONF} не должен быть симлинком" + ! grep -Eq '^[[:space:]]*Match([[:space:]]|$)' "$SSHD_MAIN_CONF" \ + || die "${SSHD_MAIN_CONF} должен содержать только глобальные настройки, без Match" + + python3 - "$SSHD_MAIN_CONF" "$target" "$TUNNEL_USER" <<'PY' +from pathlib import Path +import re +import sys + +source = Path(sys.argv[1]) +target = Path(sys.argv[2]) +user = sys.argv[3] +lines = source.read_text(encoding="utf-8").splitlines(keepends=True) +matches = [ + index for index, line in enumerate(lines) + if re.match(r"^\s*AllowUsers\s+", line) +] +if len(matches) != 1: + print( + f"{source}: ожидалась ровно одна глобальная директива AllowUsers, найдено {len(matches)}", + file=sys.stderr, + ) + raise SystemExit(1) +index = matches[0] +tokens = lines[index].split() +if user not in tokens[1:]: + newline = "\n" if lines[index].endswith("\n") else "" + lines[index] = f"AllowUsers {' '.join(tokens[1:] + [user])}{newline}" +Path(target).write_text("".join(lines), encoding="utf-8") +PY + + allow_line=$(awk '/^[[:space:]]*AllowUsers[[:space:]]+/ {print; exit}' "$target") + IFS=' ' read -r -a allow_users <<< "$allow_line" + ((${#allow_users[@]} >= 2)) || die "AllowUsers не содержит пользователей" + for user in "${allow_users[@]:1}"; do + [[ "$user" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ + || die "Неподдерживаемый шаблон AllowUsers: ${user}; перечислите реальные локальные учётные записи" + getent passwd "$user" >/dev/null \ + || die "AllowUsers содержит несуществующего пользователя: ${user}" + done + chmod 0644 "$target" +} + +verify_endpoint_from_host() { + local endpoint=$1 + local host="${endpoint%%:*}" + local port="${endpoint##*:}" + + if command -v nc >/dev/null 2>&1; then + nc -z -w 5 "$host" "$port" \ + && log "TCP ${endpoint} доступен" \ + || die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall" + elif command -v timeout >/dev/null 2>&1; then + timeout 5 bash -c 'echo > "/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null \ + && log "TCP ${endpoint} доступен" \ + || die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall" + else + log "nc и timeout не установлены; пропускаю TCP-проверку" + fi +} + +verify_network_from_host() { + local endpoint + step "Проверка сетевой доступности разрешённых endpoint с VM2" + for endpoint in "${PERMIT_OPENS[@]}"; do + verify_endpoint_from_host "$endpoint" + done +} + +atomic_install() { + local source=$1 target=$2 mode=$3 + local temporary + temporary=$(mktemp "${target}.tmp.XXXXXX") || return 1 + if ! install -o root -g root -m "$mode" "$source" "$temporary"; then + rm -f -- "$temporary" + return 1 + fi + if ! mv -Tf -- "$temporary" "$target"; then + rm -f -- "$temporary" + return 1 + fi +} + +backup_file() { + local target=$1 name=$2 backup_dir=$3 + if [[ -e "$target" || -L "$target" ]]; then + cp -a -- "$target" "${backup_dir}/${name}" + : > "${backup_dir}/${name}.existed" + fi +} + +restore_file() { + local target=$1 name=$2 backup_dir=$3 + rm -f -- "$target" + if [[ -f "${backup_dir}/${name}.existed" ]]; then + cp -a -- "${backup_dir}/${name}" "$target" + fi +} + +rollback_transaction() { + local backup_dir=$1 + log "Откат SSH-конфигурации и authorized_keys" + restore_file "$SSHD_MAIN_CONF" main.conf "$backup_dir" + restore_file "$SSHD_TUNNEL_CONF" tunnel.conf "$backup_dir" + restore_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$backup_dir" +} + +assert_effective_value() { + local config=$1 key=$2 expected=$3 actual + actual=$(awk -v key="$key" '$1 == key {$1=""; sub(/^ /, ""); print; exit}' <<< "$config") + if [[ "$actual" != "$expected" ]]; then + log "Эффективный sshd ${key}=${actual:-}, ожидалось ${expected}" + return 1 + fi +} + +verify_effective_sshd_config() { + local effective endpoint permit_open authorized_keys_value allow_user_found=false + local -a effective_permit_opens=() + effective=$(sshd -T -C "user=${TUNNEL_USER},host=localhost,addr=127.0.0.1") \ + || return 1 + + assert_effective_value "$effective" allowtcpforwarding local || return 1 + assert_effective_value "$effective" allowstreamlocalforwarding no || return 1 + assert_effective_value "$effective" allowagentforwarding no || return 1 + assert_effective_value "$effective" x11forwarding no || return 1 + assert_effective_value "$effective" gatewayports no || return 1 + assert_effective_value "$effective" permittty no || return 1 + assert_effective_value "$effective" permittunnel no || return 1 + assert_effective_value "$effective" permituserrc no || return 1 + assert_effective_value "$effective" maxsessions 0 || return 1 + assert_effective_value "$effective" passwordauthentication no || return 1 + assert_effective_value "$effective" kbdinteractiveauthentication no || return 1 + assert_effective_value "$effective" pubkeyauthentication yes || return 1 + authorized_keys_value=$(awk ' + $1 == "authorizedkeysfile" {$1=""; sub(/^ /, ""); print; exit} + ' <<< "$effective") + if [[ "$authorized_keys_value" != "${AUTHORIZED_KEYS_DIR}/%u" \ + && "$authorized_keys_value" != "$AUTHORIZED_KEYS_FILE" ]]; then + log "Эффективный AuthorizedKeysFile=${authorized_keys_value:-}, ожидался root-owned путь" + return 1 + fi + + permit_open=$(awk ' + $1 == "permitopen" { + for (i = 2; i <= NF; i++) values = values " " $i + } + END {sub(/^ /, "", values); print values} + ' <<< "$effective") + IFS=' ' read -r -a effective_permit_opens <<< "$permit_open" + if ((${#effective_permit_opens[@]} != ${#PERMIT_OPENS[@]})); then + log "Эффективный PermitOpen содержит неожиданный набор: ${permit_open:-}" + return 1 + fi + for endpoint in "${PERMIT_OPENS[@]}"; do + if [[ " ${permit_open} " != *" ${endpoint} "* ]]; then + log "Эффективный PermitOpen не содержит ${endpoint}" + return 1 + fi + done + + if awk -v user="$TUNNEL_USER" ' + $1 == "allowusers" { + for (i = 2; i <= NF; i++) if ($i == user) found = 1 + } + END {exit !found} + ' <<< "$effective"; then + allow_user_found=true + fi + if [[ "$allow_user_found" != "true" ]]; then + log "Эффективный AllowUsers не содержит ${TUNNEL_USER}" + return 1 + fi +} + +detect_ssh_service() { + if systemctl cat ssh.service >/dev/null 2>&1; then + printf 'ssh\n' + elif systemctl cat sshd.service >/dev/null 2>&1; then + printf 'sshd\n' + else + die "Не найден systemd-сервис ssh.service или sshd.service" + fi +} + +prepare_authorized_keys_dir() { + local owner mode + if [[ -e "$AUTHORIZED_KEYS_DIR" || -L "$AUTHORIZED_KEYS_DIR" ]]; then + [[ -d "$AUTHORIZED_KEYS_DIR" && ! -L "$AUTHORIZED_KEYS_DIR" ]] \ + || die "${AUTHORIZED_KEYS_DIR} должен быть обычным каталогом, не симлинком" + owner=$(stat -c '%U:%G' "$AUTHORIZED_KEYS_DIR") + [[ "$owner" == "root:root" ]] \ + || die "${AUTHORIZED_KEYS_DIR} принадлежит ${owner}, ожидался root:root" + mode=$(stat -c '%a' "$AUTHORIZED_KEYS_DIR") + (( (8#$mode & 8#022) == 0 )) \ + || die "${AUTHORIZED_KEYS_DIR} доступен для записи группе или остальным: mode ${mode}" + else + install -d -o root -g root -m 0755 "$AUTHORIZED_KEYS_DIR" + fi +} + +install_and_reload_sshd() { + step "Атомарная установка, проверка и reload sshd" + local work_dir=$1 main_candidate=$2 tunnel_candidate=$3 key_candidate=$4 + local ssh_service + ssh_service=$(detect_ssh_service) + + prepare_authorized_keys_dir + backup_file "$SSHD_MAIN_CONF" main.conf "$work_dir" + backup_file "$SSHD_TUNNEL_CONF" tunnel.conf "$work_dir" + backup_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$work_dir" + + if ! atomic_install "$main_candidate" "$SSHD_MAIN_CONF" 0644; then + rollback_transaction "$work_dir" + die "Не удалось установить ${SSHD_MAIN_CONF}; выполнен откат" + fi + if ! atomic_install "$tunnel_candidate" "$SSHD_TUNNEL_CONF" 0644; then + rollback_transaction "$work_dir" + die "Не удалось установить ${SSHD_TUNNEL_CONF}; выполнен откат" + fi + + if ! sshd -t; then + rollback_transaction "$work_dir" + die "sshd -t не прошёл; исходная конфигурация восстановлена" + fi + if ! verify_effective_sshd_config; then + rollback_transaction "$work_dir" + die "Эффективная конфигурация sshd не прошла проверку; выполнен откат" + fi + + if ! atomic_install "$key_candidate" "$AUTHORIZED_KEYS_FILE" 0644; then + rollback_transaction "$work_dir" + die "Не удалось установить ${AUTHORIZED_KEYS_FILE}; выполнен откат" + fi + if ! systemctl reload "$ssh_service"; then + rollback_transaction "$work_dir" + if sshd -t; then + systemctl reload "$ssh_service" \ + || log "КРИТИЧНО: не удалось reload исходной конфигурации ${ssh_service}" + else + log "КРИТИЧНО: исходная конфигурация после отката не проходит sshd -t" + fi + die "Reload ${ssh_service} не выполнен; конфигурация восстановлена" + fi + log "sshd reload выполнен; ключ хранится в root-owned ${AUTHORIZED_KEYS_FILE}" +} + +print_usage() { + cat < + +В другом терминале: + + nc -zv 127.0.0.1 16433 + +Подключение к БД через туннель (пример): + + psql "postgresql://:@127.0.0.1:16433/?sslmode=verify-full&sslrootcert=" + +Что запрещено для ${TUNNEL_USER}: + - sudo / shell / доступ к секретам приложения + - remote forwarding (-R), произвольный SOCKS, agent/X11 + - forwarding на адреса вне PermitOpen + +Политика источника ключа: + - CIDR: ${TUNNEL_SOURCE_CIDRS:-не задан} + - осознанно разрешён любой источник: ${TUNNEL_ALLOW_ANY_SOURCE} + +EOF +} + +main() { + local work_dir main_candidate tunnel_candidate key_candidate + + require_root + require_commands + validate_tunnel_user + validate_source_policy + validate_cidrs "$TUNNEL_SOURCE_CIDRS" + validate_key_file "$TUNNEL_AUTHORIZED_KEY_FILE" + collect_permit_opens + verify_network_from_host + create_or_update_user + + work_dir=$(mktemp -d /run/setup-tunnel-user.XXXXXX) + trap "rm -rf -- '$work_dir'" EXIT + main_candidate="${work_dir}/main.conf.candidate" + tunnel_candidate="${work_dir}/tunnel.conf.candidate" + key_candidate="${work_dir}/authorized_keys.candidate" + + build_main_config_candidate "$main_candidate" + build_sshd_tunnel_candidate "$tunnel_candidate" + build_authorized_keys_candidate "$key_candidate" + install_and_reload_sshd \ + "$work_dir" "$main_candidate" "$tunnel_candidate" "$key_candidate" + print_usage +} + +main "$@" diff --git a/VM2_services/codebase/services/deployment/tunnel_user_deploy.md b/VM2_services/codebase/services/deployment/tunnel_user_deploy.md new file mode 100644 index 0000000..897aee3 --- /dev/null +++ b/VM2_services/codebase/services/deployment/tunnel_user_deploy.md @@ -0,0 +1,133 @@ +Да, скрипт операционно идемпотентен: повторный запуск с тем же `TUNNEL_USER` приводит конфигурацию к тому же состоянию. Он повторно проверит настройки и выполнит reload SSH, поэтому не является строго no-op. + +Нельзя менять `TUNNEL_USER` при повторном запуске: старый пользователь и его ключ автоматически не удаляются. + +## Инструкция запуска + +### 1. Создать ключ на рабочем компьютере + +PowerShell: + +```powershell +ssh-keygen -t ed25519 ` + -f "$env:USERPROFILE\.ssh\han_vm2_tunnel" ` + -C "han-vm2-db-tunnel" +``` + +Не перезаписывайте существующий ключ без запланированной ротации. + +### 2. Передать ключ и скрипт на VM2 + +```powershell +scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" ` + "$env:USERPROFILE\.ssh\han_vm2_tunnel.pub" ` + admin@:/tmp/han_vm2_tunnel.pub + +scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" ` + ".\setup-tunnel-user.sh" ` + admin@:/tmp/setup-tunnel-user.sh +``` + +Подключиться к VM2: + +```powershell +ssh -i "$env:USERPROFILE\.ssh\han_vm2_admin" admin@ +``` + +### 3. Установить временные файлы + +На VM2: + +```sh +sudo install -d -o root -g root -m 0700 /root/bootstrap + +sudo install -o root -g root -m 0600 \ + /tmp/han_vm2_tunnel.pub \ + /root/bootstrap/tunnel.pub + +sudo install -o root -g root -m 0700 \ + /tmp/setup-tunnel-user.sh \ + /root/setup-tunnel-user.sh + +rm -f /tmp/han_vm2_tunnel.pub /tmp/setup-tunnel-user.sh +``` + +Текущую admin-сессию не закрывать до окончания проверки. + +### 4. Запустить с ограничением по IP + +Укажите внешний IP рабочей сети: + +```sh +sudo env \ + TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \ + TUNNEL_SOURCE_CIDRS="/32" \ + PG_HOST="192.168.0.210" \ + PG_PORT="6433" \ + bash /root/setup-tunnel-user.sh +``` + +Несколько разрешённых сетей: + +```sh +TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24" +``` + +Дополнительный endpoint: + +```sh +EXTRA_PERMIT_OPEN="192.168.0.210:5433" +``` + +### 5. Осознанно разрешить любой источник + +Только если доступ уже ограничен cloud SG/firewall: + +```sh +sudo env \ + TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \ + TUNNEL_ALLOW_ANY_SOURCE=true \ + PG_HOST="192.168.0.210" \ + PG_PORT="6433" \ + bash /root/setup-tunnel-user.sh +``` + +Одновременно задавать `TUNNEL_SOURCE_CIDRS` и `TUNNEL_ALLOW_ANY_SOURCE=true` нельзя. + +### 6. Проверить туннель + +На рабочем компьютере в отдельном PowerShell: + +```powershell +ssh ` + -i "$env:USERPROFILE\.ssh\han_vm2_tunnel" ` + -N ` + -o ExitOnForwardFailure=yes ` + -o ServerAliveInterval=30 ` + -L "127.0.0.1:16433:192.168.0.210:6433" ` + tunnel@ +``` + +Адрес и порт после `-L` должны точно совпадать с `PermitOpen`. + +Во втором терминале: + +```powershell +Test-NetConnection 127.0.0.1 -Port 16433 +``` + +Для PostgreSQL с проверкой TLS-имени: + +```sh +psql "host= hostaddr=127.0.0.1 port=16433 dbname= user= sslmode=verify-full sslrootcert=" +``` + +### 7. Очистить bootstrap-файлы + +После успешной проверки на VM2: + +```sh +sudo rm -f /root/bootstrap/tunnel.pub +``` + +Рабочий ключ уже будет установлен в `/etc/ssh/authorized_keys/tunnel`. \ No newline at end of file diff --git a/VM2_services/documentation/README.md b/VM2_services/documentation/README.md index e3745f5..5e1a04b 100644 --- a/VM2_services/documentation/README.md +++ b/VM2_services/documentation/README.md @@ -12,3 +12,8 @@ - [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md) — runbook ВМ2. Guest API, frontend, Keycloak, SMS и Bitrix Open Lines local app находятся в [`VM1_app/documentation`](../../VM1_app/documentation/README.md). ВМ2 публикует на `443` только два exact CRM webhook; Safety доступен ВМ1 только через private HTTPS `:8443`. + +Cutover caller выполняется по +[`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md) +и [fresh production runbook ВМ1](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md); +ВМ2 не предоставляет ВМ1 fallback на local stub. diff --git a/VM2_services/documentation/module-10-deployment-vm2.md b/VM2_services/documentation/module-10-deployment-vm2.md index 557fa11..064ee39 100644 --- a/VM2_services/documentation/module-10-deployment-vm2.md +++ b/VM2_services/documentation/module-10-deployment-vm2.md @@ -2,7 +2,9 @@ > Статус: целевой runbook репозитория ВМ2. > Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md). -> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md). Не переносить команды ВМ1 и не шарить Compose/IAM/secrets. +> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md) +> и её [fresh production runbook](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md). +> Не переносить команды ВМ1 и не шарить Compose/IAM/secrets. ## 1. Границы @@ -181,5 +183,8 @@ D-TBD5 Safety v2; D-TBD6 bitrix-sync cutover; performance gates уже в §2. - Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md). - ВМ1: [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md). -- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md). +- Исполняемый fresh production runbook ВМ1: + [`RUNBOOK.production.ru.md`](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md). +- Исполняемый runbook ВМ2: + [`RUNBOOK.ru.md`](../codebase/services/deployment/RUNBOOK.ru.md). - Safety / sync / nginx: [`module-05-message-safety.md`](module-05-message-safety.md), [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md). diff --git a/architectory/README.md b/architectory/README.md index 65f716e..db18499 100644 --- a/architectory/README.md +++ b/architectory/README.md @@ -20,6 +20,21 @@ | [`arch-09-redis.md`](arch-09-redis.md) | Контракт Redis: не source of truth, формат ключей, TTL, Lua, AOF/ACL. Реализация VM — `module-04-redis-vm1.md` / `module-04-redis-vm2.md` | | [`arch-10-deployment.md`](arch-10-deployment.md) | Контракт развёртывания: VPC/SG, PG/S3, роли `deploy`, TLS процедура, cutover. Runbook VM — `module-10-deployment-vm1.md` / `module-10-deployment-vm2.md`. OS-роли — arch-06 | +## Ownership-матрица архитектурных требований + +| Область | Канонический владелец | Что остаётся в связанных документах | +|---|---|---| +| Термины, поля, enum и базовый lifecycle | [`arch-00-glossary.md`](arch-00-glossary.md) | Сценарии переходов ссылаются на arch-00 и не переопределяют значения | +| Границы сервисов и пользовательские сценарии | [`arch-01-system-architecture.md`](arch-01-system-architecture.md) | API, Compose и deployment описывают реализацию этих границ | +| HTTP/API/realtime контракты | [`arch-02-api-contracts.md`](arch-02-api-contracts.md) | Routing и rollout только ссылаются на endpoint/auth contract | +| Compose, Docker networks, mounts и published ports | [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md) | arch-06 задаёт security baseline; arch-08 — поведение nginx | +| Non-secret env, secret references и business settings | [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md) | Модули задают schema/validation конкретного потребителя | +| OS-роли, secret delivery и host/container hardening | [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md) | arch-03 применяет требования в Compose; arch-10 ставит rollout gates | +| Observability contract | [`arch-07-observability.md`](arch-07-observability.md) | VM module-09 задаёт конкретные pipelines/alerts | +| Nginx, TLS/ACME, единый `308`, request id и reload | [`arch-08-nginx.md`](arch-08-nginx.md) | arch-03 задаёт mounts/ports и сохраняет исключение HTTP webhook ВМ2; VM module-03 — routing matrix | +| Redis contract | [`arch-09-redis.md`](arch-09-redis.md) | VM module-04 задаёт конкретную карту instances/keys | +| Provisioning, rollout/cutover, backup/rollback/DR | [`arch-10-deployment.md`](arch-10-deployment.md) | Профильный module-10 содержит исполняемые команды и VM-specific gates | + ## Как читать 1. Начните с **arch-01** — общая картина и зафиксированные решения MVP. @@ -61,7 +76,7 @@ | Тема | Где зафиксировано | |---|---| -| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync` → `api-backend`, уведомление клиента) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9; arch-01 — заглушка UI «Документы» | +| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync` → `api-backend`, уведомление клиента) | Спецификация: [`module-07-bitrix-sync.md`](../VM2_services/documentation/module-07-bitrix-sync.md); API-контракты — [`arch-02-api-contracts.md`](arch-02-api-contracts.md) | | Интеграция с SMS-провайдерами (отправка OTP, отключение `KEYCLOAK_OTP_MOCK_*`) | Спецификация: [`module-11-idgtl-sms.md`](../VM1_app/documentation/module-11-idgtl-sms.md) (доставка через Direct SMS API; проверка OTP — локально в Keycloak) | ## Каноническое размещение production-контуров diff --git a/architectory/arch-00-glossary.md b/architectory/arch-00-glossary.md index 48658de..072b301 100644 --- a/architectory/arch-00-glossary.md +++ b/architectory/arch-00-glossary.md @@ -126,12 +126,14 @@ | Значение | Когда | |---|---| -| `accepted` | Сообщение принято API, safety ещё не завершена или только начата | -| `processing` | Внутренний/transient на время sync-wait safety; клиенту на `POST .../messages` не отдаётся как финальный ответ | +| `accepted` | Получен финальный `allow`, сообщение и durable-намерение доставки зафиксированы, но Open Lines ещё не подтвердил приём | +| `processing` | Сообщение принято API, Safety ещё выполняется (включая sync-wait `202 pending`); клиенту на `POST .../messages` не отдаётся как финальный ответ | | `delivered` | Финальный `allow`, сообщение ушло в Open Lines (или входящее от оператора сохранено) | | `rejected` | Финальный `deny` от Message Safety | | `failed` | Инфраструктурная ошибка доставки (Bitrix/S3), не safety-deny | +Для исходящего сообщения успешный lifecycle строго следует порядку: Safety `allow` → `accepted` → приём в Open Lines → `delivered`. Статус `accepted` не означает незавершённую Safety-проверку. + Realtime-событие `message.status` передаёт актуальные `safety_status` и/или `delivery_status`. ## `MessageAttachment.scan_status` diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md index 1265939..3a6feae 100644 --- a/architectory/arch-01-system-architecture.md +++ b/architectory/arch-01-system-architecture.md @@ -195,7 +195,7 @@ Frontend не должен: - локальную регистрацию пользователя приложения: `find-or-create` `UserIdentity` по `keycloak_sub`, создание минимального `ClientProfile` для нового пользователя, обновление `last_login_at` для существующего (после OTP — см. `POST /api/v1/auth/bootstrap`); - **приём события `session_start`**: запись `UxSession`, audit/analytics-событие; **не** используется для контроля доступа; - валидация данных получаемых от frontend (соответствие типов данных, проверка обязательности полей, проверка формата данных, диапазоны значений, размер полей) через Pydantic -- хранение согласий пользователя в App DB (**`user_id`**, **`ux_session_id`**, **`client_ip`**, версии документов) — только после JWT; +- хранение согласий пользователя в App DB (**`user_id`**, nullable **`ux_session_id`**, **`client_ip`**, версии документов) — только после JWT; при bootstrap `ux_session_id=NULL` допустим, потому что новая UX-сессия создаётся следующим запросом; - профиль, структурированный блоками; - API чата, истории, файлов и документов; - realtime-доставку входящих сообщений клиенту; @@ -435,7 +435,7 @@ api-backend не решает, sync или async нужна проверка в - при **`KEYCLOAK_OTP_MOCK_ENABLED=false`**: значение сверяется локально с HMAC OTP, сгенерированного Keycloak и переданного в закрытом заказе `sms-service`; статусы Direct и callback на verify не влияют. - при неверном коде Keycloak возвращает ошибку; frontend не получает tokens, шаг 12 не выполняется. 12. При успешной проверке frontend получает tokens через OIDC Authorization Code Flow with PKCE. -13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` → минимальный профиль. +13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` с nullable `ux_session_id` (на bootstrap обычно `NULL`) → минимальный профиль. 14. Frontend вызывает **`POST /api/v1/analytics/session-start`** (если нужна новая UX-сессия) и далее работает с `X-Ux-Session-Id`. 15. Триггер App DB ставит задачу `contact.map_or_create` в `sync_queue`; `bitrix-sync` асинхронно находит или создает Contact в Битрикс24. Авторизация не должна синхронно зависеть от ответа Битрикс24 CRM. 16. Frontend создаёт диалог и отправляет отложенное сообщение (см. «Создание диалога» и поток чата). @@ -596,56 +596,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn ### Предлагаемая структура backend-репозитория -```text -backend/ - docker-compose.yml # root compose ВМ1 - .env.example - nginx/ - docker-compose.yml - nginx.conf - conf.d/ - certs/ - .gitkeep - api-backend/ - app/ - docker-compose.yml - tests/ - pyproject.toml - Dockerfile - bitrix-local-app/ - app/ - docker-compose.yml - deploy/ - tests/ - pyproject.toml - Dockerfile - keycloak/ - docker-compose.yml - realm/ - themes/ - providers/ - sms-service/ - app/ - migrations/ - openapi.yaml - Dockerfile - redis/ - docker-compose.yml - observability/ - docker-compose.yml # collector ВМ1 - otel-collector.yaml - -processing/ - docker-compose.yml # root compose ВМ2 - nginx-internal/ - message-safety/ - bitrix-sync/ - clamav/ - redis/ - observability/ # collector ВМ2 -``` - -Детальная внутренняя структура каждого сервиса (`app/`, модули, миграции) определяется в профильных спецификациях модулей (TBD). +Каноническая структура root Compose, service includes, networks и mounts задаётся в [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md). Детальная внутренняя структура сервиса определяется его профильной спецификацией. ### Compose-контуры diff --git a/architectory/arch-03-docker-compose-blueprint.md b/architectory/arch-03-docker-compose-blueprint.md index 1fce370..cd2ac3a 100644 --- a/architectory/arch-03-docker-compose-blueprint.md +++ b/architectory/arch-03-docker-compose-blueprint.md @@ -91,7 +91,6 @@ networks: volumes: redis-data: - nginx-certs: ``` Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и локальный OTEL Collector. Секреты, сети и volumes двух projects не общие. @@ -103,6 +102,7 @@ Root Compose ВМ2 включает собственный nginx с public/priva - Публикация портов наружу (`ports:`) разрешена **только** для `nginx` (80/443). Все остальные сервисы используют `expose:` для внутренних портов и общаются через Docker-сети. - `bitrix-local-app` не публикует `8080` на хост (даже на `127.0.0.1`) — он доступен `api-backend` и `nginx` через сеть `backend`/`public`. Ранее применявшийся `127.0.0.1:8080:8080` считаем устаревшим; проверки через curl на `127.0.0.1:8080` заменяются на `docker compose exec bitrix-local-app` или прокси через `nginx`. - Каждый сервисный compose-файл должен запускаться и в составе корневого контура, и автономно (`docker compose -f bitrix-local-app/docker-compose.yml up`) для локальной разработки сервиса — при условии, что переменные окружения заданы. Для автономного запуска сервис может объявлять заглушки сетей/volumes, но в составе корневого контура они переопределяются общими. +- При сборке образов нужно добавлять защиту на CRLF → LF ### Обязательный container hardening @@ -176,7 +176,7 @@ docker compose exec api-backend ruff format . Требования: -- публикует наружу только `80` и `443` (см. политику HTTP ниже); +- публикует в internet только `80` и `443` (см. политику HTTP ниже); private `8443` ВМ2 публикуется только в VPC/SG для ВМ1 и ops; - принимает внешний HTTPS-трафик; - выполняет TLS termination на reverse proxy; внутренний HTTP между контейнерами — только в закрытой Docker-сети `backend`; - non-root nginx получает writable tmpfs только для `/etc/nginx/conf.d`, @@ -196,13 +196,7 @@ docker compose exec api-backend ruff format . - при recreate/смене IP upstream действует та же post-ready reload policy либо используется явно протестированный dynamic resolver; stale IP/DNS в загруженной nginx config недопустим; -- **политика HTTP/HTTPS по доменам** (каноническое правило — [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности»): - - **веб-домен** (frontend, SPA, статика): `listen 80` допускается **только** для безусловного редиректа `301`/`308` на HTTPS; обработка бизнес-логики по HTTP запрещена; - - **API-домен** (если выделен отдельный host, напр. `api.example.ru`): **не** слушает порт `80`; только `listen 443 ssl`; HTTP-запросы к API-домену недоступны; - - **единый домен MVP** (напр. `tohin.ru` с путями `/api/*`, `/auth/*`, web): считается веб-доменом; порт `80` — только redirect на HTTPS для всего server block; после редиректа весь пользовательский трафик — HTTPS; - - **auth** на том же host, что API (`/auth/*`): следует политике host (redirect-only на :80 или HTTPS-only для выделенного API-host); - - **Bitrix local-app callbacks ВМ1** (`/bitrix/handler|install|placement`): только HTTPS; порт `80` — только redirect; - - **CRM sync webhook ВМ2** (exact `/bitrix/sync/webhook/contact|alert`): только HTTPS на отдельном processing host; HTTP не отражает query token в redirect; +- применяет каноническую HTTP/HTTPS policy из [`arch-08-nginx.md`](arch-08-nginx.md): web/единый MVP host использует только `308` на HTTPS, выделенный API host не слушает `:80`; единственное route-specific исключение — HTTP exact CRM webhook ВМ2 возвращает `404/426` без redirect и без отражения query token; - маршрутизирует `/api/*` в `api-backend` (включая WebSocket upgrade для `/api/v1/realtime`); - маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен; - маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`; @@ -415,16 +409,19 @@ Identity provider. **Обязателен** в compose-контуре с пер ## Volumes -Минимальные volumes: +Минимальные persistent volumes: -- ВМ1: Redis DB0/DB1 data, public TLS/ACME, local OTEL queue; +- ВМ1: Redis DB0/DB1 data, local OTEL queue; - ВМ2: Redis Safety data (rebuildable), ClamAV signatures и runtime, internal TLS secrets, local OTEL queue. -Public TLS и ACME на ВМ2 — не named volumes: Compose монтирует read-only host -staging `/var/lib/han-chat/public-tls` и ACME webroot -`/var/lib/han-chat/acme`. Internal TLS certificate/key передаются отдельными -Compose secrets и не объединяются с public TLS. +Public TLS и ACME на обеих VM — не named volumes. Root-only ACME state остаётся +на host в `/etc/letsencrypt`; root hook атомарно копирует только нужные +`fullchain.pem`/`privkey.pem` в `/var/lib/han-chat/public-tls`. Compose +монтирует этот staging read-only в nginx, а host webroot +`/var/lib/han-chat/acme` — в nginx и ACME client с минимально необходимыми +правами. Internal TLS certificate/key передаются отдельными Compose secrets и +не объединяются с public TLS. Данные PostgreSQL **не** хранятся в Docker volumes — только managed PostgreSQL вне compose. @@ -454,9 +451,9 @@ Local OTEL queue на каждой VM использует отдельный pe | Host | Порт 80 | Порт 443 | Примечание | |---|---|---|---| -| Веб-домен (frontend) | только `301`/`308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host | +| Веб-домен (frontend) | только `308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host | | API-домен (если выделен) | **не слушает** | только HTTPS | Post-MVP: `api.example.ru` | -| Bitrix Local App ВМ1 (`/bitrix/handler|install|placement`) | только redirect на web host | HTTPS | install/handler/placement | +| Bitrix Local App ВМ1 (`/bitrix/handler|install|placement`) | только `308` на HTTPS | HTTPS | install/handler/placement | | CRM webhook ВМ2 (exact `/bitrix/sync/webhook/contact|alert`) | generic `404/426`, без redirect query token | HTTPS | отдельный processing host | Правила: @@ -468,26 +465,12 @@ Local OTEL queue на каждой VM использует отдельный pe ### TLS и заголовки -- cookies в web-клиенте: `Secure`, `HttpOnly`, корректный `SameSite`; -- OIDC redirect URI в Keycloak — HTTPS; -- `KEYCLOAK_PUBLIC_URL`, issuer и frontend auth discovery URL совпадают по схеме, host и path; -- backend формирует внешние ссылки с учётом `X-Forwarded-Proto=https`; -- HSTS включается в production-like среде **после** проверки доменов и сертификатов; -- TLS 1.0/1.1 запрещены; минимум TLS 1.2, предпочтительно TLS 1.3; -- слабые шифры запрещены на уровне `nginx`; -- `nginx` скрывает `Server`, `X-Powered-By` и аналогичные технологические заголовки; -- security headers: `Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy` для web-приложения; -- инструкция по установке всегда открывается новой вкладкой, поэтому CSP SPA задаёт `frame-src 'none'`; allow-list iframe для инструкций отсутствует; -- секретный ключ сертификата не коммитится в репозиторий; -- использовать сертификаты доверенного CA; автоматизировать выпуск и продление (Let's Encrypt + reload `nginx`); -- non-root nginx не монтирует root-only дерево Let's Encrypt целиком: - root deploy hook атомарно копирует только `fullchain.pem` и `privkey.pem` в - host staging `root:` (`0750`, файлы `0640`), а Compose - монтирует staging read-only; -- reload после renewal выполняется только после `openssl` certificate/key - match и полного `nginx -t`; internal TLS PEM также проверяется на raw PEM, - отсутствие literal `\n`/double-base64 и совпадение ключа; -- закрыть прямой доступ к внутренним портам контейнеров извне. +TLS versions, trusted CA, HSTS/security headers, certificate validation и safe +reload принадлежат [`arch-08-nginx.md`](arch-08-nginx.md); cookie/OIDC и +application security — [`arch-01-system-architecture.md`](arch-01-system-architecture.md). +Compose применяет их через host binds из раздела «Volumes», не монтирует +root-only `/etc/letsencrypt` в nginx, не объявляет named volume public +certificates и не публикует внутренние порты. ## Nginx routing для Bitrix24 Local App @@ -495,7 +478,7 @@ Local OTEL queue на каждой VM использует отдельный pe Рекомендуемая схема: -- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80` → redirect HTTPS; `:443` — TLS + маршрутизация; +- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80` → `308` HTTPS; `:443` — TLS + маршрутизация; - **выделенный API-домен** (post-MVP, опционально): отдельный `server { listen 443 ssl; ... }` **без** `listen 80`; только `/api/*`; - для `location` WebSocket (`/api/v1/realtime`): `proxy_http_version 1.1`, `Upgrade`/`Connection` headers, увеличенный `proxy_read_timeout`; - только `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` на ВМ1 → `bitrix-local-app`; CRM `/bitrix/sync/*` на этом host не маршрутизируется; @@ -576,7 +559,7 @@ WAF не заменяет обязательные лимиты, валидац Минимальные проверки: -- `nginx`: на веб-домене — `301` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — HTTP 200/301 и успешная TLS handshake; +- `nginx`: на веб-домене — `308` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — успешная TLS handshake и ожидаемый route response; - `api-backend`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL `han_app`, Redis DB0/DB1, JWKS/discovery Keycloak и S3 permissions. Недоступность remote Message Safety отражается как degraded dependency и блокирует только send path, но не readiness read API; - `message-safety`: `/health/ready` возвращает process/core status и capability map `text|links|files|worker`; ClamAV/S3 не выключают text, DNS не выключает text без ссылок, Redis hot cache не является core gate; - `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет validated config/secrets, PostgreSQL/grants, worker/limiter state и CRM webhook config; invalid credential/config даёт not-ready, краткая CRM outage — degraded по stale policy; при `BITRIX_SYNC_ENABLED=false` ready возвращает not-ready `sync_disabled`; @@ -635,7 +618,7 @@ endpoint обязан fail-closed при недоступной требуемо 1. ВМ1, ВМ2, SigNoz и managed PostgreSQL находятся в одной private network/VPC; ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress. 2. Managed PostgreSQL не имеет public IP; SG разрешает каждой VM только нужные DB roles/schemas. 3. На каждой VM отдельный root-owned systemd unit выполняет её root Compose; `deploy` не входит в `docker`. -4. Public nginx ВМ2 публикует `80/443`; `80` используется только для ACME/redirect, `443` — только exact CRM webhook. Private `8443` разрешён только от SG ВМ1 и ops для Message Safety/internal access. +4. Public nginx ВМ2 публикует `80/443`; `80` обслуживает ACME, а HTTP exact CRM webhook возвращает `404/426` без redirect; `443` публикует только exact CRM webhook. Private `8443` разрешён только от SG ВМ1 и ops для Message Safety/internal access. 5. Deploy/cutover ВМ2 не требует изменения public routes ВМ1. Для `bitrix-sync` rollback закрывает webhook routes на nginx ВМ2 либо возвращает retryable `503`, останавливает claims и сохраняет durable tasks/mapping; возврат к фиктивному `202 ignored` запрещён. Host firewall обеих VM учитывает post-DNAT semantics `DOCKER-USER`: policy diff --git a/architectory/arch-05-agent-development-process.md b/architectory/arch-05-agent-development-process.md index 56eb6a2..188006e 100644 --- a/architectory/arch-05-agent-development-process.md +++ b/architectory/arch-05-agent-development-process.md @@ -48,7 +48,7 @@ - Для часто используемых фильтров добавляются индексы. - Миграции не должны удалять данные без отдельного согласования. - Все юзеры должны иметь ИД, которое указывается в `updater_user_id` которое они меняют. - +- При создании миграций учитывать, что asyncpg допускает один top-level SQL statement на один execute. ## API diff --git a/architectory/arch-07-observability.md b/architectory/arch-07-observability.md index 7bd937d..0534720 100644 --- a/architectory/arch-07-observability.md +++ b/architectory/arch-07-observability.md @@ -525,15 +525,15 @@ Legal retention/erasure имеет приоритет; изменение тре ### Обнаруженные архитектурные конфликты 1. `arch-03` разрешает stdout/platform exporter как минимум, но production-like расследования и alerts без backend ограничены. Закрыто выбором SigNoz (O1 / O-TBD1); stdout остаётся аварийным buffer. -2. `module-05` использует test-only terminal `400` и non-sticky verdict вместо canonical `403`/sticky production verdict. Dashboards обязаны маркировать сервис `stub`; production SLO Safety на нём недостоверен. Детали — спецификация ВМ2. -3. `module-07` задаёт full sync target; до code/portal cutover dashboard обязан показывать `sync_disabled`, а не синтетический CRM success. Queue/CRM SLI включаются только после module-07 preflight. Детали — спецификация ВМ2. +2. Test-only режимы Message Safety не входят в production SLO. Production dashboards используют canonical `403`, sticky verdict и фактический `processing_mode`; значение `stub` считается ошибкой cutover. Детали — спецификация ВМ2. +3. Queue/CRM SLI включаются только после module-07 preflight/cutover и отражают фактическое состояние `bitrix-sync` на ВМ2; синтетический CRM success запрещён. Детали — спецификация ВМ2. 4. Retention, RPO/RTO и production SLO открыты в module-01/04/06/08; значения этого документа являются initial ops policy, не закрывают legal/product TBD. 5. Observability env (`OTEL_REMOTE_*`, service names, sampling/queue limits) добавлены в arch-04 и `.env.example`; sampling/queue limits уточняются после load test. ## 19. Ссылки - Профильные спецификации: [`module-09-observability-vm1.md`](../VM1_app/documentation/module-09-observability-vm1.md), [`module-09-observability-vm2.md`](../VM2_services/documentation/module-09-observability-vm2.md). -- VM/Docker logging и firewall: [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh). -- Прототипный nginx stdout/access log: [`../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf`](../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf). +- VM/Docker logging и firewall: [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md) и профильные module-10/runbook. +- Nginx stdout/access log: [`arch-08-nginx.md`](arch-08-nginx.md) и профильные module-03. - HTTP/OTLP registry: [`arch-02-api-contracts.md`](arch-02-api-contracts.md). - Compose topology: [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md). diff --git a/architectory/arch-08-nginx.md b/architectory/arch-08-nginx.md index b3734cd..f77dff9 100644 --- a/architectory/arch-08-nginx.md +++ b/architectory/arch-08-nginx.md @@ -40,7 +40,8 @@ Upstream failures не перенаправляются на другой сер ## 3. HTTP/HTTPS и TLS -- public host каждой VM на `:80` обслуживает только ACME challenge и `308 https://$host$request_uri`, кроме явно зафиксированных исключений профильной спецификации; +- public host каждой VM на `:80` обслуживает только ACME challenge и `308 https://$host$request_uri`; +- единственное route-specific исключение: exact CRM webhook ВМ2 по HTTP возвращает `404/426` без redirect, чтобы query token не отражался в `Location`; - выделенный API host, если появится, не имеет listener `:80`; - `:443 ssl http2`, TLS 1.2/1.3, современные cipher suites, session tickets по ops policy; - сертификат доверенного CA, private key read-only и недоступен приложению; @@ -53,24 +54,33 @@ Upstream failures не перенаправляются на другой сер ## 4. ACME lifecycle -Выбран webroot Certbot/ACME client с общими named volumes: +Выбран webroot Certbot/ACME client с host-каталогами: ```text -nginx-certs -> /etc/letsencrypt (rw у certbot, ro у nginx) -nginx-acme-webroot -> /var/www/certbot +/etc/letsencrypt root-only ACME state; rw только у root/ACME client +/var/lib/han-chat/public-tls staged fullchain.pem/privkey.pem; ro bind в nginx +/var/lib/han-chat/acme webroot; bind в nginx и ACME client ``` -Каждая VM выпускает **свой** сертификат на свой public host. Секреты и volumes двух projects не общие. +Named volume для public certificate/key запрещён. Non-root nginx никогда не +монтирует `/etc/letsencrypt`: после успешного issuance/renewal root hook +проверяет certificate/key и атомарно копирует только нужные PEM в +`/var/lib/han-chat/public-tls` с `root:`, directory `0750` +и files `0640`. Каждая VM выпускает **свой** сертификат на свой public host; +host-каталоги двух VM не общие. Bootstrap: 1. DNS указывает на VM; 80/443 разрешены. -2. Запустить временный HTTP config с `/.well-known/acme-challenge/`. -3. Выпустить certificate без остановки nginx. -4. Проверить `nginx -t`, атомарно активировать TLS config, reload. +2. Подготовить root-owned webroot `/var/lib/han-chat/acme` и временный HTTP config с `/.well-known/acme-challenge/`. +3. Выпустить certificate в root-only `/etc/letsencrypt` без остановки nginx. +4. Проверить certificate/key, атомарно обновить `/var/lib/han-chat/public-tls`, выполнить `nginx -t`, активировать TLS config и reload. -Renew container/host timer выполняет `certbot renew` минимум дважды в сутки; -после фактического renewal проверяет рабочую конфигурацию командой +Root-owned host timer выполняет `certbot renew` минимум дважды в сутки; ACME +client может быть контейнеризован, но только он получает rw bind +`/etc/letsencrypt`, а public certificate остаётся host bind, не named volume. +После фактического renewal hook проверяет пару certificate/key, атомарно +обновляет staging и проверяет рабочую конфигурацию командой `docker compose exec -T nginx nginx -t -c /tmp/nginx.conf` и отправляет master-процессу `docker compose kill -s HUP nginx`. Bare-команды `nginx -t` и `nginx -s reload` запрещены: контейнер read-only, а рабочие config/PID находятся @@ -82,7 +92,8 @@ success path считается дефектом интеграции с Certbot Контролируются expiry days и последняя успешная попытка. Staging CA используется в rehearsal, чтобы не исчерпать лимиты. -Non-root nginx не монтирует root-only дерево Let's Encrypt целиком: только необходимые cert/key files по arch-03/arch-06. +Non-root nginx получает только read-only staging +`/var/lib/han-chat/public-tls`; root-only `/etc/letsencrypt` ему недоступен. ## 5. Request ID и forwarded headers @@ -176,7 +187,7 @@ nginx/ ## 11. Docker Compose -`nginx` подключён к `public` и `backend` (или эквивалентным сетям своей VM). Публикация host ports разрешена только nginx. Filesystem read-only, tmpfs для cache/run/temp/`/etc/nginx/conf.d` по arch-03, non-root где позволяет bind ports/capabilities. Cert volumes read-only для nginx. ACME client имеет только необходимые volumes/network. +`nginx` подключён к `public` и `backend` (или эквивалентным сетям своей VM). Публикация host ports разрешена только nginx. Filesystem read-only, tmpfs для cache/run/temp/`/etc/nginx/conf.d` по arch-03, non-root где позволяет bind ports/capabilities. Host staging public TLS и ACME webroot монтируются с минимальными правами; named volume сертификатов запрещён. ACME client имеет только необходимые bind mounts/network. Non-root nginx получает writable tmpfs только для `/etc/nginx/conf.d`, `/var/cache/nginx`, `/var/run` и `/tmp`; tmpfs задаёт явные UID/GID/mode. Основной `nginx.conf` подключает конкретный rendered include, не неограниченный wildcard, который позволил бы обойти `nginx -t`. diff --git a/architectory/arch-10-deployment.md b/architectory/arch-10-deployment.md index 1672cd0..aa8fead 100644 --- a/architectory/arch-10-deployment.md +++ b/architectory/arch-10-deployment.md @@ -22,8 +22,8 @@ 6. S3 — внешний Selectel-compatible storage; клиент получает только presigned URL. 7. Секреты не коммитятся, не вставляются в команды shell history и не выводятся в отчёты. 8. Миграции выполняются отдельными one-shot steps до новой версии приложения. -9. Message Safety запускается как documented stub до замены; это не production antivirus/moderation. -10. `bitrix-sync` вводится только после выполнения preflight/cutover gates module-07; до этого `BITRIX_SYNC_ENABLED=false`, public webhook закрыт на edge. +9. Production Message Safety работает только на ВМ2; local stub/fallback на ВМ1 запрещён. Caller ВМ1 использует private HTTPS, service token и проверку internal CA, при недоступности — fail closed. +10. `bitrix-sync` работает только на ВМ2 и вводится после preflight/cutover gates module-07; до подтверждённого cutover public webhook остаётся закрыт на edge. 11. На ВМ1 и ВМ2 отдельные root Compose projects/systemd units; deploy/rollback выполняются независимо. 12. ВМ2 — самостоятельная service VM с минимальным public webhook ingress, allow-listed egress, отдельным IAM principal и service-specific secret files. 13. OS-роли, SSH/sudo, secrets delivery, container hardening и private-VM lockdown подчиняются arch-06. @@ -49,7 +49,7 @@ приватный IPv4 VM например 10.20.0.0/24 private FQDN/IP managed PG - 5432 или 6432 + 5433 для Selectel PgBouncer; иной порт — только фактическое значение другого provider/endpoint han_chat URL репозитория этой VM /opt/han-chat/backend @@ -115,7 +115,7 @@ DNS: `A `, `A /deploy/ssl-renew.sh`: +- root-only ACME state: `/etc/letsencrypt`; +- read-only для nginx host staging: `/var/lib/han-chat/public-tls`; +- host ACME webroot: `/var/lib/han-chat/acme`; +- root-owned systemd timer/hook дважды в сутки с `flock`; пользователь `deploy` + может запускать только утверждённый unit и не получает доступ к ACME state; +- staging CA rehearsal предшествует production issuance; после renewal root + hook проверяет certificate/key, атомарно обновляет staging, выполняет + container `nginx -t` и только затем HUP; +- success path возвращает `0` с пустым stderr; ошибка сохраняет действующий + certificate/config и поднимает alert (<21 дней, page <7 дней). -1. взять `flock`; -2. `docker compose --profile certbot run --rm certbot renew --webroot -w /var/www/certbot --quiet`; -3. при обновлении проверить `docker compose exec -T nginx nginx -t -c /tmp/nginx.conf`; -4. только после успеха `docker compose kill -s HUP nginx`; -5. записать результат и метрику expiry; -6. ненулевой exit при ошибке; -7. не удалять действующий сертификат; -8. success path — `0` и пустой stderr. - -Пример unit `/etc/systemd/system/han-chat-cert-renew.service`: - -```ini -[Unit] -Description=Renew HAN Chat Let's Encrypt certificate -Requires=docker.service -After=docker.service network-online.target - -[Service] -Type=oneshot -User=deploy -WorkingDirectory= -ExecStart=/deploy/ssl-renew.sh -``` - -Timer `OnCalendar=*-*-* 03,15:20:00`, `RandomizedDelaySec=30m`, `Persistent=true`. `enable --now`, `list-timers`, `certbot renew --dry-run`. Alert <21 дней, page <7 дней. Ошибка renew не останавливает nginx. - -Staging issuance, затем production `--cert-name` текущего host. Host для ВМ1 — ``; для ВМ2 — ``; private `8443` — internal CA, не Let's Encrypt. Сертификат и ACME volume между VM не разделяются. +Host ВМ1 — ``, ВМ2 — ``; private `8443` +использует internal CA, не Let's Encrypt. Ни ACME state, ни staging между VM не +разделяются. ## 10. Сквозной порядок startup и cutover @@ -337,4 +324,4 @@ DR потеря VM: новая Ubuntu в VPC, hardening, DNS, secrets из vault - ВМ1: [`module-10-deployment-vm1.md`](../VM1_app/documentation/module-10-deployment-vm1.md). - ВМ2: [`module-10-deployment-vm2.md`](../VM2_services/documentation/module-10-deployment-vm2.md). -- Прототипы: [`../../HAN_chat/Deploy_steps.md`](../../HAN_chat/Deploy_steps.md), [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py). +- Исполняемые процедуры: [`RUNBOOK.production.ru.md`](../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md) для ВМ1 и [`RUNBOOK.ru.md`](../VM2_services/codebase/services/deployment/RUNBOOK.ru.md) для ВМ2. diff --git a/support&knowlages/backlog.md b/support¬es/backlog.md similarity index 95% rename from support&knowlages/backlog.md rename to support¬es/backlog.md index da84743..5d2ae37 100644 --- a/support&knowlages/backlog.md +++ b/support¬es/backlog.md @@ -30,6 +30,9 @@ # Закрыто 04.08-10.08 ## #BACK_DEFECT Исправлены дублирующиеся триггеры на создание контакта для сервиса синхронизации. Исправлено создание в БД лишних задач на обновление контакта (каждый бустрап пользователя вызывал задачу на обновление контакта) +# Закрыто 18.08-24.08 +## #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.) + # В разработку: 1 #BACK_SECURE После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. (сейчас есть Фиксированный cooldownmin_seconds_between_attempts) @@ -56,7 +59,7 @@ 12. #INFRASTRUCTURE Перераскатить сервисы от деплоя 13. #INFRASTRUCTURE Поднять второй контур для продакшн 14. #INFRASTRUCTURE Спрятать сеть за балансировщиком нагрузки -15. #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.) + 16. #INFRASTRUCTURE WireGuard-only SSH. 17. #LEGAL Обновить документы по ПД - модель угроз и меры защиты. 18. #LEGAL Уведомление в РКН по БД обработки ПД. @@ -72,6 +75,7 @@ 28. #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel 29. #BACK_BUSINESS Определить пул тестовых номеров, чтобы их было легко в Б24 отслеживать. 30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true) +31. #BACK_BUSINESS VM1 -> VM2: `curl -sS --cacert /etc/han/ca/vm2-internal-ca.crt "https://processing.internal:8443/internal/safety/status" | python3 -m json.tool` В коде захардкожен Redis в components = "degraded". Надо реализовать реальную проверку вместо костыля. # Критично для релиза: 1. Разработка message-safety diff --git a/support&knowlages/faq.md b/support¬es/faq.md similarity index 100% rename from support&knowlages/faq.md rename to support¬es/faq.md diff --git a/support&knowlages/releases/#0 deploy-steps.md b/support¬es/releases/#0 deploy-steps.md similarity index 100% rename from support&knowlages/releases/#0 deploy-steps.md rename to support¬es/releases/#0 deploy-steps.md diff --git a/support&knowlages/releases/#1 SMS OTP deploy.md b/support¬es/releases/#1 SMS OTP deploy.md similarity index 100% rename from support&knowlages/releases/#1 SMS OTP deploy.md rename to support¬es/releases/#1 SMS OTP deploy.md diff --git a/support&knowlages/releases/#1.1 VM-service-deploy.md b/support¬es/releases/#1.1 VM-service-deploy.md similarity index 99% rename from support&knowlages/releases/#1.1 VM-service-deploy.md rename to support¬es/releases/#1.1 VM-service-deploy.md index 2def5c6..0c63fef 100644 --- a/support&knowlages/releases/#1.1 VM-service-deploy.md +++ b/support¬es/releases/#1.1 VM-service-deploy.md @@ -39,14 +39,14 @@ find -type f -exec file {} \; | grep -i 'CRLF' docker login cr.selcloud.ru -cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/bitrix-sync +cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/bitrix-sync docker build -t han-bitrix-sync:1.0.3 . REGISTRY=cr.selcloud.ru/han-images docker tag han-bitrix-sync:1.0.3 $REGISTRY/han-bitrix-sync:1.0.3 docker push $REGISTRY/han-bitrix-sync:1.0.3 docker image inspect $REGISTRY/han-bitrix-sync:1.0.3 --format '{{index .RepoDigests 0}}' -cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/message-safety +cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/message-safety docker build -t han-message-safety:1.0.2 . REGISTRY=cr.selcloud.ru/han-images # ваш registry docker tag han-message-safety:1.0.2 $REGISTRY/han-message-safety:1.0.2 @@ -63,7 +63,7 @@ docker pull otel/opentelemetry-collector-contrib:0.117.0 docker image inspect nginxinc/nginx-unprivileged:1.27 --format '{{index .RepoDigests 0}}' docker image inspect redis:7.4 --format '{{index .RepoDigests 0}}' -docker image inspect clamav/clamav:1.4 --format '{{index .RepoDigests 0}}' +docker image inspect clamav/clamav:1.4.6 --format '{{index .RepoDigests 0}}' docker image inspect otel/opentelemetry-collector-contrib:0.117.0 --format '{{index .RepoDigests 0}}' # Генерация ключей для новых пользователей и первичная настройка ВМ2 @@ -148,7 +148,7 @@ netplan try Подтвердите конфигурацию в течение тайм-аута. Затем проверьте: ip -br -4 address show eth1 ip route -ping -c 3 192.168.0.1 +ping -c 3 192.168.0.210 # nginx правим разрешенные адреса nginx/allowlists/ diff --git a/support&knowlages/releases/#2 notifications deploy.md b/support¬es/releases/#2 notifications deploy.md similarity index 100% rename from support&knowlages/releases/#2 notifications deploy.md rename to support¬es/releases/#2 notifications deploy.md diff --git a/support&knowlages/scripts/diagnose-han-chat.sh b/support¬es/scripts/diagnose-han-chat.sh similarity index 100% rename from support&knowlages/scripts/diagnose-han-chat.sh rename to support¬es/scripts/diagnose-han-chat.sh diff --git a/support&knowlages/scripts/han-vm-metrics.sh b/support¬es/scripts/han-vm-metrics.sh similarity index 100% rename from support&knowlages/scripts/han-vm-metrics.sh rename to support¬es/scripts/han-vm-metrics.sh diff --git a/support&knowlages/scripts/instructions.md b/support¬es/scripts/instructions.md similarity index 100% rename from support&knowlages/scripts/instructions.md rename to support¬es/scripts/instructions.md diff --git a/support&knowlages/scripts/message_lost_diagnostic.md b/support¬es/scripts/message_lost_diagnostic.md similarity index 100% rename from support&knowlages/scripts/message_lost_diagnostic.md rename to support¬es/scripts/message_lost_diagnostic.md diff --git a/support&knowlages/scripts/pg_connection_test.sh b/support¬es/scripts/pg_connection_test.sh similarity index 100% rename from support&knowlages/scripts/pg_connection_test.sh rename to support¬es/scripts/pg_connection_test.sh diff --git a/support&knowlages/scripts/send_sms.md b/support¬es/scripts/send_sms.md similarity index 100% rename from support&knowlages/scripts/send_sms.md rename to support¬es/scripts/send_sms.md diff --git a/support¬es/usefull ops/diagnostic_scripts.md b/support¬es/usefull ops/diagnostic_scripts.md new file mode 100644 index 0000000..e69de29 diff --git a/support&knowlages/usefull ops/Обновление clamav образа.md b/support¬es/usefull ops/Обновление clamav образа.md similarity index 100% rename from support&knowlages/usefull ops/Обновление clamav образа.md rename to support¬es/usefull ops/Обновление clamav образа.md diff --git a/test-fail.tar.gz b/test-fail.tar.gz new file mode 100644 index 0000000..e69de29