diff --git a/.gitignore b/.gitignore index 03ffb11..2b96ebd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ *.log node_modules/ __pycache__/ -for_bugs_exchange/ \ No newline at end of file +for_bugs_exchange/ +*.tar.gz \ No newline at end of file diff --git a/VM1_app/codebase/backend/.env.example b/VM1_app/codebase/backend/.env.example index 14b50f1..52b990f 100644 --- a/VM1_app/codebase/backend/.env.example +++ b/VM1_app/codebase/backend/.env.example @@ -89,8 +89,9 @@ IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080 BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox +# Example only: set the actual VM2 private DNS name in the deployment .env. MESSAGE_SAFETY_URL=https://processing.internal:8443 -# Docker extra_hosts mapping for VM2 private listener: =. +# Docker extra_hosts mapping must use the same configured hostname: =. 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 diff --git a/VM1_app/codebase/backend/api-backend/README.md b/VM1_app/codebase/backend/api-backend/README.md index bc80573..b1623f4 100644 --- a/VM1_app/codebase/backend/api-backend/README.md +++ b/VM1_app/codebase/backend/api-backend/README.md @@ -59,8 +59,9 @@ han-notification-draft-cleanup-worker Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN` сохраняется как caller secret API backend; S3 credentials сервису Safety не передаются. В production подключение PostgreSQL должно использовать TLS. Target -`MESSAGE_SAFETY_URL=https://processing.internal:8443`, API prefix -`/internal/safety/v2`; certificate проверяется по CA из +задаётся через `MESSAGE_SAFETY_URL=https://:8443` +(`processing.internal` — только пример), API prefix `/internal/safety/v2`; +certificate проверяется по CA из `MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён. Smoke-сценарий `producer_test`: отправить `POST diff --git a/VM1_app/codebase/backend/keycloak/README.md b/VM1_app/codebase/backend/keycloak/README.md index 7816458..dc1521c 100644 --- a/VM1_app/codebase/backend/keycloak/README.md +++ b/VM1_app/codebase/backend/keycloak/README.md @@ -51,8 +51,10 @@ non-secret `PUBLIC_WEB_URL` environment variable. Keycloak resolves the initial `--import-realm`. Use exact Expo universal/app links and web origins. Do not replace them with -wildcards. `${PUBLIC_WEB_URL}/auth/callback` and `han-chat://auth/callback` are -allow-listed by the initial realm import. +wildcards. `${PUBLIC_WEB_URL}/auth/callback`, `${PUBLIC_WEB_URL}/mobile/oidc/callback` +and `han-chat://auth/callback` are allow-listed by the initial realm import. +Android Custom Tabs cannot follow a custom-scheme 302, so the mobile client uses +the HTTPS bridge page and then opens `han-chat://auth/callback`. The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`. diff --git a/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json b/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json index d6ed8e6..ad8ba86 100644 --- a/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json +++ b/VM1_app/codebase/backend/keycloak/realm/han-chat-realm.json @@ -61,6 +61,7 @@ "fullScopeAllowed": false, "redirectUris": [ "${PUBLIC_WEB_URL}/auth/callback", + "${PUBLIC_WEB_URL}/mobile/oidc/callback", "han-chat://auth/callback" ], "webOrigins": [ diff --git a/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java b/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java index 06b8cde..91513d2 100644 --- a/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java +++ b/VM1_app/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java @@ -29,6 +29,7 @@ class RealmContractTest { assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]")); assertTrue(realm.contains("\"han-chat://auth/callback\"")); assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/auth/callback\"")); + assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/mobile/oidc/callback\"")); assertTrue(realm.contains("\"${PUBLIC_WEB_URL}\"")); assertFalse(realm.contains("chat.han0107.ru")); } diff --git a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties index 8383777..d4752f5 100644 --- a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties +++ b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties @@ -18,7 +18,7 @@ otpResend=Отправить код снова authBack=Назад verifyOtp=Подтвердить otpVerifying=Проверяем... -otpSubmitUnavailable=Не удалось отправить код. Проверьте соединение и попробуйте ещё раз. +otpSubmitUnavailable=Не удалось завершить вход. Если код уже принят, закройте окно и откройте приложение. mockMode=Тестовый режим отправки кода phoneInvalid=Проверьте формат номера телефона. otpInvalid=Код неверен, истёк или уже использован. diff --git a/VM1_app/codebase/backend/nginx/Dockerfile b/VM1_app/codebase/backend/nginx/Dockerfile index 70ca8e7..515070e 100644 --- a/VM1_app/codebase/backend/nginx/Dockerfile +++ b/VM1_app/codebase/backend/nginx/Dockerfile @@ -7,9 +7,10 @@ RUN apt-get update \ COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template COPY templates /etc/nginx/templates-src/sites COPY snippets /etc/nginx/snippets +COPY static /etc/nginx/static COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint RUN sed -i 's/\r$//' /usr/local/bin/han-nginx-entrypoint \ && /bin/sh -n /usr/local/bin/han-nginx-entrypoint \ && chmod 0555 /usr/local/bin/han-nginx-entrypoint \ - && find /etc/nginx/templates-src /etc/nginx/snippets -type f -exec chmod 0444 {} + + && find /etc/nginx/templates-src /etc/nginx/snippets /etc/nginx/static -type f -exec chmod 0444 {} + ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"] diff --git a/VM1_app/codebase/backend/nginx/static/mobile-oidc-callback.html b/VM1_app/codebase/backend/nginx/static/mobile-oidc-callback.html new file mode 100644 index 0000000..cc0d9ce --- /dev/null +++ b/VM1_app/codebase/backend/nginx/static/mobile-oidc-callback.html @@ -0,0 +1,46 @@ + + + + + + HAN Chat + + + +

Вход выполнен. Возвращаем в приложение…

+

+ Открыть HAN Chat +

