Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений

This commit is contained in:
mi
2026-08-19 18:24:00 +03:00
parent bbef7a30c9
commit c7a80e7256
103 changed files with 3457 additions and 3725 deletions
+21 -22
View File
@@ -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: <hostname>=<private-ip>.
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
@@ -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"]
@@ -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
@@ -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()
"""
)
@@ -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
"""
)
@@ -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
@@ -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)
@@ -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)
@@ -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()
@@ -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}
@@ -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:"
@@ -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)
@@ -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)"
@@ -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"]
@@ -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
@@ -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())
@@ -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
@@ -1 +0,0 @@
"""HAN bitrix-sync DB connectivity stub."""
@@ -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)
@@ -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,
)
@@ -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 "$@"
@@ -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"]}
@@ -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
@@ -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
@@ -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
@@ -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 <cloud-user>@<VM_IP>
```
Проверьте версию ОС:
```sh
cat /etc/os-release
```
Должна использоваться Ubuntu 24.04 или более новая версия.
## 3. Передача и запуск скрипта настройки VM
Сначала передайте на VM только подготовительный скрипт. Например, с локального
компьютера:
```sh
scp deployment/scripts/setup-vm.sh <cloud-user>@<VM_IP>:/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@<VM_IP>
docker version
docker compose version
```
## 4. Копирование проекта на VM
### Вариант A — через Git
Это предпочтительный вариант: Git применит правило LF для shell-скриптов.
```sh
git clone <URL_РЕПОЗИТОРИЯ> /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@<VM_IP>:/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 '<HAN_APP_PASSWORD>';
CREATE ROLE bitrix_local_app LOGIN PASSWORD '<BITRIX_LOCAL_PASSWORD>';
CREATE ROLE bitrix_sync_user LOGIN PASSWORD '<BITRIX_SYNC_PASSWORD>';
CREATE ROLE message_safety_app LOGIN PASSWORD '<SAFETY_PASSWORD>';
CREATE ROLE keycloak_user LOGIN PASSWORD '<KEYCLOAK_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 <PG_HOST> 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 -- <command>
```
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=<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=<TOKEN_A>
BITRIX_INTERNAL_API_TOKEN=<TOKEN_A>
BITRIX_API_FORWARD_TOKEN=<TOKEN_B>
BITRIX_API_INBOX_TOKEN=<TOKEN_B>
```
Остальные токены должны быть разными:
```dotenv
MESSAGE_SAFETY_SERVICE_TOKEN=<UNIQUE_TOKEN>
BITRIX_SYNC_SERVICE_TOKEN=<UNIQUE_TOKEN>
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<UNIQUE_TOKEN>
CURSOR_HMAC_SECRET=<UNIQUE_TOKEN>
KEYCLOAK_OTP_HMAC_KEY=<UNIQUE_TOKEN_НЕ_КОРОЧЕ_32_БАЙТ>
BITRIX_TOKEN_ENCRYPTION_KEY=<URLSAFE_BASE64_KEY>
KEYCLOAK_ADMIN_PASSWORD=<UNIQUE_ADMIN_PASSWORD>
```
### 8.4. Redis
Создайте три разных пароля в secret backend; там же сформируйте Redis URL.
Следующий блок описывает логический контракт и не является содержимым `.env`:
```dotenv
REDIS_API_PASSWORD=<REDIS_API_PASSWORD>
REDIS_SAFETY_PASSWORD=<REDIS_SAFETY_PASSWORD>
REDIS_HEALTH_PASSWORD=<REDIS_HEALTH_PASSWORD>
REDIS_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/0
REDIS_REALTIME_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/1
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<REDIS_SAFETY_PASSWORD>@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://<bucket>.s3.storage.selcloud.ru/<object-key>`. Это обязательно для
браузерных 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=<ADMIN_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 <SERVICE_NAME>
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
```
### 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 <ADMIN_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 <ADMIN_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 <SERVICE_NAME>
```
## 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 2>/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 запрещено.
+5 -328
View File
@@ -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@<VM2_PUBLIC_IP>:/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='<OPS_PUBLIC_IP>/32' \
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/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-<RELEASE>.tar.gz
tar -tzf vm2-services-<RELEASE>.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' '<EXPECTED_SHA256>' \
/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.tar.gz | sha256sum --check -
ARCHIVE=/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.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='<OPS_PUBLIC_IP>/32' \
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/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 015 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 <PREVIOUS_IMMUTABLE_RELEASE>
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.
@@ -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-паролем.
До окна работ зафиксируйте: `<GIT_SHA>`, image digests, `<EXPECTED_SHA256>`,
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\<USER>\.ssh\han_vm1_deploy `
-C "han-vm1-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\<USER>\.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\<USER>\.ssh\han_vm1_deploy deploy@<VM1_IP>
ssh -i C:\Users\<USER>\.ssh\han_vm1_admin admin@<VM1_IP>
```
В 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 `<GIT_SHA>`, tree —
clean. Архив содержит один корень `backend`, не содержит `.env`, credentials,
caches и private keys:
```powershell
$Release = "<GIT_SHA>"
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@<VM1_IP>:/var/lib/han-deploy/incoming/
```
Под `deploy` разрешены только inventory/checksum:
```sh
RELEASE='<GIT_SHA>'
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='<GIT_SHA>'
EXPECTED_SHA256='<SHA256_FROM_APPROVED_WORKSTATION>'
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://<VM2_PRIVATE_DNS>:8443`,
`MESSAGE_SAFETY_EXTRA_HOST=<VM2_PRIVATE_DNS>=<VM2_PRIVATE_IP>`,
`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/<PG_CA_FILE> \
/etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<PG_CA_FILE>
```
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/<VM2_INTERNAL_CA_FILE> \
/etc/han/ca/vm2-internal-ca.pem
openssl x509 -in /etc/han/ca/vm2-internal-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<VM2_INTERNAL_CA_FILE>
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS> -verify_hostname <VM2_PRIVATE_DNS> \
-CAfile /etc/han/ca/vm2-internal-ca.pem -verify_return_error </dev/null
```
Для initial public certificate DNS уже указывает на ВМ1, `80` свободен:
```sh
PUBLIC_HOST='<PUBLIC_HOST>'
ACME_EMAIL='<OPS_EMAIL>'
certbot certonly --standalone --preferred-challenges http --staging \
-d "$PUBLIC_HOST" --cert-name "${PUBLIC_HOST}-staging" \
--email "$ACME_EMAIL" --agree-tos --no-eff-email --non-interactive
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" --cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" --agree-tos --no-eff-email --non-interactive
```
При первом копировании nginx ещё не работает, поэтому deploy hook до запуска
не вызывается. Скопируйте initial pair теми же root ownership и mode, затем
preflight проверит pair:
```sh
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" \
/var/lib/han-chat/public-tls/fullchain.pem
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" \
/var/lib/han-chat/public-tls/privkey.pem
```
В production Compose nginx обязан монтировать только
`/var/lib/han-chat/public-tls` read-only, не `/etc/letsencrypt`. Если это ещё
legacy named volume, preflight/Compose review — блокер, не workaround.
## 7. Secret sync и static preflight
```sh
systemctl restart han-secrets@production.service
systemctl is-active han-secrets@production.service
journalctl --no-pager -u han-secrets@production.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
/opt/han-chat/current/backend/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
```
В списке production services после VM2 cutover нет local `message-safety`,
`bitrix-sync`, Redis DB2 и test stubs. Только nginx публикует `80/443`; все
images immutable. Не сохраняйте resolved Compose с secret paths/metadata в
общедоступный файл.
## 8. Миграции и seed
После PITR marker под root:
```sh
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-api
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-sms
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
```
Проверьте current/head каждой схемы, идемпотентность seed и negative DDL от
runtime roles. Миграции ВМ1 `han_app` precede dependent VM2 sync cutover.
Временные cross-schema grants выдаёт owner и отзывает после проверки. Unknown
revision, multiple heads или incompatible schema — stop; ручной `stamp` и
downgrade запрещены.
## 9. Упорядоченный первый запуск
Первый запуск выполняет root через фиксированный launcher:
```sh
/usr/local/sbin/han-vm1-compose up -d redis otel-queue-init otel-collector
/usr/local/sbin/han-vm1-compose up -d keycloak sms-service
/usr/local/sbin/han-vm1-compose up -d api-backend bitrix-local-app
/usr/local/sbin/han-vm1-compose up -d \
sms-worker delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
/usr/local/sbin/han-vm1-compose up -d frontend-static
/usr/local/sbin/han-vm1-compose up -d nginx
/usr/local/sbin/han-vm1-compose ps
```
Дождитесь health, затем:
```sh
/usr/local/sbin/han-vm1-compose exec -T nginx nginx -t -c /tmp/nginx.conf
NGINX_ID=$(/usr/local/sbin/han-vm1-compose ps --status running --quiet nginx)
test -n "$NGINX_ID"
docker kill --signal HUP "$NGINX_ID" >/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://<PUBLIC_HOST>/
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST> \
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
```
Ожидается `308`, public endpoints `200`, internal route `404`, valid chain.
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
allow/deny/pending/timeout. Safety status `stub` не принимается.
До переключения `MESSAGE_SAFETY_URL` ВМ2 должна закрыть собственные gates.
После переключения подтвердите private CA/SAN, service token, `text|links|files`
capabilities и fail-closed timeout. Local Safety/Redis DB2 не оставляются как
fallback. Cutover ВМ2 Bitrix sync — отдельное окно.
Проверьте `DOCKER-USER` live counters внешним positive `80/443` и negative
port/source probe:
```sh
iptables -L HAN-CHAT-DOCKER -n -v
systemctl restart han-chat-docker-firewall.service
systemctl restart docker.service
systemctl restart han-chat-docker-firewall.service
iptables -S HAN-CHAT-DOCKER
```
Allow rules должны использовать `--ctorigdstport 80/443`; UFW INPUT counter не
является доказательством published Docker ports.
## 11. Certbot, observability и reboot gate
После запуска nginx переключите renewal на webroot, который Compose монтирует
из `/var/lib/han-chat/acme`, затем:
```sh
certbot reconfigure --cert-name '<PUBLIC_HOST>' \
--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='<PREVIOUS_COMPATIBLE_GIT_SHA>'
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.
@@ -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 <PREVIOUS_IMMUTABLE_RELEASE>
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`.
@@ -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
@@ -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
@@ -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 <<EOF
$ROOT/docker-compose.yml
$ROOT/deployment/preflight.sh
$ROOT/deployment/han-stack@.service
$ROOT/deployment/scripts/tls-deploy-hook.sh
$ROOT/deployment/secrets/han-compose
$ROOT/deployment/secrets/han-secrets
EOF
fi
for executable in \
"$ROOT/deployment/preflight.sh" \
"$ROOT/deployment/scripts/tls-deploy-hook.sh" \
"$ROOT/deployment/secrets/han-compose" \
"$ROOT/deployment/secrets/han-secrets"
do
[ -x "$executable" ] || fail "required executable is not executable: $executable"
if [ -f "$executable" ] && LC_ALL=C grep -q "$(printf '\r')" "$executable"; then
fail "CRLF is forbidden in executable: $executable"
fi
done
if [ -f "$ENV_FILE" ]; then
if grep -Eq '(^|_)(PASSWORD|SECRET|TOKEN|DATABASE_URL|REDIS_URL|PRIVATE_KEY|ACCESS_KEY)=' "$ENV_FILE"; then
fail ".env contains a secret-shaped assignment"
fi
if grep -Eq '=<[^>]+>|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"
@@ -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."
@@ -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:-<not set>}"
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 <<EOF
docker build --target static \\
-t han-chat-frontend-static:local \\
--build-arg EXPO_PUBLIC_API_BASE_URL=${EXPO_PUBLIC_API_BASE_URL} \\
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=${EXPO_PUBLIC_AUTH_BASE_URL} \\
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=${EXPO_PUBLIC_KEYCLOAK_REALM} \\
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=${EXPO_PUBLIC_KEYCLOAK_CLIENT_ID} \\
--build-arg EXPO_PUBLIC_APP_ENV=${EXPO_PUBLIC_APP_ENV} \\
frontend-test-site
EOF
printf '\n%s\n' "=== redeploy static into compose volume ==="
cat <<'EOF'
docker compose --env-file .env up -d --force-recreate frontend-static
docker compose --env-file .env up -d --force-recreate nginx
EOF
if [ "$CHECK_OIDC" -eq 1 ]; then
printf '\n%s\n' "=== OIDC discovery check ==="
curl -fsS "$OIDC_DISCOVERY" | python3 - <<'PY'
import json, sys
doc = json.load(sys.stdin)
print("issuer=", doc.get("issuer"))
print("authorization_endpoint=", doc.get("authorization_endpoint"))
PY
fi
if [ "$errors" -gt 0 ]; then
printf '\nFound %s warning(s). Fix .env or rebuild frontend with the command above.\n' "$errors" >&2
exit 1
fi
printf '\nOK: frontend build env looks consistent with %s\n' "$CONFIG_FILE"
@@ -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 <<EOF
EXTERNAL_IF=${EXTERNAL_IF}
PUBLIC_DOCKER_PORTS=${PUBLIC_DOCKER_PORTS}
EOF
cat >/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 <<EOF
PermitRootLogin no
AllowTcpForwarding no
AllowUsers ${DEPLOY_USER} ${ADMIN_USER}
EOF
log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены"
log "Root SSH отключён; разрешены deploy и admin"
else
log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены"
log "Root SSH пока не отключён: выполните двухфазную проверку"
fi
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
@@ -417,40 +418,45 @@ EOF
systemctl reload ssh
}
configure_application_security() {
step "Безопасные HTTP-заголовки приложения"
local env_file="${DEPLOY_DIR}/.env"
if [[ -f "$env_file" ]]; then
if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then
sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file"
else
printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$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 <<EOF
[Unit]
Description=Renew HAN Chat TLS certificate
After=docker.service
[Service]
Type=oneshot
User=${DEPLOY_USER}
WorkingDirectory=${DEPLOY_DIR}
ExecStart=${renew_script}
EOF
cat >/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 <<EOF | tee -a "$LOG_FILE"
Пользователь развертывания: ${DEPLOY_USER}
Каталог Compose: ${DEPLOY_DIR}
Открытые порты: ${SSH_PORT}, 80, 443
Парольный SSH/X11: отключены
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
Лог настройки: ${LOG_FILE}
Deploy: ${DEPLOY_USER} (без docker/sudo/lxd/adm/journal)
Break-glass: ${ADMIN_USER} (sudo с отдельным паролем)
Incoming: ${INCOMING_DIR}
Активный release: ${DEPLOY_DIR}
Public ingress: 80,443
SSH ingress: ${SSH_PORT}/tcp из любой сети (временно)
Root SSH hardening: ${HARDEN_SSH}
Лог: ${LOG_FILE}
Следующие действия:
1. Проверьте вход в новой SSH-сессии:
ssh ${DEPLOY_USER}@<VM_IP>
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 <command>
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
}
@@ -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
@@ -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 после окна
@@ -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"],
@@ -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
@@ -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
@@ -21,8 +21,6 @@ networks:
volumes:
redis-data:
nginx-certs:
nginx-acme-webroot:
nginx-cache:
frontend-static:
otel-queue:
@@ -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
@@ -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.
@@ -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 .
```
@@ -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:
@@ -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&currentSchema=keycloak&ApplicationName=keycloak
KC_DB_URL_PROPERTIES=currentSchema=keycloak
KC_BOOTSTRAP_ADMIN_USERNAME=bootstrap-admin
+4 -1
View File
@@ -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"]
+11 -7
View File
@@ -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.
@@ -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"
@@ -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",
@@ -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
@@ -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"]
@@ -1 +0,0 @@
"""HAN message-safety service."""
@@ -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)
@@ -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 "$@"
@@ -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"}]}
@@ -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
@@ -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": "\u00a0 дней"},
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\u00a0 дней") == "7 дней"
@@ -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"]
@@ -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"]
@@ -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;
@@ -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"]
@@ -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
@@ -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
@@ -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 \
@@ -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
+36 -14
View File
@@ -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: ожидается <Safety hostname>=<private VM2 IP>"
)
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":
@@ -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"]
+184 -10
View File
@@ -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<tag>[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")
@@ -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()