Compare commits

...

10 Commits

229 changed files with 26390 additions and 2814 deletions
+6
View File
@@ -0,0 +1,6 @@
.env
*.log
node_modules/
__pycache__/
for_bugs_exchange/
*.tar.gz
+5 -1
View File
@@ -89,8 +89,9 @@ 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
# Example only: set the actual VM2 private DNS name in the deployment .env.
MESSAGE_SAFETY_URL=https://processing.internal:8443
# Docker extra_hosts mapping for VM2 private listener: <hostname>=<private-ip>.
# Docker extra_hosts mapping must use the same configured hostname: <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
@@ -114,6 +115,7 @@ SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
HOST_NAME=vm1-production
OTEL_SERVICE_NAME_API=api-backend
OTEL_SERVICE_NAME_SMS_API=sms-service
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
@@ -125,6 +127,8 @@ OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
OTEL_REMOTE_TLS_INSECURE=false
# SDK отправляет все spans локальному Collector; решение о хранении принимает tail_sampling.
OTEL_TRACES_SAMPLER=always_on
# Операторский canary image; обязателен digest из утверждённого release record.
TELEMETRYGEN_IMAGE=ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:<64-hex>
# OTEL_QUEUE_SIZE пока документирует целевую ёмкость, конфигурация Collector закреплена в YAML.
OTEL_QUEUE_SIZE=10000
+18 -2
View File
@@ -59,8 +59,9 @@ han-notification-draft-cleanup-worker
Токены генерируются `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_URL=https://<private-vm2-name>:8443`
(`processing.internal` — только пример), API prefix `/internal/safety/v2`;
certificate проверяется по CA из
`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён.
Smoke-сценарий `producer_test`: отправить `POST
@@ -83,3 +84,18 @@ pytest
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
`503`, если ВМ2 недоступна.
## Публичная конфигурация мобильных обновлений
`GET /api/v1/public/app-config` возвращает строгий объект `mobile_update` с
политиками `google_play`, `rustore` и `app_store`. Для включённого магазина
обязательны `latest_build`, `minimum_build`, `latest_version` и HTTPS `store_url`;
`minimum_build` не может превышать `latest_build`. Для отключённого магазина
эти поля возвращаются как `null`. Опциональное `release_notes` также возвращается
как `null`, если в settings задана пустая строка. Канонический URL RuStore:
`https://www.rustore.ru/catalog/app/ru.han.chat`.
Ответ содержит `ETag`, поддерживает `If-None-Match` с ответом `304` и в
production-like конфигурации кэшируется клиентом и nginx 60 секунд. Изменения
политики применяются через штатный идемпотентный `seed-settings`; некорректные
пороги, типы и URL блокируют загрузку settings.
@@ -0,0 +1,45 @@
"""Persist W3C trace context for asynchronous delivery.
Revision ID: 0013_delivery_trace_context
Revises: 0012_safety_v2_checkpoint
Create Date: 2026-09-03
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0013_delivery_trace_context"
down_revision: str | None = "0012_safety_v2_checkpoint"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE han_app.delivery_outbox
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
"""
)
op.execute(
"""
ALTER TABLE han_app.safety_tasks
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
"""
)
def downgrade() -> None:
op.execute(
"""
ALTER TABLE han_app.delivery_outbox
DROP COLUMN IF EXISTS traceparent
"""
)
op.execute(
"""
ALTER TABLE han_app.safety_tasks
DROP COLUMN IF EXISTS traceparent
"""
)
@@ -11,6 +11,12 @@ from sqlalchemy.dialects.postgresql import insert
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
from app.db import AppSetting, Database
from app.mobile_update_settings import (
MOBILE_UPDATE_BOOLEAN_KEYS,
MOBILE_UPDATE_INTEGER_KEYS,
MOBILE_UPDATE_SETTING_KEYS,
validate_mobile_update_settings,
)
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings
@@ -38,12 +44,25 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
raise ValueError(f"{key}: type must be integer")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if key in MOBILE_UPDATE_BOOLEAN_KEYS and value_type != "boolean":
raise ValueError(f"{key}: type must be boolean")
if key in MOBILE_UPDATE_INTEGER_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if (
key in MOBILE_UPDATE_SETTING_KEYS
- MOBILE_UPDATE_BOOLEAN_KEYS
- MOBILE_UPDATE_INTEGER_KEYS
and value_type != "string"
):
raise ValueError(f"{key}: type must be string")
if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
raise ValueError(f"{key}: setting must be public")
if key in MOBILE_UPDATE_SETTING_KEYS and not raw["public"]:
raise ValueError(f"{key}: mobile update setting must be public")
description = raw.get("description")
if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string")
@@ -59,6 +78,9 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
)
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
validate_mobile_update_settings(
{row["setting_key"]: row["setting_value"] for row in rows}
)
return rows
@@ -68,6 +90,8 @@ def serialize_value(key: str, value_type: str, value: Any) -> str:
raise ValueError(f"{key}: boolean value expected")
return str(value).lower()
if value_type == "integer":
if key in MOBILE_UPDATE_INTEGER_KEYS and value is None:
return ""
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"{key}: integer value expected")
return str(value)
@@ -206,6 +206,7 @@ class SafetyTask(Base):
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
traceparent: Mapped[str | None] = mapped_column(String(55))
status: Mapped[str] = mapped_column(String(16))
processing_mode: Mapped[str | None] = mapped_column(String(16))
config_version: Mapped[int | None] = mapped_column(BigInteger)
@@ -228,6 +229,7 @@ class DeliveryOutbox(Base):
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
traceparent: Mapped[str | None] = mapped_column(String(55))
status: Mapped[str] = mapped_column(String(16), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
@@ -1,7 +1,7 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from collections.abc import Mapping, MutableMapping
from typing import Any
REDACTED = "[REDACTED]"
@@ -42,6 +42,6 @@ def sanitize_value(value: Any) -> Any:
def redact_event(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
event_dict: MutableMapping[str, Any],
) -> MutableMapping[str, Any]:
return sanitize_value(event_dict)
@@ -63,6 +63,7 @@ from app.schemas import (
MessageRequest,
OpenLinesInbox,
OtpSettingsResponse,
PublicAppConfigResponse,
SessionStartRequest,
decode_cursor,
encode_cursor,
@@ -88,11 +89,22 @@ from app.services import (
start_session,
)
from app.settings import get_settings
from app.telemetry import add_trace_context, current_trace_id, init_telemetry, instrument_fastapi
from app.telemetry import (
TelemetryRuntime,
add_trace_context,
current_trace_id,
init_telemetry,
instrument_fastapi,
)
def configure_logging(level: str) -> None:
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
logging.basicConfig(level=level, format="%(message)s")
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
telemetry.logging_handler.addFilter(
lambda record: not record.name.startswith("opentelemetry")
)
logging.getLogger().addHandler(telemetry.logging_handler)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
@@ -101,7 +113,10 @@ def configure_logging(level: str) -> None:
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
@@ -134,7 +149,7 @@ async def refresh_jwks_cache(app: FastAPI) -> None:
async def lifespan(app: FastAPI):
settings = get_settings()
telemetry = init_telemetry()
configure_logging(settings.log_level)
configure_logging(settings.log_level, telemetry)
app.state.settings = settings
app.state.db = Database(settings.database_url)
app.state.http = httpx.AsyncClient()
@@ -177,7 +192,7 @@ async def lifespan(app: FastAPI):
telemetry.shutdown()
EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"
EXPECTED_API_DB_REVISION = "0013_delivery_trace_context"
app = FastAPI(
@@ -192,6 +207,15 @@ app.include_router(notification_router)
log = structlog.get_logger()
def etag_matches(if_none_match: str | None, etag: str) -> bool:
if not if_none_match:
return False
return any(
candidate.strip().removeprefix("W/") in {"*", etag}
for candidate in if_none_match.split(",")
)
def client_ip(request: Request) -> str | None:
"""Trust forwarded client addresses only from configured reverse proxies."""
peer = request.client.host if request.client else ""
@@ -501,8 +525,17 @@ async def ready(request: Request, db: Session):
)
@app.get("/api/v1/public/app-config", tags=["public"])
async def app_config(request: Request, response: Response, settings: SnapshotDep):
@app.get(
"/api/v1/public/app-config",
tags=["public"],
response_model=PublicAppConfigResponse,
responses={304: {"description": "Cached configuration is still current"}},
)
async def app_config(
request: Request,
settings: SnapshotDep,
if_none_match: Annotated[str | None, Header()] = None,
):
await enforce_limit(
request,
"ip",
@@ -511,12 +544,15 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
settings.limit("rate_limit.public_endpoints.per_ip"),
fail_closed=False,
)
response.headers["Cache-Control"] = (
headers = {"Cache-Control": (
f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}"
)
response.headers["ETag"] = f'"{settings.version}"'
)}
etag = f'"{settings.version}"'
headers["ETag"] = etag
if etag_matches(if_none_match, etag):
return Response(status_code=304, headers=headers)
values = settings.values
return {
body = {
"auth": {
"phone_enabled": settings.boolean("auth.phone.enabled"),
"password_enabled": settings.boolean("auth.password.enabled"),
@@ -557,7 +593,28 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
),
},
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
"mobile_update": {
store: {
"enabled": settings.boolean(f"mobile_update.{store}.enabled"),
"latest_build": (
settings.integer(f"mobile_update.{store}.latest_build")
if values[f"mobile_update.{store}.latest_build"]
else None
),
"minimum_build": (
settings.integer(f"mobile_update.{store}.minimum_build")
if values[f"mobile_update.{store}.minimum_build"]
else None
),
"latest_version": values[f"mobile_update.{store}.latest_version"] or None,
"store_url": values[f"mobile_update.{store}.store_url"] or None,
"release_notes": values[f"mobile_update.{store}.release_notes"] or None,
}
for store in ("google_play", "rustore", "app_store")
},
}
validated = PublicAppConfigResponse.model_validate(body)
return JSONResponse(validated.model_dump(mode="json"), headers=headers)
@app.get("/api/v1/public/content", tags=["public"])
@@ -0,0 +1,112 @@
from collections.abc import Mapping
from urllib.parse import parse_qs, urlparse
MOBILE_UPDATE_STORES = ("google_play", "rustore", "app_store")
MOBILE_UPDATE_FIELDS = (
"enabled",
"latest_build",
"minimum_build",
"latest_version",
"store_url",
"release_notes",
)
MOBILE_UPDATE_SETTING_KEYS = {
f"mobile_update.{store}.{field}"
for store in MOBILE_UPDATE_STORES
for field in MOBILE_UPDATE_FIELDS
}
MOBILE_UPDATE_BOOLEAN_KEYS = {
f"mobile_update.{store}.enabled" for store in MOBILE_UPDATE_STORES
}
MOBILE_UPDATE_INTEGER_KEYS = {
f"mobile_update.{store}.{field}"
for store in MOBILE_UPDATE_STORES
for field in ("latest_build", "minimum_build")
}
ANDROID_PACKAGE = "ru.han.chat"
def validate_mobile_update_settings(values: Mapping[str, str]) -> None:
for store in MOBILE_UPDATE_STORES:
prefix = f"mobile_update.{store}"
required = [f"{prefix}.{field}" for field in MOBILE_UPDATE_FIELDS]
present = [key for key in required if key in values]
if not present:
continue
if len(present) != len(required):
missing = sorted(set(required) - values.keys())
raise ValueError(f"{prefix}: incomplete policy; missing {missing}")
enabled = _boolean(values[f"{prefix}.enabled"], f"{prefix}.enabled")
latest_build = _optional_build(values[f"{prefix}.latest_build"], f"{prefix}.latest_build")
minimum_build = _optional_build(
values[f"{prefix}.minimum_build"], f"{prefix}.minimum_build"
)
latest_version = values[f"{prefix}.latest_version"].strip()
store_url = values[f"{prefix}.store_url"].strip()
release_notes = values[f"{prefix}.release_notes"].strip()
if len(release_notes) > 4000:
raise ValueError(f"{prefix}.release_notes: value must not exceed 4000 characters")
if not enabled:
if any(
value not in (None, "")
for value in (
latest_build,
minimum_build,
latest_version,
store_url,
)
):
raise ValueError(f"{prefix}: disabled policy fields must be empty")
continue
if latest_build is None or minimum_build is None:
raise ValueError(f"{prefix}: enabled policy requires build thresholds")
if minimum_build > latest_build:
raise ValueError(f"{prefix}: minimum_build must not exceed latest_build")
if not latest_version:
raise ValueError(f"{prefix}: enabled policy requires latest_version")
_validate_store_url(store, store_url, f"{prefix}.store_url")
def _boolean(raw: str, key: str) -> bool:
if raw not in {"true", "false"}:
raise ValueError(f"{key}: canonical boolean value expected")
return raw == "true"
def _optional_build(raw: str, key: str) -> int | None:
if raw == "":
return None
try:
value = int(raw)
except ValueError as error:
raise ValueError(f"{key}: integer value expected") from error
if str(value) != raw or value < 1:
raise ValueError(f"{key}: positive canonical integer value expected")
return value
def _validate_store_url(store: str, raw: str, key: str) -> None:
parsed = urlparse(raw)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError(f"{key}: absolute HTTPS URL expected")
if parsed.fragment:
raise ValueError(f"{key}: URL fragments are not allowed")
host = parsed.hostname.lower()
if store == "google_play":
package = parse_qs(parsed.query).get("id", [])
valid = host == "play.google.com" and parsed.path == "/store/apps/details"
valid = valid and package == [ANDROID_PACKAGE]
elif store == "rustore":
valid = host == "www.rustore.ru" and parsed.path == (
f"/catalog/app/{ANDROID_PACKAGE}"
)
valid = valid and not parsed.query
else:
valid = host == "apps.apple.com" and "/id" in parsed.path
if not valid:
raise ValueError(f"{key}: URL does not match the {store} application")
@@ -27,6 +27,96 @@ class OtpSettingsResponse(StrictModel):
cache_ttl_seconds: int = Field(strict=True, gt=0)
class PublicAuthConfig(StrictModel):
phone_enabled: bool
password_enabled: bool
class PublicOperatorConfig(StrictModel):
call_phone: str = Field(min_length=1, max_length=32)
class PublicMessagesConfig(StrictModel):
max_text_length: int = Field(strict=True, ge=1, le=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH)
class PublicConsentConfig(StrictModel):
required: bool
document_url: HttpUrl
version: str = Field(min_length=1, max_length=64)
class PublicPersonalDataConsentConfig(PublicConsentConfig):
privacy_policy_document_url: HttpUrl
class PublicConsentsConfig(StrictModel):
personal_data: PublicPersonalDataConsentConfig
user_agreement: PublicConsentConfig
marketing: PublicConsentConfig
class PublicAttachmentsConfig(StrictModel):
allowed_extensions: list[str]
allowed_mime_types: list[str]
max_size_mb: int = Field(strict=True, gt=0)
class PublicNotificationConfig(StrictModel):
carousel_autoplay_enabled: bool
carousel_autoplay_interval_ms: int = Field(strict=True, gt=0)
class PublicUxConfig(StrictModel):
idle_timeout_minutes: int = Field(strict=True, gt=0)
class MobileStoreUpdatePolicy(StrictModel):
enabled: bool
latest_build: int | None = Field(strict=True, ge=1)
minimum_build: int | None = Field(strict=True, ge=1)
latest_version: str | None = Field(min_length=1, max_length=64)
store_url: HttpUrl | None
release_notes: str | None = Field(max_length=4000)
@model_validator(mode="after")
def validate_policy(self) -> "MobileStoreUpdatePolicy":
release_fields = (
self.latest_build,
self.minimum_build,
self.latest_version,
self.store_url,
)
if self.enabled and any(value is None for value in release_fields):
raise ValueError("enabled update policy requires all release fields")
if not self.enabled and any(value is not None for value in release_fields):
raise ValueError("disabled update policy must not expose release fields")
if (
self.minimum_build is not None
and self.latest_build is not None
and self.minimum_build > self.latest_build
):
raise ValueError("minimum_build must not exceed latest_build")
return self
class MobileUpdatePolicy(StrictModel):
google_play: MobileStoreUpdatePolicy
rustore: MobileStoreUpdatePolicy
app_store: MobileStoreUpdatePolicy
class PublicAppConfigResponse(StrictModel):
auth: PublicAuthConfig
operator: PublicOperatorConfig
messages: PublicMessagesConfig
consents: PublicConsentsConfig
attachments: PublicAttachmentsConfig
notification: PublicNotificationConfig
ux: PublicUxConfig
mobile_update: MobileUpdatePolicy
class Device(StrictModel):
platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64)
@@ -38,6 +38,12 @@ from app.integrations import (
SafetyClient,
fresh_openlines_payload,
)
from app.mobile_update_settings import (
MOBILE_UPDATE_BOOLEAN_KEYS,
MOBILE_UPDATE_INTEGER_KEYS,
MOBILE_UPDATE_SETTING_KEYS,
validate_mobile_update_settings,
)
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.realtime import RealtimeFanout
from app.schemas import (
@@ -52,6 +58,7 @@ from app.schemas import (
encode_cursor,
)
from app.settings import Settings
from app.telemetry import current_traceparent
MESSAGE_SAFETY_REPLIES = {
"text": (
@@ -89,6 +96,7 @@ async def ensure_delivery_outbox(
external_chat_id: uuid.UUID,
payload_json: dict[str, Any],
next_attempt_at: datetime,
traceparent: str | None = None,
) -> DeliveryOutbox:
"""Create the per-message outbox row or return the concurrent winner."""
statement = (
@@ -98,6 +106,7 @@ async def ensure_delivery_outbox(
message_id=message_id,
external_chat_id=external_chat_id,
payload_json=payload_json,
traceparent=traceparent,
next_attempt_at=next_attempt_at,
)
.on_conflict_do_nothing(index_elements=[DeliveryOutbox.message_id])
@@ -117,6 +126,67 @@ async def ensure_delivery_outbox(
return outbox
OPENLINES_FALLBACK_DISPLAY_NAME = "Новый клиент HAN"
def openlines_display_name(full_name: str | None) -> str:
name = (full_name or "").strip()
return name or OPENLINES_FALLBACK_DISPLAY_NAME
def openlines_user_payload(user: UserIdentity, profile: ClientProfile | None) -> dict[str, str]:
return {
"id": str(user.id),
"display_name": openlines_display_name(profile.full_name if profile else None),
"phone": user.phone_number,
}
async def load_active_client_profile(
session: AsyncSession, user_id: uuid.UUID
) -> ClientProfile | None:
return (
await session.execute(
select(ClientProfile).where(
ClientProfile.user_id == user_id, ClientProfile.record_status == "A"
)
)
).scalar_one_or_none()
def openlines_delivery_payload(
*,
message: Message,
dialog_id: uuid.UUID,
user: UserIdentity,
profile: ClientProfile | None,
attachment: MessageAttachment | None,
) -> dict[str, Any]:
files: list[dict[str, Any]] = []
if attachment:
files.append(
{
"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,
}
)
return {
"message_id": str(message.id),
"external_chat_id": str(dialog_id),
"occurred_at": message.occurred_at.isoformat(),
"user": openlines_user_payload(user, profile),
"message": {
"content_kind": message.content_kind,
"text": message.text,
"files": files,
},
}
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
@@ -165,7 +235,7 @@ REQUIRED_SETTINGS = {
"rate_limit.notification_upload.per_user",
"rate_limit.notifications_public.per_ip",
CHAT_MESSAGE_MAX_LENGTH_KEY,
} | OTP_SETTING_KEYS
} | OTP_SETTING_KEYS | MOBILE_UPDATE_SETTING_KEYS
@dataclass(frozen=True, slots=True)
@@ -225,15 +295,33 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
invalid_metadata = sorted(
row.setting_key
for row in rows
if row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
if (
row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
)
or (
row.setting_key in MOBILE_UPDATE_BOOLEAN_KEYS
and (row.value_type != "boolean" or not row.is_public)
)
or (
row.setting_key in MOBILE_UPDATE_INTEGER_KEYS
and (row.value_type != "integer" or not row.is_public)
)
or (
row.setting_key
in MOBILE_UPDATE_SETTING_KEYS
- MOBILE_UPDATE_BOOLEAN_KEYS
- MOBILE_UPDATE_INTEGER_KEYS
and (row.value_type != "string" or not row.is_public)
)
)
if invalid_metadata:
raise ValueError(
f"OTP settings must have integer type and be private: {invalid_metadata}"
f"Settings metadata is invalid: {invalid_metadata}"
)
validate_otp_settings(values)
validate_chat_settings(values)
validate_mobile_update_settings(values)
except ValueError as error:
raise DomainError(
"dependency_unavailable",
@@ -954,6 +1042,7 @@ async def send_message(
message_id=message.id,
attachment_id=attachment.id if attachment else None,
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
traceparent=current_traceparent(),
status="polling",
processing_mode=verdict["processing_mode"],
config_version=verdict["config_version"],
@@ -1032,38 +1121,23 @@ async def send_message(
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
)
message.safety_status = "allowed"
profile = await load_active_client_profile(session, user.id)
outbox = 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 []
),
},
},
payload_json=openlines_delivery_payload(
message=message,
dialog_id=dialog_id,
user=user,
profile=profile,
attachment=attachment,
),
# Keep the row recoverable after a process crash, but do not let the
# delivery worker race the synchronous first attempt.
next_attempt_at=datetime.now(UTC)
+ timedelta(seconds=settings.bitrix_local_app_http_timeout_sec + 5),
traceparent=current_traceparent(),
)
await session.commit()
await publish_message_status(fanout, message, settings)
@@ -1,11 +1,15 @@
from __future__ import annotations
import logging
import os
from collections.abc import MutableMapping
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
@@ -13,7 +17,9 @@ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagate import extract, inject, set_global_textmap
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
@@ -27,8 +33,11 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapProp
class TelemetryRuntime:
tracer_provider: TracerProvider
meter_provider: MeterProvider
logger_provider: LoggerProvider
logging_handler: LoggingHandler
def shutdown(self) -> None:
self.logger_provider.shutdown()
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
@@ -79,11 +88,29 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
set_logger_provider(logger_provider)
logging_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
RedisInstrumentor().instrument()
BotocoreInstrumentor().instrument()
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
_runtime = TelemetryRuntime(
tracer_provider,
meter_provider,
logger_provider,
logging_handler,
)
return _runtime
@@ -97,8 +124,8 @@ def instrument_fastapi(app: FastAPI) -> None:
def add_trace_context(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
event_dict: MutableMapping[str, Any],
) -> MutableMapping[str, Any]:
context = trace.get_current_span().get_span_context()
if context.is_valid:
event_dict["trace_id"] = format(context.trace_id, "032x")
@@ -109,3 +136,17 @@ def add_trace_context(
def current_trace_id() -> str | None:
context = trace.get_current_span().get_span_context()
return format(context.trace_id, "032x") if context.is_valid else None
def current_traceparent() -> str | None:
carrier: dict[str, str] = {}
inject(carrier)
return carrier.get("traceparent")
def origin_links(traceparent: str | None) -> list[trace.Link]:
if not traceparent:
return []
context = extract({"traceparent": traceparent})
span_context = trace.get_current_span(context).get_span_context()
return [trace.Link(span_context)] if span_context.is_valid else []
@@ -1,12 +1,14 @@
import asyncio
import logging
import uuid
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
import httpx
import redis.asyncio as redis
import structlog
from opentelemetry import trace
from sqlalchemy import delete, select
from app.db import (
@@ -25,20 +27,58 @@ from app.integrations import (
SafetyClient,
fresh_openlines_payload,
)
from app.logging_security import redact_event
from app.notification_models import ClientUploadDraft
from app.notification_service import expire_notifications
from app.realtime import RealtimeFanout
from app.services import (
ensure_delivery_outbox,
load_active_client_profile,
load_settings,
openlines_delivery_payload,
publish_dialog_status,
publish_message_status,
)
from app.settings import Settings, get_settings
from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry, origin_links
log = structlog.get_logger()
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
logging.basicConfig(level=level, format="%(message)s")
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
telemetry.logging_handler.addFilter(
lambda record: not record.name.startswith("opentelemetry")
)
logging.getLogger().addHandler(telemetry.logging_handler)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context,
redact_event,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
def run_worker(service_name: str, target: Callable[[], Awaitable[None]]) -> None:
settings = get_settings()
telemetry = init_telemetry(service_name)
configure_logging(settings.log_level, telemetry)
structlog.contextvars.bind_contextvars(**{"service.name": service_name})
try:
asyncio.run(target())
finally:
if telemetry:
telemetry.shutdown()
@asynccontextmanager
async def worker_http_clients(
settings: Settings,
@@ -59,6 +99,7 @@ async def delivery_once(
worker_id: str,
batch_size: int = 20,
) -> int:
tracer = trace.get_tracer("han.api.delivery-worker")
async with db.sessions() as session:
rows = (
(
@@ -86,33 +127,43 @@ async def delivery_once(
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
if row is None:
continue
message = None
dialog = None
try:
payload = await fresh_openlines_payload(row.payload_json, s3)
await client.send(row.message_id, payload, f"worker-{worker_id}")
row.status = "delivered"
message = await session.get(Message, row.message_id)
with (
tracer.start_as_current_span(
"delivery.process",
links=origin_links(row.traceparent),
),
structlog.contextvars.bound_contextvars(
delivery_outbox_id=str(row.id),
message_id=str(row.message_id),
),
):
message = None
dialog = None
try:
payload = await fresh_openlines_payload(row.payload_json, s3)
await client.send(row.message_id, payload, f"worker-{worker_id}")
row.status = "delivered"
message = await session.get(Message, row.message_id)
if message:
message.delivery_status = "delivered"
dialog = await session.get(Dialog, message.dialog_id)
if dialog:
dialog.status = "waiting_for_company"
dialog.last_message_at = datetime.now(UTC)
except DependencyFailure:
row.attempt_count += 1
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
row.next_attempt_at = datetime.now(UTC) + timedelta(
seconds=min(3600, 2**row.attempt_count)
)
row.last_error_code = "dependency_unavailable"
row.locked_at = None
row.locked_by = None
await session.commit()
if message:
message.delivery_status = "delivered"
dialog = await session.get(Dialog, message.dialog_id)
if dialog:
dialog.status = "waiting_for_company"
dialog.last_message_at = datetime.now(UTC)
except DependencyFailure:
row.attempt_count += 1
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
row.next_attempt_at = datetime.now(UTC) + timedelta(
seconds=min(3600, 2**row.attempt_count)
)
row.last_error_code = "dependency_unavailable"
row.locked_at = None
row.locked_by = None
await session.commit()
if message:
await publish_message_status(fanout, message, settings)
if dialog:
await publish_dialog_status(fanout, dialog)
await publish_message_status(fanout, message, settings)
if dialog:
await publish_dialog_status(fanout, dialog)
return len(ids)
@@ -125,6 +176,7 @@ async def safety_once(
worker_id: str,
batch_size: int = 20,
) -> int:
tracer = trace.get_tracer("han.api.safety-recovery-worker")
async with db.sessions() as session:
rows = (
(
@@ -153,7 +205,14 @@ async def safety_once(
continue
message = None
try:
verdict = await safety.poll(task.poll_location, f"worker-{worker_id}")
with tracer.start_as_current_span(
"safety.recovery.poll",
links=origin_links(task.traceparent),
):
verdict = await safety.poll(
task.poll_location,
f"worker-{worker_id}",
)
message = await session.get(Message, task.message_id)
attachment = (
await session.get(MessageAttachment, task.attachment_id)
@@ -189,38 +248,20 @@ async def safety_once(
else None
)
if dialog and user:
profile = await load_active_client_profile(session, user.id)
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 []
),
},
},
payload_json=openlines_delivery_payload(
message=message,
dialog_id=dialog.id,
user=user,
profile=profile,
attachment=attachment,
),
next_attempt_at=datetime.now(UTC),
traceparent=task.traceparent,
)
elif verdict["_status"] == 403 and message:
message.safety_processing_mode = verdict["processing_mode"]
@@ -382,20 +423,20 @@ async def notification_draft_cleanup_loop() -> None:
def delivery_main() -> None:
asyncio.run(loop("delivery"))
run_worker("delivery-worker", lambda: loop("delivery"))
def safety_main() -> None:
asyncio.run(loop("safety"))
run_worker("safety-recovery-worker", lambda: loop("safety"))
def cleanup_main() -> None:
asyncio.run(loop("cleanup"))
run_worker("cleanup-worker", lambda: loop("cleanup"))
def notification_expire_main() -> None:
asyncio.run(notification_expire_loop())
run_worker("notification-expire-worker", notification_expire_loop)
def notification_draft_cleanup_main() -> None:
asyncio.run(notification_draft_cleanup_loop())
run_worker("notification-draft-cleanup-worker", notification_draft_cleanup_loop)
@@ -19,20 +19,15 @@ paths:
/api/v1/public/app-config:
get:
operationId: getPublicAppConfig
parameters:
- {name: If-None-Match, in: header, schema: {type: string}}
responses:
"200":
description: Public application configuration
content:
application/json:
schema:
type: object
required: [messages]
properties:
messages:
type: object
required: [max_text_length]
properties:
max_text_length: {type: integer, minimum: 1, maximum: 10000}
schema: {$ref: "#/components/schemas/PublicAppConfigResponse"}
"304": {description: Cached configuration is still current}
/api/v1/public/content:
get:
operationId: getPublicContent
@@ -405,6 +400,105 @@ components:
description: Catalog action is not allowed
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
schemas:
PublicAppConfigResponse:
type: object
additionalProperties: false
required: [auth, operator, messages, consents, attachments, notification, ux, mobile_update]
properties:
auth: {$ref: "#/components/schemas/PublicAuthConfig"}
operator: {$ref: "#/components/schemas/PublicOperatorConfig"}
messages: {$ref: "#/components/schemas/PublicMessagesConfig"}
consents: {$ref: "#/components/schemas/PublicConsentsConfig"}
attachments: {$ref: "#/components/schemas/PublicAttachmentsConfig"}
notification: {$ref: "#/components/schemas/PublicNotificationConfig"}
ux: {$ref: "#/components/schemas/PublicUxConfig"}
mobile_update: {$ref: "#/components/schemas/MobileUpdatePolicy"}
PublicAuthConfig:
type: object
additionalProperties: false
required: [phone_enabled, password_enabled]
properties:
phone_enabled: {type: boolean}
password_enabled: {type: boolean}
PublicOperatorConfig:
type: object
additionalProperties: false
required: [call_phone]
properties:
call_phone: {type: string, minLength: 1, maxLength: 32}
PublicMessagesConfig:
type: object
additionalProperties: false
required: [max_text_length]
properties:
max_text_length: {type: integer, minimum: 1, maximum: 10000}
PublicConsentConfig:
type: object
additionalProperties: false
required: [required, document_url, version]
properties:
required: {type: boolean}
document_url: {type: string, format: uri, minLength: 1}
version: {type: string, minLength: 1, maxLength: 64}
PublicPersonalDataConsentConfig:
allOf:
- {$ref: "#/components/schemas/PublicConsentConfig"}
- type: object
additionalProperties: false
required: [required, document_url, version, privacy_policy_document_url]
properties:
required: {type: boolean}
document_url: {type: string, format: uri, minLength: 1}
version: {type: string, minLength: 1, maxLength: 64}
privacy_policy_document_url: {type: string, format: uri, minLength: 1}
PublicConsentsConfig:
type: object
additionalProperties: false
required: [personal_data, user_agreement, marketing]
properties:
personal_data: {$ref: "#/components/schemas/PublicPersonalDataConsentConfig"}
user_agreement: {$ref: "#/components/schemas/PublicConsentConfig"}
marketing: {$ref: "#/components/schemas/PublicConsentConfig"}
PublicAttachmentsConfig:
type: object
additionalProperties: false
required: [allowed_extensions, allowed_mime_types, max_size_mb]
properties:
allowed_extensions: {type: array, items: {type: string}}
allowed_mime_types: {type: array, items: {type: string}}
max_size_mb: {type: integer, minimum: 1}
PublicNotificationConfig:
type: object
additionalProperties: false
required: [carousel_autoplay_enabled, carousel_autoplay_interval_ms]
properties:
carousel_autoplay_enabled: {type: boolean}
carousel_autoplay_interval_ms: {type: integer, minimum: 1}
PublicUxConfig:
type: object
additionalProperties: false
required: [idle_timeout_minutes]
properties:
idle_timeout_minutes: {type: integer, minimum: 1}
MobileStoreUpdatePolicy:
type: object
additionalProperties: false
required: [enabled, latest_build, minimum_build, latest_version, store_url, release_notes]
properties:
enabled: {type: boolean}
latest_build: {type: [integer, "null"], minimum: 1}
minimum_build: {type: [integer, "null"], minimum: 1}
latest_version: {type: [string, "null"], minLength: 1, maxLength: 64}
store_url: {type: [string, "null"], format: uri, minLength: 1}
release_notes: {type: [string, "null"], maxLength: 4000}
MobileUpdatePolicy:
type: object
additionalProperties: false
required: [google_play, rustore, app_store]
properties:
google_play: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
rustore: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
app_store: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
OtpSettingsResponse:
type: object
additionalProperties: false
@@ -304,7 +304,11 @@ async def test_openlines_payload_gets_fresh_download_url_without_storage_fields(
"message_id": str(uuid.uuid4()),
"external_chat_id": str(uuid.uuid4()),
"occurred_at": "2026-07-10T12:00:00+00:00",
"user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"},
"user": {
"id": str(uuid.uuid4()),
"display_name": "Новый клиент HAN",
"phone": "+79990000000",
},
"message": {
"content_kind": "file",
"text": "",
@@ -13,6 +13,7 @@ from pydantic import SecretStr
from app.main import (
EXPECTED_API_DB_REVISION,
app,
app_config,
otp_settings,
refresh_jwks_cache,
websocket_token,
@@ -152,13 +153,116 @@ def test_committed_openapi_server_does_not_double_api_prefix() -> None:
assert committed["servers"] == [{"url": "/"}]
def test_public_config_contract_exposes_message_length() -> None:
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
def test_public_config_contract_is_strict_and_exposes_mobile_update() -> None:
generated = app.openapi()
response = generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
assert messages["required"] == ["max_text_length"]
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
assert schema["additionalProperties"] is False
assert set(schema["required"]) == {
"auth",
"operator",
"messages",
"consents",
"attachments",
"notification",
"ux",
"mobile_update",
}
mobile_ref = schema["properties"]["mobile_update"]["$ref"]
mobile = generated["components"]["schemas"][mobile_ref.rsplit("/", 1)[-1]]
assert mobile["additionalProperties"] is False
assert set(mobile["required"]) == {"google_play", "rustore", "app_store"}
store_ref = mobile["properties"]["rustore"]["$ref"]
store = generated["components"]["schemas"][store_ref.rsplit("/", 1)[-1]]
assert "release_notes" in store["required"]
release_notes = store["properties"]["release_notes"]
assert {"type": "string", "maxLength": 4000} in release_notes["anyOf"]
assert {"type": "null"} in release_notes["anyOf"]
assert "304" in generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]
async def test_public_config_returns_mobile_policy_and_supports_etag(monkeypatch) -> None:
values = {
"rate_limit.public_endpoints.per_ip": "60/minute",
"security.public_cache.max_age_seconds": "60",
"auth.phone.enabled": "true",
"auth.password.enabled": "false",
"operator.call.phone": "+74999591007",
"chat.message.max_length": "4000",
"consent.personal_data.required": "true",
"consent.personal_data.document_url": "https://example.ru/personal",
"consent.privacy_policy.document_url": "https://example.ru/privacy",
"consent.personal_data.version": "2026-06-10",
"consent.user_agreement.required": "true",
"consent.user_agreement.document_url": "https://example.ru/agreement",
"consent.user_agreement.version": "2026-06-10",
"consent.marketing.required": "false",
"consent.marketing.document_url": "https://example.ru/marketing",
"consent.marketing.version": "2026-06-10",
"chat.attachments.allowed_extensions": "jpg,pdf",
"chat.attachments.allowed_mime_types": "image/jpeg,application/pdf",
"chat.attachments.max_size_mb": "5",
"notification.carousel.autoplay_enabled": "false",
"notification.carousel.autoplay_interval_ms": "5000",
"ux.session.idle_timeout_minutes": "30",
"mobile_update.google_play.enabled": "true",
"mobile_update.google_play.latest_build": "2",
"mobile_update.google_play.minimum_build": "1",
"mobile_update.google_play.latest_version": "1.0.1",
"mobile_update.google_play.store_url": (
"https://play.google.com/store/apps/details?id=ru.han.chat"
),
"mobile_update.google_play.release_notes": "",
"mobile_update.rustore.enabled": "true",
"mobile_update.rustore.latest_build": "2",
"mobile_update.rustore.minimum_build": "1",
"mobile_update.rustore.latest_version": "1.0.1",
"mobile_update.rustore.store_url": (
"https://www.rustore.ru/catalog/app/ru.han.chat"
),
"mobile_update.rustore.release_notes": "",
"mobile_update.app_store.enabled": "false",
"mobile_update.app_store.latest_build": "",
"mobile_update.app_store.minimum_build": "",
"mobile_update.app_store.latest_version": "",
"mobile_update.app_store.store_url": "",
"mobile_update.app_store.release_notes": "",
}
settings = SettingsSnapshot(values, "settings-version")
request = SimpleNamespace(
headers={},
client=None,
app=SimpleNamespace(state=SimpleNamespace()),
)
async def no_limit(*args, **kwargs) -> None:
return None
monkeypatch.setattr("app.main.enforce_limit", no_limit)
response = await app_config(request, settings)
body = json.loads(response.body)
assert response.headers["etag"] == '"settings-version"'
assert response.headers["cache-control"] == "public, max-age=60"
assert body["mobile_update"]["google_play"]["latest_build"] == 2
assert body["mobile_update"]["google_play"]["release_notes"] is None
assert body["mobile_update"]["rustore"]["store_url"] == (
"https://www.rustore.ru/catalog/app/ru.han.chat"
)
assert body["mobile_update"]["app_store"] == {
"enabled": False,
"latest_build": None,
"minimum_build": None,
"latest_version": None,
"store_url": None,
"release_notes": None,
}
cached = await app_config(request, settings, 'W/"settings-version", "old"')
assert cached.status_code == 304
assert cached.headers["etag"] == '"settings-version"'
def test_otp_settings_contract_is_strict_and_complete() -> None:
@@ -17,6 +17,17 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None:
assert values["otp.phone.ttl_seconds"] == "60"
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
assert values["chat.message.max_length"] == "4000"
assert values["mobile_update.google_play.latest_build"] == "2"
assert values["mobile_update.google_play.minimum_build"] == "1"
assert values["mobile_update.google_play.latest_version"] == "1.0.1"
assert values["mobile_update.rustore.latest_build"] == "2"
assert values["mobile_update.rustore.store_url"] == (
"https://www.rustore.ru/catalog/app/ru.han.chat"
)
assert values["mobile_update.rustore.release_notes"] == ""
assert values["mobile_update.app_store.enabled"] == "false"
assert values["mobile_update.app_store.latest_build"] == ""
assert values["security.public_cache.max_age_seconds"] == "60"
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
@@ -76,3 +87,98 @@ def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
load_seed(path)
def _mobile_policy_yaml(
*,
store: str = "google_play",
enabled: bool = True,
latest_build: int = 2,
minimum_build: int = 1,
store_url: str = "https://play.google.com/store/apps/details?id=ru.han.chat",
) -> str:
enabled_yaml = str(enabled).lower()
return (
"schema_version: 1\nsettings:\n"
f" mobile_update.{store}.enabled: "
f"{{type: boolean, value: {enabled_yaml}, public: true}}\n"
f" mobile_update.{store}.latest_build: "
f"{{type: integer, value: {latest_build}, public: true}}\n"
f" mobile_update.{store}.minimum_build: "
f"{{type: integer, value: {minimum_build}, public: true}}\n"
f' mobile_update.{store}.latest_version: '
'{type: string, value: "1.0.1", public: true}\n'
f' mobile_update.{store}.store_url: '
f'{{type: string, value: "{store_url}", public: true}}\n'
f' mobile_update.{store}.release_notes: '
'{type: string, value: "", public: true}\n'
)
def test_seed_rejects_inverted_mobile_build_thresholds(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
_mobile_policy_yaml(latest_build=1, minimum_build=2),
encoding="utf-8",
)
with pytest.raises(ValueError, match="minimum_build must not exceed latest_build"):
load_seed(path)
def test_seed_rejects_wrong_mobile_store_url(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
_mobile_policy_yaml(
store_url="https://play.google.com/store/apps/details?id=other.package"
),
encoding="utf-8",
)
with pytest.raises(ValueError, match="does not match the google_play application"):
load_seed(path)
def test_seed_rejects_non_https_mobile_store_url(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
_mobile_policy_yaml(
store_url="http://play.google.com/store/apps/details?id=ru.han.chat"
),
encoding="utf-8",
)
with pytest.raises(ValueError, match="absolute HTTPS URL expected"):
load_seed(path)
def test_seed_accepts_canonical_rustore_url(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
_mobile_policy_yaml(
store="rustore",
store_url="https://www.rustore.ru/catalog/app/ru.han.chat",
),
encoding="utf-8",
)
rows = load_seed(path)
assert any(
row["setting_key"] == "mobile_update.rustore.store_url"
and row["setting_value"] == "https://www.rustore.ru/catalog/app/ru.han.chat"
for row in rows
)
def test_seed_rejects_legacy_rustore_url(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
_mobile_policy_yaml(
store="rustore",
store_url="https://apps.rustore.ru/app/ru.han.chat",
),
encoding="utf-8",
)
with pytest.raises(ValueError, match="does not match the rustore application"):
load_seed(path)
@@ -20,6 +20,7 @@ from app.schemas import (
from app.services import (
MESSAGE_SAFETY_REPLIES,
ensure_delivery_outbox,
openlines_user_payload,
safety_reply_message,
safety_task_recovery_at,
)
@@ -85,6 +86,20 @@ def test_safety_recovery_starts_after_synchronous_polling_window() -> None:
assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307
def test_openlines_user_payload_uses_profile_name_and_phone() -> None:
user = SimpleNamespace(id=uuid.uuid4(), phone_number="+79991234567")
named = openlines_user_payload(user, SimpleNamespace(full_name="Иван Иванов"))
assert named == {
"id": str(user.id),
"display_name": "Иван Иванов",
"phone": "+79991234567",
}
assert openlines_user_payload(user, SimpleNamespace(full_name=" "))["display_name"] == (
"Новый клиент HAN"
)
assert openlines_user_payload(user, None)["display_name"] == "Новый клиент HAN"
async def test_delivery_outbox_returns_concurrent_insert_winner() -> None:
existing = object()
session = SimpleNamespace(
@@ -23,3 +23,19 @@ def test_structlog_processor_adds_active_trace_context_only() -> None:
assert len(result["trace_id"]) == 32
assert len(result["span_id"]) == 16
assert set(result) == {"event", "request_id", "trace_id", "span_id"}
def test_no_active_span_does_not_invent_trace_context() -> None:
result = telemetry.add_trace_context(None, "info", {"event": "safe"})
assert result == {"event": "safe"}
def test_async_origin_creates_link_without_parenting_worker_span() -> None:
tracer = TracerProvider().get_tracer("test")
with tracer.start_as_current_span("request"):
traceparent = telemetry.current_traceparent()
origin_trace_id = telemetry.current_trace_id()
links = telemetry.origin_links(traceparent)
assert len(links) == 1
assert format(links[0].context.trace_id, "032x") == origin_trace_id
@@ -0,0 +1,63 @@
"""Persist request and W3C trace context for inbox forwarding.
Revision ID: 0002_inbox_trace_context
Revises: 0001_bitrix_local_schema
Create Date: 2026-09-03
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0002_inbox_trace_context"
down_revision: str | None = "0001_bitrix_local"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE bitrix_local.inbox_events
ADD COLUMN IF NOT EXISTS request_id varchar(64),
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
"""
)
op.execute(
"""
ALTER TABLE bitrix_local.outbound_messages
ADD COLUMN IF NOT EXISTS request_id varchar(64),
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
"""
)
op.execute(
"""
ALTER TABLE bitrix_local.delivery_ack_outbox
ADD COLUMN IF NOT EXISTS request_id varchar(64),
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
"""
)
def downgrade() -> None:
op.execute(
"""
ALTER TABLE bitrix_local.inbox_events
DROP COLUMN IF EXISTS traceparent,
DROP COLUMN IF EXISTS request_id
"""
)
op.execute(
"""
ALTER TABLE bitrix_local.outbound_messages
DROP COLUMN IF EXISTS traceparent,
DROP COLUMN IF EXISTS request_id
"""
)
op.execute(
"""
ALTER TABLE bitrix_local.delivery_ack_outbox
DROP COLUMN IF EXISTS traceparent,
DROP COLUMN IF EXISTS request_id
"""
)
@@ -0,0 +1,47 @@
from __future__ import annotations
import re
from collections.abc import Mapping, MutableMapping
from typing import Any
REDACTED = "[REDACTED]"
_SENSITIVE_KEY = re.compile(
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|"
r"database[_-]?url|dsn|callback[_-]?url|phone|email|message|payload)",
re.IGNORECASE,
)
_URI_USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)[^/@\s]+@", re.IGNORECASE)
_QUERY_SECRET = re.compile(
r"(?P<prefix>[?&](?:token|access_token|api_key|key|secret|password)=)[^&#\s]+",
re.IGNORECASE,
)
_AUTH_VALUE = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
def sanitize_text(value: str) -> str:
value = _URI_USERINFO.sub(r"\g<scheme>[REDACTED]@", value)
value = _QUERY_SECRET.sub(r"\g<prefix>[REDACTED]", value)
return _AUTH_VALUE.sub(r"\1 [REDACTED]", value)
def sanitize_value(value: Any) -> Any:
if isinstance(value, str):
return sanitize_text(value)
if isinstance(value, Mapping):
return {
str(key): REDACTED if _SENSITIVE_KEY.search(str(key)) else sanitize_value(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_value(item) for item in value]
if isinstance(value, tuple):
return tuple(sanitize_value(item) for item in value)
return value
def redact_event(
_logger: Any,
_method_name: str,
event_dict: MutableMapping[str, Any],
) -> MutableMapping[str, Any]:
return sanitize_value(event_dict)
@@ -16,11 +16,13 @@ from typing import Annotated, Any, Literal
from urllib.parse import quote, urlparse
import httpx
import structlog
import uvicorn
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse
from opentelemetry import trace
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import func, or_, select, text
@@ -37,9 +39,19 @@ from app.models import (
PortalInstallation,
now,
)
from app.logging_security import redact_event
from app.postgres import create_postgres_engine
from app.telemetry import (
TelemetryRuntime,
add_trace_context,
current_traceparent,
init_telemetry,
instrument_fastapi,
origin_links,
)
logger = logging.getLogger("bitrix-local-app")
logger = structlog.get_logger("bitrix-local-app")
EXPECTED_BITRIX_DB_REVISION = "0002_inbox_trace_context"
BITRIX_SENDER_PREFIX = re.compile(
r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?",
@@ -57,6 +69,7 @@ CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote(
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
app_env: str = "production-like"
log_level: str = "INFO"
bitrix_database_url: str
bitrix_client_id: str
bitrix_client_secret: str
@@ -78,6 +91,28 @@ class Settings(BaseSettings):
bitrix_worker_poll_sec: float = Field(default=1, ge=0.05, le=30)
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
logging.basicConfig(level=level, format="%(message)s")
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
telemetry.logging_handler.addFilter(
lambda record: not record.name.startswith("opentelemetry")
)
logging.getLogger().addHandler(telemetry.logging_handler)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context,
redact_event,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
class TokenCipher:
def __init__(self, encoded_key: str, version: str) -> None:
try:
@@ -114,6 +149,7 @@ class UserDto(BaseModel):
model_config = ConfigDict(extra="forbid")
id: uuid.UUID
display_name: str = Field(min_length=1, max_length=255)
phone: str | None = Field(default=None, min_length=1, max_length=32)
class FileDto(BaseModel):
@@ -308,8 +344,8 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
normalized_file["_bitrix_file_id"] = str(file_id)
if not normalized_file["download_url"] and not file_id:
logger.warning(
"inbound file has no supported download reference; keys=%s",
sorted(str(key) for key in item),
"inbound_file.unsupported_reference",
keys=sorted(str(key) for key in item),
)
files.append(normalized_file)
if not text_value and not files:
@@ -497,6 +533,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
telemetry = init_telemetry()
configure_logging(cfg.log_level, telemetry)
engine = create_postgres_engine(
cfg.bitrix_database_url,
pool_size=5,
@@ -526,6 +564,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
await task
await app.state.http.aclose()
await engine.dispose()
if telemetry:
telemetry.shutdown()
app = FastAPI(
title="HAN Bitrix24 Local App",
@@ -539,9 +579,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.middleware("http")
async def request_context(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
with structlog.contextvars.bound_contextvars(
request_id=request.state.request_id,
**{"service.name": "bitrix-local-app"},
):
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):
@@ -578,9 +622,22 @@ def create_app(settings: Settings | None = None) -> FastAPI:
async with request.app.state.sessions() as session:
portal = await active_portal(session)
await session.scalar(select(func.now()))
revision = await session.scalar(
text("SELECT version_num FROM bitrix_local.alembic_version")
)
worker_ok = all(not task.done() for task in request.app.state.workers)
if portal and portal.install_status == "installed" and worker_ok:
if (
portal
and portal.install_status == "installed"
and worker_ok
and revision == EXPECTED_BITRIX_DB_REVISION
):
return {"status": "ready", "portal": "installed", "workers": "running"}
if revision != EXPECTED_BITRIX_DB_REVISION:
return JSONResponse(
{"status": "not_ready", "reason": "migration_required"},
status_code=503,
)
return JSONResponse(
{"status": "not_ready", "reason": "portal_not_installed"}, status_code=503
)
@@ -654,7 +711,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
normalized = normalize_event(payload)
if normalized is None:
return {"status": "ignored"}
created = await save_inbox(request.app, normalized)
created = await save_inbox(
request.app,
normalized,
request.state.request_id,
current_traceparent(),
)
return JSONResponse(
{"status": "accepted" if created else "duplicate"},
status_code=202 if created else 200,
@@ -697,7 +759,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"Idempotency-Key must equal message_id",
),
)
body = dto.model_dump(mode="json")
body = dto.model_dump(mode="json", exclude_none=True)
fp = canonical_fingerprint(body)
async with request.app.state.sessions() as session:
row = await session.scalar(
@@ -730,6 +792,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
external_chat_id=dto.external_chat_id,
request_fingerprint=fp,
payload_json=body,
request_id=request.state.request_id,
traceparent=current_traceparent(),
status="sending",
lease_until=now() + timedelta(seconds=cfg.bitrix_http_timeout_sec + 5),
)
@@ -767,12 +831,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
except Exception as exc:
logger.error(
"outbound delivery failed",
extra={
"request_id": request.state.request_id,
"message_id": str(dto.message_id),
"error_type": type(exc).__name__,
},
"outbound.delivery_failed",
request_id=request.state.request_id,
message_id=str(dto.message_id),
error_type=type(exc).__name__,
)
async with request.app.state.sessions() as session:
current = await session.get(OutboundMessage, row.id, with_for_update=True)
@@ -844,6 +906,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
result = await reconcile_setup(request.app)
return {"status": "completed" if all(result.values()) else "partial", "steps": result}
instrument_fastapi(app)
return app
@@ -959,7 +1022,12 @@ async def uninstall_payload(app: FastAPI, payload: dict[str, Any]) -> None:
await session.commit()
async def save_inbox(app: FastAPI, normalized: dict[str, Any]) -> bool:
async def save_inbox(
app: FastAPI,
normalized: dict[str, Any],
request_id: str,
traceparent: str | None,
) -> bool:
fingerprint = hashlib.sha256(
json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
@@ -970,6 +1038,8 @@ async def save_inbox(app: FastAPI, normalized: dict[str, Any]) -> bool:
bitrix_message_id=normalized["bitrix_message_id"],
payload_fingerprint=fingerprint,
normalized_json=normalized,
request_id=request_id,
traceparent=traceparent,
)
async with app.state.sessions() as session:
session.add(row)
@@ -992,17 +1062,11 @@ def extract_delivery(result: dict[str, Any]) -> tuple[int | None, str | None, st
)
async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
async with app.state.sessions() as session:
row = await session.get(OutboundMessage, row_id)
portal = await active_portal(session)
if not row or not portal:
raise RuntimeError("portal_not_installed")
payload = row.payload_json
def imconnector_send_fields(connector: str, line: str, payload: dict[str, Any]) -> dict[str, Any]:
message = payload["message"]
fields: dict[str, Any] = {
"CONNECTOR": app.state.settings.bitrix_connector_id,
"LINE": app.state.settings.bitrix_open_line_id,
"CONNECTOR": connector,
"LINE": line,
"MESSAGES[0][user][id]": payload["user"]["id"],
"MESSAGES[0][user][name]": payload["user"]["display_name"],
"MESSAGES[0][message][id]": payload["message_id"],
@@ -1010,10 +1074,28 @@ async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
"MESSAGES[0][message][text]": message["text"],
"MESSAGES[0][chat][id]": payload["external_chat_id"],
}
if message["files"]:
phone = payload["user"].get("phone")
if phone:
fields["MESSAGES[0][user][phone]"] = phone
if message.get("files"):
file = message["files"][0]
fields["MESSAGES[0][message][files][0][url]"] = file["download_url"]
fields["MESSAGES[0][message][files][0][name]"] = file["name"]
return fields
async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
async with app.state.sessions() as session:
row = await session.get(OutboundMessage, row_id)
portal = await active_portal(session)
if not row or not portal:
raise RuntimeError("portal_not_installed")
payload = row.payload_json
fields = imconnector_send_fields(
app.state.settings.bitrix_connector_id,
app.state.settings.bitrix_open_line_id,
payload,
)
result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields)
chat_id, session_id, bitrix_message_id = extract_delivery(result)
response = {
@@ -1155,8 +1237,9 @@ async def worker_loop(app: FastAPI, kind: str) -> None:
await process_setup(app)
except Exception as exc:
logger.error(
"worker iteration failed",
extra={"worker_kind": kind, "error_type": type(exc).__name__},
"worker.iteration_failed",
worker_kind=kind,
error_type=type(exc).__name__,
)
try:
await asyncio.wait_for(app.state.stop.wait(), app.state.settings.bitrix_worker_poll_sec)
@@ -1169,45 +1252,57 @@ async def process_inbox(app: FastAPI) -> None:
row = await claim_one(session, InboxEvent, ["received", "retry"])
if not row:
return
try:
payload = json.loads(json.dumps(row.normalized_json))
await resolve_inbound_file_urls(app, payload)
response = await app.state.http.post(
app.state.settings.bitrix_api_forward_url,
json=payload,
headers={
"Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}",
"X-Request-ID": str(uuid.uuid4()),
},
follow_redirects=False,
)
duplicate = response.status_code in {200, 204}
if response.status_code != 201 and not duplicate:
response.raise_for_status()
async with app.state.sessions() as session:
current = await session.get(InboxEvent, row.id, with_for_update=True)
current.status = "ack_pending"
current.api_ack_status = "duplicate" if duplicate else "created"
current.lease_until = None
session.add(
DeliveryAckOutbox(
inbox_event_id=current.id,
payload_json={
"external_chat_id": str(current.external_chat_id),
"bitrix_message_id": current.bitrix_message_id,
},
)
tracer = trace.get_tracer("han.bitrix.inbox")
with (
tracer.start_as_current_span(
"bitrix.inbox.forward",
links=origin_links(row.traceparent),
),
structlog.contextvars.bound_contextvars(
request_id=row.request_id,
inbox_id=str(row.id),
**{"service.name": "bitrix-local-app"},
),
):
try:
payload = json.loads(json.dumps(row.normalized_json))
await resolve_inbound_file_urls(app, payload)
response = await app.state.http.post(
app.state.settings.bitrix_api_forward_url,
json=payload,
headers={
"Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}",
"X-Request-ID": row.request_id or str(uuid.uuid4()),
},
follow_redirects=False,
)
await session.commit()
except Exception as exc:
logger.error(
"inbound forward failed",
extra={
"inbox_id": str(row.id),
"error_type": type(exc).__name__,
},
)
await mark_retry(app, InboxEvent, row.id, "api_forward_failed")
duplicate = response.status_code in {200, 204}
if response.status_code != 201 and not duplicate:
response.raise_for_status()
async with app.state.sessions() as session:
current = await session.get(InboxEvent, row.id, with_for_update=True)
current.status = "ack_pending"
current.api_ack_status = "duplicate" if duplicate else "created"
current.lease_until = None
session.add(
DeliveryAckOutbox(
inbox_event_id=current.id,
request_id=current.request_id,
traceparent=current.traceparent,
payload_json={
"external_chat_id": str(current.external_chat_id),
"bitrix_message_id": current.bitrix_message_id,
},
)
)
await session.commit()
except Exception as exc:
logger.error(
"inbound.forward_failed",
inbox_id=str(row.id),
error_type=type(exc).__name__,
)
await mark_retry(app, InboxEvent, row.id, "api_forward_failed")
async def resolve_inbound_file_urls(app: FastAPI, payload: dict[str, Any]) -> None:
@@ -1239,27 +1334,41 @@ async def process_ack(app: FastAPI) -> None:
portal = await active_portal(session)
if not row or not portal:
return
try:
await app.state.bitrix.call(
portal,
"imconnector.send.status.delivery",
{
"CONNECTOR": app.state.settings.bitrix_connector_id,
"LINE": app.state.settings.bitrix_open_line_id,
"MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"],
"MESSAGES[0][message][id]": row.payload_json["bitrix_message_id"] or "",
},
)
async with app.state.sessions() as session:
current = await session.get(DeliveryAckOutbox, row.id, with_for_update=True)
event = await session.get(InboxEvent, current.inbox_event_id, with_for_update=True)
current.status = "completed"
current.lease_until = None
event.status = "completed"
event.delivery_ack_status = "sent"
await session.commit()
except Exception:
await mark_retry(app, DeliveryAckOutbox, row.id, "delivery_ack_failed")
tracer = trace.get_tracer("han.bitrix.ack")
with (
tracer.start_as_current_span(
"bitrix.delivery_ack.process",
links=origin_links(row.traceparent),
),
structlog.contextvars.bound_contextvars(
request_id=row.request_id,
ack_outbox_id=str(row.id),
**{"service.name": "bitrix-local-app"},
),
):
try:
await app.state.bitrix.call(
portal,
"imconnector.send.status.delivery",
{
"CONNECTOR": app.state.settings.bitrix_connector_id,
"LINE": app.state.settings.bitrix_open_line_id,
"MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"],
"MESSAGES[0][message][id]": row.payload_json["bitrix_message_id"] or "",
},
)
async with app.state.sessions() as session:
current = await session.get(DeliveryAckOutbox, row.id, with_for_update=True)
event = await session.get(
InboxEvent, current.inbox_event_id, with_for_update=True
)
current.status = "completed"
current.lease_until = None
event.status = "completed"
event.delivery_ack_status = "sent"
await session.commit()
except Exception:
await mark_retry(app, DeliveryAckOutbox, row.id, "delivery_ack_failed")
async def process_outbound(app: FastAPI) -> None:
@@ -1272,20 +1381,30 @@ async def process_outbound(app: FastAPI) -> None:
)
if not row:
return
try:
await asyncio.wait_for(
deliver_outbound(app, row.id),
timeout=app.state.settings.bitrix_http_timeout_sec,
)
except Exception as exc:
logger.error(
"outbound retry failed",
extra={
"outbound_id": str(row.id),
"error_type": type(exc).__name__,
},
)
await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed")
tracer = trace.get_tracer("han.bitrix.outbound")
with (
tracer.start_as_current_span(
"bitrix.outbound.process",
links=origin_links(row.traceparent),
),
structlog.contextvars.bound_contextvars(
request_id=row.request_id,
outbound_id=str(row.id),
**{"service.name": "bitrix-local-app"},
),
):
try:
await asyncio.wait_for(
deliver_outbound(app, row.id),
timeout=app.state.settings.bitrix_http_timeout_sec,
)
except Exception as exc:
logger.error(
"outbound.retry_failed",
outbound_id=str(row.id),
error_type=type(exc).__name__,
)
await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed")
async def process_setup(app: FastAPI) -> None:
@@ -133,6 +133,8 @@ class InboxEvent(Common, Base):
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
payload_fingerprint: Mapped[str] = mapped_column(String(64))
normalized_json: Mapped[dict] = mapped_column(JSON)
request_id: Mapped[str | None] = mapped_column(String(64))
traceparent: Mapped[str | None] = mapped_column(String(55))
status: Mapped[str] = mapped_column(String(32), default="received")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
@@ -149,6 +151,8 @@ class OutboundMessage(Common, Base):
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
request_fingerprint: Mapped[str] = mapped_column(String(64))
payload_json: Mapped[dict] = mapped_column(JSON)
request_id: Mapped[str | None] = mapped_column(String(64))
traceparent: Mapped[str | None] = mapped_column(String(55))
status: Mapped[str] = mapped_column(String(32), default="received")
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
response_json: Mapped[dict | None] = mapped_column(JSON)
@@ -165,6 +169,8 @@ class DeliveryAckOutbox(Common, Base):
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
)
payload_json: Mapped[dict] = mapped_column(JSON)
request_id: Mapped[str | None] = mapped_column(String(64))
traceparent: Mapped[str | None] = mapped_column(String(55))
status: Mapped[str] = mapped_column(String(32), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
@@ -0,0 +1,119 @@
from __future__ import annotations
import logging
import os
from collections.abc import MutableMapping
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import extract, inject, set_global_textmap
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
@dataclass(slots=True)
class TelemetryRuntime:
tracer_provider: TracerProvider
meter_provider: MeterProvider
logger_provider: LoggerProvider
logging_handler: LoggingHandler
def shutdown(self) -> None:
self.logger_provider.shutdown()
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
_runtime: TelemetryRuntime | None = None
def init_telemetry() -> TelemetryRuntime | None:
global _runtime
if _runtime is not None:
return _runtime
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
if not endpoint:
return None
resource = Resource.create(
{
"service.name": os.getenv("OTEL_SERVICE_NAME", "bitrix-local-app"),
"service.namespace": "han-chat",
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
"deployment.environment": os.getenv("APP_ENV", "production-like"),
}
)
insecure = endpoint.startswith("http://")
set_global_textmap(TraceContextTextMapPropagator())
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3))
)
trace.set_tracer_provider(tracer_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
export_interval_millis=30000,
export_timeout_millis=3000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
set_logger_provider(logger_provider)
logging_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
_runtime = TelemetryRuntime(
tracer_provider, meter_provider, logger_provider, logging_handler
)
return _runtime
def instrument_fastapi(app: FastAPI) -> None:
FastAPIInstrumentor.instrument_app(app, excluded_urls="/health/live,/health/ready")
def add_trace_context(
_logger: Any, _method_name: str, event_dict: MutableMapping[str, Any]
) -> MutableMapping[str, Any]:
context = trace.get_current_span().get_span_context()
if context.is_valid:
event_dict["trace_id"] = format(context.trace_id, "032x")
event_dict["span_id"] = format(context.span_id, "016x")
return event_dict
def current_traceparent() -> str | None:
carrier: dict[str, str] = {}
inject(carrier)
return carrier.get("traceparent")
def origin_links(traceparent: str | None) -> list[trace.Link]:
if not traceparent:
return []
context = extract({"traceparent": traceparent})
span_context = trace.get_current_span(context).get_span_context()
return [trace.Link(span_context)] if span_context.is_valid else []
@@ -91,6 +91,7 @@ components:
properties:
id: {type: string, format: uuid}
display_name: {type: string, maxLength: 255}
phone: {type: string, minLength: 1, maxLength: 32}
message:
type: object
additionalProperties: false
@@ -8,9 +8,16 @@ dependencies = [
"cryptography>=45,<46",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"opentelemetry-api>=1.44,<2",
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
"opentelemetry-sdk>=1.44,<2",
"pydantic-settings>=2.10,<3",
"python-multipart>=0.0.20,<1",
"sqlalchemy[asyncio]>=2.0.41,<3",
"structlog>=25,<26",
"uvicorn[standard]>=0.35,<1",
]
@@ -19,16 +19,29 @@ os.environ.setdefault(
import httpx
import pytest
from app.logging_security import REDACTED, sanitize_value
from app.main import (
BitrixClient,
TokenCipher,
canonical_fingerprint,
imconnector_send_fields,
normalize_event,
resolve_inbound_file_urls,
retry_delay,
safely_retryable,
validate_portal,
)
from app.telemetry import add_trace_context, origin_links
def test_bitrix_logs_redact_secrets_and_do_not_invent_trace_ids():
value = sanitize_value(
{"authorization": "Bearer secret", "url": "https://example.test/?token=secret"}
)
assert value["authorization"] == REDACTED
assert "secret" not in value["url"]
assert add_trace_context(None, "info", {"event": "safe"}) == {"event": "safe"}
assert origin_links("invalid") == []
def test_token_cipher_binds_aad():
@@ -278,3 +291,23 @@ async def test_bitrix_call_refreshes_and_retries_once_after_401():
assert result == {"ok": True}
assert auth_values == ["old-access", "new-access"]
assert refresh_calls == [False, True]
def test_imconnector_send_fields_maps_name_and_phone():
payload = {
"message_id": "mid",
"external_chat_id": "chat",
"occurred_at": "2026-07-10T09:00:00Z",
"user": {"id": "uid", "display_name": "Иван Иванов", "phone": "+79990000000"},
"message": {"text": "hello", "files": []},
}
fields = imconnector_send_fields("han_mobile_app", "8", payload)
assert fields["MESSAGES[0][user][name]"] == "Иван Иванов"
assert fields["MESSAGES[0][user][phone]"] == "+79990000000"
legacy = {
**payload,
"user": {"id": "uid", "display_name": "+79990000000"},
}
legacy_fields = imconnector_send_fields("han_mobile_app", "8", legacy)
assert "MESSAGES[0][user][phone]" not in legacy_fields
assert legacy_fields["MESSAGES[0][user][name]"] == "+79990000000"
@@ -0,0 +1,479 @@
# Runbook внедрения soft/force update мобильного приложения
## 1. Назначение и границы
Документ описывает выпуск нативных версий HAN Chat через Google Play, RuStore
и App Store и последующее включение `soft`/`force update` через публичную
backend-политику.
Механика не использует EAS OTA Update. Пользователь всегда направляется в
магазин, соответствующий каналу установленной сборки:
- `google_play`;
- `rustore`;
- `app_store`.
Канал встраивается в бинарный файл через
`EXPO_PUBLIC_DISTRIBUTION_STORE`. Поэтому Android-сборки Google Play и RuStore
нужно собирать отдельно.
Решение принимает мобильный клиент:
- `current_build < minimum_build` — обязательное обновление (`force`);
- `minimum_build <= current_build < latest_build` — мягкое обновление (`soft`);
- `current_build >= latest_build` — карточка не показывается.
`latest_version` используется только в интерфейсе. Сравнение выполняется по
целому Android `versionCode` или iOS `buildNumber`.
## 2. Ответственные и данные окна выпуска
Перед началом назначьте:
- ответственного за EAS Build;
- ответственного за Google Play Console;
- ответственного за RuStore Console;
- ответственного за App Store Connect, если выпускается iOS;
- оператора ВМ1, применяющего backend settings;
- владельца решения о переводе soft update в force update.
Создайте запись окна выпуска:
```text
Маркетинговая версия:
Git commit/tag мобильного приложения:
Git commit/tag backend:
Google Play EAS build ID:
Google Play versionCode:
RuStore EAS build ID:
RuStore versionCode:
App Store EAS build ID:
App Store buildNumber:
Дата полной доступности каждого релиза:
Минимальная поддерживаемая сборка каждого канала:
```
Не вычисляйте пороги по порядку запуска команд. Записывайте фактические значения
из завершённой EAS-сборки и подтверждайте их в консоли соответствующего магазина.
## 3. Важная особенность EAS remote version
В проекте используется:
```json
{
"cli": {
"appVersionSource": "remote"
}
}
```
Android remote `versionCode` привязан к application ID `ru.han.chat`. Профили
`google-play` и `rustore` используют один application ID и общий счётчик.
Если текущее remote-значение равно `6`, последовательная сборка обычно даст:
1. первая Android-сборка — `versionCode=7`;
2. вторая Android-сборка — `versionCode=8`.
Это допустимо. Политики Google Play и RuStore независимы, поэтому в backend
следует указать `latest_build=7` для одного канала и `latest_build=8` для
другого, если именно такие артефакты опубликованы.
Локальный `android.versionCode` в `app.config.ts` не является источником истины
при remote version source. Источник истины для rollout — опубликованный
артефакт магазина.
## 4. Предварительные проверки
### 4.1. Мобильное приложение
На локальной машине:
```powershell
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile
npm run typecheck
npm test
npx eas-cli whoami
```
Проверьте:
- `version` в `app.config.ts` соответствует выпускаемой маркетинговой версии;
- профиль `google-play` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=google_play`;
- профиль `rustore` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=rustore`;
- профиль `app-store` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=app_store`;
- `preview` и `development` не включают store policy;
- production API указывает на `https://chat.han0107.ru`.
### 4.2. Backend
На локальной машине:
```powershell
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM1_app\codebase\backend\api-backend
python -m pytest
cd ..
python -m pytest tests
```
Проверьте, что backend-релиз содержит:
- строгий объект `mobile_update` в `/api/v1/public/app-config`;
- поддержку `ETag` и `If-None-Match`;
- TTL app-config 60 секунд;
- валидацию build numbers и store URL;
- актуальный `openapi.yaml`;
- nginx `proxy_cache_valid 200 60s` для app-config.
## 5. Безопасное внедрение backend до выпуска приложения
Сначала разверните backend-код и nginx, затем мобильные сборки. Старые клиенты
игнорируют новую секцию `mobile_update`.
До публикации новой версии политика должна быть безопасной:
- либо канал отключён;
- либо `latest_build` не превышает уже опубликованный build;
- `minimum_build` не должен внезапно исключать поддерживаемые версии.
Начальные `latest_build=2` при фактической установленной сборке `6` не показывают
карточку и поэтому безопасны как временное no-op состояние.
Развёртывание backend выполняйте по основному production runbook:
`deployment/RUNBOOK.production.ru.md`.
После обновления образов и конфигурации оператор ВМ1 выполняет штатный job:
```sh
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
```
Команда идемпотентна и включает `validate_settings`. Невалидная комбинация
порогов или URL должна завершить job ошибкой.
Проверка публичного контракта с внешней машины:
```sh
curl -fsS https://chat.han0107.ru/api/v1/public/app-config
ETAG="$(curl -fsSI https://chat.han0107.ru/api/v1/public/app-config \
| awk -F': ' 'tolower($1)=="etag" {gsub("\r","",$2); print $2}')"
curl -sS -o /dev/null -w '%{http_code}\n' \
-H "If-None-Match: $ETAG" \
https://chat.han0107.ru/api/v1/public/app-config
```
Ожидается:
- первый запрос — `200`;
- в ответе присутствуют три store policy;
- повторный запрос с актуальным ETag — `304`;
- `Cache-Control` содержит `max-age=60`.
## 6. Получение текущего remote build number
На локальной машине:
```powershell
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile
npx eas-cli build:version:get --platform android --profile google-play
npx eas-cli build:version:get --platform android --profile rustore
npx eas-cli build:version:get --platform ios --profile app-store
```
Для автоматической обработки:
```powershell
npx eas-cli build:version:get --platform android --profile google-play --json
```
Одинаковое значение для двух Android-профилей до сборки ожидаемо: они используют
общий application ID. Каждая последующая Android-сборка с `autoIncrement`
увеличивает общий счётчик.
### 6.1. Тестовый APK с каналом RuStore
Профиль `preview-rustore` создаёт внутренний APK, обращается к
`https://dev-chat.han0107.ru` и встраивает канал `rustore`:
```powershell
npx eas-cli build:version:get --platform android --profile preview-rustore
npx eas-cli build --profile preview-rustore --platform android
```
У профиля задано `autoIncrement: false`, поэтому тестовая сборка не расходует
следующий production `versionCode`. Пусть фактический build APK равен `B`. Для
проверки на dev-backend задайте:
```text
soft: latest_build = B + 1, minimum_build <= B
force: latest_build = B + 1, minimum_build = B + 1
none: latest_build <= B
```
Меняйте только RuStore policy dev-окружения и применяйте её через штатный
`seed-settings` этого окружения. Не используйте тестовые пороги на production.
После отказа от soft update запись сохраняется в SecureStore без TTL. Для
повторной проверки той же политики очистите данные приложения либо увеличьте
`latest_build`.
APK использует package `ru.han.chat` и может заменить установленную
store-сборку. Для теста предпочтительно отдельное устройство.
## 7. Создание store-сборок
Рекомендуется собирать и публиковать магазины по одному, сразу записывая
фактический build number:
```powershell
npx eas-cli build --profile google-play --platform android
npx eas-cli build --profile rustore --platform android
npx eas-cli build --profile app-store --platform ios
```
App Store-команду не выполняйте, пока не настроены Apple credentials, Apple App
ID и рабочий `store_url`.
После каждой сборки:
```powershell
npx eas-cli build:list --platform android --limit 5
npx eas-cli build:view <BUILD_ID>
```
Зафиксируйте:
- EAS build ID;
- commit;
- channel/profile;
- `version`;
- фактический `versionCode`/`buildNumber`;
- checksum скачанного артефакта, если он используется в процедуре публикации.
Не запускайте вторую Android-сборку, пока не записан номер первой.
## 8. Публикация и проверка магазинов
### 8.1. Google Play
1. Загрузите AAB в требуемый track.
2. Убедитесь, что Console показывает ожидаемый `versionCode`.
3. Проведите internal/closed testing.
4. Проверьте установку и переход по ссылке:
`https://play.google.com/store/apps/details?id=ru.han.chat`.
5. Зафиксируйте процент rollout и время полной доступности.
### 8.2. RuStore
1. Загрузите предназначенный для RuStore артефакт.
2. Убедитесь, что Console показывает фактический `versionCode`.
3. Проведите тестирование канала.
4. Проверьте страницу:
`https://www.rustore.ru/catalog/app/ru.han.chat`.
5. Зафиксируйте статус модерации и время доступности.
### 8.3. App Store
До включения политики:
1. получите Apple App ID;
2. опубликуйте и проверьте сборку в App Store Connect/TestFlight;
3. укажите канонический URL `https://apps.apple.com/.../id<APPLE_ID>`;
4. подтвердите фактический `buildNumber`;
5. только после этого установите `enabled: true`.
## 9. Включение soft update
Изменяйте
`deployment/app-settings.production-like.yaml` отдельно для каждого магазина.
Пример, если Google Play опубликовал build `7`, а RuStore — build `8`:
```yaml
mobile_update.google_play.enabled: {type: boolean, value: true, public: true}
mobile_update.google_play.latest_build: {type: integer, value: 7, public: true}
mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true}
mobile_update.google_play.latest_version: {type: string, value: "1.0.1", public: true}
mobile_update.rustore.enabled: {type: boolean, value: true, public: true}
mobile_update.rustore.latest_build: {type: integer, value: 8, public: true}
mobile_update.rustore.minimum_build: {type: integer, value: 1, public: true}
mobile_update.rustore.latest_version: {type: string, value: "1.0.1", public: true}
```
Выбор `minimum_build` требует отдельного решения:
- оставить `1` — все более старые builds получают soft update;
- установить `6` — builds `15` немедленно получают force update, а build `6`
получает soft update;
- установить новый build (`7` или `8`) — все предыдущие builds получают force.
Для первого rollout рекомендуется сохранить прежний минимальный поддерживаемый
build и включить только soft update.
После review и merge настроек оператор ВМ1 выполняет:
```sh
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
```
Подождите до 60 секунд и повторно запросите app-config. Если внешний nginx уже
имел закешированный ответ, допускайте до двух минут на проверку с разных
клиентов, но не продолжайте rollout при значении старше ожидаемого.
## 10. Приёмка soft update
Используйте реальное устройство со старой store-сборкой каждого канала.
Проверьте:
1. При cold start появляется «Доступно обновление».
2. Указаны правильные текущая и новая версии.
3. Кнопка открывает правильный магазин, а не другой Android-магазин.
4. «Позже», крестик и Android Back закрывают карточку.
5. После отказа карточка той же `latest_build` не появляется при следующем
запуске: отказ хранится в SecureStore без TTL.
6. После увеличения `latest_build` появляется новая карточка.
7. После установки нового build карточка исчезает.
8. При недоступном backend приложение не блокируется.
Если продукту требуется повторное напоминание через интервал, текущую механику
следует изменить отдельно: сейчас soft-dismiss действует до появления нового
`latest_build`, очистки данных или переустановки.
## 11. Перевод в force update
Force разрешено включать только когда обязательный build:
- прошёл модерацию;
- доступен в нужном production track;
- доступен всем пользователям, которых затронет `minimum_build`;
- устанавливается и запускается;
- корректно открывается по `store_url`;
- backend и store не находятся в инциденте.
Для Google Play build `7`:
```yaml
mobile_update.google_play.latest_build: {type: integer, value: 7, public: true}
mobile_update.google_play.minimum_build: {type: integer, value: 7, public: true}
```
Для RuStore build `8`:
```yaml
mobile_update.rustore.latest_build: {type: integer, value: 8, public: true}
mobile_update.rustore.minimum_build: {type: integer, value: 8, public: true}
```
Не повышайте minimum одного магазина только потому, что релиз доступен в другом.
После изменения снова примените `seed-settings`, проверьте app-config и
протестируйте старую сборку:
- force-карточка не имеет крестика и кнопки «Позже»;
- Android Back не закрывает её;
- кнопка открывает правильный магазин;
- после возврата без установки карточка остаётся;
- после установки поддерживаемого build блокировка исчезает.
## 12. App Store policy
Пока Apple App ID неизвестен, политика должна оставаться полностью выключенной:
```yaml
mobile_update.app_store.enabled: {type: boolean, value: false, public: true}
mobile_update.app_store.latest_build: {type: integer, value: null, public: true}
mobile_update.app_store.minimum_build: {type: integer, value: null, public: true}
mobile_update.app_store.latest_version: {type: string, value: "", public: true}
mobile_update.app_store.store_url: {type: string, value: "", public: true}
mobile_update.app_store.release_notes: {type: string, value: "", public: true}
```
Отключённая политика не должна содержать частично заполненные release-поля:
backend отклонит такую конфигурацию.
## 13. Откат
### 13.1. Немедленно снять force
Понизьте `minimum_build` до последнего подтверждённого поддерживаемого значения,
не меняя `latest_build`, затем примените seed.
Пример:
```yaml
mobile_update.google_play.latest_build: {type: integer, value: 7, public: true}
mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true}
```
После успешного получения новой политики клиент снимет force-блокировку.
Кратковременная сетевая ошибка сохраняет уже показанный force до следующей
успешной проверки, поэтому дополнительно подтвердите доступность app-config.
### 13.2. Полностью отключить канал
Установите `enabled=false`, integer-поля в `null`, строковые release-поля в
пустую строку:
```yaml
mobile_update.google_play.enabled: {type: boolean, value: false, public: true}
mobile_update.google_play.latest_build: {type: integer, value: null, public: true}
mobile_update.google_play.minimum_build: {type: integer, value: null, public: true}
mobile_update.google_play.latest_version: {type: string, value: "", public: true}
mobile_update.google_play.store_url: {type: string, value: "", public: true}
mobile_update.google_play.release_notes: {type: string, value: "", public: true}
```
Не откатывайте уже использованный store build number и не публикуйте другой
артефакт с тем же `versionCode`/`buildNumber`.
### 13.3. Дефект новой версии
Если новая версия дефектна:
1. не направляйте на неё новых пользователей — отключите policy или верните
`latest_build` к безопасному опубликованному build;
2. остановите rollout в соответствующем магазине;
3. выпустите исправленную сборку с новым build number;
4. после публикации укажите новый `latest_build`;
5. только после приёмки принимайте решение о новом `minimum_build`.
## 14. Наблюдение после включения
В течение окна наблюдения контролируйте:
- `5xx` и latency `/api/v1/public/app-config`;
- долю `200/304`;
- ошибки rate limit публичного endpoint;
- доступность страниц магазинов;
- crash/error rate новой мобильной версии;
- обращения о циклической force-карточке;
- соответствие фактического store build политике каждого канала.
Stop conditions:
- URL ведёт не в тот магазин или не на HAN Chat;
- опубликованный build ниже `latest_build`;
- часть rollout-групп не может скачать minimum build;
- app-config отдаёт старую или частичную политику дольше двух минут;
- новая версия не запускается или не проходит авторизацию;
- force нельзя снять успешным изменением backend policy.
## 15. Контрольный чек-лист
- [ ] Backend с `mobile_update` развёрнут до мобильного rollout.
- [ ] ETag/304 и TTL 60 секунд проверены извне.
- [ ] Фактические build numbers записаны после каждой EAS-сборки.
- [ ] Build numbers подтверждены в консолях магазинов.
- [ ] Google Play и RuStore thresholds заполнены независимо.
- [ ] Soft update проверен на старой сборке каждого канала.
- [ ] Soft-dismiss и повторный показ для нового latest build проверены.
- [ ] Force включается только после полной доступности minimum build.
- [ ] App Store остаётся disabled до получения Apple App ID.
- [ ] Процедура отката проверена до включения force.
- [ ] Итоговые значения политики и время применения записаны в журнал выпуска.
@@ -339,6 +339,21 @@ downgrade запрещены.
## 9. Упорядоченный первый запуск
До preflight установите host Collector из pinned release artifact. Версию и
SHA-256 возьмите из утверждённого release record, не из ответа GitHub API:
```sh
export OTEL_HOST_COLLECTOR_VERSION='0.117.0'
export OTEL_HOST_COLLECTOR_SHA256='90710c909a30fc3b89dd0e389c13f9e57ebbb87d6a0dc51fdde0bf03499a5f25'
deployment/scripts/setup-vm.sh
/usr/local/bin/otelcol-contrib validate \
--config=deployment/observability/otel-host-collector.yaml
```
`setup-vm.sh` устанавливает и enable-ит unit, но не запускает host Collector.
Он работает без Docker socket; offsets и exporter queue находятся в
`/var/lib/han-otel/host-collector`.
Первый запуск выполняет root через фиксированный launcher:
```sh
@@ -363,6 +378,8 @@ 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
systemctl start han-host-otel-collector@production.service
systemctl --no-pager status han-host-otel-collector@production.service
```
`han-stack@production` становится единственным routine lifecycle interface.
@@ -376,6 +393,9 @@ stack unit. Обновление active/exited oneshot всегда требуе
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
ETAG="$(curl -fsSI https://<PUBLIC_HOST>/api/v1/public/app-config | awk -F': ' 'tolower($1)=="etag" {gsub("\r","",$2); print $2}')"
curl -sS -o /dev/null -w '%{http_code}\n' \
-H "If-None-Match: $ETAG" 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
@@ -383,7 +403,12 @@ 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.
Ожидается `308`, public endpoints `200`, повторный app-config `304`, internal
route `404`, valid chain. В app-config проверьте `mobile_update`: Google Play и
RuStore включены (`latest_build=2`, `minimum_build=1`, `latest_version=1.0.1`,
RuStore URL `https://www.rustore.ru/catalog/app/ru.han.chat`), App Store
отключён, его release-поля равны `null`; пустой `release_notes` у всех политик
также возвращается как `null`.
Проверьте 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` не принимается.
@@ -427,11 +452,25 @@ staging, выполнять config test и HUP.
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
token/PII markers и докажите их отсутствие в logs/traces.
В SigNoz один canary request должен дать ровно по одному application log от
nginx/API/Bitrix, а `trace_id`/`span_id` application logs должны открывать
соответствующий span. Platform logs ищутся по `host.name`, `service.name` и
временному окну. Проверьте отсутствие `unknown-container`, дублей Python logs,
логов обоих Collector и экспоненциального роста ingest. После restart host
Collector старые записи не должны replay-иться: offsets сохраняются.
KESL 12.4 устанавливается и принимается только по отдельному операторскому
runbook `deployment/kesl/RUNBOOK.KESL.ru.md`. Не совмещайте установку,
полную/контейнерную антивирусную проверку или изменение File Threat Protection
с deploy, миграциями, PG backup, TLS renewal и перезапуском Docker/стека.
Изменение политики KESL не является частью обычного application release.
Перед 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
han-secrets@production.service han-stack@production.service \
han-host-otel-collector@production.service certbot.timer
systemctl reboot
```
@@ -452,10 +491,13 @@ systemctl daemon-reload
systemctl restart han-secrets@production.service
/opt/han-chat/current/backend/deployment/preflight.sh
systemctl restart han-stack@production.service
systemctl restart han-host-otel-collector@production.service
```
Повторите smoke и зафиксируйте digests. Не удаляйте current/previous release,
active images, evidence или volumes. После incompatible migration используйте
active images, evidence, volumes или `/var/lib/han-otel`: offsets/queue должны
пережить rollback. Если previous release не содержит совместимого host config,
остановите host unit до возврата совместимой версии. После incompatible migration используйте
forward fix либо согласованный PG PITR + S3/Bitrix reconciliation в maintenance
window; Redis восстанавливается пустым и прогревается из PG.
@@ -47,5 +47,23 @@ settings:
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
mobile_update.google_play.enabled: {type: boolean, value: true, public: true}
mobile_update.google_play.latest_build: {type: integer, value: 2, public: true}
mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true}
mobile_update.google_play.latest_version: {type: string, value: "1.0.1", public: true}
mobile_update.google_play.store_url: {type: string, value: "https://play.google.com/store/apps/details?id=ru.han.chat", public: true}
mobile_update.google_play.release_notes: {type: string, value: "", public: true}
mobile_update.rustore.enabled: {type: boolean, value: true, public: true}
mobile_update.rustore.latest_build: {type: integer, value: 2, public: true}
mobile_update.rustore.minimum_build: {type: integer, value: 1, public: true}
mobile_update.rustore.latest_version: {type: string, value: "1.0.1", public: true}
mobile_update.rustore.store_url: {type: string, value: "https://www.rustore.ru/catalog/app/ru.han.chat", public: true}
mobile_update.rustore.release_notes: {type: string, value: "", public: true}
mobile_update.app_store.enabled: {type: boolean, value: false, public: true}
mobile_update.app_store.latest_build: {type: integer, value: null, public: true}
mobile_update.app_store.minimum_build: {type: integer, value: null, public: true}
mobile_update.app_store.latest_version: {type: string, value: "", public: true}
mobile_update.app_store.store_url: {type: string, value: "", public: true}
mobile_update.app_store.release_notes: {type: string, value: "", public: true}
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
security.public_cache.max_age_seconds: {type: integer, value: 60, public: false}
@@ -0,0 +1,45 @@
[Unit]
Description=HAN Chat VM1 host telemetry collector (%i)
Requires=han-secrets@%i.service
After=docker.service han-secrets@%i.service network-online.target
Wants=docker.service network-online.target
ConditionPathIsExecutable=/usr/local/bin/otelcol-contrib
ConditionPathIsDirectory=/opt/han-chat/current/backend
[Service]
Type=simple
User=root
Group=root
EnvironmentFile=/etc/han/vm1.env
ExecStartPre=/usr/local/bin/otelcol-contrib validate --config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml
ExecStart=/usr/local/bin/otelcol-contrib --config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml
Restart=on-failure
RestartSec=10s
TimeoutStopSec=30
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadOnlyPaths=/opt/han-chat/current/backend/deployment/observability
ReadOnlyPaths=/var/lib/docker/containers
ReadOnlyPaths=/var/log/journal
ReadOnlyPaths=/run/log/journal
ReadOnlyPaths=/run/han-chat/secrets
ReadWritePaths=/var/lib/han-otel/host-collector
LimitCORE=0
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,235 @@
# Карта доказательств АВЗ.1 и АВЗ.2 для ВМ1
Форма заполняется оператором после выполнения `RUNBOOK.KESL.ru.md`. Она не
должна содержать activation code, ключи, токены, DSN, environment, содержимое
secret-файлов, персональные данные или тестовый файл EICAR.
Ниже зафиксирован **тестовый пилот** на `devhanapp` (4 ГБ RAM). Это не
автоматическая приёмка боевой среды.
## 1. Идентификация изменения
- Change ID: тестовый пилот KESL 12.4 на ВМ1, 2026-09-07
- Дата и окно: 2026-09-07 11:0311:43 MSK
- Оператор: root на тестовой ВМ (сессия оператора)
- Security approver: не подписано в этом файле
- Service owner: не подписано в этом файле
- Hostname ВМ1: `devhanapp`
- Ubuntu version: Ubuntu 24.04.4 LTS
- Kernel version: `6.8.0-138-generic` x86_64
- KESL package/version: `kesl` `12.4.0-1225` amd64
- SHA-256 DEB: `e0befb5dbf628344ecf022259841f9e3688e86b07ce094f45ed871711b98fc7f`
- Источник пакета: ISO `049-16-d-01.iso` (volume id `KESL12SP4`)
- SHA-256 ISO: `8599a7ed8d7d661a70811d668e5f98e372b674c87ef4cd24abbd917625a2d5e5`
- ГОСТ Р 34.11-94 ISO (`rhash --gost`): совпал с суммой вендора
`6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3`
- HAN release SHA: не снимался в этом окне
- Container image digests зафиксированы: да / частично (compose ps)
Коммерческая KESL 12.4 не должна быть обозначена как сертифицированная ФСТЭК
сборка. Решение о допустимости коммерческой версии и ссылка на модель угроз:
- Решение: коммерческая 12.4 выбрана из‑за Ubuntu 24.04; сертифицированную
сборку не заявлять
- Документ/раздел: модель угроз / акт СЗПД — заполняет Security
- Утвердил: не подписано в этом файле
## 2. Входной baseline
- Все steady-state контейнеры healthy/running: да (13 running / 15 total,
2 oneshot)
- Restart count: без новых restart в окне пилота
- Public smoke: в этом окне не повторялся
- Negative port probes: в этом окне не повторялись
- CPU: контейнеры < 4% кроме кратких всплесков
- Available RAM: до KESL ~1.9 ГБ; после пилота ~2.3 ГБ (часть ушла в swap)
- Swap activity: до установки ~524 КиБ; после ~675 МБ
- Disk free: после growpart 19 ГБ из 30 ГБ
- IO wait: не снимался отдельно
- API p95: не снимался
- Redis latency / blocked clients: не снимались
- OTEL queue: не снималась
- Открытые до установки проблемы: публичный SSH 22; RAM 4 ГБ ниже
production-gate
Stop conditions и численные пороги утверждены:
- p95/Redis: для теста не применялись
- available RAM/swap: stop при available < 512 МБ; не достигнуто
- IO wait: не применялся
- disk: свободно > 10 ГБ
- health/restarts: новых unhealthy не было
## 3. АВЗ.1 — реализация антивирусной защиты
Нормативная опора:
- Приказ ФСТЭК России № 21, приложение, АВЗ.1 — «Реализация
антивирусной защиты»;
- пункт 8.6 — обнаружение вредоносных программ/информации и реагирование.
Необходимые доказательства:
- [x] `kesl` active.
- [x] Лицензия действительна (`The key is valid`, subscription active).
- [x] File Threat Protection (ID 1) имеет состояние `Started`.
- [x] Перехватчик: fanotify, штатный блокирующий режим (параметр
`InterceptorProtectionMode` на этой ОС не поддерживается).
- [x] ActionOnThreat = `DisinfectDeleteIfNotPossible` либо иное утверждённое
блокирующее/лечащее действие.
- [x] ScanArchived = `No` для real-time защиты.
- [x] Исключения ограничены тремя утверждёнными hot-data mountpoint.
- [x] Контролируемый EICAR заблокирован/обезврежен/помещён в карантин.
- [x] Событие EICAR зарегистрировано в журнале KESL.
- [x] После теста EICAR отсутствует вне карантина и тестовый каталог удалён.
- [ ] Public smoke и health после включения Block успешны.
(health контейнеров подтверждён; внешний smoke не запускался)
- [x] UFW и `HAN-CHAT-DOCKER` не изменены.
- [ ] За 24 часа нет новых restart/OOM/5xx и неприемлемой деградации.
Артефакты без секретов:
- `systemctl is-active kesl`: `active`
- `kesl-control --app-info`: 12.4.0.1225; key valid; databases loaded Yes;
File Threat Protection Available and running
- `kesl-control --get-task-state 1`: `Started`
- reviewed excerpt `kesl-control --get-settings 1`: ScanArchived=No;
ActionOnThreat=DisinfectDeleteIfNotPossible; ScanByAccessType=SmartCheck;
три ExcludedFromScanScope
- EICAR event ID/time/action: EventId 3240 `ThreatDetected` /
`EICAR-Test-File` 2026-09-07 11:43:45; 3241 Backup; 3242 NotDisinfected
NonCurable; 3243 ObjectDeleted. Сработало **Scan_File (ODS)**, не OAS
on-access: файл успели записать на диск до scan-file.
- smoke result/time: не выполнялся
- firewall comparison: `HAN-CHAT-DOCKER` без новых правил KESL
- 24h resource comparison: не выдержан
Вывод по АВЗ.1: **реализована на тестовой ВМ** (обнаружение + backup +
удаление). Ограничение: on-access не перехватил создание EICAR; реакция
доказана on-demand. Для боя повторить on-access (запись + чтение файла)
на ресурсах ≥16 ГБ.
## 4. АВЗ.2 — обновление баз признаков вредоносных программ
Нормативная опора:
- Приказ ФСТЭК России № 21, приложение, АВЗ.2 — «Обновление базы данных
признаков вредоносных компьютерных программ (вирусов)».
Необходимые доказательства:
- [x] Update (ID 6) завершилась успешно.
- [x] Базы загружены.
- [x] Дата выпуска баз актуальна на момент проверки.
- [x] Расписание Update = `Hourly` (`StartTime=2026/Sep/07 11:32:38;1`).
- [ ] Утверждён alert/регламент на ошибку и устаревание баз.
- [ ] Назначен ответственный за ежедневный контроль.
- [ ] Проверено успешное автоматическое обновление после ручного запуска.
(ручной Update успешен; первый hourly цикл ещё не наблюдался)
Артефакты без секретов:
- `kesl-control --app-info`: databases loaded Yes; last release
2026-09-07 11:25:00
- `kesl-control --get-task-state 6`: Stopped после успешного ручного запуска
- `kesl-control --get-schedule 6`: RuleType=Hourly; interval 1 hour
- время последнего успешного автоматического Update: не наблюдалось
- ссылка на alert/регламент: не создан
- ответственный: не назначен
Вывод по АВЗ.2: **реализована на тестовой ВМ** (ручное обновление +
почасовое расписание). Для боя нужны наблюдаемый hourly цикл и alert на сбой.
## 5. Связанные меры
### РСБ.13, РСБ.7
- [x] Определены события: detection, remediation/quarantine, component stop,
update failure, stale bases, license failure.
(фиксируются в журнале KESL; пример ThreatDetected/ObjectDeleted)
- [x] Определён состав полей: time, host, component/task, threat, object,
action, result, severity.
- [ ] Определены срок и место хранения.
(по умолчанию `/var/opt/kaspersky/kesl/private/storage/events.db`)
- [x] Доступ к журналу ограничен; изменение/удаление контролируется.
(root-only `kesl-control -E`)
- [ ] Экспорт в syslog/SIEM включён либо документирован локальный контроль.
Ссылка на регламент и настройки: локальный журнал KESL; SIEM не подключался.
### АНЗ.2
- [x] Контролируется версия и жизненный цикл самого KESL, а не только баз.
(12.4.0.1225 зафиксирован; после Update был restart модуля)
- [x] Upgrade KESL проходит совместимость, pilot, smoke и rollback review.
(процедура в `RUNBOOK.KESL.ru.md`)
- [x] Обновление kernel/Docker вызывает повторную проверку совместимости.
Ссылка на регламент: `deployment/kesl/RUNBOOK.KESL.ru.md`
## 6. Исключения и компенсирующие проверки
Для каждого исключения укажите точный фактический mountpoint, владельца,
причину, риск, компенсирующую проверку и дату пересмотра.
### Redis data
- Mountpoint: `/var/lib/docker/volumes/han-chat_redis-data/_data`
- Причина: AOF/RDB, latency-sensitive write path.
- Компенсация: host File Threat Protection вне volume; on-demand scan-file
release; container monitoring недоступен по лицензии
- Владелец: Operations ВМ1
- Review date: при переносе на боевые ресурсы / не позднее 1 месяца
### OTEL queue
- Mountpoint: `/var/lib/docker/volumes/han-chat_otel-queue/_data`
- Причина: persistent high-churn telemetry queue.
- Компенсация: как выше
- Владелец: Operations ВМ1
- Review date: при переносе на боевые ресурсы / не позднее 1 месяца
### nginx cache
- Mountpoint: `/var/lib/docker/volumes/han-chat_nginx-cache/_data`
- Причина: regenerable high-churn cache.
- Компенсация: как выше
- Владелец: Operations ВМ1
- Review date: при переносе на боевые ресурсы / не позднее 1 месяца
Иных исключений нет / перечислить отдельно с утверждением Security:
иных исключений нет. `/var/lib/docker` целиком не исключался.
Лицензионное ограничение: Container Monitoring / Container Scan недоступны.
## 7. Проверка отката
- [x] Команда `kesl-control --stop-task 1` проверена документально.
- [x] Процедура остановки KESL доступна break-glass admin.
- [x] Процедура `apt-get purge kesl` проверена по документации текущей версии.
- [x] Откат не использует `docker compose down -v` и не удаляет volumes.
- [x] После отката предусмотрены smoke, health и firewall checks.
- [x] Reboot выполняется только отдельным согласованным окном при необходимости.
Результат rehearsal/desk check: полный uninstall в этом окне не выполнялся;
рабочий откат FTP — `--stop-task 1`.
## 8. Итоговая приёмка
- АВЗ.1: **принято для тестовой ВМ**, с оговоркой on-access.
- АВЗ.2: **принято для тестовой ВМ**, с оговоркой ненаблюдавшегося hourly
цикла и отсутствия alert.
- Ограничения/остаточные риски:
- 4 ГБ RAM, swap ~675 МБ; профиль `ScanMemoryLimit=512` /
`MaxMemory=1024MB` **не переносить** в production;
- коммерческая, не сертифицированная ФСТЭК сборка;
- KSN выключен;
- сетевые компоненты KESL остановлены сознательно;
- container monitoring недоступен по лицензии;
- OAS не удалил EICAR в момент записи; реакция через ODS;
- внешний smoke и 24h наблюдение не закрыты;
- публичный SSH 22 остаётся открытым (не KESL).
- Следующий review: перед боевым внедрением после увеличения RAM ≥16 ГБ.
- Operations, ФИО/подпись/дата:
- Security, ФИО/подпись/дата:
- Service owner, ФИО/подпись/дата:
@@ -0,0 +1,657 @@
# KESL 12.4 standalone на production ВМ1
Это операторский runbook для поэтапного внедрения Kaspersky Endpoint Security
для Linux 12.4 на Ubuntu 24.04 ВМ1. Команды выполняются только персональной
ролью `admin` через `sudo`; repository automation этот runbook не запускает.
Цель: реализовать АВЗ.1 (обнаружение и реагирование) и АВЗ.2 (обновление баз)
без деградации Docker-стека HAN Chat.
Не выполняйте установку одновременно с deploy, миграциями, backup, ротацией
секретов, TLS renewal, перезапуском Docker или host reboot.
Официальная документация:
- [программные требования](https://support.kaspersky.ru/kes-for-linux/12.4.0/197645);
- [краткое руководство по установке](https://support.kaspersky.ru/kes-for-linux/12.4.0/install/16099);
- [автоматическая первоначальная настройка](https://support.kaspersky.ru/kes-for-linux/12.4.0/197909);
- [параметры autoinstall.ini](https://support.kaspersky.ru/kes-for-linux/12.4.0/197593);
- [ограничение CPU и памяти](https://support.kaspersky.ru/kes-for-linux/12.4.0/264979);
- [настройка File Threat Protection](https://support.kaspersky.ru/kes-for-linux/12.4.0/248490);
- [проверка контейнеров](https://support.kaspersky.ru/kes-for-linux/12.4.0/197612);
- [удаление DEB-пакета](https://support.kaspersky.ru/kes-for-linux/12.4.0/197596).
## 0. Участники, входные данные и stop conditions
До окна работ зафиксируйте:
- change ID, время окна, оператора, approver Security и on-call;
- hostname/IP ВМ1, фактическую версию Ubuntu и ядра;
- точное имя, версию и SHA-256 полученного от Kaspersky DEB-пакета;
- источник пакета и лицензию/код активации;
- текущий release SHA и image digests;
- место хранения evidence вне immutable release.
Значения лицензии, activation code, proxy credentials и секреты HAN нельзя
помещать в репозиторий, shell history, журналы или evidence.
Немедленно остановитесь, если:
- ОС, архитектура или ядро отсутствуют в матрице KESL 12.4;
- уже установлен другой антивирус или неизвестная версия KESL;
- свободно менее 4 ГБ или нет рабочего swap;
- до установки есть unhealthy/restarting контейнеры, 5xx или дефицит ресурсов;
- не совпал SHA-256 пакета;
- после этапа выросли restart count, 5xx, Redis latency/blocked clients,
OTEL queue или host IO wait сверх согласованного порога;
- File Threat Protection изменил UFW/iptables или доступность портов;
- базы не загрузились либо лицензия недействительна.
Рекомендуемые пороги отката для пилота (утвердить до установки):
- новый unhealthy/restart любого steady-state сервиса;
- публичный smoke не проходит два запуска подряд;
- host available memory менее 2 ГБ или начинается устойчивый swap-in/swap-out;
- IO wait более 10% в течение 5 минут;
- p95 API/Redis latency выросла более чем на 20% от baseline в течение 10 минут;
- свободное место уменьшилось ниже 10 ГБ или ниже 15%.
### 0.1. Исключение только для constrained test VM
На тестовой ВМ допускается пилот с 4 ГБ RAM без увеличения памяти только по
явному решению владельца среды. Это не отменяет production-gate и не является
обоснованием для переноса той же конфигурации в боевую среду.
Обязательные ограничения такого пилота:
- provider snapshot/console и оператор доступны до начала;
- `ScanMemoryLimit=512`, `MaxMemory=1024MB`;
- `UseOnDemandCPULimit=Yes`, `OnDemandCPULimit=15`;
- не запускать full filesystem scan и проверку архивов;
- ODS и ContainerScan выполнять по одному объекту, не одновременно;
- сначала Update и health, затем один ограниченный `scan-file`, затем File Threat
Protection; `InterceptorProtectionMode` на fanotify недоступен;
- остановить KESL при available memory менее 512 МБ, устойчивом swap IO,
появлении host/container OOM, restart или провале smoke;
- до режима `Block` требуется отдельное подтверждение стабильности.
Перед production-внедрением повторить sizing и baseline на боевых ресурсах;
test-профиль 512/1024 МБ автоматически не переносить.
## 1. Read-only preflight и baseline
Команды разделены на небольшие блоки: сохраните вывод каждого блока в
change record, предварительно проверив отсутствие секретов. Не публикуйте
полный `docker inspect`, Compose config или environment.
### 1.1. Host и совместимость
```sh
date -Is
hostnamectl
uname -a
dpkg --print-architecture
findmnt -no TARGET,SOURCE,FSTYPE,OPTIONS / /var/lib/docker /tmp 2>/dev/null
free -h
swapon --show
df -hT / /var/lib/docker /tmp
df -ih / /var/lib/docker /tmp
systemctl is-active docker fail2ban ufw
dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' \
kesl kesl-gui kav4fs 2>/dev/null || true
```
Проверка проходит только для `amd64`/поддерживаемой архитектуры, Ubuntu 24.04
LTS и поддерживаемого KESL ядра. Требования Kaspersky — минимум 2 ГБ RAM,
1 ГБ swap и 4 ГБ свободного диска, но этого недостаточно для данной ВМ:
Compose-лимиты суммарно около 11,6 ГБ. При RAM менее 16 ГБ установка требует
отдельного решения владельца сервиса о доступном запасе.
### 1.2. Docker и приложение
```sh
docker info --format \
'driver={{.Driver}} root={{.DockerRootDir}} containers={{.Containers}} running={{.ContainersRunning}}'
/usr/local/sbin/han-vm1-compose ps
docker ps --format \
'table {{.Names}}\t{{.Status}}\t{{.Image}}'
docker stats --no-stream --format \
'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.BlockIO}}\t{{.PIDs}}'
```
Сохраните список и состояние именованных volumes без содержимого:
```sh
for volume in redis-data otel-queue nginx-cache; do
docker volume ls --format '{{.Name}}' |
while IFS= read -r name; do
case "$name" in
*"$volume"*)
docker volume inspect --format \
'{{.Name}}\t{{.Mountpoint}}' "$name"
;;
esac
done
done
```
В change record перенесите три фактических mountpoint. Не подставляйте
предполагаемый Compose prefix.
### 1.3. Health, edge и firewall
Под root на ВМ:
```sh
systemctl --no-pager status \
han-secrets@production.service han-stack@production.service
iptables -S HAN-CHAT-DOCKER
iptables -L HAN-CHAT-DOCKER -n -v
ufw status verbose
journalctl --since '-30 min' --no-pager \
-u han-stack@production.service -p warning
```
С trusted external host выполните smoke из
`deployment/RUNBOOK.production.ru.md`, раздел 10. Если запускается repository
`deployment/scripts/smoke.sh`, он должен использовать штатный secret launcher;
не печатайте resolved environment.
Снимите из штатного observability baseline:
- API request rate, 5xx и p50/p95/p99 latency;
- Redis latency, blocked clients и memory;
- container restart/OOM count;
- host CPU, available RAM, swap, disk IO/IO wait;
- OTEL exporter failures и queue depth.
Без доступных baseline и rollback approver к установке не переходить.
## 2. Проверка пакета и подготовка
GUI на сервер не устанавливается. Дистрибутив и DEB копируются в root-only
staging `/root/kesl-install` и удаляются после приёмки. ISO в
`/var/lib/han-deploy/incoming` не распаковывается на месте и не остаётся
смонтированным после извлечения пакета.
Для тестовой ВМ ожидаемый ISO: `049-16-d-01.iso`, volume id `KESL12SP4`.
Вендор публикует контрольную сумму **ФИКС 2.0.2 / ГОСТ Р 34.11-94, программно**,
а не SHA-256. Это разные алгоритмы: оба дают 64 hex-символа, поэтому
`sha256sum --check` против суммы с сайта закономерно не совпадает.
Ожидаемая сумма вендора (ГОСТ Р 34.11-94):
```
6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3
```
Фактический SHA-256 полученного ISO (для локального учёта, не для сверки с
сайтом ФИКС):
```
8599a7ed8d7d661a70811d668e5f98e372b674c87ef4cd24abbd917625a2d5e5
```
```sh
ISO='/var/lib/han-deploy/incoming/049-16-d-01.iso'
SIG='/var/lib/han-deploy/incoming/049-16-d-01.sig'
ls -l "$ISO" "$SIG"
file "$ISO" "$SIG"
sha256sum "$ISO"
command -v rhash || true
openssl list -digest-algorithms 2>/dev/null | grep -i gost || true
```
Если доступен `rhash`, посчитайте ГОСТ и сравните с суммой вендора. ФИКС
может использовать другой набор S-блоков/порядок байт, поэтому проверьте
несколько вариантов:
```sh
rhash --gost "$ISO"
rhash --gost --gost-reverse "$ISO"
rhash --gost-cryptopro "$ISO"
rhash --gost-cryptopro --gost-reverse "$ISO"
```
Для ISO `049-16-d-01.iso` сумма вендора совпала с `rhash --gost`.
Подлинность принимается, если совпала ГОСТ-сумма **или** одновременно
выполнены все условия ниже:
- volume id ISO = `KESL12SP4`;
- внутри есть `kesl/kesl_12.4.0-1225_amd64.deb`;
- SHA-256 ISO зафиксирован в evidence;
- источник — официальный канал вендора.
Файл `.sig` без доверенного публичного ключа не заменяет эту проверку.
Только после совпадения хеша смонтируйте ISO только для чтения и найдите
`kesl_*_amd64.deb` без GUI:
```sh
install -d -m 0700 -o root -g root /root/kesl-install /mnt/kesl-iso
mount -o ro,loop "$ISO" /mnt/kesl-iso
find /mnt/kesl-iso -type f \( -iname '*kesl*' -o -iname '*.deb' \) | sort
```
Ожидается имя вида `kesl_12.4.0-*_amd64.deb`. Не копируйте `kesl-gui`, i386,
arm64, RPM, KSC agent и Windows-пакеты. Если внутри только 12.0.x или нет
amd64 DEB — stop: для Ubuntu 24.04 нужен KESL 12.4.
```sh
install -m 0600 -o root -g root \
<KESL_12_4_AMD64_DEB_FROM_ISO> /root/kesl-install/kesl.deb
dpkg-deb -f /root/kesl-install/kesl.deb Package Version Architecture
umount /mnt/kesl-iso
rmdir /mnt/kesl-iso
```
Не продолжайте при package name не `kesl`, неверной архитектуре или версии не
12.4.x.
Перед установкой сохраните только безопасные snapshots:
```sh
cp -a /etc/docker/daemon.json /root/kesl-install/docker-daemon.before.json
iptables-save > /root/kesl-install/iptables.before
ufw status verbose > /root/kesl-install/ufw.before
```
## 3. Установка с отключённой защитой
Установка изменяет host и выполняется только в maintenance window.
```sh
apt-get install /root/kesl-install/kesl.deb
```
Создайте `/root/kesl-install/autoinstall.ini` с mode `0600`. Значения EULA,
Privacy Policy и KSN должны быть осознанно согласованы с Security/Legal, а не
скопированы механически:
```ini
KSVLA_MODE=No
ENDPOINT_AGENT_MODE=No
EULA_AGREED=<Yes_AFTER_APPROVAL>
PRIVACY_POLICY_AGREED=<Yes_AFTER_APPROVAL>
USE_KSN=<Yes_OR_No_AFTER_APPROVAL>
GROUP_CLEAN=Yes
LOCALE=ru_RU.UTF-8
INSTALL_LICENSE=None
UPDATER_SOURCE=KLServers
UPDATE_EXECUTE=No
KERNEL_SRCS_INSTALL=No
USE_GUI=No
CONFIGURE_SELINUX=No
DISABLE_PROTECTION=Yes
INTERCEPTOR_MODE=UseFanotify
ENABLE_TRACES_ON_FIRST_STARTUP=No
```
Для Ubuntu AppArmor значение `CONFIGURE_SELINUX=No` ожидаемо. `UseFanotify`
не требует сборки стороннего kernel module. Если выбран KSN, документируйте
передачу данных и правовое основание.
Первоначальная настройка:
```sh
chmod 0600 /root/kesl-install/autoinstall.ini
/opt/kaspersky/kesl/bin/kesl-setup.pl \
--autoinstall=/root/kesl-install/autoinstall.ini
echo "kesl-setup exit=$?"
# Exit 71 with INSTALL_LICENSE=None is expected: setup treats None as a
# code. Do not rerun setup. Continue if kesl.service is active and
# File_Threat_Protection is Stopped.
systemctl --no-pager status kesl
kesl-control --app-info
kesl-control --supported-tech-info
kesl-control --get-task-list
```
Активацию кодом выполняйте только после успешного `kesl-setup.pl` и до
загрузки баз. Код не помещайте в `autoinstall.ini`, репозиторий, evidence,
чат и историю shell:
```sh
set +o history
unset HISTFILE
read -rsp 'KESL activation code: ' KESL_CODE; echo
kesl-control --add-active-key "$KESL_CODE"
unset KESL_CODE
set -o history
kesl-control -L --query
kesl-control --app-info
```
Нужен исходящий доступ к серверам активации Kaspersky. В выводе лицензии
оставьте только статус «ключ действителен» и срок; сам код не копируйте.
## 4. Ресурсные ограничения до первого scan
В KESL 12.4 `ScanMemoryLimit` по умолчанию равен 8192 МБ, а `MaxMemory=auto`
может разрешить до 50% доступной RAM. Для ВМ1 эти defaults не принимаются без
измерений.
Выберите значения по фактическому baseline:
- constrained test VM, 4 ГБ RAM: `ScanMemoryLimit=512`,
`MaxMemory=1024MB`, `OnDemandCPULimit=15`; только по разделу 0.1;
- 16 ГБ RAM: начните с `ScanMemoryLimit=1024`, `MaxMemory=2048MB`;
- 24–32 ГБ RAM: начните с `ScanMemoryLimit=2048`, `MaxMemory=4096MB`;
- иной размер: согласуйте значения; `ScanMemoryLimit` должен быть ниже
`MaxMemory`, а после резервирования KESL у приложения должен оставаться
исходный запас.
Сначала сохраните исходные настройки:
```sh
kesl-control --get-app-settings \
--file /root/kesl-install/app-settings.before.ini
install -m 0600 -o root -g root \
/var/opt/kaspersky/kesl/common/kesl.ini \
/root/kesl-install/kesl.ini.before
```
Установите CPU limit для ODS/ContainerScan:
```sh
kesl-control --set-app-settings \
UseOnDemandCPULimit=Yes OnDemandCPULimit=<15_FOR_TEST_OR_APPROVED_VALUE>
```
Для изменения `ScanMemoryLimit` и `MaxMemory` следуйте официальной процедуре:
остановите KESL, внесите значения в секцию `[General]` файла
`/var/opt/kaspersky/kesl/common/kesl.ini`, затем запустите KESL. Не заменяйте
файл целиком и не применяйте шаблон из репозитория как готовый конфиг.
После запуска проверьте:
```sh
systemctl is-active kesl
kesl-control --get-app-settings
kesl-control --app-info
```
## 5. Обновление баз — АВЗ.2
Запустите предустановленную задачу Update (ID 6) и дождитесь результата:
```sh
kesl-control --get-settings 6
kesl-control --get-schedule 6
kesl-control --start-task 6 -W
kesl-control --get-task-state 6
kesl-control --app-info
```
Проверьте действующую лицензию, `Базы приложения загружены: Да`, свежую дату
выпуска баз и успешное завершение Update. Затем задайте почасовой запуск:
```sh
START="$(LC_ALL=C date +'%Y/%b/%d %H:%M:%S;1')"
kesl-control --set-schedule 6 \
RuleType=Hourly \
"StartTime=${START}" \
RunMissedStartRules=No \
RandomInterval=0
kesl-control --get-schedule 6
```
Для 12.4 одного `RuleType=Hourly` недостаточно: нужен `StartTime` вида
`2026/Sep/07 12:00:00;1` (английское имя месяца, интервал 1 час). `LC_ALL=C`
обязателен, иначе локаль `ru_RU` подставит русское имя месяца. До успешного
автообновления АВЗ.2 не принимается.
Сразу после обновления повторите разделы 1.2–1.3. При деградации выполните
rollback из раздела 11.
## 6. Исключения hot-data
Исключения создаются только после получения фактических mountpoint в разделе
1.2. Разрешены три области:
- `<REDIS_DATA_MOUNTPOINT>` — AOF/RDB;
- `<OTEL_QUEUE_MOUNTPOINT>` — persistent telemetry queue;
- `<NGINX_CACHE_MOUNTPOINT>` — regenerable cache.
До изменения экспортируйте параметры:
```sh
kesl-control --get-settings 1 \
--file /root/kesl-install/file-threat.before.ini
```
Добавьте обычные исключения File Threat Protection:
```sh
kesl-control --set-settings 1 \
--add-exclusion <REDIS_DATA_MOUNTPOINT>
kesl-control --set-settings 1 \
--add-exclusion <OTEL_QUEUE_MOUNTPOINT>
kesl-control --set-settings 1 \
--add-exclusion <NGINX_CACHE_MOUNTPOINT>
kesl-control --get-settings 1
```
Не исключать:
- весь `/var/lib/docker`, `/var/lib/docker/overlay2` или все volumes;
- `/opt/han-chat/releases` и `/opt/han-chat/current`;
- `/var/lib/han-deploy/incoming`;
- `/etc/han`, `/run/han-chat`, `/etc/letsencrypt`;
- `/tmp`, `/var/tmp`, `/root` или весь filesystem.
Обычное исключение из scan может не исключить файловый перехват. Не создавайте
bind mounts и не добавляйте `ExcludedMountPoint` в первой итерации. Это
допустимо только если измерена деградация и Security письменно принял
компенсацию плановой проверкой/container scan.
## 7. Пилот задач проверки
Убедитесь, что все ODS/ContainerScan schedules, кроме Update, пока ручные:
```sh
kesl-control --get-task-list
kesl-control --get-schedule 2
kesl-control --get-schedule 18
kesl-control --set-schedule 2 RuleType=Manual
kesl-control --set-schedule 18 RuleType=Manual
```
Идентификаторы подтвердите через `--get-task-list`; не применяйте команды,
если тип задачи не совпадает.
### 7.1. Ограниченная on-demand проверка host
Сначала проверьте небольшой immutable release, не корень filesystem:
```sh
kesl-control --scan-file /opt/han-chat/current/backend \
--action Inform
```
В первом пилоте действие `Inform` не изменяет release. Проверьте результат,
events, ресурсы и application health:
```sh
kesl-control -E --query -n 100 --reverse
kesl-control --get-statistic
/usr/local/sbin/han-vm1-compose ps
docker stats --no-stream
```
### 7.2. Проверка контейнеров
Перед scan снимите список running containers. Проверяйте по одному объекту,
начиная с stateless/oneshot image, не Redis и не Keycloak:
```sh
docker ps --format '{{.Names}}\t{{.Image}}'
kesl-control --get-settings 19
kesl-control --scan-container <STATELESS_CONTAINER_OR_IMAGE>
```
После успешного одиночного теста задачу `Container_Scan` (ID 18) можно
назначить еженедельно в согласованное время. Перед этим проверьте параметры:
по умолчанию `ContainerScanAction=StopContainerIfFailed`; production-контейнер
не должен останавливаться из-за технической ошибки сканирования. Итоговое
действие отдельно утверждает Security.
Container scan после deploy выполняется только после завершения smoke, а не
одновременно с pull/start/migrations.
## 8. Ступенчатое включение File Threat Protection — АВЗ.1
`DISABLE_PROTECTION=Yes` отключает компоненты после setup. До старта сохраните
параметры и убедитесь, что `ScanArchived=No`:
```sh
kesl-control --get-settings 1
kesl-control --get-task-state 1
```
Для пилота запустите File Threat Protection. На Ubuntu 24.04 с `fanotify`
параметр `InterceptorProtectionMode` **не поддерживается** (`Unsupported
setting`): перехватчик работает в штатном блокирующем режиме на время
проверки. Отдельный `Notify` недоступен; откат — остановка задачи 1.
```sh
kesl-control --start-task 1
kesl-control --get-task-state 1
kesl-control --app-info
```
Пилот длится минимум 15–30 минут на constrained test VM и 24 часа на
боевых ресурсах. Каждые 15 минут проверяйте метрики раздела 1, события KESL
и firewall. АВЗ.1 выполняется при `Started` + `ActionOnThreat=DisinfectDeleteIfNotPossible`.
Перед приёмкой:
```sh
kesl-control --set-settings 1 \
ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No
kesl-control --get-task-state 1
kesl-control --app-info
```
При latency/swap/unhealthy выполните `kesl-control --stop-task 1`. Не
расширяйте исключения вслепую. Network Threat Protection, Firewall
Management, Web Threat Protection и Behavior Detection не запускайте.
## 9. Приёмочный тест и evidence
Тест EICAR выполняется только с письменным разрешением Security в отдельном
безопасном каталоге, не в release, volume, backup, secret или upload path.
Используйте официальную контрольную строку/файл с сайта EICAR/Kaspersky; этот
репозиторий намеренно не содержит тестовый образец.
До теста:
```sh
install -d -m 0700 -o root -g root /root/kesl-eicar-test
date -Is
kesl-control --app-info
kesl-control --get-task-state 1
```
Ожидается блокирование/лечение/карантин и событие KESL. Не прикладывайте сам
образец к evidence. Сохраните:
```sh
kesl-control --app-info
kesl-control --get-task-list
kesl-control --get-settings 1
kesl-control --get-schedule 6
kesl-control -E --query -n 100 --reverse
```
Очистите тестовый каталог после подтверждения реакции. Копию EICAR в Backup
удалите точечно, не весь Backup:
```sh
kesl-control -B --query --reverse -n 20
kesl-control -B --mass-remove --query "DetectName == 'EICAR-Test-File'"
```
Заполните `deployment/kesl/EVIDENCE.AVZ.ru.md`.
## 10. Финальные проверки
На ВМ:
```sh
systemctl is-active kesl docker \
han-secrets@production.service han-stack@production.service
kesl-control --app-info
kesl-control --get-task-state 1
kesl-control --get-task-state 6
/usr/local/sbin/han-vm1-compose ps
iptables -S HAN-CHAT-DOCKER
ufw status verbose
```
С внешнего trusted host повторите production smoke и negative port probes.
Сравните метрики минимум за 24 часа. Не выполняйте reboot только ради KESL.
Если пакет/ядро явно запросили reboot, проведите его отдельным окном по
reboot gate основного production-runbook.
После приёмки удалите package/autoinstall и временные snapshots с ВМ только
после переноса разрешённого evidence:
```sh
rm -rf /root/kesl-install /root/kesl-eicar-test
```
## 11. Rollback
### 11.1. До включения блокирующей защиты
```sh
kesl-control --stop-task 1 2>/dev/null || true
apt-get purge kesl
systemctl daemon-reload
systemctl restart han-chat-docker-firewall.service
/usr/local/sbin/han-vm1-compose ps
iptables -S HAN-CHAT-DOCKER
ufw status verbose
```
Повторите smoke и resource checks. Docker и application stack без причины не
перезапускайте.
### 11.2. При инциденте после включения Block
Сначала минимально обратимое действие:
```sh
kesl-control --stop-task 1
```
Если управление KESL не отвечает:
```sh
systemctl stop kesl
```
Затем восстановите доступность и соберите события. Полный `apt-get purge kesl`
выполняйте только по решению change approver. Reboot — только если он требуется
для удаления/ядра и есть отдельное окно.
Нельзя выполнять `docker compose down -v`, удалять volumes, чистить Redis AOF,
пересоздавать VM или менять firewall ради обхода проблемы KESL.
## 12. Эксплуатационный режим
- Update (ID 6): каждый час; alert при ошибке или устаревании баз.
- File Threat Protection (ID 1): постоянно, `Block`,
`DisinfectDeleteIfNotPossible`, `ScanArchived=No`.
- ODS: еженедельно в низкую нагрузку после подтверждения resource budget.
- ContainerScan: еженедельно и после deploy, только после smoke.
- Ежедневно: статус лицензии, компонентов, дата баз и ошибки KESL.
- Ежемесячно: review исключений и фактической нагрузки.
- После upgrade KESL/kernel/Docker: повтор пилота, smoke и evidence delta.
Любое новое исключение должно иметь владельца, причину, срок пересмотра,
компенсирующую проверку и подтверждение Security.
@@ -0,0 +1,90 @@
# KESL 12.4 standalone policy decisions for HAN Chat VM1.
#
# REFERENCE ONLY: this is deliberately not a complete kesl-control import file.
# Export the settings from the installed build, review the diff, and apply
# individual values by the commands in RUNBOOK.KESL.ru.md. Importing a partial
# or version-mismatched file can reset settings that are not listed here.
#
# This file contains no license, activation code, proxy credentials, hostname,
# IP address, secret or environment value.
[deployment]
product_major_minor=12.4
mode=standard_standalone
gui=disabled
update_source=KLServers
interceptor=fanotify
network_features=disabled
ksn=<Yes_OR_No_AFTER_SECURITY_AND_LEGAL_APPROVAL>
[resource_budget]
# Choose from measured host capacity; see runbook section 4.
scan_memory_limit_mb=<1024_OR_APPROVED_VALUE>
max_memory=<2048MB_OR_APPROVED_VALUE>
use_on_demand_cpu_limit=Yes
on_demand_cpu_limit_percent=25
[constrained_test_vm_override]
# Explicitly approved only for the 4 GB non-production VM. Never copy this
# profile to production without new sizing and baseline.
scan_memory_limit_mb=512
max_memory=1024MB
use_on_demand_cpu_limit=Yes
on_demand_cpu_limit_percent=15
full_filesystem_scan=forbidden
scan_archived=No
parallel_ods_and_container_scan=forbidden
stop_available_memory_mb=512
[update_task_6]
rule_type=Hourly
required_result=completed_successfully
required_bases_loaded=Yes
stale_bases_alert=<APPROVED_THRESHOLD>
[file_threat_protection_task_1]
steady_state=Started
interceptor_protection_mode=Block
action_on_threat=DisinfectDeleteIfNotPossible
scan_archived=No
[file_threat_exclusions]
# Replace placeholders only with mountpoints returned by docker volume inspect.
# Do not guess the Compose project prefix.
item_0000=<REDIS_DATA_MOUNTPOINT>
item_0001=<OTEL_QUEUE_MOUNTPOINT>
item_0002=<NGINX_CACHE_MOUNTPOINT>
[forbidden_broad_exclusions]
item_0000=/var/lib/docker
item_0001=/var/lib/docker/overlay2
item_0002=/opt/han-chat
item_0003=/var/lib/han-deploy/incoming
item_0004=/etc/han
item_0005=/run/han-chat
item_0006=/tmp
item_0007=/
[on_demand_scan]
initial_scope=/opt/han-chat/current/backend
initial_action=Inform
steady_schedule=<APPROVED_WEEKLY_LOW_LOAD_WINDOW>
[container_scan_task_18]
initial_schedule=Manual
steady_schedule=<APPROVED_WEEKLY_LOW_LOAD_WINDOW>
post_deploy=after_smoke_only
# Review before enabling: the vendor default can stop a container when scan
# fails technically.
container_scan_action=<SECURITY_APPROVED_NON_DISRUPTIVE_VALUE>
[evidence]
application_info=required
license_valid=required
bases_loaded_and_fresh=required
file_threat_task_started=required
update_schedule_hourly=required
eicar_block_or_remediation_event=required
application_smoke_after_each_stage=required
firewall_unchanged=required
resource_comparison_24h=required
@@ -0,0 +1,143 @@
extensions:
file_storage:
directory: /var/lib/han-otel/host-collector
timeout: 10s
receivers:
filelog/docker:
include: [/var/lib/docker/containers/*/*-json.log]
start_at: end
include_file_path: true
storage: file_storage
operators:
- type: json_parser
id: docker-json
timestamp:
parse_from: attributes.time
layout_type: gotime
layout: "2006-01-02T15:04:05.000000000Z07:00"
- type: regex_parser
id: docker-service-label
parse_from: attributes["attrs"]["tag"]
if: 'attributes["attrs"]["tag"] != nil'
regex: '^(?P<docker_service>nginx|keycloak|redis)$'
- type: move
from: attributes.docker_service
to: resource["service.name"]
- type: move
from: attributes.stream
to: attributes["log.iostream"]
- type: move
from: attributes.log
to: body
- type: json_parser
id: structured-body
parse_from: body
parse_to: attributes
if: 'body matches "^\\s*\\{"'
on_error: send
journald/host:
directory: /var/log/journal
units:
- docker.service
- han-stack@production.service
- han-secrets@production.service
- han-chat-docker-firewall.service
- certbot.service
- fail2ban.service
priority: info
start_at: end
storage: file_storage
processors:
memory_limiter:
check_interval: 1s
limit_mib: 192
spike_limit_mib: 48
resource/common:
attributes:
- {key: service.namespace, value: han-chat, action: upsert}
- {key: deployment.environment, value: "${env:APP_ENV}", action: upsert}
- {key: service.version, value: "${env:RELEASE_VERSION}", action: upsert}
- {key: host.name, value: "${env:HOST_NAME}", action: upsert}
transform/journal:
error_mode: ignore
log_statements:
- context: log
statements:
- set(resource.attributes["service.name"], attributes["_SYSTEMD_UNIT"]) where resource.attributes["service.name"] == nil and attributes["_SYSTEMD_UNIT"] != nil
- set(attributes["event.source"], "journald") where attributes["_SYSTEMD_UNIT"] != nil
- set(attributes["event.source"], "docker-json-file") where attributes["_SYSTEMD_UNIT"] == nil
- set(attributes["route.class"], "api") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/api/")
- set(attributes["route.class"], "auth") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/auth/")
- set(attributes["route.class"], "bitrix") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/bitrix/")
- set(attributes["route.class"], "callbacks") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/callbacks/")
- set(attributes["route.class"], "static") where resource.attributes["service.name"] == "nginx" and attributes["route.class"] == nil
- replace_pattern(body, "(?i)(Bearer|Basic)\\s+[A-Za-z0-9._~+/=-]+", "$$1 [REDACTED]") where IsString(body)
- replace_pattern(body, "(?i)(token|secret|password|code)=([^&\\s]+)", "$$1=[REDACTED]") where IsString(body)
- set(body, "nginx.access") where resource.attributes["service.name"] == "nginx" and attributes["log.iostream"] == "stdout"
- set(body, "keycloak.event") where resource.attributes["service.name"] == "keycloak" and attributes["log.iostream"] == "stdout"
attributes/redact:
actions:
- {key: authorization, action: delete}
- {key: http.request.header.authorization, action: delete}
- {key: http.request.header.cookie, action: delete}
- {key: http.response.header.set-cookie, action: delete}
- {key: url.query, action: delete}
- {key: url.full, action: delete}
- {key: http.target, action: delete}
- {key: http.request.body, action: delete}
- {key: db.statement, action: delete}
- {key: message, action: delete}
- {key: payload, action: delete}
- {key: user.phone, action: delete}
- {key: user.email, action: delete}
- {key: remote_addr, action: delete}
- {key: user_agent, action: delete}
- {key: uri, action: delete}
filter/allowlist:
error_mode: ignore
logs:
log_record:
- 'attributes["_SYSTEMD_UNIT"] == nil and resource.attributes["service.name"] != "nginx" and resource.attributes["service.name"] != "keycloak" and resource.attributes["service.name"] != "redis"'
- 'resource.attributes["service.name"] == "otel-collector" or resource.attributes["service.name"] == "otel-host-collector"'
filter/noise:
error_mode: ignore
logs:
log_record:
- 'resource.attributes["service.name"] == "nginx" and severity_number < SEVERITY_NUMBER_WARN and IsMatch(body, "(/health/live|/nginx-health/live|/.well-known/acme-challenge/)")'
batch:
timeout: 5s
send_batch_size: 512
send_batch_max_size: 1024
exporters:
otlp/remote:
endpoint: "${env:OTEL_REMOTE_ENDPOINT}"
headers:
authorization: ${file:/run/han-chat/secrets/OTEL_REMOTE_AUTH_HEADER}
tls:
insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"
sending_queue:
enabled: true
queue_size: 5000
storage: file_storage
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 0s
service:
extensions: [file_storage]
pipelines:
logs:
receivers: [filelog/docker, journald/host]
processors:
[memory_limiter, resource/common, filter/allowlist, filter/noise, transform/journal, attributes/redact, batch]
exporters: [otlp/remote]
telemetry:
logs:
level: error
metrics:
level: none
@@ -19,6 +19,7 @@ value() {
[ -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"
[ -d /var/log/journal ] || fail "persistent journald directory 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"
@@ -59,6 +60,8 @@ if [ -d "$ROOT" ]; then
$ROOT/docker-compose.yml
$ROOT/deployment/preflight.sh
$ROOT/deployment/han-stack@.service
$ROOT/deployment/han-host-otel-collector@.service
$ROOT/deployment/observability/otel-host-collector.yaml
$ROOT/deployment/scripts/tls-deploy-hook.sh
$ROOT/deployment/secrets/han-compose
$ROOT/deployment/secrets/han-secrets
@@ -91,6 +94,10 @@ if [ -f "$ENV_FILE" ]; then
[ "$(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"
[ -n "$(value HOST_NAME)" ] || fail "HOST_NAME is required for telemetry correlation"
echo "$(value TELEMETRYGEN_IMAGE)" |
grep -Eq '^ghcr\.io/.+@sha256:[a-f0-9]{64}$' ||
fail "TELEMETRYGEN_IMAGE must be pinned by digest"
[ "$(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 ] ||
@@ -278,6 +285,39 @@ if [ -f "$MANIFEST" ]; then
IFS=$old_ifs
fi
host_otel_config="$ROOT/deployment/observability/otel-host-collector.yaml"
if [ -f "$host_otel_config" ]; then
grep -Fq 'filelog/docker' "$host_otel_config" ||
fail "host collector must include Docker filelog"
grep -Fq 'journald/host' "$host_otel_config" ||
fail "host collector must include journald allow-list"
grep -Fq 'filter/allowlist' "$host_otel_config" ||
fail "host collector must drop non-allowlisted containers"
grep -Fq 'file_storage' "$host_otel_config" ||
fail "host collector must persist offsets and exporter queue"
! grep -Fq '/var/run/docker.sock' "$host_otel_config" ||
fail "Docker socket access is forbidden"
fi
[ -x /usr/local/bin/otelcol-contrib ] ||
fail "pinned host otelcol-contrib is not installed"
otel_checksum=/usr/local/share/han-otel/otelcol-contrib.sha256
otel_version=/usr/local/share/han-otel/otelcol-contrib.version
[ -f "$otel_checksum" ] || fail "host Collector checksum record is missing"
[ -f "$otel_version" ] || fail "host Collector version record is missing"
if [ -f "$otel_checksum" ]; then
(cd / && sha256sum -c "$otel_checksum") ||
fail "host Collector binary checksum mismatch"
fi
if [ -x /usr/local/bin/otelcol-contrib ] && [ -f "$otel_version" ]; then
/usr/local/bin/otelcol-contrib --version 2>&1 |
grep -Fq "$(cat "$otel_version")" ||
fail "host Collector binary version mismatch"
fi
if [ -x /usr/local/bin/otelcol-contrib ] && [ -f "$host_otel_config" ]; then
/usr/local/bin/otelcol-contrib validate --config="$host_otel_config" ||
fail "host collector config validation failed"
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"
@@ -1,58 +1,78 @@
#!/usr/bin/env bash
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
# Запускать на ВМ из каталога backend: /opt/han-chat/backend
# Запускать на ВМ1 под root/admin (sudo -i). Секреты не читаются из /etc/han/vm1.env.
#
# cd /opt/han-chat/backend
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
# ./deployment/scripts/seed-personal-notifications-test.sh
# sed -i 's/\r$//' /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
# chmod +x /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
# /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
#
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
# При старте api-backend синхронизирует hash токена в notification_sources.
# Токен: runtime-файл NOTIFICATIONS_TOKEN_PRODUCER_TEST
# (/run/han-chat/secrets/…, тот же, что у api-backend). Hash в notification_sources
# синхронизируется при старте api-backend.
set -euo pipefail
cd "$(dirname "$0")/../.."
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
if [[ -f "${SCRIPT_DIR}/../../docker-compose.yml" ]]; then
cd "${SCRIPT_DIR}/../.."
else
cd "${HAN_DEPLOY_DIR:-/opt/han-chat/current/backend}"
fi
ENV_FILE="${ENV_FILE:-.env}"
CONFIG_FILE="${CONFIG_FILE:-/etc/han/vm1.env}"
SECRETS_LAUNCHER="${SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}"
if [[ ! -x "$SECRETS_LAUNCHER" ]]; then
SECRETS_LAUNCHER="deployment/secrets/han-secrets"
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "Не найден конфиг ${CONFIG_FILE} (ожидается /etc/han/vm1.env)." >&2
exit 1
fi
env_value() {
python3 - "$ENV_FILE" "$1" <<'PY'
import sys
from pathlib import Path
if [[ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]]; then
[[ -x "$SECRETS_LAUNCHER" ]] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
path, wanted = 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)
break
else:
raise SystemExit(f"missing environment variable: {wanted}")
PY
compose() {
if [[ -x /usr/local/sbin/han-vm1-compose ]]; then
/usr/local/sbin/han-vm1-compose "$@"
else
docker compose --env-file "$CONFIG_FILE" "$@"
fi
}
trim_token() {
printf '%s' "$1" | tr -d '\r\n\t '
}
read_secret_file() {
local path="${1:-}"
[[ -n "$path" && -r "$path" ]] || return 1
python3 - "$path" <<'PY'
from pathlib import Path
import sys
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
PY
}
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
if [[ -z "$TOKEN" ]]; then
TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")"
TOKEN="$(trim_token "$(read_secret_file "${NOTIFICATIONS_TOKEN_PRODUCER_TEST_FILE:-}" 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN
TOKEN="$(trim_token "$(read_secret_file "${HAN_RUNTIME_SECRET_DIR:-/run/han-chat/secrets}/NOTIFICATIONS_TOKEN_PRODUCER_TEST" 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
TOKEN="$(trim_token "$(compose exec -T api-backend python3 -c 'import os; print(os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST") or "")' 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (runtime secret не найден): " TOKEN
echo
TOKEN="$(trim_token "$TOKEN")"
fi
@@ -65,7 +85,7 @@ if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
exit 1
fi
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
echo "Токен: ${#TOKEN} символов (значение не печатается)."
PAYLOAD_DIR="$(mktemp -d)"
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
@@ -103,22 +123,48 @@ create_notification() {
fi
echo
echo "========== ${label} =========="
docker run --rm \
--network han-chat-backend \
-v "${payload_file}:/payload.json:ro" \
curlimages/curl:latest \
-sS -i \
-X POST \
'http://api-backend:8000/internal/notifications/v1/notifications' \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json; charset=utf-8' \
--data-binary @/payload.json
compose exec -T \
-e "NOTIFICATIONS_PRODUCER_TOKEN=${TOKEN}" \
api-backend python3 -c "
import os
import sys
import urllib.error
import urllib.request
payload = sys.stdin.buffer.read()
request = urllib.request.Request(
'http://127.0.0.1:8000/internal/notifications/v1/notifications',
data=payload,
method='POST',
headers={
'Authorization': 'Bearer ' + os.environ['NOTIFICATIONS_PRODUCER_TOKEN'],
'Content-Type': 'application/json; charset=utf-8',
},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
sys.stdout.write('HTTP/1.1 %s\n' % response.status)
for key, value in response.headers.items():
sys.stdout.write('%s: %s\n' % (key, value))
sys.stdout.write('\n')
sys.stdout.write(response.read().decode('utf-8', errors='replace'))
sys.stdout.write('\n')
except urllib.error.HTTPError as exc:
sys.stdout.write('HTTP/1.1 %s\n' % exc.code)
for key, value in exc.headers.items():
sys.stdout.write('%s: %s\n' % (key, value))
sys.stdout.write('\n')
sys.stdout.write(exc.read().decode('utf-8', errors='replace'))
sys.stdout.write('\n')
if exc.code >= 500 or exc.code in (401, 403):
raise SystemExit(1)
" <"$payload_file"
}
prepare_docs_in_s3() {
echo >&2
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
docker compose --env-file "$ENV_FILE" exec -T \
compose exec -T \
-e "USER_ID=${USER_ID}" \
-e "RUN_ID=${RUN_ID}" \
api-backend python3 - <<'PY'
@@ -443,6 +489,7 @@ echo
echo "========== Готово =========="
echo "External ID prefix: ${RUN_ID}-*"
echo
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
echo " docker compose --env-file .env up -d --force-recreate api-backend"
echo "Если видите 401: проверьте runtime-секрет NOTIFICATIONS_TOKEN_PRODUCER_TEST"
echo "и hash в han_app.notification_sources, затем пересоздайте api-backend:"
echo " test -r /run/han-chat/secrets/NOTIFICATIONS_TOKEN_PRODUCER_TEST"
echo " /usr/local/sbin/han-vm1-compose up -d --force-recreate api-backend"
@@ -27,6 +27,8 @@ HARDEN_SSH="${HARDEN_SSH:-false}"
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
RESET_UFW="${RESET_UFW:-true}"
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
OTEL_HOST_COLLECTOR_VERSION="${OTEL_HOST_COLLECTOR_VERSION:-}"
OTEL_HOST_COLLECTOR_SHA256="${OTEL_HOST_COLLECTOR_SHA256:-}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm1-setup.log}"
log() {
@@ -120,6 +122,13 @@ configure_time() {
timedatectl set-ntp true
}
configure_journald() {
step "Persistent journald для host telemetry"
install -d -m 2755 -o root -g systemd-journal /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
journalctl --flush
}
install_authorized_key() {
local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys"
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}"
@@ -422,7 +431,7 @@ 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
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/systemctl --no-pager status han-host-otel-collector@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service, /usr/bin/journalctl --no-pager -u han-host-otel-collector@production.service
deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS
EOF
chmod 0440 /etc/sudoers.d/deploy
@@ -447,6 +456,15 @@ install_release_helpers_if_possible() {
[[ -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"
[[ -f "${deployment}/han-host-otel-collector@.service" ]] \
|| die "han-host-otel-collector@.service отсутствует"
[[ -f "${deployment}/observability/otel-host-collector.yaml" ]] \
|| die "otel-host-collector.yaml отсутствует"
[[ "$OTEL_HOST_COLLECTOR_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| die "Задайте OTEL_HOST_COLLECTOR_VERSION=x.y.z"
[[ "$OTEL_HOST_COLLECTOR_SHA256" =~ ^[a-f0-9]{64}$ ]] \
|| die "Задайте проверенный OTEL_HOST_COLLECTOR_SHA256"
[[ "$(uname -m)" == x86_64 ]] || die "Host Collector artifact рассчитан на amd64"
if getent group "$tls_group" >/dev/null; then
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|| die "han-nginx-tls имеет неожиданный GID"
@@ -472,6 +490,27 @@ install_release_helpers_if_possible() {
install -m 0644 -o root -g root \
"${deployment}/han-stack@.service" \
/etc/systemd/system/han-stack@.service
local otel_archive otel_tmp otel_url
otel_archive="$(mktemp)"
otel_tmp="$(mktemp -d)"
otel_url="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTEL_HOST_COLLECTOR_VERSION}/otelcol-contrib_${OTEL_HOST_COLLECTOR_VERSION}_linux_amd64.tar.gz"
curl --fail --location --proto '=https' --tlsv1.2 \
--output "$otel_archive" "$otel_url"
printf '%s %s\n' "$OTEL_HOST_COLLECTOR_SHA256" "$otel_archive" | sha256sum -c -
tar -xzf "$otel_archive" -C "$otel_tmp" otelcol-contrib
install -m 0755 -o root -g root "$otel_tmp/otelcol-contrib" /usr/local/bin/otelcol-contrib
install -d -m 0755 -o root -g root /usr/local/share/han-otel
sha256sum /usr/local/bin/otelcol-contrib \
>/usr/local/share/han-otel/otelcol-contrib.sha256
printf '%s\n' "$OTEL_HOST_COLLECTOR_VERSION" \
>/usr/local/share/han-otel/otelcol-contrib.version
chmod 0644 /usr/local/share/han-otel/otelcol-contrib.sha256 \
/usr/local/share/han-otel/otelcol-contrib.version
rm -rf "$otel_archive" "$otel_tmp"
install -d -m 0700 -o root -g root /var/lib/han-otel/host-collector
install -m 0644 -o root -g root \
"${deployment}/han-host-otel-collector@.service" \
/etc/systemd/system/han-host-otel-collector@.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" \
@@ -481,6 +520,7 @@ install_release_helpers_if_possible() {
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
systemctl daemon-reload
systemctl enable certbot.timer
systemctl enable han-host-otel-collector@production.service
log "Helpers установлены; application units не запущены"
}
@@ -539,6 +579,7 @@ main() {
check_os
update_system
configure_time
configure_journald
create_host_roles
configure_account_passwords
configure_layout
@@ -16,11 +16,38 @@ compose() { docker compose --env-file "$CONFIG_FILE" "$@"; }
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
TELEMETRYGEN_IMAGE="${TELEMETRYGEN_IMAGE:-}"
errors=0
ok() { printf 'OK %s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
if systemctl is-active --quiet han-host-otel-collector@production.service; then
ok "Host Collector service is running"
else
fail "Host Collector service is not running"
fi
if /usr/local/bin/otelcol-contrib validate \
--config=deployment/observability/otel-host-collector.yaml >/dev/null 2>&1; then
ok "Host Collector configuration is valid"
else
fail "Host Collector configuration is invalid"
fi
host_bad_logs="$(
journalctl --since=-10min --no-pager \
-u han-host-otel-collector@production.service 2>&1 |
grep -Ei 'queue is full|permission denied|connection refused|tls:|Unauthenticated|Permanent error' ||
true
)"
if [[ -z "$host_bad_logs" ]]; then
ok "No host Collector errors in last 10 minutes"
else
fail "Host Collector reports read/export/queue errors"
printf '%s\n' "$host_bad_logs" >&2
fi
collector_id="$(compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
if [[ -n "$collector_id" ]] &&
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
@@ -43,12 +70,16 @@ PY
fi
done
docker run --rm --network "$NETWORK" \
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
if [[ ! "$TELEMETRYGEN_IMAGE" =~ @sha256:[a-f0-9]{64}$ ]]; then
fail "TELEMETRYGEN_IMAGE must be pinned by sha256 digest"
elif docker run --rm --network "$NETWORK" \
"$TELEMETRYGEN_IMAGE" \
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null \
&& ok "100 canary traces submitted" \
|| fail "telemetrygen failed"
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null; then
ok "100 canary traces submitted"
else
fail "telemetrygen failed"
fi
bad_logs="$(
compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
@@ -72,4 +103,12 @@ cat <<'EOF'
service.namespace = han-chat
Затем выполните synthetic API request и проверьте общий trace между
api-backend и dependency spans, service.version и deployment.environment.
В Logs проверьте:
host.name = <VM1 HOST_NAME>
service.name IN (nginx, keycloak, redis, api-backend, sms-service,
sms-worker, bitrix-local-app)
У canary application log trace_id/span_id должны открывать соответствующий
span. Python events должны встречаться один раз, unknown-container и
otel-host-collector отсутствовать. Fake token/PII marker не должен находиться
ни в logs, ни в traces.
EOF
@@ -63,7 +63,7 @@ export default function ProfileScreen() {
<Feather name="user" size={40} color={colors.primaryForeground} />
</View>
<Text style={stylesLocal.name}>{fullName}</Text>
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"}</Text>
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Гражданство не указано"}</Text>
</View>
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
@@ -29,7 +29,7 @@ x-api-secrets: &api-secrets
x-api-runtime: &api-runtime
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
environment:
environment: &api-environment
<<: *api-secret-environment
APP_ENV: ${APP_ENV:-production-like}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
@@ -167,6 +167,8 @@ services:
KC_HTTP_RELATIVE_PATH: /auth
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_LOG_CONSOLE_OUTPUT: json
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-info}
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}
@@ -185,6 +187,8 @@ services:
- keycloak_settings_bridge_token
- keycloak_sms_service_token
command: ["start", "--optimized", "--import-realm"]
labels:
com.han.service.name: keycloak
expose: ["8080", "9000"]
volumes:
- type: bind
@@ -209,7 +213,10 @@ services:
core: {soft: 0, hard: 0}
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
options:
max-size: "50m"
max-file: "5"
tag: keycloak
sms-service:
<<: *sms-runtime
@@ -269,6 +276,9 @@ services:
delivery-worker:
<<: *api-runtime
command: ["han-delivery-worker"]
environment:
<<: *api-environment
OTEL_SERVICE_NAME: delivery-worker
depends_on:
api-backend: {condition: service_healthy}
bitrix-local-app: {condition: service_healthy}
@@ -283,6 +293,9 @@ services:
safety-recovery-worker:
<<: *api-runtime
command: ["han-safety-worker"]
environment:
<<: *api-environment
OTEL_SERVICE_NAME: safety-recovery-worker
depends_on:
api-backend: {condition: service_healthy}
healthcheck:
@@ -296,6 +309,9 @@ services:
cleanup-worker:
<<: *api-runtime
command: ["han-cleanup-worker"]
environment:
<<: *api-environment
OTEL_SERVICE_NAME: cleanup-worker
depends_on:
api-backend: {condition: service_healthy}
healthcheck:
@@ -309,6 +325,9 @@ services:
notification-expire-worker:
<<: *api-runtime
command: ["han-notification-expire-worker"]
environment:
<<: *api-environment
OTEL_SERVICE_NAME: notification-expire-worker
depends_on:
api-backend: {condition: service_healthy}
healthcheck:
@@ -322,6 +341,9 @@ services:
notification-draft-cleanup-worker:
<<: *api-runtime
command: ["han-notification-draft-cleanup-worker"]
environment:
<<: *api-environment
OTEL_SERVICE_NAME: notification-draft-cleanup-worker
depends_on:
api-backend: {condition: service_healthy}
healthcheck:
@@ -346,6 +368,10 @@ services:
BITRIX_API_FORWARD_TOKEN_FILE: /run/secrets/bitrix_api_forward_token
BITRIX_TOKEN_ENCRYPTION_KEY_FILE: /run/secrets/bitrix_token_encryption_key
APP_ENV: ${APP_ENV:-production-like}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
OTEL_SERVICE_NAME: bitrix-local-app
BITRIX_CLIENT_ID: ${BITRIX_CLIENT_ID}
BITRIX_CONNECTOR_ID: ${BITRIX_CONNECTOR_ID:-han_mobile_app}
BITRIX_CONNECTOR_NAME: ${BITRIX_CONNECTOR_NAME:-HAN Mobile App}
+4 -2
View File
@@ -51,8 +51,10 @@ non-secret `PUBLIC_WEB_URL` environment variable. Keycloak resolves the
initial `--import-realm`.
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.
wildcards. `${PUBLIC_WEB_URL}/auth/callback`, `${PUBLIC_WEB_URL}/mobile/oidc/callback`
and `han-chat://auth/callback` are allow-listed by the initial realm import.
Android Custom Tabs cannot follow a custom-scheme 302, so the mobile client uses
the HTTPS bridge page and then opens `han-chat://auth/callback`.
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`.
@@ -61,6 +61,7 @@
"fullScopeAllowed": false,
"redirectUris": [
"${PUBLIC_WEB_URL}/auth/callback",
"${PUBLIC_WEB_URL}/mobile/oidc/callback",
"han-chat://auth/callback"
],
"webOrigins": [
@@ -29,6 +29,7 @@ class RealmContractTest {
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}/mobile/oidc/callback\""));
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}\""));
assertFalse(realm.contains("chat.han0107.ru"));
}
@@ -17,6 +17,8 @@ otpResendCountdown=Отправить повторно через
otpResend=Отправить код снова
authBack=Назад
verifyOtp=Подтвердить
otpVerifying=Проверяем...
otpSubmitUnavailable=Не удалось завершить вход. Если код уже принят, закройте окно и откройте приложение.
mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован.
@@ -61,12 +61,18 @@
</button>
</div>
<button id="han-otp-submit" class="han-primary-button" type="submit" disabled>
<div id="han-otp-submit-error" class="han-error han-otp-error" role="alert" hidden>
<span class="han-error-icon">!</span>
<span>${msg("otpSubmitUnavailable")}</span>
</div>
<button id="han-otp-submit" class="han-primary-button" type="submit"
data-submitting-label="${msg("otpVerifying")}" disabled>
${msg("verifyOtp")}
</button>
</form>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
<script src="${url.resourcesPath}/js/han-login.js?v=7"></script>
<#if captchaEnabled!false>
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&amp;onload=hanCaptchaOnload"
async defer></script>
@@ -56,16 +56,28 @@
function initOtpForm() {
var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit"));
var form = document.getElementById("kc-otp-form");
var hidden = document.getElementById("otp");
var submit = document.getElementById("han-otp-submit");
if (!fields.length || !hidden || !submit) return;
var submitError = document.getElementById("han-otp-submit-error");
if (!fields.length || !form || !hidden || !submit) return;
var submitLabel = submit.textContent;
var submitting = false;
function submitForm() {
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
return;
}
HTMLFormElement.prototype.submit.call(form);
}
function syncOtp() {
fields.forEach(function (field) {
field.classList.toggle("han-filled", Boolean(field.value));
});
hidden.value = fields.map(function (field) { return field.value; }).join("");
submit.disabled = hidden.value.length !== fields.length;
submit.disabled = submitting || hidden.value.length !== fields.length;
}
fields.forEach(function (field, index) {
@@ -106,6 +118,22 @@
});
syncOtp();
submit.addEventListener("click", function (event) {
event.preventDefault();
syncOtp();
if (submit.disabled || submitting) return;
submitting = true;
submit.disabled = true;
submit.textContent = submit.getAttribute("data-submitting-label") || submitLabel;
if (submitError) submitError.hidden = true;
submitForm();
window.setTimeout(function () {
submitting = false;
submit.textContent = submitLabel;
syncOtp();
if (submitError) submitError.hidden = false;
}, 12000);
});
var countdown = document.getElementById("han-resend-countdown");
var countdownValue = countdown && countdown.querySelector("strong");
@@ -126,9 +154,17 @@
}
updateCountdown();
if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000);
resend.addEventListener("click", function () {
resend.addEventListener("click", function (event) {
if (document.getElementById("han-captcha-container")) return;
window.setTimeout(function () { resend.disabled = true; }, 0);
event.preventDefault();
resend.disabled = true;
var action = document.createElement("input");
action.type = "hidden";
action.name = "otp_action";
action.value = "resend";
form.appendChild(action);
submitForm();
window.setTimeout(function () { resend.disabled = false; }, 12000);
});
}
@@ -142,8 +178,11 @@
var widgetId = null;
var executing = false;
var pendingSubmitter = null;
var executionTimeout = null;
function showError() {
if (executionTimeout) window.clearTimeout(executionTimeout);
executionTimeout = null;
executing = false;
if (pendingSubmitter) pendingSubmitter.disabled = false;
pendingSubmitter = null;
@@ -162,6 +201,8 @@
invisible: true,
hl: "ru",
callback: function (token) {
if (executionTimeout) window.clearTimeout(executionTimeout);
executionTimeout = null;
if (!token || !pendingSubmitter) {
showError();
return;
@@ -209,6 +250,7 @@
if (pendingSubmitter) pendingSubmitter.disabled = true;
tokenInput.value = "";
try {
executionTimeout = window.setTimeout(showError, 12000);
window.smartCaptcha.execute(widgetId);
} catch (_error) {
showError();
+2 -1
View File
@@ -7,9 +7,10 @@ RUN apt-get update \
COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template
COPY templates /etc/nginx/templates-src/sites
COPY snippets /etc/nginx/snippets
COPY static /etc/nginx/static
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 {} +
&& find /etc/nginx/templates-src /etc/nginx/snippets /etc/nginx/static -type f -exec chmod 0444 {} +
ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"]
@@ -1,6 +1,8 @@
services:
nginx:
image: ${NGINX_IMAGE:?NGINX_IMAGE must be pinned by digest}
labels:
com.han.service.name: nginx
environment:
APP_ENV: ${APP_ENV:-production-like}
PUBLIC_HOST: ${PUBLIC_HOST}
@@ -64,4 +66,7 @@ services:
nofile: {soft: 65536, hard: 65536}
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
options:
max-size: "50m"
max-file: "5"
tag: nginx
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>HAN Chat</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 32px 24px;
background: #fff;
color: #252525;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
text-align: center;
}
p { margin: 0; line-height: 1.5; }
a {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 220px;
height: 56px;
padding: 0 24px;
border-radius: 12px;
background: #030213;
color: #fff;
font-size: 15px;
font-weight: 500;
text-decoration: none;
}
</style>
</head>
<body>
<p>Вход выполнен. Возвращаем в приложение…</p>
<p>
<a id="han-open-app" href="han-chat://auth/callbackHAN_CALLBACK_QUERY">Открыть HAN Chat</a>
</p>
<script src="/mobile/oidc/callback.js"></script>
</body>
</html>
@@ -0,0 +1,6 @@
(function () {
var target = "han-chat://auth/callback" + window.location.search + window.location.hash;
var link = document.getElementById("han-open-app");
if (link) link.href = target;
window.location.replace(target);
})();
@@ -73,7 +73,9 @@ server {
proxy_cache_methods GET HEAD;
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization $upstream_http_set_cookie;
proxy_cache_valid 200 1h;
proxy_cache_revalidate on;
proxy_cache_valid 200 60s;
proxy_cache_valid 304 60s;
proxy_pass http://api_backend;
}
location = /api/v1/public/content {
@@ -139,6 +141,23 @@ server {
add_header Cache-Control "no-cache";
include /etc/nginx/generated/security-headers.conf;
}
location = /mobile/oidc/callback {
default_type text/html;
charset utf-8;
alias /etc/nginx/static/mobile-oidc-callback.html;
sub_filter_once on;
sub_filter_types text/html;
sub_filter HAN_CALLBACK_QUERY ?$args;
add_header Cache-Control "no-store" always;
include /etc/nginx/generated/security-headers.conf;
}
location = /mobile/oidc/callback.js {
default_type text/javascript;
charset utf-8;
alias /etc/nginx/static/mobile-oidc-callback.js;
add_header Cache-Control "no-store" always;
include /etc/nginx/generated/security-headers.conf;
}
location ^~ /auth/resources/ {
include /etc/nginx/snippets/proxy-keycloak.conf;
proxy_pass http://keycloak_upstream;
@@ -21,6 +21,7 @@ services:
environment:
APP_ENV: ${APP_ENV:-production-like}
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
HOST_NAME: ${HOST_NAME:?HOST_NAME is required}
OTEL_REMOTE_ENDPOINT: ${OTEL_REMOTE_ENDPOINT}
OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}
expose: ["4317", "4318", "13133", "8888"]
@@ -54,6 +54,7 @@ processors:
- {key: service.namespace, value: han-chat, action: upsert}
- {key: deployment.environment, value: "${env:APP_ENV}", action: upsert}
- {key: service.version, value: "${env:RELEASE_VERSION}", action: upsert}
- {key: host.name, value: "${env:HOST_NAME}", action: upsert}
attributes/redact:
actions:
- {key: http.request.header.authorization, action: delete}
@@ -1,6 +1,8 @@
services:
redis:
image: ${REDIS_IMAGE:?REDIS_IMAGE must be pinned by digest}
labels:
com.han.service.name: redis
environment:
REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-384mb}
REDIS_EVICTION_POLICY: ${REDIS_EVICTION_POLICY:-volatile-lru}
@@ -32,7 +34,10 @@ services:
nofile: {soft: 65536, hard: 65536}
logging:
driver: json-file
options: {max-size: "50m", max-file: "5"}
options:
max-size: "50m"
max-file: "5"
tag: redis
secrets:
redis_api_password:
+20 -4
View File
@@ -13,12 +13,13 @@ from urllib.parse import urlparse
REQUIRED_CONFIG = {
"SECRETS_SOURCE", "APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "HAN_PG_PORT",
"HAN_PG_DATABASE", "PG_CA_HOST_PATH", "KEYCLOAK_DB_URL", "KEYCLOAK_DB_USERNAME",
"PUBLIC_HOST", "PUBLIC_WEB_URL",
"PUBLIC_HOST", "PUBLIC_WEB_URL", "HOST_NAME",
"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",
"OTEL_REMOTE_ENDPOINT", "OTEL_REMOTE_TLS_INSECURE", "TELEMETRYGEN_IMAGE",
}
REQUIRED_RUNTIME = {
"DATABASE_URL", "BITRIX_DATABASE_URL",
@@ -197,14 +198,29 @@ def validate_shared(env: dict[str, str], errors: list[str]) -> None:
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":
safety_url = env.get("MESSAGE_SAFETY_URL", "").rstrip("/")
parsed_safety_url = urlparse(safety_url)
try:
safety_port = parsed_safety_url.port
except ValueError:
safety_port = None
safety_host = parsed_safety_url.hostname or ""
if (
parsed_safety_url.scheme != "https"
or safety_port != 8443
or not re.fullmatch(r"[A-Za-z0-9.-]+", safety_host)
or safety_host.lower() in {"message-safety", "localhost", "127.0.0.1"}
or parsed_safety_url.path
or parsed_safety_url.params
or parsed_safety_url.query
or parsed_safety_url.fragment
):
errors.append(
"MESSAGE_SAFETY_URL: ожидается remote TLS endpoint "
"https://processing.internal:8443"
"https://<private VM2 hostname>:8443, не local/stub service"
)
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
@@ -1,7 +1,7 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from collections.abc import Mapping, MutableMapping
from typing import Any
REDACTED = "[REDACTED]"
@@ -42,6 +42,6 @@ def sanitize_value(value: Any) -> Any:
def redact_event(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
event_dict: MutableMapping[str, Any],
) -> MutableMapping[str, Any]:
return sanitize_value(event_dict)
@@ -32,13 +32,18 @@ from app.service import (
read_message,
)
from app.settings import get_settings
from app.telemetry import add_trace_context, init_telemetry, instrument_fastapi
from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry, instrument_fastapi
log = structlog.get_logger()
def configure_logging(level: str) -> None:
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
logging.basicConfig(level=level, format="%(message)s")
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
telemetry.logging_handler.addFilter(
lambda record: not record.name.startswith("opentelemetry")
)
logging.getLogger().addHandler(telemetry.logging_handler)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
@@ -47,7 +52,10 @@ def configure_logging(level: str) -> None:
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
@@ -55,7 +63,7 @@ def configure_logging(level: str) -> None:
async def lifespan(app: FastAPI):
settings = get_settings()
telemetry = init_telemetry()
configure_logging(settings.log_level)
configure_logging(settings.log_level, telemetry)
app.state.settings = settings
app.state.db = Database(settings.database_url)
try:
@@ -1,17 +1,23 @@
from __future__ import annotations
import logging
import os
from collections.abc import MutableMapping
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
@@ -25,8 +31,11 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapProp
class TelemetryRuntime:
tracer_provider: TracerProvider
meter_provider: MeterProvider
logger_provider: LoggerProvider
logging_handler: LoggingHandler
def shutdown(self) -> None:
self.logger_provider.shutdown()
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
@@ -53,7 +62,9 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
if not endpoint:
return None
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "sms-service"))
resource = _resource(
service_name or os.getenv("OTEL_SERVICE_NAME", "sms-service") or "sms-service"
)
insecure = endpoint.startswith("http://")
set_global_textmap(TraceContextTextMapPropagator())
@@ -77,9 +88,27 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
set_logger_provider(logger_provider)
logging_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
_runtime = TelemetryRuntime(
tracer_provider,
meter_provider,
logger_provider,
logging_handler,
)
return _runtime
@@ -93,8 +122,8 @@ def instrument_fastapi(app: FastAPI) -> None:
def add_trace_context(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
event_dict: MutableMapping[str, Any],
) -> MutableMapping[str, Any]:
context = trace.get_current_span().get_span_context()
if context.is_valid:
event_dict["trace_id"] = format(context.trace_id, "032x")
@@ -10,6 +10,7 @@ from datetime import UTC, datetime, timedelta
import httpx
import structlog
from opentelemetry import trace
from opentelemetry.propagate import extract
from prometheus_client import start_http_server
from sqlalchemy import and_, func, or_, select, update
@@ -26,14 +27,27 @@ from app.metrics import (
from app.provider import IdgtlClient, IdgtlConfig
from app.service import RuntimeSettings, load_runtime_settings
from app.settings import Settings, get_settings
from app.telemetry import add_trace_context, init_telemetry
from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry
log = structlog.get_logger()
MAX_CONNECT_ATTEMPTS = 3
def configure_logging(level: str) -> None:
def origin_links(traceparent: str | None) -> list[trace.Link]:
if not traceparent:
return []
context = extract({"traceparent": traceparent})
span_context = trace.get_current_span(context).get_span_context()
return [trace.Link(span_context)] if span_context.is_valid else []
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
logging.basicConfig(level=level, format="%(message)s")
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
telemetry.logging_handler.addFilter(
lambda record: not record.name.startswith("opentelemetry")
)
logging.getLogger().addHandler(telemetry.logging_handler)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
@@ -42,7 +56,10 @@ def configure_logging(level: str) -> None:
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
@@ -177,7 +194,7 @@ async def update_queue_metrics(db: Database) -> None:
async def worker_loop(stop: asyncio.Event) -> None:
settings = get_settings()
telemetry = init_telemetry("sms-worker")
configure_logging(settings.log_level)
configure_logging(settings.log_level, telemetry)
structlog.contextvars.bind_contextvars(**{"service.name": "sms-worker"})
metrics_server, metrics_thread = start_http_server(
settings.metrics_port,
@@ -200,8 +217,17 @@ async def worker_loop(stop: asyncio.Event) -> None:
await update_queue_metrics(db)
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
continue
process_span = tracer.start_span("sms.process", start_time=claim_started_ns)
with trace.use_span(process_span, end_on_exit=True):
process_span = tracer.start_span(
"sms.process",
start_time=claim_started_ns,
links=origin_links(message.traceparent),
)
with (
trace.use_span(process_span, end_on_exit=True),
structlog.contextvars.bound_contextvars(
sms_message_id=str(message.id)
),
):
claim_span = tracer.start_span("sms.claim", start_time=claim_started_ns)
claim_span.set_attribute("sms.claimed", True)
claim_span.end(end_time=claim_finished_ns)
@@ -1,6 +1,7 @@
from opentelemetry.sdk.trace import TracerProvider
from app import telemetry
from app.worker import origin_links
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
@@ -19,3 +20,8 @@ def test_structlog_processor_adds_active_trace_context() -> None:
assert result["event"] == "sms.safe"
assert len(result["trace_id"]) == 32
assert len(result["span_id"]) == 16
def test_sms_worker_rejects_invalid_origin_context() -> None:
assert origin_links(None) == []
assert origin_links("00-invalid") == []
+55 -1
View File
@@ -348,9 +348,63 @@ class InfrastructureConfigTests(unittest.TestCase):
source = service_main.read_text(encoding="utf-8")
self.assertNotIn("route=request.url.path", source)
self.assertIn('getattr(request.scope.get("route"), "path"', source)
for telemetry_path in (
ROOT / "api-backend/app/telemetry.py",
ROOT / "sms-service/app/telemetry.py",
ROOT / "bitrix-local-app/app/telemetry.py",
):
telemetry = telemetry_path.read_text(encoding="utf-8")
self.assertIn("OTLPLogExporter", telemetry)
self.assertIn("BatchLogRecordProcessor", telemetry)
self.assertIn("LoggingHandler", telemetry)
worker = (ROOT / "sms-service/app/worker.py").read_text(encoding="utf-8")
for span_name in ("sms.claim", "sms.process", "sms.provider", "sms.save_result"):
self.assertIn(f'"{span_name}"', worker)
bitrix = (ROOT / "bitrix-local-app/app/main.py").read_text(encoding="utf-8")
self.assertIn('EXPECTED_BITRIX_DB_REVISION = "0002_inbox_trace_context"', bitrix)
self.assertIn("links=origin_links(row.traceparent)", bitrix)
bitrix_migration = (
ROOT / "bitrix-local-app/alembic/versions/0002_inbox_trace_context.py"
).read_text(encoding="utf-8")
self.assertIn('down_revision: str | None = "0001_bitrix_local"', bitrix_migration)
def test_host_otel_collector_is_hardened_and_allowlisted(self) -> None:
config = (
ROOT / "deployment/observability/otel-host-collector.yaml"
).read_text(encoding="utf-8")
unit = (ROOT / "deployment/han-host-otel-collector@.service").read_text(
encoding="utf-8"
)
setup = (ROOT / "deployment/scripts/setup-vm.sh").read_text(encoding="utf-8")
for required in (
"filelog/docker",
"journald/host",
"filter/allowlist",
"storage: file_storage",
'layout: "2006-01-02T15:04:05.000000000Z07:00"',
'set(body, "nginx.access")',
'set(body, "keycloak.event")',
'{key: uri, action: delete}',
'resource.attributes["service.name"] != "nginx"',
'resource.attributes["service.name"] != "keycloak"',
'resource.attributes["service.name"] != "redis"',
):
self.assertIn(required, config)
self.assertNotIn("/var/run/docker.sock", config + unit)
self.assertNotIn('from: attributes["service.name"]', config)
self.assertIn("CapabilityBoundingSet=", unit)
self.assertIn("NoNewPrivileges=yes", unit)
self.assertIn("ReadOnlyPaths=/var/lib/docker/containers", unit)
self.assertIn("ReadWritePaths=/var/lib/han-otel/host-collector", unit)
self.assertIn("OTEL_HOST_COLLECTOR_SHA256", setup)
self.assertIn("sha256sum -c -", setup)
self.assertIn("journalctl --flush", setup)
self.assertNotIn("systemctl start han-host-otel-collector", setup)
verify = (
ROOT / "deployment/scripts/verify-observability.sh"
).read_text(encoding="utf-8")
self.assertIn("TELEMETRYGEN_IMAGE", verify)
self.assertNotIn("telemetrygen:latest", verify)
def test_settings_cli_and_workers_are_deployable(self) -> None:
cli = ROOT / "api-backend/app/cli"
@@ -442,7 +496,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 = "0012_safety_v2_checkpoint"', main)
self.assertIn('EXPECTED_API_DB_REVISION = "0013_delivery_trace_context"', main)
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
migration = (
@@ -152,6 +152,28 @@ class SecretHygieneTests(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertIn(expected_error, result.stderr)
def test_validator_accepts_configured_private_safety_hostname(self) -> None:
example = (ROOT / ".env.example").read_text(encoding="utf-8")
configured = (
example.replace(
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
)
.replace(
"MESSAGE_SAFETY_URL=https://processing.internal:8443",
"MESSAGE_SAFETY_URL=https://safety.vm2.corp.internal:8443",
)
.replace(
"MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4",
"MESSAGE_SAFETY_EXTRA_HOST=safety.vm2.corp.internal=192.168.0.4",
)
)
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / ".env"
config.write_text(configured, encoding="utf-8")
result = self.run_validator(config)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()
@@ -960,7 +960,7 @@ Inbound operator file:
- validate count/size/MIME and URL scheme/host policy;
- protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming;
- download with timeout to temporary stream, never local persistent disk;
- Message Safety/ClamAV не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP;
- Message Safety/KESL не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP;
- upload directly to S3-data attachments;
- only then atomically save attachment/message and ack inbox.
@@ -1085,7 +1085,7 @@ Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый
`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04.
В production validator принимает только remote `https://<private-vm2-name>:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и cross-host Docker hostname. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`.
В production validator принимает remote `https://<private-vm2-name>:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и local/stub Docker hostname. Конкретное private DNS-имя ВМ2 определяется внутренним доменом окружения и задаётся в `MESSAGE_SAFETY_URL` и согласованном `MESSAGE_SAFETY_EXTRA_HOST`; каноническое имя не требуется. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`.
## 19. Rate limiting
@@ -19,6 +19,7 @@ Nginx ВМ1 — публичная точка входа приложения: f
|---|---|---|
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
| `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer |
| exact `/mobile/oidc/callback` | static nginx | HTTPS-мост Android Custom Tabs → `han-chat://auth/callback` |
| exact `/callbacks/idgtl/sms` | `sms-service:8080` | public HTTPS POST Direct; IP allowlist + Basic auth в upstream |
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
@@ -111,7 +111,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
"occurred_at": "2026-07-10T09:00:00Z",
"user": {
"id": "uuid",
"display_name": "Клиент HAN"
"display_name": "Новый клиент HAN",
"phone": "+79990000000"
},
"message": {
"content_kind": "text",
@@ -128,7 +129,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
"message_id": "uuid",
"external_chat_id": "uuid",
"occurred_at": "2026-07-10T09:00:00Z",
"user": {"id": "uuid", "display_name": "Клиент HAN"},
"user": {"id": "uuid", "display_name": "Новый клиент HAN", "phone": "+79990000000"},
"message": {
"content_kind": "file",
"text": "",
@@ -148,7 +149,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
- `external_chat_id` строго UUID и равен App `dialog_id`;
- `text` xor один file; unknown fields запрещены;
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
- PII профиля не требуется; телефон/email не передаются;
- `display_name``ClientProfile.full_name`, если пусто — `Новый клиент HAN`;
- телефон клиента передаётся в `user.phone` и мапится в `MESSAGES[0][user][phone]` для CRM-лида; email не передаётся;
- fingerprint строится по стабильным полям без signed query;
- тот же key/fingerprint возвращает прежний результат;
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
@@ -198,7 +200,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
2. При completed вернуть сохранённый sanitized result.
3. Собрать `MESSAGES` Bitrix: `user.id`, `message.id/date/text/files`, `chat.id`.
3. Собрать `MESSAGES` Bitrix: `user.id/name/phone`, `message.id/date/text/files`, `chat.id`.
4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`.
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.
@@ -92,6 +92,7 @@ han-chat-frontend
```text
https://tohin.ru/auth/callback
https://tohin.ru/mobile/oidc/callback
han-chat://auth/callback
<Expo native scheme/callback, exact value после сборки>
```
@@ -8,7 +8,7 @@
Документ задаёт, **что агент ВМ1 реализует в Compose, коде, тестах и алертах этой машины**.
ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync`, ClamAV и Redis Safety живут на ВМ2; ВМ1 только вызывает Safety по private HTTPS и продолжает trace.
ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync` и Redis Safety живут на ВМ2; KESL 12.4 и его локальный scan-broker работают на host ВМ2. ВМ1 только вызывает Safety по private HTTPS и продолжает trace.
Агент ВМ1 не добавляет scrape, дашборды и алерты сервисов ВМ2.
@@ -22,8 +22,14 @@
| Keycloak | `keycloak` |
| `sms-service` | `sms-service` |
| `sms-worker` | `sms-worker` |
| delivery worker | `delivery-worker` |
| safety recovery worker | `safety-recovery-worker` |
| quarantine cleanup worker | `cleanup-worker` |
| notification expiration worker | `notification-expire-worker` |
| notification draft cleanup worker | `notification-draft-cleanup-worker` |
| Redis DB0/DB1 | `redis` |
| local Collector | `otel-collector` |
| host telemetry agent | `otel-host-collector` |
SMS-метрики и алерты детализированы в [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md); имена зарегистрированы в arch-07 §4.
@@ -36,6 +42,22 @@ SMS-метрики и алерты детализированы в [`module-11-i
- export в SigNoz `192.168.0.5:4317`; hostname collector ВМ2 не используется;
- pipeline, processors, limits, `otel-queue-init` и fail-open — arch-07 §3, §13, §14.
Python-сервисы экспортируют structured logs через OTLP Logs SDK в Compose
Collector. JSON stdout сохраняется только как bounded аварийный buffer и не
собирается повторно.
Отдельный hardened host agent собирает по allow-list:
- Docker `json-file` для nginx, Keycloak и Redis;
- journal units `docker`, `han-stack`, `han-secrets`, Docker firewall,
certbot и fail2ban;
- UFW log, если он включён на host.
Host agent не имеет доступа к Docker socket, исключает собственные логи и
Compose Collector, хранит offsets/queue в `/var/lib/han-otel/host-collector`
и экспортирует напрямую в тот же SigNoz. Platform events без активного span
не получают искусственные trace IDs и ищутся по host/service/time window.
Scrape targets ВМ1 (кроме самого Collector): Keycloak management metrics, Redis exporter приложения, nginx exporter, сервисные `/metrics` `api-backend` и `bitrix-local-app`, если они не идут OTLP.
## 4. Instrumentation
@@ -49,7 +49,9 @@ webhooks собственного host. Compose, IAM principal и secret bundle
performance, egress и rollback gates своего runbook.
2. ВМ1 использует
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443` и root-owned
`MESSAGE_SAFETY_CA_HOST_PATH`.
`MESSAGE_SAFETY_CA_HOST_PATH`. Значение `<VM2_PRIVATE_DNS>` определяется
внутренним DNS-доменом окружения и задаётся в `.env`; фиксированное
каноническое имя не требуется.
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
Binary file not shown.
+3
View File
@@ -10,9 +10,11 @@ import { AuthOtp } from './pages/AuthOtp';
import { AuthLoading } from './pages/AuthLoading';
import { AuthConsent } from './pages/AuthConsent';
import { Root } from './pages/Root';
import { UpdateProvider } from './contexts/UpdateContext';
export default function App() {
return (
<UpdateProvider>
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Root />}>
@@ -30,5 +32,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MemoryRouter>
</UpdateProvider>
);
}
@@ -0,0 +1,170 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ArrowUpCircle, AlertTriangle, X } from "lucide-react";
import { useUpdate } from "../contexts/UpdateContext";
export function UpdateModal() {
const { config, dismissUpdate } = useUpdate();
if (!config) return null;
const isStrict = config.type === "strict";
const handleUpdate = () => {
if (config.updateUrl) {
window.open(config.updateUrl, "_blank", "noopener,noreferrer");
}
// В strict-режиме не закрываем — пользователь обязан обновиться
if (!isStrict) {
dismissUpdate();
}
};
const handleOpenChange = (open: boolean) => {
if (!open && !isStrict) {
dismissUpdate();
}
// В strict-режиме игнорируем попытки закрыть
};
return (
<DialogPrimitive.Root open={true} onOpenChange={handleOpenChange}>
<DialogPrimitive.Portal>
{/* Оверлей: в strict-режиме pointer-events-none отключаем клик по фону */}
<DialogPrimitive.Overlay
className={[
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
isStrict ? "pointer-events-none" : "",
].join(" ")}
/>
<DialogPrimitive.Content
// В strict-режиме блокируем закрытие по Escape и клику вне
onEscapeKeyDown={(e) => isStrict && e.preventDefault()}
onPointerDownOutside={(e) => isStrict && e.preventDefault()}
onInteractOutside={(e) => isStrict && e.preventDefault()}
className={[
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
"w-full max-w-[360px] rounded-2xl border border-border bg-background shadow-2xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"focus:outline-none",
].join(" ")}
>
{/* Иконка и шапка */}
<div
className={[
"flex flex-col items-center gap-3 rounded-t-2xl px-6 pt-7 pb-5",
isStrict
? "bg-destructive/10"
: "bg-primary/5",
].join(" ")}
>
<div
className={[
"flex items-center justify-center w-14 h-14 rounded-full",
isStrict
? "bg-destructive/15 text-destructive"
: "bg-primary/10 text-primary",
].join(" ")}
>
{isStrict ? (
<AlertTriangle className="w-7 h-7" strokeWidth={2} />
) : (
<ArrowUpCircle className="w-7 h-7" strokeWidth={2} />
)}
</div>
<div className="text-center">
<DialogPrimitive.Title className="text-lg font-semibold text-foreground leading-tight">
{isStrict ? "Обновление обязательно" : "Доступно обновление"}
</DialogPrimitive.Title>
<p className="mt-1 text-sm text-muted-foreground">
Версия{" "}
<span className="font-medium text-foreground">
{config.newVersion}
</span>
</p>
</div>
</div>
{/* Тело */}
<div className="px-6 py-5 space-y-4">
<DialogPrimitive.Description className="text-sm text-muted-foreground leading-relaxed text-center">
{isStrict ? (
<>
Версия <strong>{config.currentVersion}</strong> больше не
поддерживается. Для продолжения работы необходимо установить
обновление.
</>
) : (
<>
Вышла новая версия приложения. Вы можете обновить его сейчас
или сделать это позже.
</>
)}
</DialogPrimitive.Description>
{config.releaseNotes && (
<div className="rounded-xl bg-muted/60 px-4 py-3 text-xs text-muted-foreground leading-relaxed">
{config.releaseNotes}
</div>
)}
{/* Плашка текущей / новой версии */}
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1">
{config.currentVersion}
</span>
<span></span>
<span
className={[
"rounded-md px-2 py-1 font-medium",
isStrict
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
].join(" ")}
>
{config.newVersion}
</span>
</div>
</div>
{/* Кнопки */}
<div className="px-6 pb-6 flex flex-col gap-2">
<button
onClick={handleUpdate}
className={[
"w-full rounded-xl py-3 text-sm font-semibold transition-opacity active:opacity-80",
isStrict
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground",
].join(" ")}
>
Обновить приложение
</button>
{!isStrict && (
<button
onClick={dismissUpdate}
className="w-full rounded-xl py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
Позже
</button>
)}
</div>
{/* Крестик только в soft-режиме */}
{!isStrict && (
<DialogPrimitive.Close
onClick={dismissUpdate}
className="absolute right-4 top-4 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none"
aria-label="Закрыть"
>
<X className="w-4 h-4" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
@@ -0,0 +1,43 @@
import React, { createContext, useContext, useState, useCallback } from "react";
export type UpdateType = "soft" | "strict";
export interface UpdateConfig {
type: UpdateType;
currentVersion: string;
newVersion: string;
updateUrl?: string;
releaseNotes?: string;
}
interface UpdateContextValue {
config: UpdateConfig | null;
showUpdate: (cfg: UpdateConfig) => void;
dismissUpdate: () => void;
}
const UpdateContext = createContext<UpdateContextValue | null>(null);
export function UpdateProvider({ children }: { children: React.ReactNode }) {
const [config, setConfig] = useState<UpdateConfig | null>(null);
const showUpdate = useCallback((cfg: UpdateConfig) => {
setConfig(cfg);
}, []);
const dismissUpdate = useCallback(() => {
setConfig(null);
}, []);
return (
<UpdateContext.Provider value={{ config, showUpdate, dismissUpdate }}>
{children}
</UpdateContext.Provider>
);
}
export function useUpdate() {
const ctx = useContext(UpdateContext);
if (!ctx) throw new Error("useUpdate must be used inside UpdateProvider");
return ctx;
}
+31
View File
@@ -3,13 +3,44 @@ import { Notifications } from '../components/Notifications';
import { PopularQuestions } from '../components/PopularQuestions';
import { ChatInput } from '../components/ChatInput';
import { QuickActions } from '../components/QuickActions';
import { useUpdate } from '../contexts/UpdateContext';
export function Home() {
const { showUpdate } = useUpdate();
return (
<>
{/* Компактный логотип HAN */}
<HanLogo />
{/* Demo: триггеры для тестирования диалогов обновления */}
<div className="px-4 py-2 flex gap-2">
<button
onClick={() =>
showUpdate({
type: "soft",
currentVersion: "2.4.1",
newVersion: "2.5.0",
})
}
className="flex-1 rounded-xl border border-border bg-muted/40 py-2 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors"
>
Soft update
</button>
<button
onClick={() =>
showUpdate({
type: "strict",
currentVersion: "1.8.0",
newVersion: "2.5.0",
})
}
className="flex-1 rounded-xl border border-destructive/30 bg-destructive/5 py-2 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors"
>
Strict update
</button>
</div>
{/* Уведомления от компании */}
<div className="flex-1 overflow-y-auto">
<Notifications />
+3
View File
@@ -1,5 +1,6 @@
import { Outlet, useLocation } from 'react-router';
import { Header } from '../components/Header';
import { UpdateModal } from '../components/UpdateModal';
export function Root() {
const location = useLocation();
@@ -14,6 +15,8 @@ export function Root() {
<Outlet />
</div>
<UpdateModal />
</div>
);
}
Binary file not shown.
Binary file not shown.
+8 -6
View File
@@ -1,13 +1,15 @@
# Non-secret VM2 deployment manifest. Never add DSNs, passwords, tokens or keys.
APP_ENV=production-like
RELEASE_VERSION=<immutable-release>
LOG_LEVEL=INFO
SECRETS_SOURCE=selectel
MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:<reviewed-digest>
REDIS_IMAGE=redis@sha256:<reviewed-digest>
CLAMAV_IMAGE=clamav/clamav@sha256:<reviewed-digest>
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:<reviewed-digest>
REDIS_EXPORTER_IMAGE=oliver006/redis_exporter@sha256:<reviewed-digest>
NGINX_EXPORTER_IMAGE=nginx/nginx-prometheus-exporter@sha256:<reviewed-digest>
PROCESSING_PUBLIC_HOST=<processing-public-host>
PROCESSING_PRIVATE_BIND_ADDRESS=<vm2-private-ip>
@@ -17,8 +19,7 @@ MESSAGE_SAFETY_HOST=0.0.0.0
MESSAGE_SAFETY_PORT=8080
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
MESSAGE_SAFETY_CLAMAV_HOST=clamd
MESSAGE_SAFETY_CLAMAV_PORT=3310
MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env
@@ -27,9 +28,10 @@ SELECTEL_S3_BUCKET_QUARANTINE=<quarantine-bucket>
BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_MODE=disabled
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<digits>
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<digits>
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_<digits>
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<latin_letters_or_digits>
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<latin_letters_or_digits>
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_<latin_letters_or_digits>
BITRIX_SYNC_CONTACT_SOURCE=<bitrix-contact-source-id>
BITRIX_SYNC_PORTAL_HOST=<approved-portal>.bitrix24.ru
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
@@ -11,6 +11,12 @@ BITRIX_SYNC_PUBLIC_BASE_URL=https://sync.example.ru
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_100
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_101
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_102
BITRIX_SYNC_CONTACT_SOURCE=WEB
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS=203.0.113.0/24
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
BITRIX_SYNC_DB_POOL_SIZE=5
# Optional: without this endpoint the service keeps JSON stdout and runs normally.
OTEL_EXPORTER_OTLP_ENDPOINT=
RELEASE_VERSION=unknown
APP_ENV=production-like
LOG_LEVEL=INFO
@@ -15,4 +15,4 @@ COPY --chown=10001:10001 alembic.ini openapi.yaml /srv/
WORKDIR /srv
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["han-bitrix-sync-api"]
CMD ["han-bitrix-sync-api"]
@@ -16,6 +16,22 @@ Disabled mode требует только `BITRIX_SYNC_ENABLED=false` и
валидирует весь каталог secret files, portal identity, custom fields, HTTPS host
lock и непустой CIDR allow-list до startup.
## Наблюдаемость
JSON stdout включён всегда. Если задан стандартный
`OTEL_EXPORTER_OTLP_ENDPOINT`, traces, metrics и логи дополнительно отправляются
напрямую по OTLP/gRPC через bounded batch queues. Ошибка инициализации, экспорта
или остановки телеметрии не меняет readiness и не останавливает API, worker или
reconciliation. Процессы различаются как `bitrix-sync-api`,
`bitrix-sync-worker` и `bitrix-sync-reconciliation`; namespace остаётся
`han-chat`.
FastAPI (кроме `/health/live`), SQLAlchemy и HTTPX инструментированы. Перед
stdout и OTLP выполняется application redaction: query/token/credential URL,
headers, payload, PII, object keys и SQL не экспортируются. Бизнес-метрики
используют только закрытые множества labels; UUID и внешние идентификаторы в
labels/spans не записываются.
## Границы безопасности
- CRM credential URL используется как единый секрет; redirect выключен, TLS
@@ -54,6 +70,15 @@ gates: локальный managed PostgreSQL не поднимается Compose
4. Заполнить и активировать валидную `business_alerts` settings version:
entity/category/stage/field IDs и responsible party. Placeholder `null`
запрещает alert receiver.
`value_json` настройки `business_alerts` использует ключи `entity_type_id`,
`category_id`, `stage_new`, опциональный `responsible_id` и объект `field_ids`.
В `field_ids` REST-имена пользовательских полей сопоставляются ключам
`alert_number`, `fingerprint`, `alert_type`, `severity`, `app_user_id`,
`selected_external_id`, `occurrence_count`,
`first_occurred_at`, `last_occurred_at`, `workflow_id`.
Конфликтующие Contact передаются в стандартное поле `contactIds`, а значение
`BITRIX_SYNC_CONTACT_SOURCE` — в стандартное поле `sourceId`.
5. Проверить least-privilege credential negative tests; credential администратора
запрещён.
6. Валидировать nginx exact routes, no-redirect HTTP policy, body/rate limits,
@@ -9,7 +9,7 @@ from urllib.parse import urlsplit
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
FIELD_RE = re.compile(r"^UF_CRM_[0-9]+$")
FIELD_RE = re.compile(r"^UF_CRM_[A-Za-z0-9]+$")
MEMBER_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
@@ -46,6 +46,7 @@ class Settings(BaseSettings):
contact_user_id_field: str | None = None
contact_registered_field: str | None = None
contact_citizenship_field: str | None = None
contact_source: str | None = Field(default=None, min_length=1, max_length=128)
webhook_allowed_cidrs: str = ""
http_timeout_sec: float = Field(default=10, ge=1, le=60)
@@ -97,6 +98,7 @@ class Settings(BaseSettings):
"contact_user_id_field": self.contact_user_id_field,
"contact_registered_field": self.contact_registered_field,
"contact_citizenship_field": self.contact_citizenship_field,
"contact_source": self.contact_source,
}
missing = [name for name, value in required.items() if not value]
if missing:
@@ -107,9 +109,13 @@ class Settings(BaseSettings):
"contact_citizenship_field",
):
if not FIELD_RE.fullmatch(str(getattr(self, name))):
raise ValueError(f"{name} must match UF_CRM_<digits>")
raise ValueError(f"{name} must match UF_CRM_<latin letters or digits>")
if not MEMBER_RE.fullmatch(str(self.portal_member_id)):
raise ValueError("portal_member_id has invalid format")
assert self.contact_source
self.contact_source = self.contact_source.strip()
if not self.contact_source:
raise ValueError("contact_source cannot be blank")
crm = urlsplit(self.crm_rest_webhook_url.get_secret_value())
public = urlsplit(str(self.public_base_url))
@@ -5,10 +5,14 @@ import ssl
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
from time import monotonic
from typing import Any
from urllib.parse import urljoin, urlsplit
import httpx
from opentelemetry.trace import SpanKind
from app.telemetry import record_crm, record_retry, safe_span
class CrmOutcome(StrEnum):
@@ -49,6 +53,28 @@ class CrmClient:
await self._client.aclose()
async def call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
started = monotonic()
outcome = "error"
operation = method.rsplit(".", 1)[-1]
try:
with safe_span(
"bitrix_sync.crm.request",
kind=SpanKind.CLIENT,
attributes={"crm.operation": operation},
):
result = await self._call(method, params, mutating=mutating)
outcome = result.outcome.value
if result.outcome in {
CrmOutcome.RETRY,
CrmOutcome.UNCERTAIN,
CrmOutcome.RATE_LIMITED,
}:
record_retry("crm")
return result
finally:
record_crm(method, outcome, monotonic() - started)
async def _call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
if method not in ALLOWED_METHODS:
raise ValueError("unapproved CRM method")
url = urljoin(self._base_url, method + ".json")
@@ -5,7 +5,8 @@ import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import text
from sqlalchemy import bindparam, text
from sqlalchemy.dialects.postgresql import JSONB
from app.config import Settings
from app.crm import CrmClient, CrmOutcome, CrmResult
@@ -14,10 +15,38 @@ from app.domain import (
choose_newest,
full_jitter_delay,
parse_crm_datetime,
safe_hash,
select_email,
validate_phone,
)
from app.repository import LeasedTask, LeasedWebhook, Profile, Repository
from app.telemetry import (
record_business_alert,
record_dead_letter,
record_limiter,
record_retry,
record_transition,
)
INSERT_CRM_COMMAND = text(
"""
INSERT INTO bitrix_sync.crm_commands
(id,workflow_id,command_type,safe_request,status,attempt_count,
next_attempt_at,created_at,updated_at)
VALUES (:id,:workflow_id,:type,:safe_request,'in_flight',1,now(),now(),now())
"""
).bindparams(bindparam("safe_request", type_=JSONB()))
UPDATE_CRM_COMMAND = text(
"""
UPDATE bitrix_sync.crm_commands
SET status=:status,safe_error_code=:error,http_status=:http_status,
safe_response=:safe_response,
completed_at=CASE WHEN :terminal THEN now() END,
updated_at=now()
WHERE id=:id
"""
).bindparams(bindparam("safe_response", type_=JSONB()))
class BusinessConflict(Exception):
@@ -59,6 +88,7 @@ class WorkflowEngine:
await self._manual(workflow_id, exc.code, task.user_id)
await self.repository.complete_task(task, workflow_id)
except RetryableWorkflow as exc:
record_retry("task")
delay = exc.retry_after or full_jitter_delay(
task.attempt_count + 1,
self.settings.retry_base_seconds,
@@ -178,6 +208,14 @@ class WorkflowEngine:
alert_code: str | None = None
if same_user:
selected_id = str(same_user["ID"])
if len(contacts) > 1:
await self._alert(
workflow_id,
profile.user_id,
"duplicate_contacts",
[str(item["ID"]) for item in contacts],
selected_external_id=selected_id,
)
elif not contacts:
selected_id = await self._create_contact(workflow_id, profile)
else:
@@ -193,15 +231,27 @@ class WorkflowEngine:
assert selected is not None
all_ids = [item.b24_id for item in candidates]
if selected.crm_user_id and selected.crm_user_id != str(profile.user_id):
selected_id = await self._create_contact(workflow_id, profile)
alert_code = "contact_owned_by_other_user"
selected_id = await self._create_contact(workflow_id, profile)
await self._alert(
workflow_id,
profile.user_id,
alert_code,
[*all_ids, selected_id],
selected_external_id=selected_id,
)
else:
selected_id = selected.b24_id
await self._write_identity(workflow_id, selected_id, profile.user_id, active=True)
if len(candidates) > 1:
alert_code = "duplicate_contacts"
if alert_code:
await self._alert(workflow_id, profile.user_id, alert_code, all_ids)
await self._alert(
workflow_id,
profile.user_id,
alert_code,
all_ids,
selected_external_id=selected_id,
)
await self._write_identity(workflow_id, selected_id, profile.user_id, active=True)
await self._activate_mapping(workflow_id, profile.user_id, selected_id)
async def _create_contact(self, workflow_id: uuid.UUID, profile: Profile) -> str:
@@ -452,14 +502,7 @@ class WorkflowEngine:
command_id = uuid.uuid4()
async with self.repository.transaction() as connection:
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.crm_commands
(id,workflow_id,command_type,safe_request,status,attempt_count,
next_attempt_at,created_at,updated_at)
VALUES (:id,:workflow_id,:type,:safe_request,'in_flight',1,now(),now(),now())
"""
),
INSERT_CRM_COMMAND,
{
"id": command_id,
"workflow_id": workflow_id,
@@ -473,22 +516,15 @@ class WorkflowEngine:
)
if limiter_delay <= 0:
break
record_limiter(limiter_delay)
await asyncio.sleep(limiter_delay)
async with self._in_flight:
result = await self.crm.call(method, params, mutating=mutating)
status = result.outcome.value
record_transition("command", status)
async with self.repository.transaction() as connection:
await connection.execute(
text(
"""
UPDATE bitrix_sync.crm_commands
SET status=:status,safe_error_code=:error,http_status=:http_status,
safe_response=:safe_response,
completed_at=CASE WHEN :terminal THEN now() END,
updated_at=now()
WHERE id=:id
"""
),
UPDATE_CRM_COMMAND,
{
"id": command_id,
"status": status,
@@ -546,31 +582,145 @@ class WorkflowEngine:
)
async def _alert(
self, workflow_id: uuid.UUID, user_id: uuid.UUID, alert_type: str, candidates: list[str]
self,
workflow_id: uuid.UUID,
user_id: uuid.UUID,
alert_type: str,
candidates: list[str],
*,
selected_external_id: str | None = None,
) -> None:
fingerprint = f"{alert_type}:{user_id}"
record_business_alert(alert_type)
fingerprint = safe_hash(f"{alert_type}:{user_id}")
assert fingerprint is not None
async with self.repository.transaction() as connection:
alert = (
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.business_alerts
(id,fingerprint,alert_type,severity,app_user_id,
selected_external_id,candidate_external_ids,workflow_id,status,
occurrence_count,first_occurred_at,last_occurred_at,created_at,updated_at)
VALUES (gen_random_uuid(),:fingerprint,
:type,'warning',:user_id,:selected_external_id,:candidates,
:workflow_id,'open',1,now(),now(),now(),now())
ON CONFLICT (alert_type,fingerprint) WHERE status='open'
DO UPDATE SET
selected_external_id=coalesce(
excluded.selected_external_id,
business_alerts.selected_external_id
),
candidate_external_ids=excluded.candidate_external_ids,
workflow_id=excluded.workflow_id,
occurrence_count=business_alerts.occurrence_count+1,
last_occurred_at=now(),updated_at=now()
RETURNING id,alert_number,fingerprint,alert_type,severity,app_user_id,
selected_external_id,candidate_external_ids,remote_item_id,
occurrence_count,first_occurred_at,last_occurred_at
"""
),
{
"fingerprint": fingerprint,
"type": alert_type,
"user_id": user_id,
"selected_external_id": selected_external_id,
"candidates": candidates,
"workflow_id": workflow_id,
},
)
).mappings().one()
alert_config = (
await connection.execute(
text(
"""
SELECT value_json FROM bitrix_sync.settings
WHERE key='business_alerts' AND active=true
AND validation_status='valid'
"""
)
)
).scalar_one_or_none()
if not isinstance(alert_config, dict):
raise RetryableWorkflow("business_alerts_not_configured")
entity_type_id = alert_config.get("entity_type_id")
stage_new = alert_config.get("stage_new")
if not entity_type_id or not stage_new:
raise RetryableWorkflow("business_alerts_not_configured")
title = (
f"Конфликт синхронизации #{alert['alert_number']}: {alert_type}; "
f"user={user_id}; contacts={','.join(candidates)}"
)
fields: dict[str, Any] = {
"title": title[:255],
"stageId": stage_new,
"contactIds": [int(candidate) for candidate in candidates if candidate.isdigit()],
"sourceId": self.settings.contact_source,
}
if category_id := alert_config.get("category_id"):
fields["categoryId"] = category_id
if responsible_id := alert_config.get("responsible_id"):
fields["assignedById"] = responsible_id
alert_values = {
"alert_number": str(alert["alert_number"]),
"fingerprint": alert["fingerprint"],
"alert_type": alert["alert_type"],
"severity": alert["severity"],
"app_user_id": str(alert["app_user_id"]),
"selected_external_id": alert["selected_external_id"] or "",
"occurrence_count": alert["occurrence_count"],
"first_occurred_at": alert["first_occurred_at"].isoformat(),
"last_occurred_at": alert["last_occurred_at"].isoformat(),
"workflow_id": str(workflow_id),
}
field_ids = alert_config.get("field_ids")
if isinstance(field_ids, dict):
for value_name, field_id in field_ids.items():
if value_name in alert_values and isinstance(field_id, str) and field_id:
fields[field_id] = alert_values[value_name]
remote_item_id = alert["remote_item_id"]
if remote_item_id:
await self._command(
workflow_id,
"alert_update",
"crm.item.update",
{
"entityTypeId": int(entity_type_id),
"id": remote_item_id,
"fields": fields,
},
mutating=True,
)
return
result = await self._command(
workflow_id,
"alert_add",
"crm.item.add",
{"entityTypeId": int(entity_type_id), "fields": fields},
mutating=True,
)
payload = result.result if isinstance(result.result, dict) else {}
item = payload.get("item", payload)
remote_item_id = item.get("id") if isinstance(item, dict) else None
if remote_item_id is None:
raise RetryableWorkflow("alert_item_id_missing")
async with self.repository.transaction() as connection:
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.business_alerts
(id,fingerprint,alert_type,severity,app_user_id,candidate_external_ids,
workflow_id,status,occurrence_count,first_occurred_at,last_occurred_at,
created_at,updated_at)
VALUES (gen_random_uuid(),encode(digest(:fingerprint,'sha256'),'hex'),
:type,'warning',:user_id,:candidates,:workflow_id,'open',1,
now(),now(),now(),now())
ON CONFLICT (alert_type,fingerprint) WHERE status='open'
DO UPDATE SET occurrence_count=business_alerts.occurrence_count+1,
last_occurred_at=now(),updated_at=now()
UPDATE bitrix_sync.business_alerts
SET remote_item_id=:remote_item_id,remote_stage_id=:stage_id,updated_at=now()
WHERE id=:id
"""
),
{
"fingerprint": fingerprint,
"type": alert_type,
"user_id": user_id,
"candidates": candidates,
"workflow_id": workflow_id,
"id": alert["id"],
"remote_item_id": str(remote_item_id),
"stage_id": str(stage_new),
},
)
@@ -588,6 +738,7 @@ class WorkflowEngine:
)
async def _technical_failure(self, workflow_id: uuid.UUID, code: str) -> None:
record_dead_letter("crm_command")
async with self.repository.transaction() as connection:
await connection.execute(
text(
@@ -12,20 +12,38 @@ from sqlalchemy import text
from app.config import Settings, load_settings
from app.repository import Repository
from app.security import WebhookValidationError, parse_bounded_form, validate_webhook
from app.telemetry import (
init_telemetry,
instrument_fastapi,
log_event,
record_webhook,
shutdown_telemetry,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = load_settings()
app.state.settings = settings
app.state.repository = (
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
if settings.enabled and settings.database_url
else None
)
yield
if app.state.repository:
await app.state.repository.close()
init_telemetry("bitrix-sync-api")
app.state.repository = None
try:
settings = load_settings()
app.state.settings = settings
app.state.repository = (
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
if settings.enabled and settings.database_url
else None
)
log_event(
"service.started",
"Bitrix sync API started",
attributes={"mode": settings.mode},
)
yield
finally:
if app.state.repository:
await app.state.repository.close()
log_event("service.stopped", "Bitrix sync API stopped")
shutdown_telemetry()
app = FastAPI(
@@ -35,6 +53,7 @@ app = FastAPI(
redoc_url=None,
lifespan=lifespan,
)
instrument_fastapi(app)
def settings(request: Request) -> Settings:
@@ -107,29 +126,30 @@ async def alert_webhook(
async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Response:
config = settings(request)
if not config.enabled:
raise HTTPException(status_code=503, detail="sync_disabled")
if request.headers.get("content-type", "").split(";", 1)[0].lower() != (
"application/x-www-form-urlencoded"
):
raise HTTPException(status_code=400, detail="invalid_content_type")
content_length = request.headers.get("content-length")
if content_length and (
not content_length.isdigit() or int(content_length) > config.webhook_max_body_bytes
):
raise HTTPException(status_code=413, detail="body_too_large")
body = await request.body()
if len(body) > config.webhook_max_body_bytes:
raise HTTPException(status_code=413, detail="body_too_large")
form = parse_bounded_form(body, max_fields=config.webhook_max_fields)
# The container is reachable only from the trusted VM2 nginx network.
# nginx overwrites X-Real-IP from the TCP peer after its CIDR check.
source_ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "")
alert_entity_type_id = (
await _alert_entity_type(repository(request)) if receiver == "alert" else None
)
try:
config = settings(request)
if not config.enabled:
raise HTTPException(status_code=503, detail="sync_disabled")
if request.headers.get("content-type", "").split(";", 1)[0].lower() != (
"application/x-www-form-urlencoded"
):
raise HTTPException(status_code=400, detail="invalid_content_type")
content_length = request.headers.get("content-length")
if content_length and (
not content_length.isdigit() or int(content_length) > config.webhook_max_body_bytes
):
raise HTTPException(status_code=413, detail="body_too_large")
body = await request.body()
if len(body) > config.webhook_max_body_bytes:
raise HTTPException(status_code=413, detail="body_too_large")
form = parse_bounded_form(body, max_fields=config.webhook_max_fields)
# nginx overwrites X-Real-IP from the TCP peer after its CIDR check.
source_ip = request.headers.get("x-real-ip") or (
request.client.host if request.client else ""
)
alert_entity_type_id = (
await _alert_entity_type(repository(request)) if receiver == "alert" else None
)
event = validate_webhook(
receiver,
query,
@@ -139,12 +159,21 @@ async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Re
alert_entity_type_id=alert_entity_type_id,
)
except PermissionError as exc:
record_webhook(receiver, "rejected")
raise HTTPException(status_code=403, detail="forbidden") from exc
except WebhookValidationError as exc:
record_webhook(receiver, "rejected")
raise HTTPException(status_code=400, detail="malformed_webhook") from exc
except HTTPException:
record_webhook(receiver, "rejected")
raise
except Exception:
record_webhook(receiver, "error")
raise
await repository(request).insert_webhook(
event.receiver_type, event.event_type, event.entity_id, event.source_ip
)
record_webhook(receiver, "accepted")
return Response(status_code=202)
@@ -170,4 +199,5 @@ def run() -> None:
host="0.0.0.0", # noqa: S104 - container-only port, not host-published
port=8080,
proxy_headers=False,
access_log=False,
)
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from time import monotonic
from typing import Any
from sqlalchemy import text
@@ -9,6 +10,13 @@ from sqlalchemy import text
from app.config import load_settings
from app.crm import CrmClient, CrmOutcome
from app.repository import Repository
from app.telemetry import (
init_telemetry,
log_event,
record_reconciliation,
safe_span,
shutdown_telemetry,
)
class IncrementalReconciler:
@@ -45,13 +53,13 @@ class IncrementalReconciler:
started_at = datetime.now(UTC)
since = (cursor or datetime(1970, 1, 1, tzinfo=UTC)) - timedelta(seconds=overlap_seconds)
start = 0
scanned: list[str] = []
scanned: list[tuple[str, str]] = []
while True:
result = await self.crm.call(
"crm.item.list",
{
"entityTypeId": 3,
"select": ["id"],
"select": ["id", "updatedTime"],
"filter": {
">=updatedTime": since.replace(tzinfo=None).isoformat(timespec="seconds"),
"opened": 1,
@@ -63,10 +71,15 @@ class IncrementalReconciler:
)
if result.outcome != CrmOutcome.SUCCEEDED:
raise RuntimeError(result.error_code or "reconciliation_failed")
payload: dict[str, Any] = result.result or {}
items = payload.get("items", payload if isinstance(payload, list) else [])
scanned.extend(str(item["id"]) for item in items if "id" in item)
next_start = payload.get("next")
payload: Any = result.result or {}
items = payload.get("items", []) if isinstance(payload, dict) else payload
if not isinstance(items, list):
raise RuntimeError("reconciliation_items_invalid")
for item in items:
if not isinstance(item, dict) or "id" not in item or "updatedTime" not in item:
raise RuntimeError("reconciliation_item_missing_identity")
scanned.append((str(item["id"]), str(item["updatedTime"])))
next_start = payload.get("next") if isinstance(payload, dict) else None
if next_start is None:
break
start = int(next_start)
@@ -89,50 +102,77 @@ class IncrementalReconciler:
)
return len(scanned)
async def _enqueue_changed(self, external_ids: list[str]) -> None:
if not external_ids:
async def _enqueue_changed(self, changed_contacts: list[tuple[str, str]]) -> None:
if not changed_contacts:
return
external_ids = [external_id for external_id, _ in changed_contacts]
event_ids = [
f"contact.reconciliation:{external_id}:{updated_time}"
for external_id, updated_time in changed_contacts
]
async with self.repository.transaction() as connection:
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.webhook_inbox
(id,receiver_type,event_type,external_entity_id,status,coalesced_count,
received_at,last_received_at)
SELECT gen_random_uuid(),'contact','contact.reconciliation',id,'received',1,
now(),now()
FROM unnest(CAST(:ids AS text[])) id
(id,receiver_type,event_type,event_id,external_entity_id,status,
coalesced_count,received_at,last_received_at)
SELECT gen_random_uuid(),'contact','contact.reconciliation',candidate.event_id,
candidate.external_id,'received',1,now(),now()
FROM unnest(CAST(:ids AS text[]),CAST(:event_ids AS text[]))
AS candidate(external_id,event_id)
WHERE NOT EXISTS (
SELECT 1 FROM bitrix_sync.webhook_inbox w
WHERE w.receiver_type='contact' AND w.external_entity_id=id
WHERE w.receiver_type='contact'
AND w.external_entity_id=candidate.external_id
AND w.status IN ('received','processing','retry_wait')
)
ON CONFLICT (receiver_type,event_id) WHERE event_id IS NOT NULL DO NOTHING
"""
),
{"ids": external_ids},
{"ids": external_ids, "event_ids": event_ids},
)
async def reconciliation_main() -> None:
settings = load_settings()
if not settings.enabled:
return
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
assert settings.contact_registered_field
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
crm = CrmClient(
settings.crm_rest_webhook_url.get_secret_value(),
settings.portal_host,
settings.http_timeout_sec,
)
reconciler = IncrementalReconciler(
repository, crm, settings.rest_field_name(settings.contact_registered_field)
)
init_telemetry("bitrix-sync-reconciliation")
repository: Repository | None = None
crm: CrmClient | None = None
started = monotonic()
outcome = "error"
count = 0
try:
await reconciler.run_once(settings.reconciliation_overlap_seconds)
settings = load_settings()
if not settings.enabled:
outcome = "skipped"
log_event("reconciliation.disabled", "Bitrix sync reconciliation is disabled")
return
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
assert settings.contact_registered_field
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
crm = CrmClient(
settings.crm_rest_webhook_url.get_secret_value(),
settings.portal_host,
settings.http_timeout_sec,
)
reconciler = IncrementalReconciler(
repository, crm, settings.rest_field_name(settings.contact_registered_field)
)
with safe_span("bitrix_sync.reconciliation.run"):
count = await reconciler.run_once(settings.reconciliation_overlap_seconds)
outcome = "success"
log_event(
"reconciliation.completed",
"Bitrix sync reconciliation completed",
attributes={"scanned_count": count},
)
finally:
await crm.close()
await repository.close()
record_reconciliation(outcome, count, started)
if crm is not None:
await crm.close()
if repository is not None:
await repository.close()
shutdown_telemetry()
def run() -> None:
@@ -12,6 +12,9 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from app.domain import safe_hash
from app.telemetry import record_status_snapshot
def postgres_ssl_context() -> ssl.SSLContext:
ca_file = os.environ.get("PG_CA_FILE")
@@ -83,9 +86,12 @@ class Repository:
WITH candidates AS (
SELECT id
FROM han_app.sync_queue
WHERE status IN ('pending','retry_wait')
AND next_attempt_at <= now()
AND (locked_until IS NULL OR locked_until < now())
WHERE (
status IN ('pending','retry_wait') AND next_attempt_at <= now()
)
OR (
status='leased' AND locked_until < now()
)
ORDER BY next_attempt_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT :limit
@@ -156,7 +162,7 @@ class Repository:
"""
)
async with self.engine.begin() as connection:
return (
workflow_id = (
await connection.execute(
sql,
{
@@ -167,6 +173,17 @@ class Repository:
},
)
).scalar_one()
await connection.execute(
text(
"""
UPDATE bitrix_sync.workflow_instances
SET state='running',current_step='load_profile',updated_at=now()
WHERE id=:id AND state IN ('created','running')
"""
),
{"id": workflow_id},
)
return workflow_id
async def complete_task(self, task: LeasedTask, workflow_id: uuid.UUID) -> bool:
async with self.engine.begin() as connection:
@@ -280,8 +297,12 @@ class Repository:
"""
WITH candidates AS (
SELECT id FROM bitrix_sync.webhook_inbox
WHERE status IN ('received','retry_wait') AND next_attempt_at<=now()
AND (locked_until IS NULL OR locked_until<now())
WHERE (
status IN ('received','retry_wait') AND next_attempt_at<=now()
)
OR (
status='processing' AND locked_until<now()
)
ORDER BY next_attempt_at,received_at
FOR UPDATE SKIP LOCKED LIMIT :limit
)
@@ -345,7 +366,8 @@ class Repository:
text(
"""
UPDATE han_app.client_profiles
SET full_name=:full_name,citizenship=:citizenship,email=:email,updated_at=now()
SET full_name=:full_name,citizenship=:citizenship,email=:email,
source_updated_at=:source_updated_at,updated_at=now()
WHERE user_id=:user_id AND record_status='A'
"""
),
@@ -354,6 +376,7 @@ class Repository:
"full_name": full_name,
"citizenship": citizenship,
"email": email,
"source_updated_at": source_updated_at,
},
)
await connection.execute(
@@ -363,14 +386,20 @@ class Repository:
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
citizenship_hash,source_updated_at,last_applied_source,
last_webhook_received_at,created_at,updated_at)
SELECT gen_random_uuid(),m.id,:user_id,:external_id,
encode(digest(coalesce(:full_name,''),'sha256'),'hex'),
encode(digest(coalesce(:email,''),'sha256'),'hex'),
encode(digest(coalesce(:citizenship,''),'sha256'),'hex'),
:source_updated_at,:source,
CASE WHEN :source='webhook' THEN now() END,now(),now()
SELECT gen_random_uuid(),m.id,:user_id,
CAST(:external_id AS varchar(128)),
CAST(:full_name_hash AS varchar(64)),
CAST(:email_hash AS varchar(64)),
CAST(:citizenship_hash AS varchar(64)),
CAST(:source_updated_at AS timestamptz),
CAST(:source AS varchar(24)),
CASE WHEN CAST(:source AS varchar(24))='webhook'
THEN now() END,
now(),now()
FROM bitrix_sync.entity_external_mapping m
WHERE m.entity_id=:user_id AND m.external_id=:external_id AND m.status='active'
WHERE m.entity_id=:user_id
AND m.external_id=CAST(:external_id AS varchar(128))
AND m.status='active'
ON CONFLICT (mapping_id) DO UPDATE
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
citizenship_hash=excluded.citizenship_hash,
@@ -385,9 +414,9 @@ class Repository:
{
"user_id": user_id,
"external_id": external_id,
"full_name": full_name,
"citizenship": citizenship,
"email": email,
"full_name_hash": safe_hash(full_name or ""),
"citizenship_hash": safe_hash(citizenship or ""),
"email_hash": safe_hash(email or ""),
"source_updated_at": source_updated_at,
"source": source,
},
@@ -506,12 +535,17 @@ class Repository:
queries = {
"queue": "SELECT status, count(*) count FROM han_app.sync_queue GROUP BY status",
"workflows": (
"SELECT state, count(*) count FROM bitrix_sync.workflow_instances GROUP BY state"
"SELECT state AS status, count(*) count "
"FROM bitrix_sync.workflow_instances GROUP BY state"
),
"commands": (
"SELECT status, count(*) count "
"FROM bitrix_sync.crm_commands GROUP BY status"
),
"queue_oldest_age_seconds": (
"SELECT coalesce(extract(epoch from now()-min(created_at)),0) "
"FROM han_app.sync_queue WHERE status IN ('pending','retry_wait','leased')"
),
"webhook_lag_seconds": (
"SELECT coalesce(extract(epoch from now()-min(received_at)),0) "
"FROM bitrix_sync.webhook_inbox WHERE status IN ('received','retry_wait')"
@@ -530,4 +564,5 @@ class Repository:
else:
output[key] = result.scalar_one_or_none()
output["generated_at"] = datetime.now(UTC).isoformat()
record_status_snapshot(output)
return output
@@ -0,0 +1,514 @@
from __future__ import annotations
import json
import logging
import os
import re
import sys
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from time import monotonic
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import SpanKind, Status, StatusCode
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
SERVICE_NAMES = frozenset(
{"bitrix-sync-api", "bitrix-sync-worker", "bitrix-sync-reconciliation"}
)
_SECRET_KEYS = re.compile(
r"(authorization|cookie|token|secret|password|phone|email|full.?name|"
r"message|payload|body|query|url|dsn|statement|object.?key|filename)",
re.IGNORECASE,
)
_SENSITIVE_VALUES = (
re.compile(r"(?i)\bbearer\s+\S+"),
re.compile(r"(?i)\b(?:token|password|secret|code)=\S+"),
re.compile(r"https?://[^\s?#]+[?#]\S+"),
re.compile(r"\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b"),
re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
re.compile(r"(?<!\w)\+?\d[\d ()-]{8,}\d(?!\w)"),
)
_SAFE_CODES = frozenset(
{
"success",
"retry",
"uncertain",
"permanent",
"rate_limited",
"error",
"disabled",
"skipped",
}
)
_SAFE_TASK_TYPES = frozenset(
{"contact.map_or_create", "contact.update", "contact.deactivate", "contact.webhook", "rebind"}
)
_SAFE_RECEIVERS = frozenset({"contact", "alert", "unknown"})
_SAFE_CRM_OPERATIONS = frozenset({"batch", "get", "add", "update", "list", "findbycomm"})
_SAFE_QUEUE_STATES = frozenset(
{"pending", "retry_wait", "leased", "processed", "received", "processing", "failed"}
)
_SAFE_ALERT_TYPES = frozenset(
{
"duplicate_contacts",
"contact_owned_by_other_user",
"mapping_identity_mismatch",
"rebind_target_owned",
"unknown_citizenship_enum",
"technical_configuration_failure",
}
)
_configured_service_name = "bitrix-sync-api"
_runtime: TelemetryRuntime | None = None
_logging_configured = False
def _safe_scalar(value: Any) -> Any:
if value is None or isinstance(value, (bool, int, float)):
return value
text = str(value)
for pattern in _SENSITIVE_VALUES:
text = pattern.sub("[REDACTED]", text)
return text[:512]
def redact(value: Any, *, key: str = "") -> Any:
"""Return a bounded telemetry-safe copy without mutating application data."""
if key and _SECRET_KEYS.search(key):
return "[REDACTED]"
if isinstance(value, Mapping):
return {
str(item_key)[:64]: redact(item, key=str(item_key))
for item_key, item in value.items()
}
if isinstance(value, (list, tuple, set, frozenset)):
return [redact(item) for item in list(value)[:32]]
return _safe_scalar(value)
def _trace_fields() -> dict[str, str]:
context = trace.get_current_span().get_span_context()
if not context.is_valid:
return {}
return {
"trace_id": format(context.trace_id, "032x"),
"span_id": format(context.span_id, "016x"),
}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
event = {
"timestamp": (
datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
),
"level": record.levelname,
"service.name": _configured_service_name,
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
"environment": os.getenv("APP_ENV", "production-like"),
"module": record.name,
"event": getattr(
record,
"event_name",
"log",
),
"message": record.getMessage(),
**_trace_fields(),
}
attributes = getattr(record, "telemetry_attributes", None)
if isinstance(attributes, Mapping):
event.update(attributes)
if record.exc_info:
event["error.type"] = record.exc_info[0].__name__
return json.dumps(redact(event), ensure_ascii=False, separators=(",", ":"), default=str)
class RedactionFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.msg = redact(record.getMessage()) if hasattr(record, "event_name") else "[REDACTED]"
record.args = ()
attributes = getattr(record, "telemetry_attributes", None)
safe_attributes = dict(attributes) if isinstance(attributes, Mapping) else {}
if record.exc_info:
safe_attributes["error.type"] = record.exc_info[0].__name__
record.exc_info = None
record.exc_text = None
record.telemetry_attributes = redact(safe_attributes)
return True
class RedactingBatchSpanProcessor(BatchSpanProcessor):
"""Remove sensitive auto-instrumentation attributes before queueing/export."""
def on_end(self, span: Any) -> None:
attributes = getattr(span, "_attributes", None)
if isinstance(attributes, Mapping):
sanitized = dict(attributes)
for key in list(sanitized):
key_text = str(key)
if _SECRET_KEYS.search(key_text) or key_text in {
"http.target",
"url.full",
"db.statement",
}:
sanitized[key] = "[REDACTED]"
else:
sanitized[key] = _safe_scalar(sanitized[key])
span._attributes = sanitized
super().on_end(span)
@dataclass(slots=True)
class TelemetryRuntime:
tracer_provider: TracerProvider | None = None
meter_provider: MeterProvider | None = None
logger_provider: LoggerProvider | None = None
def shutdown(self) -> None:
for provider in (self.logger_provider, self.meter_provider, self.tracer_provider):
if provider is None:
continue
try:
provider.shutdown()
except Exception:
logging.getLogger(__name__).warning(
"Telemetry shutdown failed",
extra={"event_name": "telemetry.shutdown.failed"},
)
def _resource(service_name: str) -> Resource:
return Resource.create(
{
"service.name": service_name,
"service.namespace": "han-chat",
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
"deployment.environment": os.getenv("APP_ENV", "production-like"),
}
)
def _configure_stdout(service_name: str) -> None:
global _configured_service_name, _logging_configured
_configured_service_name = service_name
if _logging_configured:
return
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
handler.addFilter(RedactionFilter())
root = logging.getLogger()
root.addHandler(handler)
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
root.setLevel(getattr(logging, level_name, logging.INFO))
_logging_configured = True
def init_telemetry(service_name: str) -> TelemetryRuntime:
"""Initialize all signals; exporter failures never stop the business process."""
global _runtime
if service_name not in SERVICE_NAMES:
raise ValueError("unregistered bitrix-sync process service.name")
_configure_stdout(service_name)
if _runtime is not None:
return _runtime
runtime = TelemetryRuntime()
_runtime = runtime
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
if not endpoint:
return runtime
try:
resource = _resource(service_name)
insecure = endpoint.startswith("http://")
set_global_textmap(TraceContextTextMapPropagator())
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
RedactingBatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
trace.set_tracer_provider(tracer_provider)
runtime.tracer_provider = tracer_provider
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
export_interval_millis=30_000,
export_timeout_millis=3000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
runtime.meter_provider = meter_provider
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
otlp_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
otlp_handler.addFilter(RedactionFilter())
logging.getLogger().addHandler(otlp_handler)
runtime.logger_provider = logger_provider
def sanitize_httpx_request(span: trace.Span, request: Any) -> None:
if not span.is_recording():
return
url = request[1]
host = getattr(url, "host", "")
scheme = getattr(url, "scheme", "https")
safe_url = f"{scheme}://{host}/[REDACTED]"
span.set_attribute("url.full", safe_url)
span.set_attribute("http.url", safe_url)
HTTPXClientInstrumentor().instrument(request_hook=sanitize_httpx_request)
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
except Exception:
logging.getLogger(__name__).exception(
"Telemetry initialization failed; stdout fallback remains active",
extra={"event_name": "telemetry.init.failed"},
)
return runtime
def instrument_fastapi(app: FastAPI) -> None:
try:
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health/live",
http_capture_headers_server_request=[],
http_capture_headers_server_response=[],
)
except Exception:
logging.getLogger(__name__).exception(
"FastAPI instrumentation failed",
extra={"event_name": "telemetry.fastapi.failed"},
)
def shutdown_telemetry() -> None:
global _runtime
if _runtime is not None:
_runtime.shutdown()
_runtime = None
def log_event(
event: str,
message: str,
*,
level: int = logging.INFO,
attributes: Mapping[str, Any] | None = None,
) -> None:
logging.getLogger("bitrix_sync").log(
level,
message,
extra={"event_name": event, "telemetry_attributes": redact(attributes or {})},
)
@contextmanager
def safe_span(
name: str,
*,
kind: SpanKind = SpanKind.INTERNAL,
attributes: Mapping[str, Any] | None = None,
) -> Iterator[trace.Span]:
safe_attributes: dict[str, bool | int | float | str] = {}
for key, value in redact(attributes or {}).items():
if not isinstance(value, (bool, int, float, str)):
continue
key_text = str(key)[:64]
if key_text == "workflow.type":
value = _bounded(str(value), _SAFE_TASK_TYPES)
elif key_text == "crm.operation":
value = _bounded(str(value), _SAFE_CRM_OPERATIONS)
elif key_text == "receiver":
value = _bounded(str(value), _SAFE_RECEIVERS, "unknown")
safe_attributes[key_text] = value
with trace.get_tracer("han.bitrix_sync").start_as_current_span(
name[:128], kind=kind, attributes=safe_attributes
) as span:
try:
yield span
except Exception as exc:
span.set_status(Status(StatusCode.ERROR, type(exc).__name__))
raise
def _bounded(value: str, allowed: frozenset[str], fallback: str = "other") -> str:
return value if value in allowed else fallback
def _metric_fail_open(function: Any) -> Any:
def wrapped(*args: Any, **kwargs: Any) -> None:
try:
function(*args, **kwargs)
except Exception:
return
return wrapped
@_metric_fail_open
def record_claim(queue: str, count: int) -> None:
queue_name = _bounded(queue, frozenset({"task", "webhook", "rebind"}))
metrics.get_meter("han.bitrix_sync").create_counter(
"bitrix_sync_claimed_items", unit="{item}"
).add(max(0, count), {"queue": queue_name})
@_metric_fail_open
def record_workflow(task_type: str, outcome: str, duration_seconds: float) -> None:
attrs = {
"workflow.type": _bounded(task_type, _SAFE_TASK_TYPES),
"outcome": _bounded(outcome, _SAFE_CODES),
}
meter = metrics.get_meter("han.bitrix_sync")
meter.create_counter("bitrix_sync_workflows", unit="{workflow}").add(1, attrs)
meter.create_histogram("bitrix_sync_workflow_duration", unit="s").record(
max(0.0, duration_seconds), attrs
)
@_metric_fail_open
def record_webhook(receiver: str, outcome: str) -> None:
metrics.get_meter("han.bitrix_sync").create_counter(
"bitrix_sync_webhooks", unit="{webhook}"
).add(
1,
{
"receiver": _bounded(receiver, _SAFE_RECEIVERS, "unknown"),
"outcome": _bounded(outcome, frozenset({"accepted", "rejected", "error"})),
},
)
@_metric_fail_open
def record_crm(method: str, outcome: str, duration_seconds: float) -> None:
operation = method.rsplit(".", 1)[-1]
operation = _bounded(operation, _SAFE_CRM_OPERATIONS)
attrs = {"operation": operation, "outcome": _bounded(outcome, _SAFE_CODES)}
meter = metrics.get_meter("han.bitrix_sync")
meter.create_counter("bitrix_sync_crm_calls", unit="{call}").add(1, attrs)
meter.create_histogram("bitrix_sync_crm_duration", unit="s").record(
max(0.0, duration_seconds), attrs
)
@_metric_fail_open
def record_limiter(delay_seconds: float) -> None:
metrics.get_meter("han.bitrix_sync").create_histogram(
"bitrix_sync_limiter_delay", unit="s"
).record(max(0.0, delay_seconds))
@_metric_fail_open
def record_transition(kind: str, state: str) -> None:
metrics.get_meter("han.bitrix_sync").create_counter(
"bitrix_sync_transitions", unit="{transition}"
).add(
1,
{
"kind": _bounded(kind, frozenset({"workflow", "command", "webhook"})),
"state": _bounded(
state,
frozenset(
{
"succeeded",
"retry",
"uncertain",
"permanent",
"rate_limited",
"waiting_manual",
"processed",
"error",
}
),
),
},
)
@_metric_fail_open
def record_retry(kind: str) -> None:
metrics.get_meter("han.bitrix_sync").create_counter(
"bitrix_sync_retries", unit="{retry}"
).add(1, {"kind": _bounded(kind, frozenset({"task", "webhook", "rebind", "crm"}))})
@_metric_fail_open
def record_dead_letter(operation: str) -> None:
metrics.get_meter("han.bitrix_sync").create_counter(
"bitrix_sync_dead_letters", unit="{item}"
).add(1, {"operation": _bounded(operation, frozenset({"crm_command"}))})
@_metric_fail_open
def record_business_alert(alert_type: str) -> None:
metrics.get_meter("han.bitrix_sync").create_counter(
"bitrix_sync_business_alerts", unit="{alert}"
).add(1, {"alert.type": _bounded(alert_type, _SAFE_ALERT_TYPES)})
@_metric_fail_open
def record_status_snapshot(status: Mapping[str, Any]) -> None:
meter = metrics.get_meter("han.bitrix_sync")
depth = meter.create_histogram("bitrix_sync_queue_depth", unit="{item}")
for queue_name in ("queue", "workflows", "commands"):
values = status.get(queue_name)
if not isinstance(values, Mapping):
continue
for state, count in values.items():
if isinstance(count, int):
depth.record(
max(0, count),
{
"queue": queue_name,
"state": _bounded(str(state), _SAFE_QUEUE_STATES),
},
)
for key in ("queue_oldest_age_seconds", "webhook_lag_seconds"):
value = status.get(key)
if isinstance(value, (int, float)):
meter.create_histogram(f"bitrix_sync_{key}", unit="s").record(max(0.0, value))
@_metric_fail_open
def record_reconciliation(outcome: str, count: int, started: float) -> None:
attrs = {"outcome": _bounded(outcome, frozenset({"success", "error", "skipped"}))}
meter = metrics.get_meter("han.bitrix_sync")
meter.create_counter("bitrix_sync_reconciliation_runs", unit="{run}").add(1, attrs)
meter.create_histogram("bitrix_sync_reconciliation_duration", unit="s").record(
max(0.0, monotonic() - started), attrs
)
meter.create_histogram("bitrix_sync_reconciliation_items", unit="{item}").record(
max(0, count), attrs
)
@@ -4,43 +4,62 @@ import asyncio
import signal
import socket
import uuid
from time import monotonic
from app.config import load_settings
from app.crm import CrmClient
from app.domain import full_jitter_delay
from app.engine import RetryableWorkflow, WorkflowEngine
from app.repository import Repository
from app.telemetry import (
init_telemetry,
log_event,
record_claim,
record_retry,
record_workflow,
safe_span,
shutdown_telemetry,
)
async def worker_main() -> None:
settings = load_settings()
if not settings.enabled:
return
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
crm = CrmClient(
settings.crm_rest_webhook_url.get_secret_value(),
settings.portal_host,
settings.http_timeout_sec,
)
engine = WorkflowEngine(repository, crm, settings)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for event in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(event, stop.set)
except NotImplementedError:
pass
worker_id = f"{socket.gethostname()}:{uuid.uuid4()}"
init_telemetry("bitrix-sync-worker")
repository: Repository | None = None
crm: CrmClient | None = None
try:
settings = load_settings()
if not settings.enabled:
log_event("worker.disabled", "Bitrix sync worker is disabled")
return
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
crm = CrmClient(
settings.crm_rest_webhook_url.get_secret_value(),
settings.portal_host,
settings.http_timeout_sec,
)
engine = WorkflowEngine(repository, crm, settings)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for event in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(event, stop.set)
except NotImplementedError:
pass
worker_id = f"{socket.gethostname()}:{uuid.uuid4()}"
log_event("worker.started", "Bitrix sync worker started")
while not stop.is_set():
tasks = await repository.claim_tasks(
worker_id, settings.claim_size, settings.lease_seconds
)
webhooks = await repository.claim_webhooks(
worker_id, settings.claim_size, settings.lease_seconds
)
rebind_ids = await repository.pending_rebind_ids(settings.claim_size)
with safe_span("bitrix_sync.worker.claim"):
tasks = await repository.claim_tasks(
worker_id, settings.claim_size, settings.lease_seconds
)
webhooks = await repository.claim_webhooks(
worker_id, settings.claim_size, settings.lease_seconds
)
rebind_ids = await repository.pending_rebind_ids(settings.claim_size)
record_claim("task", len(tasks))
record_claim("webhook", len(webhooks))
record_claim("rebind", len(rebind_ids))
if not tasks and not webhooks and not rebind_ids:
try:
await asyncio.wait_for(stop.wait(), timeout=1)
@@ -49,29 +68,68 @@ async def worker_main() -> None:
for task in tasks:
if stop.is_set():
break
await engine.process(task)
started = monotonic()
outcome = "success"
try:
with safe_span(
"bitrix_sync.worker.process",
attributes={"workflow.type": task.task_type},
):
await engine.process(task)
except Exception:
outcome = "error"
raise
finally:
record_workflow(task.task_type, outcome, monotonic() - started)
for item in webhooks:
if stop.is_set():
break
started = monotonic()
outcome = "success"
try:
await engine.process_webhook(item)
with safe_span(
"bitrix_sync.worker.webhook",
attributes={"receiver": item.receiver_type},
):
await engine.process_webhook(item)
except RetryableWorkflow as exc:
outcome = "retry"
record_retry("webhook")
delay = exc.retry_after or full_jitter_delay(
item.attempt_count + 1,
settings.retry_base_seconds,
settings.retry_max_seconds,
)
await repository.retry_webhook(item, exc.code, delay)
except Exception:
outcome = "error"
raise
finally:
record_workflow("contact.webhook", outcome, monotonic() - started)
for request_id in rebind_ids:
if stop.is_set():
break
started = monotonic()
outcome = "success"
try:
await engine.process_rebind(request_id)
with safe_span("bitrix_sync.worker.rebind"):
await engine.process_rebind(request_id)
except RetryableWorkflow:
outcome = "retry"
record_retry("rebind")
continue
except Exception:
outcome = "error"
raise
finally:
record_workflow("rebind", outcome, monotonic() - started)
finally:
await crm.close()
await repository.close()
if crm is not None:
await crm.close()
if repository is not None:
await repository.close()
log_event("worker.stopped", "Bitrix sync worker stopped")
shutdown_telemetry()
def run() -> None:
@@ -23,6 +23,7 @@ services:
BITRIX_SYNC_CONTACT_USER_ID_FIELD: ${BITRIX_SYNC_CONTACT_USER_ID_FIELD:-}
BITRIX_SYNC_CONTACT_REGISTERED_FIELD: ${BITRIX_SYNC_CONTACT_REGISTERED_FIELD:-}
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:-}
BITRIX_SYNC_CONTACT_SOURCE: ${BITRIX_SYNC_CONTACT_SOURCE:-}
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
volumes: &sync_secrets
- /run/han-chat/secrets/bitrix-sync/database-url:/run/secrets/bitrix_sync_database_url:ro
@@ -8,6 +8,12 @@ dependencies = [
"asyncpg>=0.30,<1",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"opentelemetry-api>=1.36,<2",
"opentelemetry-exporter-otlp-proto-grpc>=1.36,<2",
"opentelemetry-instrumentation-fastapi>=0.57b0,<1",
"opentelemetry-instrumentation-httpx>=0.57b0,<1",
"opentelemetry-instrumentation-sqlalchemy>=0.57b0,<1",
"opentelemetry-sdk>=1.36,<2",
"pydantic-settings>=2.10,<3",
"python-multipart>=0.0.20,<1",
"sqlalchemy[asyncio]>=2.0.41,<3",
@@ -21,5 +21,6 @@ def full_settings() -> Settings:
contact_user_id_field="UF_CRM_100",
contact_registered_field="UF_CRM_101",
contact_citizenship_field="UF_CRM_102",
contact_source="WEB",
webhook_allowed_cidrs="203.0.113.0/24",
)
@@ -31,14 +31,36 @@ def test_full_mode_rejects_portal_host_mismatch() -> None:
contact_user_id_field="UF_CRM_1",
contact_registered_field="UF_CRM_2",
contact_citizenship_field="UF_CRM_3",
contact_source="WEB",
webhook_allowed_cidrs="203.0.113.0/24",
)
def test_rest_field_conversion_is_deterministic() -> None:
assert Settings.rest_field_name("UF_CRM_1778692456") == "ufCrm_1778692456"
assert Settings.rest_field_name("UF_CRM_6a70c275346a7") == "ufCrm_6a70c275346a7"
assert Settings.rest_field_name("UF_CRM_AbC123") == "ufCrm_AbC123"
with pytest.raises(ValueError):
Settings.rest_field_name("uf_crm_1")
with pytest.raises(ValueError):
Settings.rest_field_name("UF_CRM_123_abc")
def test_image_default_allows_compose_process_role_override() -> None:
service_root = Path(__file__).parents[1]
dockerfile = (service_root / "Dockerfile").read_text(encoding="utf-8")
compose = (service_root / "compose.fragment.yaml").read_text(encoding="utf-8")
production_compose = (service_root.parent / "docker-compose.yml").read_text(encoding="utf-8")
assert 'CMD ["han-bitrix-sync-api"]' in dockerfile
assert 'ENTRYPOINT ["han-bitrix-sync-api"]' not in dockerfile
for source in (compose, production_compose):
assert 'command: ["han-bitrix-sync-worker"]' in source
assert 'command: ["han-bitrix-sync-reconciliation"]' in source
reconciliation_service = source.split(" bitrix-sync-reconciliation:", 1)[1].split(
"\n bitrix-sync-", 1
)[0]
assert 'restart: "no"' in reconciliation_service
def test_alembic_chain_preserves_legacy_baseline() -> None:
@@ -2,20 +2,67 @@ from __future__ import annotations
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import pytest
from sqlalchemy.dialects.postgresql import JSONB
from app.crm import CrmOutcome, CrmResult
from app.engine import WorkflowEngine
from app.engine import INSERT_CRM_COMMAND, UPDATE_CRM_COMMAND, WorkflowEngine
from app.repository import Profile
class Result:
rowcount = 1
def __init__(self, *, row=None, scalar=None) -> None:
self.row = row
self.scalar = scalar
def mappings(self):
return self
def one(self):
return self.row
def scalar_one_or_none(self):
return self.scalar
class Connection:
def __init__(self, statements: list[str]) -> None:
self.statements = statements
async def execute(self, statement, params=None):
sql = str(statement)
self.statements.append(sql)
if "INSERT INTO bitrix_sync.business_alerts" in sql:
now = datetime.now(UTC)
return Result(
row={
"id": uuid.uuid4(),
"alert_number": 1,
"fingerprint": "fingerprint",
"alert_type": params["type"],
"severity": "warning",
"app_user_id": params["user_id"],
"selected_external_id": params["selected_external_id"],
"candidate_external_ids": params["candidates"],
"remote_item_id": None,
"occurrence_count": 1,
"first_occurred_at": now,
"last_occurred_at": now,
}
)
if "SELECT value_json FROM bitrix_sync.settings" in sql:
return Result(
scalar={
"entity_type_id": 178,
"category_id": 5,
"stage_new": "DT178_5:NEW",
"field_ids": {"candidate_external_ids": "ufCrm_999"},
}
)
return Result()
@@ -26,7 +73,7 @@ class FakeRepository:
@asynccontextmanager
async def transaction(self):
yield Connection()
yield Connection(self.statements)
async def active_mapping(self, user_id):
return self.mapping
@@ -36,8 +83,16 @@ class FakeRepository:
class FakeCrm:
def __init__(self, user_id: uuid.UUID) -> None:
def __init__(
self,
user_id: uuid.UUID,
*,
existing_identity: bool = False,
foreign_owner: bool = False,
) -> None:
self.user_id = user_id
self.existing_identity = existing_identity
self.foreign_owner = foreign_owner
self.calls: list[tuple[str, dict]] = []
async def call(self, method, params, *, mutating):
@@ -51,12 +106,27 @@ class FakeCrm:
{
"ID": contact_id,
"CREATED_TIME": "2026-08-06T10:00:00Z",
"UF_CRM_100": None,
"UF_CRM_100": (
str(self.user_id)
if self.existing_identity and contact_id == "10"
else str(uuid.UUID(int=1))
if self.foreign_owner and contact_id == "10"
else None
),
},
)
if method == "crm.contact.add":
return CrmResult(CrmOutcome.SUCCEEDED, "11")
if method == "crm.item.add":
return CrmResult(CrmOutcome.SUCCEEDED, {"item": {"id": "500"}})
return CrmResult(CrmOutcome.SUCCEEDED, True)
def test_crm_command_json_payloads_have_explicit_jsonb_types() -> None:
assert isinstance(INSERT_CRM_COMMAND._bindparams["safe_request"].type, JSONB)
assert isinstance(UPDATE_CRM_COMMAND._bindparams["safe_response"].type, JSONB)
@pytest.mark.asyncio
async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
user_id = uuid.uuid4()
@@ -70,3 +140,46 @@ async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
updates = [params for method, params in crm.calls if method == "crm.contact.update"]
assert updates[0]["id"] == "10"
assert not any(method == "crm.contact.add" for method, _ in crm.calls)
assert any(method == "crm.item.add" for method, _ in crm.calls)
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
assert alert_add["fields"]["contactIds"] == [9, 10]
assert alert_add["fields"]["sourceId"] == "WEB"
assert "ufCrm_999" not in alert_add["fields"]
assert any("entity_external_mapping" in statement for statement in repository.statements)
assert not any("digest(" in statement for statement in repository.statements)
@pytest.mark.asyncio
async def test_recovered_duplicate_creates_alert_before_mapping(full_settings) -> None:
user_id = uuid.uuid4()
repository = FakeRepository()
crm = FakeCrm(user_id, existing_identity=True)
engine = WorkflowEngine(repository, crm, full_settings)
await engine._map_or_create(
uuid.uuid4(),
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
)
methods = [method for method, _ in crm.calls]
assert "crm.item.add" in methods
assert "crm.contact.update" not in methods
assert any("entity_external_mapping" in statement for statement in repository.statements)
@pytest.mark.asyncio
async def test_foreign_owned_contact_alert_includes_new_contact(full_settings) -> None:
user_id = uuid.uuid4()
repository = FakeRepository()
crm = FakeCrm(user_id, foreign_owner=True)
engine = WorkflowEngine(repository, crm, full_settings)
await engine._map_or_create(
uuid.uuid4(),
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
)
methods = [method for method, _ in crm.calls]
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
assert methods.index("crm.contact.add") < methods.index("crm.item.add")
assert alert_add["fields"]["contactIds"] == [9, 10, 11]
@@ -0,0 +1,178 @@
from __future__ import annotations
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from app.domain import safe_hash
from app.reconciliation import IncrementalReconciler
from app.repository import LeasedTask, Repository
class FakeResult:
def __init__(self, *, rows=(), scalar=None) -> None:
self.rows = rows
self.scalar = scalar
def __iter__(self):
return iter(self.rows)
def mappings(self):
return self
def scalar_one_or_none(self):
return self.scalar
def scalar_one(self):
return self.scalar
class FakeConnection:
async def execute(self, statement):
sql = str(statement)
if "han_app.sync_queue" in sql:
return FakeResult(rows=[SimpleNamespace(status="pending", count=2)])
if "workflow_instances" in sql:
assert "state AS status" in sql
return FakeResult(rows=[SimpleNamespace(status="created", count=3)])
if "crm_commands" in sql:
return FakeResult(rows=[SimpleNamespace(status="succeeded", count=4)])
if "webhook_inbox" in sql:
return FakeResult(scalar=1.5)
if "settings_versions" in sql:
return FakeResult(scalar=1)
raise AssertionError(f"unexpected status query: {sql}")
class FakeEngine:
def __init__(self) -> None:
self.queries: list[str] = []
@asynccontextmanager
async def connect(self):
yield FakeConnection()
@asynccontextmanager
async def begin(self):
yield ClaimConnection(self.queries)
class ClaimConnection:
def __init__(self, queries: list[str]) -> None:
self.queries = queries
async def execute(self, statement, params):
sql = str(statement)
self.queries.append(sql)
if "INSERT INTO bitrix_sync.workflow_instances" in sql:
return FakeResult(scalar=params["id"])
return FakeResult()
@pytest.mark.asyncio
async def test_status_uses_common_status_alias_for_workflows() -> None:
repository = object.__new__(Repository)
repository.engine = FakeEngine()
result = await repository.status()
assert result["queue"] == {"pending": 2}
assert result["workflows"] == {"created": 3}
assert result["commands"] == {"succeeded": 4}
assert result["webhook_lag_seconds"] == 1.5
assert result["settings_version"] == 1
@pytest.mark.asyncio
async def test_claims_recover_expired_leases() -> None:
repository = object.__new__(Repository)
engine = FakeEngine()
repository.engine = engine
assert await repository.claim_tasks("worker", 10, 60) == []
assert await repository.claim_webhooks("worker", 10, 60) == []
assert "status='leased' AND locked_until < now()" in engine.queries[0]
assert "status='processing' AND locked_until<now()" in engine.queries[1]
@pytest.mark.asyncio
async def test_create_workflow_marks_recovered_workflow_running() -> None:
repository = object.__new__(Repository)
engine = FakeEngine()
repository.engine = engine
task = LeasedTask(uuid.uuid4(), "contact.map_or_create", uuid.uuid4(), uuid.uuid4(), 0)
workflow_id = await repository.create_workflow(task)
assert isinstance(workflow_id, uuid.UUID)
assert "SET state='running'" in engine.queries[1]
@pytest.mark.asyncio
async def test_reconciliation_uses_unambiguous_external_id_alias() -> None:
queries: list[str] = []
class Connection:
async def execute(self, statement, params):
queries.append(str(statement))
return FakeResult()
class ReconciliationRepository:
@asynccontextmanager
async def transaction(self):
yield Connection()
reconciler = IncrementalReconciler(ReconciliationRepository(), None, "ufCrm_1")
await reconciler._enqueue_changed(
[
("29406", "2026-08-20T13:21:00+00:00"),
("29502", "2026-08-20T13:22:00+00:00"),
]
)
assert "AS candidate(external_id,event_id)" in queries[0]
assert "w.external_entity_id=candidate.external_id" in queries[0]
assert "ON CONFLICT (receiver_type,event_id)" in queries[0]
@pytest.mark.asyncio
async def test_apply_crm_profile_hashes_without_database_digest() -> None:
calls: list[tuple[str, dict | None]] = []
class Connection:
async def execute(self, statement, params=None):
calls.append((str(statement), params))
return FakeResult()
class Engine:
@asynccontextmanager
async def begin(self):
yield Connection()
repository = object.__new__(Repository)
repository.engine = Engine()
source_updated_at = datetime.now(UTC)
await repository.apply_crm_profile(
uuid.uuid4(),
"29406",
full_name="Тестовый пользователь",
citizenship=None,
email=None,
source_updated_at=source_updated_at,
source="webhook",
)
profile_sql, profile_params = calls[1]
snapshot_sql, snapshot_params = calls[2]
assert "source_updated_at=:source_updated_at" in profile_sql
assert profile_params["source_updated_at"] == source_updated_at
assert "digest(" not in snapshot_sql
assert "CAST(:external_id AS varchar(128))" in snapshot_sql
assert "CAST(:source AS varchar(24))" in snapshot_sql
assert snapshot_params["full_name_hash"] == safe_hash("Тестовый пользователь")
assert snapshot_params["email_hash"] == safe_hash("")
@@ -0,0 +1,172 @@
from __future__ import annotations
import json
import logging
from typing import Any
import pytest
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from app import telemetry
@pytest.mark.parametrize(
"canary",
[
"Bearer canary-authorization-value",
"person@example.test",
"+7 (999) 123-45-67",
"https://portal.example/path?token=canary",
"aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb.cccccccccc",
],
)
def test_redaction_removes_canaries_from_json_stdout(canary: str) -> None:
record = logging.LogRecord(
"test",
logging.INFO,
__file__,
1,
"safe event %s",
(canary,),
None,
)
record.event_name = "test.canary"
record.telemetry_attributes = {
"authorization": canary,
"nested": {"email": canary, "safe": canary},
}
telemetry.RedactionFilter().filter(record)
output = telemetry.JsonFormatter().format(record)
assert canary not in output
assert json.loads(output)["event"] == "test.canary"
def test_redaction_blocks_sensitive_keys_and_bounds_collections() -> None:
result = telemetry.redact(
{
"db.statement": "SELECT secret FROM users",
"object_key": "private/file.txt",
"safe": list(range(100)),
}
)
assert result["db.statement"] == "[REDACTED]"
assert result["object_key"] == "[REDACTED]"
assert len(result["safe"]) == 32
def test_redaction_drops_exception_text_before_otlp() -> None:
try:
raise RuntimeError("secret-token-in-exception")
except RuntimeError:
record = logging.LogRecord(
"test",
logging.ERROR,
__file__,
1,
"operation failed",
(),
exc_info=__import__("sys").exc_info(),
)
telemetry.RedactionFilter().filter(record)
assert record.exc_info is None
assert record.telemetry_attributes["error.type"] == "RuntimeError"
assert "secret-token-in-exception" not in telemetry.JsonFormatter().format(record)
def test_span_processor_replaces_immutable_sdk_attributes() -> None:
exporter = InMemorySpanExporter()
processor = telemetry.RedactingBatchSpanProcessor(exporter)
span = ReadableSpan(
name="crm.request",
attributes={"url.full": "https://crm.example/?token=secret", "safe.outcome": "success"},
)
processor.on_end(span)
processor.shutdown()
assert span.attributes["url.full"] == "[REDACTED]"
assert span.attributes["safe.outcome"] == "success"
def test_init_without_endpoint_is_backward_compatible(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setattr(telemetry, "_runtime", None)
runtime = telemetry.init_telemetry("bitrix-sync-worker")
assert runtime.tracer_provider is None
assert runtime.meter_provider is None
assert runtime.logger_provider is None
telemetry.shutdown_telemetry()
def test_each_process_has_distinct_service_resource() -> None:
names = {
telemetry._resource(name).attributes["service.name"] # noqa: SLF001
for name in telemetry.SERVICE_NAMES
}
assert names == {
"bitrix-sync-api",
"bitrix-sync-worker",
"bitrix-sync-reconciliation",
}
def test_init_is_fail_open_when_exporter_construction_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317")
monkeypatch.setattr(telemetry, "_runtime", None)
def fail_exporter(**_kwargs: Any) -> None:
raise RuntimeError("collector unavailable")
monkeypatch.setattr(telemetry, "OTLPSpanExporter", fail_exporter)
runtime = telemetry.init_telemetry("bitrix-sync-reconciliation")
assert runtime.tracer_provider is None
telemetry.shutdown_telemetry()
def test_business_metric_attributes_are_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
captured: list[dict[str, str]] = []
class Instrument:
def add(self, _value: int, attributes: dict[str, str]) -> None:
captured.append(attributes)
def record(self, _value: float, attributes: dict[str, str]) -> None:
captured.append(attributes)
class Meter:
def create_counter(self, *_args: Any, **_kwargs: Any) -> Instrument:
return Instrument()
def create_histogram(self, *_args: Any, **_kwargs: Any) -> Instrument:
return Instrument()
monkeypatch.setattr(telemetry.metrics, "get_meter", lambda _name: Meter())
telemetry.record_workflow("user-2f3c0a5e-identifier", "novel-state", 0.5)
telemetry.record_crm("unknown.dynamic.method", "novel-state", 0.1)
assert captured
assert all("user-2f3c0a5e-identifier" not in values.values() for values in captured)
assert all("novel-state" not in values.values() for values in captured)
def test_business_metrics_are_fail_open(monkeypatch: pytest.MonkeyPatch) -> None:
def unavailable(_name: str) -> None:
raise RuntimeError("metrics unavailable")
monkeypatch.setattr(telemetry.metrics, "get_meter", unavailable)
telemetry.record_claim("task", 1)
@@ -34,8 +34,11 @@ The full step-by-step procedure with gates and copy-paste commands is in
expose unauthenticated `PING` only for health and a password-protected
`safety` user limited to required `han:safety:*` keys/commands. The password
in `MESSAGE_SAFETY_REDIS_URL` must match. Start from
`redis/redis-safety.acl.template`, replace
`REPLACE_WITH_LONG_RANDOM_PASSWORD`, and never commit the password.
`redis/redis-safety.acl.template`, replace both placeholders, and add the
`exporter` user limited to `PING`/`INFO`. Its ACL password must exactly match
the separate `REDIS_EXPORTER_PASSWORD` secret. That secret is a JSON password
map, `{"redis://redis-safety:6379":"<THE_SAME_PASSWORD>"}`, not a raw password
string. Never commit either password.
8. Provision distinct runtime and migration DB credentials.
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` may migrate/activate policy while
`MESSAGE_SAFETY_DATABASE_URL` cannot; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
@@ -43,7 +46,9 @@ The full step-by-step procedure with gates and copy-paste commands is in
role. Migration credentials are mounted only into the `ops` profile jobs.
9. The setup script leaves UFW egress open for bootstrap. Before production,
constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL,
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
S3, Secrets Manager, Bitrix24, DNS/NTP and SigNoz destinations. Host KESL
receives only update-source egress approved by
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
Registry/package access exists only during controlled maintenance windows.
## Who runs what
@@ -75,14 +80,17 @@ Compose first:
`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).
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis,
otel-collector, Redis exporter and nginx exporter). `clamd`/`freshclam` are
absent from Compose; KESL 12.4 standalone and its broker run on the host.
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.
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, 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`.
`.env`. Current self-hosted SigNoz accepts private plaintext OTLP without
authentication; do not provision a fake auth secret or mandatory non-empty
header.
6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM
stored in Secrets Manager, not in the release tree.
@@ -136,6 +144,11 @@ Copy `.env.example` → `.env`, install loader config as
password with `systemd-creds`, edit nginx allow-lists. Details:
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4.
The local Collector receives logs directly over OTLP (no `filelog`), scrapes
only itself plus the Redis/nginx exporters, and collects host metrics through
read-only `/hostfs`. Exporters and nginx `stub_status` use internal networks and
`expose` only; they have no host-published ports.
## 5. PostgreSQL CA and initial public TLS
Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
@@ -147,6 +160,13 @@ Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
`han-processing.service` until Gate 5 completes successfully.
Before Message Safety starts in Gate 5, complete the operator KESL runbook,
then enable/start `han-kesl-scan-broker.socket` and verify KESL/socket status
plus `root:han-message-safety 0660` on `/run/han-kesl/scan.sock`. Only then
start Message Safety. The broker is a custom integration: exact output/exit
semantics of `kesl-control --scan-file --action Inform`, cleanup and throughput
must pass on the target VM2.
| Gate | Purpose |
| --- | --- |
| 1 | Secrets materialized via `han-secrets-vm2.service` |
@@ -185,8 +205,9 @@ Safety cutover on VM1 or Bitrix sync enablement.
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).
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, KESL
version/database date, hourly update, broker/socket status, 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
@@ -199,8 +220,8 @@ Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7.
## Failure policy
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
- Stale/unavailable ClamAV signatures disable file capability only; they never
convert a scan error to allow.
- A stale/unavailable KESL database or broker error disables file capability
only; scan errors remain retryable and eventually return `503`, never allow.
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
- OTEL outage queues within the bounded volume and must not change verdicts.
- Rollback does not downgrade schemas, delete durable tasks/mappings, or run
@@ -224,10 +245,12 @@ health, and restores the previous mode on failure. MOCK has no timeout: keep a
high-severity alert active until explicit `standard`, then verify normal
text/link/file capabilities and an EICAR canary.
## Known image exceptions
## Host KESL and broker
ClamAV images may require UID/path adjustments after validating the exact
digest. Do not weaken `read_only`, capabilities or mounts globally: document
the smallest writable signature/runtime paths and compensate with network and
resource limits. `freshclam` alone receives signature-CDN egress; `clamd`
receives none.
KESL 12.4 standalone and the root-owned fail-closed broker are not Compose
images. The Message Safety worker receives only
`/run/han-kesl/scan.sock`, not `kesl-control`, the Docker socket or host-root
access. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash
of KESL version plus database date. Installation, hourly database updates,
socket permissions, clean/EICAR/error/stale gates and rollback follow
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
@@ -37,8 +37,12 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен
открывать неаутентифицированный `PING` только для health и
защищённого паролем пользователя `safety`, ограниченного необходимыми
ключами/командами `han:safety:*`.
(Пароль в `MESSAGE_SAFETY_REDIS_URL` должен совпадать. Используйте `redis/redis-safety.acl.template`, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
ключами/командами `han:safety:*`. Добавьте пользователя `exporter` только с
`PING`/`INFO`; его пароль в ACL должен в точности совпадать с отдельным
`REDIS_EXPORTER_PASSWORD`. Этот secret хранится в формате JSON password map:
`{"redis://redis-safety:6379":"<ТОТ_ЖЕ_ПАРОЛЬ>"}`, а не как голая строка.
Пароль `safety` в `MESSAGE_SAFETY_REDIS_URL` также должен совпадать с ACL. Используйте
`redis/redis-safety.acl.template`, заменив оба плейсхолдера.
8. Выделите отдельные учётные данные БД для runtime и миграций.
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
@@ -47,8 +51,9 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
`ops`.
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
production ограничьте egress правилами Selectel SG/NAT/proxy до
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
источников ClamAV. Registry/package repositories оставляйте только на
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz.
Для host KESL разрешите только источники обновления из
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на
controlled maintenance window.
## Кто что выполняет
@@ -83,12 +88,15 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
[`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).
otel-collector, Redis exporter, nginx exporter). `clamd`/`freshclam` в
Compose отсутствуют: KESL 12.4 standalone и broker устанавливаются на host.
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 доступом только к этим именам.
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
Отдельный IAM principal VM2 с read-only доступом только к этим именам.
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
Текущий self-hosted SigNoz принимает private plaintext OTLP без auth, поэтому
не создавайте фиктивный auth-secret или обязательный непустой header.
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
PEM хранится в Secrets Manager, не в каталоге релиза.
@@ -301,6 +309,13 @@ DSN, token, password, access/secret key туда не записываются.
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
из mapping.
Локальный Collector принимает traces, metrics и logs напрямую по OTLP; чтение
Docker JSON через `filelog` не используется. Prometheus receiver собирает только
метрики самого Collector, `redis-exporter` и `nginx-exporter`, а `hostmetrics`
метрики VM через read-only `/hostfs`. Exporter-контейнеры имеют только `expose`
во внутренних сетях и не публикуют host ports. В nginx endpoint
`/stub_status` слушает только внутренний `8081`.
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
его в аргументы или history:
@@ -513,7 +528,7 @@ REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
При обновлении KESL policy образ Message Safety должен содержать согласованные
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
(30 дней). После обновления immutable image digest создайте новую config
version; существующую active version не редактируйте и не активируйте повторно:
@@ -535,7 +550,7 @@ NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps \
message-safety-api message-safety-worker clamd freshclam
message-safety-api message-safety-worker
unset NEXT_VERSION
```
@@ -575,8 +590,15 @@ open-file limit быть не должно. Ошибка отсутствующ
Под `root` на VM2:
```sh
cd /opt/han-chat/services
# Сначала полностью выполните operator runbook:
# deployment/kesl/RUNBOOK.KESL.ru.md
systemctl enable --now han-kesl-scan-broker.socket
systemctl --no-pager status kesl han-kesl-scan-broker.socket
test -S /run/han-kesl/scan.sock
stat -c '%U:%G %a %n' /run/han-kesl/scan.sock
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
@@ -585,6 +607,13 @@ open-file limit быть не должно. Ошибка отсутствующ
/usr/local/sbin/han-vm2-compose ps
```
До запуска Message Safety operator KESL runbook обязан подтвердить KESL 12.4
standalone, успешное ежечасное обновление, допустимый database date и canary.
Socket должен иметь `root:han-message-safety 0660` и не быть доступен посторонним
UID. Broker является custom
integration: неизвестный output/exit code `kesl-control --scan-file --action Inform`
считается scanner error, а не clean. Формат и throughput проверяются на target VM2.
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
сам Collector стартует только после этого.
@@ -879,7 +908,7 @@ journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
- container unhealthy/restart/OOM;
- срок TLS;
- возраст ClamAV signatures;
- KESL version/database date, результат ежечасного update и broker/socket status;
- OTEL queue/export errors;
- disk/RAM;
- активный MOCK mode;
@@ -1094,8 +1123,9 @@ plan.
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Устаревшая/недоступная база KESL или ошибка broker отключают только файловую
capability; ошибка сканирования никогда не превращается в allow и ведёт к
retry/`503`.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
@@ -1121,10 +1151,12 @@ han-message-safety-mode mock --text-free false --file-free false
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
## Host KESL и broker
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
KESL 12.4 standalone и root-owned fail-closed broker не являются Compose
образами. Message Safety worker получает только Unix socket
`/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root
ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как
hash KESL version + database date. Установка, ежечасное обновление, права
socket, clean/EICAR/error/stale gates и rollback выполняются строго по
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
@@ -1,7 +1,7 @@
[Unit]
Description=HAN Processing VM2 root Compose stack
Requires=docker.service han-secrets-vm2.service
After=docker.service han-secrets-vm2.service network-online.target
Requires=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket
After=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket network-online.target
[Service]
Type=oneshot
@@ -0,0 +1,95 @@
# Карта доказательств KESL на ВМ2
Заполняется оператором после `RUNBOOK.KESL.ru.md`. Не включать activation code,
secrets, ПД, S3 object key, file bytes, полный checksum или EICAR.
## 1. Изменение
- Change ID / окно:
- Оператор / approvers Security, Service, Operations:
- Hostname, Ubuntu, kernel, architecture:
- KESL package/version, SHA-256, источник:
- HAN release SHA и image digests:
- Коммерческая сборка не заявлена сертифицированной ФСТЭК: да / нет
- KSN decision и правовое основание:
## 2. Baseline и stop conditions
- VM2 containers healthy/running:
- Public/private smoke:
- CPU, available RAM, swap activity, disk/IO wait:
- Safety p95/p99, error rate, queue age:
- Redis latency/blocked clients:
- Restart/OOM, OTEL queue:
- Утверждённые пороги и rollback approver:
## 3. АВЗ.1
Норма: Приказ ФСТЭК №21, приложение АВЗ.1; п. 8.6 — обнаружение
вредоносных программ/информации и реагирование.
- [ ] `kesl.service` active, лицензия действительна.
- [ ] File Threat Protection task 1 = `Started`.
- [ ] `ActionOnThreat=DisinfectDeleteIfNotPossible`, `ScanArchived=No`.
- [ ] fanotify on-access обнаружил и обработал разрешённый EICAR.
- [ ] Событие detection/action присутствует в KESL events.
- [ ] Исключения ограничены фактическими hot-data mountpoint.
- [ ] Broker staging отсутствует в `ExcludedFromScanScope`.
- [ ] Broker подтверждает `scanned >= 1`, `skipped = 0`, `errors = 0`.
- [ ] Firewall, smoke и health после Block успешны.
- [ ] 24 часа без неприемлемой деградации/OOM/restart/5xx.
Артефакты/время/результат:
## 4. АВЗ.2
Норма: Приказ ФСТЭК №21, приложение АВЗ.2 — обновление базы признаков
вредоносных компьютерных программ.
- [ ] Update task 6 вручную завершилась успешно.
- [ ] Базы загружены, дата актуальна.
- [ ] Schedule = Hourly.
- [ ] Наблюдён последующий автоматический successful update.
- [ ] Alert на update failure / unloaded / age >240h / license failure.
- [ ] Назначен ежедневный контроль и owner.
Артефакты/время последнего automatic update:
## 5. Message Safety scan-broker
- [ ] Socket `root:han-message-safety:0660`, TCP listener отсутствует.
- [ ] Clean corpus → `clean`; Message Safety final `200 allow`.
- [ ] EICAR → `infected`; Message Safety sticky `403 deny`.
- [ ] KESL stopped/timeout/unknown output → retry/terminal `503`, не allow/deny.
- [ ] `scanner_engine=kesl`.
- [ ] `signatures_version` меняется при обновлении KESL databases.
- [ ] Старый cache не используется после смены signatures version.
- [ ] Logs/traces не содержат bytes, object key, checksum, filename или secrets.
- [ ] 5 slots / 2 files per second gate пройден.
Результаты corpus/load и ссылки на безопасные метрики:
## 6. РСБ и АНЗ.2
- [ ] Определены и защищены KESL detection/remediation/update/license events.
- [ ] Определены место/срок хранения и экспорт/регламент просмотра.
- [ ] Версия KESL и upgrade lifecycle контролируются.
- [ ] Kernel/Docker/KESL upgrade требует compatibility pilot + evidence delta.
## 7. Cutover и rollback
- [ ] До cutover KESL+ClamAV coexistence не нарушило resource gates.
- [ ] После cutover `clamd`/`freshclam` отсутствуют, старый egress закрыт.
- [ ] Предыдущая совместимая release/config пара зафиксирована.
- [ ] Desk check: application rollback возвращает ClamAV release.
- [ ] Desk check: минимальный host rollback — `kesl-control --stop-task 1`.
- [ ] Rollback не удаляет Docker volumes и не использует `down -v`.
## 8. Итог
- АВЗ.1: принято / не принято; ограничения:
- АВЗ.2: принято / не принято; ограничения:
- Message Safety KESL cutover: принято / не принято:
- Residual risk custom broker / parser / throughput:
- Operations / Security / Service owner, ФИО, подпись, дата:
- Следующий review:

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