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

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()
+7 -1
View File
@@ -11,7 +11,13 @@
- [`module-06-bitrix-local-app.md`](module-06-bitrix-local-app.md) — Bitrix24 Open Lines local app.
- [`module-08-keycloak.md`](module-08-keycloak.md) — OIDC/OTP/SmartCaptcha.
- [`module-09-observability-vm1.md`](module-09-observability-vm1.md) — telemetry ВМ1.
- [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) — runbook ВМ1.
- [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) — production
deployment boundary и cutover ВМ1.
- [`RUNBOOK.production.ru.md`](../codebase/backend/deployment/RUNBOOK.production.ru.md)
— единственный исполняемый fresh production runbook ВМ1.
- [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md) — целевой SMS-контур.
Message Safety, CRM sync, nginx/Redis/telemetry и runbook ВМ2 находятся в [`VM2_services/documentation`](../../VM2_services/documentation/README.md). После cutover ВМ1 не запускает `message-safety`, `bitrix-sync` или Redis DB2; вызов Safety идёт по private HTTPS `:8443`.
Legacy single-VM deployment guides и stub runbooks не являются production
инструкциями. In-place перенос legacy VM в production запрещён.
+81 -213
View File
@@ -1,213 +1,81 @@
# module-10-vm1. Runbook развёртывания ВМ1 HAN Chat
> Статус: целевой runbook репозитория ВМ1.
> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
> ВМ2 — [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md). Не переносить команды ВМ2 и не шарить Compose/secrets.
## 1. Границы
ВМ1 владеет edge nginx `80/443`, `api-backend`, Keycloak, SMS, `bitrix-local-app`, Redis DB0/DB1, Collector. После Safety cutover local Safety/Redis DB2 отсутствуют; `MESSAGE_SAFETY_URL` — private HTTPS ВМ2.
`<BACKEND_ROOT>` / `<BACKEND_REPO_URL>` — репозиторий ВМ1. Host ACME — `<PUBLIC_HOST>`.
## 2. Sizing ВМ1
Final sizing — D-TBD2. Disk после pull/build ≥30% free. `PUBLIC_DOCKER_PORTS=80,443`.
## 3. Hardening
По arch-10 §5 / arch-06. Пример:
```bash
sudo DEPLOY_USER=deploy \
DEPLOY_DIR=/opt/han-chat \
SSH_PORT=<SSH_PORT> \
SWAP_SIZE_GB=4 \
PUBLIC_DOCKER_PORTS=80,443 \
./deploy/setup-vm-han-chat.sh
```
Gate 2 — arch-10. Break-glass вне VM.
## 4. Release layout и `.env` ВМ1
Checkout exact SHA в `/opt/han-chat/backend`. Структура: root Compose, `nginx`, `keycloak`, `redis`, `observability`, frontend artifact.
Обязательные группы секретов/config ВМ1:
- `APP_ENV`, release, log level;
- private PG host/port/database и TLS CA; runtime DSN в secret backend;
- Redis ACL URLs DB0/DB1 только в secret backend; после cutover DB2 нет;
- public web/API/auth URLs;
- Keycloak realm/audience/hostname/bootstrap/provider secrets;
- SMS DB URL, парные Keycloak↔SMS tokens, Direct `TOKEN_1`, callback credentials;
- paired service tokens arch-02;
- Bitrix local app client/application/encryption secrets (не CRM sync webhook ВМ2);
- S3 API credentials (не Safety read-only key ВМ2);
- OTEL exporter secrets;
- nginx/TLS/rate limits;
- frontend public build values;
- после cutover: `MESSAGE_SAFETY_URL=https://<private-vm2-name>:8443` и `MESSAGE_SAFETY_CA_HOST_PATH`.
Пары: `BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN`, `BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN`, `KEYCLOAK_SMS_SERVICE_TOKEN == SMS_SERVICE_TOKEN`.
`FRONTEND_DEV_PROXY_ENABLED=false`. Safety timeout согласован с nginx ВМ1. Validation — arch-10 §8; `validate-env` в этом репозитории.
## 5. Images и frontend
Pull или build без production secrets. Frontend:
```bash
cd <FRONTEND_PROJECT_PATH>
npm ci
npm run test
npx expo export --platform web
```
Artifact в versioned `frontend-static`. Build env — только public URL/realm/client id. Secret scanner: нет service tokens/mock OTP/S3 keys.
Image/frontend gate: images по digest; static без secrets; nginx image с request-id/TLS; disk >30% free.
## 6. Root Compose ВМ1
Сервисы: edge `nginx`, `api-backend`, `keycloak`, `sms-service`, `sms-worker`, `bitrix-local-app`, Redis DB0/DB1, local `otel-collector`. После cutover — без `message-safety` и Redis DB2.
Networks: `public`, `backend`, `egress` (`sms-worker`; Keycloak входит только при `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` и только с destination allow-list SmartCaptcha API), `observability`. `sms-service` без egress при отдельном worker.
Volumes: `redis-data`, ACME, `frontend-static`, `otel-queue` + `otel-queue-init`. Единственные published mappings — nginx 80/443.
Compose gate — arch-10 применительно к этому Compose.
## 7. TLS ВМ1
Arch-10 §9 / arch-08, `-d <PUBLIC_HOST> --cert-name <PUBLIC_HOST>`. Private `8443` на ВМ1 нет.
## 8. Миграции и seed ВМ1
Preflight/upgrade:
```bash
cd <BACKEND_ROOT>
docker compose run --rm api-backend alembic current
docker compose run --rm bitrix-local-app alembic current
# PITR marker, затем:
docker compose run --rm api-backend alembic upgrade head
docker compose run --rm bitrix-local-app alembic upgrade head
```
Shared `han_app.sync_queue` мигрирует api-backend **до** sync cutover на ВМ2, но grants `bitrix_sync_user` — после обеих migrations (см. runbook ВМ2). Seed `app_settings` idempotent из этого репозитория.
SMS schema/migrations выполняет `sms-service` на ВМ1.
### Controlled rollout real SMS
1. seed `otp.phone.*`;
2. schema/role `sms`, migrations, seed `sms_setting`/active `auth_otp`;
3. test env с mock Direct;
4. production Direct `TOKEN_1`, sender, template, callback credentials;
5. записать `<IDGTL_STATIC_EGRESS_IP>` из `sms-worker`;
6. deploy `sms-service`/worker и callback route nginx ВМ1, `KEYCLOAK_OTP_MOCK_ENABLED=true`;
7. Keycloak expand migration/SPI;
8. provider smoke на `<IDGTL_TEST_PHONE>`;
9. только после evidence — `KEYCLOAK_OTP_MOCK_ENABLED=false`;
10. проверить durable order, resend, limits.
Rollback SMS: вернуть Keycloak в mock; не удалять schema/journal. Production cutover запрещён при placeholder или нестабильном egress IP.
## 9. Keycloak bootstrap
```bash
cd <BACKEND_ROOT>
docker compose up -d keycloak
```
Bootstrap admin только на первый запуск, затем MFA named admin и удаление bootstrap. Realm: public client PKCE S256, issuer `https://<PUBLIC_HOST>/auth/realms/han-chat`, без `--import-realm` на живой production без diff. Keycloak gate — discovery/JWKS HTTPS, OTP fail-closed, settings bridge.
## 10. Ordered startup ВМ1
После готовности ВМ2 (arch-10 §10 шаги 13):
1. Redis ВМ1, `otel-queue-init`, Collector;
2. API, SMS, Keycloak, local app;
3. edge nginx последним; после readiness — `nginx -t -c /tmp/nginx.conf` и HUP.
4. `MESSAGE_SAFETY_URL` переключается на ВМ2 **только** после cutover gates runbook ВМ2 и legacy gate §14.
Не использовать host ports для health curl. Expected: Redis `PONG`; Keycloak ready; Collector health; API core DB/Redis/JWKS/settings ready. S3/Safety/Open Lines могут быть `degraded` без снятия read API из readiness, но send path при недоступном Safety остаётся fail-closed. Local app до install может быть `portal_not_installed`.
## 11. Bitrix24 local app и Open Lines
Install/handler/placement URL на `https://<PUBLIC_HOST>/bitrix/...`. Canonical internal path `/internal/openlines/v1/*`, не prototype `/bitrix-internal/*`. Open Lines gate: connector line 8, outbound once, operator reply, duplicate callback безопасен.
CRM webhook robots **не** настраиваются на ВМ1.
## 12. Public smoke ВМ1
```bash
curl -I http://<PUBLIC_HOST>/
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
curl -fsS https://<PUBLIC_HOST>/api/v1/public/content
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
curl -i https://<PUBLIC_HOST>/internal/safety/v2/messages/check
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST>
```
Expected: 308; public 200; discovery 200; internal 404; valid cert.
Далее: guest content; 401 без JWT; consent → OTP → PKCE; bootstrap без phone body; `ux_session_id`; silent refresh; logout; wrong OTP. Real SMS mode — по §8.
Safety E2E **со стороны caller** (правила stub до v2 cutover): `ф``422 message_blocked`; allow path; timeout 503/504 без duplicate. Статус Safety `stub` не заменяет production AV.
Files: presigned PUT quarantine, promote/deny, owner-only download, нет URL в logs. Realtime/ownership/idempotency/429 — module-01.
Сквозной first-send до Open Lines требует готовую ВМ2; system end-to-end gate закрывается по arch-10 после обоих runbook.
## 13. Observability ВМ1
[`module-09-observability-vm1.md`](module-09-observability-vm1.md) + arch-07. Сквозной `X-Request-ID` до Safety span — совместно с ВМ2.
## 14. Legacy gate перед cutover Safety
Перед `MESSAGE_SAFETY_URL` на ВМ2:
1. validator принимает только `https://<private-vm2-name>:8443`, требует CA path, запрещает Docker hostname и plaintext;
2. internal CA root-owned; read-test UID `api-backend`, negative посторонний UID;
3. local `message-safety`, Redis DB2 и local rules-version env удалены из Compose/validator;
4. root-owned stack unit; `deploy` не в `docker`;
5. `DOCKER-USER` counters через `conntrack --ctorigdstport` после Docker restart и reboot;
6. images digest; rollback по compatible digests;
7. ordered startup этого runbook, не legacy `docker compose up -d`;
8. `han-secrets` и firewall oneshot явно перезапущены; TLS renew success — пустой stderr.
Ни старый single-VM guide, ни успешный stub Compose не являются evidence. Rollback caller — предыдущий immutable release ВМ1. Уже созданные v2 tasks не down-migrate.
## 15. Rollback, ops, incidents ВМ1
Rollback application-only: previous digests, без Alembic downgrade; при SMS incident — mock OTP, сохранить journal. Redis restore — clean instance, прогрев idempotency из PG ([`module-04-redis-vm1.md`](module-04-redis-vm1.md)). Keycloak restore — проверить issuer/JWKS/PKCE/OTP.
Routine: health, PG/TLS/disk/OTEL/Redis, Keycloak signing, Bitrix connector desired/observed.
Incident triage — arch-10 команды в `<BACKEND_ROOT>` ВМ1. Типовое: API 503 (DB/Redis/JWKS/Safety circuit); send timeout — не новый idempotency key; Redis loss — polling.
Потеря ВМ1: provision в той же VPC, restore secrets из vault, existing PG/S3, TLS `<PUBLIC_HOST>`, Bitrix local app/callbacks verify. Не пересоздавать ВМ2.
## 16. Definition of Done ВМ1
Дополнительно к arch-10 §12:
- Compose/nginx/Redis/Collector ВМ1 прошли профильные compose/nginx gates;
- Keycloak realm/provider/PKCE/OTP готов;
- SMS либо mock с accepted risk, либо real mode после §8;
- Bitrix connector line 8 проверен;
- auth/text/file/realtime E2E caller-side зелёный;
- observability ВМ1 + redaction;
- после cutover — legacy gate §14 закрыт.
## 17. TBD ВМ1
D-TBD1, D-TBD2 (VM1 sizing/SLO), D-TBD8 CLI, D-TBD9 Keycloak admin VPN.
## 18. Ссылки
- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
- ВМ2: [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md).
- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md).
# module-10-vm1. Production deployment ВМ1
> Канонический исполняемый fresh-VM runbook:
> [`RUNBOOK.production.ru.md`](../codebase/backend/deployment/RUNBOOK.production.ru.md).
> Общие security/deployment контракты:
> [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md)
> и [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
> ВМ2:
> [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md).
## Граница документа
Этот модуль фиксирует только связь спецификаций и cutover boundary. Команды
bootstrap, release activation, secrets, TLS, migrations, systemd, smoke,
reboot, rollback и DR находятся только в production runbook.
Production ВМ1 разворачивается на fresh Ubuntu 24.04. In-place hardening
legacy single-VM/stub инсталляции запрещён. Legacy
`deployment/DEPLOYMENT_GUIDE.ru.md`, `RUNBOOK.ru.md` и `RUNBOOK.md` являются
только указателями.
## Host deployment contract
- разные Ed25519 keys для `deploy` и break-glass `admin`, не совпадающие с
root bootstrap key;
- `deploy` не входит в `docker`, `sudo`, `lxd`, `adm`,
`systemd-journal`; пишет только в `/var/lib/han-deploy/incoming`;
- active release, Compose, `.env`, scripts/helpers, units, secret mappings,
credentials и sudoers принадлежат root;
- activation проверяет approved SHA-256, project root, absolute/`..` paths,
symlink/hardlink и executable modes;
- lifecycle — `han-secrets@production.service` и
`han-stack@production.service` через exact sudoers;
- public TLS копируется root hook из `/etc/letsencrypt` в
`/var/lib/han-chat/public-tls`; nginx не получает дерево Certbot;
- published Docker ports фильтруются по original destination
`conntrack --ctorigdstport 80/443`, включая restart/reboot gates.
## Cutover ВМ1 → ВМ2
ВМ1 владеет public guest/API/auth/SMS/Bitrix local-app ingress `80/443`.
ВМ2 владеет Message Safety v2 на private HTTPS `:8443` и CRM sync public
webhooks собственного host. Compose, IAM principal и secret bundle между VM не
разделяются.
До переключения caller на ВМ2:
1. ВМ2 закрыла TLS SAN/CA, PG migrations, Safety capabilities, S3 read-only,
performance, egress и rollback gates своего runbook.
2. ВМ1 использует
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443` и root-owned
`MESSAGE_SAFETY_CA_HOST_PATH`.
3. Internal CA читается фактическим UID API и не читается посторонним UID.
4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops.
5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback
отсутствуют в production Compose/validator.
6. Caller smoke подтверждает allow/deny/pending/timeout, file flow и
fail-closed поведение; status `stub` не принимается.
7. После cutover фиксируются release/image digests, schema/config versions,
firewall counters, traces и rollback approval.
Rollback caller переключает только на предыдущий schema-compatible immutable
release ВМ1. Уже созданные tasks ВМ2 не down-migrate. Потеря ВМ2 не разрешает
fail-open или возврат local stub.
Bitrix sync cutover выполняется отдельно по runbook ВМ2 после expand migrations
`han_app` на ВМ1. CRM webhook ВМ2 не маршрутизируется через nginx ВМ1.
## Связанные модули
- API caller и Safety v2:
[`module-01-api-backend.md`](module-01-api-backend.md).
- Public edge:
[`module-03-nginx-vm1.md`](module-03-nginx-vm1.md).
- Redis DB0/DB1 и удаление legacy DB2:
[`module-04-redis-vm1.md`](module-04-redis-vm1.md).
- Keycloak: [`module-08-keycloak.md`](module-08-keycloak.md).
- Observability:
[`module-09-observability-vm1.md`](module-09-observability-vm1.md).
- SMS cutover:
[`module-11-idgtl-sms.md`](module-11-idgtl-sms.md).
Binary file not shown.
Binary file not shown.
@@ -1,14 +1,22 @@
# VM2 Processing deployment runbook
This directory is the independent VM2 foundation. It does not deploy VM1 or
`codebase/backend`. All commands below are operator commands; repository
creation does not execute them.
This directory is the independent VM2 foundation in the `VM2_services`
repository. It does not deploy VM1 or `VM1_app/codebase/backend`. All commands
below are operator commands; repository creation does not execute them.
Repository root: `HAN_chat_specification/VM2_services`. Compose and deployment
artifacts live under `VM2_services/codebase/services/`. Local operator commands
assume the current directory is `VM2_services` unless stated otherwise.
The full step-by-step procedure with gates and copy-paste commands is in
[`RUNBOOK.ru.md`](RUNBOOK.ru.md).
## Production blockers before first start
1. Replace every `.env` placeholder with reviewed non-secret values. Keep
`BITRIX_SYNC_ENABLED=false` until migrations, grants, portal fields, robot
contracts and cutover are signed off.
contracts and cutover are signed off. Build and push application images to
the registry first (blocker 2).
2. Fill every `*_IMAGE` variable with a reviewed registry digest. Root Compose
rejects missing image references; mutable tags are not production evidence.
3. Install production files as `root:root`; `deploy` must not be in `docker`
@@ -38,61 +46,156 @@ creation does not execute them.
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
Registry/package access exists only during controlled maintenance windows.
## Install
## Who runs what
- Bootstrap a fresh Ubuntu 24.04 VM as root with
`deployment/scripts/setup-vm.sh`, supplying `VM1_PRIVATE_CIDRS`, optional
private/VPN `OPS_CIDRS`, and separate Ed25519 public-key files for deploy and
break-glass admin. SSH is publicly reachable but key-only and protected by
fail2ban; the CIDR variables apply only to private port `8443`. The script
installs host packages/firewalls and roles but never starts Compose. Set a
separate admin sudo password; verify deploy login, admin login and admin sudo
in independent sessions before rerunning with `HARDEN_SSH=true`.
Root/deploy/admin key reuse is rejected.
- Checkout an immutable release under `/opt/han-chat/services`.
- Copy `.env.example` to root-owned mode `0600` `.env`.
- Install `secrets_loader.py` and `han-secrets` under
`/usr/local/lib/han-secrets-vm2/`, root-owned and non-writable.
- Install `han-compose` as `/usr/local/sbin/han-vm2-compose`.
- Install `han-secrets-vm2.service` and `han-processing.service` under
`/etc/systemd/system/`.
- Install `han-message-safety-mode` as root-owned `0755` and the sudoers
template as `/etc/sudoers.d/deploy-message-safety-mode` mode `0440`; validate
with `visudo -cf`. Create the dedicated host group `han-message-safety` with
GID `10001`. Before the first Compose validation, create
`/etc/han-chat/message-safety-mode.env` as
`root:han-message-safety 0640` with all three flags `false` (or invoke the
helper's `standard` transition after the fixed launcher is installed).
- Install loader config using the exact `APP_ENV` suffix. With the committed
example (`APP_ENV=production-like`) the path is
`/etc/han/secrets/vm2-production-like.selectel.json` mode `0600`. For
controlled no-provider recovery use an explicit `file`
config pointing to a root-only `0700` directory containing exactly one file
per configured key. Selectel failure never falls back automatically.
- **Operator workstation:** builds the release archive and transfers it to VM2.
Local examples use PowerShell from `VM2_services`.
- **`root` on VM2:** host bootstrap, verified release activation, root-owned
files, `.env`, secret mapping, credentials, TLS/allow-lists, migrations and
first start.
- **`deploy` on VM2:** accepts releases only in `/var/lib/han-deploy/incoming`,
checks status/logs and runs installed fixed systemd operations through exact
sudo rules. `deploy` must not run `docker`, edit `/opt/han-chat/services` or
join the `docker` group.
- **`admin` on VM2:** personal break-glass role with a separate SSH key and
local sudo password. Not used for routine deploy; not in `docker`/`lxd`; every
login and sudo call is an incident operation.
## Preflight and startup
## Prerequisites (before §1)
Run `deployment/preflight.sh` first. Then, through the approved root units:
This runbook covers operations **on an already provisioned VM2**. Prepare outside
Compose first:
1. synchronize secrets; any missing/oversized/invalid secret blocks startup;
2. validate resolved Compose without storing its output;
3. run the two `ops` migration jobs and create/activate the reviewed initial
Message Safety config before starting either runtime;
4. validate nginx config and both certificate chains;
5. start Redis/Collector, ClamAV, application API/workers, then nginx;
6. verify that only nginx publishes `80`, `443`, and private-bound `8443`;
7. verify all non-exact public paths return `404`, HTTP webhook paths return
`426` without redirect/query reflection, wrong methods fail, and wrong
source CIDRs are rejected before upstream;
8. verify private Safety check/task/status and sync status only from approved
callers; verify public `/internal/*` is `404`;
9. canary telemetry with a fake token marker and prove query, form body,
Authorization, DSN, S3 key and object key are absent from logs/traces.
1. **Selectel infrastructure** — VPC/subnet, SG (public `80/443/22`; private
`8443` only; default-deny egress after bootstrap), sizing (4 vCPU / 8 GB RAM /
80 GB SSD — see
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)),
public and private VM2 IPs, DNS A record for `PROCESSING_PUBLIC_HOST`.
2. **Managed PostgreSQL** — schemas/roles for `message_safety` and
`bitrix_sync`, separate migration/runtime DSNs; see
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
otel-collector).
4. **Selectel Secrets Manager** — populate all remote names from
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, internal TLS PEM for `8443`). Dedicated VM2 IAM principal
with read-only access to those names only.
5. **S3 quarantine bucket** and SigNoz OTLP endpoint — non-secret values in
`.env`.
6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM
stored in Secrets Manager, not in the release tree.
Section order: §1–§5 → Gates 19 → §7 (post-acceptance). Run
`systemctl enable` and `systemctl start han-processing.service` **only after
Gate 5 succeeds**.
## 1. Bootstrap a fresh VM2
From `VM2_services` on the operator workstation, copy the setup script:
```powershell
scp -i C:\Users\MI\.ssh\hansel `
.\codebase\services\deployment\scripts\setup-vm.sh `
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
```
Bootstrap as root with separate Ed25519 deploy/admin keys, `VM1_PRIVATE_CIDRS`,
optional `OPS_CIDRS`, admin sudo password, deploy/admin login verification, then
rerun with `HARDEN_SSH=true`. Full commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §1.
## 2. Release transfer under `deploy`
From `VM2_services`:
```powershell
$Release = "<VERSION_OR_GIT_SHA>"
tar --exclude=services/.env `
--exclude='services/**/__pycache__' `
--exclude='services/**/.pytest_cache' `
--exclude='services/**/.ruff_cache' `
-czf "vm2-services-$Release.tar.gz" -C .\codebase services
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\hansel "vm2-services-$Release.tar.gz" `
deploy@<VM2_PUBLIC_IP>:/var/lib/han-deploy/incoming/
```
`deploy` verifies SHA-256 and archive listing only; it must not unpack into
production. Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §2–§3.
## 3. Activation and root-owned install
`root` verifies the archive, extracts to staging, rsyncs into
`/opt/han-chat/services`, reruns `setup-vm.sh` to install helpers/units. Details:
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §3.
## 4. Non-secret config and Selectel
Copy `.env.example``.env`, install loader config as
`/etc/han/secrets/vm2-<APP_ENV>.selectel.json`, encrypt Selectel service-user
password with `systemd-creds`, edit nginx allow-lists. Details:
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4.
## 5. PostgreSQL CA and initial public TLS
Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
`PROCESSING_PUBLIC_HOST`, stage public cert/key for nginx. Details:
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §5.
## 6. Gates 19: preflight, migrations and first start
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
`han-processing.service` until Gate 5 completes successfully.
| Gate | Purpose |
| --- | --- |
| 1 | Secrets materialized via `han-secrets-vm2.service` |
| 2 | Static preflight and resolved Compose (`@sha256:` images) |
| 3 | Migrations, initial Message Safety config create/activate |
| 4 | nginx `-t` with TLS and upstream placeholders |
| 5 | Ordered `compose up`; then `systemctl enable/start han-processing` |
| 6 | Host ports, public/private certificate chains, certbot renewal |
| 7 | Public routing smoke (308/404/426/405) |
| 8 | Private Safety/sync API from VM1/ops only |
| 9 | Fake-token canary — no secrets in logs/traces |
After Gate 5, `deploy` may run:
```sh
sudo systemctl restart han-secrets-vm2.service
sudo systemctl restart han-processing.service
sudo systemctl --no-pager status han-processing.service
sudo journalctl --no-pager -u han-processing.service
```
Full gate commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §6.
Do not open webhook traffic while `bitrix-sync` is disabled. A disabled or
failed receiver must return retryable `503`/closed routing, never successful
`2xx ignored`.
## 7. After Gate 9 — post-acceptance
Gate 9 completes VM2 technical acceptance but **does not** authorize Message
Safety cutover on VM1 or Bitrix sync enablement.
1. Verify autostart: `docker`, `han-chat-vm2-docker-firewall`, `han-secrets-vm2`,
`han-processing`, `certbot.timer`.
2. Reboot-gate: `systemctl reboot`, then repeat autostart checks and Gates 68
briefly.
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
timer, unit journals — without secret values.
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV
signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API).
5. Proceed to controlled Message Safety cutover on VM1 — see
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix
public allow-list remains `deny all;` until
[`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md)
cutover gates are signed off.
Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7.
## Failure policy
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
@@ -1,8 +1,12 @@
# Ранбук развёртывания Processing на VM2
Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не
затрагивает `codebase/backend`. Все команды ниже — операторские; создание
репозитория их не выполняет.
Этот каталог — независимая основа VM2 в репозитории `VM2_services`. Он не
разворачивает VM1 и не затрагивает `VM1_app/codebase/backend`. Все команды
ниже — операторские; создание репозитория их не выполняет.
Корень репозитория ВМ2: `HAN_chat_specification/VM2_services`. Compose и
deployment-артефакты: `VM2_services/codebase/services/`. Локальные команды
ниже предполагают текущий каталог `VM2_services`, если не указано иное.
## Блокеры production перед первым запуском
@@ -64,6 +68,33 @@
не входит в `docker`/`lxd`; каждый вход и sudo-вызов считается инцидентной
операцией.
## Предварительные условия (до §1)
Этот runbook описывает операции **на уже созданной VM2**. До bootstrap
подготовьте вне Compose:
1. **Инфраструктура Selectel** — VPC/subnet, SG (`80/443/22` public;
`8443` только private; egress default-deny после bootstrap), sizing
(4 vCPU / 8 ГБ RAM / 80 ГБ SSD — см.
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)),
public и private IP VM2, DNS A-запись `PROCESSING_PUBLIC_HOST`.
2. **Managed PostgreSQL** — schemas/roles для `message_safety` и
`bitrix_sync`, отдельные migration/runtime DSN; см.
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
clamav, otel-collector).
4. **Selectel Secrets Manager** — заполнить все remote names из
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal
VM2 с read-only доступом только к этим именам.
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
PEM хранится в Secrets Manager, не в каталоге релиза.
Порядок разделов §1–§5 → Gates 19 → §7 (post-acceptance). `systemctl enable`
и `systemctl start han-processing.service`**только после успешного Gate 5**.
## 1. Bootstrap свежей VM2
На локальном компьютере один раз создайте **два разных** ключа. Закрытые части
@@ -85,8 +116,9 @@ break-glass оператору и храниться отдельно от deplo
каталог:
```powershell
# текущий каталог: ...\HAN_chat_specification\VM2_services
scp -i C:\Users\MI\.ssh\hansel `
.\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh `
.\codebase\services\deployment\scripts\setup-vm.sh `
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
scp -i C:\Users\MI\.ssh\hansel `
C:\Users\MI\.ssh\han_vm2_deploy.pub `
@@ -161,8 +193,8 @@ sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
```
## 2. Передача релиза под `deploy`
На локальном компьютере из каталога `HAN_chat_specification`:
На локальном компьютере из каталога `VM2_services`:
```powershell
$Release = "<VERSION_OR_GIT_SHA>"
@@ -390,71 +422,16 @@ Nginx с primary GID `11001` получает только подготовле
`/etc/letsencrypt` остаётся доступен только root/Certbot. Не копируйте private
key в каталог релиза и не делайте его world-readable.
## 6. Preflight, миграции и первый запуск под `root`
## 6. Gates 19: preflight, миграции и первый запуск под `root`
Сначала синхронизируйте секреты. Затем выполните статический preflight:
```sh
systemctl start han-secrets-vm2.service
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
```
До runtime выполните миграции отдельными DB roles и активируйте начальный
Message Safety config:
Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД
выдаёт Bitrix migration-role временный read-only доступ к legacy mapping:
```sql
GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
GRANT SELECT ON TABLE han_app.entity_external_mapping
TO <BITRIX_SYNC_MIGRATION_ROLE>;
```
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version 1 --approved-by '<APPROVER>'
```
После успешного `bitrix-sync-migrate` администратор БД отзывает временные
права. Право `USAGE` отзывайте только если оно не требуется этой роли для
других согласованных операций:
```sql
REVOKE SELECT ON TABLE han_app.entity_external_mapping
FROM <BITRIX_SYNC_MIGRATION_ROLE>;
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
```
Первый запуск и enable выполняет `root` только после прохождения gates:
(внутри gate5)
```sh
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
journalctl --no-pager -u han-processing.service
```
Дальнейшие штатные операции может выполнить `deploy`:
```sh
sudo systemctl restart han-secrets-vm2.service
sudo systemctl restart han-processing.service
sudo systemctl --no-pager status han-processing.service
sudo journalctl --no-pager -u han-processing.service
```
Выполняйте gates **строго по порядку** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9.
Не включайте `han-processing.service` и не делайте `systemctl enable`, пока
Gate 5 не завершился успешно.
Установка/редактирование unit, Compose, `.env`, secret mapping, credential,
TLS, allow-list и запуск migration jobs остаются операциями `root`.
После Gate 5 штатный restart/status/logs для `deploy` — см. блок
«Дальнейшие штатные операции» ниже.
### Gate 1 — секреты материализованы
@@ -490,7 +467,39 @@ deployment/preflight.sh
### Gate 3 — миграции и активный Message Safety config
Команды миграций из предыдущего раздела выполняются под `root`. После них:
Под `root`. Перед первым `bitrix-sync-migrate` владелец `han_app` или
администратор БД выдаёт Bitrix migration-role временный read-only доступ к
legacy mapping:
```sql
GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
GRANT SELECT ON TABLE han_app.entity_external_mapping
TO <BITRIX_SYNC_MIGRATION_ROLE>;
```
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version 1 --approved-by '<APPROVER>'
```
После успешного `bitrix-sync-migrate` администратор БД отзывает временные
права. Право `USAGE` отзывайте только если оно не требуется этой роли для
других согласованных операций:
```sql
REVOKE SELECT ON TABLE han_app.entity_external_mapping
FROM <BITRIX_SYNC_MIGRATION_ROLE>;
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
```
Проверьте head revision и активную config version:
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
@@ -623,6 +632,16 @@ Message Safety; проверьте её без вывода секретов:
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
journalctl --no-pager -u han-processing.service
```
Дальнейшие штатные операции может выполнить `deploy`:
```sh
sudo systemctl restart han-secrets-vm2.service
sudo systemctl restart han-processing.service
sudo systemctl --no-pager status han-processing.service
sudo journalctl --no-pager -u han-processing.service
```
### Gate 6 — host ports и сертификаты
@@ -811,47 +830,10 @@ unset CANARY
упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию,
никогда успешный `2xx ignored`.
## Политика отказов
## 7. После Gate 9 — post-acceptance
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
менять вердикты.
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
запускает `docker compose down -v`.
## Аварийный MOCK
Разрешены только эти пять sudo-команд:
```text
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
```
Хелпер атомарно пишет только
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
# Gate 9 завершает техническую приёмку VM2, но не означает production cutover сервисов.
Gate 9 завершает техническую приёмку VM2, но **не** означает production
cutover Message Safety на VM1 и **не** разрешает включать Bitrix sync.
Дальнейший порядок:
@@ -912,6 +894,237 @@ BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_MODE=disabled
```
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после выполнения gates `module-07`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal.
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после
выполнения gates [`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md):
поля портала, webhooks, migrations, grants, backfill/watermark и rollback
rehearsal.
Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix.
После успешного Gate 9 выполните reboot-gate (п. 2 выше), зафиксируйте
приёмку (п. 3), затем выполните отдельный controlled cutover ниже. Gate 9 сам
по себе не разрешает переключать caller.
### Controlled cutover Message Safety на VM1
Cutover выполняется в согласованное окно совместно с Safety Service,
Rule Pack, Security, Product и Operations. До начала зафиксируйте текущий и
предыдущий schema-compatible immutable release VM1, ответственного за rollback
и stop conditions. `bitrix-sync` в это окно не включается.
#### 1. Предварительные условия
До изменения VM1 должны быть выполнены все условия:
- reboot-gate VM2 и повторные Gates 6–8 успешны;
- Safety работает в `standard`, не в `mock`;
- active config и rules bundle утверждены, Safety v2 migrations находятся на
ожидаемом head;
- `text`, `links`, `files`, `worker` имеют состояние `ready`;
- VM2 имеет только read-only доступ к versioned S3 quarantine objects;
- performance, egress negative tests и redaction Gate 9 закрыты;
- private DNS VM2 резолвится с VM1 только в private VPC address;
- security group/firewall разрешает `8443` от VM1 и approved ops, но не из
интернета;
- на VM1 подготовлен release без local `message-safety`, Redis DB2, local rules
env и stub fallback;
- один и тот же production service token подготовлен в раздельных secret
bundles VM1 и VM2; значение токена не печатается и не копируется в
`/etc/han/vm1.env`.
На VM1 под `root` повторите проверку TLS и readiness:
```sh
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS_NAME> \
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
-CAfile /etc/han/ca/vm2-internal-ca.pem \
-verify_return_error </dev/null
curl --fail --silent --show-error \
--cacert /etc/han/ca/vm2-internal-ca.pem \
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
```
В TLS-выводе ожидается успешная проверка chain/SAN. В status ожидаются
`processing_mode=standard`, непустой `config_version` и
`text|links|files|worker=ready`. `stub`, `mock`, `not_ready` или недоступная
capability — stop condition.
#### 2. Прямой pre-cutover smoke с VM1
Прямой smoke доказывает route, CA и paired token до перезапуска caller:
```sh
SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
cacert = "/etc/han/ca/vm2-internal-ca.pem"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "X-Request-ID: ${MESSAGE_ID}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM1 to VM2 cutover canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
```
Ожидается `HTTP 200`, `verdict=allow`, `processing_mode=standard` и непустые
`config_version`/`rules_version`. Отдельный запрос с фейковым token marker
должен вернуть `401`; production token для negative test не изменяйте.
До переключения также выполните через API VM2:
- deny smoke на утверждённом безопасном corpus case — ожидается `403`;
- повтор запроса с тем же `message_id` и тем же body — тот же sticky result;
- тот же `message_id` с другим body — `409`;
- file smoke только с реальным versioned quarantine object:
`202 + Location + Retry-After`, затем terminal `200` или `403`;
- lease/fencing smoke с остановкой/возвратом worker по утверждённому test case:
task не исполняется двумя владельцами и сохраняет sticky terminal result.
Не используйте выдуманные S3 key/version/ETag и не загружайте EICAR в
production bucket вне согласованного security test.
#### 3. Переключение caller на VM1
На VM1 установите и проверьте internal CA по процедуре
[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md)
§6. В `/etc/han/vm1.env` должны быть:
```dotenv
MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS_NAME>:8443
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
```
В root-owned Selectel secret mapping VM1 переменная
`MESSAGE_SAFETY_SERVICE_TOKEN` должна ссылаться на согласованный remote secret.
После review config и mapping:
```sh
cd /opt/han-chat/current/backend
./scripts/validate-env /etc/han/vm1.env
systemctl restart han-secrets@production.service
systemctl is-active han-secrets@production.service
journalctl --no-pager -u han-secrets@production.service
./deployment/preflight.sh
/usr/local/sbin/han-vm1-compose config --quiet
/usr/local/sbin/han-vm1-compose config --services
/usr/local/sbin/han-vm1-compose config --images
```
В `config --services` не должно быть local `message-safety`, `bitrix-sync`,
Redis DB2 или test stub. Не сохраняйте resolved Compose в файл и не выводите
secret values. Если preflight успешен, переключите caller:
```sh
systemctl restart han-stack@production.service
systemctl is-active han-stack@production.service
/usr/local/sbin/han-vm1-compose ps
```
#### 4. Post-cutover проверки через caller
Проверки выполняются через public API/штатный UI VM1, а не только прямым curl к
VM2:
1. benign text проходит Safety и отправляется ровно один раз;
2. утверждённый deny text не отправляется в Bitrix и возвращает клиенту generic
`message_blocked` без internal `rule_id`;
3. сообщение с безопасной HTTP/HTTPS-ссылкой проходит, запрещённая
private/link-local/metadata ссылка блокируется без HTTP fetch этой ссылки;
4. реальный quarantine file проходит `pending` и terminal result, после allow
продвигается штатным caller flow; deny-файл не продвигается;
5. повтор client/idempotency request не создаёт второе сообщение или второй
Safety task;
6. correlation request ID виден в VM1, VM2 и SigNoz без текста сообщения,
token, object key и других секретов.
Затем согласованным способом кратко сделайте VM2 недоступной **только для
test request** и подтвердите fail-closed: VM1 не отправляет и не продвигает
контент, возвращает контролируемую retryable ошибку, а local/stub fallback не
активируется. Сразу восстановите доступ и повторите benign smoke. Не имитируйте
отказ остановкой всей VM2, если на ней уже есть другой production traffic.
#### 5. Rollback rehearsal
Rollback caller — только на заранее проверенный предыдущий
schema-compatible immutable release VM1 по
[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md)
§12. Он не меняет nginx VM2, не понижает schema и не удаляет уже созданные
Safety tasks:
```sh
PREVIOUS='<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
```
После rehearsal повторите public smoke, верните approved current release тем же
атомарным способом и снова повторите smoke. Потеря VM2 не разрешает fail-open,
переключение на local stub или обход Safety. При несовместимой migration
rollback запрещён: используйте forward fix либо заранее согласованный recovery
plan.
#### 6. Фиксация cutover
Сохраните без secret values:
- VM1/VM2 release и image digests, Safety schema head;
- private certificate fingerprint/expiry, `config_version` и `rules_version`;
- результаты allow/deny/pending/file/timeout/fail-closed/idempotency checks;
- firewall counters и доказательство недоступности `8443` с запрещённого
source;
- traces/log search и результат redaction canary;
- фактическое время переключения и rollback rehearsal;
- approvals Safety Service, Rule Pack, Security, Product и Operations.
Только после успешного выполнения всех пунктов Message Safety cutover считается
завершённым. Cutover `bitrix-sync` остаётся отдельным изменением по
[`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md).
## Политика отказов
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
менять вердикты.
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
запускает `docker compose down -v`.
## Аварийный MOCK
Разрешены только эти пять sudo-команд:
```text
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
```
Хелпер атомарно пишет только
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
@@ -0,0 +1,588 @@
#!/usr/bin/env bash
# Создание OS-пользователя tunnel на VM2 для SSH local port forwarding к PostgreSQL/PgBouncer.
#
# Контракт: arch-06 — только local forwarding, PermitOpen allow-list, без sudo/shell.
# На VM2 по умолчанию AllowTcpForwarding=no; исключение только для tunnel.
#
# Использование (на VM2 под root):
# TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
# PG_HOST=192.168.0.210 \
# PG_PORT=6433 \
# bash setup-tunnel-user.sh
#
# Опционально:
# TUNNEL_USER=tunnel
# TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24" # ограничить источники SSH
# TUNNEL_ALLOW_ANY_SOURCE=true # осознанно разрешить ключу вход с любого адреса
# EXTRA_PERMIT_OPEN="192.168.0.210:5433" # доп. endpoint (direct PG)
# SSHD_MAIN_CONF=/etc/ssh/sshd_config.d/00-han-chat-vm2.conf
#
# По умолчанию TUNNEL_SOURCE_CIDRS обязателен. Разрешение любого источника
# требует явного TUNNEL_ALLOW_ANY_SOURCE=true и должно компенсироваться SG/firewall.
set -Eeuo pipefail
IFS=$'\n\t'
TUNNEL_USER="${TUNNEL_USER:-tunnel}"
TUNNEL_AUTHORIZED_KEY_FILE="${TUNNEL_AUTHORIZED_KEY_FILE:-}"
TUNNEL_SOURCE_CIDRS="${TUNNEL_SOURCE_CIDRS:-}"
TUNNEL_ALLOW_ANY_SOURCE="${TUNNEL_ALLOW_ANY_SOURCE:-false}"
PG_HOST="${PG_HOST:-192.168.0.210}"
PG_PORT="${PG_PORT:-6433}"
EXTRA_PERMIT_OPEN="${EXTRA_PERMIT_OPEN:-}"
SSHD_MAIN_CONF="${SSHD_MAIN_CONF:-/etc/ssh/sshd_config.d/00-han-chat-vm2.conf}"
SSHD_TUNNEL_CONF="/etc/ssh/sshd_config.d/50-han-tunnel-user.conf"
AUTHORIZED_KEYS_DIR="/etc/ssh/authorized_keys"
AUTHORIZED_KEYS_FILE="${AUTHORIZED_KEYS_DIR}/${TUNNEL_USER}"
declare -a PERMIT_OPENS=()
log() { printf '[setup-tunnel] %s\n' "$*"; }
step() { printf '\n=== %s ===\n' "$*"; }
die() { printf '[setup-tunnel] ERROR: %s\n' "$*" >&2; exit 1; }
require_root() {
[[ "$(id -u)" -eq 0 ]] || die "Скрипт нужно запускать от root"
}
require_commands() {
local command
for command in awk chmod cp getent grep id install mktemp mv passwd python3 rm \
ssh-keygen sshd stat systemctl useradd usermod; do
command -v "$command" >/dev/null 2>&1 || die "Не найдена обязательная команда: ${command}"
done
}
validate_tunnel_user() {
[[ "$TUNNEL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "Некорректное имя пользователя: ${TUNNEL_USER}"
}
validate_source_policy() {
[[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" || "$TUNNEL_ALLOW_ANY_SOURCE" == "false" ]] \
|| die "TUNNEL_ALLOW_ANY_SOURCE должен быть true или false"
if [[ -n "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then
die "Задайте либо TUNNEL_SOURCE_CIDRS, либо TUNNEL_ALLOW_ANY_SOURCE=true, но не оба"
fi
if [[ -z "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" != "true" ]]; then
die "TUNNEL_SOURCE_CIDRS обязателен; для осознанного отказа задайте TUNNEL_ALLOW_ANY_SOURCE=true"
fi
if [[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then
log "ВНИМАНИЕ: источник SSH-ключа не ограничен; доступ должен ограничиваться SG/firewall"
fi
}
validate_cidrs() {
local cidrs=$1
local err normalized
[[ -n "$cidrs" ]] || return 0
[[ "$cidrs" =~ ^[0-9A-Fa-f:./,[:space:]]+$ ]] \
|| die "TUNNEL_SOURCE_CIDRS содержит недопустимые символы"
if ! err=$(python3 - "$cidrs" 2>&1 <<'PY'
import ipaddress
import sys
parts = [raw.strip() for raw in sys.argv[1].split(",")]
if not parts or any(not part for part in parts):
print("TUNNEL_SOURCE_CIDRS содержит пустой элемент", file=sys.stderr)
raise SystemExit(1)
for cidr in parts:
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError as exc:
print(f"Некорректный CIDR: {cidr} ({exc})", file=sys.stderr)
raise SystemExit(1) from exc
PY
); then
die "${err:-TUNNEL_SOURCE_CIDRS содержит некорректный CIDR}"
fi
normalized=$(python3 - "$cidrs" <<'PY'
import ipaddress
import sys
print(",".join(
str(ipaddress.ip_network(part.strip(), strict=False))
for part in sys.argv[1].split(",")
))
PY
)
TUNNEL_SOURCE_CIDRS=$normalized
}
validate_host_port() {
local host=$1 port=$2 label=$3
[[ -n "$host" ]] || die "${label}: host пустой"
[[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]] \
|| die "${label}: host содержит недопустимые символы"
[[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) \
|| die "${label}: некорректный port ${port}"
}
parse_host_port_item() {
local item=$1 label=$2
local host port
item="${item//[[:space:]]/}"
[[ -n "$item" ]] || die "${label}: пустой host:port"
[[ "$item" == *:* ]] || die "${label}: ожидается host:port, получено ${item}"
host="${item%%:*}"
port="${item##*:}"
[[ "$host" != "$port" ]] || die "${label}: отсутствует port в ${item}"
validate_host_port "$host" "$port" "$label"
printf '%s:%s\n' "$host" "$port"
}
collect_permit_opens() {
local item host_port existing duplicate
local -a extra=()
validate_host_port "$PG_HOST" "$PG_PORT" "PG"
PERMIT_OPENS=("${PG_HOST}:${PG_PORT}")
if [[ -n "$EXTRA_PERMIT_OPEN" ]]; then
IFS=',' read -r -a extra <<< "$EXTRA_PERMIT_OPEN"
((${#extra[@]} > 0)) || die "EXTRA_PERMIT_OPEN не содержит endpoint"
for item in "${extra[@]}"; do
host_port=$(parse_host_port_item "$item" "EXTRA_PERMIT_OPEN")
duplicate=false
for existing in "${PERMIT_OPENS[@]}"; do
[[ "$existing" == "$host_port" ]] && duplicate=true
done
if [[ "$duplicate" == "false" ]]; then
PERMIT_OPENS+=("$host_port")
fi
done
fi
}
validate_key_file() {
local file=$1
local lines
[[ -n "$file" && -f "$file" && -s "$file" ]] \
|| die "Не найден TUNNEL_AUTHORIZED_KEY_FILE: ${file:-<empty>}"
ssh-keygen -l -f "$file" >/dev/null 2>&1 || die "Некорректный SSH public key: ${file}"
mapfile -t lines < <(grep -Ev '^[[:space:]]*(#|$)' "$file")
((${#lines[@]} == 1)) || die "Файл ключа должен содержать ровно одну строку без комментариев: ${file}"
[[ "${lines[0]}" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]] \
|| die "Разрешён только чистый ssh-ed25519 без SSH-опций: ${file}"
}
read_ed25519_public_key() {
local file=$1
local line key_type key_data ignored_comment
line=$(grep -Ev '^[[:space:]]*(#|$)' "$file")
IFS=$' \t' read -r key_type key_data ignored_comment <<< "$line"
[[ "$key_type" == "ssh-ed25519" ]] || die "Разрешён только ssh-ed25519"
[[ "$key_data" =~ ^[A-Za-z0-9+/=]+$ ]] || die "Некорректное тело публичного ключа"
ED25519_KEY_TYPE=$key_type
ED25519_KEY_DATA=$key_data
# Входной комментарий не влияет на авторизацию и намеренно не переносится.
ED25519_KEY_COMMENT=han-vm2-db-tunnel
}
build_authorized_keys_line() {
local key_file=$1
local from_prefix="" options host_port
read_ed25519_public_key "$key_file"
options='restrict,port-forwarding'
if [[ -n "$TUNNEL_SOURCE_CIDRS" ]]; then
from_prefix="from=\"${TUNNEL_SOURCE_CIDRS}\","
fi
for host_port in "${PERMIT_OPENS[@]}"; do
options+=",permitopen=\"${host_port}\""
done
if [[ -n "$from_prefix" ]]; then
printf '%s%s %s %s %s\n' \
"$from_prefix" "$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT"
else
printf '%s %s %s %s\n' \
"$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT"
fi
}
create_or_update_user() {
step "Создание пользователя ${TUNNEL_USER}"
local actual_home actual_shell primary_group home_owner
if id "$TUNNEL_USER" >/dev/null 2>&1; then
IFS=: read -r _ _ _ _ _ actual_home actual_shell < <(getent passwd "$TUNNEL_USER")
primary_group=$(id -gn "$TUNNEL_USER")
[[ "$actual_home" == "/home/${TUNNEL_USER}" ]] \
|| die "Существующий ${TUNNEL_USER} имеет неожиданный home: ${actual_home}"
[[ "$primary_group" == "$TUNNEL_USER" ]] \
|| die "Существующий ${TUNNEL_USER} имеет неожиданную primary group: ${primary_group}"
[[ "$actual_shell" == "/usr/sbin/nologin" ]] \
|| die "Отказываюсь переоборудовать существующего ${TUNNEL_USER} с shell ${actual_shell}"
[[ -d "$actual_home" && ! -L "$actual_home" ]] \
|| die "Home ${actual_home} отсутствует, не является каталогом или является симлинком"
home_owner=$(stat -c '%U' "$actual_home")
[[ "$home_owner" == "$TUNNEL_USER" ]] \
|| die "Home ${actual_home} принадлежит ${home_owner}, ожидался ${TUNNEL_USER}"
log "Найден ранее созданный выделенный пользователь ${TUNNEL_USER}"
else
getent group "$TUNNEL_USER" >/dev/null 2>&1 \
&& die "Группа ${TUNNEL_USER} уже существует без одноимённого пользователя"
useradd --create-home --user-group --home-dir "/home/${TUNNEL_USER}" \
--shell /usr/sbin/nologin "$TUNNEL_USER"
fi
passwd -l "$TUNNEL_USER" >/dev/null
usermod --lock --groups "" "$TUNNEL_USER"
[[ "$(id -nG "$TUNNEL_USER")" == "$TUNNEL_USER" ]] \
|| die "Не удалось удалить дополнительные группы пользователя ${TUNNEL_USER}"
}
build_authorized_keys_candidate() {
local target=$1
build_authorized_keys_line "$TUNNEL_AUTHORIZED_KEY_FILE" > "$target"
# sshd читает AuthorizedKeysFile с правами целевого пользователя.
# Файл остаётся root-owned и поэтому 0644 не позволяет tunnel изменить ключ.
chmod 0644 "$target"
}
build_sshd_tunnel_candidate() {
local target=$1 host_port
{
printf 'Match User %s\n' "$TUNNEL_USER"
printf ' AuthorizedKeysFile %s/%%u\n' "$AUTHORIZED_KEYS_DIR"
echo ' AllowTcpForwarding local'
echo ' AllowStreamLocalForwarding no'
echo ' GatewayPorts no'
echo ' AllowAgentForwarding no'
echo ' X11Forwarding no'
echo ' PermitTTY no'
echo ' PermitTunnel no'
echo ' PermitUserRC no'
echo ' MaxSessions 0'
printf ' PermitOpen'
for host_port in "${PERMIT_OPENS[@]}"; do
printf ' %s' "$host_port"
done
printf '\nMatch all\n'
} > "$target"
chmod 0644 "$target"
}
build_main_config_candidate() {
local target=$1 allow_line user
local -a allow_users=()
[[ -f "$SSHD_MAIN_CONF" ]] || die "Не найден ${SSHD_MAIN_CONF}"
[[ ! -L "$SSHD_MAIN_CONF" ]] || die "${SSHD_MAIN_CONF} не должен быть симлинком"
! grep -Eq '^[[:space:]]*Match([[:space:]]|$)' "$SSHD_MAIN_CONF" \
|| die "${SSHD_MAIN_CONF} должен содержать только глобальные настройки, без Match"
python3 - "$SSHD_MAIN_CONF" "$target" "$TUNNEL_USER" <<'PY'
from pathlib import Path
import re
import sys
source = Path(sys.argv[1])
target = Path(sys.argv[2])
user = sys.argv[3]
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
matches = [
index for index, line in enumerate(lines)
if re.match(r"^\s*AllowUsers\s+", line)
]
if len(matches) != 1:
print(
f"{source}: ожидалась ровно одна глобальная директива AllowUsers, найдено {len(matches)}",
file=sys.stderr,
)
raise SystemExit(1)
index = matches[0]
tokens = lines[index].split()
if user not in tokens[1:]:
newline = "\n" if lines[index].endswith("\n") else ""
lines[index] = f"AllowUsers {' '.join(tokens[1:] + [user])}{newline}"
Path(target).write_text("".join(lines), encoding="utf-8")
PY
allow_line=$(awk '/^[[:space:]]*AllowUsers[[:space:]]+/ {print; exit}' "$target")
IFS=' ' read -r -a allow_users <<< "$allow_line"
((${#allow_users[@]} >= 2)) || die "AllowUsers не содержит пользователей"
for user in "${allow_users[@]:1}"; do
[[ "$user" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "Неподдерживаемый шаблон AllowUsers: ${user}; перечислите реальные локальные учётные записи"
getent passwd "$user" >/dev/null \
|| die "AllowUsers содержит несуществующего пользователя: ${user}"
done
chmod 0644 "$target"
}
verify_endpoint_from_host() {
local endpoint=$1
local host="${endpoint%%:*}"
local port="${endpoint##*:}"
if command -v nc >/dev/null 2>&1; then
nc -z -w 5 "$host" "$port" \
&& log "TCP ${endpoint} доступен" \
|| die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall"
elif command -v timeout >/dev/null 2>&1; then
timeout 5 bash -c 'echo > "/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null \
&& log "TCP ${endpoint} доступен" \
|| die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall"
else
log "nc и timeout не установлены; пропускаю TCP-проверку"
fi
}
verify_network_from_host() {
local endpoint
step "Проверка сетевой доступности разрешённых endpoint с VM2"
for endpoint in "${PERMIT_OPENS[@]}"; do
verify_endpoint_from_host "$endpoint"
done
}
atomic_install() {
local source=$1 target=$2 mode=$3
local temporary
temporary=$(mktemp "${target}.tmp.XXXXXX") || return 1
if ! install -o root -g root -m "$mode" "$source" "$temporary"; then
rm -f -- "$temporary"
return 1
fi
if ! mv -Tf -- "$temporary" "$target"; then
rm -f -- "$temporary"
return 1
fi
}
backup_file() {
local target=$1 name=$2 backup_dir=$3
if [[ -e "$target" || -L "$target" ]]; then
cp -a -- "$target" "${backup_dir}/${name}"
: > "${backup_dir}/${name}.existed"
fi
}
restore_file() {
local target=$1 name=$2 backup_dir=$3
rm -f -- "$target"
if [[ -f "${backup_dir}/${name}.existed" ]]; then
cp -a -- "${backup_dir}/${name}" "$target"
fi
}
rollback_transaction() {
local backup_dir=$1
log "Откат SSH-конфигурации и authorized_keys"
restore_file "$SSHD_MAIN_CONF" main.conf "$backup_dir"
restore_file "$SSHD_TUNNEL_CONF" tunnel.conf "$backup_dir"
restore_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$backup_dir"
}
assert_effective_value() {
local config=$1 key=$2 expected=$3 actual
actual=$(awk -v key="$key" '$1 == key {$1=""; sub(/^ /, ""); print; exit}' <<< "$config")
if [[ "$actual" != "$expected" ]]; then
log "Эффективный sshd ${key}=${actual:-<empty>}, ожидалось ${expected}"
return 1
fi
}
verify_effective_sshd_config() {
local effective endpoint permit_open authorized_keys_value allow_user_found=false
local -a effective_permit_opens=()
effective=$(sshd -T -C "user=${TUNNEL_USER},host=localhost,addr=127.0.0.1") \
|| return 1
assert_effective_value "$effective" allowtcpforwarding local || return 1
assert_effective_value "$effective" allowstreamlocalforwarding no || return 1
assert_effective_value "$effective" allowagentforwarding no || return 1
assert_effective_value "$effective" x11forwarding no || return 1
assert_effective_value "$effective" gatewayports no || return 1
assert_effective_value "$effective" permittty no || return 1
assert_effective_value "$effective" permittunnel no || return 1
assert_effective_value "$effective" permituserrc no || return 1
assert_effective_value "$effective" maxsessions 0 || return 1
assert_effective_value "$effective" passwordauthentication no || return 1
assert_effective_value "$effective" kbdinteractiveauthentication no || return 1
assert_effective_value "$effective" pubkeyauthentication yes || return 1
authorized_keys_value=$(awk '
$1 == "authorizedkeysfile" {$1=""; sub(/^ /, ""); print; exit}
' <<< "$effective")
if [[ "$authorized_keys_value" != "${AUTHORIZED_KEYS_DIR}/%u" \
&& "$authorized_keys_value" != "$AUTHORIZED_KEYS_FILE" ]]; then
log "Эффективный AuthorizedKeysFile=${authorized_keys_value:-<empty>}, ожидался root-owned путь"
return 1
fi
permit_open=$(awk '
$1 == "permitopen" {
for (i = 2; i <= NF; i++) values = values " " $i
}
END {sub(/^ /, "", values); print values}
' <<< "$effective")
IFS=' ' read -r -a effective_permit_opens <<< "$permit_open"
if ((${#effective_permit_opens[@]} != ${#PERMIT_OPENS[@]})); then
log "Эффективный PermitOpen содержит неожиданный набор: ${permit_open:-<empty>}"
return 1
fi
for endpoint in "${PERMIT_OPENS[@]}"; do
if [[ " ${permit_open} " != *" ${endpoint} "* ]]; then
log "Эффективный PermitOpen не содержит ${endpoint}"
return 1
fi
done
if awk -v user="$TUNNEL_USER" '
$1 == "allowusers" {
for (i = 2; i <= NF; i++) if ($i == user) found = 1
}
END {exit !found}
' <<< "$effective"; then
allow_user_found=true
fi
if [[ "$allow_user_found" != "true" ]]; then
log "Эффективный AllowUsers не содержит ${TUNNEL_USER}"
return 1
fi
}
detect_ssh_service() {
if systemctl cat ssh.service >/dev/null 2>&1; then
printf 'ssh\n'
elif systemctl cat sshd.service >/dev/null 2>&1; then
printf 'sshd\n'
else
die "Не найден systemd-сервис ssh.service или sshd.service"
fi
}
prepare_authorized_keys_dir() {
local owner mode
if [[ -e "$AUTHORIZED_KEYS_DIR" || -L "$AUTHORIZED_KEYS_DIR" ]]; then
[[ -d "$AUTHORIZED_KEYS_DIR" && ! -L "$AUTHORIZED_KEYS_DIR" ]] \
|| die "${AUTHORIZED_KEYS_DIR} должен быть обычным каталогом, не симлинком"
owner=$(stat -c '%U:%G' "$AUTHORIZED_KEYS_DIR")
[[ "$owner" == "root:root" ]] \
|| die "${AUTHORIZED_KEYS_DIR} принадлежит ${owner}, ожидался root:root"
mode=$(stat -c '%a' "$AUTHORIZED_KEYS_DIR")
(( (8#$mode & 8#022) == 0 )) \
|| die "${AUTHORIZED_KEYS_DIR} доступен для записи группе или остальным: mode ${mode}"
else
install -d -o root -g root -m 0755 "$AUTHORIZED_KEYS_DIR"
fi
}
install_and_reload_sshd() {
step "Атомарная установка, проверка и reload sshd"
local work_dir=$1 main_candidate=$2 tunnel_candidate=$3 key_candidate=$4
local ssh_service
ssh_service=$(detect_ssh_service)
prepare_authorized_keys_dir
backup_file "$SSHD_MAIN_CONF" main.conf "$work_dir"
backup_file "$SSHD_TUNNEL_CONF" tunnel.conf "$work_dir"
backup_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$work_dir"
if ! atomic_install "$main_candidate" "$SSHD_MAIN_CONF" 0644; then
rollback_transaction "$work_dir"
die "Не удалось установить ${SSHD_MAIN_CONF}; выполнен откат"
fi
if ! atomic_install "$tunnel_candidate" "$SSHD_TUNNEL_CONF" 0644; then
rollback_transaction "$work_dir"
die "Не удалось установить ${SSHD_TUNNEL_CONF}; выполнен откат"
fi
if ! sshd -t; then
rollback_transaction "$work_dir"
die "sshd -t не прошёл; исходная конфигурация восстановлена"
fi
if ! verify_effective_sshd_config; then
rollback_transaction "$work_dir"
die "Эффективная конфигурация sshd не прошла проверку; выполнен откат"
fi
if ! atomic_install "$key_candidate" "$AUTHORIZED_KEYS_FILE" 0644; then
rollback_transaction "$work_dir"
die "Не удалось установить ${AUTHORIZED_KEYS_FILE}; выполнен откат"
fi
if ! systemctl reload "$ssh_service"; then
rollback_transaction "$work_dir"
if sshd -t; then
systemctl reload "$ssh_service" \
|| log "КРИТИЧНО: не удалось reload исходной конфигурации ${ssh_service}"
else
log "КРИТИЧНО: исходная конфигурация после отката не проходит sshd -t"
fi
die "Reload ${ssh_service} не выполнен; конфигурация восстановлена"
fi
log "sshd reload выполнен; ключ хранится в root-owned ${AUTHORIZED_KEYS_FILE}"
}
print_usage() {
cat <<EOF
Готово.
Проверка с вашей рабочей машины (не закрывая текущую admin-сессию):
ssh -i ~/.ssh/han_vm2_tunnel -N \\
-L 16433:${PG_HOST}:${PG_PORT} \\
${TUNNEL_USER}@<VM2_PUBLIC_IP>
В другом терминале:
nc -zv 127.0.0.1 16433
Подключение к БД через туннель (пример):
psql "postgresql://<USER>:<PASSWORD>@127.0.0.1:16433/<DB>?sslmode=verify-full&sslrootcert=<PATH_TO_CA>"
Что запрещено для ${TUNNEL_USER}:
- sudo / shell / доступ к секретам приложения
- remote forwarding (-R), произвольный SOCKS, agent/X11
- forwarding на адреса вне PermitOpen
Политика источника ключа:
- CIDR: ${TUNNEL_SOURCE_CIDRS:-не задан}
- осознанно разрешён любой источник: ${TUNNEL_ALLOW_ANY_SOURCE}
EOF
}
main() {
local work_dir main_candidate tunnel_candidate key_candidate
require_root
require_commands
validate_tunnel_user
validate_source_policy
validate_cidrs "$TUNNEL_SOURCE_CIDRS"
validate_key_file "$TUNNEL_AUTHORIZED_KEY_FILE"
collect_permit_opens
verify_network_from_host
create_or_update_user
work_dir=$(mktemp -d /run/setup-tunnel-user.XXXXXX)
trap "rm -rf -- '$work_dir'" EXIT
main_candidate="${work_dir}/main.conf.candidate"
tunnel_candidate="${work_dir}/tunnel.conf.candidate"
key_candidate="${work_dir}/authorized_keys.candidate"
build_main_config_candidate "$main_candidate"
build_sshd_tunnel_candidate "$tunnel_candidate"
build_authorized_keys_candidate "$key_candidate"
install_and_reload_sshd \
"$work_dir" "$main_candidate" "$tunnel_candidate" "$key_candidate"
print_usage
}
main "$@"
@@ -0,0 +1,133 @@
Да, скрипт операционно идемпотентен: повторный запуск с тем же `TUNNEL_USER` приводит конфигурацию к тому же состоянию. Он повторно проверит настройки и выполнит reload SSH, поэтому не является строго no-op.
Нельзя менять `TUNNEL_USER` при повторном запуске: старый пользователь и его ключ автоматически не удаляются.
## Инструкция запуска
### 1. Создать ключ на рабочем компьютере
PowerShell:
```powershell
ssh-keygen -t ed25519 `
-f "$env:USERPROFILE\.ssh\han_vm2_tunnel" `
-C "han-vm2-db-tunnel"
```
Не перезаписывайте существующий ключ без запланированной ротации.
### 2. Передать ключ и скрипт на VM2
```powershell
scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" `
"$env:USERPROFILE\.ssh\han_vm2_tunnel.pub" `
admin@<VM2_PUBLIC_IP>:/tmp/han_vm2_tunnel.pub
scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" `
".\setup-tunnel-user.sh" `
admin@<VM2_PUBLIC_IP>:/tmp/setup-tunnel-user.sh
```
Подключиться к VM2:
```powershell
ssh -i "$env:USERPROFILE\.ssh\han_vm2_admin" admin@<VM2_PUBLIC_IP>
```
### 3. Установить временные файлы
На VM2:
```sh
sudo install -d -o root -g root -m 0700 /root/bootstrap
sudo install -o root -g root -m 0600 \
/tmp/han_vm2_tunnel.pub \
/root/bootstrap/tunnel.pub
sudo install -o root -g root -m 0700 \
/tmp/setup-tunnel-user.sh \
/root/setup-tunnel-user.sh
rm -f /tmp/han_vm2_tunnel.pub /tmp/setup-tunnel-user.sh
```
Текущую admin-сессию не закрывать до окончания проверки.
### 4. Запустить с ограничением по IP
Укажите внешний IP рабочей сети:
```sh
sudo env \
TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
TUNNEL_SOURCE_CIDRS="<YOUR_PUBLIC_IP>/32" \
PG_HOST="192.168.0.210" \
PG_PORT="6433" \
bash /root/setup-tunnel-user.sh
```
Несколько разрешённых сетей:
```sh
TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24"
```
Дополнительный endpoint:
```sh
EXTRA_PERMIT_OPEN="192.168.0.210:5433"
```
### 5. Осознанно разрешить любой источник
Только если доступ уже ограничен cloud SG/firewall:
```sh
sudo env \
TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
TUNNEL_ALLOW_ANY_SOURCE=true \
PG_HOST="192.168.0.210" \
PG_PORT="6433" \
bash /root/setup-tunnel-user.sh
```
Одновременно задавать `TUNNEL_SOURCE_CIDRS` и `TUNNEL_ALLOW_ANY_SOURCE=true` нельзя.
### 6. Проверить туннель
На рабочем компьютере в отдельном PowerShell:
```powershell
ssh `
-i "$env:USERPROFILE\.ssh\han_vm2_tunnel" `
-N `
-o ExitOnForwardFailure=yes `
-o ServerAliveInterval=30 `
-L "127.0.0.1:16433:192.168.0.210:6433" `
tunnel@<VM2_PUBLIC_IP>
```
Адрес и порт после `-L` должны точно совпадать с `PermitOpen`.
Во втором терминале:
```powershell
Test-NetConnection 127.0.0.1 -Port 16433
```
Для PostgreSQL с проверкой TLS-имени:
```sh
psql "host=<DB_CERTIFICATE_NAME> hostaddr=127.0.0.1 port=16433 dbname=<DB> user=<USER> sslmode=verify-full sslrootcert=<PATH_TO_CA>"
```
### 7. Очистить bootstrap-файлы
После успешной проверки на VM2:
```sh
sudo rm -f /root/bootstrap/tunnel.pub
```
Рабочий ключ уже будет установлен в `/etc/ssh/authorized_keys/tunnel`.
+5
View File
@@ -12,3 +12,8 @@
- [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md) — runbook ВМ2.
Guest API, frontend, Keycloak, SMS и Bitrix Open Lines local app находятся в [`VM1_app/documentation`](../../VM1_app/documentation/README.md). ВМ2 публикует на `443` только два exact CRM webhook; Safety доступен ВМ1 только через private HTTPS `:8443`.
Cutover caller выполняется по
[`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md)
и [fresh production runbook ВМ1](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md);
ВМ2 не предоставляет ВМ1 fallback на local stub.
@@ -2,7 +2,9 @@
> Статус: целевой runbook репозитория ВМ2.
> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md). Не переносить команды ВМ1 и не шарить Compose/IAM/secrets.
> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md)
> и её [fresh production runbook](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md).
> Не переносить команды ВМ1 и не шарить Compose/IAM/secrets.
## 1. Границы
@@ -181,5 +183,8 @@ D-TBD5 Safety v2; D-TBD6 bitrix-sync cutover; performance gates уже в §2.
- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
- ВМ1: [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md).
- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md).
- Исполняемый fresh production runbook ВМ1:
[`RUNBOOK.production.ru.md`](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md).
- Исполняемый runbook ВМ2:
[`RUNBOOK.ru.md`](../codebase/services/deployment/RUNBOOK.ru.md).
- Safety / sync / nginx: [`module-05-message-safety.md`](module-05-message-safety.md), [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md).
+16 -1
View File
@@ -20,6 +20,21 @@
| [`arch-09-redis.md`](arch-09-redis.md) | Контракт Redis: не source of truth, формат ключей, TTL, Lua, AOF/ACL. Реализация VM — `module-04-redis-vm1.md` / `module-04-redis-vm2.md` |
| [`arch-10-deployment.md`](arch-10-deployment.md) | Контракт развёртывания: VPC/SG, PG/S3, роли `deploy`, TLS процедура, cutover. Runbook VM — `module-10-deployment-vm1.md` / `module-10-deployment-vm2.md`. OS-роли — arch-06 |
## Ownership-матрица архитектурных требований
| Область | Канонический владелец | Что остаётся в связанных документах |
|---|---|---|
| Термины, поля, enum и базовый lifecycle | [`arch-00-glossary.md`](arch-00-glossary.md) | Сценарии переходов ссылаются на arch-00 и не переопределяют значения |
| Границы сервисов и пользовательские сценарии | [`arch-01-system-architecture.md`](arch-01-system-architecture.md) | API, Compose и deployment описывают реализацию этих границ |
| HTTP/API/realtime контракты | [`arch-02-api-contracts.md`](arch-02-api-contracts.md) | Routing и rollout только ссылаются на endpoint/auth contract |
| Compose, Docker networks, mounts и published ports | [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md) | arch-06 задаёт security baseline; arch-08 — поведение nginx |
| Non-secret env, secret references и business settings | [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md) | Модули задают schema/validation конкретного потребителя |
| OS-роли, secret delivery и host/container hardening | [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md) | arch-03 применяет требования в Compose; arch-10 ставит rollout gates |
| Observability contract | [`arch-07-observability.md`](arch-07-observability.md) | VM module-09 задаёт конкретные pipelines/alerts |
| Nginx, TLS/ACME, единый `308`, request id и reload | [`arch-08-nginx.md`](arch-08-nginx.md) | arch-03 задаёт mounts/ports и сохраняет исключение HTTP webhook ВМ2; VM module-03 — routing matrix |
| Redis contract | [`arch-09-redis.md`](arch-09-redis.md) | VM module-04 задаёт конкретную карту instances/keys |
| Provisioning, rollout/cutover, backup/rollback/DR | [`arch-10-deployment.md`](arch-10-deployment.md) | Профильный module-10 содержит исполняемые команды и VM-specific gates |
## Как читать
1. Начните с **arch-01** — общая картина и зафиксированные решения MVP.
@@ -61,7 +76,7 @@
| Тема | Где зафиксировано |
|---|---|
| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync``api-backend`, уведомление клиента) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9; arch-01 — заглушка UI «Документы» |
| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync``api-backend`, уведомление клиента) | Спецификация: [`module-07-bitrix-sync.md`](../VM2_services/documentation/module-07-bitrix-sync.md); API-контракты — [`arch-02-api-contracts.md`](arch-02-api-contracts.md) |
| Интеграция с SMS-провайдерами (отправка OTP, отключение `KEYCLOAK_OTP_MOCK_*`) | Спецификация: [`module-11-idgtl-sms.md`](../VM1_app/documentation/module-11-idgtl-sms.md) (доставка через Direct SMS API; проверка OTP — локально в Keycloak) |
## Каноническое размещение production-контуров
+4 -2
View File
@@ -126,12 +126,14 @@
| Значение | Когда |
|---|---|
| `accepted` | Сообщение принято API, safety ещё не завершена или только начата |
| `processing` | Внутренний/transient на время sync-wait safety; клиенту на `POST .../messages` не отдаётся как финальный ответ |
| `accepted` | Получен финальный `allow`, сообщение и durable-намерение доставки зафиксированы, но Open Lines ещё не подтвердил приём |
| `processing` | Сообщение принято API, Safety ещё выполняется (включая sync-wait `202 pending`); клиенту на `POST .../messages` не отдаётся как финальный ответ |
| `delivered` | Финальный `allow`, сообщение ушло в Open Lines (или входящее от оператора сохранено) |
| `rejected` | Финальный `deny` от Message Safety |
| `failed` | Инфраструктурная ошибка доставки (Bitrix/S3), не safety-deny |
Для исходящего сообщения успешный lifecycle строго следует порядку: Safety `allow``accepted` → приём в Open Lines → `delivered`. Статус `accepted` не означает незавершённую Safety-проверку.
Realtime-событие `message.status` передаёт актуальные `safety_status` и/или `delivery_status`.
## `MessageAttachment.scan_status`
+3 -52
View File
@@ -195,7 +195,7 @@ Frontend не должен:
- локальную регистрацию пользователя приложения: `find-or-create` `UserIdentity` по `keycloak_sub`, создание минимального `ClientProfile` для нового пользователя, обновление `last_login_at` для существующего (после OTP — см. `POST /api/v1/auth/bootstrap`);
- **приём события `session_start`**: запись `UxSession`, audit/analytics-событие; **не** используется для контроля доступа;
- валидация данных получаемых от frontend (соответствие типов данных, проверка обязательности полей, проверка формата данных, диапазоны значений, размер полей) через Pydantic
- хранение согласий пользователя в App DB (**`user_id`**, **`ux_session_id`**, **`client_ip`**, версии документов) — только после JWT;
- хранение согласий пользователя в App DB (**`user_id`**, nullable **`ux_session_id`**, **`client_ip`**, версии документов) — только после JWT; при bootstrap `ux_session_id=NULL` допустим, потому что новая UX-сессия создаётся следующим запросом;
- профиль, структурированный блоками;
- API чата, истории, файлов и документов;
- realtime-доставку входящих сообщений клиенту;
@@ -435,7 +435,7 @@ api-backend не решает, sync или async нужна проверка в
- при **`KEYCLOAK_OTP_MOCK_ENABLED=false`**: значение сверяется локально с HMAC OTP, сгенерированного Keycloak и переданного в закрытом заказе `sms-service`; статусы Direct и callback на verify не влияют.
- при неверном коде Keycloak возвращает ошибку; frontend не получает tokens, шаг 12 не выполняется.
12. При успешной проверке frontend получает tokens через OIDC Authorization Code Flow with PKCE.
13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` → минимальный профиль.
13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` с nullable `ux_session_id` (на bootstrap обычно `NULL`) → минимальный профиль.
14. Frontend вызывает **`POST /api/v1/analytics/session-start`** (если нужна новая UX-сессия) и далее работает с `X-Ux-Session-Id`.
15. Триггер App DB ставит задачу `contact.map_or_create` в `sync_queue`; `bitrix-sync` асинхронно находит или создает Contact в Битрикс24. Авторизация не должна синхронно зависеть от ответа Битрикс24 CRM.
16. Frontend создаёт диалог и отправляет отложенное сообщение (см. «Создание диалога» и поток чата).
@@ -596,56 +596,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn
### Предлагаемая структура backend-репозитория
```text
backend/
docker-compose.yml # root compose ВМ1
.env.example
nginx/
docker-compose.yml
nginx.conf
conf.d/
certs/
.gitkeep
api-backend/
app/
docker-compose.yml
tests/
pyproject.toml
Dockerfile
bitrix-local-app/
app/
docker-compose.yml
deploy/
tests/
pyproject.toml
Dockerfile
keycloak/
docker-compose.yml
realm/
themes/
providers/
sms-service/
app/
migrations/
openapi.yaml
Dockerfile
redis/
docker-compose.yml
observability/
docker-compose.yml # collector ВМ1
otel-collector.yaml
processing/
docker-compose.yml # root compose ВМ2
nginx-internal/
message-safety/
bitrix-sync/
clamav/
redis/
observability/ # collector ВМ2
```
Детальная внутренняя структура каждого сервиса (`app/`, модули, миграции) определяется в профильных спецификациях модулей (TBD).
Каноническая структура root Compose, service includes, networks и mounts задаётся в [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md). Детальная внутренняя структура сервиса определяется его профильной спецификацией.
### Compose-контуры
@@ -91,7 +91,6 @@ networks:
volumes:
redis-data:
nginx-certs:
```
Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и локальный OTEL Collector. Секреты, сети и volumes двух projects не общие.
@@ -103,6 +102,7 @@ Root Compose ВМ2 включает собственный nginx с public/priva
- Публикация портов наружу (`ports:`) разрешена **только** для `nginx` (80/443). Все остальные сервисы используют `expose:` для внутренних портов и общаются через Docker-сети.
- `bitrix-local-app` не публикует `8080` на хост (даже на `127.0.0.1`) — он доступен `api-backend` и `nginx` через сеть `backend`/`public`. Ранее применявшийся `127.0.0.1:8080:8080` считаем устаревшим; проверки через curl на `127.0.0.1:8080` заменяются на `docker compose exec bitrix-local-app` или прокси через `nginx`.
- Каждый сервисный compose-файл должен запускаться и в составе корневого контура, и автономно (`docker compose -f bitrix-local-app/docker-compose.yml up`) для локальной разработки сервиса — при условии, что переменные окружения заданы. Для автономного запуска сервис может объявлять заглушки сетей/volumes, но в составе корневого контура они переопределяются общими.
- При сборке образов нужно добавлять защиту на CRLF → LF
### Обязательный container hardening
@@ -176,7 +176,7 @@ docker compose exec api-backend ruff format .
Требования:
- публикует наружу только `80` и `443` (см. политику HTTP ниже);
- публикует в internet только `80` и `443` (см. политику HTTP ниже); private `8443` ВМ2 публикуется только в VPC/SG для ВМ1 и ops;
- принимает внешний HTTPS-трафик;
- выполняет TLS termination на reverse proxy; внутренний HTTP между контейнерами — только в закрытой Docker-сети `backend`;
- non-root nginx получает writable tmpfs только для `/etc/nginx/conf.d`,
@@ -196,13 +196,7 @@ docker compose exec api-backend ruff format .
- при recreate/смене IP upstream действует та же post-ready reload policy либо
используется явно протестированный dynamic resolver; stale IP/DNS в
загруженной nginx config недопустим;
- **политика HTTP/HTTPS по доменам** (каноническое правило — [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности»):
- **веб-домен** (frontend, SPA, статика): `listen 80` допускается **только** для безусловного редиректа `301`/`308` на HTTPS; обработка бизнес-логики по HTTP запрещена;
- **API-домен** (если выделен отдельный host, напр. `api.example.ru`): **не** слушает порт `80`; только `listen 443 ssl`; HTTP-запросы к API-домену недоступны;
- **единый домен MVP** (напр. `tohin.ru` с путями `/api/*`, `/auth/*`, web): считается веб-доменом; порт `80` — только redirect на HTTPS для всего server block; после редиректа весь пользовательский трафик — HTTPS;
- **auth** на том же host, что API (`/auth/*`): следует политике host (redirect-only на :80 или HTTPS-only для выделенного API-host);
- **Bitrix local-app callbacks ВМ1** (`/bitrix/handler|install|placement`): только HTTPS; порт `80` — только redirect;
- **CRM sync webhook ВМ2** (exact `/bitrix/sync/webhook/contact|alert`): только HTTPS на отдельном processing host; HTTP не отражает query token в redirect;
- применяет каноническую HTTP/HTTPS policy из [`arch-08-nginx.md`](arch-08-nginx.md): web/единый MVP host использует только `308` на HTTPS, выделенный API host не слушает `:80`; единственное route-specific исключение — HTTP exact CRM webhook ВМ2 возвращает `404/426` без redirect и без отражения query token;
- маршрутизирует `/api/*` в `api-backend` (включая WebSocket upgrade для `/api/v1/realtime`);
- маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен;
- маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`;
@@ -415,16 +409,19 @@ Identity provider. **Обязателен** в compose-контуре с пер
## Volumes
Минимальные volumes:
Минимальные persistent volumes:
- ВМ1: Redis DB0/DB1 data, public TLS/ACME, local OTEL queue;
- ВМ1: Redis DB0/DB1 data, local OTEL queue;
- ВМ2: Redis Safety data (rebuildable), ClamAV signatures и runtime,
internal TLS secrets, local OTEL queue.
Public TLS и ACME на ВМ2 — не named volumes: Compose монтирует read-only host
staging `/var/lib/han-chat/public-tls` и ACME webroot
`/var/lib/han-chat/acme`. Internal TLS certificate/key передаются отдельными
Compose secrets и не объединяются с public TLS.
Public TLS и ACME на обеих VM — не named volumes. Root-only ACME state остаётся
на host в `/etc/letsencrypt`; root hook атомарно копирует только нужные
`fullchain.pem`/`privkey.pem` в `/var/lib/han-chat/public-tls`. Compose
монтирует этот staging read-only в nginx, а host webroot
`/var/lib/han-chat/acme` — в nginx и ACME client с минимально необходимыми
правами. Internal TLS certificate/key передаются отдельными Compose secrets и
не объединяются с public TLS.
Данные PostgreSQL **не** хранятся в Docker volumes — только managed PostgreSQL вне compose.
@@ -454,9 +451,9 @@ Local OTEL queue на каждой VM использует отдельный pe
| Host | Порт 80 | Порт 443 | Примечание |
|---|---|---|---|
| Веб-домен (frontend) | только `301`/`308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host |
| Веб-домен (frontend) | только `308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host |
| API-домен (если выделен) | **не слушает** | только HTTPS | Post-MVP: `api.example.ru` |
| Bitrix Local App ВМ1 (`/bitrix/handler|install|placement`) | только redirect на web host | HTTPS | install/handler/placement |
| Bitrix Local App ВМ1 (`/bitrix/handler|install|placement`) | только `308` на HTTPS | HTTPS | install/handler/placement |
| CRM webhook ВМ2 (exact `/bitrix/sync/webhook/contact|alert`) | generic `404/426`, без redirect query token | HTTPS | отдельный processing host |
Правила:
@@ -468,26 +465,12 @@ Local OTEL queue на каждой VM использует отдельный pe
### TLS и заголовки
- cookies в web-клиенте: `Secure`, `HttpOnly`, корректный `SameSite`;
- OIDC redirect URI в Keycloak — HTTPS;
- `KEYCLOAK_PUBLIC_URL`, issuer и frontend auth discovery URL совпадают по схеме, host и path;
- backend формирует внешние ссылки с учётом `X-Forwarded-Proto=https`;
- HSTS включается в production-like среде **после** проверки доменов и сертификатов;
- TLS 1.0/1.1 запрещены; минимум TLS 1.2, предпочтительно TLS 1.3;
- слабые шифры запрещены на уровне `nginx`;
- `nginx` скрывает `Server`, `X-Powered-By` и аналогичные технологические заголовки;
- security headers: `Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy` для web-приложения;
- инструкция по установке всегда открывается новой вкладкой, поэтому CSP SPA задаёт `frame-src 'none'`; allow-list iframe для инструкций отсутствует;
- секретный ключ сертификата не коммитится в репозиторий;
- использовать сертификаты доверенного CA; автоматизировать выпуск и продление (Let's Encrypt + reload `nginx`);
- non-root nginx не монтирует root-only дерево Let's Encrypt целиком:
root deploy hook атомарно копирует только `fullchain.pem` и `privkey.pem` в
host staging `root:<dedicated-tls-group>` (`0750`, файлы `0640`), а Compose
монтирует staging read-only;
- reload после renewal выполняется только после `openssl` certificate/key
match и полного `nginx -t`; internal TLS PEM также проверяется на raw PEM,
отсутствие literal `\n`/double-base64 и совпадение ключа;
- закрыть прямой доступ к внутренним портам контейнеров извне.
TLS versions, trusted CA, HSTS/security headers, certificate validation и safe
reload принадлежат [`arch-08-nginx.md`](arch-08-nginx.md); cookie/OIDC и
application security — [`arch-01-system-architecture.md`](arch-01-system-architecture.md).
Compose применяет их через host binds из раздела «Volumes», не монтирует
root-only `/etc/letsencrypt` в nginx, не объявляет named volume public
certificates и не публикует внутренние порты.
## Nginx routing для Bitrix24 Local App
@@ -495,7 +478,7 @@ Local OTEL queue на каждой VM использует отдельный pe
Рекомендуемая схема:
- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80`redirect HTTPS; `:443` — TLS + маршрутизация;
- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80``308` HTTPS; `:443` — TLS + маршрутизация;
- **выделенный API-домен** (post-MVP, опционально): отдельный `server { listen 443 ssl; ... }` **без** `listen 80`; только `/api/*`;
- для `location` WebSocket (`/api/v1/realtime`): `proxy_http_version 1.1`, `Upgrade`/`Connection` headers, увеличенный `proxy_read_timeout`;
- только `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` на ВМ1 → `bitrix-local-app`; CRM `/bitrix/sync/*` на этом host не маршрутизируется;
@@ -576,7 +559,7 @@ WAF не заменяет обязательные лимиты, валидац
Минимальные проверки:
- `nginx`: на веб-домене — `301` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` HTTP 200/301 и успешная TLS handshake;
- `nginx`: на веб-домене — `308` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — успешная TLS handshake и ожидаемый route response;
- `api-backend`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL `han_app`, Redis DB0/DB1, JWKS/discovery Keycloak и S3 permissions. Недоступность remote Message Safety отражается как degraded dependency и блокирует только send path, но не readiness read API;
- `message-safety`: `/health/ready` возвращает process/core status и capability map `text|links|files|worker`; ClamAV/S3 не выключают text, DNS не выключает text без ссылок, Redis hot cache не является core gate;
- `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет validated config/secrets, PostgreSQL/grants, worker/limiter state и CRM webhook config; invalid credential/config даёт not-ready, краткая CRM outage — degraded по stale policy; при `BITRIX_SYNC_ENABLED=false` ready возвращает not-ready `sync_disabled`;
@@ -635,7 +618,7 @@ endpoint обязан fail-closed при недоступной требуемо
1. ВМ1, ВМ2, SigNoz и managed PostgreSQL находятся в одной private network/VPC; ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress.
2. Managed PostgreSQL не имеет public IP; SG разрешает каждой VM только нужные DB roles/schemas.
3. На каждой VM отдельный root-owned systemd unit выполняет её root Compose; `deploy` не входит в `docker`.
4. Public nginx ВМ2 публикует `80/443`; `80` используется только для ACME/redirect, `443` только exact CRM webhook. Private `8443` разрешён только от SG ВМ1 и ops для Message Safety/internal access.
4. Public nginx ВМ2 публикует `80/443`; `80` обслуживает ACME, а HTTP exact CRM webhook возвращает `404/426` без redirect; `443` публикует только exact CRM webhook. Private `8443` разрешён только от SG ВМ1 и ops для Message Safety/internal access.
5. Deploy/cutover ВМ2 не требует изменения public routes ВМ1. Для `bitrix-sync` rollback закрывает webhook routes на nginx ВМ2 либо возвращает retryable `503`, останавливает claims и сохраняет durable tasks/mapping; возврат к фиктивному `202 ignored` запрещён.
Host firewall обеих VM учитывает post-DNAT semantics `DOCKER-USER`: policy
@@ -48,7 +48,7 @@
- Для часто используемых фильтров добавляются индексы.
- Миграции не должны удалять данные без отдельного согласования.
- Все юзеры должны иметь ИД, которое указывается в `updater_user_id` которое они меняют.
- При создании миграций учитывать, что asyncpg допускает один top-level SQL statement на один execute.
## API
+4 -4
View File
@@ -525,15 +525,15 @@ Legal retention/erasure имеет приоритет; изменение тре
### Обнаруженные архитектурные конфликты
1. `arch-03` разрешает stdout/platform exporter как минимум, но production-like расследования и alerts без backend ограничены. Закрыто выбором SigNoz (O1 / O-TBD1); stdout остаётся аварийным buffer.
2. `module-05` использует test-only terminal `400` и non-sticky verdict вместо canonical `403`/sticky production verdict. Dashboards обязаны маркировать сервис `stub`; production SLO Safety на нём недостоверен. Детали — спецификация ВМ2.
3. `module-07` задаёт full sync target; до code/portal cutover dashboard обязан показывать `sync_disabled`, а не синтетический CRM success. Queue/CRM SLI включаются только после module-07 preflight. Детали — спецификация ВМ2.
2. Test-only режимы Message Safety не входят в production SLO. Production dashboards используют canonical `403`, sticky verdict и фактический `processing_mode`; значение `stub` считается ошибкой cutover. Детали — спецификация ВМ2.
3. Queue/CRM SLI включаются только после module-07 preflight/cutover и отражают фактическое состояние `bitrix-sync` на ВМ2; синтетический CRM success запрещён. Детали — спецификация ВМ2.
4. Retention, RPO/RTO и production SLO открыты в module-01/04/06/08; значения этого документа являются initial ops policy, не закрывают legal/product TBD.
5. Observability env (`OTEL_REMOTE_*`, service names, sampling/queue limits) добавлены в arch-04 и `.env.example`; sampling/queue limits уточняются после load test.
## 19. Ссылки
- Профильные спецификации: [`module-09-observability-vm1.md`](../VM1_app/documentation/module-09-observability-vm1.md), [`module-09-observability-vm2.md`](../VM2_services/documentation/module-09-observability-vm2.md).
- VM/Docker logging и firewall: [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh).
- Прототипный nginx stdout/access log: [`../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf`](../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf).
- VM/Docker logging и firewall: [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md) и профильные module-10/runbook.
- Nginx stdout/access log: [`arch-08-nginx.md`](arch-08-nginx.md) и профильные module-03.
- HTTP/OTLP registry: [`arch-02-api-contracts.md`](arch-02-api-contracts.md).
- Compose topology: [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md).
+23 -12
View File
@@ -40,7 +40,8 @@ Upstream failures не перенаправляются на другой сер
## 3. HTTP/HTTPS и TLS
- public host каждой VM на `:80` обслуживает только ACME challenge и `308 https://$host$request_uri`, кроме явно зафиксированных исключений профильной спецификации;
- public host каждой VM на `:80` обслуживает только ACME challenge и `308 https://$host$request_uri`;
- единственное route-specific исключение: exact CRM webhook ВМ2 по HTTP возвращает `404/426` без redirect, чтобы query token не отражался в `Location`;
- выделенный API host, если появится, не имеет listener `:80`;
- `:443 ssl http2`, TLS 1.2/1.3, современные cipher suites, session tickets по ops policy;
- сертификат доверенного CA, private key read-only и недоступен приложению;
@@ -53,24 +54,33 @@ Upstream failures не перенаправляются на другой сер
## 4. ACME lifecycle
Выбран webroot Certbot/ACME client с общими named volumes:
Выбран webroot Certbot/ACME client с host-каталогами:
```text
nginx-certs -> /etc/letsencrypt (rw у certbot, ro у nginx)
nginx-acme-webroot -> /var/www/certbot
/etc/letsencrypt root-only ACME state; rw только у root/ACME client
/var/lib/han-chat/public-tls staged fullchain.pem/privkey.pem; ro bind в nginx
/var/lib/han-chat/acme webroot; bind в nginx и ACME client
```
Каждая VM выпускает **свой** сертификат на свой public host. Секреты и volumes двух projects не общие.
Named volume для public certificate/key запрещён. Non-root nginx никогда не
монтирует `/etc/letsencrypt`: после успешного issuance/renewal root hook
проверяет certificate/key и атомарно копирует только нужные PEM в
`/var/lib/han-chat/public-tls` с `root:<dedicated-tls-group>`, directory `0750`
и files `0640`. Каждая VM выпускает **свой** сертификат на свой public host;
host-каталоги двух VM не общие.
Bootstrap:
1. DNS указывает на VM; 80/443 разрешены.
2. Запустить временный HTTP config с `/.well-known/acme-challenge/`.
3. Выпустить certificate без остановки nginx.
4. Проверить `nginx -t`, атомарно активировать TLS config, reload.
2. Подготовить root-owned webroot `/var/lib/han-chat/acme` и временный HTTP config с `/.well-known/acme-challenge/`.
3. Выпустить certificate в root-only `/etc/letsencrypt` без остановки nginx.
4. Проверить certificate/key, атомарно обновить `/var/lib/han-chat/public-tls`, выполнить `nginx -t`, активировать TLS config и reload.
Renew container/host timer выполняет `certbot renew` минимум дважды в сутки;
после фактического renewal проверяет рабочую конфигурацию командой
Root-owned host timer выполняет `certbot renew` минимум дважды в сутки; ACME
client может быть контейнеризован, но только он получает rw bind
`/etc/letsencrypt`, а public certificate остаётся host bind, не named volume.
После фактического renewal hook проверяет пару certificate/key, атомарно
обновляет staging и проверяет рабочую конфигурацию командой
`docker compose exec -T nginx nginx -t -c /tmp/nginx.conf` и отправляет
master-процессу `docker compose kill -s HUP nginx`. Bare-команды `nginx -t` и
`nginx -s reload` запрещены: контейнер read-only, а рабочие config/PID находятся
@@ -82,7 +92,8 @@ success path считается дефектом интеграции с Certbot
Контролируются expiry days и последняя успешная попытка. Staging CA используется
в rehearsal, чтобы не исчерпать лимиты.
Non-root nginx не монтирует root-only дерево Let's Encrypt целиком: только необходимые cert/key files по arch-03/arch-06.
Non-root nginx получает только read-only staging
`/var/lib/han-chat/public-tls`; root-only `/etc/letsencrypt` ему недоступен.
## 5. Request ID и forwarded headers
@@ -176,7 +187,7 @@ nginx/
## 11. Docker Compose
`nginx` подключён к `public` и `backend` (или эквивалентным сетям своей VM). Публикация host ports разрешена только nginx. Filesystem read-only, tmpfs для cache/run/temp/`/etc/nginx/conf.d` по arch-03, non-root где позволяет bind ports/capabilities. Cert volumes read-only для nginx. ACME client имеет только необходимые volumes/network.
`nginx` подключён к `public` и `backend` (или эквивалентным сетям своей VM). Публикация host ports разрешена только nginx. Filesystem read-only, tmpfs для cache/run/temp/`/etc/nginx/conf.d` по arch-03, non-root где позволяет bind ports/capabilities. Host staging public TLS и ACME webroot монтируются с минимальными правами; named volume сертификатов запрещён. ACME client имеет только необходимые bind mounts/network.
Non-root nginx получает writable tmpfs только для `/etc/nginx/conf.d`, `/var/cache/nginx`, `/var/run` и `/tmp`; tmpfs задаёт явные UID/GID/mode. Основной `nginx.conf` подключает конкретный rendered include, не неограниченный wildcard, который позволил бы обойти `nginx -t`.
+21 -34
View File
@@ -22,8 +22,8 @@
6. S3 — внешний Selectel-compatible storage; клиент получает только presigned URL.
7. Секреты не коммитятся, не вставляются в команды shell history и не выводятся в отчёты.
8. Миграции выполняются отдельными one-shot steps до новой версии приложения.
9. Message Safety запускается как documented stub до замены; это не production antivirus/moderation.
10. `bitrix-sync` вводится только после выполнения preflight/cutover gates module-07; до этого `BITRIX_SYNC_ENABLED=false`, public webhook закрыт на edge.
9. Production Message Safety работает только на ВМ2; local stub/fallback на ВМ1 запрещён. Caller ВМ1 использует private HTTPS, service token и проверку internal CA, при недоступности — fail closed.
10. `bitrix-sync` работает только на ВМ2 и вводится после preflight/cutover gates module-07; до подтверждённого cutover public webhook остаётся закрыт на edge.
11. На ВМ1 и ВМ2 отдельные root Compose projects/systemd units; deploy/rollback выполняются независимо.
12. ВМ2 — самостоятельная service VM с минимальным public webhook ingress, allow-listed egress, отдельным IAM principal и service-specific secret files.
13. OS-роли, SSH/sudo, secrets delivery, container hardening и private-VM lockdown подчиняются arch-06.
@@ -49,7 +49,7 @@
<VM_PRIVATE_IP> приватный IPv4 VM
<VPC_CIDR> например 10.20.0.0/24
<PG_PRIVATE_HOST> private FQDN/IP managed PG
<PG_PORT> 5432 или 6432
<PG_PORT> 5433 для Selectel PgBouncer; иной порт — только фактическое значение другого provider/endpoint
<PG_DATABASE> han_chat
<BACKEND_REPO_URL> URL репозитория этой VM
<BACKEND_ROOT> /opt/han-chat/backend
@@ -115,7 +115,7 @@ DNS: `A <PUBLIC_HOST> → <VM1_PUBLIC_IP>`, `A <PROCESSING_PUBLIC_HOST> → <VM2
## 5. Stage 2 — hardening Ubuntu
Процедура первичная для **каждой** application VM. Скрипт-прототип [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh) полезен для UFW, fail2ban, Docker и `DOCKER-USER`. Перед production: review версии; не передавать IP/ключи в git; проверить unattended upgrades; `deploy` не в группе `docker`; `AllowTcpForwarding no` по умолчанию; отдельный `tunnel` при необходимости; root-owned systemd-units и `/etc/sudoers.d/deploy` без wildcard.
Процедура первичная для **каждой** application VM и выполняется только по её профильному production runbook. Перед production: review версии; не передавать IP/ключи в git; проверить unattended upgrades; `deploy` не в группе `docker`; `AllowTcpForwarding no` по умолчанию; отдельный `tunnel` при необходимости; root-owned systemd-units и `/etc/sudoers.d/deploy` без wildcard.
`PUBLIC_DOCKER_PORTS` задаёт профильный runbook (ВМ1: `80,443`; ВМ2 — свои public 80/443, private 8443 не internet).
@@ -216,37 +216,24 @@ Validation: нет `change-me`, paired tokens equal, PG TLS, public HTTPS, `FRON
## 9. TLS/ACME процедура
Webroot two-phase, независимо в root Compose каждой VM, без `compose down`. Staging CA rehearsal, затем production `--cert-name` текущего host. Сертификат и ACME volume между VM не разделяются.
Канонические bootstrap, renewal, validation и safe reload задаёт
[`arch-08-nginx.md`](arch-08-nginx.md). Rollout каждой VM применяет эту модель
независимо, без `compose down` и без named volume сертификатов:
Renew: systemd timer дважды в сутки. Скрипт `<BACKEND_ROOT>/deploy/ssl-renew.sh`:
- root-only ACME state: `/etc/letsencrypt`;
- read-only для nginx host staging: `/var/lib/han-chat/public-tls`;
- host ACME webroot: `/var/lib/han-chat/acme`;
- root-owned systemd timer/hook дважды в сутки с `flock`; пользователь `deploy`
может запускать только утверждённый unit и не получает доступ к ACME state;
- staging CA rehearsal предшествует production issuance; после renewal root
hook проверяет certificate/key, атомарно обновляет staging, выполняет
container `nginx -t` и только затем HUP;
- success path возвращает `0` с пустым stderr; ошибка сохраняет действующий
certificate/config и поднимает alert (<21 дней, page <7 дней).
1. взять `flock`;
2. `docker compose --profile certbot run --rm certbot renew --webroot -w /var/www/certbot --quiet`;
3. при обновлении проверить `docker compose exec -T nginx nginx -t -c /tmp/nginx.conf`;
4. только после успеха `docker compose kill -s HUP nginx`;
5. записать результат и метрику expiry;
6. ненулевой exit при ошибке;
7. не удалять действующий сертификат;
8. success path — `0` и пустой stderr.
Пример unit `/etc/systemd/system/han-chat-cert-renew.service`:
```ini
[Unit]
Description=Renew HAN Chat Let's Encrypt certificate
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
User=deploy
WorkingDirectory=<BACKEND_ROOT>
ExecStart=<BACKEND_ROOT>/deploy/ssl-renew.sh
```
Timer `OnCalendar=*-*-* 03,15:20:00`, `RandomizedDelaySec=30m`, `Persistent=true`. `enable --now`, `list-timers`, `certbot renew --dry-run`. Alert <21 дней, page <7 дней. Ошибка renew не останавливает nginx.
Staging issuance, затем production `--cert-name` текущего host. Host для ВМ1 — `<PUBLIC_HOST>`; для ВМ2 — `<PROCESSING_PUBLIC_HOST>`; private `8443` — internal CA, не Let's Encrypt. Сертификат и ACME volume между VM не разделяются.
Host ВМ1 — `<PUBLIC_HOST>`, ВМ2 — `<PROCESSING_PUBLIC_HOST>`; private `8443`
использует internal CA, не Let's Encrypt. Ни ACME state, ни staging между VM не
разделяются.
## 10. Сквозной порядок startup и cutover
@@ -337,4 +324,4 @@ DR потеря VM: новая Ubuntu в VPC, hardening, DNS, secrets из vault
- ВМ1: [`module-10-deployment-vm1.md`](../VM1_app/documentation/module-10-deployment-vm1.md).
- ВМ2: [`module-10-deployment-vm2.md`](../VM2_services/documentation/module-10-deployment-vm2.md).
- Прототипы: [`../../HAN_chat/Deploy_steps.md`](../../HAN_chat/Deploy_steps.md), [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
- Исполняемые процедуры: [`RUNBOOK.production.ru.md`](../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md) для ВМ1 и [`RUNBOOK.ru.md`](../VM2_services/codebase/services/deployment/RUNBOOK.ru.md) для ВМ2.
@@ -30,6 +30,9 @@
# Закрыто 04.08-10.08
## #BACK_DEFECT Исправлены дублирующиеся триггеры на создание контакта для сервиса синхронизации. Исправлено создание в БД лишних задач на обновление контакта (каждый бустрап пользователя вызывал задачу на обновление контакта)
# Закрыто 18.08-24.08
## #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
# В разработку:
1 #BACK_SECURE После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
@@ -56,7 +59,7 @@
12. #INFRASTRUCTURE Перераскатить сервисы от деплоя
13. #INFRASTRUCTURE Поднять второй контур для продакшн
14. #INFRASTRUCTURE Спрятать сеть за балансировщиком нагрузки
15. #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
16. #INFRASTRUCTURE WireGuard-only SSH.
17. #LEGAL Обновить документы по ПД - модель угроз и меры защиты.
18. #LEGAL Уведомление в РКН по БД обработки ПД.
@@ -72,6 +75,7 @@
28. #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel
29. #BACK_BUSINESS Определить пул тестовых номеров, чтобы их было легко в Б24 отслеживать.
30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true)
31. #BACK_BUSINESS VM1 -> VM2: `curl -sS --cacert /etc/han/ca/vm2-internal-ca.crt "https://processing.internal:8443/internal/safety/status" | python3 -m json.tool` В коде захардкожен Redis в components = "degraded". Надо реализовать реальную проверку вместо костыля.
# Критично для релиза:
1. Разработка message-safety
@@ -39,14 +39,14 @@ find -type f -exec file {} \; | grep -i 'CRLF'
docker login cr.selcloud.ru
cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/bitrix-sync
cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/bitrix-sync
docker build -t han-bitrix-sync:1.0.3 .
REGISTRY=cr.selcloud.ru/han-images
docker tag han-bitrix-sync:1.0.3 $REGISTRY/han-bitrix-sync:1.0.3
docker push $REGISTRY/han-bitrix-sync:1.0.3
docker image inspect $REGISTRY/han-bitrix-sync:1.0.3 --format '{{index .RepoDigests 0}}'
cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/message-safety
cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/message-safety
docker build -t han-message-safety:1.0.2 .
REGISTRY=cr.selcloud.ru/han-images # ваш registry
docker tag han-message-safety:1.0.2 $REGISTRY/han-message-safety:1.0.2
@@ -63,7 +63,7 @@ docker pull otel/opentelemetry-collector-contrib:0.117.0
docker image inspect nginxinc/nginx-unprivileged:1.27 --format '{{index .RepoDigests 0}}'
docker image inspect redis:7.4 --format '{{index .RepoDigests 0}}'
docker image inspect clamav/clamav:1.4 --format '{{index .RepoDigests 0}}'
docker image inspect clamav/clamav:1.4.6 --format '{{index .RepoDigests 0}}'
docker image inspect otel/opentelemetry-collector-contrib:0.117.0 --format '{{index .RepoDigests 0}}'
# Генерация ключей для новых пользователей и первичная настройка ВМ2
@@ -148,7 +148,7 @@ netplan try
Подтвердите конфигурацию в течение тайм-аута. Затем проверьте:
ip -br -4 address show eth1
ip route
ping -c 3 192.168.0.1
ping -c 3 192.168.0.210
# nginx правим разрешенные адреса
nginx/allowlists/

Some files were not shown because too many files have changed in this diff Show More