+ + + diff --git a/VM1_app/codebase/backend/nginx/static/mobile-oidc-callback.js b/VM1_app/codebase/backend/nginx/static/mobile-oidc-callback.js new file mode 100644 index 0000000..fb71b20 --- /dev/null +++ b/VM1_app/codebase/backend/nginx/static/mobile-oidc-callback.js @@ -0,0 +1,6 @@ +(function () { + var target = "han-chat://auth/callback" + window.location.search + window.location.hash; + var link = document.getElementById("han-open-app"); + if (link) link.href = target; + window.location.replace(target); +})(); diff --git a/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template b/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template index 955183e..32c6da6 100644 --- a/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template +++ b/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template @@ -139,6 +139,23 @@ server { add_header Cache-Control "no-cache"; include /etc/nginx/generated/security-headers.conf; } + location = /mobile/oidc/callback { + default_type text/html; + charset utf-8; + alias /etc/nginx/static/mobile-oidc-callback.html; + sub_filter_once on; + sub_filter_types text/html; + sub_filter HAN_CALLBACK_QUERY ?$args; + add_header Cache-Control "no-store" always; + include /etc/nginx/generated/security-headers.conf; + } + location = /mobile/oidc/callback.js { + default_type text/javascript; + charset utf-8; + alias /etc/nginx/static/mobile-oidc-callback.js; + add_header Cache-Control "no-store" always; + include /etc/nginx/generated/security-headers.conf; + } location ^~ /auth/resources/ { include /etc/nginx/snippets/proxy-keycloak.conf; proxy_pass http://keycloak_upstream; diff --git a/VM1_app/codebase/backend/scripts/validate-env b/VM1_app/codebase/backend/scripts/validate-env index b337b1e..0b0f253 100644 --- a/VM1_app/codebase/backend/scripts/validate-env +++ b/VM1_app/codebase/backend/scripts/validate-env @@ -197,14 +197,29 @@ def validate_shared(env: dict[str, str], errors: list[str]) -> None: errors.append(f"{key}: ожидается http URL с Docker DNS service name") if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"): errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth") - if env.get("MESSAGE_SAFETY_URL", "").rstrip("/") != "https://processing.internal:8443": + safety_url = env.get("MESSAGE_SAFETY_URL", "").rstrip("/") + parsed_safety_url = urlparse(safety_url) + try: + safety_port = parsed_safety_url.port + except ValueError: + safety_port = None + safety_host = parsed_safety_url.hostname or "" + if ( + parsed_safety_url.scheme != "https" + or safety_port != 8443 + or not re.fullmatch(r"[A-Za-z0-9.-]+", safety_host) + or safety_host.lower() in {"message-safety", "localhost", "127.0.0.1"} + or parsed_safety_url.path + or parsed_safety_url.params + or parsed_safety_url.query + or parsed_safety_url.fragment + ): errors.append( "MESSAGE_SAFETY_URL: ожидается remote TLS endpoint " - "https://processing.internal:8443" + "https://:8443, не local/stub service" ) try: extra_host, extra_ip = env.get("MESSAGE_SAFETY_EXTRA_HOST", "").rsplit("=", 1) - safety_host = urlparse(env.get("MESSAGE_SAFETY_URL", "")).hostname address = ipaddress.ip_address(extra_ip) if extra_host != safety_host or not address.is_private: raise ValueError diff --git a/VM1_app/codebase/backend/tests/test_secret_hygiene.py b/VM1_app/codebase/backend/tests/test_secret_hygiene.py index 3bbd42d..1978ea4 100644 --- a/VM1_app/codebase/backend/tests/test_secret_hygiene.py +++ b/VM1_app/codebase/backend/tests/test_secret_hygiene.py @@ -152,6 +152,28 @@ class SecretHygieneTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn(expected_error, result.stderr) + def test_validator_accepts_configured_private_safety_hostname(self) -> None: + example = (ROOT / ".env.example").read_text(encoding="utf-8") + configured = ( + example.replace( + "KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false", + "KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true", + ) + .replace( + "MESSAGE_SAFETY_URL=https://processing.internal:8443", + "MESSAGE_SAFETY_URL=https://safety.vm2.corp.internal:8443", + ) + .replace( + "MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4", + "MESSAGE_SAFETY_EXTRA_HOST=safety.vm2.corp.internal=192.168.0.4", + ) + ) + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / ".env" + config.write_text(configured, encoding="utf-8") + result = self.run_validator(config) + self.assertEqual(result.returncode, 0, result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/VM1_app/documentation/module-01-api-backend.md b/VM1_app/documentation/module-01-api-backend.md index ebb7017..fbe1588 100644 --- a/VM1_app/documentation/module-01-api-backend.md +++ b/VM1_app/documentation/module-01-api-backend.md @@ -1085,7 +1085,7 @@ Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый `SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04. -В production validator принимает только remote `https://:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и cross-host Docker hostname. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`. +В production validator принимает remote `https://:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и local/stub Docker hostname. Конкретное private DNS-имя ВМ2 определяется внутренним доменом окружения и задаётся в `MESSAGE_SAFETY_URL` и согласованном `MESSAGE_SAFETY_EXTRA_HOST`; каноническое имя не требуется. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`. ## 19. Rate limiting diff --git a/VM1_app/documentation/module-03-nginx-vm1.md b/VM1_app/documentation/module-03-nginx-vm1.md index b6e5493..cd7249a 100644 --- a/VM1_app/documentation/module-03-nginx-vm1.md +++ b/VM1_app/documentation/module-03-nginx-vm1.md @@ -19,6 +19,7 @@ Nginx ВМ1 — публичная точка входа приложения: f |---|---|---| | `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS | | `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer | +| exact `/mobile/oidc/callback` | static nginx | HTTPS-мост Android Custom Tabs → `han-chat://auth/callback` | | exact `/callbacks/idgtl/sms` | `sms-service:8080` | public HTTPS POST Direct; IP allowlist + Basic auth в upstream | | `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS | | exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy | diff --git a/VM1_app/documentation/module-08-keycloak.md b/VM1_app/documentation/module-08-keycloak.md index d9a9908..090ebb8 100644 --- a/VM1_app/documentation/module-08-keycloak.md +++ b/VM1_app/documentation/module-08-keycloak.md @@ -92,6 +92,7 @@ han-chat-frontend ```text https://tohin.ru/auth/callback +https://tohin.ru/mobile/oidc/callback han-chat://auth/callback ``` diff --git a/VM1_app/documentation/module-10-deployment-vm1.md b/VM1_app/documentation/module-10-deployment-vm1.md index 8e64d06..2cbe85e 100644 --- a/VM1_app/documentation/module-10-deployment-vm1.md +++ b/VM1_app/documentation/module-10-deployment-vm1.md @@ -49,7 +49,9 @@ webhooks собственного host. Compose, IAM principal и secret bundle performance, egress и rollback gates своего runbook. 2. ВМ1 использует `MESSAGE_SAFETY_URL=https://:8443` и root-owned - `MESSAGE_SAFETY_CA_HOST_PATH`. + `MESSAGE_SAFETY_CA_HOST_PATH`. Значение `` определяется + внутренним DNS-доменом окружения и задаётся в `.env`; фиксированное + каноническое имя не требуется. 3. Internal CA читается фактическим UID API и не читается посторонним UID. 4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops. 5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback diff --git a/VM1_app/vm1-bug-v1.tar.gz b/VM1_app/vm1-bug-v1.tar.gz deleted file mode 100644 index 6b76a44..0000000 Binary files a/VM1_app/vm1-bug-v1.tar.gz and /dev/null differ diff --git a/VM1_app/vm1-bug-v2.tar.gz b/VM1_app/vm1-bug-v2.tar.gz deleted file mode 100644 index c580280..0000000 Binary files a/VM1_app/vm1-bug-v2.tar.gz and /dev/null differ diff --git a/VM2_services/vm1-bug-v10.tar.gz b/VM2_services/vm1-bug-v10.tar.gz deleted file mode 100644 index d95f242..0000000 Binary files a/VM2_services/vm1-bug-v10.tar.gz and /dev/null differ diff --git a/VM3_signoz/Signoz/README.md b/VM3_signoz/Signoz/README.md index 0427dec..7851ce6 100644 --- a/VM3_signoz/Signoz/README.md +++ b/VM3_signoz/Signoz/README.md @@ -1,7 +1,8 @@ # SigNoz для HAN Chat Одноузловой self-hosted SigNoz на Ubuntu 24.04 через официальный -`foundryctl` и Docker Compose. +`foundryctl` и Docker Compose. Исполняемый порядок раскатки, lockdown и +обновления — только в [docs/RUNBOOK.ru.md](docs/RUNBOOK.ru.md). Сетевая модель: @@ -19,127 +20,13 @@ - рабочий приватный интерфейс с адресом `192.168.0.5`; - доступ к ВМ по SSH через приватную сеть до отключения внешнего IP. -## Установка - -Скопируйте эту папку на ВМ, например в `/opt/signoz`: - -```bash -rsync -rltD --no-perms --no-owner --no-group -ivc --delete \ - --exclude='.env' \ - --exclude='dist/' \ - --exclude='secrets/' \ - --exclude='pours/' \ - --exclude='casting.yaml.lock' \ - -e "ssh -i ~/.ssh/hansel-private" \ - /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/Signoz/ \ - root@135.106.166.7:/opt/signoz/ -``` - -Исключения `pours/` и `casting.yaml.lock` обязательны при повторной -синхронизации: Foundry создаёт их непосредственно на VM. Без исключений -`rsync --delete` удалит runtime-конфигурацию, которой нет локально. - -На ВМ: - -```bash -cd /opt/signoz -sed -i 's/\r$//' scripts/*.sh -chmod +x scripts/*.sh - -sudo ./scripts/05-configure-private-network.sh -sudo PRIVATE_IP=192.168.0.5 ./scripts/00-check-vm.sh -sudo ./scripts/10-install-docker.sh -sudo PRIVATE_IP=192.168.0.5 ./scripts/20-deploy-signoz.sh -``` - -После запуска создайте первого администратора и организацию SigNoz. До этого -OpAMP не выдаст ingester рабочую OTLP-конфигурацию, хотя контейнер будет -выглядеть запущенным. - -Адреса `127.0.0.1` всегда означают loopback той машины, на которой -интерпретируются. HAN_CHAT имеет приватный адрес `192.168.0.1`, а SigNoz — -`192.168.0.5`. UI слушает `127.0.0.1:8080` именно на VM SigNoz. - -Для постоянного доступа после удаления публичного IP SigNoz добавьте в -`C:\Users\MI\.ssh\config`: - -```sshconfig -Host han-jump - HostName 135.106.164.58 - User root - IdentityFile C:\Users\MI\.ssh\hansel - -Host signoz-private - HostName 192.168.0.5 - User root - IdentityFile C:\Users\MI\.ssh\hansel-private - ProxyJump han-jump -``` -Затем ssh signoz-ui -N - -(устаревшее: -На рабочем компьютере откройте туннель до SigNoz через HAN_CHAT: - -```powershell -ssh -L 8080:127.0.0.1:8080 signoz-private -N -``` -) -Маршрут SSH: Windows → публичный адрес HAN_CHAT → `192.168.0.5:22`. -Назначение `127.0.0.1:8080` в `-L` открывает конечная VM SigNoz, а не -HAN_CHAT. - -Откройте `http://127.0.0.1:8080`, создайте администратора/организацию, затем -на VM выполните: - -```bash -cd /opt/signoz -sudo docker compose -f pours/deployment/compose.yaml restart ingester -sudo ./scripts/30-verify-signoz.sh -``` - -После успешной проверки настройте host firewall. Подставьте реальный приватный -IP backend: - -```bash -sudo OTLP_SOURCE=192.168.0.1 \ - ADMIN_CIDR=192.168.0.0/24 \ - PRIVATE_IP=192.168.0.5 \ - ./scripts/40-configure-firewall.sh -``` - -Затем выполните чек-лист из [docs/NETWORK.md](docs/NETWORK.md), подключите -backend по [docs/BACKEND_OTLP.md](docs/BACKEND_OTLP.md) и только после -успешной end-to-end проверки удалите внешний IP. - -## Проверка и управление - -```bash -cd /opt/signoz -sudo ./scripts/30-verify-signoz.sh -sudo docker compose -f pours/deployment/compose.yaml ps -sudo docker compose -f pours/deployment/compose.yaml logs --tail=200 -sudo docker compose -f pours/deployment/compose.yaml restart -``` - -Не редактируйте `pours/` вручную: Foundry перегенерирует эту папку. -Постоянные изменения вносятся в `casting.yaml`, после чего снова запускается -`20-deploy-signoz.sh`. - -Обновление требует временного доступа к Docker Hub, GitHub и SigNoz: - -```bash -cd /opt/signoz -sudo ./scripts/20-deploy-signoz.sh -``` - -Перед обновлением сделайте snapshot диска ВМ. Данные находятся в Docker -volumes ClickHouse и PostgreSQL; `docker compose down -v` удалит их и поэтому -для штатного обслуживания запрещён. - ## Документация +- [RUNBOOK.ru.md](docs/RUNBOOK.ru.md) — раскатка ВМ3, verify, firewall, + lockdown, обновление и rollback; - [NETWORK.md](docs/NETWORK.md) — сеть, группа безопасности, SSH-туннель; - [BACKEND_OTLP.md](docs/BACKEND_OTLP.md) — передача телеметрии HAN Chat; -- [SIGNOZ_RUNBOOK.md](docs/SIGNOZ_RUNBOOK.md) — что смотреть в SigNoz; +- [SIGNOZ_RUNBOOK.md](docs/SIGNOZ_RUNBOOK.md) — ежедневная работа в UI и + разбор инцидентов; - [MVP_DASHBOARDS_ALERTS.md](docs/MVP_DASHBOARDS_ALERTS.md) — versioned спецификация первых dashboards и alerts. diff --git a/VM3_signoz/Signoz/docs/NETWORK.md b/VM3_signoz/Signoz/docs/NETWORK.md index 338cf34..997b853 100644 --- a/VM3_signoz/Signoz/docs/NETWORK.md +++ b/VM3_signoz/Signoz/docs/NETWORK.md @@ -1,5 +1,9 @@ # Сеть и доступ +Исполняемый порядок раскатки, включая Netplan, SG и lockdown — в +[RUNBOOK.ru.md](RUNBOOK.ru.md). Этот файл фиксирует сетевую диагностику и +проверки доступа. + ## Сбор диагностики До изменения Netplan выполните и сохраните вывод: @@ -50,29 +54,28 @@ sudo ./scripts/05-configure-private-network.sh ```bash ip -br -4 address show eth1 ip route get 192.168.0.1 -ping -c 3 192.168.0.1 ``` -С другой VM приватной сети проверьте: +С другой VM приватной сети проверьте SSH. ICMP в группе безопасности обычно +закрыт, `ping` не является проверкой связности: ```bash -ping -c 3 192.168.0.5 -ssh -i ~/.ssh/hansel-private root@192.168.0.5 +ssh -i ~/.ssh/hansel-private -o ConnectTimeout=5 root@192.168.0.5 ``` Если новая SSH-сессия работает, вернитесь в первую и подтвердите Netplan клавишей Enter. Если нет — не подтверждайте: через 120 секунд произойдёт откат. -Отсутствие ответа на ping само по себе может означать запрет ICMP; SSH является -основной проверкой. ## Группа безопасности Минимальные входящие правила для VM SigNoz: -- TCP 22 от административного узла/подсети приватной сети; -- TCP 4317 от security groups/private IP ВМ1 и ВМ2; -- TCP 4318 от ВМ1/ВМ2 только если планируется OTLP/HTTP; +- TCP 22: на хосте UFW разрешает любой источник; конкретные IP/подсети + задаются в SG или файрволе приватной сети провайдера; +- TCP 4317 от приватной подсети `192.168.0.0/24` (ВМ1 и ВМ2); +- TCP 4318 от той же подсети, если нужен OTLP/HTTP; - никаких входящих правил для 8080, 5432, 8123, 9000, 9181. + UI только через SSH `-L 8080:127.0.0.1:8080`. Для текущего backend используется OTLP/gRPC, поэтому после проверки 4318 можно закрыть. Исходящий доступ к приватной сети оставьте. Для обновления временно diff --git a/VM3_signoz/Signoz/docs/RUNBOOK.ru.md b/VM3_signoz/Signoz/docs/RUNBOOK.ru.md new file mode 100644 index 0000000..1e83eba --- /dev/null +++ b/VM3_signoz/Signoz/docs/RUNBOOK.ru.md @@ -0,0 +1,407 @@ +# Runbook развёртывания SigNoz (ВМ3) + +Это единственный исполняемый runbook раскатки self-hosted SigNoz для HAN Chat. +Команды выполняет оператор; repository automation их не запускает. + +ВМ3 — private/no-egress узел по +[`arch-06-service-hosting-security.md`](../../../architectory/arch-06-service-hosting-security.md): +после приёмки нет internet ingress, нет постоянного internet egress, UI не +публикуется, OTLP доступен только из приватной сети. Раскатка не завершена, +пока не закрыт lockdown и обе группы проверок (снаружи и из private network). + +Эксплуатация UI, разбор инцидентов и алерты — в +[`SIGNOZ_RUNBOOK.md`](SIGNOZ_RUNBOOK.md). Подключение приложений — +[`BACKEND_OTLP.md`](BACKEND_OTLP.md). Сеть и группы безопасности — +[`NETWORK.md`](NETWORK.md). + +## 0. Назначение, инвентарь и stop conditions + +Каталог репозитория: `HAN_chat_specification/VM3_signoz/Signoz/`. +Рабочий каталог на ВМ: `/opt/signoz`. Compose Foundry создаёт в +`pours/deployment/compose.yaml`; этот каталог принадлежит runtime ВМ и не +хранится в git. + +Текущее окружение (подставьте актуальные значения, если они изменились): + +| Узел | Публичный IP | Приватный IP | +|---|---|---| +| ВМ1 HAN Chat (jump) | `135.106.164.58` | `192.168.0.1` | +| ВМ2 Processing | — | `192.168.0.4` | +| ВМ3 SigNoz | `135.106.166.7` только на bootstrap | `192.168.0.5` | + +Порты: + +- OTLP/gRPC: `192.168.0.5:4317` — основной ingest ВМ1 и ВМ2; +- OTLP/HTTP: `192.168.0.5:4318` — диагностика, после приёмки можно закрыть; +- UI/API: `127.0.0.1:8080` на ВМ3, доступ только через SSH-туннель; +- ClickHouse `8123/9000`, PostgreSQL `5432`, ClickHouse Keeper `9181` наружу + не публикуются. + +SSH-ключи (не коммитьте private keys): + +- jump/ВМ1: `C:\Users\MI\.ssh\hansel`; +- приватный вход на ВМ3: `C:\Users\MI\.ssh\hansel-private`. + +Stop condition: нет приватного `192.168.0.5`, MAC `eth1` не совпал, нет +исходящего HTTPS на bootstrap, заняты `8080/4317/4318`, Compose публикует +служебный порт на `0.0.0.0`, не создан первый администратор SigNoz, verify +скрипт завершился ошибкой, нет альтернативного SSH через jump, нет snapshot +диска перед lockdown/обновлением. + +Запрещены: `docker compose down -v`, `docker volume prune`, +`docker system prune --volumes`; ручное редактирование `pours/`; +`rsync --delete` без исключений `pours/` и `casting.yaml.lock`; публикация +`8080/4317/4318` на всех интерфейсах; постоянный public SSH «на будущее». + +Текущие host-скрипты выполняются от `root`. Роли `deploy`/`admin` из arch-06 +на ВМ3 ещё не автоматизированы; не имитируйте их вручную поверх Foundry. + +## 1. Предварительные условия (облако) + +До копирования файлов на ВМ подготовьте вне Compose: + +1. Fresh Ubuntu 24.04, минимум 4 GiB RAM (лучше 8 GiB) и 30 GiB свободно + в `/opt`. Для эксплуатации ClickHouse закладывайте запас диска под + retention. +2. Private subnet `192.168.0.0/24` с ВМ1, ВМ2 и managed PostgreSQL. + Приватный адрес ВМ3 — `192.168.0.5`. +3. Временный public IP только на окно bootstrap. Cloud SG на этом этапе: + SSH TCP 22 с trusted ops CIDR, исходящий HTTPS/DNS к Docker Hub, GitHub, + `signoz.io`, Ubuntu archive. Порты `8080/4317/4318/5432/8123/9000/9181` + из интернета запрещены. +4. После lockdown целевая SG: + - TCP 22 от jump/приватной подсети (`192.168.0.1` или SG ВМ1); + - TCP 4317 от приватных IP/SG ВМ1 и ВМ2; + - TCP 4318 только если оставлен HTTP ingest; + - никакого internet ingress; + - internet egress закрыт, кроме согласованного break-glass окна. +5. Зафиксируйте MAC приватного NIC. Скрипт + `05-configure-private-network.sh` по умолчанию ждёт + `eth1` / `fa:16:3e:b6:c6:70`. Если MAC другой — передайте + `PRIVATE_MAC` явно, не правьте cloud-init `eth0`. + +## 2. SSH-доступ оператора + +На Windows добавьте в `C:\Users\MI\.ssh\config`: + +```sshconfig +Host han-jump + HostName 135.106.164.58 + User root + IdentityFile C:\Users\MI\.ssh\hansel + +Host signoz-bootstrap + HostName 135.106.166.7 + User root + IdentityFile C:\Users\MI\.ssh\hansel + +Host signoz-private + HostName 192.168.0.5 + User root + IdentityFile C:\Users\MI\.ssh\hansel-private + ProxyJump han-jump + +Host signoz-ui + HostName 192.168.0.5 + User root + IdentityFile C:\Users\MI\.ssh\hansel-private + ProxyJump han-jump + LocalForward 8080 127.0.0.1:8080 +``` + +До lockdown: `ssh signoz-bootstrap`. После появления приватного адреса +проверьте `ssh signoz-private`, не закрывая bootstrap-сессию. Для UI: + +```powershell +ssh signoz-ui -N +``` + +Откройте `http://127.0.0.1:8080`. Туннель обязан указывать на loopback +**ВМ3**. Не используйте `-L 8080:192.168.0.5:8080` через jump: UI слушает +`127.0.0.1:8080` на SigNoz, а не на `192.168.0.5`. + +## 3. Передача каталога на ВМ + +С WSL, не закрывая исключений. `pours/` и `casting.yaml.lock` создаёт +Foundry на ВМ; без исключений `rsync --delete` уничтожит runtime. + +Первичная установка (публичный IP ещё есть): + +```bash +rsync -rltD --no-perms --no-owner --no-group -ivc \ + --exclude='.env' \ + --exclude='dist/' \ + --exclude='secrets/' \ + --exclude='pours/' \ + --exclude='casting.yaml.lock' \ + -e "ssh -i ~/.ssh/hansel" \ + /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/VM3_signoz/Signoz/ \ + root@135.106.166.7:/opt/signoz/ +``` + +Повторная синхронизация после lockdown — через jump (`signoz-private`) и +с `--delete`, сохранив те же exclude. + +На ВМ: + +```bash +cd /opt/signoz +sed -i 's/\r$//' scripts/*.sh +chmod +x scripts/*.sh +``` + +Не копируйте `.env`, ключи и `pours/`. Постоянные правки портов вносятся +только в `casting.yaml`, затем снова запускается `20-deploy-signoz.sh`. + +## 4. Приватный интерфейс + +Сохраните диагностику по [`NETWORK.md`](NETWORK.md). Default route должен +остаться на публичном `eth0`; у `eth1` gateway нет. + +```bash +cd /opt/signoz +sudo ./scripts/05-configure-private-network.sh +``` + +Пока `netplan try` ждёт 120 секунд, во **второй** сессии проверьте: + +```bash +ip -br -4 address show eth1 +ip route get 192.168.0.1 +``` + +С ВМ1. ICMP в SG обычно закрыт, `ping` не используйте: + +```bash +ssh -i ~/.ssh/hansel-private -o ConnectTimeout=5 root@192.168.0.5 +``` + +Подтверждайте Netplan Enter только после успешного SSH с ВМ1. + +## 5. Preflight и Docker + +Исходящий интернет на этом шаге ещё нужен. + +```bash +cd /opt/signoz +sudo PRIVATE_IP=192.168.0.5 ./scripts/00-check-vm.sh +sudo ./scripts/10-install-docker.sh +``` + +`00-check-vm.sh` требует Ubuntu 24.04, ≥4 GiB RAM, ≥30 GiB в `/opt`, адрес +`192.168.0.5`, DNS/HTTPS к Docker Hub, GitHub и SigNoz, свободные +`8080/4317/4318`. Любой `FAIL` — stop. + +## 6. Первый запуск SigNoz + +`casting.yaml` обязан содержать bind `192.168.0.5:4317/4318` и +`127.0.0.1:8080`. Скрипт остановится, если Foundry опубликует эти порты на +`0.0.0.0`. + +```bash +cd /opt/signoz +sudo PRIVATE_IP=192.168.0.5 ./scripts/20-deploy-signoz.sh +``` + +Ожидается: `foundryctl gauge/forge`, `compose pull/up`, UI health на +`http://127.0.0.1:8080/api/v1/health` за ≤5 минут. Контейнеры `running` ещё +не означают готовый ingest: до создания организации OpAMP не выдаёт +ingester рабочую OTLP-конфигурацию. + +## 7. Первый администратор + +На рабочей станции: + +```powershell +ssh signoz-ui -N +``` + +Откройте `http://127.0.0.1:8080`, создайте организацию и администратора. +Пароль не сохраняйте в репозитории и history. Затем на ВМ3: + +```bash +cd /opt/signoz +sudo docker compose -f pours/deployment/compose.yaml restart ingester +``` + +Без этого шага TCP `4317` может слушаться, а телеметрия — не приниматься. + +## 8. Verify стека + +```bash +cd /opt/signoz +sudo PRIVATE_IP=192.168.0.5 ./scripts/30-verify-signoz.sh +sudo docker compose -f pours/deployment/compose.yaml ps -a +ss -lntp '( sport = :8080 or sport = :4317 or sport = :4318 )' +``` + +Ожидается healthy PostgreSQL, ClickHouse Keeper, ClickHouse, SigNoz UI; +TCP `4317` на `192.168.0.5`; HTTP `4318` принимает пустой OTLP JSON; +`8080` только на `127.0.0.1`. Любой `FAIL` — смотрите +`docker compose -f pours/deployment/compose.yaml logs --tail=200`, не +продолжайте к firewall/lockdown. + +С ВМ1 и ВМ2: + +```bash +timeout 3 bash -c 'exec 3<>/dev/tcp/192.168.0.5/4317' \ + && echo 'OTLP gRPC reachable' +``` + +## 9. Host firewall и cloud SG + +Скрипт интерактивный: введите `APPLY` только после второй живой SSH-сессии. +Он сбрасывает UFW и ставит: + +- TCP 22 с любого источника — allow-list IP задаёте в SG/файрволе + приватной сети провайдера; +- TCP 4317 и 4318 на `192.168.0.5` только из `192.168.0.0/24` (ВМ1 и ВМ2); +- 8080 не открывается. UI остаётся на `127.0.0.1:8080` и доступен так: + +```powershell +ssh -i C:\Users\MI\.ssh\hansel -L 8080:127.0.0.1:8080 root@ -N +``` + +```bash +cd /opt/signoz +sudo PRIVATE_IP=192.168.0.5 \ + PRIVATE_CIDR=192.168.0.0/24 \ + ./scripts/40-configure-firewall.sh +``` + +UFW INPUT не фильтрует все Docker-публикации. Источник OTLP дополнительно +держите в SG. `casting.yaml` биндит OTLP на приватный IP, UI — на loopback. + +В SG не открывайте публично 8080/5432/8123/9000/9181. SSH 22 режьте +источником на стороне провайдера, не на UFW. + +## 10. Подключение ВМ1 и ВМ2 + +Не считайте раскатку ВМ3 законченной по одной проверке TCP `4317`. Канал ingest +настраивается на application VM по [`BACKEND_OTLP.md`](BACKEND_OTLP.md): + +```dotenv +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 +OTEL_REMOTE_ENDPOINT=192.168.0.5:4317 +OTEL_REMOTE_AUTH_HEADER= +OTEL_REMOTE_TLS_INSECURE=true +``` + +`OTEL_REMOTE_TLS_INSECURE=true` допустим только в этой изолированной +приватной сети. После recreate collector на каждой VM отправьте canary +traces и найдите в SigNoz: + +```text +service.name = han-chat-otlp-smoke +service.namespace = han-chat +deployment.environment = production-like +``` + +Collector сохраняет около 10% успешных traces: для smoke нужно порядка +100 traces, не 10. End-to-end и outage/recovery acceptance описаны в +`BACKEND_OTLP.md`; без них отсутствие ошибок в UI не доказывает здоровье +HAN Chat. + +## 11. Snapshot, reboot gate и lockdown + +До удаления public IP: + +1. `ssh signoz-private` открывается отдельной сессией. +2. Туннель `signoz-ui` показывает UI, администратор существует. +3. `30-verify-signoz.sh` без ошибок. +4. С ВМ1 и ВМ2 доступен `192.168.0.5:4317`. +5. В SigNoz есть свежий canary или production-like trace. +6. Все критичные контейнеры `running`/`healthy`. +7. Создан snapshot диска ВМ3. +8. В SG нет публичного доступа к служебным портам. + +Reboot gate. Не закрывайте jump/console доступ: + +```bash +cd /opt/signoz +sudo ./scripts/30-verify-signoz.sh +sudo systemctl is-enabled docker.service +sudo systemctl reboot +``` + +После reconnect через `signoz-private` повторите verify, `compose ps`, +слушатели портов и canary с ВМ1. + +Lockdown по arch-06: + +- удалите public IP `135.106.166.7`; +- уберите internet ingress из SG, включая public SSH; +- закройте общий internet egress (DNS/NTP/private OTLP остаются); +- с внешней сети SSH и `8080/4317` недоступны; +- из private network SSH через jump и OTLP `4317` работают; +- bootstrap installer-файлы и ненужные package caches можно удалить. + +Раскатка не завершена, пока lockdown не зафиксирован. Постоянно оставлять +public IP «для обновлений» запрещено. + +## 12. Штатное обслуживание и обновление + +Проверка: + +```bash +cd /opt/signoz +sudo ./scripts/30-verify-signoz.sh +sudo docker compose -f pours/deployment/compose.yaml ps +sudo docker compose -f pours/deployment/compose.yaml logs --since=30m +df -h +docker system df +``` + +Не редактируйте `pours/` вручную. Изменение bind-адресов — только +`casting.yaml` + `20-deploy-signoz.sh`. + +Обновление — break-glass по arch-06: временный исходящий HTTPS к Docker Hub, +GitHub и SigNoz, минимальное окно, затем повтор lockdown. + +```bash +cd /opt/signoz +# 1. snapshot диска +# 2. временный egress +sudo df -h +sudo ./scripts/20-deploy-signoz.sh +sudo ./scripts/30-verify-signoz.sh +# 3. проверить сохранность старых traces/metrics +# 4. закрыть egress +``` + +Перед обновлением синхронизируйте git-каталог на ВМ с теми же exclude, что +в §3. После обновления повторите smoke canary с ВМ1. + +## 13. Rollback и потеря ВМ + +Откат приложения: предыдущий проверенный `casting.yaml` + повтор +`20-deploy-signoz.sh` **без** `-v`. Данные живут в Docker volumes ClickHouse +и PostgreSQL; их удаление — потеря телеметрии. + +Потеря ВМ3: новая Ubuntu 24.04 этим runbook, тот же приватный `192.168.0.5` +(или согласованная смена endpoint на ВМ1/ВМ2), новые volumes пустые. +Исторические traces не восстанавливаются без snapshot диска. Collectors на +ВМ1/ВМ2 fail-open для бизнеса и буферизуют в bounded queue; это не замена +SigNoz. + +Компрометация host: не «чистите» ВМ. Изолируйте, сохраните evidence, +ротируйте доступ и раскатывайте заново из trusted image. + +## 14. Acceptance record + +Сохраните без паролей: + +- дата/UTC окна, исполнитель, approver; +- публичный IP bootstrap и факт его удаления; +- приватный IP, MAC `eth1`, правила SG и `ufw status`; +- вывод `00-check-vm.sh` и `30-verify-signoz.sh`; +- `docker compose ps` и image ids; +- факт создания организации/администратора (без секрета); +- ссылка на canary trace `han-chat-otlp-smoke`; +- snapshot id, reboot gate, lockdown checks снаружи и из private network; +- следующее согласованное окно для dashboards/alerts по + [`MVP_DASHBOARDS_ALERTS.md`](MVP_DASHBOARDS_ALERTS.md). + +После приёмки ежедневная работа — [`SIGNOZ_RUNBOOK.md`](SIGNOZ_RUNBOOK.md). +Dashboards и paging alerts создавайте только после baseline, не в этом же +окне раскатки. diff --git a/VM3_signoz/Signoz/docs/SIGNOZ_RUNBOOK.md b/VM3_signoz/Signoz/docs/SIGNOZ_RUNBOOK.md index ab9c0ac..c71d027 100644 --- a/VM3_signoz/Signoz/docs/SIGNOZ_RUNBOOK.md +++ b/VM3_signoz/Signoz/docs/SIGNOZ_RUNBOOK.md @@ -1,5 +1,9 @@ # Работа с SigNoz для HAN Chat +Это эксплуатационный runbook UI: фильтры, ежедневная проверка и разбор +инцидентов. Раскатка ВМ3, lockdown и обновление стека — только в +[RUNBOOK.ru.md](RUNBOOK.ru.md). + ## Базовые фильтры Во всех разделах начинайте с: diff --git a/VM3_signoz/Signoz/scripts/00-check-vm.sh b/VM3_signoz/Signoz/scripts/00-check-vm.sh index 133ed6c..58e351f 100644 --- a/VM3_signoz/Signoz/scripts/00-check-vm.sh +++ b/VM3_signoz/Signoz/scripts/00-check-vm.sh @@ -36,18 +36,35 @@ else fail "DNS не разрешает адреса репозиториев" fi -for url in \ - https://archive.ubuntu.com \ - https://download.docker.com \ - https://registry-1.docker.io \ - https://github.com \ - https://signoz.io; do - if curl -4fsSI --connect-timeout 8 --max-time 15 "$url" >/dev/null; then - ok "доступен $url" - else - fail "нет доступа к $url" - fi -done +probe_https() { + local label="$1" + local url="$2" + shift 2 + local code + code="$(curl -4sS -o /dev/null -w '%{http_code}' \ + --connect-timeout 8 --max-time 15 "$url" || true)" + local expected + for expected in "$@"; do + if [[ "$code" == "$expected" ]]; then + ok "доступен $label" + return 0 + fi + done + fail "нет доступа к $label (HTTP ${code:-нет ответа})" +} + +# Сайты отвечают 2xx/3xx. Docker Registry API на /v2/ без токена штатно +# отдаёт 401; корень registry-1.docker.io часто даёт 404 — это не отказ сети. +probe_https https://archive.ubuntu.com https://archive.ubuntu.com \ + 200 301 302 303 307 308 +probe_https https://download.docker.com https://download.docker.com \ + 200 301 302 303 307 308 +probe_https https://registry-1.docker.io https://registry-1.docker.io/v2/ \ + 200 401 +probe_https https://github.com https://github.com \ + 200 301 302 303 307 308 +probe_https https://signoz.io https://signoz.io \ + 200 301 302 303 307 308 echo echo "=== Занятость портов ===" diff --git a/VM3_signoz/Signoz/scripts/40-configure-firewall.sh b/VM3_signoz/Signoz/scripts/40-configure-firewall.sh index 46089cd..bd6792f 100644 --- a/VM3_signoz/Signoz/scripts/40-configure-firewall.sh +++ b/VM3_signoz/Signoz/scripts/40-configure-firewall.sh @@ -2,43 +2,70 @@ set -Eeuo pipefail PRIVATE_IP="${PRIVATE_IP:-192.168.0.5}" -ADMIN_CIDR="${ADMIN_CIDR:-192.168.0.0/24}" -OTLP_SOURCE="${OTLP_SOURCE:-}" +PRIVATE_CIDR="${PRIVATE_CIDR:-192.168.0.0/24}" if [[ $EUID -ne 0 ]]; then echo "Запустите через sudo." >&2 exit 1 fi -if [[ -z "$OTLP_SOURCE" ]]; then - echo "Укажите приватный IP backend, например:" >&2 - echo " sudo OTLP_SOURCE=192.168.0.1 $0" >&2 +[[ "$PRIVATE_CIDR" =~ ^[0-9.]+/[0-9]+$ ]] || { + echo "Некорректный PRIVATE_CIDR: ${PRIVATE_CIDR}" >&2 exit 1 -fi +} ip -4 addr show | grep -Fq "inet ${PRIVATE_IP}/" || { echo "Приватный адрес ${PRIVATE_IP} не найден." >&2 exit 1 } -echo "SSH будет разрешён только из ${ADMIN_CIDR}." -echo "OTLP будет разрешён только от ${OTLP_SOURCE}." -echo "До продолжения проверьте отдельную SSH-сессию через приватную сеть." +cat < -N + +До продолжения откройте вторую SSH-сессию, чтобы не потерять доступ. +EOF read -r -p "Введите APPLY для применения правил: " answer [[ "$answer" == APPLY ]] || { echo "Отменено."; exit 1; } -apt-get update -apt-get install -y ufw +if ! command -v ufw >/dev/null; then + apt-get update + apt-get install -y ufw +fi + +ufw --force reset ufw default deny incoming ufw default allow outgoing ufw logging medium -ufw allow from "$ADMIN_CIDR" to "$PRIVATE_IP" port 22 proto tcp comment "SigNoz admin SSH" -ufw allow from "$OTLP_SOURCE" to "$PRIVATE_IP" port 4317 proto tcp comment "HAN OTLP gRPC" -ufw allow from "$OTLP_SOURCE" to "$PRIVATE_IP" port 4318 proto tcp comment "HAN OTLP HTTP" + +# Источник SSH на хосте не фильтруем: allow-list делает файрвол приватной +# сети / SG провайдера. Проброс UI идёт внутри этой сессии и не требует 8080. +ufw allow 22/tcp comment "SSH (source limited by provider SG)" + +ufw allow from "$PRIVATE_CIDR" to "$PRIVATE_IP" port 4317 proto tcp \ + comment "HAN OTLP gRPC from private net" +ufw allow from "$PRIVATE_CIDR" to "$PRIVATE_IP" port 4318 proto tcp \ + comment "HAN OTLP HTTP from private net" + ufw --force enable ufw status verbose -cat <<'EOF' +cat < -N + +затем http://127.0.0.1:8080 на рабочей станции. + +Docker-публикации могут обходить UFW INPUT. Если 4317 доступен вне +${PRIVATE_CIDR}, ограничьте источник в SG провайдера. casting.yaml биндит +OTLP на ${PRIVATE_IP}, UI — на loopback. EOF diff --git a/VM4_Expo-mobile/src/app-context.tsx b/VM4_Expo-mobile/src/app-context.tsx index 016b9ec..e2f840e 100644 --- a/VM4_Expo-mobile/src/app-context.tsx +++ b/VM4_Expo-mobile/src/app-context.tsx @@ -74,6 +74,19 @@ export function AppProvider({ children }: { children: React.ReactNode }) { try { await SecureStore.setItemAsync(PENDING_CONSENTS_KEY, JSON.stringify(consents)); const result = await beginAuthorization(); + if (getAccessToken()) { + setAuthStatus("authenticated"); + await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY); + return true; + } + if (result.type === "dismiss" || result.type === "cancel") { + await new Promise((resolve) => setTimeout(resolve, 800)); + if (getAccessToken()) { + setAuthStatus("authenticated"); + await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY); + return true; + } + } if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") { setAuthStatus("guest"); if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed"); diff --git a/VM4_Expo-mobile/src/auth.ts b/VM4_Expo-mobile/src/auth.ts index a409514..aa8a318 100644 --- a/VM4_Expo-mobile/src/auth.ts +++ b/VM4_Expo-mobile/src/auth.ts @@ -1,8 +1,10 @@ import * as AuthSession from "expo-auth-session"; import * as Crypto from "expo-crypto"; +import * as Linking from "expo-linking"; import * as SecureStore from "expo-secure-store"; import * as WebBrowser from "expo-web-browser"; -import { env, oidcIssuer } from "./config"; +import { AppState, Platform } from "react-native"; +import { env, mobileHttpsRedirectUri, oidcIssuer } from "./config"; import { buildOidcDeviceMetadata } from "./oidc-device"; import { SingleFlight } from "./single-flight"; import type { TokenSet } from "./types"; @@ -34,10 +36,66 @@ const secureStore = { del: (key: string) => SecureStore.deleteItemAsync(key), }; +type PkceState = { + verifier: string; + state: string; + nonce: string; + createdAt: number; + redirectUri: string; +}; + const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", ""); -const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" }); +const nativeRedirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" }); const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`; +function oauthRedirectUri() { + return Platform.OS === "android" ? mobileHttpsRedirectUri : nativeRedirectUri; +} + +function isAuthCallbackUrl(url: string) { + return url.startsWith(nativeRedirectUri) || url.startsWith(mobileHttpsRedirectUri); +} + +function paramsFromCallbackUrl(url: string) { + const callback = new URL(url); + return { + code: callback.searchParams.get("code"), + state: callback.searchParams.get("state"), + error: callback.searchParams.get("error"), + }; +} + +async function openAuthSession(authUrl: string) { + if (Platform.OS !== "android") { + return WebBrowser.openAuthSessionAsync(authUrl, nativeRedirectUri); + } + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (result: WebBrowser.WebBrowserAuthSessionResult) => { + if (settled) return; + settled = true; + linkingSub.remove(); + appSub.remove(); + resolve(result); + }; + + const linkingSub = Linking.addEventListener("url", ({ url }) => { + if (isAuthCallbackUrl(url)) finish({ type: "success", url }); + }); + const appSub = AppState.addEventListener("change", (state) => { + if (state !== "active") return; + setTimeout(() => finish({ type: WebBrowser.WebBrowserResultType.DISMISS }), 1_500); + }); + + void WebBrowser.openBrowserAsync(authUrl, { createTask: false, showInRecents: true }).catch((error) => { + linkingSub.remove(); + appSub.remove(); + reject(error); + }); + }); +} + export function configureAuthFailure(callback: () => void) { authFailure = callback; } @@ -78,12 +136,15 @@ export async function beginAuthorization() { const verifier = random(); const state = random(); const nonce = random(); + const redirectUri = oauthRedirectUri(); const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, { encoding: Crypto.CryptoEncoding.BASE64, }); const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); const deviceMetadata = await buildOidcDeviceMetadata(secureStore); - await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() })); + await secureStore.set(PKCE_KEY, JSON.stringify({ + verifier, state, nonce, createdAt: Date.now(), redirectUri, + } satisfies PkceState)); const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({ client_id: env.clientId, redirect_uri: redirectUri, @@ -95,17 +156,9 @@ export async function beginAuthorization() { nonce, ...deviceMetadata, })}`; - const result = await WebBrowser.openAuthSessionAsync(url, redirectUri); + const result = await openAuthSession(url); if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" }; - const callback = new URL(result.url); - return { - type: "success" as const, - params: { - code: callback.searchParams.get("code"), - state: callback.searchParams.get("state"), - error: callback.searchParams.get("error"), - }, - }; + return { type: "success" as const, params: paramsFromCallbackUrl(result.url) }; } export function completeAuthorization(code: string, state: string) { @@ -117,7 +170,7 @@ async function completeAuthorizationOnce(code: string, state: string) { const raw = await secureStore.get(PKCE_KEY); await secureStore.del(PKCE_KEY); if (!raw) throw new Error("pkce_state_missing"); - const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number }; + const saved = JSON.parse(raw) as PkceState; if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) { throw new Error("pkce_state_invalid"); } @@ -127,7 +180,7 @@ async function completeAuthorizationOnce(code: string, state: string) { body: new URLSearchParams({ grant_type: "authorization_code", client_id: env.clientId, - redirect_uri: redirectUri, + redirect_uri: saved.redirectUri || oauthRedirectUri(), code, code_verifier: saved.verifier, }).toString(), diff --git a/VM4_Expo-mobile/src/config.ts b/VM4_Expo-mobile/src/config.ts index 888f806..eaa2d48 100644 --- a/VM4_Expo-mobile/src/config.ts +++ b/VM4_Expo-mobile/src/config.ts @@ -12,5 +12,7 @@ export const env = Object.freeze({ appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development", }); +export const mobileHttpsRedirectUri = `${env.apiBaseUrl}/mobile/oidc/callback`; + export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`; export const isProduction = env.appEnv === "production"; diff --git a/VM4_Expo-mobile/tests/unit/core.test.ts b/VM4_Expo-mobile/tests/unit/core.test.ts index 413b76b..db3cbea 100644 --- a/VM4_Expo-mobile/tests/unit/core.test.ts +++ b/VM4_Expo-mobile/tests/unit/core.test.ts @@ -20,6 +20,8 @@ vi.mock("expo-secure-store", () => ({ })); vi.mock("expo-web-browser", () => ({ maybeCompleteAuthSession: vi.fn(), + openAuthSessionAsync: vi.fn(), + openBrowserAsync: vi.fn(), })); vi.mock("expo-document-picker", () => ({ getDocumentAsync: vi.fn(), @@ -35,6 +37,7 @@ vi.mock("expo-file-system/legacy", () => ({ })); vi.mock("expo-linking", () => ({ openURL: vi.fn(), + addEventListener: vi.fn(() => ({ remove: vi.fn() })), })); vi.mock("expo-sharing", () => ({ isAvailableAsync: vi.fn(), @@ -42,7 +45,7 @@ vi.mock("expo-sharing", () => ({ })); vi.mock("react-native", () => ({ Platform: { OS: "android", Version: "test", constants: {} }, - AppState: { currentState: "active" }, + AppState: { currentState: "active", addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, })); import { buildOidcDeviceMetadata } from "../../src/oidc-device"; diff --git a/support¬es/backlog.md b/support¬es/backlog.md index 19d902a..15e1bf3 100644 --- a/support¬es/backlog.md +++ b/support¬es/backlog.md @@ -80,6 +80,7 @@ 33. #BACK_BUSINESS Создать поле "Номер телефона в приложении". Подробности реализации ниже (на подумать) 34. #BACK_BUSINESS реализация смены номера телефона. Подробности реализации ниже (на подумать) 35. #BACK_BUSINESS схлапывание контактов. Подробности реализации ниже (на подумать) +36. #INFRASTRUCTURE положить в облако секреты ВМ # Критично для релиза: ~~1. Разработка message-safety~~ diff --git a/support¬es/releases/#0 deploy-steps.md b/support¬es/releases/#0 deploy-steps.md deleted file mode 100644 index 0dab391..0000000 --- a/support¬es/releases/#0 deploy-steps.md +++ /dev/null @@ -1,117 +0,0 @@ -# Release #0: безопасный порядок развертывания - -Этот файл больше не является журналом реальных адресов, SSH-ключей и локальных -путей. Фактические значения инфраструктуры хранятся в защищённой CMDB/ops wiki, -а не в Git. - -## 1. Подготовка ВМ - -Разрешите извне только 80/443 и SSH из административной сети. PostgreSQL -доступен ВМ только через приватную сеть. - -```sh -scp -i deployment/scripts/setup-vm.sh \ - @:/tmp/setup-vm.sh -ssh -i @ -sudo chmod 0755 /tmp/setup-vm.sh -sudo /tmp/setup-vm.sh -``` - -После создания пользователя `deploy` проверьте отдельную SSH-сессию до -отключения root login. Не копируйте приватный ключ на ВМ. - -## 2. Доставка приложения - -Предпочтительно использовать Git либо архив без runtime-данных: - -```sh -tar -C HAN_chat_specification/codebase/backend \ - --exclude='.env' \ - --exclude='secrets' \ - --exclude='backups' \ - -czf han-chat-backend.tar.gz . -scp -i han-chat-backend.tar.gz deploy@:/tmp/ -``` - -На ВМ: - -```sh -sudo install -d -o deploy -g deploy -m 0755 /opt/han-chat/backend -sudo -u deploy tar -C /opt/han-chat/backend \ - -xzf /tmp/han-chat-backend.tar.gz -cd /opt/han-chat/backend -sudo find . -type f \( -name '*.sh' -o -name 'validate-env' \ - -o -name 'han-secrets' -o -name 'han-compose' \) \ - -exec dos2unix {} + -sudo chmod 0755 scripts/validate-env deployment/scripts/*.sh \ - deployment/secrets/han-secrets deployment/secrets/han-compose \ - redis/scripts/*.sh nginx/scripts/*.sh -sudo deployment/scripts/setup-vm.sh -``` - -## 3. Несекретная конфигурация - -`.env` создаётся на самой ВМ из `.env.example` и содержит только URL, resource -names, feature flags и `SECRETS_SOURCE`. Его разрешено передавать как обычный -config, но запрещено добавлять credential-bearing DSN, password, token и key. - -```sh -cp .env.example .env -chmod 0600 .env -nano .env -./scripts/validate-env .env -``` - -## 4. Selectel Secrets Manager - -Выполните `deployment/secrets/SELECTEL_RUNBOOK.ru.md`: - -1. отдельный проект и service user; -2. provider secrets и audit alerts; -3. root-only JSON-карта; -4. encrypted systemd credential; -5. `SECRETS_SOURCE=selectel`. - -Не передавайте значение секрета аргументом команды, через `export`, тикет или -shell history. Старые значения из прежнего `.env` после cutover ротируются. - -## 5. Проверка и запуск - -```sh -sudo systemctl daemon-reload -sudo systemctl enable han-secrets@production.service -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 -python3 -m unittest discover -s tests -v -sudo deployment/secrets/han-compose build --pull -sudo deployment/scripts/migrate.sh -sudo deployment/secrets/han-compose up -d --wait -sudo deployment/scripts/smoke.sh -``` - -Для диагностики используйте `han-compose ps` и ограниченные logs. Не выводите -resolved Compose config, `docker inspect` environment, полный `env` или secret -files. - -## 6. Откат - -Откат приложения использует immutable image/release ID и подтверждённую -совместимость схемы: - -```sh -sudo SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \ - deployment/scripts/rollback.sh -``` - -Откат секрета выполняется активацией предыдущей версии в Selectel, повторным -sync и пересозданием только затронутых сервисов. Snapshot старого `.env` не -создаётся. - -## 7. Break-glass - -Только при подтверждённом инциденте доставьте root-only recovery file из -защищённой офлайн-копии, установите `SECRETS_SOURCE=file`, выполните -sync/validate/recreate и зафиксируйте событие. Автоматический fallback запрещён. -После восстановления Selectel верните штатный режим и удалите recovery file. diff --git a/support¬es/releases/#1 SMS OTP deploy.md b/support¬es/releases/#1 SMS OTP deploy.md deleted file mode 100644 index a8cd99a..0000000 --- a/support¬es/releases/#1 SMS OTP deploy.md +++ /dev/null @@ -1,405 +0,0 @@ -# #1 SMS OTP deploy - -Безопасный порядок развёртывания `sms-service`/worker на существующей ВМ и включения реальной OTP-доставки: сначала подготовить PostgreSQL и секреты, затем запустить новый контур при `mock=true`, проверить i-Digital и только после этого переключить Keycloak. - -Старую сборку Keycloak после expand-миграции возвращать нельзя. Аварийный откат выполняется переключением новой сборки обратно в mock-режим. - -## 0. До начала - -- Получить у i-Digital: - - `TOKEN_1`; - - согласованное имя отправителя; - - согласованный текст `auth_otp`; - - подтверждённый source IP для callback; - - регистрацию статического egress IP ВМ. -- Создать PITR marker/backup managed PostgreSQL. -- Скопировать `/opt/han-chat/backend/.env` в защищённое место вне каталога релиза. -- Оставить `KEYCLOAK_OTP_MOCK_ENABLED=true` до последнего этапа. -- На production-like при mock-режиме оставить `KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true`. -- Подтвердить у Direct актуальность IP `185.203.96.7`, указанного в `codebase/backend/nginx/templates/site-tls.conf.template`. Если IP другой — обновить allowlist до сборки nginx. - -## 1. Создать пользователя и схему PostgreSQL - -### 1.1. Создать пользователя - -В интерфейсе Selectel создать отдельного пользователя: - -```text -sms_user -``` - -Использовать случайный пароль не короче 32 символов. - -### 1.2. Создать схему - -Подключиться к `han_chat` под `dbAdmin` и выполнить: - -```sql -GRANT CONNECT ON DATABASE han_chat TO sms_user; -GRANT CREATE ON DATABASE han_chat TO sms_user; - -CREATE SCHEMA IF NOT EXISTS sms AUTHORIZATION sms_user; -REVOKE ALL ON SCHEMA sms FROM PUBLIC; - -ALTER ROLE sms_user IN DATABASE han_chat SET search_path TO sms, public; -``` - -### 1.3. Проверить - -Переподключиться к БД как `sms_user`: - -```sql -SELECT current_user; -SHOW search_path; - -SELECT - nspname, - pg_get_userbyid(nspowner) AS owner -FROM pg_namespace -WHERE nspname = 'sms'; -``` - -Ожидаемый результат: - -- `current_user = sms_user`; -- `search_path = sms, public`; -- владелец схемы `sms` — `sms_user`. - -Не выдавать `sms_user` права на схемы `han_app` и `keycloak`. - -## 2. Заполнить `.env` на ВМ - -Файл: - -```text -/opt/han-chat/backend/.env -``` - -Добавить или обновить: - -```dotenv -SMS_SERVICE_IMAGE=han-chat-sms-service:local -SMS_DATABASE_URL=postgresql+asyncpg://sms_user:@:/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem - -KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080 -SMS_SERVICE_TOKEN= -KEYCLOAK_SMS_SERVICE_TOKEN=<ТОЧНО ТО ЖЕ ЗНАЧЕНИЕ> - -IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru -IDGTL_SMS_API_KEY=<ГОТОВЫЙ TOKEN_1 БЕЗ ПОВТОРНОГО BASE64> -IDGTL_SMS_CALLBACK_PUBLIC_URL=https:///callbacks/idgtl/sms -IDGTL_SMS_CALLBACK_USERNAME= -IDGTL_SMS_CALLBACK_PASSWORD= - -NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m - -KEYCLOAK_OTP_MOCK_ENABLED=true -KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true -``` - -Важно: - -- пароль БД необходимо URL-encode, если он содержит специальные символы; -- `SMS_SERVICE_TOKEN` и `KEYCLOAK_SMS_SERVICE_TOKEN` должны совпадать; -- `IDGTL_SMS_API_KEY` — уже готовое значение Basic API key `TOKEN_1`, повторно кодировать его нельзя; -- `KEYCLOAK_OTP_HMAC_KEY` во время rollout не менять. - -### 2.1. Проверить egress IP - -Из каталога `/opt/han-chat/backend`: - -```bash -docker compose --env-file .env --profile ops run --rm \ - --entrypoint curl toolbox -fsS https://api.ipify.org -``` - -Полученный IP передать Direct для allowlist. При динамическом IP сначала настроить статический IP/NAT. - -### 2.2. Проверить конфигурацию - -```bash -cd /opt/han-chat/backend - -./scripts/validate-env .env -docker compose --env-file .env config --quiet -docker compose --env-file .env config --services -``` - -## 3. Скопировать и собрать release - -Копирование проекта выполняется по инструкции `deploy-steps.md`. - -Сначала выполнить `rsync` с флагом `-n` и проверить список изменений. Убедиться, что исключены: - -```text -.env -secrets/ -*.crt -*.pem -*.key -``` - -После проверки повторить `rsync` без `-n`. - -На ВМ: - -```bash -cd /opt/han-chat/backend - -find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} + -chmod +x scripts/validate-env deployment/scripts/*.sh nginx/scripts/*.sh - -./scripts/validate-env .env - -docker compose --env-file .env build --pull \ - api-backend sms-service keycloak frontend-static nginx -``` - -На этом этапе `KEYCLOAK_OTP_MOCK_ENABLED` всё ещё должен быть `true`. - -## 4. Применить миграции - -Перед миграцией создать PITR marker у провайдера БД. - -```bash -cd /opt/han-chat/backend - -PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh -``` - -Команда применит: - -- migration `0005_otp_settings` для `han_app`; -- migration `0001_initial` для схемы `sms`; -- migration `0002_seed` для схемы `sms`; -- остальные штатные migrations проекта. - -При необходимости применить production-like settings: - -```bash -docker compose --env-file .env --profile ops run --rm seed-settings -``` - -Проверить версии: - -```sql -SELECT version_num FROM han_app.alembic_version; -SELECT version_num FROM sms.alembic_version; -``` - -Ожидается: - -```text -han_app: 0005_otp_settings -sms: 0002_seed -``` - -## 5. Записать согласованные sender и SMS-шаблон - -Миграция намеренно создаёт placeholder. Пока он не заменён, `sms-service` будет возвращать `not_ready`. - -Подключиться как `sms_user` и выполнить, подставив согласованные значения: - -```sql -UPDATE sms.sms_setting -SET setting_value = to_jsonb(''::text), - updated_at = now() -WHERE setting_key = 'provider.idgtl.default_sender_name'; - -UPDATE sms.sms_template -SET body_template = 'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.', - sender_name = NULL, - approved_at = now(), - updated_at = now(), - created_by = 'ops-approved' -WHERE code = 'auth_otp' - AND channel = 'SMS' - AND locale = 'ru' - AND version = 1; -``` - -Если оператор согласовал другой текст, использовать именно его. В тексте должны остаться ровно два placeholders: - -```text -{code} -{ttl_min} -``` - -Для OTP должно сохраняться: - -```text -max_parts = 1 -``` - -Проверить: - -```sql -SELECT - code, - channel, - locale, - version, - body_template, - sender_name, - max_parts, - is_active, - approved_at -FROM sms.sms_template -WHERE code = 'auth_otp'; - -SELECT setting_key, setting_value -FROM sms.sms_setting -ORDER BY setting_key; -``` - -Должна существовать ровно одна active+approved версия `auth_otp`, а placeholder имени отправителя должен быть заменён. - -## 6. Запустить SMS-контур при `mock=true` - -```bash -cd /opt/han-chat/backend - -docker compose --env-file .env up -d sms-service sms-worker -docker compose --env-file .env ps sms-service sms-worker -docker compose --env-file .env logs --since=10m sms-service sms-worker -``` - -Ожидается: - -- `sms-service` — healthy; -- worker запущен; -- отсутствуют ошибки Direct `401`/`402`; -- отсутствуют contract errors; -- отсутствуют необъяснённые `uncertain`. - -Затем запустить обновлённые смежные сервисы, не выключая mock: - -```bash -docker compose --env-file .env up -d --force-recreate \ - api-backend keycloak frontend-static nginx - -docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf -deployment/scripts/smoke.sh -``` - -Первый запуск новой сборки Keycloak применит Liquibase expand migration. Старые незавершённые OTP challenges будут помечены истёкшими, поэтому запускать Keycloak лучше в период низкой активности. - -## 7. Проверить i-Digital до включения real mode - -Через внутренний endpoint: - -```text -POST /internal/sms/v1/send -``` - -заказать одну SMS на контролируемый номер. - -Требования к тесту: - -- использовать уникальный `idempotency_key`; -- не записывать service token и OTP в shell history; -- JSON body создать во временном файле с правами `600`; -- после теста удалить временный файл. - -Проверить журнал: - -```sql -SELECT - id, - created_at, - phone_masked, - send_status, - delivery_status, - provider_message_id, - provider_error_code, - attempt_count, - callback_last_at -FROM sms.sms_outbound_message -ORDER BY created_at DESC -LIMIT 10; -``` - -Ожидается: - -1. После заказа создана одна строка. -2. `send_status` переходит в `accepted`. -3. `provider_message_id` заполнен. -4. Callback меняет `delivery_status` на `sent`/`delivered`. -5. Повтор идентичного запроса возвращает тот же `sms_message_id` и не создаёт вторую SMS. - -Проверить edge: - -- публичный `/internal/sms/*` возвращает `404`; -- callback не с IP Direct возвращает `403`; -- реальный callback Direct проходит IP allowlist и Basic auth. - -## 8. Включить реальные SMS - -Только после успешной тестовой отправки изменить: - -```dotenv -KEYCLOAK_OTP_MOCK_ENABLED=false -KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false -KEYCLOAK_OTP_MOCK_CODE= -``` - -Применить: - -```bash -cd /opt/han-chat/backend - -./scripts/validate-env .env - -docker compose --env-file .env up -d \ - --no-deps \ - --force-recreate keycloak - -docker compose --env-file .env ps keycloak -docker compose --env-file .env logs --since=10m \ - keycloak sms-service sms-worker -``` - -Проверить полный пользовательский сценарий: - -1. Ввод номера телефона. -2. Получение реальной SMS. -3. Неверный OTP отклоняется. -4. Верный OTP авторизует пользователя. -5. Resend создаёт новый challenge. -6. Старый challenge получает `superseded`. -7. Старый код больше не принимается. -8. OTP истекает через 60 секунд. -9. Работают лимиты отправок и проверок. -10. Уже active challenge продолжает локально проверяться при временно остановленном worker. - -## 9. Аварийный откат - -Не выполнять: - -- downgrade Alembic; -- downgrade Liquibase; -- возврат старой сборки Keycloak. - -После expand migration старая сборка Keycloak несовместима с новыми обязательными полями challenge. - -Безопасный rollback — оставить новую сборку и вернуть mock: - -```dotenv -KEYCLOAK_OTP_MOCK_ENABLED=true -KEYCLOAK_OTP_MOCK_CODE=<НЕПУБЛИЧНЫЙ 6-ЗНАЧНЫЙ КОД> -KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true -``` - -```bash -cd /opt/han-chat/backend - -./scripts/validate-env .env - -docker compose --env-file .env up -d \ - --no-deps \ - --force-recreate keycloak -``` - -`sms-service` и worker можно оставить запущенными для обработки callback и reconciliation. Новые SMS-заказы от Keycloak прекратятся. - -Записи со статусом `uncertain` автоматически не переотправлять — их необходимо разбирать вручную. diff --git a/support¬es/releases/#1.1 VM-service-deploy.md b/support¬es/releases/#1.1 VM-service-deploy.md deleted file mode 100644 index 0c63fef..0000000 --- a/support¬es/releases/#1.1 VM-service-deploy.md +++ /dev/null @@ -1,960 +0,0 @@ -# Usefull commands - -## Скопировать файл по технологии через архив -cd C:\Users\MI\Documents\Assistent\HAN_chat_specification - -$Hotfix = "bug-v15" -tar -czf "vm2-$Hotfix.tar.gz" -C .\codebase ` - services/deployment/scripts/setup-vm.sh -Get-FileHash "vm2-$Hotfix.tar.gz" -Algorithm SHA256 -scp -i C:\Users\MI\.ssh\han_vm2_deploy ` - "vm2-$Hotfix.tar.gz" ` - deploy@135.106.179.209:/var/lib/han-deploy/incoming/ - - -services/docker-compose.yml -HAN_chat_specification\codebase\services\docker-compose.yml -HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh - -HOTFIX='bug-v15' -EXPECTED_SHA256='BF85F75F45F63BFB8310C6E0B835276DE7EBF316180120F6383590C19E44FEE4' -ARCHIVE="/var/lib/han-deploy/incoming/vm2-${HOTFIX}.tar.gz" - -printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check - -tar -tzf "$ARCHIVE" - -STAGING="$(mktemp -d /opt/han-chat/.nginx-hotfix.XXXXXX)" -tar -xzf "$ARCHIVE" -C "$STAGING" --no-same-owner --no-same-permissions - -install -m 0644 -o root -g root "$STAGING/services/deployment/scripts/setup-vm.sh" /opt/han-chat/services/deployment/scripts/setup-vm.sh - -sed -i 's/\r$//' ./deployment/scripts/setup-vm.sh - -find -type f -exec file {} \; | grep -i 'CRLF' - -## Перезапустить контейнер -/usr/local/sbin/han-vm2-compose up -d --force-recreate freshclam - -# Создание докер-образов - -docker login cr.selcloud.ru - -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/.../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 -docker push $REGISTRY/han-message-safety:1.0.2 -docker image inspect $REGISTRY/han-message-safety:1.0.2 --format '{{index .RepoDigests 0}}' - - - -## Чтобы собрать sha256 с остальных образов, их надо закачать -docker pull nginxinc/nginx-unprivileged:1.27 -docker pull redis:7.4 -docker pull clamav/clamav:1.4 -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.6 --format '{{index .RepoDigests 0}}' -docker image inspect otel/opentelemetry-collector-contrib:0.117.0 --format '{{index .RepoDigests 0}}' - -# Генерация ключей для новых пользователей и первичная настройка ВМ2 - -ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_deploy -C "han-vm2-deploy" -ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_admin -C "han-vm2-break-glass-admin" - - -scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh root@135.106.179.209:/root/setup-vm2.sh -scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\.ssh\han_vm2_deploy.pub C:\Users\MI\.ssh\han_vm2_admin.pub root@135.106.179.209:/root/ - - -На VM2 в текущей root-сессии задайте реальные CIDR. `/0` скрипт отклоняет: -```sh -ssh -i C:\Users\MI\.ssh\hansel root@135.106.179.209 -sed -i 's/\r$//' /root/setup-vm2.sh - -install -d -m 0700 -o root -g root /root/bootstrap -install -m 0600 -o root -g root /root/han_vm2_deploy.pub /root/bootstrap/deploy.pub -install -m 0600 -o root -g root /root/han_vm2_admin.pub /root/bootstrap/admin.pub -chmod 0700 /root/setup-vm2.sh -DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ -ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ -VM1_PRIVATE_CIDRS='192.168.0.0/24' \ -/root/setup-vm2.sh -``` - -1 В текущей root-сессии задайте `admin` отдельный сложный sudo-пароль. Он не -разрешает password SSH: пароль нужен только после входа по admin key: -```sh -passwd admin -``` -2 Не закрывая root-сессию, проверьте отдельные SSH-ключи deploy и admin. -ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@135.106.179.209 -ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@135.106.179.209 -В сессии admin проверьте sudo -v и sudo -i, затем завершите root shell: -sudo -v # проверить, что пароль admin принимается -sudo -i # открыть root shell (приглашение обычно root@...) -id # убедиться, что uid=0 -exit # ← вот это «завершите root shell» — вернуться к admin@ - - -# Только после успешной проверки `deploy`, `admin` и `sudo` повторите на VM2 -под `root`: - -```sh -DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ -ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ -VM1_PRIVATE_CIDRS='192.168.0.0/24' \ -HARDEN_SSH=true \ -SKIP_APT_UPGRADE=true \ -/root/setup-vm2.sh -``` -Это добавит `PermitRootLogin no` и `AllowUsers deploy admin`. -Публичные bootstrap-копии после проверки можно удалить под `admin`: - -```sh -sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub -``` - -# Проверка наличия сетевого интерфейса для ВМ и его создание -ls -la /etc/netplan -netplan get -ip route - - -cat >/etc/netplan/60-private-network.yaml <<'EOF' -network: - version: 2 - ethernets: - eth1: - dhcp4: false - addresses: - - 192.168.0.4/24 - optional: true -EOF - -chmod 0600 /etc/netplan/60-private-network.yaml -netplan generate -netplan try - -Подтвердите конфигурацию в течение тайм-аута. Затем проверьте: -ip -br -4 address show eth1 -ip route -ping -c 3 192.168.0.210 - -# nginx правим разрешенные адреса -nginx/allowlists/ - -# Копируем проект -(Текущая команда копирует весь каталог ради простоты формирования единого архива, но технической необходимости в исходниках приложений нет) - -На локальном компьютере из каталога `HAN_chat_specification`: - -```powershell -$Release = "1.0.3" -tar --exclude=services/.env ` - --exclude='services/**/__pycache__' ` - --exclude='services/**/.pytest_cache' ` - --exclude='services/**/.ruff_cache' ` - -czf "vm2-services-$Release.tar.gz" -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase services -Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256 -scp -i C:\Users\MI\.ssh\han_vm2_deploy "vm2-services-$Release.tar.gz" ` - deploy@135.106.179.209:/var/lib/han-deploy/incoming/ -``` - -Под `deploy` на VM2 вычислите checksum. Значение должно совпасть с локальным: - -```sh -RELEASE='1.0.2' -cd /var/lib/han-deploy/incoming -sha256sum "vm2-services-${RELEASE}.tar.gz" -tar -tzf "vm2-services-${RELEASE}.tar.gz" -``` -На этом действия `deploy` с файлами заканчиваются. Не распаковывайте релиз -через `sudo` и не копируйте его в production от имени `deploy`. - -# Активация и установка файлов под `root` - -ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@135.106.179.209 -sudo -i -id -Должно быть uid=0(root). - -Под `root` ещё раз сверьте ожидаемый SHA-256 и список архива. Не продолжайте, -если архив содержит абсолютные пути, `..`, symlink/hardlink или лишний проект: - -```sh -RELEASE='1.0.3' -EXPECTED_SHA256='0FC8F2B85EC39EE41BD6D3E3789963A4D985B3E2E9638E50BEAF978EA6A46271' -ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz" -printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check - -tar -tvzf "$ARCHIVE" -if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then - echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2 - exit 1 -fi -if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then - echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2 - exit 1 -fi -if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then - echo 'ОШИБКА: архив содержит symlink или hardlink' >&2 - exit 1 -fi -install -d -m 0755 -o root -g root /opt/han-chat/services -STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)" -tar --extract --gzip --file "$ARCHIVE" \ - --directory "$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" -chmod 0755 /opt/han-chat/services/deployment/preflight.sh -``` -cd /opt/han-chat/services/ -sudo apt update -sudo apt install -y dos2unix -find . -type f \( \ - -name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.sudoers' -o -name 'han-compose' -o -name 'han-secrets' -o -name 'han-message-safety-mode' -o -name '*.yml' -o -name '*.conf*' -o -name 'preflight.sh' -o -name '*.yaml' -o -name '*.md' -o -name '*.toml' -o -name 'Dockerfile' -o -name '*.acl*' \ -\) -exec dos2unix {} + -find -type f -exec file {} \; | grep -i 'CRLF' -sudo apt remove -y dos2unix -sudo apt purge -y dos2unix - - -# Если перезалили -scp /opt/han-chat/services/deployment/scripts/setup-vm.sh /root/setup-vm2.sh -chmod 0700 /root/setup-vm2.sh - -Была проблема с каретками, полечилась так: -(sed -i 's/\r$//' \ - /opt/han-chat/services/deployment/deploy-message-safety-mode.sudoers - -install -m 0440 -o root -g root \ - /opt/han-chat/services/deployment/deploy-message-safety-mode.sudoers \ - /etc/sudoers.d/deploy-message-safety-mode - -visudo -cf /etc/sudoers.d/deploy-message-safety-mode) - - - -Повторите setup под `root`: теперь он установит helpers и units из активного -релиза. Приложение всё ещё не запускается: - -```sh -DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ -ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ -VM1_PRIVATE_CIDRS='192.168.0.0/24' \ -HARDEN_SSH=true \ -SKIP_APT_UPGRADE=true \ -/root/setup-vm2.sh -``` - -# Несекретная конфигурация и Selectel под `root` - -`APP_ENV` определяет имя loader config. При значении из `.env.example` -`APP_ENV=production-like` файл обязан называться -`/etc/han/secrets/vm2-production-like.selectel.json`: - -```sh -cd /opt/han-chat/services -install -m 0600 -o root -g root .env.example .env -editor .env - -# я скопировал и вставил значения из локального -sed -i 's/\r$//' .env - - -install -m 0600 -o root -g root \ - deployment/secrets/config.example.json \ - /etc/han/secrets/vm2-production-like.selectel.json -editor /etc/han/secrets/vm2-production-like.selectel.json -``` -sudo sed -i 's/\r$//' /etc/han/secrets/vm2-production-like.selectel.json -ls /etc/han/secrets/vm2-production-like.selectel.json - -# Для заполнения секретов нужно создать сертификаты для ВМ1-ВМ2. - -### 1. Создать CA (один раз) - -На безопасной машине (не обязательно VM2): - -```bash -mkdir -p ~/han-internal-ca && cd ~/han-internal-ca - -openssl genrsa -out ca.key 4096 -openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \ - -subj "/CN=HAN Internal CA" \ - -out ca.crt -``` -`ca.key` храните только у себя; на ВМ не кладите. - -### 2. Выпустить серверный сертификат VM2 - -```bash -openssl genrsa -out vm2-processing.key 2048 - -cat > vm2-processing.ext <<'EOF' -authorityKeyIdentifier=keyid,issuer -basicConstraints=CA:FALSE -keyUsage = digitalSignature, keyEncipherment -extendedKeyUsage = serverAuth -subjectAltName = @alt_names - -[alt_names] -DNS.1 = processing.internal -EOF - -openssl req -new -key vm2-processing.key \ - -subj "/CN=processing.internal" \ - -out vm2-processing.csr - -openssl x509 -req -in vm2-processing.csr \ - -CA ca.crt -CAkey ca.key -CAcreateserial \ - -out vm2-processing.crt -days 825 -sha256 \ - -extfile vm2-processing.ext -``` - -Проверка: - -```bash -openssl x509 -in vm2-processing.crt -noout -text | grep -A2 'Subject Alternative Name' -``` - -### 3. Разложить секреты - -**VM2** (в Selectel Secrets / файлы loader’а): - -- `VM2_INTERNAL_TLS_CERTIFICATE` ← содержимое `vm2-processing.crt` (можно fullchain: cert + ca.crt) -- `VM2_INTERNAL_TLS_PRIVATE_KEY` ← `vm2-processing.key` - -**VM1**: -scp -i ~/.ssh/hansel ./han-internal-ca/ca.crt root@135.106.164.58:/tmp/ca.crt - -На VM1 от root: -install -d -o root -g root -m 0755 /opt/han-chat/backend/secrets/tls -install -o root -g root -m 0644 /tmp/ca.crt \ - /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt -rm -f /tmp/ca.crt - -ls /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt - -nano /opt/han-chat/backend/.env - -MESSAGE_SAFETY_URL=https://processing.internal:8443 -MESSAGE_SAFETY_CA_HOST_PATH=/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt - -# PRIVATE NETWORK - -В private DNS / hosts на VM1: -echo '192.168.0.4 processing.internal' >> /etc/hosts - -Зашифруйте пароль Selectel service user через systemd credentials, не помещая -его в аргументы или history: - -```sh -read -rsp 'Selectel VM2 service-user password: ' SELECTEL_PASSWORD; echo -printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \ - --name=selectel-service-user-password - \ - /etc/han/credentials/vm2.selectel-password.cred -unset SELECTEL_PASSWORD -chown root:root /etc/han/credentials/vm2.selectel-password.cred -chmod 0600 /etc/han/credentials/vm2.selectel-password.cred -``` - -Активные nginx allow-list файлы редактирует только `root`; последней строкой -обязательно остаётся `deny all;`. До cutover Bitrix public allow-list должен -оставаться закрытым: - -```sh -editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf -install -m 0644 -o root -g root \ - /opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf.template \ - /opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf -chown root:root /opt/han-chat/services/nginx/allowlists/*.conf -chmod 0644 /opt/han-chat/services/nginx/allowlists/*.conf -``` - -Нужно заполнить **два активных nginx allow-list** на VM2 (не UFW из `setup-vm.sh`). Шаблоны лежат в `nginx/allowlists/`. - -### 1. `private-caller-allowlist.conf` — обязательно для работы Safety - -Кто может ходить на **private API `:8443`** (VM1 → Message Safety / sync status). - -Пример: - -```nginx -# allow приватный IP VM1 -allow 192.168.0.10/32; -# опционально — ваш VPN/ops в private сети -# allow 192.168.0.50/32; -deny all; -``` - -Берёте приватный IP VM1 (тот же смысл, что `VM1_PRIVATE_CIDRS`). Последняя строка всегда `deny all;`. - -### 2. `bitrix-webhook-allowlist.conf` — только перед включением Bitrix - -Кто может бить в **публичные webhook** (`/bitrix/sync/webhook/...`). - -До cutover ранбук говорит: **оставить закрытым** (`deny all;` без `allow`) — это нормально, пока `BITRIX_SYNC_ENABLED=false`. - -Перед включением sync — IP/CIDR исходящих адресов Битрикс24 (или прокси), например: - -```nginx -allow 185.x.x.x/32; -deny all; -``` - -### Где править - -На VM2 после раскладки релиза: - -```text -/opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf -/opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf -``` - -Шаблоны-подсказки: `*.conf.template`. Активные `.conf` в git намеренно с одним `deny all;` — заполняете на сервере (или копируете из template и раскомментируете `allow`). - -Это **отдельный слой от UFW**: UFW режет порт на хосте, nginx allow-list — кто дойдёт до location после TLS. - - -# Сертификат PostgreSQL и первоначальный выпуск public TLS - -### CA управляемой PostgreSQL - -Скачайте CA-сертификат кластера из панели провайдера и передайте его на VM2 во -временный путь. Под `root` установите сертификат вне каталога релиза: - -scp -i C:\Users\MI\.ssh\han_vm2_deploy -r C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\sertificates\CA.pem deploy@135.106.179.209:/tmp/ca.pem - -```sh -install -d -m 0755 -o root -g root /etc/han/ca -install -m 0644 -o root -g root \ - /tmp/ca.pem \ - /etc/han/ca/managed-postgresql-ca.pem -openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \ - -noout -subject -issuer -dates -rm -f /tmp/ca.pem -``` - -# Первоначальный выпуск Let's Encrypt - -`PROCESSING_PUBLIC_HOST` должен быть DNS-именем, A-запись которого уже указывает -на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт -`80` должен быть разрешён в cloud firewall/UFW и пока не занят nginx. - -Под `root` задайте значения только для текущей shell-сессии и подготовьте -постоянный webroot: - -```sh -PUBLIC_HOST=service4chat.han0107.ru -ACME_EMAIL=ap@han.ru -install -d -m 0755 -o root -g root /var/lib/han-chat/acme -getent ahostsv4 "$PUBLIC_HOST" -ss -lntp | grep -E ':80[[:space:]]' && { - echo 'Порт 80 уже занят; остановите listener перед standalone-проверкой' >&2 - exit 1 -} || true -``` - -Сначала проверьте ACME через staging CA. Этот сертификат nginx не использует: - -```sh -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 -``` - -После успешного staging-теста выпустите production-сертификат: - -```sh -certbot certonly --standalone --preferred-challenges http \ - -d "$PUBLIC_HOST" \ - --cert-name "$PUBLIC_HOST" \ - --email "$ACME_EMAIL" \ - --agree-tos --no-eff-email --non-interactive -certbot certificates -test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" -test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" -``` -Nginx получает `/etc/letsencrypt` с host read-only и после этого может пройти -Gate 4 и первый запуск. Не копируйте private key в каталог релиза. - - -# 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 -``` - -для перезапуска секретов -systemctl restart han-secrets-vm2.service -systemctl show han-secrets-vm2.service -p ActiveState -p SubState -p Result -p ExecMainStartTimestamp -p ExecMainStatus - -Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД -выдаёт Bitrix migration-role временный read-only доступ к legacy mapping: - -```sql -GRANT USAGE ON SCHEMA han_app TO bitrix_sync_user; -GRANT SELECT ON TABLE han_app.entity_external_mapping - TO bitrix_sync_user; - - -До runtime выполните миграции отдельными DB roles и активируйте начальный -Message Safety config: - -```sh -/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate -/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate - -/usr/local/sbin/han-vm2-compose --profile ops run --rm \ - --entrypoint message-safety-config message-safety-migrate \ - create /app/app/artifacts/seed-config.yaml --version 1 --actor '' -/usr/local/sbin/han-vm2-compose --profile ops run --rm \ - --entrypoint message-safety-config message-safety-migrate \ - activate --version 1 --approved-by '' -``` - -Потом права отзываем -REVOKE SELECT ON TABLE han_app.entity_external_mapping - FROM bitrix_sync_user; -REVOKE USAGE ON SCHEMA han_app FROM bitrix_sync_user; - -Первый запуск и enable выполняет `root` только после прохождения gates: - - -Установка/редактирование unit, Compose, `.env`, secret mapping, credential, -TLS, allow-list и запуск migration jobs остаются операциями `root`. - -# GATES - -### Gate 1 — секреты материализованы - -Под `root` на VM2: - -```sh -systemctl restart han-secrets-vm2.service -systemctl is-active han-secrets-vm2.service -journalctl --no-pager -u han-secrets-vm2.service -test -s /run/han-chat/secrets/manifest -cut -d= -f1 /run/han-chat/secrets/manifest | sort -``` -Ожидается `active`; журнал не содержит значений секретов; последняя команда -показывает только имена всех ключей из mapping. Не выполняйте `cat` файлов -секретов и не вставляйте реальные значения в terminal history. - -### Gate 2 — статический preflight и Compose -Под `root` на VM2: - -```sh -cd /opt/han-chat/services -deployment/preflight.sh -/usr/local/sbin/han-vm2-compose config --quiet -/usr/local/sbin/han-vm2-compose config --services -/usr/local/sbin/han-vm2-compose config --images -``` -Все команды должны завершиться с кодом `0`. В списке services нет PostgreSQL, -а все production images содержат `@sha256:`. Вывод полного resolved Compose в -файл не сохраняйте. - -### Gate 3 — миграции и активный Message Safety config - -Команды миграций из предыдущего раздела выполняются под `root`. После них: - -```sh -/usr/local/sbin/han-vm2-compose --profile ops run --rm \ - message-safety-migrate current -/usr/local/sbin/han-vm2-compose --profile ops run --rm \ - bitrix-sync-migrate current -``` -Ожидается по одной head revision каждого сервиса. `create --version 1` -выполняется только при первом развёртывании. Для следующего конфига используйте -новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно -активировать старую версию нельзя. Alembic downgrade запрещён. - -### Gate 4 — конфигурация nginx до запуска - -После выпуска public TLS в `/etc/letsencrypt` и материализации internal TLS -secrets. В Selectel значения `VM2_INTERNAL_TLS_CERTIFICATE` и -`VM2_INTERNAL_TLS_PRIVATE_KEY` сохраняются как исходный PEM с настоящими -переводами строк, не как повторный base64 и не как строка с литералами `\n`. -После изменения remote secret перезапустите `han-secrets-vm2.service`; preflight -проверит формат PEM и соответствие certificate/key без вывода их содержимого: - -```sh -/opt/han-chat/services/deployment/preflight.sh -/usr/local/sbin/han-vm2-compose run --rm --no-deps \ - -e MESSAGE_SAFETY_UPSTREAM_HOST=127.0.0.1 \ - -e BITRIX_SYNC_UPSTREAM_HOST=127.0.0.1 \ - nginx \ - nginx -t -c /etc/nginx/nginx.conf -``` - -Базовый `nginx.conf` подключает обязательный -`/etc/nginx/conf.d/10-vm2.conf`, поэтому команда завершится ошибкой, если -entrypoint не создал конфигурацию из шаблона. Временные значения upstream -нужны только для проверки до первого запуска backend-контейнеров; production -Compose подставляет DNS-имена сервисов. Ожидается `syntax is ok` и `test is -successful`; ошибок `conf.d is not writable` и предупреждения о превышении -open-file limit быть не должно. Ошибка отсутствующего сертификата является -блокером, а не основанием временно убрать TLS. - - -### Gate 5 — упорядоченный первый запуск - -Под `root` на VM2: - -```sh -/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector -/usr/local/sbin/han-vm2-compose up -d freshclam clamd -/usr/local/sbin/han-vm2-compose up -d \ - message-safety-api message-safety-worker -/usr/local/sbin/han-vm2-compose up -d \ - bitrix-sync bitrix-sync-worker bitrix-sync-reconciliation -/usr/local/sbin/han-vm2-compose up -d nginx -/usr/local/sbin/han-vm2-compose ps -``` - -Дождитесь `healthy` у сервисов с healthcheck. Не продолжайте при -`Restarting`, `unhealthy`, OOM или неожиданном `Exited`. После успешного -первого запуска передайте дальнейший lifecycle systemd: - -```sh -systemctl enable han-secrets-vm2.service han-processing.service -systemctl start han-processing.service -systemctl --no-pager status han-processing.service -``` - -### Gate 6 — host ports и сертификаты - -Под `root` на VM2: - -```sh -ss -lntp | grep -E ':(80|443|8443|6379|8080|4317|4318)[[:space:]]' -/usr/local/sbin/han-vm2-compose ps --format json | jq . -``` - -Ожидаются host listeners только nginx: public `80`, `443` и private-bound -`8443` на `PROCESSING_PRIVATE_BIND_ADDRESS`. `6379`, container `8080` и OTLP -`4317/4318` на host отсутствуют. - -С доверенной рабочей станции проверьте public chain: - -```sh -openssl s_client -connect service4chat.han0107.ru:443 \ - -servername service4chat.han0107.ru \ - -verify_hostname service4chat.han0107.ru -verify_return_error &2 - exit 1 -fi -unset CANARY -``` - -В SigNoz выполните поиск этого же marker по logs и span attributes за окно -теста: результат должен быть пустым. Отдельными фейковыми markers повторите -проверку для DSN-подобной строки, S3 key и object key. Реальные secrets для -такой проверки не используйте. - -Не открывайте webhook-трафик, пока `bitrix-sync` отключён. Отключённый или -упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию, -никогда успешный `2xx ignored`. - -## Политика отказов - -- Отказ зависимости 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` — нет. - - -Проверка отправки в Сигноз - -docker network ls | grep observability - -docker run --rm --network han-processing_observability \ - ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \ - traces --otlp-endpoint otel-collector:4317 --otlp-insecure \ - --service han-processing-otlp-smoke --traces 100 --rate 20 - -Проверка -/usr/local/sbin/han-vm2-compose logs --since=5m otel-collector | - grep -Ei 'error|refused|queue is full|Unauthenticated|tls' || echo 'нет ошибок экспорта' - -после успешной проверки нужно удалить -docker rmi ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest - -docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" - -# Проверка финальная - -1. Проверить автозапуск: - -```sh -systemctl is-enabled \ - docker.service \ - han-chat-vm2-docker-firewall.service \ - han-secrets-vm2.service \ - han-processing.service \ - certbot.timer - -(все enabled) - -systemctl is-active \ - docker.service \ - han-chat-vm2-docker-firewall.service \ - han-processing.service \ - certbot.timer - -(все active) - -/usr/local/sbin/han-vm2-compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}" -``` - -2. Провести reboot-gate. Только после проверки отдельного входа `admin` и доступа к консоли Selectel: - -```sh -systemctl reboot -``` - -После переподключения повторить команды выше и кратко Gate 6–8: HTTPS, firewall, private Safety API. - -3. Зафиксировать итог релиза: - -```sh -/usr/local/sbin/han-vm2-compose config --images -/usr/local/sbin/han-vm2-compose ps -systemctl list-timers certbot.timer -journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service -``` - -Сохранить версии образов, дату приёмки и результаты gates без значений секретов. - -4. Настроить эксплуатационный мониторинг: - -- container unhealthy/restart/OOM; -- срок TLS; -- возраст ClamAV signatures; -- OTEL queue/export errors; -- disk/RAM; -- активный MOCK mode; -- недоступность private Safety API. - -5. Далее — отдельный controlled cutover Message Safety на VM1: private URL, internal CA, service token, API integration, rollback rehearsal и функциональные проверки. - -6. `bitrix-sync` пока оставить: - -```dotenv -BITRIX_SYNC_ENABLED=false -BITRIX_SYNC_MODE=disabled -``` - -Public allow-list — только `deny all;`. Включать Bitrix можно лишь после выполнения gates `module-07`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal. - -Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix. \ No newline at end of file diff --git a/support¬es/releases/#2 notifications deploy.md b/support¬es/releases/#2 notifications deploy.md deleted file mode 100644 index 0ac9fba..0000000 --- a/support¬es/releases/#2 notifications deploy.md +++ /dev/null @@ -1,317 +0,0 @@ -## Накатывание Notification Center v1 - -Выполнять на сервере из каталога: - -```sh -cd /path/to/HAN_chat_specification/codebase/backend -``` - -### 1. Подготовить резервную точку - -Создайте PITR-маркер/снимок PostgreSQL. Миграция forward-only — откатывать её нельзя. - -### 2. Обновить код - -```sh -git fetch -git checkout <утверждённый-commit-or-tag> -git status --short -``` - -Рабочее дерево должно быть чистым. - -### 3. Добавить переменные в `.env` - -Не перезаписывайте существующий `.env`. Добавьте: - -```sh -NOTIFICATIONS_TOKEN_PRODUCER_TEST=<случайный-токен> - -NGINX_RATE_LIMIT_NOTIFICATIONS_READ=120r/m -NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION=60r/m -NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD=20r/m -NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC=60r/m -``` - -Токен можно создать так: - -```sh -openssl rand -hex 32 -chmod 600 .env -``` - -Проверить конфигурацию: - -```sh -./scripts/validate-env .env -docker compose --env-file .env config --quiet -``` - -### 4. Собрать новые образы - -```sh -docker compose --env-file .env build --pull \ - api-backend frontend-static nginx -``` - -### 5. Выполнить миграцию - -```sh -PITR_MARKER_CONFIRMED=true ENV_FILE=.env \ - deployment/scripts/migrate.sh - -deployment/scripts/seed.sh -``` - -Ожидаемая ревизия: - -```text -0008_notifications_v1 -``` - -Проверка: - -```sh -docker compose --env-file .env --profile ops run --rm \ - migrate-api alembic current -``` - -### 6. Обновить статический frontend - -```sh -docker compose --env-file .env run --rm frontend-static -``` - -### 7. Перезапустить изменённые сервисы - -```sh -docker compose --env-file .env up -d --force-recreate \ - api-backend \ - delivery-worker \ - safety-recovery-worker \ - cleanup-worker \ - notification-expire-worker \ - notification-draft-cleanup-worker \ - nginx -``` - -### 8. Проверить состояние - -```sh -docker compose ps - -docker compose logs --since=10m \ - api-backend \ - notification-expire-worker \ - notification-draft-cleanup-worker \ - nginx - -docker compose exec -T nginx nginx -t -c /tmp/nginx.conf -``` - -Проверить API: - -```sh -curl -fsS https://chat.han0107.ru/health/live -curl -fsS https://chat.han0107.ru/health/ready -curl -fsS https://chat.han0107.ru/api/v1/public/notification-types -curl -fsS https://chat.han0107.ru/api/v1/public/notifications -``` - -Внешний internal endpoint обязан возвращать `404`: - -```sh -curl -i https://chat.han0107.ru/internal/notifications/v1/notifications -``` - -### 9. Провести smoke-тест - -```sh -deployment/scripts/smoke.sh -``` - -Дополнительно проверить через `producer_test`: - -- Create возвращает `201`; -- повтор того же тела — `200`; -- то же `external_id` с изменённым телом — `409`; -- Cancel — `200`; -- повторный Cancel — `200`; -- уведомление появляется у указанного `user_id`. - -Полный эксплуатационный чек-лист находится в `codebase/backend/deployment/RUNBOOK.ru.md`. - -Важно: при проблеме не выполнять `docker compose down -v` и не откатывать Alembic. После применения `0008` безопасный путь — исправляющий релиз; старый backend может не пройти readiness из-за проверки версии схемы. - -# Создание уведомлений - -## Публичные (инсерт в БД) - -BEGIN; - -INSERT INTO han_app.guest_notifications ( - id, - notification_type, - notification_datetime, - header, - text, - priority_override, - date_expired, - price, - old_price, - instruction_url, - chat_message_text, - lifecycle_status, - closed_at, - record_status, - status_changed_at, - status_change_reason, - created_at, - updated_at, - updater_user_id -) -VALUES --- 1. Авторизация -( - '019fa3e9-8d91-7199-8199-609916d48f04', - 'authorize', - now(), - 'Войдите в личный кабинет', - 'Авторизуйтесь, чтобы видеть персональные статусы, документы и уведомления.', - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - 'active', - NULL, - 'A', - NULL, - NULL, - now(), - now(), - NULL -), - --- 2. Установка приложения -( - '019fa3e9-8d91-722a-90df-e4824e23b354', - 'install_app', - now(), - 'Установите приложение HAN', - 'Добавьте приложение на главный экран, чтобы сервис всегда был под рукой.', - NULL, - NULL, - NULL, - NULL, - 'https://www.han0107.ru/app/how-install-pwa', - NULL, - 'active', - NULL, - 'A', - NULL, - NULL, - now(), - now(), - NULL -), - --- 3. Глобальная акция -( - '019fa3e9-8d91-7d19-8873-eefb2a60f116', - 'promo_global', - now(), - 'Специальное предложение', - 'Узнайте подробнее об актуальной акции.', - NULL, - now() + interval '30 days', - NULL, - NULL, - NULL, - 'Здравствуйте! Хочу узнать подробнее об акции.', - 'active', - NULL, - 'A', - NULL, - NULL, - now(), - now(), - NULL -), - --- 4. Глобальное предложение -( - '019fa3e9-8d91-7452-a13d-3d2518ea257d', - 'ads_global', - now(), - 'Нужна помощь?', - 'Расскажем об услугах и подберём подходящее решение.', - NULL, - now() + interval '30 days', - NULL, - NULL, - NULL, - 'Здравствуйте! Хочу получить консультацию по услугам.', - 'active', - NULL, - 'A', - NULL, - NULL, - now(), - now(), - NULL -) -ON CONFLICT (id) DO UPDATE SET - notification_datetime = EXCLUDED.notification_datetime, - header = EXCLUDED.header, - text = EXCLUDED.text, - priority_override = EXCLUDED.priority_override, - date_expired = EXCLUDED.date_expired, - price = EXCLUDED.price, - old_price = EXCLUDED.old_price, - instruction_url = EXCLUDED.instruction_url, - chat_message_text = EXCLUDED.chat_message_text, - lifecycle_status = 'active', - closed_at = NULL, - record_status = 'A', - status_changed_at = now(), - status_change_reason = 'guest_campaign_republished', - updated_at = now(); - -COMMIT; - -## Персональные - -Запускать из /opt/han-chat/backend. Публичный nginx не пропускает internal API, поэтому используем контейнер в backend-сети. - -read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST: " TOKEN -echo -read -rp "USER_ID клиента: " USER_ID - -NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -EXTERNAL_ID="manual-news-$(date +%s)" - -docker run --rm -i \ - --network han-chat-backend \ - curlimages/curl:latest \ - -sS -i \ - -X POST \ - 'http://api-backend:8000/internal/notifications/v1/notifications' \ - -H "Authorization: Bearer ${TOKEN}" \ - -H 'Content-Type: application/json' \ - --data-binary @- <