Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений
This commit is contained in:
@@ -7,19 +7,23 @@ RELEASE_VERSION=change-me-release
|
||||
LOG_LEVEL=INFO
|
||||
COMPOSE_PROJECT_NAME=han-chat
|
||||
|
||||
# Immutable service images built by their owning modules.
|
||||
API_BACKEND_IMAGE=han-chat-api-backend:local
|
||||
MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local
|
||||
BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local
|
||||
BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local
|
||||
KEYCLOAK_IMAGE=han-chat-keycloak:local
|
||||
SMS_SERVICE_IMAGE=han-chat-sms-service:local
|
||||
# Immutable production images. Replace every example digest during release materialization.
|
||||
API_BACKEND_IMAGE=registry.example.ru/han-chat/api-backend@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
BITRIX_LOCAL_APP_IMAGE=registry.example.ru/han-chat/bitrix-local-app@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
FRONTEND_STATIC_IMAGE=registry.example.ru/han-chat/frontend-static@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
KEYCLOAK_IMAGE=registry.example.ru/han-chat/keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
NGINX_IMAGE=registry.example.ru/han-chat/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
OTEL_QUEUE_INIT_IMAGE=alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
REDIS_IMAGE=registry.example.ru/han-chat/redis@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
SMS_SERVICE_IMAGE=registry.example.ru/han-chat/sms-service@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
TOOLBOX_IMAGE=curlimages/curl@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# Managed PostgreSQL is external to Compose. Credential-bearing DSNs are secrets.
|
||||
HAN_PG_HOST=managed-pg.private.example
|
||||
HAN_PG_PORT=5433
|
||||
HAN_PG_DATABASE=han_chat
|
||||
PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem
|
||||
PG_CA_HOST_PATH=/etc/han/ca/managed-postgresql-ca.pem
|
||||
# PgBouncer session mode uses database-level search_path; runtime DSNs must not pass options/currentSchema.
|
||||
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||
KEYCLOAK_DB_SCHEMA=keycloak
|
||||
@@ -33,8 +37,8 @@ PUBLIC_AUTH_URL=https://chat.example.ru/auth
|
||||
NGINX_HTTP_PORT=80
|
||||
NGINX_HTTPS_PORT=443
|
||||
NGINX_TLS_ENABLED=true
|
||||
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
|
||||
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
|
||||
NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem
|
||||
NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem
|
||||
NGINX_HSTS_MAX_AGE=31536000
|
||||
NGINX_CLIENT_MAX_BODY_SIZE=8m
|
||||
NGINX_RATE_LIMIT_API=60r/m
|
||||
@@ -53,7 +57,7 @@ NGINX_MESSAGE_READ_TIMEOUT_SEC=330
|
||||
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
|
||||
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
||||
BITRIX_FRAME_ANCESTORS=https://*.bitrix24.ru
|
||||
S3_CONNECT_SRC=https://*.s3.ru-7.storage.selcloud.ru
|
||||
S3_CONNECT_SRC=https://*.s3.ru-6.storage.selcloud.ru
|
||||
|
||||
FRONTEND_DEV_PROXY_ENABLED=false
|
||||
# При false значение не используется
|
||||
@@ -85,20 +89,15 @@ IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms
|
||||
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
||||
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
||||
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
|
||||
MESSAGE_SAFETY_URL=http://message-safety:8080
|
||||
MESSAGE_SAFETY_URL=https://processing.internal:8443
|
||||
# Docker extra_hosts mapping for VM2 private listener: <hostname>=<private-ip>.
|
||||
MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4
|
||||
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
|
||||
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC=5
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2
|
||||
MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300
|
||||
MESSAGE_SAFETY_TASK_TTL_SEC=900
|
||||
MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60
|
||||
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
|
||||
#Отключение синхронизации (при отключенной синхронизации параметры ниже не работают)
|
||||
BITRIX_SYNC_ENABLED=false
|
||||
BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru
|
||||
BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC=60
|
||||
BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC=30
|
||||
BITRIX_SYNC_CRM_MAX_CONCURRENCY=2
|
||||
BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50
|
||||
|
||||
BITRIX_CLIENT_ID=change-me-client-id
|
||||
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||
BITRIX_CONNECTOR_NAME=HAN Mobile App
|
||||
|
||||
@@ -14,6 +14,8 @@ RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
|
||||
COPY alembic.ini ./
|
||||
COPY alembic ./alembic
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/han-container-entrypoint \
|
||||
&& /bin/sh -n /usr/local/bin/han-container-entrypoint
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
|
||||
@@ -56,11 +56,12 @@ han-notification-draft-cleanup-worker
|
||||
продюсера Notification Center; в БД синхронизируется только SHA-256 hash;
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT` — опциональный endpoint collector.
|
||||
|
||||
Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety
|
||||
не передаются этому контейнеру. В production подключение PostgreSQL должно использовать
|
||||
TLS. Target `MESSAGE_SAFETY_URL=https://processing.internal:8443`; certificate
|
||||
проверяется по internal CA, plaintext HTTP запрещён. Текущий Docker hostname
|
||||
`message-safety` относится только к legacy stub до cutover.
|
||||
Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN`
|
||||
сохраняется как caller secret API backend; S3 credentials сервису Safety не
|
||||
передаются. В production подключение PostgreSQL должно использовать TLS. Target
|
||||
`MESSAGE_SAFETY_URL=https://processing.internal:8443`, API prefix
|
||||
`/internal/safety/v2`; certificate проверяется по CA из
|
||||
`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён.
|
||||
|
||||
Smoke-сценарий `producer_test`: отправить `POST
|
||||
/internal/notifications/v1/notifications` с `Authorization: Bearer
|
||||
|
||||
@@ -26,37 +26,70 @@ def upgrade() -> None:
|
||||
ADD COLUMN IF NOT EXISTS last_error_code varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS last_error_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS completed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS cancel_reason varchar(255);
|
||||
|
||||
ADD COLUMN IF NOT EXISTS cancel_reason varchar(255)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE han_app.sync_queue
|
||||
SET status = CASE status
|
||||
WHEN 'processing' THEN 'pending'
|
||||
WHEN 'failed' THEN 'retry_wait'
|
||||
ELSE status
|
||||
END
|
||||
WHERE status IN ('processing', 'failed');
|
||||
|
||||
WHERE status IN ('processing', 'failed')
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.sync_queue
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_status_check;
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_status_check
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.sync_queue
|
||||
ADD CONSTRAINT sync_queue_status_check CHECK (
|
||||
status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled')
|
||||
) NOT VALID;
|
||||
) NOT VALID
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.sync_queue
|
||||
VALIDATE CONSTRAINT sync_queue_status_check;
|
||||
|
||||
VALIDATE CONSTRAINT sync_queue_status_check
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.sync_queue
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key;
|
||||
DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next;
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key
|
||||
"""
|
||||
)
|
||||
op.execute("DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_claim
|
||||
ON han_app.sync_queue(status, next_attempt_at, created_at);
|
||||
ON han_app.sync_queue(status, next_attempt_at, created_at)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_expired_lease
|
||||
ON han_app.sync_queue(locked_until) WHERE status = 'leased';
|
||||
ON han_app.sync_queue(locked_until) WHERE status = 'leased'
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_entity_history
|
||||
ON han_app.sync_queue(entity_type, entity_id, created_at DESC);
|
||||
ON han_app.sync_queue(entity_type, entity_id, created_at DESC)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sync_queue_active_dedup
|
||||
ON han_app.sync_queue(dedup_key)
|
||||
WHERE status IN ('pending','leased','retry_wait');
|
||||
WHERE status IN ('pending','leased','retry_wait')
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -173,13 +206,20 @@ def upgrade() -> None:
|
||||
updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_profile_contact_sync
|
||||
AFTER INSERT OR UPDATE OF record_status
|
||||
ON han_app.client_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
+52
-14
@@ -21,32 +21,58 @@ def upgrade() -> None:
|
||||
ALTER TABLE han_app.messages
|
||||
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
|
||||
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.message_attachments
|
||||
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024);
|
||||
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.message_attachments
|
||||
DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check;
|
||||
DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.message_attachments
|
||||
ADD CONSTRAINT message_attachments_scan_status_check
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.message_attachments
|
||||
VALIDATE CONSTRAINT message_attachments_scan_status_check;
|
||||
|
||||
VALIDATE CONSTRAINT message_attachments_scan_status_check
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.safety_tasks
|
||||
ADD COLUMN IF NOT EXISTS poll_location varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS rules_version varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS expires_at timestamptz;
|
||||
ADD COLUMN IF NOT EXISTS expires_at timestamptz
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE han_app.safety_tasks
|
||||
SET poll_location = '/internal/safety/v2/messages/tasks/' || task_id,
|
||||
expires_at = deadline_at
|
||||
WHERE poll_location IS NULL OR expires_at IS NULL;
|
||||
WHERE poll_location IS NULL OR expires_at IS NULL
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.safety_tasks
|
||||
ALTER COLUMN poll_location SET NOT NULL,
|
||||
ALTER COLUMN expires_at SET NOT NULL;
|
||||
ALTER COLUMN expires_at SET NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -58,14 +84,26 @@ def upgrade() -> None:
|
||||
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check;
|
||||
DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
ADD CONSTRAINT client_upload_drafts_scan_status_check
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
VALIDATE CONSTRAINT client_upload_drafts_scan_status_check;
|
||||
VALIDATE CONSTRAINT client_upload_drafts_scan_status_check
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@@ -254,11 +254,23 @@ class SafetyClient:
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
response = await self.http.get(
|
||||
f"{str(self.settings.message_safety_url).rstrip('/')}/health/ready",
|
||||
f"{str(self.settings.message_safety_url).rstrip('/')}/internal/safety/status",
|
||||
timeout=2,
|
||||
)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
body = response.json()
|
||||
capabilities = body.get("capabilities", {})
|
||||
return (
|
||||
body.get("status") in {"ok", "degraded"}
|
||||
and body.get("processing_mode") == "standard"
|
||||
and type(body.get("config_version")) is int
|
||||
and all(
|
||||
capabilities.get(name) == "ready"
|
||||
for name in ("text", "links", "files", "worker")
|
||||
)
|
||||
)
|
||||
except (httpx.HTTPError, AttributeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -138,9 +138,7 @@ async def lifespan(app: FastAPI):
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
app.state.http = httpx.AsyncClient()
|
||||
app.state.safety_http = httpx.AsyncClient(
|
||||
verify=settings.message_safety_ca_file or True
|
||||
)
|
||||
app.state.safety_http = httpx.AsyncClient(verify=settings.message_safety_ca_file)
|
||||
app.state.redis = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
|
||||
app.state.jwks = JWKSValidator(settings, app.state.http)
|
||||
|
||||
@@ -74,6 +74,49 @@ def safety_reply_message(dialog_id: uuid.UUID, content_kind: str) -> Message:
|
||||
)
|
||||
|
||||
|
||||
def safety_task_recovery_at(now: datetime, settings: Settings) -> datetime:
|
||||
"""Keep recovery behind the synchronous poller's worst-case final attempt."""
|
||||
return now + timedelta(
|
||||
seconds=settings.message_safety_task_poll_max_sec
|
||||
+ settings.message_safety_task_poll_interval_sec
|
||||
+ 5
|
||||
)
|
||||
|
||||
|
||||
async def ensure_delivery_outbox(
|
||||
session: AsyncSession,
|
||||
message_id: uuid.UUID,
|
||||
external_chat_id: uuid.UUID,
|
||||
payload_json: dict[str, Any],
|
||||
next_attempt_at: datetime,
|
||||
) -> DeliveryOutbox:
|
||||
"""Create the per-message outbox row or return the concurrent winner."""
|
||||
statement = (
|
||||
insert(DeliveryOutbox)
|
||||
.values(
|
||||
id=uuid.uuid4(),
|
||||
message_id=message_id,
|
||||
external_chat_id=external_chat_id,
|
||||
payload_json=payload_json,
|
||||
next_attempt_at=next_attempt_at,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[DeliveryOutbox.message_id])
|
||||
.returning(DeliveryOutbox.id)
|
||||
)
|
||||
outbox_id = (await session.execute(statement)).scalar_one_or_none()
|
||||
if outbox_id is not None:
|
||||
outbox = await session.get(DeliveryOutbox, outbox_id)
|
||||
else:
|
||||
outbox = (
|
||||
await session.execute(
|
||||
select(DeliveryOutbox).where(DeliveryOutbox.message_id == message_id)
|
||||
)
|
||||
).scalar_one()
|
||||
if outbox is None: # pragma: no cover - defensive guard for an invalid DB response
|
||||
raise RuntimeError("Delivery outbox row was not returned")
|
||||
return outbox
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
||||
self.code, self.status, self.message = code, status, message
|
||||
@@ -918,7 +961,9 @@ async def send_message(
|
||||
expires_at=datetime.fromisoformat(verdict["expires_at"].replace("Z", "+00:00")),
|
||||
deadline_at=now
|
||||
+ timedelta(seconds=settings.message_safety_task_poll_max_sec + 900),
|
||||
next_poll_at=now,
|
||||
# The request owns the first polling window. Recovery starts
|
||||
# afterwards if the request crashes before completing the task.
|
||||
next_poll_at=safety_task_recovery_at(datetime.now(UTC), settings),
|
||||
)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
@@ -987,7 +1032,8 @@ async def send_message(
|
||||
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
|
||||
)
|
||||
message.safety_status = "allowed"
|
||||
outbox = DeliveryOutbox(
|
||||
outbox = await ensure_delivery_outbox(
|
||||
session,
|
||||
message_id=message.id,
|
||||
external_chat_id=dialog_id,
|
||||
payload_json={
|
||||
@@ -1019,7 +1065,6 @@ async def send_message(
|
||||
next_attempt_at=datetime.now(UTC)
|
||||
+ timedelta(seconds=settings.bitrix_local_app_http_timeout_sec + 5),
|
||||
)
|
||||
session.add(outbox)
|
||||
await session.commit()
|
||||
await publish_message_status(fanout, message, settings)
|
||||
delivery_payload = await fresh_openlines_payload(outbox.payload_json, s3)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
@@ -26,12 +28,28 @@ from app.integrations import (
|
||||
from app.notification_models import ClientUploadDraft
|
||||
from app.notification_service import expire_notifications
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.services import load_settings, publish_dialog_status, publish_message_status
|
||||
from app.services import (
|
||||
ensure_delivery_outbox,
|
||||
load_settings,
|
||||
publish_dialog_status,
|
||||
publish_message_status,
|
||||
)
|
||||
from app.settings import Settings, get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def worker_http_clients(
|
||||
settings: Settings,
|
||||
) -> AsyncIterator[tuple[httpx.AsyncClient, httpx.AsyncClient]]:
|
||||
async with (
|
||||
httpx.AsyncClient() as openlines_http,
|
||||
httpx.AsyncClient(verify=settings.message_safety_ca_file) as safety_http,
|
||||
):
|
||||
yield openlines_http, safety_http
|
||||
|
||||
|
||||
async def delivery_once(
|
||||
db: Database,
|
||||
client: OpenLinesClient,
|
||||
@@ -171,8 +189,8 @@ async def safety_once(
|
||||
else None
|
||||
)
|
||||
if dialog and user:
|
||||
session.add(
|
||||
DeliveryOutbox(
|
||||
await ensure_delivery_outbox(
|
||||
session,
|
||||
message_id=message.id,
|
||||
external_chat_id=dialog.id,
|
||||
payload_json={
|
||||
@@ -187,14 +205,16 @@ async def safety_once(
|
||||
"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 []
|
||||
),
|
||||
@@ -202,7 +222,6 @@ async def safety_once(
|
||||
},
|
||||
next_attempt_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
elif verdict["_status"] == 403 and message:
|
||||
message.safety_processing_mode = verdict["processing_mode"]
|
||||
message.safety_config_version = verdict["config_version"]
|
||||
@@ -270,14 +289,14 @@ async def cleanup_once(db: Database, s3: S3Client, batch_size: int = 100) -> int
|
||||
async def loop(kind: str) -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings.database_url)
|
||||
http = httpx.AsyncClient()
|
||||
safety = SafetyClient(settings, http)
|
||||
openlines = OpenLinesClient(settings, http)
|
||||
s3 = S3Client(settings)
|
||||
redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
|
||||
fanout = RealtimeFanout(redis_rt)
|
||||
worker_id = f"{kind}-{uuid.uuid4()}"
|
||||
try:
|
||||
async with worker_http_clients(settings) as (openlines_http, safety_http):
|
||||
safety = SafetyClient(settings, safety_http)
|
||||
openlines = OpenLinesClient(settings, openlines_http)
|
||||
while True:
|
||||
count = 0
|
||||
if kind == "delivery":
|
||||
@@ -289,7 +308,6 @@ async def loop(kind: str) -> None:
|
||||
if not count:
|
||||
await asyncio.sleep(settings.worker_poll_interval_sec)
|
||||
finally:
|
||||
await http.aclose()
|
||||
await redis_rt.aclose()
|
||||
await db.close()
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ services:
|
||||
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-https://processing.internal:8443}
|
||||
MESSAGE_SAFETY_API_PREFIX: ${MESSAGE_SAFETY_API_PREFIX:-/internal/safety/v2}
|
||||
MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.integrations import (
|
||||
)
|
||||
from app.realtime import CHANNEL_PREFIX
|
||||
from app.settings import Settings
|
||||
from app.workers import worker_http_clients
|
||||
|
||||
|
||||
def settings() -> Settings:
|
||||
@@ -25,8 +26,9 @@ def settings() -> Settings:
|
||||
"KEYCLOAK_INTERNAL_URL": "http://keycloak:8080",
|
||||
"KEYCLOAK_REALM": "han",
|
||||
"KEYCLOAK_AUDIENCE": "api",
|
||||
"MESSAGE_SAFETY_URL": "http://safety:8080",
|
||||
"MESSAGE_SAFETY_URL": "https://processing.internal:8443",
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token",
|
||||
"MESSAGE_SAFETY_CA_FILE": "/run/config/message-safety-internal-ca.pem",
|
||||
"BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080",
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token",
|
||||
"BITRIX_API_INBOX_TOKEN": "inbox-token",
|
||||
@@ -42,6 +44,85 @@ def settings() -> Settings:
|
||||
return Settings.model_validate(common)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_uses_isolated_tls_client_for_remote_safety(monkeypatch) -> None:
|
||||
created = []
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.closed = False
|
||||
created.append(self)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
self.closed = True
|
||||
|
||||
monkeypatch.setattr("app.workers.httpx.AsyncClient", FakeAsyncClient)
|
||||
|
||||
async with worker_http_clients(settings()) as (openlines_http, safety_http):
|
||||
assert openlines_http.kwargs == {}
|
||||
assert safety_http.kwargs == {
|
||||
"verify": "/run/config/message-safety-internal-ca.pem"
|
||||
}
|
||||
assert openlines_http is not safety_http
|
||||
|
||||
assert all(client.closed for client in created)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_ready_uses_private_status_alias_and_accepts_redis_degradation() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/internal/safety/status"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "degraded",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 1,
|
||||
"capabilities": {
|
||||
"text": "ready",
|
||||
"links": "ready",
|
||||
"files": "ready",
|
||||
"worker": "ready",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
assert await SafetyClient(settings(), http).ready() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("processing_mode", "files"),
|
||||
(("mock", "ready"), ("standard", "unavailable")),
|
||||
)
|
||||
async def test_safety_ready_rejects_unsafe_mode_or_unavailable_capability(
|
||||
processing_mode: str, files: str
|
||||
) -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "degraded",
|
||||
"processing_mode": processing_mode,
|
||||
"config_version": 1,
|
||||
"capabilities": {
|
||||
"text": "ready",
|
||||
"links": "ready",
|
||||
"files": files,
|
||||
"worker": "ready",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
assert await SafetyClient(settings(), http).ready() is False
|
||||
|
||||
|
||||
def test_realtime_channel_matches_redis_acl_namespace() -> None:
|
||||
assert CHANNEL_PREFIX == "han:rt:dialog:"
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
@@ -14,7 +17,12 @@ from app.schemas import (
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message
|
||||
from app.services import (
|
||||
MESSAGE_SAFETY_REPLIES,
|
||||
ensure_delivery_outbox,
|
||||
safety_reply_message,
|
||||
safety_task_recovery_at,
|
||||
)
|
||||
|
||||
|
||||
def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None:
|
||||
@@ -67,6 +75,41 @@ def test_message_safety_business_replies_are_content_specific() -> None:
|
||||
assert reply.delivery_status == "delivered"
|
||||
|
||||
|
||||
def test_safety_recovery_starts_after_synchronous_polling_window() -> None:
|
||||
now = datetime(2026, 8, 19, tzinfo=UTC)
|
||||
settings = SimpleNamespace(
|
||||
message_safety_task_poll_max_sec=300,
|
||||
message_safety_task_poll_interval_sec=2,
|
||||
)
|
||||
|
||||
assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307
|
||||
|
||||
|
||||
async def test_delivery_outbox_returns_concurrent_insert_winner() -> None:
|
||||
existing = object()
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(scalar_one_or_none=lambda: None),
|
||||
SimpleNamespace(scalar_one=lambda: existing),
|
||||
]
|
||||
),
|
||||
get=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await ensure_delivery_outbox(
|
||||
session,
|
||||
message_id=uuid.uuid4(),
|
||||
external_chat_id=uuid.uuid4(),
|
||||
payload_json={"message_id": "test"},
|
||||
next_attempt_at=datetime(2026, 8, 19, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert result is existing
|
||||
assert session.execute.await_count == 2
|
||||
session.get.assert_not_awaited()
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical_and_user_scoped() -> None:
|
||||
user = uuid.uuid4()
|
||||
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
|
||||
|
||||
@@ -7,6 +7,8 @@ COPY alembic ./alembic
|
||||
COPY alembic.ini pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/han-container-entrypoint \
|
||||
&& /bin/sh -n /usr/local/bin/han-container-entrypoint
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
WORKDIR /service
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -1,30 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://unused
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
[handlers]
|
||||
keys = console
|
||||
[formatters]
|
||||
keys = generic
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -1,38 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from alembic import context
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
config = context.config
|
||||
database_url = os.environ["BITRIX_SYNC_DATABASE_URL"]
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url",
|
||||
database_url.replace("%", "%%"),
|
||||
)
|
||||
target_metadata = None
|
||||
|
||||
|
||||
def run_offline() -> None:
|
||||
context.configure(url=config.get_main_option("sqlalchemy.url"), literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_online() -> None:
|
||||
engine = create_postgres_engine(database_url)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def do_run(connection) -> None:
|
||||
context.configure(connection=connection)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_offline()
|
||||
else:
|
||||
asyncio.run(run_online())
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
"""Establish the bitrix-sync connectivity-stub migration baseline."""
|
||||
|
||||
revision = "0001_sync_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The connectivity stub deliberately owns no runtime tables.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1 +0,0 @@
|
||||
"""HAN bitrix-sync DB connectivity stub."""
|
||||
@@ -1,315 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated, Protocol
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
app_env: str = "production-like"
|
||||
bitrix_sync_enabled: bool = True
|
||||
bitrix_sync_database_url: str | None = None
|
||||
bitrix_sync_service_token: str = Field(min_length=16)
|
||||
bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05)
|
||||
bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05)
|
||||
bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5)
|
||||
bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05)
|
||||
bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05)
|
||||
bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1)
|
||||
bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5)
|
||||
bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled(self) -> Settings:
|
||||
if self.bitrix_sync_enabled and not self.bitrix_sync_database_url:
|
||||
raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled")
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class Snapshot:
|
||||
state: str
|
||||
started_at: datetime
|
||||
last_started_at: datetime | None = None
|
||||
last_finished_at: datetime | None = None
|
||||
last_success_at: datetime | None = None
|
||||
success: bool | None = None
|
||||
duration_ms: int | None = None
|
||||
error_code: str | None = None
|
||||
consecutive_failures: int = 0
|
||||
next_check_at: datetime | None = None
|
||||
worker_running: bool = False
|
||||
|
||||
|
||||
class Probe(Protocol):
|
||||
async def check(self) -> None: ...
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
class DatabaseProbe:
|
||||
def __init__(self, engine: AsyncEngine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
async def check(self) -> None:
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.scalar(text("SELECT 1"))
|
||||
if result != 1:
|
||||
raise RuntimeError("unexpected_result")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
|
||||
|
||||
def classify_error(exc: Exception) -> str:
|
||||
name = type(exc).__name__.lower()
|
||||
text_value = str(exc).lower()
|
||||
if isinstance(exc, TimeoutError):
|
||||
return "db_query_timeout"
|
||||
if "auth" in name or "password" in text_value:
|
||||
return "db_auth_failed"
|
||||
if "ssl" in name or "tls" in text_value or "certificate" in text_value:
|
||||
return "db_tls_failed"
|
||||
if str(exc) == "unexpected_result":
|
||||
return "unexpected_result"
|
||||
return "db_unavailable"
|
||||
|
||||
|
||||
def jsonable(value):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
return value
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI:
|
||||
cfg = settings or Settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.snapshot = Snapshot(
|
||||
state="disabled" if not cfg.bitrix_sync_enabled else "starting",
|
||||
started_at=utcnow(),
|
||||
)
|
||||
app.state.stop = asyncio.Event()
|
||||
app.state.probe = probe
|
||||
app.state.loop_task = None
|
||||
if cfg.bitrix_sync_enabled:
|
||||
if app.state.probe is None:
|
||||
engine = create_postgres_engine(
|
||||
cfg.bitrix_sync_database_url or "",
|
||||
pool_size=cfg.bitrix_sync_db_pool_size,
|
||||
max_overflow=0,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec,
|
||||
server_settings={"application_name": "han-bitrix-sync"},
|
||||
)
|
||||
app.state.probe = DatabaseProbe(engine)
|
||||
await run_probe(app)
|
||||
app.state.snapshot.worker_running = True
|
||||
app.state.loop_task = asyncio.create_task(
|
||||
periodic_loop(app), name="db-connectivity-probe"
|
||||
)
|
||||
yield
|
||||
app.state.snapshot.state = "stopping"
|
||||
app.state.stop.set()
|
||||
if app.state.loop_task:
|
||||
app.state.loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await app.state.loop_task
|
||||
if app.state.probe:
|
||||
await app.state.probe.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="HAN Bitrix Sync Connectivity Stub",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url=None if cfg.app_env != "test" else "/docs",
|
||||
)
|
||||
app.state.settings = cfg
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_id(request: Request, call_next):
|
||||
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
return response
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_: Request, exc: HTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
|
||||
@app.get("/health/live")
|
||||
async def live():
|
||||
return {"status": "live"}
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def ready(request: Request):
|
||||
snapshot: Snapshot = request.app.state.snapshot
|
||||
if not cfg.bitrix_sync_enabled:
|
||||
return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503)
|
||||
age = (
|
||||
(utcnow() - snapshot.last_success_at).total_seconds()
|
||||
if snapshot.last_success_at
|
||||
else None
|
||||
)
|
||||
max_age = max(
|
||||
cfg.bitrix_sync_ready_max_staleness_sec,
|
||||
2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio),
|
||||
)
|
||||
is_ready = (
|
||||
snapshot.state == "healthy"
|
||||
and snapshot.worker_running
|
||||
and age is not None
|
||||
and age <= max_age
|
||||
)
|
||||
if is_ready:
|
||||
return {
|
||||
"status": "ready",
|
||||
"mode": "db_connectivity_stub",
|
||||
"database": {
|
||||
"status": "ok",
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"age_seconds": round(age or 0, 3),
|
||||
},
|
||||
}
|
||||
reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable"
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"reason": reason,
|
||||
"database": {
|
||||
"status": "down",
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"consecutive_failures": snapshot.consecutive_failures,
|
||||
},
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
@app.get("/internal/sync/v1/status")
|
||||
async def status(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
candidate = (
|
||||
authorization[7:] if authorization and authorization.startswith("Bearer ") else ""
|
||||
)
|
||||
if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token):
|
||||
raise HTTPException(
|
||||
401,
|
||||
{
|
||||
"error": {
|
||||
"code": "service_unauthorized",
|
||||
"message": "Service authentication failed",
|
||||
"request_id": request.state.request_id,
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
snapshot: Snapshot = request.app.state.snapshot
|
||||
last_check = None
|
||||
if snapshot.last_started_at:
|
||||
last_check = {
|
||||
"started_at": jsonable(snapshot.last_started_at),
|
||||
"finished_at": jsonable(snapshot.last_finished_at),
|
||||
"success": snapshot.success,
|
||||
"duration_ms": snapshot.duration_ms,
|
||||
"error_code": snapshot.error_code,
|
||||
}
|
||||
next_in = (
|
||||
max(0, (snapshot.next_check_at - utcnow()).total_seconds())
|
||||
if snapshot.next_check_at
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"service": "bitrix-sync",
|
||||
"enabled": cfg.bitrix_sync_enabled,
|
||||
"mode": "db_connectivity_stub",
|
||||
"crm_sync_implemented": False,
|
||||
"state": snapshot.state,
|
||||
"started_at": jsonable(snapshot.started_at),
|
||||
"last_check": last_check,
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"consecutive_failures": snapshot.consecutive_failures,
|
||||
"next_check_in_seconds": round(next_in, 3) if next_in is not None else None,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def run_probe(app: FastAPI) -> None:
|
||||
cfg: Settings = app.state.settings
|
||||
snapshot: Snapshot = app.state.snapshot
|
||||
snapshot.last_started_at = utcnow()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec):
|
||||
await app.state.probe.check()
|
||||
except Exception as exc:
|
||||
snapshot.success = False
|
||||
snapshot.error_code = classify_error(exc)
|
||||
snapshot.consecutive_failures += 1
|
||||
snapshot.state = "degraded"
|
||||
else:
|
||||
snapshot.success = True
|
||||
snapshot.error_code = None
|
||||
snapshot.consecutive_failures = 0
|
||||
snapshot.last_success_at = utcnow()
|
||||
snapshot.state = "healthy"
|
||||
finally:
|
||||
snapshot.last_finished_at = utcnow()
|
||||
snapshot.duration_ms = round((time.monotonic() - started) * 1000)
|
||||
|
||||
|
||||
async def periodic_loop(app: FastAPI) -> None:
|
||||
cfg: Settings = app.state.settings
|
||||
snapshot: Snapshot = app.state.snapshot
|
||||
try:
|
||||
while not app.state.stop.is_set():
|
||||
if snapshot.consecutive_failures:
|
||||
cap = min(
|
||||
cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1),
|
||||
cfg.bitrix_sync_db_retry_max_sec,
|
||||
)
|
||||
delay = random.uniform(0, cap)
|
||||
else:
|
||||
jitter = (
|
||||
cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio
|
||||
)
|
||||
delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter)
|
||||
snapshot.next_check_at = utcnow() + timedelta(seconds=delay)
|
||||
try:
|
||||
await asyncio.wait_for(app.state.stop.wait(), timeout=delay)
|
||||
break
|
||||
except TimeoutError:
|
||||
await run_probe(app)
|
||||
finally:
|
||||
snapshot.worker_running = False
|
||||
|
||||
|
||||
_settings = Settings()
|
||||
app = create_app(_settings)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
|
||||
@@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
|
||||
def asyncpg_dsn(url: str) -> str:
|
||||
if url.startswith("postgresql+asyncpg://"):
|
||||
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
return url
|
||||
|
||||
|
||||
def create_postgres_engine(
|
||||
url: str,
|
||||
*,
|
||||
server_settings: dict[str, str] | None = None,
|
||||
**engine_options: Any,
|
||||
) -> AsyncEngine:
|
||||
dsn = asyncpg_dsn(url)
|
||||
|
||||
async def connect():
|
||||
return await asyncpg.connect(dsn=dsn, server_settings=server_settings)
|
||||
|
||||
return create_async_engine(
|
||||
"postgresql+asyncpg://",
|
||||
async_creator=connect,
|
||||
**engine_options,
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec "$@"
|
||||
@@ -1,46 +0,0 @@
|
||||
openapi: 3.1.0
|
||||
info: {title: HAN Bitrix Sync Connectivity Stub, version: 1.0.0}
|
||||
paths:
|
||||
/health/live:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: Process is live
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/Live"}}}
|
||||
/health/ready:
|
||||
get:
|
||||
responses:
|
||||
"200": {description: Latest PostgreSQL probe is fresh and successful}
|
||||
"503": {description: Disabled, stale, or database unavailable}
|
||||
/internal/sync/v1/status:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||
responses:
|
||||
"200":
|
||||
description: Connectivity-loop status
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/Status"}}}
|
||||
"401": {description: Service authentication failed}
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: {type: http, scheme: bearer}
|
||||
schemas:
|
||||
Live:
|
||||
type: object
|
||||
required: [status]
|
||||
properties: {status: {const: live}}
|
||||
Status:
|
||||
type: object
|
||||
required: [service, enabled, mode, crm_sync_implemented, state, started_at]
|
||||
properties:
|
||||
service: {const: bitrix-sync}
|
||||
enabled: {type: boolean}
|
||||
mode: {const: db_connectivity_stub}
|
||||
crm_sync_implemented: {const: false}
|
||||
state: {type: string, enum: [starting, disabled, healthy, degraded, stopping]}
|
||||
started_at: {type: string, format: date-time}
|
||||
last_check: {type: [object, "null"]}
|
||||
last_success_at: {type: [string, "null"], format: date-time}
|
||||
consecutive_failures: {type: integer, minimum: 0}
|
||||
next_check_in_seconds: {type: [number, "null"]}
|
||||
@@ -1,30 +0,0 @@
|
||||
[project]
|
||||
name = "han-bitrix-sync"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"fastapi>=0.116,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -1,65 +0,0 @@
|
||||
import os
|
||||
|
||||
os.environ.setdefault("BITRIX_SYNC_ENABLED", "false")
|
||||
os.environ.setdefault("BITRIX_SYNC_SERVICE_TOKEN", "test-sync-token-32-characters")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import Settings, create_app
|
||||
|
||||
|
||||
class Probe:
|
||||
def __init__(self, fail=False):
|
||||
self.fail = fail
|
||||
self.calls = 0
|
||||
|
||||
async def check(self):
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
raise OSError("down")
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_semantics():
|
||||
settings = Settings(
|
||||
bitrix_sync_enabled=False,
|
||||
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||
)
|
||||
app = create_app(settings)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
assert (await client.get("/health/live")).status_code == 200
|
||||
ready = await client.get("/health/ready")
|
||||
assert ready.status_code == 503
|
||||
assert ready.json()["reason"] == "sync_disabled"
|
||||
status = await client.get(
|
||||
"/internal/sync/v1/status",
|
||||
headers={"Authorization": "Bearer test-sync-token-32-characters"},
|
||||
)
|
||||
assert status.json()["state"] == "disabled"
|
||||
assert status.json()["crm_sync_implemented"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_probe_and_auth():
|
||||
probe = Probe()
|
||||
settings = Settings(
|
||||
bitrix_sync_enabled=True,
|
||||
bitrix_sync_database_url="postgresql://unused/unused",
|
||||
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||
bitrix_sync_db_check_interval_sec=60,
|
||||
)
|
||||
app = create_app(settings, probe)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
assert probe.calls == 1
|
||||
assert (await client.get("/health/ready")).status_code == 200
|
||||
assert (await client.get("/internal/sync/v1/status")).status_code == 401
|
||||
@@ -0,0 +1,5 @@
|
||||
*.sh text eol=lf
|
||||
preflight.sh text eol=lf
|
||||
secrets/han-compose text eol=lf
|
||||
secrets/han-secrets text eol=lf
|
||||
*.service text eol=lf
|
||||
@@ -1,826 +1,11 @@
|
||||
# Подробная инструкция по развертыванию и запуску HAN Chat
|
||||
# Legacy deployment guide — не использовать в production
|
||||
|
||||
Эта инструкция описывает первый запуск **текущего legacy/stub проекта ВМ1** на одной виртуальной
|
||||
машине с Ubuntu 24.04. Она не разворачивает target ВМ2 Processing и не подтверждает production-готовность Message Safety v2. Все команды предполагают, что проект расположен в
|
||||
`/opt/han-chat/backend`, а команды Docker Compose выполняются из этого каталога.
|
||||
Этот файл сохранён только как совместимый указатель для старых ссылок.
|
||||
Single-VM/stub команды удалены: они выдавали `deploy` root-equivalent доступ к
|
||||
Docker, изменяли production-каталог и не обеспечивали target cutover ВМ1→ВМ2.
|
||||
|
||||
PostgreSQL и Selectel S3 не запускаются в Docker Compose: их необходимо создать
|
||||
заранее как внешние управляемые сервисы. Из интернета должны быть доступны только
|
||||
порты 80 и 443 виртуальной машины.
|
||||
Для fresh production ВМ1 используйте только
|
||||
[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md).
|
||||
|
||||
## 1. Что потребуется до начала работы
|
||||
|
||||
Подготовьте:
|
||||
|
||||
1. Виртуальную машину с Ubuntu 24.04 и минимум 4 vCPU, 8 ГБ RAM и 40 ГБ диска.
|
||||
2. SSH-доступ к VM пользователем с правом `sudo`.
|
||||
3. Домен, например `chat.example.ru`, и возможность изменить его DNS.
|
||||
4. Управляемый PostgreSQL, доступный VM по приватной сети.
|
||||
5. Три приватных бакета Selectel S3.
|
||||
6. Учетные данные приложения Bitrix24.
|
||||
7. При необходимости — удаленный OTLP-бэкенд для телеметрии.
|
||||
8. Локальную копию каталога `HAN_chat_specification/codebase/backend` либо URL
|
||||
Git-репозитория, из которого его можно получить.
|
||||
|
||||
Для первого тестового запуска допустимы mock OTP, заглушка Message Safety и
|
||||
заглушка bitrix-sync. Они не являются полноценными production-реализациями.
|
||||
|
||||
Целевой cutover выполняется по `modules/module-10-deployment-runbook.md`: самостоятельная ВМ2, root Compose/systemd unit, собственный nginx с public exact CRM webhook `80/443` и private Message Safety listener `8443`, раздельные TLS-контуры, secrets/IAM, egress allow-list и local OTEL Collector. Не переносите команды этого single-VM guide на ВМ2 без VM2-specific manifests.
|
||||
|
||||
## 2. Первичный вход на VM
|
||||
|
||||
Подключитесь к созданной VM облачным пользователем:
|
||||
|
||||
```sh
|
||||
ssh <cloud-user>@<VM_IP>
|
||||
```
|
||||
|
||||
Проверьте версию ОС:
|
||||
|
||||
```sh
|
||||
cat /etc/os-release
|
||||
```
|
||||
|
||||
Должна использоваться Ubuntu 24.04 или более новая версия.
|
||||
|
||||
## 3. Передача и запуск скрипта настройки VM
|
||||
|
||||
Сначала передайте на VM только подготовительный скрипт. Например, с локального
|
||||
компьютера:
|
||||
|
||||
```sh
|
||||
scp deployment/scripts/setup-vm.sh <cloud-user>@<VM_IP>:/tmp/setup-vm.sh
|
||||
```
|
||||
|
||||
На VM выполните:
|
||||
|
||||
```sh
|
||||
chmod +x /tmp/setup-vm.sh
|
||||
sudo /tmp/setup-vm.sh
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
|
||||
- обновит Ubuntu и установит базовые пакеты;
|
||||
- создаст пользователя `deploy`;
|
||||
- установит Docker Engine и Docker Compose;
|
||||
- настроит UFW, fail2ban и цепочку `DOCKER-USER`;
|
||||
- откроет только SSH, HTTP и HTTPS;
|
||||
- создаст `/opt/han-chat/backend`;
|
||||
- создаст swap;
|
||||
- включит автоматические обновления безопасности;
|
||||
- отключит парольный SSH-вход и X11 forwarding;
|
||||
- заблокирует локальные пароли `root` и `deploy` после проверки SSH-ключей.
|
||||
|
||||
Если `authorized_keys` пользователя `deploy` отсутствует, скрипт остановится до
|
||||
блокировки паролей. `HARDEN_SSH=true` дополнительно запрещает прямой вход
|
||||
пользователем `root` и SSH TCP forwarding; включайте этот режим только после
|
||||
проверки входа пользователем `deploy` по ключу в отдельной сессии.
|
||||
|
||||
Если SSH работает на нестандартном порту или имя внешнего интерфейса известно
|
||||
заранее, передайте параметры:
|
||||
|
||||
```sh
|
||||
sudo SSH_PORT=2222 EXTERNAL_IF=ens3 /tmp/setup-vm.sh
|
||||
```
|
||||
|
||||
После завершения выйдите из SSH-сессии: членство `deploy` в группе `docker`
|
||||
начинает действовать только после нового входа.
|
||||
|
||||
```sh
|
||||
exit
|
||||
ssh deploy@<VM_IP>
|
||||
docker version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
## 4. Копирование проекта на VM
|
||||
|
||||
### Вариант A — через Git
|
||||
|
||||
Это предпочтительный вариант: Git применит правило LF для shell-скриптов.
|
||||
|
||||
```sh
|
||||
git clone <URL_РЕПОЗИТОРИЯ> /tmp/han-chat-source
|
||||
cp -a /tmp/han-chat-source/HAN_chat_specification/codebase/backend/. \
|
||||
/opt/han-chat/backend/
|
||||
cd /opt/han-chat/backend
|
||||
```
|
||||
|
||||
Если `HAN_chat_specification` является корнем репозитория:
|
||||
|
||||
```sh
|
||||
cp -a /tmp/han-chat-source/codebase/backend/. /opt/han-chat/backend/
|
||||
```
|
||||
|
||||
### Вариант B — архивом с локального компьютера
|
||||
|
||||
Создайте архив именно из содержимого каталога `backend`, включая скрытые файлы:
|
||||
|
||||
```sh
|
||||
tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz .
|
||||
scp han-chat-backend.tar.gz deploy@<VM_IP>:/tmp/
|
||||
```
|
||||
|
||||
На VM:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
tar -xzf /tmp/han-chat-backend.tar.gz
|
||||
|
||||
# Обязательно при копировании с Windows:
|
||||
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
|
||||
chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh
|
||||
```
|
||||
|
||||
Проверьте наличие точки запуска:
|
||||
|
||||
```sh
|
||||
test -f /opt/han-chat/backend/docker-compose.yml
|
||||
test -f /opt/han-chat/backend/.env.example
|
||||
```
|
||||
|
||||
## 5. Настройка DNS и сетевого доступа
|
||||
|
||||
Создайте DNS-запись:
|
||||
|
||||
```text
|
||||
chat.example.ru A <ПУБЛИЧНЫЙ_IP_VM>
|
||||
```
|
||||
|
||||
Дождитесь обновления DNS:
|
||||
|
||||
```sh
|
||||
getent ahostsv4 chat.example.ru
|
||||
```
|
||||
|
||||
В облачной группе безопасности VM разрешите входящие подключения:
|
||||
|
||||
- TCP 80 из интернета;
|
||||
- TCP 443 из интернета;
|
||||
- SSH только из доверенной сети или с административного IP.
|
||||
|
||||
Не открывайте наружу порты 6379, 4317, 4318, 8000, 8080 и 9000.
|
||||
|
||||
В группе безопасности PostgreSQL разрешите входящий трафик на порт PostgreSQL
|
||||
только от приватного адреса или группы безопасности VM.
|
||||
|
||||
## 6. Подготовка управляемого PostgreSQL
|
||||
|
||||
Создайте одну базу данных:
|
||||
|
||||
```text
|
||||
han_chat
|
||||
```
|
||||
|
||||
В ней нужны схемы:
|
||||
|
||||
```text
|
||||
han_app
|
||||
bitrix_local
|
||||
bitrix_sync
|
||||
message_safety
|
||||
keycloak
|
||||
```
|
||||
|
||||
Для текущей MVP-реализации используются следующие пользователи:
|
||||
|
||||
```text
|
||||
han_app
|
||||
bitrix_local_app
|
||||
bitrix_sync_user
|
||||
message_safety_app
|
||||
keycloak_user
|
||||
```
|
||||
|
||||
Создать пользователей и схемы можно через панель провайдера либо от имени
|
||||
администратора PostgreSQL. Пример SQL:
|
||||
|
||||
```sql
|
||||
CREATE ROLE han_app LOGIN PASSWORD '<HAN_APP_PASSWORD>';
|
||||
CREATE ROLE bitrix_local_app LOGIN PASSWORD '<BITRIX_LOCAL_PASSWORD>';
|
||||
CREATE ROLE bitrix_sync_user LOGIN PASSWORD '<BITRIX_SYNC_PASSWORD>';
|
||||
CREATE ROLE message_safety_app LOGIN PASSWORD '<SAFETY_PASSWORD>';
|
||||
CREATE ROLE keycloak_user LOGIN PASSWORD '<KEYCLOAK_PASSWORD>';
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS han_app AUTHORIZATION han_app;
|
||||
CREATE SCHEMA IF NOT EXISTS bitrix_local AUTHORIZATION bitrix_local_app;
|
||||
CREATE SCHEMA IF NOT EXISTS bitrix_sync AUTHORIZATION bitrix_sync_user;
|
||||
CREATE SCHEMA IF NOT EXISTS message_safety AUTHORIZATION message_safety_app;
|
||||
CREATE SCHEMA IF NOT EXISTS keycloak AUTHORIZATION keycloak_user;
|
||||
|
||||
GRANT CONNECT ON DATABASE han_chat TO
|
||||
han_app, bitrix_local_app, bitrix_sync_user, message_safety_app, keycloak_user;
|
||||
```
|
||||
|
||||
Текущие migration jobs используют те же DSN, что и сервисы. Поэтому владельцы
|
||||
схем должны иметь право создавать таблицы в своих схемах. Для более строгого
|
||||
production-разделения migration/runtime ролей потребуется отдельная настройка
|
||||
DSN и прав, которой в текущем `.env.example` нет.
|
||||
|
||||
Скачайте CA-сертификат PostgreSQL у провайдера и поместите его на VM:
|
||||
|
||||
```sh
|
||||
mkdir -p /opt/han-chat/backend/secrets/pg
|
||||
cp /путь/к/ca.pem /opt/han-chat/backend/secrets/pg/ca.pem
|
||||
chmod 644 /opt/han-chat/backend/secrets/pg/ca.pem
|
||||
```
|
||||
|
||||
CA-сертификат не является секретом. Права `644` нужны, чтобы его могли прочитать
|
||||
контейнеры, работающие не от root.
|
||||
|
||||
Проверьте сетевую доступность:
|
||||
|
||||
```sh
|
||||
nc -vz <PG_HOST> 6432
|
||||
```
|
||||
|
||||
Замените `6432` на фактический порт провайдера.
|
||||
|
||||
## 7. Подготовка Selectel S3
|
||||
|
||||
Создайте три приватных бакета:
|
||||
|
||||
```text
|
||||
han-chat-quarantine
|
||||
han-chat-attachments
|
||||
han-chat-documents
|
||||
```
|
||||
|
||||
Создайте две пары ключей:
|
||||
|
||||
1. Ключ API с правом чтения и записи в бакеты.
|
||||
2. Отдельный ключ Message Safety только с правом чтения карантина.
|
||||
|
||||
Для бакетов запретите публичный доступ. Для браузерной загрузки настройте CORS:
|
||||
|
||||
- Allowed origin: `https://chat.example.ru`;
|
||||
- Methods: `PUT`, `GET`, `HEAD`;
|
||||
- Headers: `Content-Type`, `x-amz-*`;
|
||||
- Expose header: `ETag`.
|
||||
|
||||
Для карантина задайте lifecycle удаления объектов с запасом относительно
|
||||
`MESSAGE_SAFETY_TASK_TTL_SEC`.
|
||||
|
||||
## 8. Создание файла окружения
|
||||
|
||||
`.env` содержит только несекретную конфигурацию. На VM:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
umask 077
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Замените несекретные адреса `example.*`. Не добавляйте в `.env` пароли, токены,
|
||||
ключи, credential-bearing DSN или пути `*_FILE`. Установите отдельно проверенный
|
||||
launcher `deployment/secrets/han-secrets` из ops-пакета. Его интерфейс:
|
||||
|
||||
```sh
|
||||
deployment/secrets/han-secrets run --config .env -- <command>
|
||||
```
|
||||
|
||||
Launcher читает `SECRETS_SOURCE=file|selectel`, устанавливает
|
||||
`HAN_SECRETS_ACTIVE=1`, выдаёт значения только дочернему процессу и не печатает
|
||||
их. Рекомендуемый `HAN_RUNTIME_SECRET_MANIFEST` содержит только пары
|
||||
`SECRET_KEY=/absolute/protected/path`; файлы имеют mode `0400`/`0600`.
|
||||
|
||||
### 8.1. Основные адреса
|
||||
|
||||
Для домена `chat.example.ru`:
|
||||
|
||||
```dotenv
|
||||
APP_ENV=production-like
|
||||
RELEASE_VERSION=2026-07-13-1
|
||||
|
||||
PUBLIC_HOST=chat.example.ru
|
||||
PUBLIC_WEB_URL=https://chat.example.ru
|
||||
PUBLIC_API_URL=https://chat.example.ru/api
|
||||
PUBLIC_AUTH_URL=https://chat.example.ru/auth
|
||||
|
||||
KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth
|
||||
KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth
|
||||
KEYCLOAK_REALM=han-chat
|
||||
KEYCLOAK_AUDIENCE=han-chat-api
|
||||
```
|
||||
|
||||
### 8.2. PostgreSQL
|
||||
|
||||
В `.env` укажите только host, port, database и путь к публичному CA:
|
||||
|
||||
```dotenv
|
||||
HAN_PG_HOST=<PG_HOST>
|
||||
HAN_PG_PORT=6432
|
||||
HAN_PG_DATABASE=han_chat
|
||||
PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem
|
||||
|
||||
KEYCLOAK_DB_SCHEMA=keycloak
|
||||
```
|
||||
|
||||
Все service DSN, включая JDBC и backup DSN, формирует secret backend. Runtime
|
||||
validator проверяет `verify-full`, `sslrootcert` и запрет `options/currentSchema`
|
||||
без вывода строк подключения.
|
||||
|
||||
Порт `5433` используется с PgBouncer в режиме `session`. Не добавляйте
|
||||
`options=-csearch_path...` или JDBC-параметр `currentSchema`: они передают
|
||||
startup parameter `search_path`, который Selectel PgBouncer отклоняет. Для
|
||||
Keycloak схема задаётся отдельно через `KEYCLOAK_DB_SCHEMA`.
|
||||
Для каждой сервисной роли заранее задайте database-level `search_path`.
|
||||
|
||||
Если пароль содержит `@`, `:`, `/`, `?`, `#` или `%`, его необходимо
|
||||
URL-кодировать внутри PostgreSQL URL.
|
||||
|
||||
### 8.3. Подготовка runtime-секретов
|
||||
|
||||
Генерируйте секреты вне shell history средствами secret manager. Не выполняйте
|
||||
`export TOKEN=...` и не вставляйте значения в команды. Имена обязательных
|
||||
runtime-переменных определены в `scripts/validate-env`; парные токены связываются
|
||||
в secret backend.
|
||||
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Для `BITRIX_TOKEN_ENCRYPTION_KEY` нужен URL-safe Base64 ключ ровно из 32 байт:
|
||||
|
||||
```sh
|
||||
python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())'
|
||||
```
|
||||
|
||||
Ни один из этих секретов не добавляется в `.env`. Пары проверяются runtime
|
||||
validator без вывода значений.
|
||||
|
||||
```dotenv
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN=<TOKEN_A>
|
||||
BITRIX_INTERNAL_API_TOKEN=<TOKEN_A>
|
||||
|
||||
BITRIX_API_FORWARD_TOKEN=<TOKEN_B>
|
||||
BITRIX_API_INBOX_TOKEN=<TOKEN_B>
|
||||
```
|
||||
|
||||
Остальные токены должны быть разными:
|
||||
|
||||
```dotenv
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN=<UNIQUE_TOKEN>
|
||||
BITRIX_SYNC_SERVICE_TOKEN=<UNIQUE_TOKEN>
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<UNIQUE_TOKEN>
|
||||
CURSOR_HMAC_SECRET=<UNIQUE_TOKEN>
|
||||
KEYCLOAK_OTP_HMAC_KEY=<UNIQUE_TOKEN_НЕ_КОРОЧЕ_32_БАЙТ>
|
||||
BITRIX_TOKEN_ENCRYPTION_KEY=<URLSAFE_BASE64_KEY>
|
||||
KEYCLOAK_ADMIN_PASSWORD=<UNIQUE_ADMIN_PASSWORD>
|
||||
```
|
||||
|
||||
### 8.4. Redis
|
||||
|
||||
Создайте три разных пароля в secret backend; там же сформируйте Redis URL.
|
||||
Следующий блок описывает логический контракт и не является содержимым `.env`:
|
||||
|
||||
```dotenv
|
||||
REDIS_API_PASSWORD=<REDIS_API_PASSWORD>
|
||||
REDIS_SAFETY_PASSWORD=<REDIS_SAFETY_PASSWORD>
|
||||
REDIS_HEALTH_PASSWORD=<REDIS_HEALTH_PASSWORD>
|
||||
|
||||
REDIS_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/0
|
||||
REDIS_REALTIME_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/1
|
||||
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<REDIS_SAFETY_PASSWORD>@redis:6379/2
|
||||
```
|
||||
|
||||
### 8.5. Mock OTP
|
||||
|
||||
В текущих deploy-артефактах реализован только mock OTP. Для запуска до controlled SMS rollout:
|
||||
|
||||
```dotenv
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
|
||||
```
|
||||
|
||||
`KEYCLOAK_OTP_MOCK_CODE` хранится только в secret backend. Не используйте mock
|
||||
как production-механизм доставки OTP.
|
||||
|
||||
Целевой real mode задаёт `modules/module-11-idgtl-sms.md`: Keycloak генерирует и локально проверяет OTP, `sms-service` надёжно записывает заказ/журнал, worker вызывает i-Digital Direct, callback обновляет только delivery journal. Нельзя просто установить `KEYCLOAK_OTP_MOCK_ENABLED=false`.
|
||||
|
||||
До переключения необходимы: schema/role `sms` и migrations/seed, active approved `auth_otp` (`code`, `ttl_min`), согласованный sender, Direct `TOKEN_1`, парные service tokens, отдельные callback credentials, exact nginx callback route, подтверждённый source IP Direct и статический egress IP worker. Сначала deploy при mock=true, затем provider smoke/callback/redaction evidence и только после этого cutover. Rollback возвращает mock без удаления SMS schema/journal.
|
||||
|
||||
### 8.6. S3
|
||||
|
||||
```dotenv
|
||||
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
|
||||
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
||||
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
|
||||
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
|
||||
```
|
||||
|
||||
Обе пары S3 credentials хранятся только в secret backend.
|
||||
|
||||
API backend принудительно использует virtual-hosted addressing:
|
||||
`https://<bucket>.s3.storage.selcloud.ru/<object-key>`. Это обязательно для
|
||||
браузерных presigned PUT и CORS в Selectel; path-style URL для этого сценария не
|
||||
используйте. DNS и исходящий HTTPS с ВМ должны разрешать поддомены бакетов.
|
||||
|
||||
### 8.7. Bitrix24
|
||||
|
||||
До установки локального приложения загрузите credentials в secret backend.
|
||||
В `.env` остаются только несекретные connector/public URL параметры:
|
||||
|
||||
```dotenv
|
||||
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||
BITRIX_OPEN_LINE_ID=8
|
||||
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
|
||||
```
|
||||
|
||||
`BITRIX_APPLICATION_TOKEN` сохраняется в secret backend после создания
|
||||
приложения и никогда не помещается в `.env`.
|
||||
|
||||
### 8.8. TLS и наблюдаемость
|
||||
|
||||
До выпуска сертификата оставьте в `.env` целевые значения:
|
||||
|
||||
```dotenv
|
||||
NGINX_TLS_ENABLED=true
|
||||
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
|
||||
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
|
||||
ACME_EMAIL=<ADMIN_EMAIL>
|
||||
```
|
||||
|
||||
Если удаленный OTLP-бэкенд пока не выбран, укажите временные непубличные значения
|
||||
и примите ограничение: Collector будет пытаться отправлять телеметрию и сохранять
|
||||
ее в ограниченной очереди. Перед production-запуском задайте реальный endpoint.
|
||||
|
||||
## 9. Проверка окружения и конфигурации Compose
|
||||
|
||||
Выполните:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
./scripts/validate-env .env
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
sudo deployment/secrets/han-compose config --services
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Не переходите к следующему шагу, пока все команды не завершатся успешно.
|
||||
|
||||
Посмотрите итоговую конфигурацию портов:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env config | grep -n 'published:'
|
||||
```
|
||||
|
||||
Публиковаться должны только 80 и 443 у nginx.
|
||||
|
||||
## 10. Сборка образов
|
||||
|
||||
Соберите все локальные образы:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env build --pull
|
||||
```
|
||||
|
||||
Проверьте список:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env images
|
||||
```
|
||||
|
||||
Сборка Keycloak включает Java OTP SPI, а сборка `frontend-static` экспортирует
|
||||
тестовый Expo Web frontend.
|
||||
|
||||
## 11. Миграции БД и начальные настройки
|
||||
|
||||
Перед миграциями создайте backup/PITR marker в панели провайдера PostgreSQL.
|
||||
Затем:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
Скрипт применит миграции `han_app`, `bitrix_local` и baseline `bitrix_sync`, после
|
||||
чего загрузит `deployment/app-settings.production-like.yaml`.
|
||||
|
||||
Повторный запуск seed должен быть безопасным:
|
||||
|
||||
```sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
## 12. Запуск внутренних сервисов
|
||||
|
||||
Сначала запустите Redis:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d redis
|
||||
docker compose --env-file .env ps redis
|
||||
```
|
||||
|
||||
Затем Keycloak и OpenTelemetry:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d keycloak otel-collector
|
||||
docker compose --env-file .env ps keycloak otel-collector
|
||||
```
|
||||
|
||||
Первый запуск Keycloak может занять несколько минут: он создаст свои таблицы и
|
||||
импортирует realm `han-chat`.
|
||||
|
||||
После готовности Keycloak:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d message-safety
|
||||
docker compose --env-file .env up -d api-backend
|
||||
docker compose --env-file .env up -d bitrix-local-app bitrix-sync
|
||||
docker compose --env-file .env up -d \
|
||||
delivery-worker safety-recovery-worker cleanup-worker
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
Если сервис не становится healthy:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env logs --tail=200 <SERVICE_NAME>
|
||||
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
|
||||
```
|
||||
|
||||
### 12.1. Повторная раскатка upstream при уже работающем nginx
|
||||
|
||||
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
|
||||
`up --build`, `pull`, rollback или `--force-recreate` контейнер может получить
|
||||
новый IP, а работающий nginx продолжит использовать старый и вернёт `502
|
||||
Connection refused`.
|
||||
|
||||
После пересоздания `api-backend`, `keycloak`, `sms-service` или
|
||||
`bitrix-local-app` обязательно выполните:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --wait \
|
||||
api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose --env-file .env exec -T nginx \
|
||||
nginx -t -c /tmp/nginx.conf
|
||||
docker compose --env-file .env kill -s HUP nginx
|
||||
|
||||
curl -fsS "https://${PUBLIC_HOST}/api/v1/public/app-config" | jq
|
||||
curl -fsS \
|
||||
"https://${PUBLIC_HOST}/auth/realms/han-chat/.well-known/openid-configuration" |
|
||||
jq
|
||||
```
|
||||
|
||||
Не используйте bare-команды `nginx -t` и `nginx -s reload`: рабочая
|
||||
конфигурация находится в `/tmp/nginx.conf`, PID — в `/tmp/nginx.pid`, а
|
||||
контейнер использует read-only filesystem.
|
||||
|
||||
## 13. Первоначальный выпуск TLS-сертификата
|
||||
|
||||
Для ACME требуется работающий nginx по HTTP. В `.env` оставьте
|
||||
`NGINX_TLS_ENABLED=true`, но первый nginx запустите с временным переопределением:
|
||||
|
||||
```sh
|
||||
NGINX_TLS_ENABLED=false \
|
||||
docker compose --env-file .env up -d frontend-static nginx
|
||||
```
|
||||
|
||||
Проверьте HTTP:
|
||||
|
||||
```sh
|
||||
curl -I http://chat.example.ru/
|
||||
```
|
||||
|
||||
Сначала рекомендуется проверить Certbot через staging:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env --profile certbot run --rm certbot certonly \
|
||||
--staging \
|
||||
--webroot -w /var/www/certbot \
|
||||
-d chat.example.ru \
|
||||
--cert-name chat.example.ru-staging \
|
||||
--email <ADMIN_EMAIL> \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
```
|
||||
|
||||
После успешного staging-теста выпустите рабочий сертификат с основным cert-name
|
||||
без `--staging`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env --profile certbot run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot \
|
||||
-d chat.example.ru \
|
||||
--cert-name chat.example.ru \
|
||||
--email <ADMIN_EMAIL> \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
```
|
||||
|
||||
Пересоздайте nginx уже с TLS:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
curl -I https://chat.example.ru/
|
||||
```
|
||||
|
||||
Повторно запустите VM setup, чтобы он обнаружил проект и установил systemd-таймер
|
||||
продления сертификата:
|
||||
|
||||
```sh
|
||||
sudo /opt/han-chat/backend/deployment/scripts/setup-vm.sh
|
||||
systemctl status han-chat-ssl-renew.timer
|
||||
```
|
||||
|
||||
## 14. Запуск всего контура
|
||||
|
||||
Теперь можно привести весь проект к состоянию, описанному Compose:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
Проверьте, что контейнеры не перезапускаются:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env ps
|
||||
docker compose --env-file .env logs --since=10m
|
||||
```
|
||||
|
||||
## 15. Публичная проверка
|
||||
|
||||
Запустите smoke-тест:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
Также вручную проверьте:
|
||||
|
||||
```sh
|
||||
curl -fsS https://chat.example.ru/api/v1/public/app-config | jq
|
||||
curl -fsS https://chat.example.ru/api/v1/public/content | jq
|
||||
curl -fsS \
|
||||
https://chat.example.ru/auth/realms/han-chat/.well-known/openid-configuration | jq
|
||||
```
|
||||
|
||||
Внутренний API не должен быть опубликован:
|
||||
|
||||
```sh
|
||||
curl -i https://chat.example.ru/internal/safety/v2/messages/check
|
||||
```
|
||||
|
||||
Ожидаемый статус — `404`.
|
||||
|
||||
Откройте в браузере:
|
||||
|
||||
```text
|
||||
https://chat.example.ru/
|
||||
```
|
||||
|
||||
Для тестовой авторизации получите mock code утверждённым защищённым способом,
|
||||
не читая его из `.env` и не помещая в shell history.
|
||||
|
||||
## 16. Подключение Bitrix24
|
||||
|
||||
В настройках локального приложения Bitrix24 задайте HTTPS-адреса:
|
||||
|
||||
```text
|
||||
Установка: https://chat.example.ru/bitrix/install
|
||||
Обработчик: https://chat.example.ru/bitrix/handler
|
||||
Placement: https://chat.example.ru/bitrix/placement
|
||||
```
|
||||
|
||||
После установки:
|
||||
|
||||
1. Получите и сохраните application token.
|
||||
2. Сохраните его как `BITRIX_APPLICATION_TOKEN` в secret backend.
|
||||
3. Пересоздайте сервис:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --force-recreate bitrix-local-app
|
||||
docker compose --env-file .env logs --tail=200 bitrix-local-app
|
||||
```
|
||||
|
||||
Проверьте коннектор `han_mobile_app` и Открытую линию 8.
|
||||
|
||||
## 17. Включение SSH hardening
|
||||
|
||||
Только после успешного входа пользователем `deploy` по ключу в отдельной сессии:
|
||||
|
||||
```sh
|
||||
sudo HARDEN_SSH=true \
|
||||
/opt/han-chat/backend/deployment/scripts/setup-vm.sh
|
||||
```
|
||||
|
||||
Не закрывайте текущую SSH-сессию, пока не проверили новый вход.
|
||||
|
||||
## 18. Обычный перезапуск проекта
|
||||
|
||||
Для штатного запуска после перезагрузки VM:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
Для перезапуска одного сервиса:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env restart api-backend
|
||||
```
|
||||
|
||||
После изменения `.env` используйте пересоздание, а не `restart`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --force-recreate <SERVICE_NAME>
|
||||
```
|
||||
|
||||
## 19. Обновление версии проекта
|
||||
|
||||
Перед обновлением:
|
||||
|
||||
1. Создайте backup/PITR marker PostgreSQL.
|
||||
2. Сохраните текущие image digests.
|
||||
3. Получите новый код.
|
||||
4. Проверьте несекретный `.env` и runtime secret set.
|
||||
5. Пересоберите образы.
|
||||
6. Примените миграции и seed.
|
||||
7. Пересоздайте сервисы.
|
||||
|
||||
Команды:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
./scripts/validate-env .env
|
||||
docker compose --env-file .env build --pull
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
docker compose --env-file .env up -d
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
## 20. Диагностика
|
||||
|
||||
Состояние сервисов:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
Все логи:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env logs --tail=300
|
||||
```
|
||||
|
||||
Логи конкретного сервиса:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env logs -f api-backend
|
||||
```
|
||||
|
||||
Проверка firewall:
|
||||
|
||||
```sh
|
||||
sudo ufw status verbose
|
||||
sudo iptables -L HAN-CHAT-DOCKER -n -v
|
||||
```
|
||||
|
||||
Проверка сертификата:
|
||||
|
||||
```sh
|
||||
openssl s_client -connect chat.example.ru:443 -servername chat.example.ru \
|
||||
</dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates
|
||||
```
|
||||
|
||||
Проверка свободного места:
|
||||
|
||||
```sh
|
||||
df -h
|
||||
docker system df
|
||||
```
|
||||
|
||||
Не выполняйте `docker compose down -v`: эта команда удалит именованные volumes.
|
||||
Не выполняйте Alembic downgrade. Для отката используйте
|
||||
`deployment/scripts/rollback.sh` и инструкции из `RUNBOOK.ru.md`.
|
||||
|
||||
## 21. Когда развертывание можно считать завершенным
|
||||
|
||||
Проект запущен корректно, если:
|
||||
|
||||
- `docker compose ps` показывает healthy для критических сервисов;
|
||||
- `deployment/scripts/smoke.sh` завершается успешно;
|
||||
- открывается тестовый frontend;
|
||||
- проходит авторизация через mock OTP;
|
||||
- отправляются текстовые и файловые сообщения;
|
||||
- внутренние URL возвращают 404 снаружи;
|
||||
- TLS-сертификат действителен;
|
||||
- логи не содержат токены, PII и тексты сообщений;
|
||||
- настроены резервное копирование PostgreSQL и продление TLS.
|
||||
|
||||
Для формальной production-like приемки после этого пройдите контрольные этапы
|
||||
из `deployment/RUNBOOK.ru.md`.
|
||||
Production через этот legacy guide или stubs не поддерживается. In-place
|
||||
обновление legacy VM запрещено.
|
||||
|
||||
@@ -1,330 +1,7 @@
|
||||
# HAN Chat production-like deployment runbook
|
||||
# Legacy pointer
|
||||
|
||||
This is the executable checklist for the single-VM contour. PostgreSQL and S3
|
||||
are managed external services. Never use `docker compose down -v`, an Alembic
|
||||
downgrade, or a mutable image tag during deployment.
|
||||
The authoritative executable fresh-production VM1 procedure is:
|
||||
[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md).
|
||||
|
||||
## VM2 Processing is a separate host
|
||||
|
||||
Do not run this backend/VM1 setup script on VM2. VM2 has its own bootstrap:
|
||||
`codebase/services/deployment/scripts/setup-vm.sh`, and its authoritative
|
||||
operator checklist is `codebase/services/deployment/RUNBOOK.ru.md`.
|
||||
|
||||
The VM2 ownership boundary is intentionally different from the legacy VM1
|
||||
script: `deploy` is **not** a member of the `docker` group. Root owns
|
||||
`/opt/han-chat/services`, Compose, units, helpers, `.env`, allow-lists and
|
||||
secret mappings. Deploy may write only to `/var/lib/han-deploy/incoming` and
|
||||
may invoke exact systemd/safety-mode commands installed in sudoers.
|
||||
The separate `admin` account is break-glass only: it has its own Ed25519 key
|
||||
and a separate local sudo password. Root, deploy and admin keys must differ.
|
||||
|
||||
Initial VM2 bootstrap commands:
|
||||
|
||||
```sh
|
||||
# Local operator workstation: upload only the reviewed setup script.
|
||||
scp codebase/services/deployment/scripts/setup-vm.sh \
|
||||
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
|
||||
|
||||
# VM2 root: install host packages/roles/firewalls; this does not start Compose.
|
||||
chmod 0700 /root/setup-vm2.sh
|
||||
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
|
||||
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
|
||||
OPS_CIDRS='<OPS_PUBLIC_IP>/32' \
|
||||
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/32' \
|
||||
/root/setup-vm2.sh
|
||||
```
|
||||
|
||||
Generate and upload the two public keys before this command; never copy the
|
||||
root key into either account. Set the admin sudo password with `passwd admin`.
|
||||
Keep the root session open and verify both key-based logins plus `sudo -v` as
|
||||
admin in separate sessions. Only then rerun as VM2 root with
|
||||
`HARDEN_SSH=true SKIP_APT_UPGRADE=true` to disable direct root SSH.
|
||||
|
||||
Release transfer is performed as deploy, while activation and installation
|
||||
remain root operations:
|
||||
|
||||
```sh
|
||||
# deploy: receive and inspect only.
|
||||
cd /var/lib/han-deploy/incoming
|
||||
sha256sum vm2-services-<RELEASE>.tar.gz
|
||||
tar -tzf vm2-services-<RELEASE>.tar.gz
|
||||
|
||||
# root: verify the operator-provided digest, activate root-owned files,
|
||||
# then rerun setup-vm.sh so it installs fixed helpers and systemd units.
|
||||
printf '%s %s\n' '<EXPECTED_SHA256>' \
|
||||
/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.tar.gz | sha256sum --check -
|
||||
ARCHIVE=/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.tar.gz
|
||||
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then exit 1; fi
|
||||
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then exit 1; fi
|
||||
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then exit 1; fi
|
||||
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
|
||||
tar -xzf "$ARCHIVE" \
|
||||
-C "$STAGING" --no-same-owner --no-same-permissions
|
||||
test -f "$STAGING/services/docker-compose.yml"
|
||||
rsync -a --delete --exclude=.env --chown=root:root --chmod=D755,F644 \
|
||||
"$STAGING/services/" /opt/han-chat/services/
|
||||
rm -rf -- "$STAGING"
|
||||
OPS_CIDRS='<OPS_PUBLIC_IP>/32' \
|
||||
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/32' \
|
||||
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
|
||||
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
|
||||
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
|
||||
/root/setup-vm2.sh
|
||||
```
|
||||
|
||||
After root configures `.env`, Selectel encrypted credentials, loader mapping,
|
||||
TLS and CIDR allow-lists, root synchronizes secrets, runs preflight/migrations
|
||||
and performs the first start. Subsequent routine operations available to
|
||||
deploy are limited to:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart han-secrets-vm2.service
|
||||
sudo systemctl restart han-processing.service
|
||||
sudo systemctl --no-pager status han-processing.service
|
||||
sudo journalctl --no-pager -u han-processing.service
|
||||
```
|
||||
|
||||
Exact archive activation, file installation, credential creation, migration
|
||||
and first-start commands are documented in the VM2 Russian runbook referenced
|
||||
above. They must not be replaced with direct Docker access for deploy.
|
||||
|
||||
## Gate 0 — decisions and ownership
|
||||
|
||||
- [ ] Release SHA/digests, maintenance window, on-call and rollback owner recorded.
|
||||
- [ ] RPO/RTO accepted; initial targets are PG RPO <=15 minutes and RTO <=4 hours.
|
||||
- [ ] Remote OTLP backend selected, or debug-only acceptance limitation accepted.
|
||||
- [ ] Mock OTP, Safety stub and bitrix-sync stub risks explicitly accepted.
|
||||
|
||||
## Gate 1 — VPC, DNS and security groups
|
||||
|
||||
- [ ] Managed PostgreSQL has only a private endpoint and accepts traffic from VM SG.
|
||||
- [ ] Internet can reach only VM TCP 80/443; SSH is restricted to VPN/ops CIDR.
|
||||
- [ ] Ports 6379, 4317/4318, 8000, 8080 and 9000 are denied externally.
|
||||
- [ ] DNS `A` for `PUBLIC_HOST` points at the VM and outbound HTTPS is available.
|
||||
|
||||
## Gate 2 — VM hardening
|
||||
|
||||
On a fresh Ubuntu 24.04 VM, run:
|
||||
|
||||
```sh
|
||||
sudo deployment/scripts/setup-vm.sh
|
||||
```
|
||||
|
||||
The script disables password SSH and X11 forwarding by default, then locks the
|
||||
local `root` and `deploy` passwords after checking authorized keys. Before
|
||||
setting `HARDEN_SSH=true`, which also disables root login and TCP forwarding,
|
||||
verify key-based deploy access in a separate SSH session.
|
||||
|
||||
- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active.
|
||||
- [ ] Key-only deploy account works in a second session; root/password SSH is off.
|
||||
- [ ] UFW/cloud SG and `DOCKER-USER` policy survive reboot.
|
||||
- [ ] Docker Engine and Compose support `include` and long-form `env_file`.
|
||||
|
||||
## Gate 3 — managed PostgreSQL
|
||||
|
||||
- [ ] Daily backup, PITR, deletion protection, encryption and alerts are enabled.
|
||||
- [ ] Provider CA is installed at `PG_CA_HOST_PATH`; all DSNs use `verify-full`.
|
||||
- [ ] Schemas `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
|
||||
have separate migration/runtime roles with tested negative grants.
|
||||
- [ ] Migration tested against an empty DB and a clone of the previous release.
|
||||
|
||||
## Gate 4 — Selectel S3
|
||||
|
||||
- [ ] Quarantine, attachments and documents buckets are private and encrypted.
|
||||
- [ ] API credentials are prefix-scoped; Safety credentials are quarantine read-only.
|
||||
- [ ] Browser CORS permits exact HTTPS origin and PUT headers only.
|
||||
- [ ] Quarantine lifecycle exceeds Safety poll/recovery; data retention is approved.
|
||||
|
||||
## Gate 5 — immutable release
|
||||
|
||||
- [ ] Checkout is detached at the approved SHA and working tree is clean.
|
||||
- [ ] Service images are immutable and scanned; no unresolved critical/high issue.
|
||||
- [ ] Root `docker-compose.yml` is the only deployment entry point.
|
||||
|
||||
## Gate 6 — environment and secrets
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Replace non-secret configuration placeholders only.
|
||||
./scripts/validate-env .env
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
```
|
||||
|
||||
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` contains no secret keys or credential-bearing DSNs.
|
||||
- [ ] `deployment/secrets/han-secrets` sets `HAN_SECRETS_ACTIVE=1`, does not log values,
|
||||
and optionally exposes a paths-only `HAN_RUNTIME_SECRET_MANIFEST`.
|
||||
- [ ] Runtime token pairs match, PG verifies TLS, public URLs are HTTPS.
|
||||
- [ ] Mock OTP risk is accepted and runtime secrets are unique >=128-bit values.
|
||||
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` is unique and supplied only through secret/env; the `producer_test` source seed stores only its hash.
|
||||
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
|
||||
|
||||
## Gate 7 — images and static frontend
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env pull
|
||||
docker compose --env-file .env build --pull frontend-static nginx redis
|
||||
docker compose --env-file .env run --rm frontend-static
|
||||
```
|
||||
|
||||
- [ ] Frontend export was tested/scanned and copied by `frontend-static` into its named volume.
|
||||
- [ ] Build artifacts contain no secrets or unintended source maps.
|
||||
- [ ] At least 30% VM disk remains free.
|
||||
|
||||
## Gate 8 — topology
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env config --services
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
- [ ] Exactly nginx publishes `80:80` and `443:443`; no PostgreSQL service exists.
|
||||
- [ ] Redis AOF/RDB/ACL and OTEL persistent queue volumes are present.
|
||||
- [ ] `backend` and `observability` are internal networks.
|
||||
|
||||
## Gate 9 — ACME/TLS bootstrap
|
||||
|
||||
Set `NGINX_TLS_ENABLED=false` only for this bootstrap command:
|
||||
|
||||
```sh
|
||||
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
|
||||
docker compose --profile certbot run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
|
||||
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
```
|
||||
|
||||
First rehearse with Certbot `--staging`. Install a twice-daily systemd timer for
|
||||
`deployment/scripts/ssl-renew.sh`; test `certbot renew --dry-run`. Enable HSTS
|
||||
only after chain, hostname, redirect and TLS 1.2/1.3 checks pass.
|
||||
|
||||
## Gate 10 — migrations and seed
|
||||
|
||||
Create a provider PITR marker, then:
|
||||
|
||||
```sh
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
- [ ] Expected Alembic revisions are active and runtime users did not perform DDL.
|
||||
- [ ] Seed succeeds twice and mandatory settings contain no secret.
|
||||
- [ ] Schema remains backward-compatible with the previous images.
|
||||
|
||||
## Gate 11 — Keycloak
|
||||
|
||||
```sh
|
||||
docker compose up -d keycloak
|
||||
docker compose ps keycloak
|
||||
```
|
||||
|
||||
- [ ] Discovery/JWKS issuer is the exact public `/auth` HTTPS URL.
|
||||
- [ ] Frontend client is public PKCE S256; implicit/password/social flows are off.
|
||||
- [ ] Wrong/replayed OTP and limits fail safely; settings bridge is fail-closed.
|
||||
- [ ] Bootstrap admin was removed/rotated and named admin MFA is enabled.
|
||||
|
||||
## Gate 12 — ordered startup and readiness
|
||||
|
||||
```sh
|
||||
docker compose up -d redis
|
||||
docker compose up -d keycloak otel-collector
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d api-backend
|
||||
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
|
||||
notification-expire-worker notification-draft-cleanup-worker
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Nginx resolves Docker upstream names when its configuration is loaded. After
|
||||
recreating `api-backend`, `keycloak`, `sms-service`, or `bitrix-local-app`,
|
||||
wait for readiness, validate the active `/tmp/nginx.conf`, and signal the
|
||||
master process with `HUP` as shown above. Do not use bare `nginx -t` or
|
||||
`nginx -s reload`: they target the default config/PID under read-only
|
||||
`/var/run`, not the running Nginx instance.
|
||||
|
||||
- [ ] No restart loop/OOM; critical readiness is green.
|
||||
- [ ] `notification-expire-worker` runs daily closure with an advisory lock; `notification-draft-cleanup-worker` removes expired drafts/S3 objects. Both entrypoints exist in the installed image.
|
||||
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
|
||||
- [ ] External `/internal/*` is 404 and OTEL accepts telemetry.
|
||||
|
||||
## Gate 13 — Bitrix24
|
||||
|
||||
- [ ] Install, handler and placement URLs use the exact public HTTPS paths.
|
||||
- [ ] Connector `han_mobile_app` is active on Open Line 8; events are bound once.
|
||||
- [ ] OAuth is encrypted; callback/application/service tokens never enter logs.
|
||||
- [ ] Outbound and operator reply paths are idempotent; internal status is private.
|
||||
|
||||
## Gate 14 — smoke and E2E
|
||||
|
||||
```sh
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
- [ ] Guest, OTP/PKCE/bootstrap/session, refresh and logout paths pass.
|
||||
- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass.
|
||||
- [ ] File quarantine/promote/delete, owner-only download and audit pass.
|
||||
- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass.
|
||||
- [ ] Closed-network `producer_test` Create/Cancel smoke passes; identical Create returns `200`, changed payload returns `409`, and the external internal route returns `404`.
|
||||
- [ ] Expire advisory locking and first download of any linked document are verified; hiding is one-time and an existing `date_expired` is preserved.
|
||||
- [ ] Logs contain no PII, message body, token or presigned query.
|
||||
|
||||
## Gate 15 — observability
|
||||
|
||||
- [ ] Known request ID links nginx, API and downstream trace; UX ID is not a label.
|
||||
- [ ] Three signals reach the selected backend; SLO queries and alerts are tested.
|
||||
- [ ] Remote outage fills/drains the bounded persistent queue without business outage.
|
||||
- [ ] Secret/PII canary is absent. Collector restart/drop/refused metrics are checked.
|
||||
|
||||
For local acceptance only, start the redacted debug collector with:
|
||||
`docker compose --profile observability-local up -d otel-collector-local`.
|
||||
|
||||
## Gate 16 — open traffic
|
||||
|
||||
- [ ] Gates 0–15 are signed; fresh backup/PITR evidence and previous images exist.
|
||||
- [ ] HSTS is enabled, release digests/schema/realm versions are recorded.
|
||||
- [ ] No active page; on-call and product owner accept stub limitations.
|
||||
- [ ] Observe 5xx/auth/delivery/DB/Redis/OOM/OTEL queue/Bitrix for 60 minutes.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Provider backup/PITR is authoritative. A supplemental verified logical dump:
|
||||
|
||||
```sh
|
||||
deployment/scripts/backup.sh /opt/han-chat/backups
|
||||
```
|
||||
|
||||
Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests,
|
||||
do not route production DNS/Bitrix callbacks, run smoke, and record measured RPO/RTO.
|
||||
Redis may be restored empty; its AOF/RDB is not a business backup.
|
||||
|
||||
## Rollback
|
||||
|
||||
Only roll back to images compatible with the current schema:
|
||||
|
||||
```sh
|
||||
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
|
||||
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
Rollback reuses the current runtime secret set and non-secret config. Do not
|
||||
create or restore an environment snapshot.
|
||||
|
||||
## Real SMS rollout addendum
|
||||
|
||||
This runbook remains mock-only until module-11 artifacts exist. An SMS release requires schema/role `sms`, versioned migrations and an active approved `auth_otp` seed, `sms-service`/worker, the exact callback route, paired service tokens, Direct `TOKEN_1`, approved sender/template, separate callback credentials, a reconfirmed callback source IP, and a static worker egress IP.
|
||||
|
||||
Order: App DB OTP seed → SMS schema/migrations/seed → mock Direct tests → production SMS deployment while Keycloak remains in mock mode → Keycloak expand migration/SPI → controlled provider smoke plus callback/redaction evidence → real mode. Roll back by restoring mock mode without deleting the journal/schema; stop new real orders and drain or record in-flight/`uncertain` rows. Downgrade only with proven schema compatibility.
|
||||
|
||||
Never run Alembic downgrade. After a backward-incompatible migration choose a
|
||||
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
|
||||
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
|
||||
The former single-VM/stub checklist has been removed. It must not be used for
|
||||
production deployment or VM1→VM2 cutover.
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
# Fresh production runbook ВМ1 HAN Chat
|
||||
|
||||
Это единственный исполняемый production-runbook ВМ1. Он предназначен только
|
||||
для новой Ubuntu 24.04 VM: in-place преобразование старой single-VM/stub
|
||||
инсталляции запрещено. Команды выполняет оператор; repository automation их не
|
||||
запускает.
|
||||
|
||||
Запрещены: реальные секреты в репозитории, `.env`, argv/history или логах;
|
||||
доступ `deploy` к Docker; mutable image tags; `docker compose down -v`;
|
||||
Alembic downgrade; автоматический fallback Selectel → локальный файл.
|
||||
|
||||
## 0. Участники, переменные и stop conditions
|
||||
|
||||
- локальная машина: создаёт проверенный архив и SHA-256;
|
||||
- `root`: bootstrap, активация release, config/secrets/TLS, миграции и первый
|
||||
запуск;
|
||||
- `deploy`: пишет только в `/var/lib/han-deploy/incoming`, затем использует
|
||||
exact systemd/status/log commands;
|
||||
- `admin`: персональная break-glass роль с отдельным ключом и sudo-паролем.
|
||||
|
||||
До окна работ зафиксируйте: `<GIT_SHA>`, image digests, `<EXPECTED_SHA256>`,
|
||||
DNS/IP ВМ1, private DNS/SAN ВМ2, ops CIDR, PG/S3 inventory, PITR marker,
|
||||
RPO/RTO, on-call, approver и предыдущий совместимый release.
|
||||
|
||||
Stop condition: любой placeholder, незакрытый preflight, несовпавший digest,
|
||||
невалидный TLS, broad SG/sudo, неизвестная Alembic revision, stub/local Safety,
|
||||
неуспешный negative probe или отсутствие rollback evidence.
|
||||
|
||||
## 1. Bootstrap новой ВМ1
|
||||
|
||||
На локальной машине создайте две разные Ed25519 key pairs. Private keys не
|
||||
передаются на VM и не должны совпадать с bootstrap root key:
|
||||
|
||||
```powershell
|
||||
ssh-keygen -t ed25519 -a 100 -f C:\Users\<USER>\.ssh\han_vm1_deploy `
|
||||
-C "han-vm1-deploy"
|
||||
ssh-keygen -t ed25519 -a 100 -f C:\Users\<USER>\.ssh\han_vm1_admin `
|
||||
-C "han-vm1-break-glass-admin"
|
||||
```
|
||||
|
||||
Передайте setup и только public keys во временный root-каталог. В уже открытой
|
||||
bootstrap root-сессии:
|
||||
|
||||
```sh
|
||||
install -d -m 0700 -o root -g root /root/bootstrap
|
||||
install -m 0600 -o root -g root /tmp/han_vm1_deploy.pub /root/bootstrap/deploy.pub
|
||||
install -m 0600 -o root -g root /tmp/han_vm1_admin.pub /root/bootstrap/admin.pub
|
||||
install -m 0700 -o root -g root /tmp/setup-vm.sh /root/setup-vm1.sh
|
||||
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
|
||||
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
|
||||
/root/setup-vm1.sh
|
||||
passwd admin
|
||||
```
|
||||
|
||||
Setup устанавливает host packages, Docker/Compose, UFW, fail2ban, security
|
||||
updates, `DOCKER-USER`, swap и роли. Он не запускает Compose. Cloud SG должен
|
||||
разрешать `80/443` из интернета. На bootstrap-этапе UFW временно разрешает SSH
|
||||
из любой сети; после настройки WireGuard закройте public SSH и разрешите его
|
||||
только через WireGuard. PG принимает TLS только от SG/private IP ВМ1. `6379`,
|
||||
`4317/4318`, `8000`, `8080`, `9000` наружу запрещены.
|
||||
|
||||
Не закрывая root-сессию, проверьте в двух новых сессиях:
|
||||
|
||||
```powershell
|
||||
ssh -i C:\Users\<USER>\.ssh\han_vm1_deploy deploy@<VM1_IP>
|
||||
ssh -i C:\Users\<USER>\.ssh\han_vm1_admin admin@<VM1_IP>
|
||||
```
|
||||
|
||||
В admin-сессии:
|
||||
|
||||
```sh
|
||||
sudo -v
|
||||
sudo -i
|
||||
id
|
||||
exit
|
||||
```
|
||||
|
||||
Только после успеха обоих SSH-входов и admin sudo повторите в исходной
|
||||
root-сессии:
|
||||
|
||||
```sh
|
||||
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
|
||||
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
|
||||
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
|
||||
/root/setup-vm1.sh
|
||||
```
|
||||
|
||||
Откройте ещё по одной новой deploy/admin сессии после reload. Подтвердите
|
||||
`PermitRootLogin no`, `AllowUsers deploy admin`, key-only auth и отсутствие
|
||||
forwarding. Только затем закрывайте bootstrap root session. Root-only public
|
||||
key files сохраните до установки helpers из первого release; private keys на
|
||||
VM отсутствуют. Доступ cloud console/recovery остаётся break-glass.
|
||||
|
||||
## 2. Сборка и передача immutable release
|
||||
|
||||
На локальной машине checkout должен быть exact detached `<GIT_SHA>`, tree —
|
||||
clean. Архив содержит один корень `backend`, не содержит `.env`, credentials,
|
||||
caches и private keys:
|
||||
|
||||
```powershell
|
||||
$Release = "<GIT_SHA>"
|
||||
tar --exclude=backend/.env `
|
||||
--exclude='backend/**/__pycache__' `
|
||||
--exclude='backend/**/.pytest_cache' `
|
||||
--exclude='backend/**/.ruff_cache' `
|
||||
-czf "vm1-backend-$Release.tar.gz" `
|
||||
-C .\VM1_app\codebase backend
|
||||
Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256
|
||||
scp "vm1-backend-$Release.tar.gz" `
|
||||
deploy@<VM1_IP>:/var/lib/han-deploy/incoming/
|
||||
```
|
||||
|
||||
Под `deploy` разрешены только inventory/checksum:
|
||||
|
||||
```sh
|
||||
RELEASE='<GIT_SHA>'
|
||||
cd /var/lib/han-deploy/incoming
|
||||
sha256sum "vm1-backend-${RELEASE}.tar.gz"
|
||||
tar -tzf "vm1-backend-${RELEASE}.tar.gz"
|
||||
```
|
||||
|
||||
## 3. Root-активация: SHA и anti-traversal
|
||||
|
||||
Под `admin`, затем `sudo -i`. Не распаковывайте недоверенный архив до всех
|
||||
проверок:
|
||||
|
||||
```sh
|
||||
RELEASE='<GIT_SHA>'
|
||||
EXPECTED_SHA256='<SHA256_FROM_APPROVED_WORKSTATION>'
|
||||
ARCHIVE="/var/lib/han-deploy/incoming/vm1-backend-${RELEASE}.tar.gz"
|
||||
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
|
||||
tar -tvzf "$ARCHIVE"
|
||||
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^backend/\.env$)'; then
|
||||
echo 'unsafe path or .env' >&2; exit 1
|
||||
fi
|
||||
if tar -tzf "$ARCHIVE" | grep -Ev '^backend(/|$)' | grep -q .; then
|
||||
echo 'archive has files outside backend' >&2; exit 1
|
||||
fi
|
||||
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then
|
||||
echo 'symlink/hardlink is forbidden' >&2; exit 1
|
||||
fi
|
||||
|
||||
TARGET="/opt/han-chat/releases/${RELEASE}"
|
||||
test ! -e "$TARGET"
|
||||
install -d -m 0755 -o root -g root "$TARGET"
|
||||
tar --extract --gzip --file "$ARCHIVE" --directory "$TARGET" \
|
||||
--no-same-owner --no-same-permissions
|
||||
test -f "$TARGET/backend/docker-compose.yml"
|
||||
chmod 0755 \
|
||||
"$TARGET/backend/deployment/scripts/setup-vm.sh" \
|
||||
"$TARGET/backend/deployment/preflight.sh" \
|
||||
"$TARGET/backend/deployment/scripts/tls-deploy-hook.sh" \
|
||||
"$TARGET/backend/deployment/secrets/han-compose" \
|
||||
"$TARGET/backend/deployment/secrets/han-secrets"
|
||||
test -x "$TARGET/backend/deployment/scripts/setup-vm.sh"
|
||||
test -x "$TARGET/backend/deployment/preflight.sh"
|
||||
test -x "$TARGET/backend/deployment/scripts/tls-deploy-hook.sh"
|
||||
test -x "$TARGET/backend/deployment/secrets/han-compose"
|
||||
test -x "$TARGET/backend/deployment/secrets/han-secrets"
|
||||
if find "$TARGET/backend" -type l -print -quit | grep -q .; then exit 1; fi
|
||||
chown -R root:root "$TARGET"
|
||||
chmod -R go-w "$TARGET"
|
||||
ln -s "releases/${RELEASE}" /opt/han-chat/.current-new
|
||||
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
|
||||
```
|
||||
|
||||
Executable modes поставляет release manifest/git. Нельзя применять blanket
|
||||
`--chmod=F644` или рекурсивный `chmod`, снимающий executable bit. Сохраните
|
||||
SHA-256 архива и release SHA в change record.
|
||||
|
||||
Повторите setup из активного release с теми же public key files. Он
|
||||
установит root-owned launcher, secret unit, stack unit, TLS hook и sudoers, но
|
||||
не запустит приложение:
|
||||
|
||||
```sh
|
||||
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
|
||||
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
|
||||
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
|
||||
/opt/han-chat/current/backend/deployment/scripts/setup-vm.sh
|
||||
visudo -cf /etc/sudoers.d/deploy
|
||||
sudo -l -U deploy
|
||||
rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub
|
||||
```
|
||||
|
||||
Убедитесь, что в выводе нет wildcard, shell/editor/cp/chmod/docker и что
|
||||
`deploy` не входит в `docker`, `sudo`, `lxd`, `adm`, `systemd-journal`.
|
||||
Для повторного setup после будущего release заново передайте только проверенные
|
||||
public keys в root-only временный каталог и удалите их после выполнения.
|
||||
|
||||
## 4. Несекретный config и secrets
|
||||
|
||||
Под root создайте `/etc/han/vm1.env` из reviewed production template. Config
|
||||
живёт вне immutable release и не меняется при rollback. В нём только
|
||||
несекретные значения и paths; `APP_ENV=production`,
|
||||
`SECRETS_SOURCE=selectel`, все images pinned `@sha256:...`,
|
||||
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443`,
|
||||
`MESSAGE_SAFETY_EXTRA_HOST=<VM2_PRIVATE_DNS>=<VM2_PRIVATE_IP>`,
|
||||
`MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem`,
|
||||
`NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem`,
|
||||
`NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem`.
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/current/backend
|
||||
install -m 0600 -o root -g root .env.example /etc/han/vm1.env
|
||||
editor /etc/han/vm1.env
|
||||
./scripts/validate-env /etc/han/vm1.env
|
||||
install -m 0600 -o root -g root deployment/secrets/config.example.json \
|
||||
/etc/han/secrets/production.selectel.json
|
||||
editor /etc/han/secrets/production.selectel.json
|
||||
```
|
||||
|
||||
Mapping должен содержать только VM1 secrets. Убедитесь, что в нём отсутствуют
|
||||
legacy local `message-safety`/`bitrix-sync` consumers, Redis DB2 и credentials
|
||||
сервисов ВМ2; отдельный IAM principal ВМ1 получает read-only только к
|
||||
перечисленным remote names. Пароли/DSN/token/S3 keys не помещаются в
|
||||
`/etc/han/vm1.env`.
|
||||
|
||||
Создайте encrypted systemd credential без значения в argv/history:
|
||||
|
||||
```sh
|
||||
read -rsp 'Selectel VM1 service-user password: ' SELECTEL_PASSWORD; echo
|
||||
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
|
||||
--name=selectel-service-user-password - \
|
||||
/etc/han/credentials/production.selectel-password.cred
|
||||
unset SELECTEL_PASSWORD
|
||||
chown root:root /etc/han/credentials/production.selectel-password.cred
|
||||
chmod 0600 /etc/han/credentials/production.selectel-password.cred
|
||||
```
|
||||
|
||||
Подтвердите token pairs без печати значений средствами validator. Не делайте
|
||||
`cat` runtime secret files. Emergency file mode — отдельная root-only
|
||||
процедура без automatic fallback.
|
||||
|
||||
## 5. Managed PostgreSQL и S3
|
||||
|
||||
Установите provider CA вне release:
|
||||
|
||||
```sh
|
||||
install -m 0644 -o root -g root /tmp/<PG_CA_FILE> \
|
||||
/etc/han/ca/managed-postgresql-ca.pem
|
||||
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
|
||||
-noout -subject -issuer -dates
|
||||
rm -f /tmp/<PG_CA_FILE>
|
||||
```
|
||||
|
||||
Runtime и migration roles разделены для `han_app`, `bitrix_local`, `sms`,
|
||||
`keycloak`; runtime не имеет DDL/ownership. DSN проверяет hostname и chain.
|
||||
Включены encryption, deletion protection, backup/PITR и alerts. Перед
|
||||
миграциями создаётся provider PITR marker.
|
||||
|
||||
S3: private encrypted buckets documents/attachments/quarantine; exact browser
|
||||
origin CORS; prefix-scoped VM1 key; versioning/lifecycle; public ACL off.
|
||||
Read-only quarantine key Safety принадлежит IAM ВМ2 и не копируется на ВМ1.
|
||||
Проверьте negative access к чужому prefix/bucket.
|
||||
|
||||
## 6. Public TLS и internal VM2 CA
|
||||
|
||||
Установите внутренний CA, которым ВМ1 проверяет SAN ВМ2:
|
||||
|
||||
```sh
|
||||
install -m 0644 -o root -g root /tmp/<VM2_INTERNAL_CA_FILE> \
|
||||
/etc/han/ca/vm2-internal-ca.pem
|
||||
openssl x509 -in /etc/han/ca/vm2-internal-ca.pem \
|
||||
-noout -subject -issuer -dates
|
||||
rm -f /tmp/<VM2_INTERNAL_CA_FILE>
|
||||
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
|
||||
-servername <VM2_PRIVATE_DNS> -verify_hostname <VM2_PRIVATE_DNS> \
|
||||
-CAfile /etc/han/ca/vm2-internal-ca.pem -verify_return_error </dev/null
|
||||
```
|
||||
|
||||
Для initial public certificate DNS уже указывает на ВМ1, `80` свободен:
|
||||
|
||||
```sh
|
||||
PUBLIC_HOST='<PUBLIC_HOST>'
|
||||
ACME_EMAIL='<OPS_EMAIL>'
|
||||
certbot certonly --standalone --preferred-challenges http --staging \
|
||||
-d "$PUBLIC_HOST" --cert-name "${PUBLIC_HOST}-staging" \
|
||||
--email "$ACME_EMAIL" --agree-tos --no-eff-email --non-interactive
|
||||
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
|
||||
certbot certonly --standalone --preferred-challenges http \
|
||||
-d "$PUBLIC_HOST" --cert-name "$PUBLIC_HOST" \
|
||||
--email "$ACME_EMAIL" --agree-tos --no-eff-email --non-interactive
|
||||
```
|
||||
|
||||
При первом копировании nginx ещё не работает, поэтому deploy hook до запуска
|
||||
не вызывается. Скопируйте initial pair теми же root ownership и mode, затем
|
||||
preflight проверит pair:
|
||||
|
||||
```sh
|
||||
install -m 0640 -o root -g han-nginx-tls \
|
||||
"/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" \
|
||||
/var/lib/han-chat/public-tls/fullchain.pem
|
||||
install -m 0640 -o root -g han-nginx-tls \
|
||||
"/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" \
|
||||
/var/lib/han-chat/public-tls/privkey.pem
|
||||
```
|
||||
|
||||
В production Compose nginx обязан монтировать только
|
||||
`/var/lib/han-chat/public-tls` read-only, не `/etc/letsencrypt`. Если это ещё
|
||||
legacy named volume, preflight/Compose review — блокер, не workaround.
|
||||
|
||||
## 7. Secret sync и static preflight
|
||||
|
||||
```sh
|
||||
systemctl restart han-secrets@production.service
|
||||
systemctl is-active han-secrets@production.service
|
||||
journalctl --no-pager -u han-secrets@production.service
|
||||
test -s /run/han-chat/secrets/manifest
|
||||
cut -d= -f1 /run/han-chat/secrets/manifest | sort
|
||||
|
||||
/opt/han-chat/current/backend/deployment/preflight.sh
|
||||
/usr/local/sbin/han-vm1-compose config --quiet
|
||||
/usr/local/sbin/han-vm1-compose config --services
|
||||
/usr/local/sbin/han-vm1-compose config --images
|
||||
```
|
||||
|
||||
В списке production services после VM2 cutover нет local `message-safety`,
|
||||
`bitrix-sync`, Redis DB2 и test stubs. Только nginx публикует `80/443`; все
|
||||
images immutable. Не сохраняйте resolved Compose с secret paths/metadata в
|
||||
общедоступный файл.
|
||||
|
||||
## 8. Миграции и seed
|
||||
|
||||
После PITR marker под root:
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-api
|
||||
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local
|
||||
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-sms
|
||||
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
|
||||
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
|
||||
```
|
||||
|
||||
Проверьте current/head каждой схемы, идемпотентность seed и negative DDL от
|
||||
runtime roles. Миграции ВМ1 `han_app` precede dependent VM2 sync cutover.
|
||||
Временные cross-schema grants выдаёт owner и отзывает после проверки. Unknown
|
||||
revision, multiple heads или incompatible schema — stop; ручной `stamp` и
|
||||
downgrade запрещены.
|
||||
|
||||
## 9. Упорядоченный первый запуск
|
||||
|
||||
Первый запуск выполняет root через фиксированный launcher:
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/han-vm1-compose up -d redis otel-queue-init otel-collector
|
||||
/usr/local/sbin/han-vm1-compose up -d keycloak sms-service
|
||||
/usr/local/sbin/han-vm1-compose up -d api-backend bitrix-local-app
|
||||
/usr/local/sbin/han-vm1-compose up -d \
|
||||
sms-worker delivery-worker safety-recovery-worker cleanup-worker \
|
||||
notification-expire-worker notification-draft-cleanup-worker
|
||||
/usr/local/sbin/han-vm1-compose up -d frontend-static
|
||||
/usr/local/sbin/han-vm1-compose up -d nginx
|
||||
/usr/local/sbin/han-vm1-compose ps
|
||||
```
|
||||
|
||||
Дождитесь health, затем:
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/han-vm1-compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
NGINX_ID=$(/usr/local/sbin/han-vm1-compose ps --status running --quiet nginx)
|
||||
test -n "$NGINX_ID"
|
||||
docker kill --signal HUP "$NGINX_ID" >/dev/null
|
||||
unset NGINX_ID
|
||||
systemctl enable han-secrets@production.service han-stack@production.service
|
||||
systemctl start han-stack@production.service
|
||||
```
|
||||
|
||||
`han-stack@production` становится единственным routine lifecycle interface.
|
||||
После изменения secret source сначала explicit restart secret unit, затем
|
||||
stack unit. Обновление active/exited oneshot всегда требует `restart`.
|
||||
|
||||
## 10. Smoke, firewall и cutover
|
||||
|
||||
С внешней машины:
|
||||
|
||||
```sh
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
|
||||
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
|
||||
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
||||
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST> \
|
||||
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
|
||||
```
|
||||
|
||||
Ожидается `308`, public endpoints `200`, internal route `404`, valid chain.
|
||||
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
|
||||
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
|
||||
allow/deny/pending/timeout. Safety status `stub` не принимается.
|
||||
|
||||
До переключения `MESSAGE_SAFETY_URL` ВМ2 должна закрыть собственные gates.
|
||||
После переключения подтвердите private CA/SAN, service token, `text|links|files`
|
||||
capabilities и fail-closed timeout. Local Safety/Redis DB2 не оставляются как
|
||||
fallback. Cutover ВМ2 Bitrix sync — отдельное окно.
|
||||
|
||||
Проверьте `DOCKER-USER` live counters внешним positive `80/443` и negative
|
||||
port/source probe:
|
||||
|
||||
```sh
|
||||
iptables -L HAN-CHAT-DOCKER -n -v
|
||||
systemctl restart han-chat-docker-firewall.service
|
||||
systemctl restart docker.service
|
||||
systemctl restart han-chat-docker-firewall.service
|
||||
iptables -S HAN-CHAT-DOCKER
|
||||
```
|
||||
|
||||
Allow rules должны использовать `--ctorigdstport 80/443`; UFW INPUT counter не
|
||||
является доказательством published Docker ports.
|
||||
|
||||
## 11. Certbot, observability и reboot gate
|
||||
|
||||
После запуска nginx переключите renewal на webroot, который Compose монтирует
|
||||
из `/var/lib/han-chat/acme`, затем:
|
||||
|
||||
```sh
|
||||
certbot reconfigure --cert-name '<PUBLIC_HOST>' \
|
||||
--authenticator webroot --webroot-path /var/lib/han-chat/acme
|
||||
certbot renew --dry-run --run-deploy-hooks
|
||||
systemctl enable --now certbot.timer
|
||||
systemctl list-timers certbot.timer
|
||||
```
|
||||
|
||||
Hook должен завершаться `0` с пустым stderr на success, атомарно обновлять
|
||||
staging, выполнять config test и HUP.
|
||||
|
||||
Проверьте logs/metrics/traces, request ID через nginx/API/VM2, alerts для
|
||||
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
|
||||
token/PII markers и докажите их отсутствие в logs/traces.
|
||||
|
||||
Перед reboot проверьте admin SSH и provider console:
|
||||
|
||||
```sh
|
||||
systemctl is-enabled docker.service han-chat-docker-firewall.service \
|
||||
han-secrets@production.service han-stack@production.service certbot.timer
|
||||
systemctl reboot
|
||||
```
|
||||
|
||||
После reconnect повторите status, stack `ps`, public/private TLS, smoke,
|
||||
negative ports и firewall counters. Без reboot gate deployment не завершён.
|
||||
|
||||
## 12. Rollback и disaster recovery
|
||||
|
||||
Application rollback допускается только на предыдущий root-owned release,
|
||||
совместимый с текущей schema. Секреты и `/etc/han/vm1.env` остаются текущими:
|
||||
|
||||
```sh
|
||||
PREVIOUS='<PREVIOUS_COMPATIBLE_GIT_SHA>'
|
||||
test -d "/opt/han-chat/releases/${PREVIOUS}/backend"
|
||||
ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new
|
||||
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
|
||||
systemctl daemon-reload
|
||||
systemctl restart han-secrets@production.service
|
||||
/opt/han-chat/current/backend/deployment/preflight.sh
|
||||
systemctl restart han-stack@production.service
|
||||
```
|
||||
|
||||
Повторите smoke и зафиксируйте digests. Не удаляйте current/previous release,
|
||||
active images, evidence или volumes. После incompatible migration используйте
|
||||
forward fix либо согласованный PG PITR + S3/Bitrix reconciliation в maintenance
|
||||
window; Redis восстанавливается пустым и прогревается из PG.
|
||||
|
||||
Потеря ВМ1: создайте новую VM этим runbook, восстановите DNS/SG, root-owned
|
||||
approved release, VM1 IAM secrets, managed PG/S3 и public TLS; ВМ2 не
|
||||
пересоздавайте. До cutover держите traffic закрытым. Ежеквартально делайте
|
||||
isolated restore rehearsal, не направляя production DNS/Bitrix callbacks, и
|
||||
фиксируйте фактические RPO/RTO.
|
||||
|
||||
При компрометации host не «очищайте» VM: изолируйте, сохраните evidence,
|
||||
ротируйте доступные secrets/tokens и reprovision из trusted image.
|
||||
|
||||
## 13. Production acceptance record
|
||||
|
||||
Сохраните без secret values: release/archive SHA-256, image digests, schema
|
||||
heads, realm/config version, PG PITR marker, TLS fingerprints/expiry, systemd
|
||||
status, positive/negative firewall probes, public/private smokes, VM2 cutover
|
||||
approval, observability canary, reboot result, rollback/restore rehearsal и
|
||||
подписи Operations/Security/Product.
|
||||
@@ -1,271 +1,9 @@
|
||||
# Инструкция по развертыванию HAN Chat в production-like окружении
|
||||
# Legacy runbook — указатель
|
||||
|
||||
Это исполняемый чек-лист для контура на одной виртуальной машине. PostgreSQL и S3
|
||||
используются как внешние управляемые сервисы. Во время развертывания запрещено
|
||||
использовать `docker compose down -v`, откат миграций Alembic и изменяемые теги образов.
|
||||
Исполняемый fresh production runbook ВМ1:
|
||||
[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md).
|
||||
|
||||
Подробная пошаговая инструкция для первого запуска находится в
|
||||
`deployment/DEPLOYMENT_GUIDE.ru.md`.
|
||||
|
||||
## Этап 0 — решения и зоны ответственности
|
||||
|
||||
- [ ] Зафиксированы SHA/дайджесты релиза, окно обслуживания, дежурный и ответственный за откат.
|
||||
- [ ] Согласованы RPO/RTO; начальные цели: RPO PostgreSQL не более 15 минут и RTO не более 4 часов.
|
||||
- [ ] Выбран удаленный OTLP-бэкенд либо принято ограничение на использование только отладочного контура.
|
||||
- [ ] Явно приняты риски mock OTP, заглушки Safety и заглушки bitrix-sync.
|
||||
|
||||
## Этап 1 — VPC, DNS и группы безопасности
|
||||
|
||||
- [ ] Управляемый PostgreSQL имеет только приватную точку доступа и принимает трафик от группы безопасности VM.
|
||||
- [ ] Из интернета доступны только TCP-порты VM 80/443; SSH ограничен VPN или CIDR администраторов.
|
||||
- [ ] Порты 6379, 4317/4318, 8000, 8080 и 9000 закрыты для внешнего доступа.
|
||||
- [ ] DNS-запись `A` для `PUBLIC_HOST` указывает на VM; исходящий HTTPS доступен.
|
||||
|
||||
## Этап 2 — защита виртуальной машины
|
||||
|
||||
На новой Ubuntu 24.04 можно выполнить подготовительный скрипт:
|
||||
|
||||
```sh
|
||||
sudo deployment/scripts/setup-vm.sh
|
||||
```
|
||||
|
||||
Скрипт по умолчанию отключает парольный SSH-вход и X11 forwarding, а после
|
||||
проверки ключей блокирует локальные пароли `root` и `deploy`. Перед включением
|
||||
`HARDEN_SSH=true`, которое дополнительно запрещает root-вход и TCP forwarding,
|
||||
обязательно проверьте вход пользователем `deploy` по ключу в отдельной сессии.
|
||||
|
||||
- [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска.
|
||||
- [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены.
|
||||
- [ ] Правила UFW/облачной группы безопасности и политика `DOCKER-USER` сохраняются после перезагрузки.
|
||||
- [ ] Docker Engine и Compose поддерживают `include` и полную форму `env_file`.
|
||||
|
||||
## Этап 3 — управляемый PostgreSQL
|
||||
|
||||
- [ ] Включены ежедневные резервные копии, PITR, защита от удаления, шифрование и оповещения.
|
||||
- [ ] CA-сертификат провайдера установлен по пути `PG_CA_HOST_PATH`; все DSN используют `verify-full`.
|
||||
- [ ] Для схем `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
|
||||
созданы отдельные роли миграций и выполнения; запрет лишних прав проверен тестами.
|
||||
- [ ] Миграции проверены на пустой БД и на клоне БД предыдущего релиза.
|
||||
|
||||
## Этап 4 — Selectel S3
|
||||
|
||||
- [ ] Бакеты карантина, вложений и документов закрыты от публичного доступа и зашифрованы.
|
||||
- [ ] Права API ограничены префиксами; учетные данные Safety имеют доступ к карантину только на чтение.
|
||||
- [ ] CORS бакетов разрешает только точный HTTPS-origin браузера и необходимые заголовки PUT.
|
||||
- [ ] Срок хранения карантина превышает время Safety polling/recovery; политика хранения данных согласована.
|
||||
|
||||
## Этап 5 — неизменяемый релиз
|
||||
|
||||
- [ ] Репозиторий переключен на утвержденный SHA в detached-режиме; рабочее дерево чистое.
|
||||
- [ ] Образы сервисов неизменяемы и просканированы; нерешенных критических и высоких уязвимостей нет.
|
||||
- [ ] Корневой `docker-compose.yml` является единственной точкой запуска.
|
||||
|
||||
## Этап 6 — окружение и секреты
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Замените только несекретные placeholders.
|
||||
./scripts/validate-env .env
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
```
|
||||
|
||||
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` не содержит secret keys и DSN с credentials.
|
||||
- [ ] `deployment/secrets/han-secrets` устанавливает `HAN_SECRETS_ACTIVE=1`,
|
||||
не пишет значения в лог и при возможности передаёт paths-only manifest
|
||||
через `HAN_RUNTIME_SECRET_MANIFEST`.
|
||||
- [ ] Runtime-пары токенов совпадают, PostgreSQL проверяет TLS.
|
||||
- [ ] Риск mock OTP принят; runtime-секреты уникальны и содержат не менее 128 бит энтропии.
|
||||
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` сгенерирован отдельно, передан только через secret/env; seed `notification_sources.code='producer_test'` содержит только его hash.
|
||||
- [ ] Установлено `FRONTEND_DEV_PROXY_ENABLED=false`; таймауты Safety и nginx согласованы.
|
||||
|
||||
## Этап 7 — образы и статический frontend
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env pull
|
||||
docker compose --env-file .env build --pull frontend-static nginx redis
|
||||
docker compose --env-file .env run --rm frontend-static
|
||||
```
|
||||
|
||||
- [ ] Экспорт frontend проверен и просканирован, затем скопирован сервисом `frontend-static` в именованный volume.
|
||||
- [ ] Артефакты сборки не содержат секретов и непредусмотренных source map.
|
||||
- [ ] На диске VM остается не менее 30% свободного места.
|
||||
|
||||
## Этап 8 — топология
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env config --services
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
- [ ] Только nginx публикует `80:80` и `443:443`; сервиса PostgreSQL в Compose нет.
|
||||
- [ ] Присутствуют volumes Redis AOF/RDB/ACL и постоянной очереди OTEL.
|
||||
- [ ] Сети `backend` и `observability` являются внутренними.
|
||||
|
||||
## Этап 9 — первоначальная настройка ACME/TLS
|
||||
|
||||
Установите `NGINX_TLS_ENABLED=false` только для команды первоначального запуска:
|
||||
|
||||
```sh
|
||||
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
|
||||
docker compose --profile certbot run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
|
||||
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
```
|
||||
|
||||
Сначала выполните проверку с параметром Certbot `--staging`. Установите systemd-таймер,
|
||||
запускающий `deployment/scripts/ssl-renew.sh` дважды в сутки, и проверьте
|
||||
`certbot renew --dry-run`. Включайте HSTS только после проверки цепочки сертификатов,
|
||||
имени хоста, перенаправления и поддержки TLS 1.2/1.3.
|
||||
|
||||
## Этап 10 — миграции и начальные данные
|
||||
|
||||
Создайте у провайдера точку восстановления PITR, затем выполните:
|
||||
|
||||
```sh
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
- [ ] Активны ожидаемые ревизии Alembic; runtime-пользователи не выполняли DDL.
|
||||
- [ ] Повторный seed завершается успешно; обязательные настройки не содержат секретов.
|
||||
- [ ] Схема остается обратно совместимой с образами предыдущего релиза.
|
||||
|
||||
## Этап 11 — Keycloak
|
||||
|
||||
```sh
|
||||
docker compose up -d keycloak
|
||||
docker compose ps keycloak
|
||||
```
|
||||
|
||||
- [ ] Issuer discovery/JWKS точно совпадает с публичным HTTPS URL `/auth`.
|
||||
- [ ] Frontend-клиент является публичным PKCE S256; implicit, password и social flows отключены.
|
||||
- [ ] Неверный или повторно использованный OTP и превышение лимитов безопасно отклоняются; settings bridge работает fail-closed.
|
||||
- [ ] При `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` initial send и resend требуют свежий SmartCaptcha token; техническая недоступность Yandex подтверждена как fail-open в логах.
|
||||
- [ ] CSP login-страницы содержит `smartcaptcha.cloud.yandex.ru`/`yastatic.net`, а `/auth/realms/master/protocol/openid-connect/3p-cookies/step2.html` и Admin Console работают без CAPTCHA CSP.
|
||||
- [ ] Временный администратор удален либо его пароль изменен; для именного администратора включена MFA.
|
||||
|
||||
Если предыдущая попытка сохранила custom CSP в realm, сбросьте только это поле через `kcadm`; `.env` как shell-файл не загружать:
|
||||
|
||||
```sh
|
||||
docker compose exec -T keycloak sh -lc '
|
||||
set -eu
|
||||
cfg=/tmp/han-kcadm.config
|
||||
/opt/keycloak/bin/kcadm.sh config credentials --config "$cfg" \
|
||||
--server http://127.0.0.1:8080/auth --realm master \
|
||||
--user "$KC_BOOTSTRAP_ADMIN_USERNAME" \
|
||||
--password "$KC_BOOTSTRAP_ADMIN_PASSWORD"
|
||||
/opt/keycloak/bin/kcadm.sh update realms/han-chat --config "$cfg" \
|
||||
-s "browserSecurityHeaders.contentSecurityPolicy="
|
||||
rm -f "$cfg"
|
||||
'
|
||||
```
|
||||
|
||||
## Этап 12 — последовательный запуск и готовность
|
||||
|
||||
```sh
|
||||
docker compose up -d redis
|
||||
docker compose up -d keycloak otel-collector
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d api-backend
|
||||
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
|
||||
notification-expire-worker notification-draft-cleanup-worker
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
|
||||
любого пересоздания `api-backend`, `keycloak`, `sms-service` или
|
||||
`bitrix-local-app` дождитесь их readiness, проверьте именно рабочий
|
||||
`/tmp/nginx.conf` и отправьте master-процессу `HUP`, как показано выше.
|
||||
Обычные `nginx -t` и `nginx -s reload` использовать нельзя: они обращаются к
|
||||
дефолтному config/PID в read-only `/var/run` и не перезагружают рабочий Nginx.
|
||||
|
||||
- [ ] Нет циклических перезапусков и OOM; критические readiness-проверки успешны.
|
||||
- [ ] `notification-expire-worker` выполняет ежедневное закрытие с advisory lock; `notification-draft-cleanup-worker` очищает просроченные drafts/S3. Оба entrypoint присутствуют в установленном образе.
|
||||
- [ ] Сохраняется только документированная деградация: Bitrix не установлен и bitrix-sync работает как заглушка.
|
||||
- [ ] Внешний запрос `/internal/*` возвращает 404; OTEL принимает телеметрию.
|
||||
|
||||
## Этап 13 — Bitrix24
|
||||
|
||||
- [ ] URL установки, обработчика и placement используют точные публичные HTTPS-пути.
|
||||
- [ ] Коннектор `han_mobile_app` активен в Открытой линии 8; события привязаны однократно.
|
||||
- [ ] OAuth зашифрован; callback-, application- и service-токены не попадают в логи.
|
||||
- [ ] Исходящие сообщения и ответы оператора идемпотентны; внутренний статус не опубликован наружу.
|
||||
|
||||
## Этап 14 — smoke- и E2E-тесты
|
||||
|
||||
```sh
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
- [ ] Успешны сценарии гостя, OTP/PKCE/bootstrap/session, обновления токена и выхода.
|
||||
- [ ] Проверены Safety allow/deny/pending/timeout и один параллельный медленный poll.
|
||||
- [ ] Проверены карантин, перенос и удаление файлов, скачивание только владельцем и аудит.
|
||||
- [ ] Проверены переподключение WS с REST-сверкой, 404 при обращении к чужому ресурсу, идемпотентность и 429.
|
||||
- [ ] От имени `producer_test` выполнены Create и Cancel через закрытый `/internal/notifications/v1/*`; тот же Create вернул `200`, изменённый payload — `409`, внешний запрос — `404`.
|
||||
- [ ] Проверены expire job с advisory lock и первое скачивание любого связанного документа: уведомление скрывается один раз, а исходный `date_expired` не перезаписывается.
|
||||
- [ ] Логи не содержат PII, текстов сообщений, токенов и query-параметров presigned URL.
|
||||
|
||||
## Этап 15 — наблюдаемость
|
||||
|
||||
- [ ] Известный request ID связывает трассировку nginx, API и downstream-сервисов; UX ID не используется как label.
|
||||
- [ ] Все три сигнала поступают в выбранный бэкенд; SLO-запросы и оповещения проверены.
|
||||
- [ ] При недоступности удаленного сервиса ограниченная постоянная очередь заполняется и опустошается без остановки бизнес-функций.
|
||||
- [ ] Тестовые секреты и PII отсутствуют; проверены метрики перезапуска, потерь, отказов и очереди Collector.
|
||||
|
||||
Только для локальной приемки запустите отладочный Collector с удалением чувствительных данных:
|
||||
`docker compose --profile observability-local up -d otel-collector-local`.
|
||||
|
||||
## Этап 16 — открытие трафика
|
||||
|
||||
- [ ] Этапы 0–15 подписаны; имеются свежие подтверждения backup/PITR и предыдущие образы.
|
||||
- [ ] HSTS включен; дайджесты релиза, версии схем и realm зафиксированы.
|
||||
- [ ] Активных инцидентов нет; дежурный и владелец продукта приняли ограничения заглушек.
|
||||
- [ ] В течение 60 минут контролируются 5xx, auth, доставка, БД, Redis, OOM, очередь OTEL и Bitrix.
|
||||
|
||||
## Резервное копирование и восстановление
|
||||
|
||||
Основным механизмом являются backup/PITR провайдера. Дополнительный проверенный логический дамп:
|
||||
|
||||
```sh
|
||||
deployment/scripts/backup.sh /opt/han-chat/backups
|
||||
```
|
||||
|
||||
Ежеквартально восстанавливайте PostgreSQL и S3 в изолированной VPC, развертывайте те же
|
||||
дайджесты образов, не направляйте туда production DNS и callbacks Bitrix, выполняйте
|
||||
smoke-тесты и фиксируйте фактические RPO/RTO. Redis можно восстановить пустым:
|
||||
его AOF/RDB не является резервной копией бизнес-данных.
|
||||
|
||||
## Откат
|
||||
|
||||
Откатывайтесь только на образы, совместимые с текущей схемой:
|
||||
|
||||
```sh
|
||||
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
|
||||
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
Откат использует текущие runtime-секреты и текущий несекретный config. Snapshot
|
||||
старого `.env` не создаётся и не восстанавливается.
|
||||
|
||||
## Дополнение: rollout реальной SMS-авторизации
|
||||
|
||||
Текущий runbook остаётся mock-only, пока артефакты module-11 не реализованы. Для SMS release обязательны: schema/role `sms`, migrations/seed active approved `auth_otp`, `sms-service`/worker, exact callback route, парные service tokens, Direct `TOKEN_1`, согласованные sender/template, отдельные callback credentials, подтверждённый callback source IP и статический egress IP worker.
|
||||
|
||||
Порядок: App DB OTP seed → SMS schema/migrations/seed → test с mock Direct → production SMS deploy при `KEYCLOAK_OTP_MOCK_ENABLED=true` → Keycloak expand migration/SPI → provider smoke и callback/redaction evidence → real mode. Rollback: вернуть mock, не удалять journal/schema, остановить новые real orders и зафиксировать in-flight/`uncertain`; downgrade только при доказанной совместимости.
|
||||
|
||||
Никогда не выполняйте downgrade Alembic. После обратно несовместимой миграции используйте
|
||||
исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время
|
||||
технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение
|
||||
с неопределенным статусом не было отправлено повторно.
|
||||
Старый single-VM/stub порядок намеренно удалён. Он не является основанием для
|
||||
production acceptance, rollback или cutover Message Safety на ВМ2. Не
|
||||
восстанавливайте из истории команды прямого Docker-доступа пользователя
|
||||
`deploy`.
|
||||
|
||||
@@ -32,8 +32,8 @@ x-api-job-environment: &api-job-environment
|
||||
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL:-http://keycloak:8080/auth}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-han-chat-api}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-https://processing.internal:8443}
|
||||
MESSAGE_SAFETY_API_PREFIX: ${MESSAGE_SAFETY_API_PREFIX:-/internal/safety/v2}
|
||||
MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
@@ -46,9 +46,22 @@ x-api-job-environment: &api-job-environment
|
||||
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32}
|
||||
|
||||
x-python-job-runtime: &python-job-runtime
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 0.5
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
services:
|
||||
migrate-api:
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
<<: *python-job-runtime
|
||||
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: DATABASE_URL
|
||||
@@ -60,12 +73,10 @@ services:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
migrate-bitrix-local:
|
||||
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
|
||||
<<: *python-job-runtime
|
||||
image: ${BITRIX_LOCAL_APP_IMAGE:?BITRIX_LOCAL_APP_IMAGE must be pinned by digest}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: BITRIX_DATABASE_URL
|
||||
@@ -77,29 +88,10 @@ services:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
migrate-bitrix-sync:
|
||||
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: BITRIX_SYNC_DATABASE_URL
|
||||
BITRIX_SYNC_DATABASE_URL_FILE: /run/secrets/bitrix_sync_database_url
|
||||
secrets:
|
||||
- bitrix_sync_database_url
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
migrate-sms:
|
||||
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
|
||||
<<: *python-job-runtime
|
||||
image: ${SMS_SERVICE_IMAGE:?SMS_SERVICE_IMAGE must be pinned by digest}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: SMS_DATABASE_URL
|
||||
@@ -111,12 +103,10 @@ services:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
seed-settings:
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
<<: *python-job-runtime
|
||||
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
|
||||
profiles: ["ops"]
|
||||
environment: *api-job-environment
|
||||
secrets: *api-job-secrets
|
||||
@@ -133,15 +123,17 @@ services:
|
||||
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
toolbox:
|
||||
image: curlimages/curl:8.11.1
|
||||
image: ${TOOLBOX_IMAGE:?TOOLBOX_IMAGE must be pinned by digest}
|
||||
profiles: ["ops"]
|
||||
entrypoint: ["sleep", "infinity"]
|
||||
networks: [backend, observability, egress]
|
||||
restart: "no"
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:size=16m,mode=1777"]
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 32
|
||||
mem_limit: 64m
|
||||
cpus: 0.25
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
[Unit]
|
||||
Description=HAN Chat VM1 root Compose stack (%i)
|
||||
Requires=docker.service han-secrets@%i.service
|
||||
After=docker.service han-secrets@%i.service network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathIsDirectory=/opt/han-chat/current/backend
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/han-chat/current/backend
|
||||
ExecStart=/usr/local/sbin/han-vm1-compose up -d --remove-orphans
|
||||
ExecReload=/usr/local/sbin/han-vm1-compose up -d --remove-orphans
|
||||
ExecStop=/usr/local/sbin/han-vm1-compose stop
|
||||
TimeoutStartSec=600
|
||||
TimeoutStopSec=180
|
||||
UMask=0077
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
LimitCORE=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=${1:-/opt/han-chat/current/backend}
|
||||
ENV_FILE=${2:-/etc/han/vm1.env}
|
||||
MANIFEST=${3:-/run/han-chat/secrets/manifest}
|
||||
failures=0
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
value() {
|
||||
awk -F= -v key="$1" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE"
|
||||
}
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "preflight must run as root"
|
||||
[ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing"
|
||||
[ -f "$ENV_FILE" ] || fail ".env is missing"
|
||||
[ -f "$MANIFEST" ] || fail "runtime secret manifest is missing"
|
||||
[ -L /opt/han-chat/current ] || fail "/opt/han-chat/current must be a root-controlled release link"
|
||||
[ "$(/usr/bin/stat -c '%U:%G' /opt/han-chat/current)" = root:root ] ||
|
||||
fail "active release link must be root:root"
|
||||
nginx_compose="$ROOT/nginx/docker-compose.yml"
|
||||
[ -f "$nginx_compose" ] || fail "nginx compose fragment is missing"
|
||||
if [ -f "$nginx_compose" ]; then
|
||||
grep -Fq '/var/lib/han-chat/public-tls' "$nginx_compose" &&
|
||||
grep -Fq '/run/tls' "$nginx_compose" ||
|
||||
fail "nginx must mount host public-tls staging at /run/tls"
|
||||
grep -Fq '/var/lib/han-chat/acme' "$nginx_compose" ||
|
||||
fail "nginx must mount host ACME webroot"
|
||||
! grep -Fq '/etc/letsencrypt' "$nginx_compose" ||
|
||||
fail "nginx/certbot compose must not mount the root Certbot tree"
|
||||
! grep -Eq '^[[:space:]]+certbot:' "$nginx_compose" ||
|
||||
fail "certbot must not be a Compose service"
|
||||
! grep -Eq 'nginx-(certs|acme)' "$nginx_compose" ||
|
||||
fail "legacy nginx TLS named volumes are forbidden"
|
||||
fi
|
||||
! grep -Eq '^[[:space:]]+nginx-(certs|acme)' "$ROOT/docker-compose.yml" ||
|
||||
fail "legacy nginx TLS named volumes are forbidden"
|
||||
|
||||
if [ -d "$ROOT" ]; then
|
||||
if find "$ROOT" -type l -print -quit | grep -q .; then
|
||||
fail "active release contains a symlink"
|
||||
fi
|
||||
while IFS= read -r protected; do
|
||||
[ -f "$protected" ] || {
|
||||
fail "protected deployment file is missing: $protected"
|
||||
continue
|
||||
}
|
||||
[ "$(/usr/bin/stat -c '%U:%G' "$protected")" = root:root ] ||
|
||||
fail "$protected must be root:root"
|
||||
mode=$(/usr/bin/stat -c '%A' "$protected")
|
||||
case "$mode" in
|
||||
??????w???|????????w?) fail "$protected is writable by group/other" ;;
|
||||
esac
|
||||
done <<EOF
|
||||
$ROOT/docker-compose.yml
|
||||
$ROOT/deployment/preflight.sh
|
||||
$ROOT/deployment/han-stack@.service
|
||||
$ROOT/deployment/scripts/tls-deploy-hook.sh
|
||||
$ROOT/deployment/secrets/han-compose
|
||||
$ROOT/deployment/secrets/han-secrets
|
||||
EOF
|
||||
fi
|
||||
|
||||
for executable in \
|
||||
"$ROOT/deployment/preflight.sh" \
|
||||
"$ROOT/deployment/scripts/tls-deploy-hook.sh" \
|
||||
"$ROOT/deployment/secrets/han-compose" \
|
||||
"$ROOT/deployment/secrets/han-secrets"
|
||||
do
|
||||
[ -x "$executable" ] || fail "required executable is not executable: $executable"
|
||||
if [ -f "$executable" ] && LC_ALL=C grep -q "$(printf '\r')" "$executable"; then
|
||||
fail "CRLF is forbidden in executable: $executable"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
if grep -Eq '(^|_)(PASSWORD|SECRET|TOKEN|DATABASE_URL|REDIS_URL|PRIVATE_KEY|ACCESS_KEY)=' "$ENV_FILE"; then
|
||||
fail ".env contains a secret-shaped assignment"
|
||||
fi
|
||||
if grep -Eq '=<[^>]+>|change-me|example\.(com|org|net)|\.invalid([:/]|$)' "$ENV_FILE"; then
|
||||
fail ".env contains placeholders"
|
||||
fi
|
||||
[ "$(value APP_ENV)" = production ] || fail "APP_ENV must be production"
|
||||
[ "$(value SECRETS_SOURCE)" = selectel ] || fail "production SECRETS_SOURCE must be selectel"
|
||||
[ "$(value FRONTEND_DEV_PROXY_ENABLED)" = false ] ||
|
||||
fail "FRONTEND_DEV_PROXY_ENABLED must be false"
|
||||
[ "$(value NGINX_TLS_ENABLED)" = true ] || fail "NGINX_TLS_ENABLED must be true"
|
||||
[ "$(value NGINX_HTTP_PORT)" = 80 ] || fail "nginx must publish host port 80"
|
||||
[ "$(value NGINX_HTTPS_PORT)" = 443 ] || fail "nginx must publish host port 443"
|
||||
[ "$(value NGINX_TLS_CERTIFICATE)" = /run/tls/fullchain.pem ] ||
|
||||
fail "nginx certificate must use staged /run/tls/fullchain.pem"
|
||||
[ "$(value NGINX_TLS_CERTIFICATE_KEY)" = /run/tls/privkey.pem ] ||
|
||||
fail "nginx key must use staged /run/tls/privkey.pem"
|
||||
safety_url=$(value MESSAGE_SAFETY_URL)
|
||||
echo "$safety_url" | grep -Eq '^https://[A-Za-z0-9.-]+:8443$' ||
|
||||
fail "MESSAGE_SAFETY_URL must be private HTTPS VM2 :8443"
|
||||
echo "$safety_url" | grep -Eq '(message-safety|localhost|127\.0\.0\.1)' &&
|
||||
fail "MESSAGE_SAFETY_URL must not point to a local/stub service"
|
||||
safety_extra_host=$(value MESSAGE_SAFETY_EXTRA_HOST)
|
||||
if ! python3 - "$safety_url" "$safety_extra_host" <<'PY'
|
||||
import ipaddress
|
||||
import socket
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = urlparse(sys.argv[1]).hostname
|
||||
try:
|
||||
mapped_host, mapped_ip = sys.argv[2].rsplit("=", 1)
|
||||
mapped_address = ipaddress.ip_address(mapped_ip)
|
||||
except (IndexError, ValueError):
|
||||
raise SystemExit(1)
|
||||
allowed = (
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
)
|
||||
if (
|
||||
not host
|
||||
or mapped_host != host
|
||||
or not any(mapped_address in network for network in allowed)
|
||||
):
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
addresses = {
|
||||
ipaddress.ip_address(item[4][0])
|
||||
for item in socket.getaddrinfo(host, 8443, type=socket.SOCK_STREAM)
|
||||
}
|
||||
except OSError:
|
||||
raise SystemExit(1)
|
||||
if (
|
||||
not addresses
|
||||
or mapped_address not in addresses
|
||||
or any(not any(address in network for network in allowed) for address in addresses)
|
||||
):
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
fail "MESSAGE_SAFETY_URL hostname must resolve only to private VPC addresses and MESSAGE_SAFETY_EXTRA_HOST must map the same address"
|
||||
fi
|
||||
safety_ca=$(value MESSAGE_SAFETY_CA_HOST_PATH)
|
||||
[ -n "$safety_ca" ] || fail "MESSAGE_SAFETY_CA_HOST_PATH is required"
|
||||
[ -f "$safety_ca" ] || fail "VM2 internal CA file is missing"
|
||||
pg_ca=$(value PG_CA_HOST_PATH)
|
||||
[ -f "$pg_ca" ] || fail "managed PostgreSQL CA file is missing"
|
||||
for image_key in \
|
||||
API_BACKEND_IMAGE \
|
||||
BITRIX_LOCAL_APP_IMAGE \
|
||||
FRONTEND_STATIC_IMAGE \
|
||||
KEYCLOAK_IMAGE \
|
||||
NGINX_IMAGE \
|
||||
OTEL_COLLECTOR_IMAGE \
|
||||
OTEL_QUEUE_INIT_IMAGE \
|
||||
REDIS_IMAGE \
|
||||
SMS_SERVICE_IMAGE \
|
||||
TOOLBOX_IMAGE
|
||||
do
|
||||
image=$(value "$image_key")
|
||||
echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' ||
|
||||
fail "$image_key must be pinned by sha256 digest"
|
||||
case "$image" in
|
||||
registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000)
|
||||
fail "$image_key still contains the example image/digest"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
resolved_config=$(mktemp)
|
||||
resolved_services=$(mktemp)
|
||||
resolved_images=$(mktemp)
|
||||
trap 'rm -f "$resolved_config" "$resolved_services" "$resolved_images"' EXIT HUP INT TERM
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
fail "docker is required to resolve production Compose"
|
||||
elif [ -f "$ENV_FILE" ]; then
|
||||
if ! docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \
|
||||
config >"$resolved_config"; then
|
||||
fail "production Compose does not resolve"
|
||||
else
|
||||
docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \
|
||||
config --services >"$resolved_services" ||
|
||||
fail "cannot enumerate resolved production services"
|
||||
docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \
|
||||
config --images >"$resolved_images" ||
|
||||
fail "cannot enumerate resolved production images"
|
||||
|
||||
while IFS= read -r image; do
|
||||
[ -n "$image" ] || continue
|
||||
echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' ||
|
||||
fail "resolved production image is not digest-pinned: $image"
|
||||
case "$image" in
|
||||
registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000)
|
||||
fail "resolved production image still contains an example value: $image"
|
||||
;;
|
||||
esac
|
||||
done <"$resolved_images"
|
||||
[ -s "$resolved_images" ] || fail "resolved production image set is empty"
|
||||
|
||||
for forbidden_service in certbot message-safety bitrix-sync otel-collector-local; do
|
||||
! grep -Fxq "$forbidden_service" "$resolved_services" ||
|
||||
fail "local/legacy service resolved in production: $forbidden_service"
|
||||
done
|
||||
|
||||
published_services=$(
|
||||
awk '
|
||||
/^services:$/ { in_services=1; next }
|
||||
in_services && /^[^ ]/ { in_services=0 }
|
||||
in_services && /^ [A-Za-z0-9_.-]+:$/ {
|
||||
service=$1
|
||||
sub(/:$/, "", service)
|
||||
next
|
||||
}
|
||||
in_services && /^ ports:$/ { print service }
|
||||
' "$resolved_config"
|
||||
)
|
||||
[ "$published_services" = nginx ] ||
|
||||
fail "only nginx may publish production host ports (found: ${published_services:-none})"
|
||||
fi
|
||||
fi
|
||||
|
||||
tls_dir=/var/lib/han-chat/public-tls
|
||||
getent group han-nginx-tls | awk -F: '$3 == 11001 {found=1} END {exit !found}' ||
|
||||
fail "han-nginx-tls group with GID 11001 is missing"
|
||||
[ "$(/usr/bin/stat -c '%U:%G:%a' "$tls_dir" 2>/dev/null || true)" = root:han-nginx-tls:750 ] ||
|
||||
fail "public TLS directory must be root:han-nginx-tls 0750"
|
||||
for tls_file in fullchain.pem privkey.pem; do
|
||||
path="$tls_dir/$tls_file"
|
||||
[ -s "$path" ] || {
|
||||
fail "public TLS file is missing: $path"
|
||||
continue
|
||||
}
|
||||
[ "$(/usr/bin/stat -c '%U:%G:%a' "$path")" = root:han-nginx-tls:640 ] ||
|
||||
fail "$path must be root:han-nginx-tls 0640"
|
||||
done
|
||||
if [ -s "$tls_dir/fullchain.pem" ] && [ -s "$tls_dir/privkey.pem" ]; then
|
||||
cert_public=$(
|
||||
openssl x509 -in "$tls_dir/fullchain.pem" -pubkey -noout 2>/dev/null |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | awk '{print $1}'
|
||||
) || cert_public=
|
||||
key_public=$(
|
||||
openssl pkey -in "$tls_dir/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null |
|
||||
sha256sum | awk '{print $1}'
|
||||
) || key_public=
|
||||
[ -n "$cert_public" ] && [ "$cert_public" = "$key_public" ] ||
|
||||
fail "public TLS certificate/private key are invalid or do not match"
|
||||
fi
|
||||
|
||||
required_secrets='
|
||||
DATABASE_URL
|
||||
REDIS_URL
|
||||
REDIS_REALTIME_URL
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN
|
||||
BITRIX_DATABASE_URL
|
||||
SMS_DATABASE_URL
|
||||
KEYCLOAK_DB_PASSWORD
|
||||
KEYCLOAK_ADMIN_PASSWORD
|
||||
SELECTEL_S3_ACCESS_KEY
|
||||
SELECTEL_S3_SECRET_KEY
|
||||
OTEL_REMOTE_AUTH_HEADER'
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
old_ifs=$IFS
|
||||
IFS='
|
||||
'
|
||||
for name in $required_secrets; do
|
||||
[ -n "$name" ] || continue
|
||||
path=$(awk -F= -v key="$name" '$1 == key {print substr($0, index($0, "=") + 1)}' "$MANIFEST")
|
||||
[ -n "$path" ] || {
|
||||
fail "manifest is missing $name"
|
||||
continue
|
||||
}
|
||||
[ -f "$path" ] || fail "secret file is missing for $name"
|
||||
done
|
||||
IFS=$old_ifs
|
||||
fi
|
||||
|
||||
if [ -x "$ROOT/scripts/validate-env" ] && [ -f "$ENV_FILE" ] && [ -f "$MANIFEST" ]; then
|
||||
"$ROOT/scripts/validate-env" "$ENV_FILE" --runtime-manifest "$MANIFEST" ||
|
||||
fail "config/runtime validator rejected the production inputs"
|
||||
else
|
||||
fail "config/runtime validator or its inputs are unavailable"
|
||||
fi
|
||||
|
||||
if [ "$failures" -ne 0 ]; then
|
||||
echo "preflight: $failures failure(s); VM1 deployment remains closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "preflight: static VM1 production gates passed"
|
||||
@@ -25,10 +25,8 @@ fi
|
||||
docker compose --env-file "$CONFIG_FILE" config --quiet
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms
|
||||
echo "Migrations completed; record revisions in release evidence."
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/bin/sh
|
||||
# Print frontend build-time env derived from deployment .env.
|
||||
# Usage:
|
||||
# ./deployment/scripts/print-frontend-env.sh
|
||||
# CONFIG_FILE=/opt/han-chat/backend/.env ./deployment/scripts/print-frontend-env.sh --check-oidc
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
CHECK_OIDC=0
|
||||
FRONTEND_CLIENT_ID=${FRONTEND_CLIENT_ID:-han-chat-frontend}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check-oidc) CHECK_OIDC=1 ;;
|
||||
-h|--help)
|
||||
cat <<'EOF'
|
||||
Usage: print-frontend-env.sh [--check-oidc]
|
||||
|
||||
Reads deployment .env and prints EXPO_PUBLIC_* build args for frontend-test-site.
|
||||
|
||||
Environment:
|
||||
CONFIG_FILE path to .env (default: .env)
|
||||
FRONTEND_CLIENT_ID OIDC client id (default: han-chat-frontend)
|
||||
|
||||
Options:
|
||||
--check-oidc curl OpenID discovery using derived auth base URL
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -f "$CONFIG_FILE" ] || {
|
||||
echo "Config file not found: $CONFIG_FILE" >&2
|
||||
exit 66
|
||||
}
|
||||
|
||||
env_value() {
|
||||
python3 - "$CONFIG_FILE" "$1" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path, wanted = sys.argv[1:]
|
||||
found = False
|
||||
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip() == wanted:
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
print(value)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
raise SystemExit(f"missing environment variable: {wanted}")
|
||||
PY
|
||||
}
|
||||
|
||||
env_value_or_default() {
|
||||
python3 - "$CONFIG_FILE" "$1" "$2" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path, wanted, default = sys.argv[1:]
|
||||
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip() == wanted:
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
print(value or default)
|
||||
break
|
||||
else:
|
||||
print(default)
|
||||
PY
|
||||
}
|
||||
|
||||
strip_trailing_slash() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
print(sys.argv[1].rstrip("/"))
|
||||
PY
|
||||
}
|
||||
|
||||
PUBLIC_HOST=$(env_value PUBLIC_HOST)
|
||||
PUBLIC_WEB_URL=$(strip_trailing_slash "$(env_value PUBLIC_WEB_URL)")
|
||||
PUBLIC_AUTH_URL=$(strip_trailing_slash "$(env_value_or_default PUBLIC_AUTH_URL "")")
|
||||
KEYCLOAK_PUBLIC_URL=$(strip_trailing_slash "$(env_value KEYCLOAK_PUBLIC_URL)")
|
||||
KEYCLOAK_REALM=$(env_value KEYCLOAK_REALM)
|
||||
APP_ENV=$(env_value_or_default APP_ENV production-like)
|
||||
FRONTEND_STATIC_IMAGE=$(env_value_or_default FRONTEND_STATIC_IMAGE "")
|
||||
|
||||
if [ -z "$PUBLIC_AUTH_URL" ]; then
|
||||
PUBLIC_AUTH_URL="$KEYCLOAK_PUBLIC_URL"
|
||||
fi
|
||||
|
||||
EXPO_PUBLIC_API_BASE_URL="$PUBLIC_WEB_URL"
|
||||
EXPO_PUBLIC_AUTH_BASE_URL="$PUBLIC_AUTH_URL"
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM="$KEYCLOAK_REALM"
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID="$FRONTEND_CLIENT_ID"
|
||||
EXPO_PUBLIC_APP_ENV="$APP_ENV"
|
||||
OIDC_ISSUER="${EXPO_PUBLIC_AUTH_BASE_URL}/realms/${EXPO_PUBLIC_KEYCLOAK_REALM}"
|
||||
OIDC_DISCOVERY="${OIDC_ISSUER}/.well-known/openid-configuration"
|
||||
|
||||
errors=0
|
||||
warn() {
|
||||
printf 'WARN: %s\n' "$1" >&2
|
||||
errors=$((errors + 1))
|
||||
}
|
||||
|
||||
for name in \
|
||||
EXPO_PUBLIC_API_BASE_URL \
|
||||
EXPO_PUBLIC_AUTH_BASE_URL \
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM \
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID; do
|
||||
eval "value=\$$name"
|
||||
case "$value" in
|
||||
""|http://localhost:*|https://chat.example.ru*|https://tohin.ru*)
|
||||
warn "$name looks unset or still uses example/default value: '$value'"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s\n' "=== deployment .env (source: ${CONFIG_FILE}) ==="
|
||||
printf '%s=%s\n' PUBLIC_HOST "$PUBLIC_HOST"
|
||||
printf '%s=%s\n' PUBLIC_WEB_URL "$PUBLIC_WEB_URL"
|
||||
printf '%s=%s\n' PUBLIC_AUTH_URL "$PUBLIC_AUTH_URL"
|
||||
printf '%s=%s\n' KEYCLOAK_PUBLIC_URL "$KEYCLOAK_PUBLIC_URL"
|
||||
printf '%s=%s\n' KEYCLOAK_REALM "$KEYCLOAK_REALM"
|
||||
printf '%s=%s\n' APP_ENV "$APP_ENV"
|
||||
printf '%s=%s\n' FRONTEND_STATIC_IMAGE "${FRONTEND_STATIC_IMAGE:-<not set>}"
|
||||
|
||||
printf '\n%s\n' "=== frontend build-time env (EXPO_PUBLIC_*) ==="
|
||||
printf '%s=%s\n' EXPO_PUBLIC_API_BASE_URL "$EXPO_PUBLIC_API_BASE_URL"
|
||||
printf '%s=%s\n' EXPO_PUBLIC_AUTH_BASE_URL "$EXPO_PUBLIC_AUTH_BASE_URL"
|
||||
printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_REALM "$EXPO_PUBLIC_KEYCLOAK_REALM"
|
||||
printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_CLIENT_ID "$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID"
|
||||
printf '%s=%s\n' EXPO_PUBLIC_APP_ENV "$EXPO_PUBLIC_APP_ENV"
|
||||
|
||||
printf '\n%s\n' "=== derived OIDC ==="
|
||||
printf '%s=%s\n' OIDC_ISSUER "$OIDC_ISSUER"
|
||||
printf '%s=%s\n' OIDC_DISCOVERY "$OIDC_DISCOVERY"
|
||||
printf '%s=%s\n' AUTH_CALLBACK "${PUBLIC_WEB_URL}/auth/callback"
|
||||
|
||||
printf '\n%s\n' "=== docker build (frontend-test-site) ==="
|
||||
cat <<EOF
|
||||
docker build --target static \\
|
||||
-t han-chat-frontend-static:local \\
|
||||
--build-arg EXPO_PUBLIC_API_BASE_URL=${EXPO_PUBLIC_API_BASE_URL} \\
|
||||
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=${EXPO_PUBLIC_AUTH_BASE_URL} \\
|
||||
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=${EXPO_PUBLIC_KEYCLOAK_REALM} \\
|
||||
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=${EXPO_PUBLIC_KEYCLOAK_CLIENT_ID} \\
|
||||
--build-arg EXPO_PUBLIC_APP_ENV=${EXPO_PUBLIC_APP_ENV} \\
|
||||
frontend-test-site
|
||||
EOF
|
||||
|
||||
printf '\n%s\n' "=== redeploy static into compose volume ==="
|
||||
cat <<'EOF'
|
||||
docker compose --env-file .env up -d --force-recreate frontend-static
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
EOF
|
||||
|
||||
if [ "$CHECK_OIDC" -eq 1 ]; then
|
||||
printf '\n%s\n' "=== OIDC discovery check ==="
|
||||
curl -fsS "$OIDC_DISCOVERY" | python3 - <<'PY'
|
||||
import json, sys
|
||||
doc = json.load(sys.stdin)
|
||||
print("issuer=", doc.get("issuer"))
|
||||
print("authorization_endpoint=", doc.get("authorization_endpoint"))
|
||||
PY
|
||||
fi
|
||||
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
printf '\nFound %s warning(s). Fix .env or rebuild frontend with the command above.\n' "$errors" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '\nOK: frontend build env looks consistent with %s\n' "$CONFIG_FILE"
|
||||
@@ -1,51 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Первичная подготовка Ubuntu 24.04 для HAN Chat.
|
||||
# Fresh Ubuntu 24.04 bootstrap for production VM1. This script never starts
|
||||
# Compose/application containers and never creates DNS, PG, S3, TLS or secrets.
|
||||
# Run twice: first with HARDEN_SSH=false, then only after independent deploy and
|
||||
# admin login/sudo checks with HARDEN_SSH=true.
|
||||
#
|
||||
# Скрипт настраивает только VM: пользователя развертывания, базовые пакеты,
|
||||
# Docker/Compose, UFW, fail2ban, DOCKER-USER, swap и каталоги проекта.
|
||||
# PostgreSQL и S3 остаются внешними управляемыми сервисами. Скрипт не создает
|
||||
# .env, секреты, DNS, S3-бакеты, схемы БД и TLS-сертификаты.
|
||||
# Required:
|
||||
# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub
|
||||
# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub
|
||||
#
|
||||
# Запуск на свежей VM:
|
||||
# chmod +x deployment/scripts/setup-vm.sh
|
||||
# sudo deployment/scripts/setup-vm.sh
|
||||
#
|
||||
# Основные параметры:
|
||||
# DEPLOY_USER=deploy
|
||||
# DEPLOY_DIR=/opt/han-chat/backend
|
||||
# SSH_PORT=22
|
||||
# TIMEZONE=Europe/Moscow
|
||||
# SWAP_SIZE_GB=4
|
||||
# EXTERNAL_IF=ens3
|
||||
# PUBLIC_DOCKER_PORTS=80,443
|
||||
# COPY_SSH_KEYS=true
|
||||
# HARDEN_SSH=false
|
||||
# LOCK_ACCOUNT_PASSWORDS=true
|
||||
# HSTS_MAX_AGE_SECONDS=31536000
|
||||
# RESET_UFW=false
|
||||
# SKIP_APT_UPGRADE=false
|
||||
#
|
||||
# Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются
|
||||
# по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно
|
||||
# запрещает прямой root-вход и SSH TCP forwarding.
|
||||
# Optional: SSH_PORT=22 EXTERNAL_IF=ens3 SWAP_SIZE_GB=4 HARDEN_SSH=false
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
DEPLOY_USER="${DEPLOY_USER:-deploy}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/backend}"
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
DEPLOY_AUTHORIZED_KEY_FILE="${DEPLOY_AUTHORIZED_KEY_FILE:-}"
|
||||
ADMIN_AUTHORIZED_KEY_FILE="${ADMIN_AUTHORIZED_KEY_FILE:-}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/current/backend}"
|
||||
INCOMING_DIR="${INCOMING_DIR:-/var/lib/han-deploy/incoming}"
|
||||
SSH_PORT="${SSH_PORT:-22}"
|
||||
TIMEZONE="${TIMEZONE:-Europe/Moscow}"
|
||||
SWAP_SIZE_GB="${SWAP_SIZE_GB:-4}"
|
||||
EXTERNAL_IF="${EXTERNAL_IF:-}"
|
||||
PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}"
|
||||
COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}"
|
||||
HARDEN_SSH="${HARDEN_SSH:-false}"
|
||||
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
|
||||
HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}"
|
||||
RESET_UFW="${RESET_UFW:-false}"
|
||||
RESET_UFW="${RESET_UFW:-true}"
|
||||
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm1-setup.log}"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
|
||||
@@ -73,17 +55,26 @@ require_root() {
|
||||
}
|
||||
|
||||
validate_parameters() {
|
||||
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER"
|
||||
[[ "$DEPLOY_DIR" == /* ]] || die "DEPLOY_DIR должен быть абсолютным путем"
|
||||
[[ "$DEPLOY_USER" == deploy ]] || die "Production unit/sudoers требуют DEPLOY_USER=deploy"
|
||||
[[ "$ADMIN_USER" == admin ]] || die "Break-glass роль должна называться admin"
|
||||
[[ "$DEPLOY_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|
||||
|| die "Задайте безопасный абсолютный DEPLOY_AUTHORIZED_KEY_FILE"
|
||||
[[ "$ADMIN_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|
||||
|| die "Задайте безопасный абсолютный ADMIN_AUTHORIZED_KEY_FILE"
|
||||
[[ "$DEPLOY_AUTHORIZED_KEY_FILE" != "$ADMIN_AUTHORIZED_KEY_FILE" ]] \
|
||||
|| die "deploy и admin должны использовать разные key files"
|
||||
[[ "$DEPLOY_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "Некорректный DEPLOY_DIR"
|
||||
[[ "$INCOMING_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "Некорректный INCOMING_DIR"
|
||||
[[ "$DEPLOY_DIR" != "$INCOMING_DIR" ]] || die "Production и incoming должны различаться"
|
||||
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом"
|
||||
((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона"
|
||||
[[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом"
|
||||
[[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \
|
||||
|| die "HSTS_MAX_AGE_SECONDS должен быть целым числом"
|
||||
((HSTS_MAX_AGE_SECONDS >= 31536000)) \
|
||||
|| die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000"
|
||||
[[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \
|
||||
|| die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443"
|
||||
[[ "$HARDEN_SSH" == true || "$HARDEN_SSH" == false ]] || die "Некорректный HARDEN_SSH"
|
||||
[[ "$LOCK_ACCOUNT_PASSWORDS" == true || "$LOCK_ACCOUNT_PASSWORDS" == false ]] \
|
||||
|| die "Некорректный LOCK_ACCOUNT_PASSWORDS"
|
||||
[[ "$RESET_UFW" == true || "$RESET_UFW" == false ]] || die "Некорректный RESET_UFW"
|
||||
[[ "$SKIP_APT_UPGRADE" == true || "$SKIP_APT_UPGRADE" == false ]] \
|
||||
|| die "Некорректный SKIP_APT_UPGRADE"
|
||||
}
|
||||
|
||||
check_os() {
|
||||
@@ -107,7 +98,7 @@ update_system() {
|
||||
apt-get install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
dos2unix \
|
||||
certbot \
|
||||
fail2ban \
|
||||
git \
|
||||
gnupg \
|
||||
@@ -117,11 +108,10 @@ update_system() {
|
||||
netcat-openbsd \
|
||||
openssl \
|
||||
python3 \
|
||||
python3-venv \
|
||||
rsync \
|
||||
sudo \
|
||||
unattended-upgrades \
|
||||
ufw
|
||||
apt-get autoremove -y
|
||||
}
|
||||
|
||||
configure_time() {
|
||||
@@ -130,65 +120,79 @@ configure_time() {
|
||||
timedatectl set-ntp true
|
||||
}
|
||||
|
||||
create_deploy_user() {
|
||||
step "Пользователь развертывания"
|
||||
if ! id "$DEPLOY_USER" >/dev/null 2>&1; then
|
||||
useradd --create-home --shell /bin/bash "$DEPLOY_USER"
|
||||
log "Создан пользователь ${DEPLOY_USER}"
|
||||
else
|
||||
log "Пользователь ${DEPLOY_USER} уже существует"
|
||||
fi
|
||||
install_authorized_key() {
|
||||
local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys"
|
||||
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}"
|
||||
[[ "$(wc -l <"$source")" -eq 1 ]] || die "${source} должен содержать один ключ"
|
||||
ssh-keygen -l -f "$source" >/dev/null || die "Некорректный SSH key ${source}"
|
||||
grep -Eq '^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$' "$source" \
|
||||
|| die "Разрешены только Ed25519 ключи"
|
||||
install -d -m 0700 -o "$user" -g "$user" "/home/${user}/.ssh"
|
||||
install -m 0600 -o "$user" -g "$user" "$source" "$target"
|
||||
}
|
||||
|
||||
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
|
||||
"/home/${DEPLOY_USER}/.ssh"
|
||||
|
||||
local source_user="${SUDO_USER:-}"
|
||||
local source_keys=""
|
||||
local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys"
|
||||
if [[ -n "$source_user" && "$source_user" != "root" ]]; then
|
||||
source_keys="/home/${source_user}/.ssh/authorized_keys"
|
||||
elif [[ -s /root/.ssh/authorized_keys ]]; then
|
||||
source_user="root"
|
||||
source_keys="/root/.ssh/authorized_keys"
|
||||
create_host_roles() {
|
||||
step "Роли deploy и break-glass admin"
|
||||
id "$DEPLOY_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$DEPLOY_USER"
|
||||
id "$ADMIN_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$ADMIN_USER"
|
||||
install_authorized_key "$DEPLOY_USER" "$DEPLOY_AUTHORIZED_KEY_FILE"
|
||||
install_authorized_key "$ADMIN_USER" "$ADMIN_AUTHORIZED_KEY_FILE"
|
||||
local deploy_key admin_key group
|
||||
deploy_key="$(awk '{print $2}' "$DEPLOY_AUTHORIZED_KEY_FILE")"
|
||||
admin_key="$(awk '{print $2}' "$ADMIN_AUTHORIZED_KEY_FILE")"
|
||||
[[ "$deploy_key" != "$admin_key" ]] || die "deploy/admin используют один ключ"
|
||||
if [[ -s /root/.ssh/authorized_keys ]] &&
|
||||
{ grep -Fq "$deploy_key" /root/.ssh/authorized_keys ||
|
||||
grep -Fq "$admin_key" /root/.ssh/authorized_keys; }; then
|
||||
die "deploy/admin key совпадает с root key"
|
||||
fi
|
||||
|
||||
if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then
|
||||
install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$source_keys" "$target_keys"
|
||||
log "SSH-ключи скопированы от ${source_user}"
|
||||
for group in docker sudo lxd adm systemd-journal; do
|
||||
if getent group "$group" >/dev/null &&
|
||||
id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$group"; then
|
||||
gpasswd -d "$DEPLOY_USER" "$group"
|
||||
fi
|
||||
|
||||
if [[ ! -s "$target_keys" ]]; then
|
||||
log "ПРЕДУПРЕЖДЕНИЕ: у ${DEPLOY_USER} отсутствует authorized_keys"
|
||||
done
|
||||
usermod -aG sudo "$ADMIN_USER"
|
||||
for group in docker lxd; do
|
||||
if getent group "$group" >/dev/null &&
|
||||
id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx "$group"; then
|
||||
gpasswd -d "$ADMIN_USER" "$group"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
configure_account_passwords() {
|
||||
step "Блокировка локальных паролей привилегированных учетных записей"
|
||||
if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
|
||||
log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены"
|
||||
return
|
||||
fi
|
||||
|
||||
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|
||||
|| die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст"
|
||||
|
||||
step "Пароли host-ролей"
|
||||
if [[ "$LOCK_ACCOUNT_PASSWORDS" == true ]]; then
|
||||
passwd --lock root
|
||||
passwd --lock "$DEPLOY_USER"
|
||||
log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен"
|
||||
fi
|
||||
local status
|
||||
status="$(passwd --status "$ADMIN_USER" | awk '{print $2}')"
|
||||
if [[ "$HARDEN_SSH" == true && "$status" != P ]]; then
|
||||
die "До HARDEN_SSH=true задайте отдельный sudo-пароль: passwd ${ADMIN_USER}"
|
||||
fi
|
||||
[[ "$status" == P ]] || log "ПРЕДУПРЕЖДЕНИЕ: задайте sudo-пароль admin"
|
||||
}
|
||||
|
||||
configure_layout() {
|
||||
step "Каталоги HAN Chat"
|
||||
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR"
|
||||
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
|
||||
"${DEPLOY_DIR}/secrets" \
|
||||
"${DEPLOY_DIR}/secrets/pg" \
|
||||
"${DEPLOY_DIR}/backups"
|
||||
|
||||
local env_file="${DEPLOY_DIR}/.env"
|
||||
if [[ -f "$env_file" ]]; then
|
||||
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
step "Root-owned releases и incoming staging"
|
||||
install -d -m 0755 -o root -g root /opt/han-chat /opt/han-chat/releases
|
||||
install -d -m 0755 -o root -g root /var/lib/han-deploy
|
||||
install -d -m 0750 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$INCOMING_DIR"
|
||||
install -d -m 0700 -o root -g root /etc/han /etc/han/secrets /etc/han/credentials
|
||||
install -d -m 0755 -o root -g root /etc/han/ca /var/lib/han-chat/acme
|
||||
if [[ -f /etc/han/vm1.env ]]; then
|
||||
[[ "$(stat -c '%U:%G:%a' /etc/han/vm1.env)" == root:root:600 ]] \
|
||||
|| die "/etc/han/vm1.env должен быть root:root 0600"
|
||||
fi
|
||||
if [[ -d "$DEPLOY_DIR" ]]; then
|
||||
if find "$DEPLOY_DIR" ! -user root -print -quit | grep -q .; then
|
||||
die "Активный release содержит файлы не root-owned"
|
||||
fi
|
||||
if find "$DEPLOY_DIR" -perm /022 -print -quit | grep -q .; then
|
||||
die "Активный release доступен на запись group/other"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -261,7 +265,6 @@ install_docker() {
|
||||
EOF
|
||||
systemctl enable --now docker
|
||||
systemctl restart docker
|
||||
usermod -aG docker "$DEPLOY_USER"
|
||||
|
||||
docker compose version >/dev/null \
|
||||
|| die "Docker Compose plugin не установлен"
|
||||
@@ -276,8 +279,7 @@ configure_ufw() {
|
||||
fi
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow "${SSH_PORT}/tcp" comment 'HAN Chat SSH'
|
||||
ufw limit "${SSH_PORT}/tcp" comment 'HAN Chat SSH rate limit'
|
||||
ufw allow "$SSH_PORT"/tcp comment 'HAN VM1 SSH temporary public access'
|
||||
ufw allow 80/tcp comment 'HAN Chat HTTP'
|
||||
ufw allow 443/tcp comment 'HAN Chat HTTPS'
|
||||
ufw logging medium
|
||||
@@ -317,7 +319,6 @@ configure_docker_firewall() {
|
||||
step "Фильтрация опубликованных Docker-портов"
|
||||
cat >/etc/default/han-chat-docker-firewall <<EOF
|
||||
EXTERNAL_IF=${EXTERNAL_IF}
|
||||
PUBLIC_DOCKER_PORTS=${PUBLIC_DOCKER_PORTS}
|
||||
EOF
|
||||
|
||||
cat >/usr/local/sbin/han-chat-docker-firewall <<'FIREWALL'
|
||||
@@ -341,14 +342,10 @@ iptables -F HAN-CHAT-DOCKER
|
||||
iptables -A HAN-CHAT-DOCKER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
|
||||
iptables -A HAN-CHAT-DOCKER -i lo -j RETURN
|
||||
|
||||
IFS=',' read -ra ports <<<"$PUBLIC_DOCKER_PORTS"
|
||||
for port in "${ports[@]}"; do
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || {
|
||||
echo "Некорректный порт: $port" >&2
|
||||
exit 1
|
||||
}
|
||||
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp --dport "$port" -j RETURN
|
||||
done
|
||||
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \
|
||||
-m conntrack --ctorigdstport 80 -j RETURN
|
||||
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \
|
||||
-m conntrack --ctorigdstport 443 -j RETURN
|
||||
|
||||
# Блокируется только новый входящий трафик с внешнего интерфейса в Docker bridge.
|
||||
# Исходящий и межконтейнерный трафик этой цепочкой не затрагивается.
|
||||
@@ -384,7 +381,8 @@ EOF
|
||||
ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now han-chat-docker-firewall.service
|
||||
systemctl enable han-chat-docker-firewall.service
|
||||
systemctl restart han-chat-docker-firewall.service
|
||||
}
|
||||
|
||||
configure_ssh() {
|
||||
@@ -396,6 +394,9 @@ configure_ssh() {
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
PermitEmptyPasswords no
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding no
|
||||
X11Forwarding no
|
||||
MaxAuthTries 3
|
||||
ClientAliveInterval 120
|
||||
@@ -403,13 +404,13 @@ ClientAliveCountMax 2
|
||||
Port ${SSH_PORT}
|
||||
EOF
|
||||
if [[ "$HARDEN_SSH" == "true" ]]; then
|
||||
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF'
|
||||
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
|
||||
PermitRootLogin no
|
||||
AllowTcpForwarding no
|
||||
AllowUsers ${DEPLOY_USER} ${ADMIN_USER}
|
||||
EOF
|
||||
log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены"
|
||||
log "Root SSH отключён; разрешены deploy и admin"
|
||||
else
|
||||
log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены"
|
||||
log "Root SSH пока не отключён: выполните двухфазную проверку"
|
||||
fi
|
||||
|
||||
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
|
||||
@@ -417,40 +418,45 @@ EOF
|
||||
systemctl reload ssh
|
||||
}
|
||||
|
||||
configure_application_security() {
|
||||
step "Безопасные HTTP-заголовки приложения"
|
||||
local env_file="${DEPLOY_DIR}/.env"
|
||||
|
||||
if [[ -f "$env_file" ]]; then
|
||||
if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then
|
||||
sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file"
|
||||
else
|
||||
printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$env_file"
|
||||
fi
|
||||
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}"
|
||||
else
|
||||
log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию"
|
||||
fi
|
||||
install_deploy_sudoers() {
|
||||
step "Exact sudoers для deploy"
|
||||
cat >/etc/sudoers.d/deploy <<'EOF'
|
||||
Cmnd_Alias HAN_VM1_UNITS = /usr/bin/systemctl start han-secrets@production.service, /usr/bin/systemctl restart han-secrets@production.service, /usr/bin/systemctl start han-stack@production.service, /usr/bin/systemctl restart han-stack@production.service, /usr/bin/systemctl stop han-stack@production.service
|
||||
Cmnd_Alias HAN_VM1_STATUS = /usr/bin/systemctl --no-pager status han-secrets@production.service, /usr/bin/systemctl --no-pager status han-stack@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service
|
||||
deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/deploy
|
||||
visudo -cf /etc/sudoers.d/deploy >/dev/null || die "Некорректный sudoers"
|
||||
}
|
||||
|
||||
install_secret_loader_if_possible() {
|
||||
step "Загрузчик секретов"
|
||||
install_release_helpers_if_possible() {
|
||||
step "Root-owned helpers, TLS hook и units"
|
||||
local source_dir="${DEPLOY_DIR}/deployment/secrets"
|
||||
if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then
|
||||
log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске"
|
||||
local deployment="${DEPLOY_DIR}/deployment"
|
||||
local tls_group=han-nginx-tls tls_gid=11001
|
||||
if [[ ! -f "${DEPLOY_DIR}/docker-compose.yml" ||
|
||||
! -f "${source_dir}/secrets_loader.py" ||
|
||||
! -f "${deployment}/han-stack@.service" ]]; then
|
||||
log "Активный релиз отсутствует; повторите после root-активации"
|
||||
return
|
||||
fi
|
||||
chmod 0750 "${source_dir}/han-secrets" "${source_dir}/han-compose"
|
||||
|
||||
install -d -m 0700 -o root -g root \
|
||||
/etc/han \
|
||||
/etc/han/secrets \
|
||||
/etc/han/credentials
|
||||
install -d -m 0755 -o root -g root \
|
||||
/usr/local/lib/han-secrets \
|
||||
/usr/local/share/doc/han-secrets
|
||||
if find "$DEPLOY_DIR" -type l -print -quit | grep -q .; then
|
||||
die "Внутри активного release обнаружен symlink"
|
||||
fi
|
||||
[[ -x "${deployment}/preflight.sh" ]] || die "preflight.sh не executable"
|
||||
[[ -x "${deployment}/scripts/tls-deploy-hook.sh" ]] || die "TLS hook не executable"
|
||||
[[ -x "${source_dir}/han-compose" ]] || die "han-compose не executable"
|
||||
[[ -x "${source_dir}/han-secrets" ]] || die "han-secrets не executable"
|
||||
if getent group "$tls_group" >/dev/null; then
|
||||
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|
||||
|| die "han-nginx-tls имеет неожиданный GID"
|
||||
elif getent group "$tls_gid" >/dev/null; then
|
||||
die "GID ${tls_gid} уже занят"
|
||||
else
|
||||
groupadd --system --gid "$tls_gid" "$tls_group"
|
||||
fi
|
||||
install -d -m 0750 -o root -g "$tls_group" /var/lib/han-chat/public-tls
|
||||
install -d -m 0755 -o root -g root /usr/local/lib/han-secrets /usr/local/share/doc/han-secrets
|
||||
install -m 0750 -o root -g root \
|
||||
"${source_dir}/secrets_loader.py" \
|
||||
/usr/local/lib/han-secrets/secrets_loader.py
|
||||
@@ -459,56 +465,23 @@ install_secret_loader_if_possible() {
|
||||
/usr/local/lib/han-secrets/han-secrets
|
||||
install -m 0750 -o root -g root \
|
||||
"${source_dir}/han-compose" \
|
||||
/usr/local/bin/han-compose
|
||||
/usr/local/sbin/han-vm1-compose
|
||||
install -m 0644 -o root -g root \
|
||||
"${source_dir}/han-secrets@.service" \
|
||||
/etc/systemd/system/han-secrets@.service
|
||||
install -m 0644 -o root -g root \
|
||||
"${deployment}/han-stack@.service" \
|
||||
/etc/systemd/system/han-stack@.service
|
||||
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
|
||||
install -m 0755 -o root -g root \
|
||||
"${deployment}/scripts/tls-deploy-hook.sh" \
|
||||
/etc/letsencrypt/renewal-hooks/deploy/han-chat-nginx
|
||||
install -m 0644 -o root -g root \
|
||||
"${source_dir}/SELECTEL_RUNBOOK.ru.md" \
|
||||
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
|
||||
if [[ ! -e /etc/han/secrets/production.selectel.json.example ]]; then
|
||||
install -m 0600 -o root -g root \
|
||||
"${source_dir}/config.example.json" \
|
||||
/etc/han/secrets/production.selectel.json.example
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
log "Загрузчик установлен, но не включен: сначала выполните SELECTEL_RUNBOOK.ru.md"
|
||||
}
|
||||
|
||||
install_ssl_timer_if_possible() {
|
||||
step "Таймер продления TLS"
|
||||
local renew_script="${DEPLOY_DIR}/deployment/scripts/ssl-renew.sh"
|
||||
if [[ ! -x "$renew_script" ]]; then
|
||||
log "Проект еще не скопирован: таймер TLS будет установлен при повторном запуске"
|
||||
return
|
||||
fi
|
||||
|
||||
cat >/etc/systemd/system/han-chat-ssl-renew.service <<EOF
|
||||
[Unit]
|
||||
Description=Renew HAN Chat TLS certificate
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=${DEPLOY_USER}
|
||||
WorkingDirectory=${DEPLOY_DIR}
|
||||
ExecStart=${renew_script}
|
||||
EOF
|
||||
|
||||
cat >/etc/systemd/system/han-chat-ssl-renew.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Run HAN Chat TLS renewal twice daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03,15:20:00
|
||||
RandomizedDelaySec=30m
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now han-chat-ssl-renew.timer
|
||||
systemctl enable certbot.timer
|
||||
log "Helpers установлены; application units не запущены"
|
||||
}
|
||||
|
||||
verify() {
|
||||
@@ -520,7 +493,21 @@ verify() {
|
||||
iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null \
|
||||
|| { log "FAIL: цепочка HAN-CHAT-DOCKER не подключена"; failed=1; }
|
||||
docker compose version >/dev/null || { log "FAIL: Compose недоступен"; failed=1; }
|
||||
[[ -d "$DEPLOY_DIR" ]] || { log "FAIL: отсутствует ${DEPLOY_DIR}"; failed=1; }
|
||||
if id -nG "$DEPLOY_USER" | tr ' ' '\n' |
|
||||
grep -Eq '^(docker|sudo|lxd|adm|systemd-journal)$'; then
|
||||
log "FAIL: deploy состоит в привилегированной группе"; failed=1
|
||||
fi
|
||||
id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx sudo \
|
||||
|| { log "FAIL: admin не состоит в sudo"; failed=1; }
|
||||
[[ "$(stat -c '%U:%G:%a' "$INCOMING_DIR")" == deploy:deploy:750 ]] \
|
||||
|| { log "FAIL: неверные права incoming"; failed=1; }
|
||||
local external_if="${EXTERNAL_IF:-}"
|
||||
[[ -n "$external_if" ]] || external_if="$(ip -4 route show default | awk '{print $5; exit}')"
|
||||
for port in 80 443; do
|
||||
iptables -C HAN-CHAT-DOCKER -i "$external_if" -p tcp \
|
||||
-m conntrack --ctorigdstport "$port" -j RETURN 2>/dev/null \
|
||||
|| { log "FAIL: нет original host port ${port}"; failed=1; }
|
||||
done
|
||||
((failed == 0)) || die "Базовая проверка VM не пройдена"
|
||||
log "Базовая проверка VM пройдена"
|
||||
}
|
||||
@@ -529,35 +516,17 @@ summary() {
|
||||
step "Настройка VM завершена"
|
||||
cat <<EOF | tee -a "$LOG_FILE"
|
||||
|
||||
Пользователь развертывания: ${DEPLOY_USER}
|
||||
Каталог Compose: ${DEPLOY_DIR}
|
||||
Открытые порты: ${SSH_PORT}, 80, 443
|
||||
Парольный SSH/X11: отключены
|
||||
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
|
||||
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
|
||||
Лог настройки: ${LOG_FILE}
|
||||
Deploy: ${DEPLOY_USER} (без docker/sudo/lxd/adm/journal)
|
||||
Break-glass: ${ADMIN_USER} (sudo с отдельным паролем)
|
||||
Incoming: ${INCOMING_DIR}
|
||||
Активный release: ${DEPLOY_DIR}
|
||||
Public ingress: 80,443
|
||||
SSH ingress: ${SSH_PORT}/tcp из любой сети (временно)
|
||||
Root SSH hardening: ${HARDEN_SSH}
|
||||
Лог: ${LOG_FILE}
|
||||
|
||||
Следующие действия:
|
||||
1. Проверьте вход в новой SSH-сессии:
|
||||
ssh ${DEPLOY_USER}@<VM_IP>
|
||||
2. Скопируйте содержимое codebase/backend в:
|
||||
${DEPLOY_DIR}
|
||||
3. Поместите CA PostgreSQL:
|
||||
${DEPLOY_DIR}/secrets/pg/ca.pem
|
||||
4. Создайте только несекретный config:
|
||||
cd ${DEPLOY_DIR}
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
./scripts/validate-env .env
|
||||
5. Настройте Selectel, encrypted bootstrap credential и fallback map:
|
||||
deployment/secrets/SELECTEL_RUNBOOK.ru.md
|
||||
6. Выполняйте Compose только через:
|
||||
sudo deployment/secrets/han-compose <command>
|
||||
7. Продолжите с Gate 7 в:
|
||||
deployment/RUNBOOK.ru.md
|
||||
8. После копирования проекта повторно запустите этот скрипт для установки unit-файлов.
|
||||
|
||||
Важно: членство в группе docker начнет действовать после нового входа в систему.
|
||||
Продолжайте только по deployment/RUNBOOK.production.ru.md.
|
||||
Скрипт не запускал Compose и прикладные сервисы.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -570,7 +539,7 @@ main() {
|
||||
check_os
|
||||
update_system
|
||||
configure_time
|
||||
create_deploy_user
|
||||
create_host_roles
|
||||
configure_account_passwords
|
||||
configure_layout
|
||||
configure_swap
|
||||
@@ -581,9 +550,8 @@ main() {
|
||||
configure_unattended_upgrades
|
||||
configure_docker_firewall
|
||||
configure_ssh
|
||||
configure_application_security
|
||||
install_secret_loader_if_possible
|
||||
install_ssl_timer_if_possible
|
||||
install_deploy_sudoers
|
||||
install_release_helpers_if_possible
|
||||
verify
|
||||
summary
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
COMPOSE=/usr/local/sbin/han-vm1-compose
|
||||
TLS_DIR=/var/lib/han-chat/public-tls
|
||||
TLS_GROUP=han-nginx-tls
|
||||
|
||||
lineage=${RENEWED_LINEAGE:?Certbot did not provide RENEWED_LINEAGE}
|
||||
test -s "$lineage/fullchain.pem"
|
||||
test -s "$lineage/privkey.pem"
|
||||
test -d "$TLS_DIR"
|
||||
getent group "$TLS_GROUP" >/dev/null
|
||||
|
||||
staging=$(mktemp -d "${TLS_DIR}/.renew.XXXXXX")
|
||||
trap 'rm -rf -- "$staging"' EXIT HUP INT TERM
|
||||
install -m 0640 -o root -g "$TLS_GROUP" \
|
||||
"$lineage/fullchain.pem" "$staging/fullchain.pem"
|
||||
install -m 0640 -o root -g "$TLS_GROUP" \
|
||||
"$lineage/privkey.pem" "$staging/privkey.pem"
|
||||
|
||||
cert_public=$(
|
||||
openssl x509 -in "$staging/fullchain.pem" -pubkey -noout |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | awk '{print $1}'
|
||||
)
|
||||
key_public=$(
|
||||
openssl pkey -in "$staging/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null |
|
||||
sha256sum | awk '{print $1}'
|
||||
)
|
||||
[ "$cert_public" = "$key_public" ] || {
|
||||
echo "renewed certificate and private key do not match" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mv -f "$staging/fullchain.pem" "$TLS_DIR/fullchain.pem"
|
||||
mv -f "$staging/privkey.pem" "$TLS_DIR/privkey.pem"
|
||||
rmdir "$staging"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
container=$("$COMPOSE" ps --status running --quiet nginx)
|
||||
[ -n "$container" ] || {
|
||||
echo "HAN VM1 nginx is not running" >&2
|
||||
exit 1
|
||||
}
|
||||
if ! output=$("$COMPOSE" exec -T nginx nginx -t -c /tmp/nginx.conf 2>&1); then
|
||||
printf '%s\n' "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
/usr/bin/docker kill --signal HUP "$container" >/dev/null
|
||||
@@ -44,19 +44,19 @@ TLS и redirect policy отключать нельзя.
|
||||
## 2. Каталог секретов
|
||||
|
||||
Скопируйте `config.example.json` в
|
||||
`/etc/han/secrets/production-like.selectel.json` и замените account, username,
|
||||
`/etc/han/secrets/production.selectel.json` и замените account, username,
|
||||
project, region и `remote`. Каноническое имя слева обязано совпадать с
|
||||
Compose/validator; `remote` — неизменяемый ключ в Selectel.
|
||||
|
||||
Используйте консервативные provider keys с дефисами, например:
|
||||
|
||||
- `han-chat-prod-pg-han-app-dsn`, `han-chat-prod-pg-bitrix-local-dsn`,
|
||||
`han-chat-prod-pg-bitrix-sync-dsn`, `han-chat-prod-pg-sms-dsn`,
|
||||
`han-chat-prod-pg-sms-dsn`,
|
||||
`han-chat-prod-pg-keycloak-password`, `han-chat-prod-pg-backup-dsn`;
|
||||
- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-safety-password`,
|
||||
`han-chat-prod-redis-health-password`, а также три credential-bearing URL;
|
||||
- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-health-password`,
|
||||
а также credential-bearing URL только для Redis DB0/DB1;
|
||||
- `han-chat-prod-message-safety-token`, `han-chat-prod-bitrix-internal-token`,
|
||||
`han-chat-prod-bitrix-forward-token`, `han-chat-prod-bitrix-sync-token`,
|
||||
`han-chat-prod-bitrix-forward-token`,
|
||||
`han-chat-prod-keycloak-settings-token`, `han-chat-prod-sms-service-token`;
|
||||
- Keycloak bootstrap password, OTP HMAC и mock code только для среды, где mock
|
||||
действительно включён;
|
||||
@@ -79,25 +79,25 @@ S3 и Bitrix sync credentials до появления потребляющего
|
||||
|
||||
## 3. Установка на ВМ
|
||||
|
||||
Повторный запуск `deployment/scripts/setup-vm.sh` после копирования проекта
|
||||
устанавливает loader, launcher, systemd template и этот runbook. Вручную:
|
||||
Повторный запуск `deployment/scripts/setup-vm.sh` после root-активации release
|
||||
устанавливает loader, launcher, systemd units и этот runbook. Production
|
||||
установка вручную не поддерживается: точные owner/mode и пути задаёт setup.
|
||||
Ожидаемые артефакты:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0700 /etc/han/secrets /etc/han/credentials
|
||||
sudo install -d -m 0755 /usr/local/lib/han-secrets
|
||||
sudo install -m 0750 secrets_loader.py han-secrets /usr/local/lib/han-secrets/
|
||||
sudo install -m 0750 han-compose /usr/local/bin/han-compose
|
||||
sudo install -m 0644 han-secrets@.service /etc/systemd/system/
|
||||
sudo install -m 0600 config.example.json \
|
||||
/etc/han/secrets/production-like.selectel.json
|
||||
test -x /usr/local/lib/han-secrets/han-secrets
|
||||
test -x /usr/local/sbin/han-vm1-compose
|
||||
test -f /etc/systemd/system/han-secrets@.service
|
||||
test -f /etc/systemd/system/han-stack@.service
|
||||
```
|
||||
|
||||
Обычный `/opt/han-chat/backend/.env` содержит только несекретные параметры.
|
||||
`/etc/han/vm1.env` принадлежит root (`0600`) и содержит только несекретные
|
||||
параметры. Config находится вне immutable release.
|
||||
Штатный режим:
|
||||
|
||||
```dotenv
|
||||
SECRETS_SOURCE=selectel
|
||||
APP_ENV=production-like
|
||||
APP_ENV=production
|
||||
```
|
||||
|
||||
## 4. Bootstrap credential
|
||||
@@ -116,23 +116,21 @@ sudo chmod 0600 /etc/han/credentials/production.selectel-password.cred
|
||||
|
||||
## 5. Проверка и запуск
|
||||
|
||||
Все команды, которым нужны Compose secrets, запускайте от root через wrapper:
|
||||
Secret sync и preflight запускает root. `deploy` не вызывает Docker/launcher:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
sudo ./scripts/validate-env .env
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable han-secrets@production.service
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
cd /opt/han-chat/current/backend
|
||||
./scripts/validate-env /etc/han/vm1.env
|
||||
systemctl restart han-secrets@production.service
|
||||
./scripts/validate-env /etc/han/vm1.env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
sudo deployment/secrets/han-compose up -d --wait
|
||||
deployment/preflight.sh
|
||||
/usr/local/sbin/han-vm1-compose config --quiet
|
||||
```
|
||||
|
||||
Selectel sync запускается именно unit-файлом: только он предоставляет
|
||||
расшифрованный bootstrap credential через `$CREDENTIALS_DIRECTORY`.
|
||||
`han-compose` и ops-скрипты используют уже синхронизированный manifest и
|
||||
`han-vm1-compose` и ops-скрипты используют уже синхронизированный manifest и
|
||||
отказываются работать, если `SECRETS_SOURCE`/loader config не совпадают с
|
||||
runtime state. После смены source, provider version или JSON-карты сначала
|
||||
выполняйте `systemctl restart han-secrets@production.service`.
|
||||
@@ -142,27 +140,17 @@ runtime state. После смены source, provider version или JSON-кар
|
||||
приёмки должна подтвердить отсутствие canary value в `docker inspect`, stdout,
|
||||
json logs, traces и shell history.
|
||||
|
||||
На выделенной только под HAN Chat ВМ после canary и проверки file fallback
|
||||
установите fail-closed ordering:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/systemd/system/docker.service.d
|
||||
sudo install -m 0644 \
|
||||
deployment/secrets/docker-han-secrets.conf.example \
|
||||
/etc/systemd/system/docker.service.d/han-secrets.conf
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
После этого проведите reboot rehearsal: materializer должен завершиться до
|
||||
autorestart контейнеров. Ошибка Selectel намеренно блокирует Docker. На ВМ с
|
||||
другими workloads такой глобальный `Requires=` запрещён: нужен отдельный Docker
|
||||
Fail-closed ordering задают `han-stack@production.service` и
|
||||
`han-secrets@production.service`. Глобальную зависимость Docker daemon от
|
||||
секретов не устанавливайте. После настройки проведите reboot rehearsal:
|
||||
materializer должен завершиться до root stack unit. На ВМ с другими workloads
|
||||
тем более запрещено связывать весь Docker
|
||||
daemon/VM, иначе fail-closed HAN остановит несвязанные системы.
|
||||
|
||||
## 6. Явный file fallback
|
||||
|
||||
Подготовьте отдельную карту
|
||||
`/etc/han/secrets/production-like.file.json`: скопируйте Selectel-карту,
|
||||
`/etc/han/secrets/production.file.json`: скопируйте Selectel-карту,
|
||||
установите `"mode": "file"`, удалите `selectel` и `http`, добавьте:
|
||||
|
||||
```json
|
||||
@@ -178,7 +166,7 @@ shell: запрещены `export`, substitutions, multiline, неизвестн
|
||||
ключи. Файл — `root:root 0600`.
|
||||
|
||||
При инциденте доставьте recovery-файл из защищённой офлайн-копии и только затем
|
||||
явно измените `.env`:
|
||||
явно измените `/etc/han/vm1.env`:
|
||||
|
||||
```dotenv
|
||||
SECRETS_SOURCE=file
|
||||
@@ -198,7 +186,7 @@ validate/recreate через wrappers. После восстановления S
|
||||
4. Сделайте версию current, удалите pin, снова sync и пересоздайте только
|
||||
потребителей.
|
||||
5. Для rollback активируйте предыдущую provider version; не храните snapshot
|
||||
старого `.env`.
|
||||
старого `/etc/han/vm1.env`.
|
||||
|
||||
Selectel не позволяет удалить отдельную версию — только секрет целиком. Старые
|
||||
значения должны быть отозваны в PostgreSQL/S3/Bitrix/i-Digital после окна
|
||||
|
||||
@@ -26,11 +26,6 @@
|
||||
"consumers": ["bitrix-local-app", "bitrix-local-migrate"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"BITRIX_SYNC_DATABASE_URL": {
|
||||
"remote": "BITRIX_SYNC_DATABASE_URL",
|
||||
"consumers": ["bitrix-sync", "bitrix-sync-migrate"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"SMS_DATABASE_URL": {
|
||||
"remote": "SMS_DATABASE_URL",
|
||||
"consumers": ["sms-service", "sms-worker", "sms-migrate"],
|
||||
@@ -68,7 +63,7 @@
|
||||
},
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY": {
|
||||
"remote": "BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||
"consumers": ["api-backend", "bitrix-local-app", "bitrix-sync"],
|
||||
"consumers": ["api-backend", "bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"REDIS_API_PASSWORD": {
|
||||
@@ -86,16 +81,6 @@
|
||||
"consumers": ["api-backend"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"REDIS_SAFETY_PASSWORD": {
|
||||
"remote": "REDIS_SAFETY_PASSWORD",
|
||||
"consumers": ["redis"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"MESSAGE_SAFETY_REDIS_URL": {
|
||||
"remote": "MESSAGE_SAFETY_REDIS_URL",
|
||||
"consumers": ["message-safety", "safety-recovery-worker"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"REDIS_HEALTH_PASSWORD": {
|
||||
"remote": "REDIS_HEALTH_PASSWORD",
|
||||
"consumers": ["redis", "redis-exporter"],
|
||||
@@ -103,7 +88,7 @@
|
||||
},
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN": {
|
||||
"remote": "MESSAGE_SAFETY_SERVICE_TOKEN",
|
||||
"consumers": ["api-backend", "message-safety"],
|
||||
"consumers": ["api-backend"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": {
|
||||
@@ -126,11 +111,6 @@
|
||||
"consumers": ["api-backend", "bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_SYNC_SERVICE_TOKEN": {
|
||||
"remote": "BITRIX_SYNC_SERVICE_TOKEN",
|
||||
"consumers": ["api-backend", "bitrix-sync"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": {
|
||||
"remote": "KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
|
||||
"consumers": ["api-backend", "keycloak"],
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/backend}
|
||||
DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/current/backend}
|
||||
SOURCE_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
||||
[ ! -f "$SOURCE_DIR/../../docker-compose.yml" ] || \
|
||||
DEPLOY_DIR=$(CDPATH= cd -- "$SOURCE_DIR/../.." && pwd)
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
CONFIG_FILE=${CONFIG_FILE:-/etc/han/vm1.env}
|
||||
LAUNCHER=${HAN_SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}
|
||||
[ -x "$LAUNCHER" ] || LAUNCHER=deployment/secrets/han-secrets
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
[Unit]
|
||||
Description=Materialize HAN service secrets (%i)
|
||||
Description=Materialize HAN VM1 service secrets (%i)
|
||||
Documentation=file:/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
Before=han-stack@%i.service
|
||||
ConditionPathIsDirectory=/opt/han-chat/current/backend
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
RuntimeDirectory=han-chat/secrets
|
||||
RuntimeDirectory=han-chat han-chat/secrets
|
||||
RuntimeDirectoryMode=0700
|
||||
ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /opt/han-chat/backend/.env
|
||||
ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /etc/han/vm1.env
|
||||
LoadCredentialEncrypted=selectel-service-user-password:/etc/han/credentials/%i.selectel-password.cred
|
||||
RemainAfterExit=yes
|
||||
StandardOutput=null
|
||||
|
||||
@@ -21,8 +21,6 @@ networks:
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
nginx-certs:
|
||||
nginx-acme-webroot:
|
||||
nginx-cache:
|
||||
frontend-static:
|
||||
otel-queue:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
|
||||
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
|
||||
EXPO_PUBLIC_API_BASE_URL=https://chat.example.ru
|
||||
EXPO_PUBLIC_AUTH_BASE_URL=https://chat.example.ru/auth
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
|
||||
EXPO_PUBLIC_APP_ENV=production-like
|
||||
|
||||
@@ -13,6 +13,10 @@ ENV EXPO_PUBLIC_API_BASE_URL=$EXPO_PUBLIC_API_BASE_URL \
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \
|
||||
EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV
|
||||
RUN test -n "$EXPO_PUBLIC_API_BASE_URL" \
|
||||
&& test -n "$EXPO_PUBLIC_AUTH_BASE_URL" \
|
||||
&& test -n "$EXPO_PUBLIC_KEYCLOAK_REALM" \
|
||||
&& test -n "$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID"
|
||||
RUN npm run build:pwa
|
||||
|
||||
# One-shot Compose init container copies the immutable export to nginx's volume.
|
||||
|
||||
@@ -40,8 +40,8 @@ Dockerfile собирает статический OCI-артефакт `/dist`
|
||||
|
||||
```bash
|
||||
docker build --target static \
|
||||
--build-arg EXPO_PUBLIC_API_BASE_URL=https://tohin.ru \
|
||||
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth \
|
||||
--build-arg EXPO_PUBLIC_API_BASE_URL="${PUBLIC_WEB_URL}" \
|
||||
--build-arg EXPO_PUBLIC_AUTH_BASE_URL="${PUBLIC_AUTH_URL}" \
|
||||
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=han-chat \
|
||||
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend .
|
||||
```
|
||||
|
||||
@@ -28,9 +28,7 @@ x-api-secrets: &api-secrets
|
||||
- cursor_hmac_secret
|
||||
|
||||
x-api-runtime: &api-runtime
|
||||
build:
|
||||
context: ../../api-backend
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
|
||||
environment:
|
||||
<<: *api-secret-environment
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
@@ -40,8 +38,8 @@ x-api-runtime: &api-runtime
|
||||
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL:-http://keycloak:8080/auth}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-han-chat-api}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-https://processing.internal:8443}
|
||||
MESSAGE_SAFETY_API_PREFIX: ${MESSAGE_SAFETY_API_PREFIX:-/internal/safety/v2}
|
||||
MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
@@ -65,8 +63,17 @@ x-api-runtime: &api-runtime
|
||||
source: ${MESSAGE_SAFETY_CA_HOST_PATH}
|
||||
target: /run/config/message-safety-internal-ca.pem
|
||||
read_only: true
|
||||
extra_hosts:
|
||||
- "${MESSAGE_SAFETY_EXTRA_HOST:?MESSAGE_SAFETY_EXTRA_HOST must map VM2 private DNS to private IP}"
|
||||
networks: [backend, observability, egress]
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=128m,mode=1777
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 256
|
||||
mem_limit: 1g
|
||||
cpus: 1.0
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
logging:
|
||||
@@ -96,9 +103,7 @@ x-sms-api-secrets: &sms-api-secrets
|
||||
- idgtl_sms_callback_password
|
||||
|
||||
x-sms-runtime: &sms-runtime
|
||||
build:
|
||||
context: ../../sms-service
|
||||
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
|
||||
image: ${SMS_SERVICE_IMAGE:?SMS_SERVICE_IMAGE must be pinned by digest}
|
||||
environment: *sms-api-environment
|
||||
secrets: *sms-api-secrets
|
||||
volumes:
|
||||
@@ -106,7 +111,14 @@ x-sms-runtime: &sms-runtime
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
target: /run/secrets/pg-ca.pem
|
||||
read_only: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 0.5
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
logging:
|
||||
@@ -115,16 +127,7 @@ x-sms-runtime: &sms-runtime
|
||||
|
||||
services:
|
||||
frontend-static:
|
||||
build:
|
||||
context: ../../frontend-test-site
|
||||
target: static
|
||||
args:
|
||||
EXPO_PUBLIC_API_BASE_URL: ${PUBLIC_WEB_URL}
|
||||
EXPO_PUBLIC_AUTH_BASE_URL: ${PUBLIC_AUTH_URL}
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID: han-chat-frontend
|
||||
EXPO_PUBLIC_APP_ENV: ${APP_ENV:-production-like}
|
||||
image: han-chat-frontend-static:${RELEASE_VERSION:-local}
|
||||
image: ${FRONTEND_STATIC_IMAGE:?FRONTEND_STATIC_IMAGE must be pinned by digest}
|
||||
volumes:
|
||||
- frontend-static:/output
|
||||
restart: "no"
|
||||
@@ -133,13 +136,14 @@ services:
|
||||
- /tmp:size=8m,mode=1777
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 64
|
||||
mem_limit: 128m
|
||||
cpus: 0.25
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
keycloak:
|
||||
build:
|
||||
context: ../../keycloak
|
||||
image: ${KEYCLOAK_IMAGE:-han-chat-keycloak:local}
|
||||
image: ${KEYCLOAK_IMAGE:?KEYCLOAK_IMAGE must be pinned by digest}
|
||||
environment:
|
||||
HAN_SECRET_VARS: >-
|
||||
KC_DB_PASSWORD KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||
@@ -164,6 +168,7 @@ services:
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_METRICS_ENABLED: "true"
|
||||
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL}
|
||||
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?PUBLIC_WEB_URL is required for realm import}
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
|
||||
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false}
|
||||
KEYCLOAK_YANDEX_CAPTCHA_ENABLED: ${KEYCLOAK_YANDEX_CAPTCHA_ENABLED:-false}
|
||||
@@ -194,7 +199,12 @@ services:
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
# read_only is intentionally omitted until Keycloak runtime data/provider paths are externalized.
|
||||
pids_limit: 512
|
||||
mem_limit: 2g
|
||||
cpus: 2.0
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
logging:
|
||||
@@ -242,45 +252,12 @@ services:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
message-safety:
|
||||
build:
|
||||
context: ../../message-safety
|
||||
image: ${MESSAGE_SAFETY_IMAGE:-han-chat-message-safety:local}
|
||||
environment:
|
||||
HAN_SECRET_VARS: MESSAGE_SAFETY_REDIS_URL MESSAGE_SAFETY_SERVICE_TOKEN
|
||||
MESSAGE_SAFETY_REDIS_URL_FILE: /run/secrets/message_safety_redis_url
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN_FILE: /run/secrets/message_safety_service_token
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
MESSAGE_SAFETY_RULES_VERSION: ${MESSAGE_SAFETY_RULES_VERSION:-2026-01-01}
|
||||
MESSAGE_SAFETY_TASK_TTL_SEC: ${MESSAGE_SAFETY_TASK_TTL_SEC:-900}
|
||||
secrets:
|
||||
- message_safety_redis_url
|
||||
- message_safety_service_token
|
||||
expose: ["8080"]
|
||||
networks: [backend, observability, egress]
|
||||
depends_on:
|
||||
redis: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
api-backend:
|
||||
<<: *api-runtime
|
||||
expose: ["8000"]
|
||||
depends_on:
|
||||
redis: {condition: service_healthy}
|
||||
keycloak: {condition: service_healthy}
|
||||
message-safety: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
|
||||
interval: 10s
|
||||
@@ -308,7 +285,6 @@ services:
|
||||
command: ["han-safety-worker"]
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
message-safety: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-safety-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||
interval: 30s
|
||||
@@ -348,7 +324,6 @@ services:
|
||||
command: ["han-notification-draft-cleanup-worker"]
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
message-safety: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-notification-draft-cleanup-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||
interval: 30s
|
||||
@@ -358,9 +333,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
bitrix-local-app:
|
||||
build:
|
||||
context: ../../bitrix-local-app
|
||||
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
|
||||
image: ${BITRIX_LOCAL_APP_IMAGE:?BITRIX_LOCAL_APP_IMAGE must be pinned by digest}
|
||||
environment:
|
||||
HAN_SECRET_VARS: >-
|
||||
BITRIX_DATABASE_URL BITRIX_CLIENT_SECRET
|
||||
@@ -404,47 +377,20 @@ services:
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 0.5
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
bitrix-sync:
|
||||
build:
|
||||
context: ../../bitrix-sync
|
||||
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
|
||||
environment:
|
||||
HAN_SECRET_VARS: BITRIX_SYNC_DATABASE_URL BITRIX_SYNC_SERVICE_TOKEN
|
||||
BITRIX_SYNC_DATABASE_URL_FILE: /run/secrets/bitrix_sync_database_url
|
||||
BITRIX_SYNC_SERVICE_TOKEN_FILE: /run/secrets/bitrix_sync_service_token
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
BITRIX_SYNC_ENABLED: ${BITRIX_SYNC_ENABLED:-false}
|
||||
secrets:
|
||||
- bitrix_sync_database_url
|
||||
- bitrix_sync_service_token
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
target: /run/secrets/pg-ca.pem
|
||||
read_only: true
|
||||
networks: [backend, observability, egress]
|
||||
depends_on:
|
||||
redis: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
secrets:
|
||||
api_database_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/DATABASE_URL
|
||||
@@ -476,8 +422,6 @@ secrets:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/IDGTL_SMS_CALLBACK_USERNAME
|
||||
idgtl_sms_callback_password:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/IDGTL_SMS_CALLBACK_PASSWORD
|
||||
message_safety_redis_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/MESSAGE_SAFETY_REDIS_URL
|
||||
bitrix_database_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_DATABASE_URL
|
||||
bitrix_client_secret:
|
||||
@@ -490,10 +434,6 @@ secrets:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_API_FORWARD_TOKEN
|
||||
bitrix_token_encryption_key:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_TOKEN_ENCRYPTION_KEY
|
||||
bitrix_sync_database_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_SYNC_DATABASE_URL
|
||||
bitrix_sync_service_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_SYNC_SERVICE_TOKEN
|
||||
keycloak_db_password:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_DB_PASSWORD
|
||||
keycloak_admin_password:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
|
||||
PUBLIC_WEB_URL=https://chat.example.ru
|
||||
KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth
|
||||
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.internal:6432/han_chat?sslmode=verify-full¤tSchema=keycloak&ApplicationName=keycloak
|
||||
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=bootstrap-admin
|
||||
|
||||
@@ -7,6 +7,9 @@ RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
|
||||
COPY src ./src
|
||||
COPY realm ./realm
|
||||
COPY themes ./themes
|
||||
COPY container-entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' container-entrypoint.sh \
|
||||
&& /bin/sh -n container-entrypoint.sh
|
||||
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
|
||||
|
||||
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
|
||||
@@ -21,7 +24,7 @@ RUN /opt/keycloak/bin/kc.sh build
|
||||
FROM quay.io/keycloak/keycloak:26.1.4
|
||||
COPY --from=keycloak-build --chown=keycloak:keycloak /opt/keycloak/ /opt/keycloak/
|
||||
COPY --chown=keycloak:keycloak realm/han-chat-realm.json /opt/keycloak/data/import/han-chat-realm.json
|
||||
COPY --chown=keycloak:keycloak --chmod=0555 container-entrypoint.sh /opt/keycloak/bin/han-container-entrypoint
|
||||
COPY --from=provider-build --chown=keycloak:keycloak --chmod=0555 /build/container-entrypoint.sh /opt/keycloak/bin/han-container-entrypoint
|
||||
USER 1000
|
||||
EXPOSE 8080 9000
|
||||
ENTRYPOINT ["/opt/keycloak/bin/han-container-entrypoint"]
|
||||
|
||||
@@ -45,13 +45,14 @@ The browser token is validated by Keycloak before `OtpFlow.start()`. A provider
|
||||
|
||||
SmartCaptcha CSP is applied only by nginx to the `han-chat` login and login-action endpoints. Never set a custom `browserSecurityHeaders.contentSecurityPolicy` in the realm: it can break Keycloak Admin Console and third-party cookie iframes.
|
||||
|
||||
Before production deployment replace the explicit placeholder entries in `realm/han-chat-realm.json`:
|
||||
Realm import derives browser redirect, logout and web-origin URLs from the
|
||||
non-secret `PUBLIC_WEB_URL` environment variable. Keycloak resolves the
|
||||
`${PUBLIC_WEB_URL}` placeholders in `realm/han-chat-realm.json` during the
|
||||
initial `--import-realm`.
|
||||
|
||||
- `https://APP_LINK_HOST.example/auth/callback`
|
||||
- `https://APP_LINK_HOST.example/auth/logout`
|
||||
- `https://APP_WEB_ORIGIN.example`
|
||||
|
||||
Use exact Expo universal/app links and web origins. Do not replace them with wildcards. `https://tohin.ru/auth/callback` and `han-chat://auth/callback` are already allow-listed.
|
||||
Use exact Expo universal/app links and web origins. Do not replace them with
|
||||
wildcards. `${PUBLIC_WEB_URL}/auth/callback` and `han-chat://auth/callback` are
|
||||
allow-listed by the initial realm import.
|
||||
|
||||
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
|
||||
|
||||
@@ -109,4 +110,7 @@ Reserve, SMS HTTP order, and activation/order-failure run as separate transactio
|
||||
|
||||
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
|
||||
|
||||
Backups must include the full Keycloak schema (realm signing keys and provider tables). After restore verify issuer `https://tohin.ru/auth/realms/han-chat`, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic.
|
||||
Backups must include the full Keycloak schema (realm signing keys and provider
|
||||
tables). After restore verify issuer
|
||||
`${KEYCLOAK_PUBLIC_URL}/realms/han-chat`, JWKS, client redirects, browser flow
|
||||
binding, challenge persistence and refresh revocation before opening traffic.
|
||||
|
||||
@@ -21,7 +21,8 @@ services:
|
||||
KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required}
|
||||
KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME:?KEYCLOAK_DB_USERNAME is required}
|
||||
KC_DB_URL_PROPERTIES: ${KC_DB_URL_PROPERTIES:-currentSchema=keycloak}
|
||||
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-https://tohin.ru/auth}
|
||||
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:?KEYCLOAK_PUBLIC_URL is required}
|
||||
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?PUBLIC_WEB_URL is required for realm import}
|
||||
KC_HOSTNAME_STRICT: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HTTP_PORT: "8080"
|
||||
|
||||
@@ -60,15 +60,15 @@
|
||||
"frontchannelLogout": true,
|
||||
"fullScopeAllowed": false,
|
||||
"redirectUris": [
|
||||
"https://chat.han0107.ru/auth/callback",
|
||||
"${PUBLIC_WEB_URL}/auth/callback",
|
||||
"han-chat://auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://chat.han0107.ru"
|
||||
"${PUBLIC_WEB_URL}"
|
||||
],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"post.logout.redirect.uris": "https://chat.han0107.ru/##han-chat://auth/logout",
|
||||
"post.logout.redirect.uris": "${PUBLIC_WEB_URL}/##han-chat://auth/logout",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
|
||||
+3
@@ -28,6 +28,9 @@ class RealmContractTest {
|
||||
assertTrue(realm.contains("\"refreshTokenMaxReuse\": 0"));
|
||||
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
||||
assertTrue(realm.contains("\"han-chat://auth/callback\""));
|
||||
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/auth/callback\""));
|
||||
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}\""));
|
||||
assertFalse(realm.contains("chat.han0107.ru"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
FROM python:3.12-slim AS runtime
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
WORKDIR /service
|
||||
COPY app ./app
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -1 +0,0 @@
|
||||
"""HAN message-safety service."""
|
||||
@@ -1,334 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import random
|
||||
import unicodedata
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated, Any, Literal, Protocol
|
||||
|
||||
import redis.asyncio as redis
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
app_env: str = "production-like"
|
||||
message_safety_redis_url: str = "redis://redis:6379/2"
|
||||
message_safety_service_token: str = Field(min_length=16)
|
||||
message_safety_rules_version: str = "2026-01-01"
|
||||
message_safety_task_ttl_sec: int = Field(default=900, ge=330)
|
||||
message_safety_poll_after_ms: int = Field(default=2000, ge=100, le=30000)
|
||||
safety_stub_worker_mode: Literal["emulated_on_poll"] = "emulated_on_poll"
|
||||
safety_stub_rng_seed: int | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def forbid_seed_outside_tests(self) -> Settings:
|
||||
if self.safety_stub_rng_seed is not None and self.app_env != "test":
|
||||
raise ValueError("SAFETY_STUB_RNG_SEED is allowed only when APP_ENV=test")
|
||||
return self
|
||||
|
||||
|
||||
class Attachment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
attachment_id: uuid.UUID
|
||||
quarantine_object_key: str = Field(min_length=1, max_length=1024)
|
||||
mime_type: str = Field(min_length=1, max_length=255)
|
||||
size_bytes: int = Field(ge=0, le=10 * 1024 * 1024)
|
||||
checksum: str = Field(pattern=r"^sha256:[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
class CheckRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
message_id: uuid.UUID
|
||||
content_kind: Literal["text", "file"]
|
||||
text: str = Field(default="", max_length=10000)
|
||||
attachment: Attachment | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_kind(self) -> CheckRequest:
|
||||
if self.content_kind == "file" and self.attachment is None:
|
||||
raise ValueError("attachment is required for file content")
|
||||
if self.content_kind == "text" and self.attachment is not None:
|
||||
raise ValueError("attachment is forbidden for text content")
|
||||
return self
|
||||
|
||||
|
||||
class TaskStore(Protocol):
|
||||
async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str: ...
|
||||
async def poll(self, task_id: str) -> int | None: ...
|
||||
async def ready(self) -> bool: ...
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
class RedisTaskStore:
|
||||
_reserve_lua = """
|
||||
local prior = redis.call('GET', KEYS[1])
|
||||
if prior then
|
||||
local sep = string.find(prior, '|', 1, true)
|
||||
local old_fp = string.sub(prior, 1, sep - 1)
|
||||
if old_fp ~= ARGV[1] then return {'conflict'} end
|
||||
return {'existing', string.sub(prior, sep + 1)}
|
||||
end
|
||||
redis.call('HSET', KEYS[2], 'schema_version', '1', 'message_id', ARGV[2],
|
||||
'created_at_ms', ARGV[3], 'poll_count', '0', 'rules_version', ARGV[5])
|
||||
redis.call('EXPIRE', KEYS[2], ARGV[4])
|
||||
redis.call('SET', KEYS[1], ARGV[1] .. '|' .. ARGV[6], 'EX', ARGV[4])
|
||||
return {'created', ARGV[6]}
|
||||
"""
|
||||
_poll_lua = """
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then return nil end
|
||||
return redis.call('HINCRBY', KEYS[1], 'poll_count', 1)
|
||||
"""
|
||||
|
||||
def __init__(self, client: redis.Redis, rules_version: str) -> None:
|
||||
self.client = client
|
||||
self.rules_version = rules_version
|
||||
|
||||
async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str:
|
||||
result = await self.client.eval(
|
||||
self._reserve_lua,
|
||||
2,
|
||||
f"han:safety:task-by-message:{message_id}",
|
||||
f"han:safety:task:{task_id}",
|
||||
fingerprint,
|
||||
message_id,
|
||||
str(int(datetime.now(UTC).timestamp() * 1000)),
|
||||
str(ttl),
|
||||
self.rules_version,
|
||||
task_id,
|
||||
)
|
||||
status = _decode(result[0])
|
||||
if status == "conflict":
|
||||
raise ValueError("conflict")
|
||||
return _decode(result[1])
|
||||
|
||||
async def poll(self, task_id: str) -> int | None:
|
||||
key = f"han:safety:task:{task_id}"
|
||||
count = await self.client.eval(self._poll_lua, 1, key)
|
||||
return int(count) if count is not None else None
|
||||
|
||||
async def ready(self) -> bool:
|
||||
key = f"han:safety:ready:{uuid.uuid4()}"
|
||||
try:
|
||||
await self.client.set(key, "1", ex=5)
|
||||
return await self.client.get(key) == b"1"
|
||||
finally:
|
||||
await self.client.delete(key)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
def _decode(value: Any) -> str:
|
||||
return value.decode() if isinstance(value, bytes) else str(value)
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
return unicodedata.normalize("NFKC", text.replace("\r\n", "\n").replace("\r", "\n")).lstrip()
|
||||
|
||||
|
||||
def fingerprint(dto: CheckRequest) -> str:
|
||||
body = dto.model_dump(mode="json")
|
||||
body["text"] = normalize(dto.text)
|
||||
encoded = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def error(code: str, message: str, request_id: str, details: dict[str, Any] | None = None) -> dict:
|
||||
return {
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"request_id": request_id,
|
||||
"details": details or {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_app(
|
||||
settings: Settings | None = None,
|
||||
store: TaskStore | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> FastAPI:
|
||||
cfg = settings or Settings()
|
||||
verdict_rng = rng or random.Random(cfg.safety_stub_rng_seed)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.store = store or RedisTaskStore(
|
||||
redis.from_url(cfg.message_safety_redis_url, decode_responses=False),
|
||||
cfg.message_safety_rules_version,
|
||||
)
|
||||
yield
|
||||
await app.state.store.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="HAN Message Safety",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url=None if cfg.app_env != "test" else "/docs",
|
||||
)
|
||||
app.state.settings = cfg
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_id_middleware(request: Request, call_next):
|
||||
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
return response
|
||||
|
||||
def authorize(
|
||||
request: Request,
|
||||
token: Annotated[str | None, Header(alias="X-Service-Token")] = None,
|
||||
) -> None:
|
||||
if token is None or not hmac.compare_digest(token, cfg.message_safety_service_token):
|
||||
raise HTTPException(
|
||||
401,
|
||||
error(
|
||||
"service_unauthorized",
|
||||
"Service authentication failed",
|
||||
request.state.request_id,
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_: Request, exc: HTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error(request: Request, _: RequestValidationError):
|
||||
return JSONResponse(
|
||||
error("validation_error", "Request is invalid", request.state.request_id),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@app.get("/health/live")
|
||||
async def live() -> dict[str, str]:
|
||||
return {"status": "live"}
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def ready(request: Request):
|
||||
try:
|
||||
ok = await request.app.state.store.ready()
|
||||
except Exception:
|
||||
ok = False
|
||||
body = {
|
||||
"status": "ready" if ok else "not_ready",
|
||||
"components": {"redis": "ok" if ok else "down"},
|
||||
}
|
||||
return JSONResponse(body, status_code=200 if ok else 503)
|
||||
|
||||
@app.post("/internal/safety/v1/messages/check", dependencies=[Depends(authorize)])
|
||||
async def check(dto: CheckRequest, request: Request):
|
||||
text = normalize(dto.text)
|
||||
common = {"rules_version": cfg.message_safety_rules_version}
|
||||
if text and text[0] in {"ф", "Ф"}:
|
||||
return JSONResponse(
|
||||
{
|
||||
"verdict": "deny",
|
||||
"rule_id": "stub.starts_with_cyrillic_ef",
|
||||
"reason_code": "stub_blocked",
|
||||
**common,
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
if text and unicodedata.category(text[0]) == "Nd":
|
||||
task_id = str(uuid.uuid4())
|
||||
try:
|
||||
task_id = await request.app.state.store.reserve(
|
||||
str(dto.message_id), fingerprint(dto), task_id, cfg.message_safety_task_ttl_sec
|
||||
)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
error(
|
||||
"safety_request_conflict", "message_id was reused", request.state.request_id
|
||||
),
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
error(
|
||||
"redis_unavailable", "Task storage is unavailable", request.state.request_id
|
||||
),
|
||||
status_code=503,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"verdict": "pending",
|
||||
"task_id": task_id,
|
||||
"poll_after_ms": cfg.message_safety_poll_after_ms,
|
||||
"expires_at": (
|
||||
datetime.now(UTC) + timedelta(seconds=cfg.message_safety_task_ttl_sec)
|
||||
)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
**common,
|
||||
},
|
||||
status_code=203,
|
||||
)
|
||||
return {"verdict": "allow", "rule_id": "stub.default_allow", **common}
|
||||
|
||||
@app.get("/internal/safety/v1/messages/tasks/{task_id}", dependencies=[Depends(authorize)])
|
||||
async def task(task_id: str, request: Request):
|
||||
try:
|
||||
parsed = str(uuid.UUID(task_id))
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
error("validation_error", "Request is invalid", request.state.request_id),
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
count = await request.app.state.store.poll(parsed)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
error("redis_unavailable", "Task storage is unavailable", request.state.request_id),
|
||||
status_code=503,
|
||||
)
|
||||
if count is None:
|
||||
return JSONResponse(
|
||||
error("task_not_found", "Task was not found", request.state.request_id),
|
||||
status_code=404,
|
||||
)
|
||||
outcome = verdict_rng.choice(("pending", "allow", "final_error"))
|
||||
if outcome == "pending":
|
||||
return JSONResponse(
|
||||
{
|
||||
"verdict": "pending",
|
||||
"task_id": parsed,
|
||||
"poll_after_ms": cfg.message_safety_poll_after_ms,
|
||||
},
|
||||
status_code=203,
|
||||
)
|
||||
if outcome == "allow":
|
||||
return {"verdict": "allow", "task_id": parsed, "rule_id": "stub.random_allow"}
|
||||
return JSONResponse(
|
||||
{
|
||||
"verdict": "deny",
|
||||
"task_id": parsed,
|
||||
**error(
|
||||
"stub_final_error",
|
||||
"Stub task returned a final negative verdict",
|
||||
request.state.request_id,
|
||||
{"terminal": True},
|
||||
),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec "$@"
|
||||
@@ -1,58 +0,0 @@
|
||||
openapi: 3.1.0
|
||||
info: {title: HAN Message Safety, version: 1.0.0}
|
||||
paths:
|
||||
/health/live:
|
||||
get: {responses: {"200": {description: Live}}}
|
||||
/health/ready:
|
||||
get: {responses: {"200": {description: Ready}, "503": {description: Redis unavailable}}}
|
||||
/internal/safety/v1/messages/check:
|
||||
post:
|
||||
security: [{ServiceToken: []}]
|
||||
parameters: [{$ref: "#/components/parameters/RequestId"}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/CheckRequest"}
|
||||
responses:
|
||||
"200": {description: Allow}
|
||||
"203": {description: Pending}
|
||||
"403": {description: Deny}
|
||||
"409": {description: Conflicting message id}
|
||||
"503": {description: Redis unavailable}
|
||||
/internal/safety/v1/messages/tasks/{task_id}:
|
||||
get:
|
||||
security: [{ServiceToken: []}]
|
||||
parameters:
|
||||
- {name: task_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
- {$ref: "#/components/parameters/RequestId"}
|
||||
responses:
|
||||
"200": {description: Allow}
|
||||
"203": {description: Pending}
|
||||
"400": {description: Validation error or terminal stub rejection}
|
||||
"404": {description: Task not found}
|
||||
components:
|
||||
securitySchemes:
|
||||
ServiceToken: {type: apiKey, in: header, name: X-Service-Token}
|
||||
parameters:
|
||||
RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||
schemas:
|
||||
Attachment:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [attachment_id, quarantine_object_key, mime_type, size_bytes, checksum]
|
||||
properties:
|
||||
attachment_id: {type: string, format: uuid}
|
||||
quarantine_object_key: {type: string, maxLength: 1024}
|
||||
mime_type: {type: string, maxLength: 255}
|
||||
size_bytes: {type: integer, minimum: 0}
|
||||
checksum: {type: string, pattern: "^sha256:[0-9a-fA-F]{64}$"}
|
||||
CheckRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id, content_kind]
|
||||
properties:
|
||||
message_id: {type: string, format: uuid}
|
||||
content_kind: {type: string, enum: [text, file]}
|
||||
text: {type: string, maxLength: 10000, default: ""}
|
||||
attachment: {anyOf: [{$ref: "#/components/schemas/Attachment"}, {type: "null"}]}
|
||||
@@ -1,28 +0,0 @@
|
||||
[project]
|
||||
name = "han-message-safety"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.116,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"redis>=6,<7",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -1,103 +0,0 @@
|
||||
import os
|
||||
import random
|
||||
import uuid
|
||||
|
||||
os.environ.setdefault("MESSAGE_SAFETY_SERVICE_TOKEN", "test-service-token-32-characters")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import Settings, create_app, normalize
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self):
|
||||
self.tasks = {}
|
||||
|
||||
async def reserve(self, message_id, fingerprint, task_id, ttl):
|
||||
prior = self.tasks.get(message_id)
|
||||
if prior and prior[0] != fingerprint:
|
||||
raise ValueError("conflict")
|
||||
if prior:
|
||||
return prior[1]
|
||||
self.tasks[message_id] = (fingerprint, task_id)
|
||||
return task_id
|
||||
|
||||
async def poll(self, task_id):
|
||||
return 1 if any(value[1] == task_id for value in self.tasks.values()) else None
|
||||
|
||||
async def ready(self):
|
||||
return True
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class SequenceRandom(random.Random):
|
||||
def __init__(self):
|
||||
self.values = iter(("pending", "allow", "final_error"))
|
||||
|
||||
def choice(self, _):
|
||||
return next(self.values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rules_auth_and_independent_poll():
|
||||
settings = Settings(
|
||||
app_env="test",
|
||||
message_safety_service_token="test-service-token-32-characters",
|
||||
)
|
||||
app = create_app(settings, Store(), SequenceRandom())
|
||||
headers = {"X-Service-Token": settings.message_safety_service_token}
|
||||
message_id = str(uuid.uuid4())
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
assert (
|
||||
await client.post(
|
||||
"/internal/safety/v1/messages/check",
|
||||
json={
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"content_kind": "text",
|
||||
"text": " Файл",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
).status_code == 403
|
||||
pending = await client.post(
|
||||
"/internal/safety/v1/messages/check",
|
||||
json={"message_id": message_id, "content_kind": "text", "text": "\u00a07 дней"},
|
||||
headers=headers,
|
||||
)
|
||||
assert pending.status_code == 203
|
||||
task_id = pending.json()["task_id"]
|
||||
assert [
|
||||
(
|
||||
await client.get(
|
||||
f"/internal/safety/v1/messages/tasks/{task_id}", headers=headers
|
||||
)
|
||||
).status_code
|
||||
for _ in range(3)
|
||||
] == [203, 200, 400]
|
||||
assert (
|
||||
await client.post(
|
||||
"/internal/safety/v1/messages/check",
|
||||
json={
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"content_kind": "text",
|
||||
"text": "документ",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
).status_code == 200
|
||||
assert (
|
||||
await client.post(
|
||||
"/internal/safety/v1/messages/check",
|
||||
json={"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "ok"},
|
||||
)
|
||||
).status_code == 401
|
||||
|
||||
|
||||
def test_normalization():
|
||||
assert normalize("\r\n\u00a07 дней") == "7 дней"
|
||||
@@ -9,6 +9,7 @@ COPY templates /etc/nginx/templates-src/sites
|
||||
COPY snippets /etc/nginx/snippets
|
||||
COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/han-nginx-entrypoint \
|
||||
&& /bin/sh -n /usr/local/bin/han-nginx-entrypoint \
|
||||
&& chmod 0555 /usr/local/bin/han-nginx-entrypoint \
|
||||
&& find /etc/nginx/templates-src /etc/nginx/snippets -type f -exec chmod 0444 {} +
|
||||
ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
services:
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
image: han-chat-nginx:${RELEASE_VERSION:-local}
|
||||
image: ${NGINX_IMAGE:?NGINX_IMAGE must be pinned by digest}
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||
@@ -33,8 +31,8 @@ services:
|
||||
- "${NGINX_HTTPS_PORT:-443}:443"
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- nginx-certs:/etc/letsencrypt:ro
|
||||
- nginx-acme-webroot:/var/www/certbot:ro
|
||||
- /var/lib/han-chat/public-tls:/run/tls:ro
|
||||
- /var/lib/han-chat/acme:/var/www/certbot:ro
|
||||
- frontend-static:/usr/share/nginx/html:ro
|
||||
- nginx-cache:/var/cache/nginx
|
||||
networks: [public, backend]
|
||||
@@ -44,7 +42,6 @@ services:
|
||||
keycloak: {condition: service_healthy}
|
||||
sms-service: {condition: service_healthy}
|
||||
bitrix-local-app: {condition: service_healthy}
|
||||
bitrix-sync: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/nginx-health/live || curl -fsS http://127.0.0.1/nginx-health/live"]
|
||||
interval: 10s
|
||||
@@ -58,6 +55,9 @@ services:
|
||||
cap_drop: ["ALL"]
|
||||
cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 256
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ulimits:
|
||||
@@ -65,13 +65,3 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot:v3.1.0
|
||||
profiles: ["certbot"]
|
||||
volumes:
|
||||
- nginx-certs:/etc/letsencrypt
|
||||
- nginx-acme-webroot:/var/www/certbot
|
||||
networks: [public]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: ["ALL"]
|
||||
|
||||
@@ -68,7 +68,6 @@ http {
|
||||
upstream keycloak_upstream { server keycloak:8080; keepalive 16; }
|
||||
upstream sms_service_upstream { server sms-service:8080; keepalive 8; }
|
||||
upstream bitrix_local { server bitrix-local-app:8080; keepalive 16; }
|
||||
upstream bitrix_sync_upstream { server bitrix-sync:8080; keepalive 8; }
|
||||
upstream frontend_dev { server ${EXPO_DEV_SERVER_HOSTPORT}; keepalive 8; }
|
||||
|
||||
include /etc/nginx/generated/site.conf;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
services:
|
||||
otel-queue-init:
|
||||
image: alpine:3.21.2
|
||||
image: ${OTEL_QUEUE_INIT_IMAGE:?OTEL_QUEUE_INIT_IMAGE must be pinned by digest}
|
||||
command: ["sh", "-c", "chown 10001:10001 /queue && chmod 0700 /queue"]
|
||||
volumes:
|
||||
- otel-queue:/queue
|
||||
networks: [observability]
|
||||
restart: "no"
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:size=8m,mode=1777"]
|
||||
cap_drop: ["ALL"]
|
||||
cap_add: ["CHOWN", "FOWNER"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
pids_limit: 32
|
||||
mem_limit: 32m
|
||||
cpus: 0.25
|
||||
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:0.117.0
|
||||
image: ${OTEL_COLLECTOR_IMAGE:?OTEL_COLLECTOR_IMAGE must be pinned by digest}
|
||||
command: ["--config=/etc/otelcol/config.yaml"]
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
@@ -41,6 +45,7 @@ services:
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 0.5
|
||||
logging:
|
||||
@@ -48,7 +53,7 @@ services:
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
otel-collector-local:
|
||||
image: otel/opentelemetry-collector-contrib:0.117.0
|
||||
image: ${OTEL_COLLECTOR_IMAGE:?OTEL_COLLECTOR_IMAGE must be pinned by digest}
|
||||
profiles: ["observability-local"]
|
||||
command: ["--config=/etc/otelcol/config.yaml"]
|
||||
expose: ["4317", "4318", "13133"]
|
||||
|
||||
@@ -5,6 +5,7 @@ COPY redis.conf /etc/han-redis/redis.conf
|
||||
COPY users.acl.template /etc/han-redis/users.acl.template
|
||||
COPY scripts/entrypoint.sh /usr/local/lib/han/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/lib/han/entrypoint.sh \
|
||||
&& /bin/sh -n /usr/local/lib/han/entrypoint.sh \
|
||||
&& chmod 0555 /usr/local/lib/han/entrypoint.sh \
|
||||
&& chmod 0444 /etc/han-redis/*
|
||||
USER redis
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
services:
|
||||
redis:
|
||||
build:
|
||||
context: .
|
||||
image: han-chat-redis:${RELEASE_VERSION:-local}
|
||||
image: ${REDIS_IMAGE:?REDIS_IMAGE must be pinned by digest}
|
||||
environment:
|
||||
REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-384mb}
|
||||
REDIS_EVICTION_POLICY: ${REDIS_EVICTION_POLICY:-volatile-lru}
|
||||
secrets:
|
||||
- redis_api_password
|
||||
- redis_safety_password
|
||||
- redis_health_password
|
||||
expose: ["6379"]
|
||||
volumes:
|
||||
@@ -27,7 +24,9 @@ services:
|
||||
- /tmp:size=8m,mode=1777
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: ["ALL"]
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 0.5
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
nofile: {soft: 65536, hard: 65536}
|
||||
@@ -38,7 +37,5 @@ services:
|
||||
secrets:
|
||||
redis_api_password:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_API_PASSWORD
|
||||
redis_safety_password:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_SAFETY_PASSWORD
|
||||
redis_health_password:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_HEALTH_PASSWORD
|
||||
|
||||
@@ -3,7 +3,6 @@ set -eu
|
||||
|
||||
for item in \
|
||||
REDIS_API_PASSWORD:redis_api_password \
|
||||
REDIS_SAFETY_PASSWORD:redis_safety_password \
|
||||
REDIS_HEALTH_PASSWORD:redis_health_password
|
||||
do
|
||||
name=${item%%:*}
|
||||
@@ -21,9 +20,9 @@ do
|
||||
done
|
||||
|
||||
umask 077
|
||||
envsubst '${REDIS_API_PASSWORD} ${REDIS_SAFETY_PASSWORD} ${REDIS_HEALTH_PASSWORD}' \
|
||||
envsubst '${REDIS_API_PASSWORD} ${REDIS_HEALTH_PASSWORD}' \
|
||||
< /etc/han-redis/users.acl.template > /tmp/users.acl
|
||||
unset REDIS_API_PASSWORD REDIS_SAFETY_PASSWORD REDIS_HEALTH_PASSWORD
|
||||
unset REDIS_API_PASSWORD REDIS_HEALTH_PASSWORD
|
||||
|
||||
exec redis-server /etc/han-redis/redis.conf \
|
||||
--aclfile /tmp/users.acl \
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
user default off
|
||||
user api_backend on >${REDIS_API_PASSWORD} ~han:api:* ~han:rt:* ~han:coord:* &han:rt:* +@read +@write +@connection +@scripting +publish +subscribe +psubscribe -flushall -flushdb -config -module -keys
|
||||
user message_safety on >${REDIS_SAFETY_PASSWORD} ~han:safety:* +@read +@write +@connection +@scripting -flushall -flushdb -config -module -keys -publish -subscribe -psubscribe
|
||||
user ops_health on >${REDIS_HEALTH_PASSWORD} +ping +info +client|id -select
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Validate public deployment config separately from runtime secrets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
@@ -16,15 +17,16 @@ REQUIRED_CONFIG = {
|
||||
"PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
|
||||
"KEYCLOAK_INTERNAL_URL", "KEYCLOAK_REALM", "KEYCLOAK_SMS_SERVICE_URL",
|
||||
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
|
||||
"MESSAGE_SAFETY_URL", "MESSAGE_SAFETY_EXTRA_HOST",
|
||||
"MESSAGE_SAFETY_CA_HOST_PATH", "MESSAGE_SAFETY_API_PREFIX",
|
||||
}
|
||||
REQUIRED_RUNTIME = {
|
||||
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
|
||||
"DATABASE_URL", "BITRIX_DATABASE_URL",
|
||||
"SMS_DATABASE_URL", "KEYCLOAK_DB_PASSWORD", "REDIS_URL",
|
||||
"REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL", "REDIS_API_PASSWORD",
|
||||
"REDIS_SAFETY_PASSWORD", "REDIS_HEALTH_PASSWORD",
|
||||
"REDIS_REALTIME_URL", "REDIS_API_PASSWORD", "REDIS_HEALTH_PASSWORD",
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN", "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
|
||||
"BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN",
|
||||
"BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN",
|
||||
"BITRIX_API_INBOX_TOKEN",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY",
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN",
|
||||
"IDGTL_SMS_API_KEY", "IDGTL_SMS_CALLBACK_USERNAME",
|
||||
@@ -34,18 +36,15 @@ REQUIRED_RUNTIME = {
|
||||
}
|
||||
OPTIONAL_SECRET_KEYS = {
|
||||
"BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN",
|
||||
"BITRIX_SYNC_CRM_WEBHOOK_URL", "BITRIX_SYNC_WEBHOOK_TOKEN",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
|
||||
"NOTIFICATIONS_TOKEN_PRODUCER_TEST",
|
||||
"OTEL_REMOTE_AUTH_HEADER", "KEYCLOAK_OTP_MOCK_CODE",
|
||||
}
|
||||
FORBIDDEN_CONFIG_KEYS = REQUIRED_RUNTIME | OPTIONAL_SECRET_KEYS | {
|
||||
"BITRIX_SYNC_APP_DATABASE_URL", "PG_BACKUP_DSN",
|
||||
"PG_BACKUP_DSN",
|
||||
}
|
||||
DSN_KEYS = {
|
||||
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_APP_DATABASE_URL",
|
||||
"BITRIX_SYNC_DATABASE_URL", "MESSAGE_SAFETY_DATABASE_URL",
|
||||
"SMS_DATABASE_URL", "PG_BACKUP_DSN",
|
||||
"DATABASE_URL", "BITRIX_DATABASE_URL", "SMS_DATABASE_URL", "PG_BACKUP_DSN",
|
||||
}
|
||||
PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I)
|
||||
|
||||
@@ -148,7 +147,6 @@ def validate_runtime(config: dict[str, str], runtime: dict[str, str], errors: li
|
||||
redis_contract = (
|
||||
("REDIS_URL", "api_backend", "REDIS_API_PASSWORD", "/0"),
|
||||
("REDIS_REALTIME_URL", "api_backend", "REDIS_API_PASSWORD", "/1"),
|
||||
("MESSAGE_SAFETY_REDIS_URL", "message_safety", "REDIS_SAFETY_PASSWORD", "/2"),
|
||||
)
|
||||
for key, username, password_key, database in redis_contract:
|
||||
parsed = urlparse(runtime.get(key, ""))
|
||||
@@ -180,23 +178,47 @@ def validate_shared(env: dict[str, str], errors: list[str]) -> None:
|
||||
):
|
||||
if env.get(key) and urlparse(env[key]).scheme != "https":
|
||||
errors.append(f"{key}: публичный URL должен использовать https")
|
||||
public_origin = f"https://{env.get('PUBLIC_HOST', '')}"
|
||||
expected_public_urls = {
|
||||
"PUBLIC_WEB_URL": public_origin,
|
||||
"PUBLIC_API_URL": f"{public_origin}/api",
|
||||
"PUBLIC_AUTH_URL": f"{public_origin}/auth",
|
||||
"KEYCLOAK_PUBLIC_URL": f"{public_origin}/auth",
|
||||
}
|
||||
for key, expected in expected_public_urls.items():
|
||||
if env.get(key, "").rstrip("/") != expected:
|
||||
errors.append(f"{key}: должен быть производным от PUBLIC_HOST ({expected})")
|
||||
for key in (
|
||||
"KEYCLOAK_INTERNAL_URL", "KEYCLOAK_SMS_SERVICE_URL",
|
||||
"MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL",
|
||||
"BITRIX_LOCAL_APP_BASE_URL",
|
||||
):
|
||||
parsed = urlparse(env.get(key, ""))
|
||||
if parsed.scheme != "http" or "." in (parsed.hostname or ""):
|
||||
errors.append(f"{key}: ожидается http URL с Docker DNS service name")
|
||||
if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"):
|
||||
errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth")
|
||||
if env.get("MESSAGE_SAFETY_URL", "").rstrip("/") != "https://processing.internal:8443":
|
||||
errors.append(
|
||||
"MESSAGE_SAFETY_URL: ожидается remote TLS endpoint "
|
||||
"https://processing.internal:8443"
|
||||
)
|
||||
try:
|
||||
extra_host, extra_ip = env.get("MESSAGE_SAFETY_EXTRA_HOST", "").rsplit("=", 1)
|
||||
safety_host = urlparse(env.get("MESSAGE_SAFETY_URL", "")).hostname
|
||||
address = ipaddress.ip_address(extra_ip)
|
||||
if extra_host != safety_host or not address.is_private:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors.append(
|
||||
"MESSAGE_SAFETY_EXTRA_HOST: ожидается <Safety hostname>=<private VM2 IP>"
|
||||
)
|
||||
if env.get("MESSAGE_SAFETY_API_PREFIX") != "/internal/safety/v2":
|
||||
errors.append("MESSAGE_SAFETY_API_PREFIX: ожидается /internal/safety/v2")
|
||||
try:
|
||||
poll = int(env.get("MESSAGE_SAFETY_TASK_POLL_MAX_SEC", "0"))
|
||||
nginx = int(env.get("NGINX_MESSAGE_READ_TIMEOUT_SEC", "0"))
|
||||
ttl = int(env.get("MESSAGE_SAFETY_TASK_TTL_SEC", "0"))
|
||||
if nginx < poll + 30:
|
||||
errors.append("NGINX_MESSAGE_READ_TIMEOUT_SEC должен быть >= poll max + 30")
|
||||
if ttl <= poll + 60:
|
||||
errors.append("MESSAGE_SAFETY_TASK_TTL_SEC должен превышать poll max + recovery margin")
|
||||
except ValueError:
|
||||
errors.append("Safety/NGINX timeout values должны быть целыми числами")
|
||||
if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true":
|
||||
|
||||
@@ -14,6 +14,8 @@ RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
|
||||
COPY alembic.ini ./
|
||||
COPY migrations ./migrations
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/han-container-entrypoint \
|
||||
&& /bin/sh -n /usr/local/bin/han-container-entrypoint
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -11,6 +12,44 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class InfrastructureConfigTests(unittest.TestCase):
|
||||
def test_migrations_pass_one_top_level_statement_per_execute(self) -> None:
|
||||
migration_roots = (
|
||||
ROOT / "api-backend/alembic/versions",
|
||||
ROOT / "bitrix-local-app/alembic/versions",
|
||||
ROOT / "sms-service/migrations/versions",
|
||||
)
|
||||
dollar_quoted = re.compile(
|
||||
r"\$\$.*?\$\$|\$(?P<tag>[A-Za-z_][A-Za-z0-9_]*)\$.*?\$(?P=tag)\$",
|
||||
re.DOTALL,
|
||||
)
|
||||
single_quoted = re.compile(r"'(?:''|[^'])*'", re.DOTALL)
|
||||
|
||||
for migration_root in migration_roots:
|
||||
for migration in migration_root.glob("*.py"):
|
||||
tree = ast.parse(
|
||||
migration.read_text(encoding="utf-8"),
|
||||
filename=str(migration),
|
||||
)
|
||||
for call in ast.walk(tree):
|
||||
if not (
|
||||
isinstance(call, ast.Call)
|
||||
and isinstance(call.func, ast.Attribute)
|
||||
and call.func.attr == "execute"
|
||||
and call.args
|
||||
and isinstance(call.args[0], ast.Constant)
|
||||
and isinstance(call.args[0].value, str)
|
||||
):
|
||||
continue
|
||||
sql = dollar_quoted.sub("DOLLAR_QUOTED_BODY", call.args[0].value)
|
||||
sql = single_quoted.sub("STRING_LITERAL", sql)
|
||||
sql = re.sub(r"--[^\n]*|/\*.*?\*/", "", sql, flags=re.DOTALL)
|
||||
statements = [part for part in sql.split(";") if part.strip()]
|
||||
self.assertLessEqual(
|
||||
len(statements),
|
||||
1,
|
||||
f"{migration}:{call.lineno} passes multiple SQL commands to execute",
|
||||
)
|
||||
|
||||
def test_structlog_event_is_not_passed_twice(self) -> None:
|
||||
log_methods = {"debug", "info", "warning", "error", "exception", "critical"}
|
||||
for source_path in (ROOT / "api-backend/app").rglob("*.py"):
|
||||
@@ -55,7 +94,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8")
|
||||
self.assertEqual(jobs.count("networks: [backend, egress]"), 5)
|
||||
self.assertEqual(jobs.count("networks: [backend, egress]"), 4)
|
||||
|
||||
observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("networks: [observability, egress]", observability)
|
||||
@@ -79,6 +118,107 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertIn('NGINX_HTTP_PORT:-80}:80', nginx)
|
||||
self.assertIn('NGINX_HTTPS_PORT:-443}:443', nginx)
|
||||
|
||||
def test_production_compose_uses_only_required_digest_images(self) -> None:
|
||||
image_variables = (
|
||||
"API_BACKEND_IMAGE",
|
||||
"BITRIX_LOCAL_APP_IMAGE",
|
||||
"FRONTEND_STATIC_IMAGE",
|
||||
"KEYCLOAK_IMAGE",
|
||||
"NGINX_IMAGE",
|
||||
"OTEL_COLLECTOR_IMAGE",
|
||||
"OTEL_QUEUE_INIT_IMAGE",
|
||||
"REDIS_IMAGE",
|
||||
"SMS_SERVICE_IMAGE",
|
||||
"TOOLBOX_IMAGE",
|
||||
)
|
||||
compose_paths = (
|
||||
ROOT / "infra/compose/application.yml",
|
||||
ROOT / "nginx/docker-compose.yml",
|
||||
ROOT / "redis/docker-compose.yml",
|
||||
ROOT / "observability/docker-compose.yml",
|
||||
ROOT / "deployment/docker-compose.jobs.yml",
|
||||
)
|
||||
combined = "\n".join(path.read_text(encoding="utf-8") for path in compose_paths)
|
||||
self.assertNotIn("\n build:", combined)
|
||||
for variable in image_variables:
|
||||
self.assertIn(f"${{{variable}:?", combined, variable)
|
||||
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
for variable in image_variables:
|
||||
match = re.search(rf"^{variable}=(.+)$", example, re.MULTILINE)
|
||||
self.assertIsNotNone(match, variable)
|
||||
self.assertRegex(match.group(1), r"@sha256:[0-9a-f]{64}$")
|
||||
|
||||
preflight = (ROOT / "deployment/preflight.sh").read_text(encoding="utf-8")
|
||||
self.assertIn("config --images", preflight)
|
||||
self.assertIn("config --services", preflight)
|
||||
self.assertIn("only nginx may publish production host ports", preflight)
|
||||
self.assertIn("still contains the example image/digest", preflight)
|
||||
self.assertIn("socket.getaddrinfo", preflight)
|
||||
self.assertIn('ipaddress.ip_network("10.0.0.0/8")', preflight)
|
||||
self.assertIn("hostname must resolve only to private VPC addresses", preflight)
|
||||
self.assertIn('--runtime-manifest "$MANIFEST"', preflight)
|
||||
for variable in image_variables:
|
||||
self.assertIn(variable, preflight)
|
||||
|
||||
def test_legacy_compose_tls_is_removed(self) -> None:
|
||||
root = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
|
||||
combined = f"{root}\n{nginx}"
|
||||
self.assertNotIn("nginx-certs", combined)
|
||||
self.assertNotIn("nginx-acme", combined)
|
||||
self.assertNotIn("\n certbot:", nginx)
|
||||
self.assertIn("/var/lib/han-chat/public-tls:/run/tls:ro", nginx)
|
||||
self.assertIn("/var/lib/han-chat/acme:/var/www/certbot:ro", nginx)
|
||||
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
self.assertIn("NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem", example)
|
||||
self.assertIn("NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem", example)
|
||||
|
||||
def test_runtime_services_have_compose_hardening(self) -> None:
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
for anchor in ("x-api-runtime: &api-runtime", "x-sms-runtime: &sms-runtime"):
|
||||
start = application.index(anchor)
|
||||
end = application.index("\n\n", start)
|
||||
runtime = application[start:end]
|
||||
for setting in (
|
||||
"read_only: true",
|
||||
'cap_drop: ["ALL"]',
|
||||
'security_opt: ["no-new-privileges:true"]',
|
||||
"/tmp:size=",
|
||||
"pids_limit:",
|
||||
"mem_limit:",
|
||||
"cpus:",
|
||||
):
|
||||
self.assertIn(setting, runtime, f"{anchor}: {setting}")
|
||||
|
||||
bitrix = application[
|
||||
application.index("\n bitrix-local-app:") : application.index("\nsecrets:")
|
||||
]
|
||||
for setting in (
|
||||
"read_only: true",
|
||||
'cap_drop: ["ALL"]',
|
||||
'security_opt: ["no-new-privileges:true"]',
|
||||
"/tmp:size=",
|
||||
"pids_limit:",
|
||||
"mem_limit:",
|
||||
"cpus:",
|
||||
):
|
||||
self.assertIn(setting, bitrix)
|
||||
|
||||
keycloak = application[
|
||||
application.index("\n keycloak:") : application.index("\n sms-service:")
|
||||
]
|
||||
self.assertNotIn("\n read_only:", keycloak)
|
||||
self.assertIn('cap_drop: ["ALL"]', keycloak)
|
||||
self.assertIn("pids_limit:", keycloak)
|
||||
self.assertIn("read_only is intentionally omitted", keycloak)
|
||||
|
||||
jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8")
|
||||
self.assertEqual(jobs.count("<<: *python-job-runtime"), 4)
|
||||
for setting in ("read_only: true", 'cap_drop: ["ALL"]', "/tmp:size="):
|
||||
self.assertIn(setting, jobs)
|
||||
|
||||
def test_vm_and_nginx_security_defaults(self) -> None:
|
||||
setup = (ROOT / "deployment/scripts/setup-vm.sh").read_text(encoding="utf-8")
|
||||
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
@@ -87,7 +227,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
ROOT / "deployment/scripts/ssl-renew.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("LOCK_ACCOUNT_PASSWORDS=true", setup)
|
||||
self.assertIn('LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"', setup)
|
||||
self.assertIn('passwd --lock root', setup)
|
||||
self.assertIn('passwd --lock "$DEPLOY_USER"', setup)
|
||||
self.assertIn("X11Forwarding no", setup)
|
||||
@@ -163,7 +303,8 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertIn("save 900 1", config)
|
||||
self.assertIn("user default off", acl)
|
||||
self.assertIn("~han:api:*", acl)
|
||||
self.assertIn("~han:safety:*", acl)
|
||||
self.assertNotIn("message_safety", acl)
|
||||
self.assertNotIn("REDIS_SAFETY_PASSWORD", acl)
|
||||
|
||||
def test_otel_has_redaction_and_persistent_queue(self) -> None:
|
||||
config = (ROOT / "observability/otel-collector.yaml").read_text(encoding="utf-8")
|
||||
@@ -223,7 +364,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("\n frontend-static:", application)
|
||||
self.assertIn("frontend-test-site", application)
|
||||
self.assertIn("${FRONTEND_STATIC_IMAGE:?", application)
|
||||
self.assertIn("frontend-static:/output", application)
|
||||
for service, command in (
|
||||
("sms-worker:", "han-sms-worker"),
|
||||
@@ -246,6 +387,9 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
def test_expo_public_environment_uses_static_property_access(self) -> None:
|
||||
config = (ROOT / "frontend-test-site/src/config.ts").read_text(encoding="utf-8")
|
||||
auth = (ROOT / "frontend-test-site/src/auth.ts").read_text(encoding="utf-8")
|
||||
dockerfile = (ROOT / "frontend-test-site/Dockerfile").read_text(encoding="utf-8")
|
||||
realm = (ROOT / "keycloak/realm/han-chat-realm.json").read_text(encoding="utf-8")
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
callback = (ROOT / "frontend-test-site/app/auth/callback.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
@@ -254,6 +398,10 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertIn("process.env.EXPO_PUBLIC_AUTH_BASE_URL", config)
|
||||
self.assertIn('path: "auth/callback"', auth)
|
||||
self.assertIn('window.location.assign(url)', auth)
|
||||
self.assertIn('test -n "$EXPO_PUBLIC_AUTH_BASE_URL"', dockerfile)
|
||||
self.assertIn("${PUBLIC_WEB_URL}/auth/callback", realm)
|
||||
self.assertNotIn("chat.han0107.ru", realm)
|
||||
self.assertIn("PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?", application)
|
||||
self.assertIn("completionStarted.current", callback)
|
||||
self.assertTrue((ROOT / "frontend-test-site/app/auth/callback.tsx").is_file())
|
||||
|
||||
@@ -261,7 +409,6 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
for relative_path in (
|
||||
"api-backend/alembic/env.py",
|
||||
"bitrix-local-app/alembic/env.py",
|
||||
"bitrix-sync/alembic/env.py",
|
||||
"sms-service/migrations/env.py",
|
||||
):
|
||||
env_script = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
@@ -295,7 +442,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
dedup_fix,
|
||||
)
|
||||
self.assertIn('down_revision: str | None = "0009_chat_message_max"', dedup_fix)
|
||||
self.assertIn('EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"', main)
|
||||
self.assertIn('EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"', main)
|
||||
|
||||
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
|
||||
migration = (
|
||||
@@ -370,14 +517,41 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
|
||||
for service in (
|
||||
"api-backend",
|
||||
"sms-service",
|
||||
"message-safety",
|
||||
"bitrix-local-app",
|
||||
"bitrix-sync",
|
||||
"keycloak",
|
||||
"nginx",
|
||||
"redis",
|
||||
"sms-service",
|
||||
):
|
||||
dockerfile = (ROOT / service / "Dockerfile").read_text(encoding="utf-8")
|
||||
self.assertIn("han-container-entrypoint", dockerfile, service)
|
||||
self.assertIn("sed -i 's/\\r$//'", dockerfile, service)
|
||||
self.assertIn("/bin/sh -n", dockerfile, service)
|
||||
|
||||
frontend = (ROOT / "frontend-test-site/Dockerfile").read_text(encoding="utf-8")
|
||||
self.assertIn('ENTRYPOINT ["/bin/sh", "-ec"]', frontend)
|
||||
self.assertNotIn("entrypoint.sh", frontend)
|
||||
|
||||
def test_legacy_stubs_are_absent_and_safety_is_remote_tls(self) -> None:
|
||||
for stub in ("message-safety", "bitrix-sync"):
|
||||
self.assertFalse((ROOT / stub / "pyproject.toml").exists())
|
||||
self.assertFalse((ROOT / stub / "Dockerfile").exists())
|
||||
self.assertFalse((ROOT / stub / "app/main.py").exists())
|
||||
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8")
|
||||
nginx = (ROOT / "nginx/nginx.conf.template").read_text(encoding="utf-8")
|
||||
for config in (example, application, jobs):
|
||||
self.assertIn("https://processing.internal:8443", config)
|
||||
self.assertIn("/internal/safety/v2", config)
|
||||
self.assertIn(
|
||||
"MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4", example
|
||||
)
|
||||
self.assertIn("${MESSAGE_SAFETY_EXTRA_HOST:?", application)
|
||||
self.assertIn("MESSAGE_SAFETY_CA_HOST_PATH", example)
|
||||
self.assertNotIn("message-safety:", application)
|
||||
self.assertNotIn("bitrix-sync:", application)
|
||||
self.assertNotIn("bitrix_sync_upstream", nginx)
|
||||
|
||||
def test_env_validator_accepts_materialized_example(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
|
||||
@@ -20,16 +20,12 @@ def runtime_values() -> dict[str, str]:
|
||||
{
|
||||
"DATABASE_URL": f"postgresql+asyncpg://han_app:password@pg:5433/han_chat{pg_tail}",
|
||||
"BITRIX_DATABASE_URL": f"postgresql://bitrix:password@pg:5433/han_chat{pg_tail}",
|
||||
"BITRIX_SYNC_DATABASE_URL": f"postgresql://sync:password@pg:5433/han_chat{pg_tail}",
|
||||
"MESSAGE_SAFETY_DATABASE_URL": f"postgresql://safety:password@pg:5433/han_chat{pg_tail}",
|
||||
"SMS_DATABASE_URL": f"postgresql+asyncpg://sms:password@pg:5433/han_chat{pg_tail}",
|
||||
"KEYCLOAK_DB_URL": f"jdbc:postgresql://pg:5433/han_chat{pg_tail}",
|
||||
"REDIS_API_PASSWORD": "r" * 32,
|
||||
"REDIS_SAFETY_PASSWORD": "s" * 32,
|
||||
"REDIS_HEALTH_PASSWORD": "h" * 32,
|
||||
"REDIS_URL": f"redis://api_backend:{'r' * 32}@redis:6379/0",
|
||||
"REDIS_REALTIME_URL": f"redis://api_backend:{'r' * 32}@redis:6379/1",
|
||||
"MESSAGE_SAFETY_REDIS_URL": f"redis://message_safety:{'s' * 32}@redis:6379/2",
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "b" * 32,
|
||||
"BITRIX_INTERNAL_API_TOKEN": "b" * 32,
|
||||
"BITRIX_API_FORWARD_TOKEN": "f" * 32,
|
||||
@@ -121,6 +117,41 @@ class SecretHygieneTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn(secret_canary, result.stdout + result.stderr)
|
||||
|
||||
def test_validator_enforces_remote_safety_tls_contract(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
cases = (
|
||||
(
|
||||
"MESSAGE_SAFETY_URL=https://processing.internal:8443",
|
||||
"MESSAGE_SAFETY_URL=http://message-safety:8080",
|
||||
"remote TLS endpoint",
|
||||
),
|
||||
(
|
||||
"MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem",
|
||||
"MESSAGE_SAFETY_CA_HOST_PATH=",
|
||||
"MESSAGE_SAFETY_CA_HOST_PATH",
|
||||
),
|
||||
(
|
||||
"MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4",
|
||||
"MESSAGE_SAFETY_EXTRA_HOST=processing.internal=8.8.8.8",
|
||||
"MESSAGE_SAFETY_EXTRA_HOST",
|
||||
),
|
||||
(
|
||||
"MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2",
|
||||
"MESSAGE_SAFETY_API_PREFIX=/internal/safety/v1",
|
||||
"MESSAGE_SAFETY_API_PREFIX",
|
||||
),
|
||||
)
|
||||
for original, replacement, expected_error in cases:
|
||||
with self.subTest(replacement=replacement), tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / ".env"
|
||||
config.write_text(
|
||||
example.replace(original, replacement),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_validator(config)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(expected_error, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
- [`module-06-bitrix-local-app.md`](module-06-bitrix-local-app.md) — Bitrix24 Open Lines local app.
|
||||
- [`module-08-keycloak.md`](module-08-keycloak.md) — OIDC/OTP/SmartCaptcha.
|
||||
- [`module-09-observability-vm1.md`](module-09-observability-vm1.md) — telemetry ВМ1.
|
||||
- [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) — runbook ВМ1.
|
||||
- [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) — production
|
||||
deployment boundary и cutover ВМ1.
|
||||
- [`RUNBOOK.production.ru.md`](../codebase/backend/deployment/RUNBOOK.production.ru.md)
|
||||
— единственный исполняемый fresh production runbook ВМ1.
|
||||
- [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md) — целевой SMS-контур.
|
||||
|
||||
Message Safety, CRM sync, nginx/Redis/telemetry и runbook ВМ2 находятся в [`VM2_services/documentation`](../../VM2_services/documentation/README.md). После cutover ВМ1 не запускает `message-safety`, `bitrix-sync` или Redis DB2; вызов Safety идёт по private HTTPS `:8443`.
|
||||
|
||||
Legacy single-VM deployment guides и stub runbooks не являются production
|
||||
инструкциями. In-place перенос legacy VM в production запрещён.
|
||||
|
||||
@@ -1,213 +1,81 @@
|
||||
# module-10-vm1. Runbook развёртывания ВМ1 HAN Chat
|
||||
|
||||
> Статус: целевой runbook репозитория ВМ1.
|
||||
> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
> ВМ2 — [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md). Не переносить команды ВМ2 и не шарить Compose/secrets.
|
||||
|
||||
## 1. Границы
|
||||
|
||||
ВМ1 владеет edge nginx `80/443`, `api-backend`, Keycloak, SMS, `bitrix-local-app`, Redis DB0/DB1, Collector. После Safety cutover local Safety/Redis DB2 отсутствуют; `MESSAGE_SAFETY_URL` — private HTTPS ВМ2.
|
||||
|
||||
`<BACKEND_ROOT>` / `<BACKEND_REPO_URL>` — репозиторий ВМ1. Host ACME — `<PUBLIC_HOST>`.
|
||||
|
||||
## 2. Sizing ВМ1
|
||||
|
||||
Final sizing — D-TBD2. Disk после pull/build ≥30% free. `PUBLIC_DOCKER_PORTS=80,443`.
|
||||
|
||||
## 3. Hardening
|
||||
|
||||
По arch-10 §5 / arch-06. Пример:
|
||||
|
||||
```bash
|
||||
sudo DEPLOY_USER=deploy \
|
||||
DEPLOY_DIR=/opt/han-chat \
|
||||
SSH_PORT=<SSH_PORT> \
|
||||
SWAP_SIZE_GB=4 \
|
||||
PUBLIC_DOCKER_PORTS=80,443 \
|
||||
./deploy/setup-vm-han-chat.sh
|
||||
```
|
||||
|
||||
Gate 2 — arch-10. Break-glass вне VM.
|
||||
|
||||
## 4. Release layout и `.env` ВМ1
|
||||
|
||||
Checkout exact SHA в `/opt/han-chat/backend`. Структура: root Compose, `nginx`, `keycloak`, `redis`, `observability`, frontend artifact.
|
||||
|
||||
Обязательные группы секретов/config ВМ1:
|
||||
|
||||
- `APP_ENV`, release, log level;
|
||||
- private PG host/port/database и TLS CA; runtime DSN в secret backend;
|
||||
- Redis ACL URLs DB0/DB1 только в secret backend; после cutover DB2 нет;
|
||||
- public web/API/auth URLs;
|
||||
- Keycloak realm/audience/hostname/bootstrap/provider secrets;
|
||||
- SMS DB URL, парные Keycloak↔SMS tokens, Direct `TOKEN_1`, callback credentials;
|
||||
- paired service tokens arch-02;
|
||||
- Bitrix local app client/application/encryption secrets (не CRM sync webhook ВМ2);
|
||||
- S3 API credentials (не Safety read-only key ВМ2);
|
||||
- OTEL exporter secrets;
|
||||
- nginx/TLS/rate limits;
|
||||
- frontend public build values;
|
||||
- после cutover: `MESSAGE_SAFETY_URL=https://<private-vm2-name>:8443` и `MESSAGE_SAFETY_CA_HOST_PATH`.
|
||||
|
||||
Пары: `BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN`, `BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN`, `KEYCLOAK_SMS_SERVICE_TOKEN == SMS_SERVICE_TOKEN`.
|
||||
|
||||
`FRONTEND_DEV_PROXY_ENABLED=false`. Safety timeout согласован с nginx ВМ1. Validation — arch-10 §8; `validate-env` в этом репозитории.
|
||||
|
||||
## 5. Images и frontend
|
||||
|
||||
Pull или build без production secrets. Frontend:
|
||||
|
||||
```bash
|
||||
cd <FRONTEND_PROJECT_PATH>
|
||||
npm ci
|
||||
npm run test
|
||||
npx expo export --platform web
|
||||
```
|
||||
|
||||
Artifact в versioned `frontend-static`. Build env — только public URL/realm/client id. Secret scanner: нет service tokens/mock OTP/S3 keys.
|
||||
|
||||
Image/frontend gate: images по digest; static без secrets; nginx image с request-id/TLS; disk >30% free.
|
||||
|
||||
## 6. Root Compose ВМ1
|
||||
|
||||
Сервисы: edge `nginx`, `api-backend`, `keycloak`, `sms-service`, `sms-worker`, `bitrix-local-app`, Redis DB0/DB1, local `otel-collector`. После cutover — без `message-safety` и Redis DB2.
|
||||
|
||||
Networks: `public`, `backend`, `egress` (`sms-worker`; Keycloak входит только при `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` и только с destination allow-list SmartCaptcha API), `observability`. `sms-service` без egress при отдельном worker.
|
||||
|
||||
Volumes: `redis-data`, ACME, `frontend-static`, `otel-queue` + `otel-queue-init`. Единственные published mappings — nginx 80/443.
|
||||
|
||||
Compose gate — arch-10 применительно к этому Compose.
|
||||
|
||||
## 7. TLS ВМ1
|
||||
|
||||
Arch-10 §9 / arch-08, `-d <PUBLIC_HOST> --cert-name <PUBLIC_HOST>`. Private `8443` на ВМ1 нет.
|
||||
|
||||
## 8. Миграции и seed ВМ1
|
||||
|
||||
Preflight/upgrade:
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
docker compose run --rm api-backend alembic current
|
||||
docker compose run --rm bitrix-local-app alembic current
|
||||
# PITR marker, затем:
|
||||
docker compose run --rm api-backend alembic upgrade head
|
||||
docker compose run --rm bitrix-local-app alembic upgrade head
|
||||
```
|
||||
|
||||
Shared `han_app.sync_queue` мигрирует api-backend **до** sync cutover на ВМ2, но grants `bitrix_sync_user` — после обеих migrations (см. runbook ВМ2). Seed `app_settings` idempotent из этого репозитория.
|
||||
|
||||
SMS schema/migrations выполняет `sms-service` на ВМ1.
|
||||
|
||||
### Controlled rollout real SMS
|
||||
|
||||
1. seed `otp.phone.*`;
|
||||
2. schema/role `sms`, migrations, seed `sms_setting`/active `auth_otp`;
|
||||
3. test env с mock Direct;
|
||||
4. production Direct `TOKEN_1`, sender, template, callback credentials;
|
||||
5. записать `<IDGTL_STATIC_EGRESS_IP>` из `sms-worker`;
|
||||
6. deploy `sms-service`/worker и callback route nginx ВМ1, `KEYCLOAK_OTP_MOCK_ENABLED=true`;
|
||||
7. Keycloak expand migration/SPI;
|
||||
8. provider smoke на `<IDGTL_TEST_PHONE>`;
|
||||
9. только после evidence — `KEYCLOAK_OTP_MOCK_ENABLED=false`;
|
||||
10. проверить durable order, resend, limits.
|
||||
|
||||
Rollback SMS: вернуть Keycloak в mock; не удалять schema/journal. Production cutover запрещён при placeholder или нестабильном egress IP.
|
||||
|
||||
## 9. Keycloak bootstrap
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
docker compose up -d keycloak
|
||||
```
|
||||
|
||||
Bootstrap admin только на первый запуск, затем MFA named admin и удаление bootstrap. Realm: public client PKCE S256, issuer `https://<PUBLIC_HOST>/auth/realms/han-chat`, без `--import-realm` на живой production без diff. Keycloak gate — discovery/JWKS HTTPS, OTP fail-closed, settings bridge.
|
||||
|
||||
## 10. Ordered startup ВМ1
|
||||
|
||||
После готовности ВМ2 (arch-10 §10 шаги 1–3):
|
||||
|
||||
1. Redis ВМ1, `otel-queue-init`, Collector;
|
||||
2. API, SMS, Keycloak, local app;
|
||||
3. edge nginx последним; после readiness — `nginx -t -c /tmp/nginx.conf` и HUP.
|
||||
4. `MESSAGE_SAFETY_URL` переключается на ВМ2 **только** после cutover gates runbook ВМ2 и legacy gate §14.
|
||||
|
||||
Не использовать host ports для health curl. Expected: Redis `PONG`; Keycloak ready; Collector health; API core DB/Redis/JWKS/settings ready. S3/Safety/Open Lines могут быть `degraded` без снятия read API из readiness, но send path при недоступном Safety остаётся fail-closed. Local app до install может быть `portal_not_installed`.
|
||||
|
||||
## 11. Bitrix24 local app и Open Lines
|
||||
|
||||
Install/handler/placement URL на `https://<PUBLIC_HOST>/bitrix/...`. Canonical internal path `/internal/openlines/v1/*`, не prototype `/bitrix-internal/*`. Open Lines gate: connector line 8, outbound once, operator reply, duplicate callback безопасен.
|
||||
|
||||
CRM webhook robots **не** настраиваются на ВМ1.
|
||||
|
||||
## 12. Public smoke ВМ1
|
||||
|
||||
```bash
|
||||
curl -I http://<PUBLIC_HOST>/
|
||||
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
|
||||
curl -fsS https://<PUBLIC_HOST>/api/v1/public/content
|
||||
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
|
||||
curl -i https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
||||
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST>
|
||||
```
|
||||
|
||||
Expected: 308; public 200; discovery 200; internal 404; valid cert.
|
||||
|
||||
Далее: guest content; 401 без JWT; consent → OTP → PKCE; bootstrap без phone body; `ux_session_id`; silent refresh; logout; wrong OTP. Real SMS mode — по §8.
|
||||
|
||||
Safety E2E **со стороны caller** (правила stub до v2 cutover): `ф` → `422 message_blocked`; allow path; timeout 503/504 без duplicate. Статус Safety `stub` не заменяет production AV.
|
||||
|
||||
Files: presigned PUT quarantine, promote/deny, owner-only download, нет URL в logs. Realtime/ownership/idempotency/429 — module-01.
|
||||
|
||||
Сквозной first-send до Open Lines требует готовую ВМ2; system end-to-end gate закрывается по arch-10 после обоих runbook.
|
||||
|
||||
## 13. Observability ВМ1
|
||||
|
||||
[`module-09-observability-vm1.md`](module-09-observability-vm1.md) + arch-07. Сквозной `X-Request-ID` до Safety span — совместно с ВМ2.
|
||||
|
||||
## 14. Legacy gate перед cutover Safety
|
||||
|
||||
Перед `MESSAGE_SAFETY_URL` на ВМ2:
|
||||
|
||||
1. validator принимает только `https://<private-vm2-name>:8443`, требует CA path, запрещает Docker hostname и plaintext;
|
||||
2. internal CA root-owned; read-test UID `api-backend`, negative посторонний UID;
|
||||
3. local `message-safety`, Redis DB2 и local rules-version env удалены из Compose/validator;
|
||||
4. root-owned stack unit; `deploy` не в `docker`;
|
||||
5. `DOCKER-USER` counters через `conntrack --ctorigdstport` после Docker restart и reboot;
|
||||
6. images digest; rollback по compatible digests;
|
||||
7. ordered startup этого runbook, не legacy `docker compose up -d`;
|
||||
8. `han-secrets` и firewall oneshot явно перезапущены; TLS renew success — пустой stderr.
|
||||
|
||||
Ни старый single-VM guide, ни успешный stub Compose не являются evidence. Rollback caller — предыдущий immutable release ВМ1. Уже созданные v2 tasks не down-migrate.
|
||||
|
||||
## 15. Rollback, ops, incidents ВМ1
|
||||
|
||||
Rollback application-only: previous digests, без Alembic downgrade; при SMS incident — mock OTP, сохранить journal. Redis restore — clean instance, прогрев idempotency из PG ([`module-04-redis-vm1.md`](module-04-redis-vm1.md)). Keycloak restore — проверить issuer/JWKS/PKCE/OTP.
|
||||
|
||||
Routine: health, PG/TLS/disk/OTEL/Redis, Keycloak signing, Bitrix connector desired/observed.
|
||||
|
||||
Incident triage — arch-10 команды в `<BACKEND_ROOT>` ВМ1. Типовое: API 503 (DB/Redis/JWKS/Safety circuit); send timeout — не новый idempotency key; Redis loss — polling.
|
||||
|
||||
Потеря ВМ1: provision в той же VPC, restore secrets из vault, existing PG/S3, TLS `<PUBLIC_HOST>`, Bitrix local app/callbacks verify. Не пересоздавать ВМ2.
|
||||
|
||||
## 16. Definition of Done ВМ1
|
||||
|
||||
Дополнительно к arch-10 §12:
|
||||
|
||||
- Compose/nginx/Redis/Collector ВМ1 прошли профильные compose/nginx gates;
|
||||
- Keycloak realm/provider/PKCE/OTP готов;
|
||||
- SMS либо mock с accepted risk, либо real mode после §8;
|
||||
- Bitrix connector line 8 проверен;
|
||||
- auth/text/file/realtime E2E caller-side зелёный;
|
||||
- observability ВМ1 + redaction;
|
||||
- после cutover — legacy gate §14 закрыт.
|
||||
|
||||
## 17. TBD ВМ1
|
||||
|
||||
D-TBD1, D-TBD2 (VM1 sizing/SLO), D-TBD8 CLI, D-TBD9 Keycloak admin VPN.
|
||||
|
||||
## 18. Ссылки
|
||||
|
||||
- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
- ВМ2: [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md).
|
||||
- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md).
|
||||
# module-10-vm1. Production deployment ВМ1
|
||||
|
||||
> Канонический исполняемый fresh-VM runbook:
|
||||
> [`RUNBOOK.production.ru.md`](../codebase/backend/deployment/RUNBOOK.production.ru.md).
|
||||
> Общие security/deployment контракты:
|
||||
> [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md)
|
||||
> и [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
> ВМ2:
|
||||
> [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md).
|
||||
|
||||
## Граница документа
|
||||
|
||||
Этот модуль фиксирует только связь спецификаций и cutover boundary. Команды
|
||||
bootstrap, release activation, secrets, TLS, migrations, systemd, smoke,
|
||||
reboot, rollback и DR находятся только в production runbook.
|
||||
|
||||
Production ВМ1 разворачивается на fresh Ubuntu 24.04. In-place hardening
|
||||
legacy single-VM/stub инсталляции запрещён. Legacy
|
||||
`deployment/DEPLOYMENT_GUIDE.ru.md`, `RUNBOOK.ru.md` и `RUNBOOK.md` являются
|
||||
только указателями.
|
||||
|
||||
## Host deployment contract
|
||||
|
||||
- разные Ed25519 keys для `deploy` и break-glass `admin`, не совпадающие с
|
||||
root bootstrap key;
|
||||
- `deploy` не входит в `docker`, `sudo`, `lxd`, `adm`,
|
||||
`systemd-journal`; пишет только в `/var/lib/han-deploy/incoming`;
|
||||
- active release, Compose, `.env`, scripts/helpers, units, secret mappings,
|
||||
credentials и sudoers принадлежат root;
|
||||
- activation проверяет approved SHA-256, project root, absolute/`..` paths,
|
||||
symlink/hardlink и executable modes;
|
||||
- lifecycle — `han-secrets@production.service` и
|
||||
`han-stack@production.service` через exact sudoers;
|
||||
- public TLS копируется root hook из `/etc/letsencrypt` в
|
||||
`/var/lib/han-chat/public-tls`; nginx не получает дерево Certbot;
|
||||
- published Docker ports фильтруются по original destination
|
||||
`conntrack --ctorigdstport 80/443`, включая restart/reboot gates.
|
||||
|
||||
## Cutover ВМ1 → ВМ2
|
||||
|
||||
ВМ1 владеет public guest/API/auth/SMS/Bitrix local-app ingress `80/443`.
|
||||
ВМ2 владеет Message Safety v2 на private HTTPS `:8443` и CRM sync public
|
||||
webhooks собственного host. Compose, IAM principal и secret bundle между VM не
|
||||
разделяются.
|
||||
|
||||
До переключения caller на ВМ2:
|
||||
|
||||
1. ВМ2 закрыла TLS SAN/CA, PG migrations, Safety capabilities, S3 read-only,
|
||||
performance, egress и rollback gates своего runbook.
|
||||
2. ВМ1 использует
|
||||
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443` и root-owned
|
||||
`MESSAGE_SAFETY_CA_HOST_PATH`.
|
||||
3. Internal CA читается фактическим UID API и не читается посторонним UID.
|
||||
4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops.
|
||||
5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback
|
||||
отсутствуют в production Compose/validator.
|
||||
6. Caller smoke подтверждает allow/deny/pending/timeout, file flow и
|
||||
fail-closed поведение; status `stub` не принимается.
|
||||
7. После cutover фиксируются release/image digests, schema/config versions,
|
||||
firewall counters, traces и rollback approval.
|
||||
|
||||
Rollback caller переключает только на предыдущий schema-compatible immutable
|
||||
release ВМ1. Уже созданные tasks ВМ2 не down-migrate. Потеря ВМ2 не разрешает
|
||||
fail-open или возврат local stub.
|
||||
|
||||
Bitrix sync cutover выполняется отдельно по runbook ВМ2 после expand migrations
|
||||
`han_app` на ВМ1. CRM webhook ВМ2 не маршрутизируется через nginx ВМ1.
|
||||
|
||||
## Связанные модули
|
||||
|
||||
- API caller и Safety v2:
|
||||
[`module-01-api-backend.md`](module-01-api-backend.md).
|
||||
- Public edge:
|
||||
[`module-03-nginx-vm1.md`](module-03-nginx-vm1.md).
|
||||
- Redis DB0/DB1 и удаление legacy DB2:
|
||||
[`module-04-redis-vm1.md`](module-04-redis-vm1.md).
|
||||
- Keycloak: [`module-08-keycloak.md`](module-08-keycloak.md).
|
||||
- Observability:
|
||||
[`module-09-observability-vm1.md`](module-09-observability-vm1.md).
|
||||
- SMS cutover:
|
||||
[`module-11-idgtl-sms.md`](module-11-idgtl-sms.md).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,14 +1,22 @@
|
||||
# VM2 Processing deployment runbook
|
||||
|
||||
This directory is the independent VM2 foundation. It does not deploy VM1 or
|
||||
`codebase/backend`. All commands below are operator commands; repository
|
||||
creation does not execute them.
|
||||
This directory is the independent VM2 foundation in the `VM2_services`
|
||||
repository. It does not deploy VM1 or `VM1_app/codebase/backend`. All commands
|
||||
below are operator commands; repository creation does not execute them.
|
||||
|
||||
Repository root: `HAN_chat_specification/VM2_services`. Compose and deployment
|
||||
artifacts live under `VM2_services/codebase/services/`. Local operator commands
|
||||
assume the current directory is `VM2_services` unless stated otherwise.
|
||||
|
||||
The full step-by-step procedure with gates and copy-paste commands is in
|
||||
[`RUNBOOK.ru.md`](RUNBOOK.ru.md).
|
||||
|
||||
## Production blockers before first start
|
||||
|
||||
1. Replace every `.env` placeholder with reviewed non-secret values. Keep
|
||||
`BITRIX_SYNC_ENABLED=false` until migrations, grants, portal fields, robot
|
||||
contracts and cutover are signed off.
|
||||
contracts and cutover are signed off. Build and push application images to
|
||||
the registry first (blocker 2).
|
||||
2. Fill every `*_IMAGE` variable with a reviewed registry digest. Root Compose
|
||||
rejects missing image references; mutable tags are not production evidence.
|
||||
3. Install production files as `root:root`; `deploy` must not be in `docker`
|
||||
@@ -38,61 +46,156 @@ creation does not execute them.
|
||||
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
|
||||
Registry/package access exists only during controlled maintenance windows.
|
||||
|
||||
## Install
|
||||
## Who runs what
|
||||
|
||||
- Bootstrap a fresh Ubuntu 24.04 VM as root with
|
||||
`deployment/scripts/setup-vm.sh`, supplying `VM1_PRIVATE_CIDRS`, optional
|
||||
private/VPN `OPS_CIDRS`, and separate Ed25519 public-key files for deploy and
|
||||
break-glass admin. SSH is publicly reachable but key-only and protected by
|
||||
fail2ban; the CIDR variables apply only to private port `8443`. The script
|
||||
installs host packages/firewalls and roles but never starts Compose. Set a
|
||||
separate admin sudo password; verify deploy login, admin login and admin sudo
|
||||
in independent sessions before rerunning with `HARDEN_SSH=true`.
|
||||
Root/deploy/admin key reuse is rejected.
|
||||
- Checkout an immutable release under `/opt/han-chat/services`.
|
||||
- Copy `.env.example` to root-owned mode `0600` `.env`.
|
||||
- Install `secrets_loader.py` and `han-secrets` under
|
||||
`/usr/local/lib/han-secrets-vm2/`, root-owned and non-writable.
|
||||
- Install `han-compose` as `/usr/local/sbin/han-vm2-compose`.
|
||||
- Install `han-secrets-vm2.service` and `han-processing.service` under
|
||||
`/etc/systemd/system/`.
|
||||
- Install `han-message-safety-mode` as root-owned `0755` and the sudoers
|
||||
template as `/etc/sudoers.d/deploy-message-safety-mode` mode `0440`; validate
|
||||
with `visudo -cf`. Create the dedicated host group `han-message-safety` with
|
||||
GID `10001`. Before the first Compose validation, create
|
||||
`/etc/han-chat/message-safety-mode.env` as
|
||||
`root:han-message-safety 0640` with all three flags `false` (or invoke the
|
||||
helper's `standard` transition after the fixed launcher is installed).
|
||||
- Install loader config using the exact `APP_ENV` suffix. With the committed
|
||||
example (`APP_ENV=production-like`) the path is
|
||||
`/etc/han/secrets/vm2-production-like.selectel.json` mode `0600`. For
|
||||
controlled no-provider recovery use an explicit `file`
|
||||
config pointing to a root-only `0700` directory containing exactly one file
|
||||
per configured key. Selectel failure never falls back automatically.
|
||||
- **Operator workstation:** builds the release archive and transfers it to VM2.
|
||||
Local examples use PowerShell from `VM2_services`.
|
||||
- **`root` on VM2:** host bootstrap, verified release activation, root-owned
|
||||
files, `.env`, secret mapping, credentials, TLS/allow-lists, migrations and
|
||||
first start.
|
||||
- **`deploy` on VM2:** accepts releases only in `/var/lib/han-deploy/incoming`,
|
||||
checks status/logs and runs installed fixed systemd operations through exact
|
||||
sudo rules. `deploy` must not run `docker`, edit `/opt/han-chat/services` or
|
||||
join the `docker` group.
|
||||
- **`admin` on VM2:** personal break-glass role with a separate SSH key and
|
||||
local sudo password. Not used for routine deploy; not in `docker`/`lxd`; every
|
||||
login and sudo call is an incident operation.
|
||||
|
||||
## Preflight and startup
|
||||
## Prerequisites (before §1)
|
||||
|
||||
Run `deployment/preflight.sh` first. Then, through the approved root units:
|
||||
This runbook covers operations **on an already provisioned VM2**. Prepare outside
|
||||
Compose first:
|
||||
|
||||
1. synchronize secrets; any missing/oversized/invalid secret blocks startup;
|
||||
2. validate resolved Compose without storing its output;
|
||||
3. run the two `ops` migration jobs and create/activate the reviewed initial
|
||||
Message Safety config before starting either runtime;
|
||||
4. validate nginx config and both certificate chains;
|
||||
5. start Redis/Collector, ClamAV, application API/workers, then nginx;
|
||||
6. verify that only nginx publishes `80`, `443`, and private-bound `8443`;
|
||||
7. verify all non-exact public paths return `404`, HTTP webhook paths return
|
||||
`426` without redirect/query reflection, wrong methods fail, and wrong
|
||||
source CIDRs are rejected before upstream;
|
||||
8. verify private Safety check/task/status and sync status only from approved
|
||||
callers; verify public `/internal/*` is `404`;
|
||||
9. canary telemetry with a fake token marker and prove query, form body,
|
||||
Authorization, DSN, S3 key and object key are absent from logs/traces.
|
||||
1. **Selectel infrastructure** — VPC/subnet, SG (public `80/443/22`; private
|
||||
`8443` only; default-deny egress after bootstrap), sizing (4 vCPU / 8 GB RAM /
|
||||
80 GB SSD — see
|
||||
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)),
|
||||
public and private VM2 IPs, DNS A record for `PROCESSING_PUBLIC_HOST`.
|
||||
2. **Managed PostgreSQL** — schemas/roles for `message_safety` and
|
||||
`bitrix_sync`, separate migration/runtime DSNs; see
|
||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
|
||||
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
|
||||
otel-collector).
|
||||
4. **Selectel Secrets Manager** — populate all remote names from
|
||||
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
|
||||
`REDIS_SAFETY_ACL`, internal TLS PEM for `8443`). Dedicated VM2 IAM principal
|
||||
with read-only access to those names only.
|
||||
5. **S3 quarantine bucket** and SigNoz OTLP endpoint — non-secret values in
|
||||
`.env`.
|
||||
6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM
|
||||
stored in Secrets Manager, not in the release tree.
|
||||
|
||||
Section order: §1–§5 → Gates 1–9 → §7 (post-acceptance). Run
|
||||
`systemctl enable` and `systemctl start han-processing.service` **only after
|
||||
Gate 5 succeeds**.
|
||||
|
||||
## 1. Bootstrap a fresh VM2
|
||||
|
||||
From `VM2_services` on the operator workstation, copy the setup script:
|
||||
|
||||
```powershell
|
||||
scp -i C:\Users\MI\.ssh\hansel `
|
||||
.\codebase\services\deployment\scripts\setup-vm.sh `
|
||||
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
|
||||
```
|
||||
|
||||
Bootstrap as root with separate Ed25519 deploy/admin keys, `VM1_PRIVATE_CIDRS`,
|
||||
optional `OPS_CIDRS`, admin sudo password, deploy/admin login verification, then
|
||||
rerun with `HARDEN_SSH=true`. Full commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §1.
|
||||
|
||||
## 2. Release transfer under `deploy`
|
||||
|
||||
From `VM2_services`:
|
||||
|
||||
```powershell
|
||||
$Release = "<VERSION_OR_GIT_SHA>"
|
||||
tar --exclude=services/.env `
|
||||
--exclude='services/**/__pycache__' `
|
||||
--exclude='services/**/.pytest_cache' `
|
||||
--exclude='services/**/.ruff_cache' `
|
||||
-czf "vm2-services-$Release.tar.gz" -C .\codebase services
|
||||
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
|
||||
scp -i C:\Users\MI\.ssh\hansel "vm2-services-$Release.tar.gz" `
|
||||
deploy@<VM2_PUBLIC_IP>:/var/lib/han-deploy/incoming/
|
||||
```
|
||||
|
||||
`deploy` verifies SHA-256 and archive listing only; it must not unpack into
|
||||
production. Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §2–§3.
|
||||
|
||||
## 3. Activation and root-owned install
|
||||
|
||||
`root` verifies the archive, extracts to staging, rsyncs into
|
||||
`/opt/han-chat/services`, reruns `setup-vm.sh` to install helpers/units. Details:
|
||||
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §3.
|
||||
|
||||
## 4. Non-secret config and Selectel
|
||||
|
||||
Copy `.env.example` → `.env`, install loader config as
|
||||
`/etc/han/secrets/vm2-<APP_ENV>.selectel.json`, encrypt Selectel service-user
|
||||
password with `systemd-creds`, edit nginx allow-lists. Details:
|
||||
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4.
|
||||
|
||||
## 5. PostgreSQL CA and initial public TLS
|
||||
|
||||
Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
|
||||
`PROCESSING_PUBLIC_HOST`, stage public cert/key for nginx. Details:
|
||||
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §5.
|
||||
|
||||
## 6. Gates 1–9: preflight, migrations and first start
|
||||
|
||||
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
|
||||
`han-processing.service` until Gate 5 completes successfully.
|
||||
|
||||
| Gate | Purpose |
|
||||
| --- | --- |
|
||||
| 1 | Secrets materialized via `han-secrets-vm2.service` |
|
||||
| 2 | Static preflight and resolved Compose (`@sha256:` images) |
|
||||
| 3 | Migrations, initial Message Safety config create/activate |
|
||||
| 4 | nginx `-t` with TLS and upstream placeholders |
|
||||
| 5 | Ordered `compose up`; then `systemctl enable/start han-processing` |
|
||||
| 6 | Host ports, public/private certificate chains, certbot renewal |
|
||||
| 7 | Public routing smoke (308/404/426/405) |
|
||||
| 8 | Private Safety/sync API from VM1/ops only |
|
||||
| 9 | Fake-token canary — no secrets in logs/traces |
|
||||
|
||||
After Gate 5, `deploy` may run:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart han-secrets-vm2.service
|
||||
sudo systemctl restart han-processing.service
|
||||
sudo systemctl --no-pager status han-processing.service
|
||||
sudo journalctl --no-pager -u han-processing.service
|
||||
```
|
||||
|
||||
Full gate commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §6.
|
||||
|
||||
Do not open webhook traffic while `bitrix-sync` is disabled. A disabled or
|
||||
failed receiver must return retryable `503`/closed routing, never successful
|
||||
`2xx ignored`.
|
||||
|
||||
## 7. After Gate 9 — post-acceptance
|
||||
|
||||
Gate 9 completes VM2 technical acceptance but **does not** authorize Message
|
||||
Safety cutover on VM1 or Bitrix sync enablement.
|
||||
|
||||
1. Verify autostart: `docker`, `han-chat-vm2-docker-firewall`, `han-secrets-vm2`,
|
||||
`han-processing`, `certbot.timer`.
|
||||
2. Reboot-gate: `systemctl reboot`, then repeat autostart checks and Gates 6–8
|
||||
briefly.
|
||||
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
|
||||
timer, unit journals — without secret values.
|
||||
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV
|
||||
signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API).
|
||||
5. Proceed to controlled Message Safety cutover on VM1 — see
|
||||
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
|
||||
6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix
|
||||
public allow-list remains `deny all;` until
|
||||
[`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md)
|
||||
cutover gates are signed off.
|
||||
|
||||
Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7.
|
||||
|
||||
## Failure policy
|
||||
|
||||
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# Ранбук развёртывания Processing на VM2
|
||||
|
||||
Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не
|
||||
затрагивает `codebase/backend`. Все команды ниже — операторские; создание
|
||||
репозитория их не выполняет.
|
||||
Этот каталог — независимая основа VM2 в репозитории `VM2_services`. Он не
|
||||
разворачивает VM1 и не затрагивает `VM1_app/codebase/backend`. Все команды
|
||||
ниже — операторские; создание репозитория их не выполняет.
|
||||
|
||||
Корень репозитория ВМ2: `HAN_chat_specification/VM2_services`. Compose и
|
||||
deployment-артефакты: `VM2_services/codebase/services/`. Локальные команды
|
||||
ниже предполагают текущий каталог `VM2_services`, если не указано иное.
|
||||
|
||||
## Блокеры production перед первым запуском
|
||||
|
||||
@@ -64,6 +68,33 @@
|
||||
не входит в `docker`/`lxd`; каждый вход и sudo-вызов считается инцидентной
|
||||
операцией.
|
||||
|
||||
## Предварительные условия (до §1)
|
||||
|
||||
Этот runbook описывает операции **на уже созданной VM2**. До bootstrap
|
||||
подготовьте вне Compose:
|
||||
|
||||
1. **Инфраструктура Selectel** — VPC/subnet, SG (`80/443/22` public;
|
||||
`8443` только private; egress default-deny после bootstrap), sizing
|
||||
(4 vCPU / 8 ГБ RAM / 80 ГБ SSD — см.
|
||||
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)),
|
||||
public и private IP VM2, DNS A-запись `PROCESSING_PUBLIC_HOST`.
|
||||
2. **Managed PostgreSQL** — schemas/roles для `message_safety` и
|
||||
`bitrix_sync`, отдельные migration/runtime DSN; см.
|
||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
|
||||
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
|
||||
clamav, otel-collector).
|
||||
4. **Selectel Secrets Manager** — заполнить все remote names из
|
||||
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
|
||||
`REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal
|
||||
VM2 с read-only доступом только к этим именам.
|
||||
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
|
||||
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
|
||||
PEM хранится в Secrets Manager, не в каталоге релиза.
|
||||
|
||||
Порядок разделов §1–§5 → Gates 1–9 → §7 (post-acceptance). `systemctl enable`
|
||||
и `systemctl start han-processing.service` — **только после успешного Gate 5**.
|
||||
|
||||
## 1. Bootstrap свежей VM2
|
||||
|
||||
На локальном компьютере один раз создайте **два разных** ключа. Закрытые части
|
||||
@@ -85,8 +116,9 @@ break-glass оператору и храниться отдельно от deplo
|
||||
каталог:
|
||||
|
||||
```powershell
|
||||
# текущий каталог: ...\HAN_chat_specification\VM2_services
|
||||
scp -i C:\Users\MI\.ssh\hansel `
|
||||
.\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh `
|
||||
.\codebase\services\deployment\scripts\setup-vm.sh `
|
||||
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
|
||||
scp -i C:\Users\MI\.ssh\hansel `
|
||||
C:\Users\MI\.ssh\han_vm2_deploy.pub `
|
||||
@@ -162,7 +194,7 @@ sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
|
||||
|
||||
## 2. Передача релиза под `deploy`
|
||||
|
||||
На локальном компьютере из каталога `HAN_chat_specification`:
|
||||
На локальном компьютере из каталога `VM2_services`:
|
||||
|
||||
```powershell
|
||||
$Release = "<VERSION_OR_GIT_SHA>"
|
||||
@@ -390,71 +422,16 @@ Nginx с primary GID `11001` получает только подготовле
|
||||
`/etc/letsencrypt` остаётся доступен только root/Certbot. Не копируйте private
|
||||
key в каталог релиза и не делайте его world-readable.
|
||||
|
||||
## 6. Preflight, миграции и первый запуск под `root`
|
||||
## 6. Gates 1–9: preflight, миграции и первый запуск под `root`
|
||||
|
||||
Сначала синхронизируйте секреты. Затем выполните статический preflight:
|
||||
|
||||
```sh
|
||||
systemctl start han-secrets-vm2.service
|
||||
/opt/han-chat/services/deployment/preflight.sh
|
||||
/usr/local/sbin/han-vm2-compose config --quiet
|
||||
```
|
||||
|
||||
До runtime выполните миграции отдельными DB roles и активируйте начальный
|
||||
Message Safety config:
|
||||
|
||||
Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД
|
||||
выдаёт Bitrix migration-role временный read-only доступ к legacy mapping:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
GRANT SELECT ON TABLE han_app.entity_external_mapping
|
||||
TO <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
```
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
|
||||
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||||
--entrypoint message-safety-config message-safety-migrate \
|
||||
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||||
--entrypoint message-safety-config message-safety-migrate \
|
||||
activate --version 1 --approved-by '<APPROVER>'
|
||||
```
|
||||
|
||||
После успешного `bitrix-sync-migrate` администратор БД отзывает временные
|
||||
права. Право `USAGE` отзывайте только если оно не требуется этой роли для
|
||||
других согласованных операций:
|
||||
|
||||
```sql
|
||||
REVOKE SELECT ON TABLE han_app.entity_external_mapping
|
||||
FROM <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
```
|
||||
|
||||
Первый запуск и enable выполняет `root` только после прохождения gates:
|
||||
(внутри gate5)
|
||||
|
||||
```sh
|
||||
systemctl enable han-secrets-vm2.service han-processing.service
|
||||
systemctl start han-processing.service
|
||||
systemctl --no-pager status han-processing.service
|
||||
journalctl --no-pager -u han-processing.service
|
||||
```
|
||||
|
||||
Дальнейшие штатные операции может выполнить `deploy`:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart han-secrets-vm2.service
|
||||
sudo systemctl restart han-processing.service
|
||||
sudo systemctl --no-pager status han-processing.service
|
||||
sudo journalctl --no-pager -u han-processing.service
|
||||
```
|
||||
Выполняйте gates **строго по порядку** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9.
|
||||
Не включайте `han-processing.service` и не делайте `systemctl enable`, пока
|
||||
Gate 5 не завершился успешно.
|
||||
|
||||
Установка/редактирование unit, Compose, `.env`, secret mapping, credential,
|
||||
TLS, allow-list и запуск migration jobs остаются операциями `root`.
|
||||
После Gate 5 штатный restart/status/logs для `deploy` — см. блок
|
||||
«Дальнейшие штатные операции» ниже.
|
||||
|
||||
### Gate 1 — секреты материализованы
|
||||
|
||||
@@ -490,7 +467,39 @@ deployment/preflight.sh
|
||||
|
||||
### Gate 3 — миграции и активный Message Safety config
|
||||
|
||||
Команды миграций из предыдущего раздела выполняются под `root`. После них:
|
||||
Под `root`. Перед первым `bitrix-sync-migrate` владелец `han_app` или
|
||||
администратор БД выдаёт Bitrix migration-role временный read-only доступ к
|
||||
legacy mapping:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
GRANT SELECT ON TABLE han_app.entity_external_mapping
|
||||
TO <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
```
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
|
||||
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||||
--entrypoint message-safety-config message-safety-migrate \
|
||||
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||||
--entrypoint message-safety-config message-safety-migrate \
|
||||
activate --version 1 --approved-by '<APPROVER>'
|
||||
```
|
||||
|
||||
После успешного `bitrix-sync-migrate` администратор БД отзывает временные
|
||||
права. Право `USAGE` отзывайте только если оно не требуется этой роли для
|
||||
других согласованных операций:
|
||||
|
||||
```sql
|
||||
REVOKE SELECT ON TABLE han_app.entity_external_mapping
|
||||
FROM <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
```
|
||||
|
||||
Проверьте head revision и активную config version:
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||||
@@ -623,6 +632,16 @@ Message Safety; проверьте её без вывода секретов:
|
||||
systemctl enable han-secrets-vm2.service han-processing.service
|
||||
systemctl start han-processing.service
|
||||
systemctl --no-pager status han-processing.service
|
||||
journalctl --no-pager -u han-processing.service
|
||||
```
|
||||
|
||||
Дальнейшие штатные операции может выполнить `deploy`:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart han-secrets-vm2.service
|
||||
sudo systemctl restart han-processing.service
|
||||
sudo systemctl --no-pager status han-processing.service
|
||||
sudo journalctl --no-pager -u han-processing.service
|
||||
```
|
||||
|
||||
### Gate 6 — host ports и сертификаты
|
||||
@@ -811,47 +830,10 @@ unset CANARY
|
||||
упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию,
|
||||
никогда успешный `2xx ignored`.
|
||||
|
||||
## Политика отказов
|
||||
## 7. После Gate 9 — post-acceptance
|
||||
|
||||
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
|
||||
контент.
|
||||
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
|
||||
capability; ошибка сканирования никогда не превращается в allow.
|
||||
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
|
||||
истины.
|
||||
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
|
||||
менять вердикты.
|
||||
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
|
||||
запускает `docker compose down -v`.
|
||||
|
||||
## Аварийный MOCK
|
||||
|
||||
Разрешены только эти пять sudo-команд:
|
||||
|
||||
```text
|
||||
han-message-safety-mode standard
|
||||
han-message-safety-mode mock --text-free true --file-free true
|
||||
han-message-safety-mode mock --text-free true --file-free false
|
||||
han-message-safety-mode mock --text-free false --file-free true
|
||||
han-message-safety-mode mock --text-free false --file-free false
|
||||
```
|
||||
|
||||
Хелпер атомарно пишет только
|
||||
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
|
||||
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
|
||||
таймаута: держите high-severity alert активным до явного `standard`, затем
|
||||
проверьте нормальные text/link/file capabilities и EICAR-canary.
|
||||
|
||||
## Известные исключения по образам
|
||||
|
||||
Образы ClamAV могут потребовать корректировок UID/path после валидации
|
||||
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
|
||||
задокументируйте минимальные writable пути для сигнатур/runtime и
|
||||
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
|
||||
получает только `freshclam`; `clamd` — нет.
|
||||
|
||||
|
||||
# Gate 9 завершает техническую приёмку VM2, но не означает production cutover сервисов.
|
||||
Gate 9 завершает техническую приёмку VM2, но **не** означает production
|
||||
cutover Message Safety на VM1 и **не** разрешает включать Bitrix sync.
|
||||
|
||||
Дальнейший порядок:
|
||||
|
||||
@@ -912,6 +894,237 @@ BITRIX_SYNC_ENABLED=false
|
||||
BITRIX_SYNC_MODE=disabled
|
||||
```
|
||||
|
||||
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после выполнения gates `module-07`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal.
|
||||
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после
|
||||
выполнения gates [`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md):
|
||||
поля портала, webhooks, migrations, grants, backfill/watermark и rollback
|
||||
rehearsal.
|
||||
|
||||
Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix.
|
||||
После успешного Gate 9 выполните reboot-gate (п. 2 выше), зафиксируйте
|
||||
приёмку (п. 3), затем выполните отдельный controlled cutover ниже. Gate 9 сам
|
||||
по себе не разрешает переключать caller.
|
||||
|
||||
### Controlled cutover Message Safety на VM1
|
||||
|
||||
Cutover выполняется в согласованное окно совместно с Safety Service,
|
||||
Rule Pack, Security, Product и Operations. До начала зафиксируйте текущий и
|
||||
предыдущий schema-compatible immutable release VM1, ответственного за rollback
|
||||
и stop conditions. `bitrix-sync` в это окно не включается.
|
||||
|
||||
#### 1. Предварительные условия
|
||||
|
||||
До изменения VM1 должны быть выполнены все условия:
|
||||
|
||||
- reboot-gate VM2 и повторные Gates 6–8 успешны;
|
||||
- Safety работает в `standard`, не в `mock`;
|
||||
- active config и rules bundle утверждены, Safety v2 migrations находятся на
|
||||
ожидаемом head;
|
||||
- `text`, `links`, `files`, `worker` имеют состояние `ready`;
|
||||
- VM2 имеет только read-only доступ к versioned S3 quarantine objects;
|
||||
- performance, egress negative tests и redaction Gate 9 закрыты;
|
||||
- private DNS VM2 резолвится с VM1 только в private VPC address;
|
||||
- security group/firewall разрешает `8443` от VM1 и approved ops, но не из
|
||||
интернета;
|
||||
- на VM1 подготовлен release без local `message-safety`, Redis DB2, local rules
|
||||
env и stub fallback;
|
||||
- один и тот же production service token подготовлен в раздельных secret
|
||||
bundles VM1 и VM2; значение токена не печатается и не копируется в
|
||||
`/etc/han/vm1.env`.
|
||||
|
||||
На VM1 под `root` повторите проверку TLS и readiness:
|
||||
|
||||
```sh
|
||||
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
|
||||
-servername <VM2_PRIVATE_DNS_NAME> \
|
||||
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
|
||||
-CAfile /etc/han/ca/vm2-internal-ca.pem \
|
||||
-verify_return_error </dev/null
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
--cacert /etc/han/ca/vm2-internal-ca.pem \
|
||||
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
|
||||
```
|
||||
|
||||
В TLS-выводе ожидается успешная проверка chain/SAN. В status ожидаются
|
||||
`processing_mode=standard`, непустой `config_version` и
|
||||
`text|links|files|worker=ready`. `stub`, `mock`, `not_ready` или недоступная
|
||||
capability — stop condition.
|
||||
|
||||
#### 2. Прямой pre-cutover smoke с VM1
|
||||
|
||||
Прямой smoke доказывает route, CA и paired token до перезапуска caller:
|
||||
|
||||
```sh
|
||||
SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
|
||||
MESSAGE_ID="$(uuidgen)"
|
||||
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
|
||||
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
|
||||
cacert = "/etc/han/ca/vm2-internal-ca.pem"
|
||||
request = "POST"
|
||||
header = "X-Service-Token: ${SAFETY_TOKEN}"
|
||||
header = "X-Request-ID: ${MESSAGE_ID}"
|
||||
header = "Content-Type: application/json"
|
||||
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM1 to VM2 cutover canary\",\"attachment\":null}"
|
||||
EOF
|
||||
unset SAFETY_TOKEN MESSAGE_ID
|
||||
```
|
||||
|
||||
Ожидается `HTTP 200`, `verdict=allow`, `processing_mode=standard` и непустые
|
||||
`config_version`/`rules_version`. Отдельный запрос с фейковым token marker
|
||||
должен вернуть `401`; production token для negative test не изменяйте.
|
||||
|
||||
До переключения также выполните через API VM2:
|
||||
|
||||
- deny smoke на утверждённом безопасном corpus case — ожидается `403`;
|
||||
- повтор запроса с тем же `message_id` и тем же body — тот же sticky result;
|
||||
- тот же `message_id` с другим body — `409`;
|
||||
- file smoke только с реальным versioned quarantine object:
|
||||
`202 + Location + Retry-After`, затем terminal `200` или `403`;
|
||||
- lease/fencing smoke с остановкой/возвратом worker по утверждённому test case:
|
||||
task не исполняется двумя владельцами и сохраняет sticky terminal result.
|
||||
|
||||
Не используйте выдуманные S3 key/version/ETag и не загружайте EICAR в
|
||||
production bucket вне согласованного security test.
|
||||
|
||||
#### 3. Переключение caller на VM1
|
||||
|
||||
На VM1 установите и проверьте internal CA по процедуре
|
||||
[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md)
|
||||
§6. В `/etc/han/vm1.env` должны быть:
|
||||
|
||||
```dotenv
|
||||
MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS_NAME>:8443
|
||||
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
|
||||
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
|
||||
```
|
||||
|
||||
В root-owned Selectel secret mapping VM1 переменная
|
||||
`MESSAGE_SAFETY_SERVICE_TOKEN` должна ссылаться на согласованный remote secret.
|
||||
После review config и mapping:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/current/backend
|
||||
./scripts/validate-env /etc/han/vm1.env
|
||||
|
||||
systemctl restart han-secrets@production.service
|
||||
systemctl is-active han-secrets@production.service
|
||||
journalctl --no-pager -u han-secrets@production.service
|
||||
|
||||
./deployment/preflight.sh
|
||||
/usr/local/sbin/han-vm1-compose config --quiet
|
||||
/usr/local/sbin/han-vm1-compose config --services
|
||||
/usr/local/sbin/han-vm1-compose config --images
|
||||
```
|
||||
|
||||
В `config --services` не должно быть local `message-safety`, `bitrix-sync`,
|
||||
Redis DB2 или test stub. Не сохраняйте resolved Compose в файл и не выводите
|
||||
secret values. Если preflight успешен, переключите caller:
|
||||
|
||||
```sh
|
||||
systemctl restart han-stack@production.service
|
||||
systemctl is-active han-stack@production.service
|
||||
/usr/local/sbin/han-vm1-compose ps
|
||||
```
|
||||
|
||||
#### 4. Post-cutover проверки через caller
|
||||
|
||||
Проверки выполняются через public API/штатный UI VM1, а не только прямым curl к
|
||||
VM2:
|
||||
|
||||
1. benign text проходит Safety и отправляется ровно один раз;
|
||||
2. утверждённый deny text не отправляется в Bitrix и возвращает клиенту generic
|
||||
`message_blocked` без internal `rule_id`;
|
||||
3. сообщение с безопасной HTTP/HTTPS-ссылкой проходит, запрещённая
|
||||
private/link-local/metadata ссылка блокируется без HTTP fetch этой ссылки;
|
||||
4. реальный quarantine file проходит `pending` и terminal result, после allow
|
||||
продвигается штатным caller flow; deny-файл не продвигается;
|
||||
5. повтор client/idempotency request не создаёт второе сообщение или второй
|
||||
Safety task;
|
||||
6. correlation request ID виден в VM1, VM2 и SigNoz без текста сообщения,
|
||||
token, object key и других секретов.
|
||||
|
||||
Затем согласованным способом кратко сделайте VM2 недоступной **только для
|
||||
test request** и подтвердите fail-closed: VM1 не отправляет и не продвигает
|
||||
контент, возвращает контролируемую retryable ошибку, а local/stub fallback не
|
||||
активируется. Сразу восстановите доступ и повторите benign smoke. Не имитируйте
|
||||
отказ остановкой всей VM2, если на ней уже есть другой production traffic.
|
||||
|
||||
#### 5. Rollback rehearsal
|
||||
|
||||
Rollback caller — только на заранее проверенный предыдущий
|
||||
schema-compatible immutable release VM1 по
|
||||
[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md)
|
||||
§12. Он не меняет nginx VM2, не понижает schema и не удаляет уже созданные
|
||||
Safety tasks:
|
||||
|
||||
```sh
|
||||
PREVIOUS='<PREVIOUS_COMPATIBLE_GIT_SHA>'
|
||||
test -d "/opt/han-chat/releases/${PREVIOUS}/backend"
|
||||
ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new
|
||||
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
|
||||
systemctl daemon-reload
|
||||
systemctl restart han-secrets@production.service
|
||||
/opt/han-chat/current/backend/deployment/preflight.sh
|
||||
systemctl restart han-stack@production.service
|
||||
```
|
||||
|
||||
После rehearsal повторите public smoke, верните approved current release тем же
|
||||
атомарным способом и снова повторите smoke. Потеря VM2 не разрешает fail-open,
|
||||
переключение на local stub или обход Safety. При несовместимой migration
|
||||
rollback запрещён: используйте forward fix либо заранее согласованный recovery
|
||||
plan.
|
||||
|
||||
#### 6. Фиксация cutover
|
||||
|
||||
Сохраните без secret values:
|
||||
|
||||
- VM1/VM2 release и image digests, Safety schema head;
|
||||
- private certificate fingerprint/expiry, `config_version` и `rules_version`;
|
||||
- результаты allow/deny/pending/file/timeout/fail-closed/idempotency checks;
|
||||
- firewall counters и доказательство недоступности `8443` с запрещённого
|
||||
source;
|
||||
- traces/log search и результат redaction canary;
|
||||
- фактическое время переключения и rollback rehearsal;
|
||||
- approvals Safety Service, Rule Pack, Security, Product и Operations.
|
||||
|
||||
Только после успешного выполнения всех пунктов Message Safety cutover считается
|
||||
завершённым. Cutover `bitrix-sync` остаётся отдельным изменением по
|
||||
[`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md).
|
||||
|
||||
## Политика отказов
|
||||
|
||||
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
|
||||
контент.
|
||||
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
|
||||
capability; ошибка сканирования никогда не превращается в allow.
|
||||
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
|
||||
истины.
|
||||
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
|
||||
менять вердикты.
|
||||
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
|
||||
запускает `docker compose down -v`.
|
||||
|
||||
## Аварийный MOCK
|
||||
|
||||
Разрешены только эти пять sudo-команд:
|
||||
|
||||
```text
|
||||
han-message-safety-mode standard
|
||||
han-message-safety-mode mock --text-free true --file-free true
|
||||
han-message-safety-mode mock --text-free true --file-free false
|
||||
han-message-safety-mode mock --text-free false --file-free true
|
||||
han-message-safety-mode mock --text-free false --file-free false
|
||||
```
|
||||
|
||||
Хелпер атомарно пишет только
|
||||
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
|
||||
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
|
||||
таймаута: держите high-severity alert активным до явного `standard`, затем
|
||||
проверьте нормальные text/link/file capabilities и EICAR-canary.
|
||||
|
||||
## Известные исключения по образам
|
||||
|
||||
Образы ClamAV могут потребовать корректировок UID/path после валидации
|
||||
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
|
||||
задокументируйте минимальные writable пути для сигнатур/runtime и
|
||||
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
|
||||
получает только `freshclam`; `clamd` — нет.
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
#!/usr/bin/env bash
|
||||
# Создание OS-пользователя tunnel на VM2 для SSH local port forwarding к PostgreSQL/PgBouncer.
|
||||
#
|
||||
# Контракт: arch-06 — только local forwarding, PermitOpen allow-list, без sudo/shell.
|
||||
# На VM2 по умолчанию AllowTcpForwarding=no; исключение только для tunnel.
|
||||
#
|
||||
# Использование (на VM2 под root):
|
||||
# TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
|
||||
# PG_HOST=192.168.0.210 \
|
||||
# PG_PORT=6433 \
|
||||
# bash setup-tunnel-user.sh
|
||||
#
|
||||
# Опционально:
|
||||
# TUNNEL_USER=tunnel
|
||||
# TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24" # ограничить источники SSH
|
||||
# TUNNEL_ALLOW_ANY_SOURCE=true # осознанно разрешить ключу вход с любого адреса
|
||||
# EXTRA_PERMIT_OPEN="192.168.0.210:5433" # доп. endpoint (direct PG)
|
||||
# SSHD_MAIN_CONF=/etc/ssh/sshd_config.d/00-han-chat-vm2.conf
|
||||
#
|
||||
# По умолчанию TUNNEL_SOURCE_CIDRS обязателен. Разрешение любого источника
|
||||
# требует явного TUNNEL_ALLOW_ANY_SOURCE=true и должно компенсироваться SG/firewall.
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
TUNNEL_USER="${TUNNEL_USER:-tunnel}"
|
||||
TUNNEL_AUTHORIZED_KEY_FILE="${TUNNEL_AUTHORIZED_KEY_FILE:-}"
|
||||
TUNNEL_SOURCE_CIDRS="${TUNNEL_SOURCE_CIDRS:-}"
|
||||
TUNNEL_ALLOW_ANY_SOURCE="${TUNNEL_ALLOW_ANY_SOURCE:-false}"
|
||||
PG_HOST="${PG_HOST:-192.168.0.210}"
|
||||
PG_PORT="${PG_PORT:-6433}"
|
||||
EXTRA_PERMIT_OPEN="${EXTRA_PERMIT_OPEN:-}"
|
||||
SSHD_MAIN_CONF="${SSHD_MAIN_CONF:-/etc/ssh/sshd_config.d/00-han-chat-vm2.conf}"
|
||||
SSHD_TUNNEL_CONF="/etc/ssh/sshd_config.d/50-han-tunnel-user.conf"
|
||||
AUTHORIZED_KEYS_DIR="/etc/ssh/authorized_keys"
|
||||
AUTHORIZED_KEYS_FILE="${AUTHORIZED_KEYS_DIR}/${TUNNEL_USER}"
|
||||
|
||||
declare -a PERMIT_OPENS=()
|
||||
|
||||
log() { printf '[setup-tunnel] %s\n' "$*"; }
|
||||
step() { printf '\n=== %s ===\n' "$*"; }
|
||||
die() { printf '[setup-tunnel] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_root() {
|
||||
[[ "$(id -u)" -eq 0 ]] || die "Скрипт нужно запускать от root"
|
||||
}
|
||||
|
||||
require_commands() {
|
||||
local command
|
||||
for command in awk chmod cp getent grep id install mktemp mv passwd python3 rm \
|
||||
ssh-keygen sshd stat systemctl useradd usermod; do
|
||||
command -v "$command" >/dev/null 2>&1 || die "Не найдена обязательная команда: ${command}"
|
||||
done
|
||||
}
|
||||
|
||||
validate_tunnel_user() {
|
||||
[[ "$TUNNEL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|
||||
|| die "Некорректное имя пользователя: ${TUNNEL_USER}"
|
||||
}
|
||||
|
||||
validate_source_policy() {
|
||||
[[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" || "$TUNNEL_ALLOW_ANY_SOURCE" == "false" ]] \
|
||||
|| die "TUNNEL_ALLOW_ANY_SOURCE должен быть true или false"
|
||||
|
||||
if [[ -n "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then
|
||||
die "Задайте либо TUNNEL_SOURCE_CIDRS, либо TUNNEL_ALLOW_ANY_SOURCE=true, но не оба"
|
||||
fi
|
||||
if [[ -z "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" != "true" ]]; then
|
||||
die "TUNNEL_SOURCE_CIDRS обязателен; для осознанного отказа задайте TUNNEL_ALLOW_ANY_SOURCE=true"
|
||||
fi
|
||||
if [[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then
|
||||
log "ВНИМАНИЕ: источник SSH-ключа не ограничен; доступ должен ограничиваться SG/firewall"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_cidrs() {
|
||||
local cidrs=$1
|
||||
local err normalized
|
||||
[[ -n "$cidrs" ]] || return 0
|
||||
[[ "$cidrs" =~ ^[0-9A-Fa-f:./,[:space:]]+$ ]] \
|
||||
|| die "TUNNEL_SOURCE_CIDRS содержит недопустимые символы"
|
||||
|
||||
if ! err=$(python3 - "$cidrs" 2>&1 <<'PY'
|
||||
import ipaddress
|
||||
import sys
|
||||
|
||||
parts = [raw.strip() for raw in sys.argv[1].split(",")]
|
||||
if not parts or any(not part for part in parts):
|
||||
print("TUNNEL_SOURCE_CIDRS содержит пустой элемент", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
for cidr in parts:
|
||||
try:
|
||||
ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError as exc:
|
||||
print(f"Некорректный CIDR: {cidr} ({exc})", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
PY
|
||||
); then
|
||||
die "${err:-TUNNEL_SOURCE_CIDRS содержит некорректный CIDR}"
|
||||
fi
|
||||
|
||||
normalized=$(python3 - "$cidrs" <<'PY'
|
||||
import ipaddress
|
||||
import sys
|
||||
print(",".join(
|
||||
str(ipaddress.ip_network(part.strip(), strict=False))
|
||||
for part in sys.argv[1].split(",")
|
||||
))
|
||||
PY
|
||||
)
|
||||
TUNNEL_SOURCE_CIDRS=$normalized
|
||||
}
|
||||
|
||||
validate_host_port() {
|
||||
local host=$1 port=$2 label=$3
|
||||
[[ -n "$host" ]] || die "${label}: host пустой"
|
||||
[[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]] \
|
||||
|| die "${label}: host содержит недопустимые символы"
|
||||
[[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) \
|
||||
|| die "${label}: некорректный port ${port}"
|
||||
}
|
||||
|
||||
parse_host_port_item() {
|
||||
local item=$1 label=$2
|
||||
local host port
|
||||
|
||||
item="${item//[[:space:]]/}"
|
||||
[[ -n "$item" ]] || die "${label}: пустой host:port"
|
||||
[[ "$item" == *:* ]] || die "${label}: ожидается host:port, получено ${item}"
|
||||
|
||||
host="${item%%:*}"
|
||||
port="${item##*:}"
|
||||
[[ "$host" != "$port" ]] || die "${label}: отсутствует port в ${item}"
|
||||
validate_host_port "$host" "$port" "$label"
|
||||
printf '%s:%s\n' "$host" "$port"
|
||||
}
|
||||
|
||||
collect_permit_opens() {
|
||||
local item host_port existing duplicate
|
||||
local -a extra=()
|
||||
|
||||
validate_host_port "$PG_HOST" "$PG_PORT" "PG"
|
||||
PERMIT_OPENS=("${PG_HOST}:${PG_PORT}")
|
||||
|
||||
if [[ -n "$EXTRA_PERMIT_OPEN" ]]; then
|
||||
IFS=',' read -r -a extra <<< "$EXTRA_PERMIT_OPEN"
|
||||
((${#extra[@]} > 0)) || die "EXTRA_PERMIT_OPEN не содержит endpoint"
|
||||
for item in "${extra[@]}"; do
|
||||
host_port=$(parse_host_port_item "$item" "EXTRA_PERMIT_OPEN")
|
||||
duplicate=false
|
||||
for existing in "${PERMIT_OPENS[@]}"; do
|
||||
[[ "$existing" == "$host_port" ]] && duplicate=true
|
||||
done
|
||||
if [[ "$duplicate" == "false" ]]; then
|
||||
PERMIT_OPENS+=("$host_port")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
validate_key_file() {
|
||||
local file=$1
|
||||
local lines
|
||||
|
||||
[[ -n "$file" && -f "$file" && -s "$file" ]] \
|
||||
|| die "Не найден TUNNEL_AUTHORIZED_KEY_FILE: ${file:-<empty>}"
|
||||
ssh-keygen -l -f "$file" >/dev/null 2>&1 || die "Некорректный SSH public key: ${file}"
|
||||
|
||||
mapfile -t lines < <(grep -Ev '^[[:space:]]*(#|$)' "$file")
|
||||
((${#lines[@]} == 1)) || die "Файл ключа должен содержать ровно одну строку без комментариев: ${file}"
|
||||
[[ "${lines[0]}" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]] \
|
||||
|| die "Разрешён только чистый ssh-ed25519 без SSH-опций: ${file}"
|
||||
}
|
||||
|
||||
read_ed25519_public_key() {
|
||||
local file=$1
|
||||
local line key_type key_data ignored_comment
|
||||
|
||||
line=$(grep -Ev '^[[:space:]]*(#|$)' "$file")
|
||||
IFS=$' \t' read -r key_type key_data ignored_comment <<< "$line"
|
||||
|
||||
[[ "$key_type" == "ssh-ed25519" ]] || die "Разрешён только ssh-ed25519"
|
||||
[[ "$key_data" =~ ^[A-Za-z0-9+/=]+$ ]] || die "Некорректное тело публичного ключа"
|
||||
|
||||
ED25519_KEY_TYPE=$key_type
|
||||
ED25519_KEY_DATA=$key_data
|
||||
# Входной комментарий не влияет на авторизацию и намеренно не переносится.
|
||||
ED25519_KEY_COMMENT=han-vm2-db-tunnel
|
||||
}
|
||||
|
||||
build_authorized_keys_line() {
|
||||
local key_file=$1
|
||||
local from_prefix="" options host_port
|
||||
|
||||
read_ed25519_public_key "$key_file"
|
||||
options='restrict,port-forwarding'
|
||||
|
||||
if [[ -n "$TUNNEL_SOURCE_CIDRS" ]]; then
|
||||
from_prefix="from=\"${TUNNEL_SOURCE_CIDRS}\","
|
||||
fi
|
||||
|
||||
for host_port in "${PERMIT_OPENS[@]}"; do
|
||||
options+=",permitopen=\"${host_port}\""
|
||||
done
|
||||
|
||||
if [[ -n "$from_prefix" ]]; then
|
||||
printf '%s%s %s %s %s\n' \
|
||||
"$from_prefix" "$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT"
|
||||
else
|
||||
printf '%s %s %s %s\n' \
|
||||
"$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT"
|
||||
fi
|
||||
}
|
||||
|
||||
create_or_update_user() {
|
||||
step "Создание пользователя ${TUNNEL_USER}"
|
||||
local actual_home actual_shell primary_group home_owner
|
||||
|
||||
if id "$TUNNEL_USER" >/dev/null 2>&1; then
|
||||
IFS=: read -r _ _ _ _ _ actual_home actual_shell < <(getent passwd "$TUNNEL_USER")
|
||||
primary_group=$(id -gn "$TUNNEL_USER")
|
||||
[[ "$actual_home" == "/home/${TUNNEL_USER}" ]] \
|
||||
|| die "Существующий ${TUNNEL_USER} имеет неожиданный home: ${actual_home}"
|
||||
[[ "$primary_group" == "$TUNNEL_USER" ]] \
|
||||
|| die "Существующий ${TUNNEL_USER} имеет неожиданную primary group: ${primary_group}"
|
||||
[[ "$actual_shell" == "/usr/sbin/nologin" ]] \
|
||||
|| die "Отказываюсь переоборудовать существующего ${TUNNEL_USER} с shell ${actual_shell}"
|
||||
[[ -d "$actual_home" && ! -L "$actual_home" ]] \
|
||||
|| die "Home ${actual_home} отсутствует, не является каталогом или является симлинком"
|
||||
home_owner=$(stat -c '%U' "$actual_home")
|
||||
[[ "$home_owner" == "$TUNNEL_USER" ]] \
|
||||
|| die "Home ${actual_home} принадлежит ${home_owner}, ожидался ${TUNNEL_USER}"
|
||||
log "Найден ранее созданный выделенный пользователь ${TUNNEL_USER}"
|
||||
else
|
||||
getent group "$TUNNEL_USER" >/dev/null 2>&1 \
|
||||
&& die "Группа ${TUNNEL_USER} уже существует без одноимённого пользователя"
|
||||
useradd --create-home --user-group --home-dir "/home/${TUNNEL_USER}" \
|
||||
--shell /usr/sbin/nologin "$TUNNEL_USER"
|
||||
fi
|
||||
|
||||
passwd -l "$TUNNEL_USER" >/dev/null
|
||||
usermod --lock --groups "" "$TUNNEL_USER"
|
||||
[[ "$(id -nG "$TUNNEL_USER")" == "$TUNNEL_USER" ]] \
|
||||
|| die "Не удалось удалить дополнительные группы пользователя ${TUNNEL_USER}"
|
||||
}
|
||||
|
||||
build_authorized_keys_candidate() {
|
||||
local target=$1
|
||||
build_authorized_keys_line "$TUNNEL_AUTHORIZED_KEY_FILE" > "$target"
|
||||
# sshd читает AuthorizedKeysFile с правами целевого пользователя.
|
||||
# Файл остаётся root-owned и поэтому 0644 не позволяет tunnel изменить ключ.
|
||||
chmod 0644 "$target"
|
||||
}
|
||||
|
||||
build_sshd_tunnel_candidate() {
|
||||
local target=$1 host_port
|
||||
|
||||
{
|
||||
printf 'Match User %s\n' "$TUNNEL_USER"
|
||||
printf ' AuthorizedKeysFile %s/%%u\n' "$AUTHORIZED_KEYS_DIR"
|
||||
echo ' AllowTcpForwarding local'
|
||||
echo ' AllowStreamLocalForwarding no'
|
||||
echo ' GatewayPorts no'
|
||||
echo ' AllowAgentForwarding no'
|
||||
echo ' X11Forwarding no'
|
||||
echo ' PermitTTY no'
|
||||
echo ' PermitTunnel no'
|
||||
echo ' PermitUserRC no'
|
||||
echo ' MaxSessions 0'
|
||||
printf ' PermitOpen'
|
||||
for host_port in "${PERMIT_OPENS[@]}"; do
|
||||
printf ' %s' "$host_port"
|
||||
done
|
||||
printf '\nMatch all\n'
|
||||
} > "$target"
|
||||
chmod 0644 "$target"
|
||||
}
|
||||
|
||||
build_main_config_candidate() {
|
||||
local target=$1 allow_line user
|
||||
local -a allow_users=()
|
||||
[[ -f "$SSHD_MAIN_CONF" ]] || die "Не найден ${SSHD_MAIN_CONF}"
|
||||
[[ ! -L "$SSHD_MAIN_CONF" ]] || die "${SSHD_MAIN_CONF} не должен быть симлинком"
|
||||
! grep -Eq '^[[:space:]]*Match([[:space:]]|$)' "$SSHD_MAIN_CONF" \
|
||||
|| die "${SSHD_MAIN_CONF} должен содержать только глобальные настройки, без Match"
|
||||
|
||||
python3 - "$SSHD_MAIN_CONF" "$target" "$TUNNEL_USER" <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = Path(sys.argv[1])
|
||||
target = Path(sys.argv[2])
|
||||
user = sys.argv[3]
|
||||
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
matches = [
|
||||
index for index, line in enumerate(lines)
|
||||
if re.match(r"^\s*AllowUsers\s+", line)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
print(
|
||||
f"{source}: ожидалась ровно одна глобальная директива AllowUsers, найдено {len(matches)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
index = matches[0]
|
||||
tokens = lines[index].split()
|
||||
if user not in tokens[1:]:
|
||||
newline = "\n" if lines[index].endswith("\n") else ""
|
||||
lines[index] = f"AllowUsers {' '.join(tokens[1:] + [user])}{newline}"
|
||||
Path(target).write_text("".join(lines), encoding="utf-8")
|
||||
PY
|
||||
|
||||
allow_line=$(awk '/^[[:space:]]*AllowUsers[[:space:]]+/ {print; exit}' "$target")
|
||||
IFS=' ' read -r -a allow_users <<< "$allow_line"
|
||||
((${#allow_users[@]} >= 2)) || die "AllowUsers не содержит пользователей"
|
||||
for user in "${allow_users[@]:1}"; do
|
||||
[[ "$user" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|
||||
|| die "Неподдерживаемый шаблон AllowUsers: ${user}; перечислите реальные локальные учётные записи"
|
||||
getent passwd "$user" >/dev/null \
|
||||
|| die "AllowUsers содержит несуществующего пользователя: ${user}"
|
||||
done
|
||||
chmod 0644 "$target"
|
||||
}
|
||||
|
||||
verify_endpoint_from_host() {
|
||||
local endpoint=$1
|
||||
local host="${endpoint%%:*}"
|
||||
local port="${endpoint##*:}"
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z -w 5 "$host" "$port" \
|
||||
&& log "TCP ${endpoint} доступен" \
|
||||
|| die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall"
|
||||
elif command -v timeout >/dev/null 2>&1; then
|
||||
timeout 5 bash -c 'echo > "/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null \
|
||||
&& log "TCP ${endpoint} доступен" \
|
||||
|| die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall"
|
||||
else
|
||||
log "nc и timeout не установлены; пропускаю TCP-проверку"
|
||||
fi
|
||||
}
|
||||
|
||||
verify_network_from_host() {
|
||||
local endpoint
|
||||
step "Проверка сетевой доступности разрешённых endpoint с VM2"
|
||||
for endpoint in "${PERMIT_OPENS[@]}"; do
|
||||
verify_endpoint_from_host "$endpoint"
|
||||
done
|
||||
}
|
||||
|
||||
atomic_install() {
|
||||
local source=$1 target=$2 mode=$3
|
||||
local temporary
|
||||
temporary=$(mktemp "${target}.tmp.XXXXXX") || return 1
|
||||
if ! install -o root -g root -m "$mode" "$source" "$temporary"; then
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
if ! mv -Tf -- "$temporary" "$target"; then
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_file() {
|
||||
local target=$1 name=$2 backup_dir=$3
|
||||
if [[ -e "$target" || -L "$target" ]]; then
|
||||
cp -a -- "$target" "${backup_dir}/${name}"
|
||||
: > "${backup_dir}/${name}.existed"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_file() {
|
||||
local target=$1 name=$2 backup_dir=$3
|
||||
rm -f -- "$target"
|
||||
if [[ -f "${backup_dir}/${name}.existed" ]]; then
|
||||
cp -a -- "${backup_dir}/${name}" "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
rollback_transaction() {
|
||||
local backup_dir=$1
|
||||
log "Откат SSH-конфигурации и authorized_keys"
|
||||
restore_file "$SSHD_MAIN_CONF" main.conf "$backup_dir"
|
||||
restore_file "$SSHD_TUNNEL_CONF" tunnel.conf "$backup_dir"
|
||||
restore_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$backup_dir"
|
||||
}
|
||||
|
||||
assert_effective_value() {
|
||||
local config=$1 key=$2 expected=$3 actual
|
||||
actual=$(awk -v key="$key" '$1 == key {$1=""; sub(/^ /, ""); print; exit}' <<< "$config")
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
log "Эффективный sshd ${key}=${actual:-<empty>}, ожидалось ${expected}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_effective_sshd_config() {
|
||||
local effective endpoint permit_open authorized_keys_value allow_user_found=false
|
||||
local -a effective_permit_opens=()
|
||||
effective=$(sshd -T -C "user=${TUNNEL_USER},host=localhost,addr=127.0.0.1") \
|
||||
|| return 1
|
||||
|
||||
assert_effective_value "$effective" allowtcpforwarding local || return 1
|
||||
assert_effective_value "$effective" allowstreamlocalforwarding no || return 1
|
||||
assert_effective_value "$effective" allowagentforwarding no || return 1
|
||||
assert_effective_value "$effective" x11forwarding no || return 1
|
||||
assert_effective_value "$effective" gatewayports no || return 1
|
||||
assert_effective_value "$effective" permittty no || return 1
|
||||
assert_effective_value "$effective" permittunnel no || return 1
|
||||
assert_effective_value "$effective" permituserrc no || return 1
|
||||
assert_effective_value "$effective" maxsessions 0 || return 1
|
||||
assert_effective_value "$effective" passwordauthentication no || return 1
|
||||
assert_effective_value "$effective" kbdinteractiveauthentication no || return 1
|
||||
assert_effective_value "$effective" pubkeyauthentication yes || return 1
|
||||
authorized_keys_value=$(awk '
|
||||
$1 == "authorizedkeysfile" {$1=""; sub(/^ /, ""); print; exit}
|
||||
' <<< "$effective")
|
||||
if [[ "$authorized_keys_value" != "${AUTHORIZED_KEYS_DIR}/%u" \
|
||||
&& "$authorized_keys_value" != "$AUTHORIZED_KEYS_FILE" ]]; then
|
||||
log "Эффективный AuthorizedKeysFile=${authorized_keys_value:-<empty>}, ожидался root-owned путь"
|
||||
return 1
|
||||
fi
|
||||
|
||||
permit_open=$(awk '
|
||||
$1 == "permitopen" {
|
||||
for (i = 2; i <= NF; i++) values = values " " $i
|
||||
}
|
||||
END {sub(/^ /, "", values); print values}
|
||||
' <<< "$effective")
|
||||
IFS=' ' read -r -a effective_permit_opens <<< "$permit_open"
|
||||
if ((${#effective_permit_opens[@]} != ${#PERMIT_OPENS[@]})); then
|
||||
log "Эффективный PermitOpen содержит неожиданный набор: ${permit_open:-<empty>}"
|
||||
return 1
|
||||
fi
|
||||
for endpoint in "${PERMIT_OPENS[@]}"; do
|
||||
if [[ " ${permit_open} " != *" ${endpoint} "* ]]; then
|
||||
log "Эффективный PermitOpen не содержит ${endpoint}"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
if awk -v user="$TUNNEL_USER" '
|
||||
$1 == "allowusers" {
|
||||
for (i = 2; i <= NF; i++) if ($i == user) found = 1
|
||||
}
|
||||
END {exit !found}
|
||||
' <<< "$effective"; then
|
||||
allow_user_found=true
|
||||
fi
|
||||
if [[ "$allow_user_found" != "true" ]]; then
|
||||
log "Эффективный AllowUsers не содержит ${TUNNEL_USER}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_ssh_service() {
|
||||
if systemctl cat ssh.service >/dev/null 2>&1; then
|
||||
printf 'ssh\n'
|
||||
elif systemctl cat sshd.service >/dev/null 2>&1; then
|
||||
printf 'sshd\n'
|
||||
else
|
||||
die "Не найден systemd-сервис ssh.service или sshd.service"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_authorized_keys_dir() {
|
||||
local owner mode
|
||||
if [[ -e "$AUTHORIZED_KEYS_DIR" || -L "$AUTHORIZED_KEYS_DIR" ]]; then
|
||||
[[ -d "$AUTHORIZED_KEYS_DIR" && ! -L "$AUTHORIZED_KEYS_DIR" ]] \
|
||||
|| die "${AUTHORIZED_KEYS_DIR} должен быть обычным каталогом, не симлинком"
|
||||
owner=$(stat -c '%U:%G' "$AUTHORIZED_KEYS_DIR")
|
||||
[[ "$owner" == "root:root" ]] \
|
||||
|| die "${AUTHORIZED_KEYS_DIR} принадлежит ${owner}, ожидался root:root"
|
||||
mode=$(stat -c '%a' "$AUTHORIZED_KEYS_DIR")
|
||||
(( (8#$mode & 8#022) == 0 )) \
|
||||
|| die "${AUTHORIZED_KEYS_DIR} доступен для записи группе или остальным: mode ${mode}"
|
||||
else
|
||||
install -d -o root -g root -m 0755 "$AUTHORIZED_KEYS_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
install_and_reload_sshd() {
|
||||
step "Атомарная установка, проверка и reload sshd"
|
||||
local work_dir=$1 main_candidate=$2 tunnel_candidate=$3 key_candidate=$4
|
||||
local ssh_service
|
||||
ssh_service=$(detect_ssh_service)
|
||||
|
||||
prepare_authorized_keys_dir
|
||||
backup_file "$SSHD_MAIN_CONF" main.conf "$work_dir"
|
||||
backup_file "$SSHD_TUNNEL_CONF" tunnel.conf "$work_dir"
|
||||
backup_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$work_dir"
|
||||
|
||||
if ! atomic_install "$main_candidate" "$SSHD_MAIN_CONF" 0644; then
|
||||
rollback_transaction "$work_dir"
|
||||
die "Не удалось установить ${SSHD_MAIN_CONF}; выполнен откат"
|
||||
fi
|
||||
if ! atomic_install "$tunnel_candidate" "$SSHD_TUNNEL_CONF" 0644; then
|
||||
rollback_transaction "$work_dir"
|
||||
die "Не удалось установить ${SSHD_TUNNEL_CONF}; выполнен откат"
|
||||
fi
|
||||
|
||||
if ! sshd -t; then
|
||||
rollback_transaction "$work_dir"
|
||||
die "sshd -t не прошёл; исходная конфигурация восстановлена"
|
||||
fi
|
||||
if ! verify_effective_sshd_config; then
|
||||
rollback_transaction "$work_dir"
|
||||
die "Эффективная конфигурация sshd не прошла проверку; выполнен откат"
|
||||
fi
|
||||
|
||||
if ! atomic_install "$key_candidate" "$AUTHORIZED_KEYS_FILE" 0644; then
|
||||
rollback_transaction "$work_dir"
|
||||
die "Не удалось установить ${AUTHORIZED_KEYS_FILE}; выполнен откат"
|
||||
fi
|
||||
if ! systemctl reload "$ssh_service"; then
|
||||
rollback_transaction "$work_dir"
|
||||
if sshd -t; then
|
||||
systemctl reload "$ssh_service" \
|
||||
|| log "КРИТИЧНО: не удалось reload исходной конфигурации ${ssh_service}"
|
||||
else
|
||||
log "КРИТИЧНО: исходная конфигурация после отката не проходит sshd -t"
|
||||
fi
|
||||
die "Reload ${ssh_service} не выполнен; конфигурация восстановлена"
|
||||
fi
|
||||
log "sshd reload выполнен; ключ хранится в root-owned ${AUTHORIZED_KEYS_FILE}"
|
||||
}
|
||||
|
||||
print_usage() {
|
||||
cat <<EOF
|
||||
|
||||
Готово.
|
||||
|
||||
Проверка с вашей рабочей машины (не закрывая текущую admin-сессию):
|
||||
|
||||
ssh -i ~/.ssh/han_vm2_tunnel -N \\
|
||||
-L 16433:${PG_HOST}:${PG_PORT} \\
|
||||
${TUNNEL_USER}@<VM2_PUBLIC_IP>
|
||||
|
||||
В другом терминале:
|
||||
|
||||
nc -zv 127.0.0.1 16433
|
||||
|
||||
Подключение к БД через туннель (пример):
|
||||
|
||||
psql "postgresql://<USER>:<PASSWORD>@127.0.0.1:16433/<DB>?sslmode=verify-full&sslrootcert=<PATH_TO_CA>"
|
||||
|
||||
Что запрещено для ${TUNNEL_USER}:
|
||||
- sudo / shell / доступ к секретам приложения
|
||||
- remote forwarding (-R), произвольный SOCKS, agent/X11
|
||||
- forwarding на адреса вне PermitOpen
|
||||
|
||||
Политика источника ключа:
|
||||
- CIDR: ${TUNNEL_SOURCE_CIDRS:-не задан}
|
||||
- осознанно разрешён любой источник: ${TUNNEL_ALLOW_ANY_SOURCE}
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
local work_dir main_candidate tunnel_candidate key_candidate
|
||||
|
||||
require_root
|
||||
require_commands
|
||||
validate_tunnel_user
|
||||
validate_source_policy
|
||||
validate_cidrs "$TUNNEL_SOURCE_CIDRS"
|
||||
validate_key_file "$TUNNEL_AUTHORIZED_KEY_FILE"
|
||||
collect_permit_opens
|
||||
verify_network_from_host
|
||||
create_or_update_user
|
||||
|
||||
work_dir=$(mktemp -d /run/setup-tunnel-user.XXXXXX)
|
||||
trap "rm -rf -- '$work_dir'" EXIT
|
||||
main_candidate="${work_dir}/main.conf.candidate"
|
||||
tunnel_candidate="${work_dir}/tunnel.conf.candidate"
|
||||
key_candidate="${work_dir}/authorized_keys.candidate"
|
||||
|
||||
build_main_config_candidate "$main_candidate"
|
||||
build_sshd_tunnel_candidate "$tunnel_candidate"
|
||||
build_authorized_keys_candidate "$key_candidate"
|
||||
install_and_reload_sshd \
|
||||
"$work_dir" "$main_candidate" "$tunnel_candidate" "$key_candidate"
|
||||
print_usage
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,133 @@
|
||||
Да, скрипт операционно идемпотентен: повторный запуск с тем же `TUNNEL_USER` приводит конфигурацию к тому же состоянию. Он повторно проверит настройки и выполнит reload SSH, поэтому не является строго no-op.
|
||||
|
||||
Нельзя менять `TUNNEL_USER` при повторном запуске: старый пользователь и его ключ автоматически не удаляются.
|
||||
|
||||
## Инструкция запуска
|
||||
|
||||
### 1. Создать ключ на рабочем компьютере
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
ssh-keygen -t ed25519 `
|
||||
-f "$env:USERPROFILE\.ssh\han_vm2_tunnel" `
|
||||
-C "han-vm2-db-tunnel"
|
||||
```
|
||||
|
||||
Не перезаписывайте существующий ключ без запланированной ротации.
|
||||
|
||||
### 2. Передать ключ и скрипт на VM2
|
||||
|
||||
```powershell
|
||||
scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" `
|
||||
"$env:USERPROFILE\.ssh\han_vm2_tunnel.pub" `
|
||||
admin@<VM2_PUBLIC_IP>:/tmp/han_vm2_tunnel.pub
|
||||
|
||||
scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" `
|
||||
".\setup-tunnel-user.sh" `
|
||||
admin@<VM2_PUBLIC_IP>:/tmp/setup-tunnel-user.sh
|
||||
```
|
||||
|
||||
Подключиться к VM2:
|
||||
|
||||
```powershell
|
||||
ssh -i "$env:USERPROFILE\.ssh\han_vm2_admin" admin@<VM2_PUBLIC_IP>
|
||||
```
|
||||
|
||||
### 3. Установить временные файлы
|
||||
|
||||
На VM2:
|
||||
|
||||
```sh
|
||||
sudo install -d -o root -g root -m 0700 /root/bootstrap
|
||||
|
||||
sudo install -o root -g root -m 0600 \
|
||||
/tmp/han_vm2_tunnel.pub \
|
||||
/root/bootstrap/tunnel.pub
|
||||
|
||||
sudo install -o root -g root -m 0700 \
|
||||
/tmp/setup-tunnel-user.sh \
|
||||
/root/setup-tunnel-user.sh
|
||||
|
||||
rm -f /tmp/han_vm2_tunnel.pub /tmp/setup-tunnel-user.sh
|
||||
```
|
||||
|
||||
Текущую admin-сессию не закрывать до окончания проверки.
|
||||
|
||||
### 4. Запустить с ограничением по IP
|
||||
|
||||
Укажите внешний IP рабочей сети:
|
||||
|
||||
```sh
|
||||
sudo env \
|
||||
TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
|
||||
TUNNEL_SOURCE_CIDRS="<YOUR_PUBLIC_IP>/32" \
|
||||
PG_HOST="192.168.0.210" \
|
||||
PG_PORT="6433" \
|
||||
bash /root/setup-tunnel-user.sh
|
||||
```
|
||||
|
||||
Несколько разрешённых сетей:
|
||||
|
||||
```sh
|
||||
TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24"
|
||||
```
|
||||
|
||||
Дополнительный endpoint:
|
||||
|
||||
```sh
|
||||
EXTRA_PERMIT_OPEN="192.168.0.210:5433"
|
||||
```
|
||||
|
||||
### 5. Осознанно разрешить любой источник
|
||||
|
||||
Только если доступ уже ограничен cloud SG/firewall:
|
||||
|
||||
```sh
|
||||
sudo env \
|
||||
TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
|
||||
TUNNEL_ALLOW_ANY_SOURCE=true \
|
||||
PG_HOST="192.168.0.210" \
|
||||
PG_PORT="6433" \
|
||||
bash /root/setup-tunnel-user.sh
|
||||
```
|
||||
|
||||
Одновременно задавать `TUNNEL_SOURCE_CIDRS` и `TUNNEL_ALLOW_ANY_SOURCE=true` нельзя.
|
||||
|
||||
### 6. Проверить туннель
|
||||
|
||||
На рабочем компьютере в отдельном PowerShell:
|
||||
|
||||
```powershell
|
||||
ssh `
|
||||
-i "$env:USERPROFILE\.ssh\han_vm2_tunnel" `
|
||||
-N `
|
||||
-o ExitOnForwardFailure=yes `
|
||||
-o ServerAliveInterval=30 `
|
||||
-L "127.0.0.1:16433:192.168.0.210:6433" `
|
||||
tunnel@<VM2_PUBLIC_IP>
|
||||
```
|
||||
|
||||
Адрес и порт после `-L` должны точно совпадать с `PermitOpen`.
|
||||
|
||||
Во втором терминале:
|
||||
|
||||
```powershell
|
||||
Test-NetConnection 127.0.0.1 -Port 16433
|
||||
```
|
||||
|
||||
Для PostgreSQL с проверкой TLS-имени:
|
||||
|
||||
```sh
|
||||
psql "host=<DB_CERTIFICATE_NAME> hostaddr=127.0.0.1 port=16433 dbname=<DB> user=<USER> sslmode=verify-full sslrootcert=<PATH_TO_CA>"
|
||||
```
|
||||
|
||||
### 7. Очистить bootstrap-файлы
|
||||
|
||||
После успешной проверки на VM2:
|
||||
|
||||
```sh
|
||||
sudo rm -f /root/bootstrap/tunnel.pub
|
||||
```
|
||||
|
||||
Рабочий ключ уже будет установлен в `/etc/ssh/authorized_keys/tunnel`.
|
||||
@@ -12,3 +12,8 @@
|
||||
- [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md) — runbook ВМ2.
|
||||
|
||||
Guest API, frontend, Keycloak, SMS и Bitrix Open Lines local app находятся в [`VM1_app/documentation`](../../VM1_app/documentation/README.md). ВМ2 публикует на `443` только два exact CRM webhook; Safety доступен ВМ1 только через private HTTPS `:8443`.
|
||||
|
||||
Cutover caller выполняется по
|
||||
[`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md)
|
||||
и [fresh production runbook ВМ1](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md);
|
||||
ВМ2 не предоставляет ВМ1 fallback на local stub.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
> Статус: целевой runbook репозитория ВМ2.
|
||||
> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md). Не переносить команды ВМ1 и не шарить Compose/IAM/secrets.
|
||||
> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md)
|
||||
> и её [fresh production runbook](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md).
|
||||
> Не переносить команды ВМ1 и не шарить Compose/IAM/secrets.
|
||||
|
||||
## 1. Границы
|
||||
|
||||
@@ -181,5 +183,8 @@ D-TBD5 Safety v2; D-TBD6 bitrix-sync cutover; performance gates уже в §2.
|
||||
|
||||
- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
- ВМ1: [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md).
|
||||
- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md).
|
||||
- Исполняемый fresh production runbook ВМ1:
|
||||
[`RUNBOOK.production.ru.md`](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md).
|
||||
- Исполняемый runbook ВМ2:
|
||||
[`RUNBOOK.ru.md`](../codebase/services/deployment/RUNBOOK.ru.md).
|
||||
- Safety / sync / nginx: [`module-05-message-safety.md`](module-05-message-safety.md), [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md).
|
||||
|
||||
+16
-1
@@ -20,6 +20,21 @@
|
||||
| [`arch-09-redis.md`](arch-09-redis.md) | Контракт Redis: не source of truth, формат ключей, TTL, Lua, AOF/ACL. Реализация VM — `module-04-redis-vm1.md` / `module-04-redis-vm2.md` |
|
||||
| [`arch-10-deployment.md`](arch-10-deployment.md) | Контракт развёртывания: VPC/SG, PG/S3, роли `deploy`, TLS процедура, cutover. Runbook VM — `module-10-deployment-vm1.md` / `module-10-deployment-vm2.md`. OS-роли — arch-06 |
|
||||
|
||||
## Ownership-матрица архитектурных требований
|
||||
|
||||
| Область | Канонический владелец | Что остаётся в связанных документах |
|
||||
|---|---|---|
|
||||
| Термины, поля, enum и базовый lifecycle | [`arch-00-glossary.md`](arch-00-glossary.md) | Сценарии переходов ссылаются на arch-00 и не переопределяют значения |
|
||||
| Границы сервисов и пользовательские сценарии | [`arch-01-system-architecture.md`](arch-01-system-architecture.md) | API, Compose и deployment описывают реализацию этих границ |
|
||||
| HTTP/API/realtime контракты | [`arch-02-api-contracts.md`](arch-02-api-contracts.md) | Routing и rollout только ссылаются на endpoint/auth contract |
|
||||
| Compose, Docker networks, mounts и published ports | [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md) | arch-06 задаёт security baseline; arch-08 — поведение nginx |
|
||||
| Non-secret env, secret references и business settings | [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md) | Модули задают schema/validation конкретного потребителя |
|
||||
| OS-роли, secret delivery и host/container hardening | [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md) | arch-03 применяет требования в Compose; arch-10 ставит rollout gates |
|
||||
| Observability contract | [`arch-07-observability.md`](arch-07-observability.md) | VM module-09 задаёт конкретные pipelines/alerts |
|
||||
| Nginx, TLS/ACME, единый `308`, request id и reload | [`arch-08-nginx.md`](arch-08-nginx.md) | arch-03 задаёт mounts/ports и сохраняет исключение HTTP webhook ВМ2; VM module-03 — routing matrix |
|
||||
| Redis contract | [`arch-09-redis.md`](arch-09-redis.md) | VM module-04 задаёт конкретную карту instances/keys |
|
||||
| Provisioning, rollout/cutover, backup/rollback/DR | [`arch-10-deployment.md`](arch-10-deployment.md) | Профильный module-10 содержит исполняемые команды и VM-specific gates |
|
||||
|
||||
## Как читать
|
||||
|
||||
1. Начните с **arch-01** — общая картина и зафиксированные решения MVP.
|
||||
@@ -61,7 +76,7 @@
|
||||
|
||||
| Тема | Где зафиксировано |
|
||||
|---|---|
|
||||
| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync` → `api-backend`, уведомление клиента) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9; arch-01 — заглушка UI «Документы» |
|
||||
| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync` → `api-backend`, уведомление клиента) | Спецификация: [`module-07-bitrix-sync.md`](../VM2_services/documentation/module-07-bitrix-sync.md); API-контракты — [`arch-02-api-contracts.md`](arch-02-api-contracts.md) |
|
||||
| Интеграция с SMS-провайдерами (отправка OTP, отключение `KEYCLOAK_OTP_MOCK_*`) | Спецификация: [`module-11-idgtl-sms.md`](../VM1_app/documentation/module-11-idgtl-sms.md) (доставка через Direct SMS API; проверка OTP — локально в Keycloak) |
|
||||
|
||||
## Каноническое размещение production-контуров
|
||||
|
||||
@@ -126,12 +126,14 @@
|
||||
|
||||
| Значение | Когда |
|
||||
|---|---|
|
||||
| `accepted` | Сообщение принято API, safety ещё не завершена или только начата |
|
||||
| `processing` | Внутренний/transient на время sync-wait safety; клиенту на `POST .../messages` не отдаётся как финальный ответ |
|
||||
| `accepted` | Получен финальный `allow`, сообщение и durable-намерение доставки зафиксированы, но Open Lines ещё не подтвердил приём |
|
||||
| `processing` | Сообщение принято API, Safety ещё выполняется (включая sync-wait `202 pending`); клиенту на `POST .../messages` не отдаётся как финальный ответ |
|
||||
| `delivered` | Финальный `allow`, сообщение ушло в Open Lines (или входящее от оператора сохранено) |
|
||||
| `rejected` | Финальный `deny` от Message Safety |
|
||||
| `failed` | Инфраструктурная ошибка доставки (Bitrix/S3), не safety-deny |
|
||||
|
||||
Для исходящего сообщения успешный lifecycle строго следует порядку: Safety `allow` → `accepted` → приём в Open Lines → `delivered`. Статус `accepted` не означает незавершённую Safety-проверку.
|
||||
|
||||
Realtime-событие `message.status` передаёт актуальные `safety_status` и/или `delivery_status`.
|
||||
|
||||
## `MessageAttachment.scan_status`
|
||||
|
||||
@@ -195,7 +195,7 @@ Frontend не должен:
|
||||
- локальную регистрацию пользователя приложения: `find-or-create` `UserIdentity` по `keycloak_sub`, создание минимального `ClientProfile` для нового пользователя, обновление `last_login_at` для существующего (после OTP — см. `POST /api/v1/auth/bootstrap`);
|
||||
- **приём события `session_start`**: запись `UxSession`, audit/analytics-событие; **не** используется для контроля доступа;
|
||||
- валидация данных получаемых от frontend (соответствие типов данных, проверка обязательности полей, проверка формата данных, диапазоны значений, размер полей) через Pydantic
|
||||
- хранение согласий пользователя в App DB (**`user_id`**, **`ux_session_id`**, **`client_ip`**, версии документов) — только после JWT;
|
||||
- хранение согласий пользователя в App DB (**`user_id`**, nullable **`ux_session_id`**, **`client_ip`**, версии документов) — только после JWT; при bootstrap `ux_session_id=NULL` допустим, потому что новая UX-сессия создаётся следующим запросом;
|
||||
- профиль, структурированный блоками;
|
||||
- API чата, истории, файлов и документов;
|
||||
- realtime-доставку входящих сообщений клиенту;
|
||||
@@ -435,7 +435,7 @@ api-backend не решает, sync или async нужна проверка в
|
||||
- при **`KEYCLOAK_OTP_MOCK_ENABLED=false`**: значение сверяется локально с HMAC OTP, сгенерированного Keycloak и переданного в закрытом заказе `sms-service`; статусы Direct и callback на verify не влияют.
|
||||
- при неверном коде Keycloak возвращает ошибку; frontend не получает tokens, шаг 12 не выполняется.
|
||||
12. При успешной проверке frontend получает tokens через OIDC Authorization Code Flow with PKCE.
|
||||
13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` → минимальный профиль.
|
||||
13. Frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** — в теле передаёт локально принятые согласия и device metadata (см. arch-02). api-backend атомарно: `find-or-create` по JWT `sub` (`keycloak_sub`), телефон из JWT claims (не из body) → сохранение `UserConsent` на `user_id` с nullable `ux_session_id` (на bootstrap обычно `NULL`) → минимальный профиль.
|
||||
14. Frontend вызывает **`POST /api/v1/analytics/session-start`** (если нужна новая UX-сессия) и далее работает с `X-Ux-Session-Id`.
|
||||
15. Триггер App DB ставит задачу `contact.map_or_create` в `sync_queue`; `bitrix-sync` асинхронно находит или создает Contact в Битрикс24. Авторизация не должна синхронно зависеть от ответа Битрикс24 CRM.
|
||||
16. Frontend создаёт диалог и отправляет отложенное сообщение (см. «Создание диалога» и поток чата).
|
||||
@@ -596,56 +596,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn
|
||||
|
||||
### Предлагаемая структура backend-репозитория
|
||||
|
||||
```text
|
||||
backend/
|
||||
docker-compose.yml # root compose ВМ1
|
||||
.env.example
|
||||
nginx/
|
||||
docker-compose.yml
|
||||
nginx.conf
|
||||
conf.d/
|
||||
certs/
|
||||
.gitkeep
|
||||
api-backend/
|
||||
app/
|
||||
docker-compose.yml
|
||||
tests/
|
||||
pyproject.toml
|
||||
Dockerfile
|
||||
bitrix-local-app/
|
||||
app/
|
||||
docker-compose.yml
|
||||
deploy/
|
||||
tests/
|
||||
pyproject.toml
|
||||
Dockerfile
|
||||
keycloak/
|
||||
docker-compose.yml
|
||||
realm/
|
||||
themes/
|
||||
providers/
|
||||
sms-service/
|
||||
app/
|
||||
migrations/
|
||||
openapi.yaml
|
||||
Dockerfile
|
||||
redis/
|
||||
docker-compose.yml
|
||||
observability/
|
||||
docker-compose.yml # collector ВМ1
|
||||
otel-collector.yaml
|
||||
|
||||
processing/
|
||||
docker-compose.yml # root compose ВМ2
|
||||
nginx-internal/
|
||||
message-safety/
|
||||
bitrix-sync/
|
||||
clamav/
|
||||
redis/
|
||||
observability/ # collector ВМ2
|
||||
```
|
||||
|
||||
Детальная внутренняя структура каждого сервиса (`app/`, модули, миграции) определяется в профильных спецификациях модулей (TBD).
|
||||
Каноническая структура root Compose, service includes, networks и mounts задаётся в [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md). Детальная внутренняя структура сервиса определяется его профильной спецификацией.
|
||||
|
||||
### Compose-контуры
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ networks:
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
nginx-certs:
|
||||
```
|
||||
|
||||
Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и локальный OTEL Collector. Секреты, сети и volumes двух projects не общие.
|
||||
@@ -103,6 +102,7 @@ Root Compose ВМ2 включает собственный nginx с public/priva
|
||||
- Публикация портов наружу (`ports:`) разрешена **только** для `nginx` (80/443). Все остальные сервисы используют `expose:` для внутренних портов и общаются через Docker-сети.
|
||||
- `bitrix-local-app` не публикует `8080` на хост (даже на `127.0.0.1`) — он доступен `api-backend` и `nginx` через сеть `backend`/`public`. Ранее применявшийся `127.0.0.1:8080:8080` считаем устаревшим; проверки через curl на `127.0.0.1:8080` заменяются на `docker compose exec bitrix-local-app` или прокси через `nginx`.
|
||||
- Каждый сервисный compose-файл должен запускаться и в составе корневого контура, и автономно (`docker compose -f bitrix-local-app/docker-compose.yml up`) для локальной разработки сервиса — при условии, что переменные окружения заданы. Для автономного запуска сервис может объявлять заглушки сетей/volumes, но в составе корневого контура они переопределяются общими.
|
||||
- При сборке образов нужно добавлять защиту на CRLF → LF
|
||||
|
||||
### Обязательный container hardening
|
||||
|
||||
@@ -176,7 +176,7 @@ docker compose exec api-backend ruff format .
|
||||
|
||||
Требования:
|
||||
|
||||
- публикует наружу только `80` и `443` (см. политику HTTP ниже);
|
||||
- публикует в internet только `80` и `443` (см. политику HTTP ниже); private `8443` ВМ2 публикуется только в VPC/SG для ВМ1 и ops;
|
||||
- принимает внешний HTTPS-трафик;
|
||||
- выполняет TLS termination на reverse proxy; внутренний HTTP между контейнерами — только в закрытой Docker-сети `backend`;
|
||||
- non-root nginx получает writable tmpfs только для `/etc/nginx/conf.d`,
|
||||
@@ -196,13 +196,7 @@ docker compose exec api-backend ruff format .
|
||||
- при recreate/смене IP upstream действует та же post-ready reload policy либо
|
||||
используется явно протестированный dynamic resolver; stale IP/DNS в
|
||||
загруженной nginx config недопустим;
|
||||
- **политика HTTP/HTTPS по доменам** (каноническое правило — [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности»):
|
||||
- **веб-домен** (frontend, SPA, статика): `listen 80` допускается **только** для безусловного редиректа `301`/`308` на HTTPS; обработка бизнес-логики по HTTP запрещена;
|
||||
- **API-домен** (если выделен отдельный host, напр. `api.example.ru`): **не** слушает порт `80`; только `listen 443 ssl`; HTTP-запросы к API-домену недоступны;
|
||||
- **единый домен MVP** (напр. `tohin.ru` с путями `/api/*`, `/auth/*`, web): считается веб-доменом; порт `80` — только redirect на HTTPS для всего server block; после редиректа весь пользовательский трафик — HTTPS;
|
||||
- **auth** на том же host, что API (`/auth/*`): следует политике host (redirect-only на :80 или HTTPS-only для выделенного API-host);
|
||||
- **Bitrix local-app callbacks ВМ1** (`/bitrix/handler|install|placement`): только HTTPS; порт `80` — только redirect;
|
||||
- **CRM sync webhook ВМ2** (exact `/bitrix/sync/webhook/contact|alert`): только HTTPS на отдельном processing host; HTTP не отражает query token в redirect;
|
||||
- применяет каноническую HTTP/HTTPS policy из [`arch-08-nginx.md`](arch-08-nginx.md): web/единый MVP host использует только `308` на HTTPS, выделенный API host не слушает `:80`; единственное route-specific исключение — HTTP exact CRM webhook ВМ2 возвращает `404/426` без redirect и без отражения query token;
|
||||
- маршрутизирует `/api/*` в `api-backend` (включая WebSocket upgrade для `/api/v1/realtime`);
|
||||
- маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен;
|
||||
- маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`;
|
||||
@@ -415,16 +409,19 @@ Identity provider. **Обязателен** в compose-контуре с пер
|
||||
|
||||
## Volumes
|
||||
|
||||
Минимальные volumes:
|
||||
Минимальные persistent volumes:
|
||||
|
||||
- ВМ1: Redis DB0/DB1 data, public TLS/ACME, local OTEL queue;
|
||||
- ВМ1: Redis DB0/DB1 data, local OTEL queue;
|
||||
- ВМ2: Redis Safety data (rebuildable), ClamAV signatures и runtime,
|
||||
internal TLS secrets, local OTEL queue.
|
||||
|
||||
Public TLS и ACME на ВМ2 — не named volumes: Compose монтирует read-only host
|
||||
staging `/var/lib/han-chat/public-tls` и ACME webroot
|
||||
`/var/lib/han-chat/acme`. Internal TLS certificate/key передаются отдельными
|
||||
Compose secrets и не объединяются с public TLS.
|
||||
Public TLS и ACME на обеих VM — не named volumes. Root-only ACME state остаётся
|
||||
на host в `/etc/letsencrypt`; root hook атомарно копирует только нужные
|
||||
`fullchain.pem`/`privkey.pem` в `/var/lib/han-chat/public-tls`. Compose
|
||||
монтирует этот staging read-only в nginx, а host webroot
|
||||
`/var/lib/han-chat/acme` — в nginx и ACME client с минимально необходимыми
|
||||
правами. Internal TLS certificate/key передаются отдельными Compose secrets и
|
||||
не объединяются с public TLS.
|
||||
|
||||
Данные PostgreSQL **не** хранятся в Docker volumes — только managed PostgreSQL вне compose.
|
||||
|
||||
@@ -454,9 +451,9 @@ Local OTEL queue на каждой VM использует отдельный pe
|
||||
|
||||
| Host | Порт 80 | Порт 443 | Примечание |
|
||||
|---|---|---|---|
|
||||
| Веб-домен (frontend) | только `301`/`308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host |
|
||||
| Веб-домен (frontend) | только `308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host |
|
||||
| API-домен (если выделен) | **не слушает** | только HTTPS | Post-MVP: `api.example.ru` |
|
||||
| Bitrix Local App ВМ1 (`/bitrix/handler|install|placement`) | только redirect на web host | HTTPS | install/handler/placement |
|
||||
| Bitrix Local App ВМ1 (`/bitrix/handler|install|placement`) | только `308` на HTTPS | HTTPS | install/handler/placement |
|
||||
| CRM webhook ВМ2 (exact `/bitrix/sync/webhook/contact|alert`) | generic `404/426`, без redirect query token | HTTPS | отдельный processing host |
|
||||
|
||||
Правила:
|
||||
@@ -468,26 +465,12 @@ Local OTEL queue на каждой VM использует отдельный pe
|
||||
|
||||
### TLS и заголовки
|
||||
|
||||
- cookies в web-клиенте: `Secure`, `HttpOnly`, корректный `SameSite`;
|
||||
- OIDC redirect URI в Keycloak — HTTPS;
|
||||
- `KEYCLOAK_PUBLIC_URL`, issuer и frontend auth discovery URL совпадают по схеме, host и path;
|
||||
- backend формирует внешние ссылки с учётом `X-Forwarded-Proto=https`;
|
||||
- HSTS включается в production-like среде **после** проверки доменов и сертификатов;
|
||||
- TLS 1.0/1.1 запрещены; минимум TLS 1.2, предпочтительно TLS 1.3;
|
||||
- слабые шифры запрещены на уровне `nginx`;
|
||||
- `nginx` скрывает `Server`, `X-Powered-By` и аналогичные технологические заголовки;
|
||||
- security headers: `Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy` для web-приложения;
|
||||
- инструкция по установке всегда открывается новой вкладкой, поэтому CSP SPA задаёт `frame-src 'none'`; allow-list iframe для инструкций отсутствует;
|
||||
- секретный ключ сертификата не коммитится в репозиторий;
|
||||
- использовать сертификаты доверенного CA; автоматизировать выпуск и продление (Let's Encrypt + reload `nginx`);
|
||||
- non-root nginx не монтирует root-only дерево Let's Encrypt целиком:
|
||||
root deploy hook атомарно копирует только `fullchain.pem` и `privkey.pem` в
|
||||
host staging `root:<dedicated-tls-group>` (`0750`, файлы `0640`), а Compose
|
||||
монтирует staging read-only;
|
||||
- reload после renewal выполняется только после `openssl` certificate/key
|
||||
match и полного `nginx -t`; internal TLS PEM также проверяется на raw PEM,
|
||||
отсутствие literal `\n`/double-base64 и совпадение ключа;
|
||||
- закрыть прямой доступ к внутренним портам контейнеров извне.
|
||||
TLS versions, trusted CA, HSTS/security headers, certificate validation и safe
|
||||
reload принадлежат [`arch-08-nginx.md`](arch-08-nginx.md); cookie/OIDC и
|
||||
application security — [`arch-01-system-architecture.md`](arch-01-system-architecture.md).
|
||||
Compose применяет их через host binds из раздела «Volumes», не монтирует
|
||||
root-only `/etc/letsencrypt` в nginx, не объявляет named volume public
|
||||
certificates и не публикует внутренние порты.
|
||||
|
||||
## Nginx routing для Bitrix24 Local App
|
||||
|
||||
@@ -495,7 +478,7 @@ Local OTEL queue на каждой VM использует отдельный pe
|
||||
|
||||
Рекомендуемая схема:
|
||||
|
||||
- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80` → redirect HTTPS; `:443` — TLS + маршрутизация;
|
||||
- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80` → `308` HTTPS; `:443` — TLS + маршрутизация;
|
||||
- **выделенный API-домен** (post-MVP, опционально): отдельный `server { listen 443 ssl; ... }` **без** `listen 80`; только `/api/*`;
|
||||
- для `location` WebSocket (`/api/v1/realtime`): `proxy_http_version 1.1`, `Upgrade`/`Connection` headers, увеличенный `proxy_read_timeout`;
|
||||
- только `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` на ВМ1 → `bitrix-local-app`; CRM `/bitrix/sync/*` на этом host не маршрутизируется;
|
||||
@@ -576,7 +559,7 @@ WAF не заменяет обязательные лимиты, валидац
|
||||
|
||||
Минимальные проверки:
|
||||
|
||||
- `nginx`: на веб-домене — `301` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — HTTP 200/301 и успешная TLS handshake;
|
||||
- `nginx`: на веб-домене — `308` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — успешная TLS handshake и ожидаемый route response;
|
||||
- `api-backend`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL `han_app`, Redis DB0/DB1, JWKS/discovery Keycloak и S3 permissions. Недоступность remote Message Safety отражается как degraded dependency и блокирует только send path, но не readiness read API;
|
||||
- `message-safety`: `/health/ready` возвращает process/core status и capability map `text|links|files|worker`; ClamAV/S3 не выключают text, DNS не выключает text без ссылок, Redis hot cache не является core gate;
|
||||
- `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет validated config/secrets, PostgreSQL/grants, worker/limiter state и CRM webhook config; invalid credential/config даёт not-ready, краткая CRM outage — degraded по stale policy; при `BITRIX_SYNC_ENABLED=false` ready возвращает not-ready `sync_disabled`;
|
||||
@@ -635,7 +618,7 @@ endpoint обязан fail-closed при недоступной требуемо
|
||||
1. ВМ1, ВМ2, SigNoz и managed PostgreSQL находятся в одной private network/VPC; ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress.
|
||||
2. Managed PostgreSQL не имеет public IP; SG разрешает каждой VM только нужные DB roles/schemas.
|
||||
3. На каждой VM отдельный root-owned systemd unit выполняет её root Compose; `deploy` не входит в `docker`.
|
||||
4. Public nginx ВМ2 публикует `80/443`; `80` используется только для ACME/redirect, `443` — только exact CRM webhook. Private `8443` разрешён только от SG ВМ1 и ops для Message Safety/internal access.
|
||||
4. Public nginx ВМ2 публикует `80/443`; `80` обслуживает ACME, а HTTP exact CRM webhook возвращает `404/426` без redirect; `443` публикует только exact CRM webhook. Private `8443` разрешён только от SG ВМ1 и ops для Message Safety/internal access.
|
||||
5. Deploy/cutover ВМ2 не требует изменения public routes ВМ1. Для `bitrix-sync` rollback закрывает webhook routes на nginx ВМ2 либо возвращает retryable `503`, останавливает claims и сохраняет durable tasks/mapping; возврат к фиктивному `202 ignored` запрещён.
|
||||
|
||||
Host firewall обеих VM учитывает post-DNAT semantics `DOCKER-USER`: policy
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
- Для часто используемых фильтров добавляются индексы.
|
||||
- Миграции не должны удалять данные без отдельного согласования.
|
||||
- Все юзеры должны иметь ИД, которое указывается в `updater_user_id` которое они меняют.
|
||||
|
||||
- При создании миграций учитывать, что asyncpg допускает один top-level SQL statement на один execute.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -525,15 +525,15 @@ Legal retention/erasure имеет приоритет; изменение тре
|
||||
### Обнаруженные архитектурные конфликты
|
||||
|
||||
1. `arch-03` разрешает stdout/platform exporter как минимум, но production-like расследования и alerts без backend ограничены. Закрыто выбором SigNoz (O1 / O-TBD1); stdout остаётся аварийным buffer.
|
||||
2. `module-05` использует test-only terminal `400` и non-sticky verdict вместо canonical `403`/sticky production verdict. Dashboards обязаны маркировать сервис `stub`; production SLO Safety на нём недостоверен. Детали — спецификация ВМ2.
|
||||
3. `module-07` задаёт full sync target; до code/portal cutover dashboard обязан показывать `sync_disabled`, а не синтетический CRM success. Queue/CRM SLI включаются только после module-07 preflight. Детали — спецификация ВМ2.
|
||||
2. Test-only режимы Message Safety не входят в production SLO. Production dashboards используют canonical `403`, sticky verdict и фактический `processing_mode`; значение `stub` считается ошибкой cutover. Детали — спецификация ВМ2.
|
||||
3. Queue/CRM SLI включаются только после module-07 preflight/cutover и отражают фактическое состояние `bitrix-sync` на ВМ2; синтетический CRM success запрещён. Детали — спецификация ВМ2.
|
||||
4. Retention, RPO/RTO и production SLO открыты в module-01/04/06/08; значения этого документа являются initial ops policy, не закрывают legal/product TBD.
|
||||
5. Observability env (`OTEL_REMOTE_*`, service names, sampling/queue limits) добавлены в arch-04 и `.env.example`; sampling/queue limits уточняются после load test.
|
||||
|
||||
## 19. Ссылки
|
||||
|
||||
- Профильные спецификации: [`module-09-observability-vm1.md`](../VM1_app/documentation/module-09-observability-vm1.md), [`module-09-observability-vm2.md`](../VM2_services/documentation/module-09-observability-vm2.md).
|
||||
- VM/Docker logging и firewall: [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh).
|
||||
- Прототипный nginx stdout/access log: [`../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf`](../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf).
|
||||
- VM/Docker logging и firewall: [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md) и профильные module-10/runbook.
|
||||
- Nginx stdout/access log: [`arch-08-nginx.md`](arch-08-nginx.md) и профильные module-03.
|
||||
- HTTP/OTLP registry: [`arch-02-api-contracts.md`](arch-02-api-contracts.md).
|
||||
- Compose topology: [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md).
|
||||
|
||||
@@ -40,7 +40,8 @@ Upstream failures не перенаправляются на другой сер
|
||||
|
||||
## 3. HTTP/HTTPS и TLS
|
||||
|
||||
- public host каждой VM на `:80` обслуживает только ACME challenge и `308 https://$host$request_uri`, кроме явно зафиксированных исключений профильной спецификации;
|
||||
- public host каждой VM на `:80` обслуживает только ACME challenge и `308 https://$host$request_uri`;
|
||||
- единственное route-specific исключение: exact CRM webhook ВМ2 по HTTP возвращает `404/426` без redirect, чтобы query token не отражался в `Location`;
|
||||
- выделенный API host, если появится, не имеет listener `:80`;
|
||||
- `:443 ssl http2`, TLS 1.2/1.3, современные cipher suites, session tickets по ops policy;
|
||||
- сертификат доверенного CA, private key read-only и недоступен приложению;
|
||||
@@ -53,24 +54,33 @@ Upstream failures не перенаправляются на другой сер
|
||||
|
||||
## 4. ACME lifecycle
|
||||
|
||||
Выбран webroot Certbot/ACME client с общими named volumes:
|
||||
Выбран webroot Certbot/ACME client с host-каталогами:
|
||||
|
||||
```text
|
||||
nginx-certs -> /etc/letsencrypt (rw у certbot, ro у nginx)
|
||||
nginx-acme-webroot -> /var/www/certbot
|
||||
/etc/letsencrypt root-only ACME state; rw только у root/ACME client
|
||||
/var/lib/han-chat/public-tls staged fullchain.pem/privkey.pem; ro bind в nginx
|
||||
/var/lib/han-chat/acme webroot; bind в nginx и ACME client
|
||||
```
|
||||
|
||||
Каждая VM выпускает **свой** сертификат на свой public host. Секреты и volumes двух projects не общие.
|
||||
Named volume для public certificate/key запрещён. Non-root nginx никогда не
|
||||
монтирует `/etc/letsencrypt`: после успешного issuance/renewal root hook
|
||||
проверяет certificate/key и атомарно копирует только нужные PEM в
|
||||
`/var/lib/han-chat/public-tls` с `root:<dedicated-tls-group>`, directory `0750`
|
||||
и files `0640`. Каждая VM выпускает **свой** сертификат на свой public host;
|
||||
host-каталоги двух VM не общие.
|
||||
|
||||
Bootstrap:
|
||||
|
||||
1. DNS указывает на VM; 80/443 разрешены.
|
||||
2. Запустить временный HTTP config с `/.well-known/acme-challenge/`.
|
||||
3. Выпустить certificate без остановки nginx.
|
||||
4. Проверить `nginx -t`, атомарно активировать TLS config, reload.
|
||||
2. Подготовить root-owned webroot `/var/lib/han-chat/acme` и временный HTTP config с `/.well-known/acme-challenge/`.
|
||||
3. Выпустить certificate в root-only `/etc/letsencrypt` без остановки nginx.
|
||||
4. Проверить certificate/key, атомарно обновить `/var/lib/han-chat/public-tls`, выполнить `nginx -t`, активировать TLS config и reload.
|
||||
|
||||
Renew container/host timer выполняет `certbot renew` минимум дважды в сутки;
|
||||
после фактического renewal проверяет рабочую конфигурацию командой
|
||||
Root-owned host timer выполняет `certbot renew` минимум дважды в сутки; ACME
|
||||
client может быть контейнеризован, но только он получает rw bind
|
||||
`/etc/letsencrypt`, а public certificate остаётся host bind, не named volume.
|
||||
После фактического renewal hook проверяет пару certificate/key, атомарно
|
||||
обновляет staging и проверяет рабочую конфигурацию командой
|
||||
`docker compose exec -T nginx nginx -t -c /tmp/nginx.conf` и отправляет
|
||||
master-процессу `docker compose kill -s HUP nginx`. Bare-команды `nginx -t` и
|
||||
`nginx -s reload` запрещены: контейнер read-only, а рабочие config/PID находятся
|
||||
@@ -82,7 +92,8 @@ success path считается дефектом интеграции с Certbot
|
||||
Контролируются expiry days и последняя успешная попытка. Staging CA используется
|
||||
в rehearsal, чтобы не исчерпать лимиты.
|
||||
|
||||
Non-root nginx не монтирует root-only дерево Let's Encrypt целиком: только необходимые cert/key files по arch-03/arch-06.
|
||||
Non-root nginx получает только read-only staging
|
||||
`/var/lib/han-chat/public-tls`; root-only `/etc/letsencrypt` ему недоступен.
|
||||
|
||||
## 5. Request ID и forwarded headers
|
||||
|
||||
@@ -176,7 +187,7 @@ nginx/
|
||||
|
||||
## 11. Docker Compose
|
||||
|
||||
`nginx` подключён к `public` и `backend` (или эквивалентным сетям своей VM). Публикация host ports разрешена только nginx. Filesystem read-only, tmpfs для cache/run/temp/`/etc/nginx/conf.d` по arch-03, non-root где позволяет bind ports/capabilities. Cert volumes read-only для nginx. ACME client имеет только необходимые volumes/network.
|
||||
`nginx` подключён к `public` и `backend` (или эквивалентным сетям своей VM). Публикация host ports разрешена только nginx. Filesystem read-only, tmpfs для cache/run/temp/`/etc/nginx/conf.d` по arch-03, non-root где позволяет bind ports/capabilities. Host staging public TLS и ACME webroot монтируются с минимальными правами; named volume сертификатов запрещён. ACME client имеет только необходимые bind mounts/network.
|
||||
|
||||
Non-root nginx получает writable tmpfs только для `/etc/nginx/conf.d`, `/var/cache/nginx`, `/var/run` и `/tmp`; tmpfs задаёт явные UID/GID/mode. Основной `nginx.conf` подключает конкретный rendered include, не неограниченный wildcard, который позволил бы обойти `nginx -t`.
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
6. S3 — внешний Selectel-compatible storage; клиент получает только presigned URL.
|
||||
7. Секреты не коммитятся, не вставляются в команды shell history и не выводятся в отчёты.
|
||||
8. Миграции выполняются отдельными one-shot steps до новой версии приложения.
|
||||
9. Message Safety запускается как documented stub до замены; это не production antivirus/moderation.
|
||||
10. `bitrix-sync` вводится только после выполнения preflight/cutover gates module-07; до этого `BITRIX_SYNC_ENABLED=false`, public webhook закрыт на edge.
|
||||
9. Production Message Safety работает только на ВМ2; local stub/fallback на ВМ1 запрещён. Caller ВМ1 использует private HTTPS, service token и проверку internal CA, при недоступности — fail closed.
|
||||
10. `bitrix-sync` работает только на ВМ2 и вводится после preflight/cutover gates module-07; до подтверждённого cutover public webhook остаётся закрыт на edge.
|
||||
11. На ВМ1 и ВМ2 отдельные root Compose projects/systemd units; deploy/rollback выполняются независимо.
|
||||
12. ВМ2 — самостоятельная service VM с минимальным public webhook ingress, allow-listed egress, отдельным IAM principal и service-specific secret files.
|
||||
13. OS-роли, SSH/sudo, secrets delivery, container hardening и private-VM lockdown подчиняются arch-06.
|
||||
@@ -49,7 +49,7 @@
|
||||
<VM_PRIVATE_IP> приватный IPv4 VM
|
||||
<VPC_CIDR> например 10.20.0.0/24
|
||||
<PG_PRIVATE_HOST> private FQDN/IP managed PG
|
||||
<PG_PORT> 5432 или 6432
|
||||
<PG_PORT> 5433 для Selectel PgBouncer; иной порт — только фактическое значение другого provider/endpoint
|
||||
<PG_DATABASE> han_chat
|
||||
<BACKEND_REPO_URL> URL репозитория этой VM
|
||||
<BACKEND_ROOT> /opt/han-chat/backend
|
||||
@@ -115,7 +115,7 @@ DNS: `A <PUBLIC_HOST> → <VM1_PUBLIC_IP>`, `A <PROCESSING_PUBLIC_HOST> → <VM2
|
||||
|
||||
## 5. Stage 2 — hardening Ubuntu
|
||||
|
||||
Процедура первичная для **каждой** application VM. Скрипт-прототип [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh) полезен для UFW, fail2ban, Docker и `DOCKER-USER`. Перед production: review версии; не передавать IP/ключи в git; проверить unattended upgrades; `deploy` не в группе `docker`; `AllowTcpForwarding no` по умолчанию; отдельный `tunnel` при необходимости; root-owned systemd-units и `/etc/sudoers.d/deploy` без wildcard.
|
||||
Процедура первичная для **каждой** application VM и выполняется только по её профильному production runbook. Перед production: review версии; не передавать IP/ключи в git; проверить unattended upgrades; `deploy` не в группе `docker`; `AllowTcpForwarding no` по умолчанию; отдельный `tunnel` при необходимости; root-owned systemd-units и `/etc/sudoers.d/deploy` без wildcard.
|
||||
|
||||
`PUBLIC_DOCKER_PORTS` задаёт профильный runbook (ВМ1: `80,443`; ВМ2 — свои public 80/443, private 8443 не internet).
|
||||
|
||||
@@ -216,37 +216,24 @@ Validation: нет `change-me`, paired tokens equal, PG TLS, public HTTPS, `FRON
|
||||
|
||||
## 9. TLS/ACME процедура
|
||||
|
||||
Webroot two-phase, независимо в root Compose каждой VM, без `compose down`. Staging CA rehearsal, затем production `--cert-name` текущего host. Сертификат и ACME volume между VM не разделяются.
|
||||
Канонические bootstrap, renewal, validation и safe reload задаёт
|
||||
[`arch-08-nginx.md`](arch-08-nginx.md). Rollout каждой VM применяет эту модель
|
||||
независимо, без `compose down` и без named volume сертификатов:
|
||||
|
||||
Renew: systemd timer дважды в сутки. Скрипт `<BACKEND_ROOT>/deploy/ssl-renew.sh`:
|
||||
- root-only ACME state: `/etc/letsencrypt`;
|
||||
- read-only для nginx host staging: `/var/lib/han-chat/public-tls`;
|
||||
- host ACME webroot: `/var/lib/han-chat/acme`;
|
||||
- root-owned systemd timer/hook дважды в сутки с `flock`; пользователь `deploy`
|
||||
может запускать только утверждённый unit и не получает доступ к ACME state;
|
||||
- staging CA rehearsal предшествует production issuance; после renewal root
|
||||
hook проверяет certificate/key, атомарно обновляет staging, выполняет
|
||||
container `nginx -t` и только затем HUP;
|
||||
- success path возвращает `0` с пустым stderr; ошибка сохраняет действующий
|
||||
certificate/config и поднимает alert (<21 дней, page <7 дней).
|
||||
|
||||
1. взять `flock`;
|
||||
2. `docker compose --profile certbot run --rm certbot renew --webroot -w /var/www/certbot --quiet`;
|
||||
3. при обновлении проверить `docker compose exec -T nginx nginx -t -c /tmp/nginx.conf`;
|
||||
4. только после успеха `docker compose kill -s HUP nginx`;
|
||||
5. записать результат и метрику expiry;
|
||||
6. ненулевой exit при ошибке;
|
||||
7. не удалять действующий сертификат;
|
||||
8. success path — `0` и пустой stderr.
|
||||
|
||||
Пример unit `/etc/systemd/system/han-chat-cert-renew.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Renew HAN Chat Let's Encrypt certificate
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=deploy
|
||||
WorkingDirectory=<BACKEND_ROOT>
|
||||
ExecStart=<BACKEND_ROOT>/deploy/ssl-renew.sh
|
||||
```
|
||||
|
||||
Timer `OnCalendar=*-*-* 03,15:20:00`, `RandomizedDelaySec=30m`, `Persistent=true`. `enable --now`, `list-timers`, `certbot renew --dry-run`. Alert <21 дней, page <7 дней. Ошибка renew не останавливает nginx.
|
||||
|
||||
Staging issuance, затем production `--cert-name` текущего host. Host для ВМ1 — `<PUBLIC_HOST>`; для ВМ2 — `<PROCESSING_PUBLIC_HOST>`; private `8443` — internal CA, не Let's Encrypt. Сертификат и ACME volume между VM не разделяются.
|
||||
Host ВМ1 — `<PUBLIC_HOST>`, ВМ2 — `<PROCESSING_PUBLIC_HOST>`; private `8443`
|
||||
использует internal CA, не Let's Encrypt. Ни ACME state, ни staging между VM не
|
||||
разделяются.
|
||||
|
||||
## 10. Сквозной порядок startup и cutover
|
||||
|
||||
@@ -337,4 +324,4 @@ DR потеря VM: новая Ubuntu в VPC, hardening, DNS, secrets из vault
|
||||
|
||||
- ВМ1: [`module-10-deployment-vm1.md`](../VM1_app/documentation/module-10-deployment-vm1.md).
|
||||
- ВМ2: [`module-10-deployment-vm2.md`](../VM2_services/documentation/module-10-deployment-vm2.md).
|
||||
- Прототипы: [`../../HAN_chat/Deploy_steps.md`](../../HAN_chat/Deploy_steps.md), [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
|
||||
- Исполняемые процедуры: [`RUNBOOK.production.ru.md`](../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md) для ВМ1 и [`RUNBOOK.ru.md`](../VM2_services/codebase/services/deployment/RUNBOOK.ru.md) для ВМ2.
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
# Закрыто 04.08-10.08
|
||||
## #BACK_DEFECT Исправлены дублирующиеся триггеры на создание контакта для сервиса синхронизации. Исправлено создание в БД лишних задач на обновление контакта (каждый бустрап пользователя вызывал задачу на обновление контакта)
|
||||
|
||||
# Закрыто 18.08-24.08
|
||||
## #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
|
||||
|
||||
# В разработку:
|
||||
|
||||
1 #BACK_SECURE После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
|
||||
@@ -56,7 +59,7 @@
|
||||
12. #INFRASTRUCTURE Перераскатить сервисы от деплоя
|
||||
13. #INFRASTRUCTURE Поднять второй контур для продакшн
|
||||
14. #INFRASTRUCTURE Спрятать сеть за балансировщиком нагрузки
|
||||
15. #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
|
||||
|
||||
16. #INFRASTRUCTURE WireGuard-only SSH.
|
||||
17. #LEGAL Обновить документы по ПД - модель угроз и меры защиты.
|
||||
18. #LEGAL Уведомление в РКН по БД обработки ПД.
|
||||
@@ -72,6 +75,7 @@
|
||||
28. #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel
|
||||
29. #BACK_BUSINESS Определить пул тестовых номеров, чтобы их было легко в Б24 отслеживать.
|
||||
30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true)
|
||||
31. #BACK_BUSINESS VM1 -> VM2: `curl -sS --cacert /etc/han/ca/vm2-internal-ca.crt "https://processing.internal:8443/internal/safety/status" | python3 -m json.tool` В коде захардкожен Redis в components = "degraded". Надо реализовать реальную проверку вместо костыля.
|
||||
|
||||
# Критично для релиза:
|
||||
1. Разработка message-safety
|
||||
+4
-4
@@ -39,14 +39,14 @@ find -type f -exec file {} \; | grep -i 'CRLF'
|
||||
|
||||
docker login cr.selcloud.ru
|
||||
|
||||
cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/bitrix-sync
|
||||
cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/bitrix-sync
|
||||
docker build -t han-bitrix-sync:1.0.3 .
|
||||
REGISTRY=cr.selcloud.ru/han-images
|
||||
docker tag han-bitrix-sync:1.0.3 $REGISTRY/han-bitrix-sync:1.0.3
|
||||
docker push $REGISTRY/han-bitrix-sync:1.0.3
|
||||
docker image inspect $REGISTRY/han-bitrix-sync:1.0.3 --format '{{index .RepoDigests 0}}'
|
||||
|
||||
cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/message-safety
|
||||
cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/message-safety
|
||||
docker build -t han-message-safety:1.0.2 .
|
||||
REGISTRY=cr.selcloud.ru/han-images # ваш registry
|
||||
docker tag han-message-safety:1.0.2 $REGISTRY/han-message-safety:1.0.2
|
||||
@@ -63,7 +63,7 @@ docker pull otel/opentelemetry-collector-contrib:0.117.0
|
||||
|
||||
docker image inspect nginxinc/nginx-unprivileged:1.27 --format '{{index .RepoDigests 0}}'
|
||||
docker image inspect redis:7.4 --format '{{index .RepoDigests 0}}'
|
||||
docker image inspect clamav/clamav:1.4 --format '{{index .RepoDigests 0}}'
|
||||
docker image inspect clamav/clamav:1.4.6 --format '{{index .RepoDigests 0}}'
|
||||
docker image inspect otel/opentelemetry-collector-contrib:0.117.0 --format '{{index .RepoDigests 0}}'
|
||||
|
||||
# Генерация ключей для новых пользователей и первичная настройка ВМ2
|
||||
@@ -148,7 +148,7 @@ netplan try
|
||||
Подтвердите конфигурацию в течение тайм-аута. Затем проверьте:
|
||||
ip -br -4 address show eth1
|
||||
ip route
|
||||
ping -c 3 192.168.0.1
|
||||
ping -c 3 192.168.0.210
|
||||
|
||||
# nginx правим разрешенные адреса
|
||||
nginx/allowlists/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user