Правки различные
This commit is contained in:
@@ -3,3 +3,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
for_bugs_exchange/
|
for_bugs_exchange/
|
||||||
|
*.tar.gz
|
||||||
@@ -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_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
||||||
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
||||||
BITRIX_API_FORWARD_URL=http://api-backend:8000/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
|
MESSAGE_SAFETY_URL=https://processing.internal:8443
|
||||||
# Docker extra_hosts mapping for VM2 private listener: <hostname>=<private-ip>.
|
# Docker extra_hosts mapping must use the same configured hostname: <hostname>=<private-ip>.
|
||||||
MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4
|
MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4
|
||||||
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
|
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
|
||||||
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
|
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ han-notification-draft-cleanup-worker
|
|||||||
Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN`
|
Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN`
|
||||||
сохраняется как caller secret API backend; S3 credentials сервису Safety не
|
сохраняется как caller secret API backend; S3 credentials сервису Safety не
|
||||||
передаются. В production подключение PostgreSQL должно использовать TLS. Target
|
передаются. В production подключение PostgreSQL должно использовать TLS. Target
|
||||||
`MESSAGE_SAFETY_URL=https://processing.internal:8443`, API prefix
|
задаётся через `MESSAGE_SAFETY_URL=https://<private-vm2-name>:8443`
|
||||||
`/internal/safety/v2`; certificate проверяется по CA из
|
(`processing.internal` — только пример), API prefix `/internal/safety/v2`;
|
||||||
|
certificate проверяется по CA из
|
||||||
`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён.
|
`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён.
|
||||||
|
|
||||||
Smoke-сценарий `producer_test`: отправить `POST
|
Smoke-сценарий `producer_test`: отправить `POST
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ non-secret `PUBLIC_WEB_URL` environment variable. Keycloak resolves the
|
|||||||
initial `--import-realm`.
|
initial `--import-realm`.
|
||||||
|
|
||||||
Use exact Expo universal/app links and web origins. Do not replace them with
|
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
|
wildcards. `${PUBLIC_WEB_URL}/auth/callback`, `${PUBLIC_WEB_URL}/mobile/oidc/callback`
|
||||||
allow-listed by the initial realm import.
|
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`.
|
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"fullScopeAllowed": false,
|
"fullScopeAllowed": false,
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"${PUBLIC_WEB_URL}/auth/callback",
|
"${PUBLIC_WEB_URL}/auth/callback",
|
||||||
|
"${PUBLIC_WEB_URL}/mobile/oidc/callback",
|
||||||
"han-chat://auth/callback"
|
"han-chat://auth/callback"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
|
|||||||
+1
@@ -29,6 +29,7 @@ class RealmContractTest {
|
|||||||
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
||||||
assertTrue(realm.contains("\"han-chat://auth/callback\""));
|
assertTrue(realm.contains("\"han-chat://auth/callback\""));
|
||||||
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/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}\""));
|
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}\""));
|
||||||
assertFalse(realm.contains("chat.han0107.ru"));
|
assertFalse(realm.contains("chat.han0107.ru"));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ otpResend=Отправить код снова
|
|||||||
authBack=Назад
|
authBack=Назад
|
||||||
verifyOtp=Подтвердить
|
verifyOtp=Подтвердить
|
||||||
otpVerifying=Проверяем...
|
otpVerifying=Проверяем...
|
||||||
otpSubmitUnavailable=Не удалось отправить код. Проверьте соединение и попробуйте ещё раз.
|
otpSubmitUnavailable=Не удалось завершить вход. Если код уже принят, закройте окно и откройте приложение.
|
||||||
mockMode=Тестовый режим отправки кода
|
mockMode=Тестовый режим отправки кода
|
||||||
phoneInvalid=Проверьте формат номера телефона.
|
phoneInvalid=Проверьте формат номера телефона.
|
||||||
otpInvalid=Код неверен, истёк или уже использован.
|
otpInvalid=Код неверен, истёк или уже использован.
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ RUN apt-get update \
|
|||||||
COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template
|
COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template
|
||||||
COPY templates /etc/nginx/templates-src/sites
|
COPY templates /etc/nginx/templates-src/sites
|
||||||
COPY snippets /etc/nginx/snippets
|
COPY snippets /etc/nginx/snippets
|
||||||
|
COPY static /etc/nginx/static
|
||||||
COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint
|
COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint
|
||||||
RUN sed -i 's/\r$//' /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 \
|
&& /bin/sh -n /usr/local/bin/han-nginx-entrypoint \
|
||||||
&& chmod 0555 /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"]
|
ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"]
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>HAN Chat</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
background: #fff;
|
||||||
|
color: #252525;
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
p { margin: 0; line-height: 1.5; }
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 220px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #030213;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Вход выполнен. Возвращаем в приложение…</p>
|
||||||
|
<p>
|
||||||
|
<a id="han-open-app" href="han-chat://auth/callbackHAN_CALLBACK_QUERY">Открыть HAN Chat</a>
|
||||||
|
</p>
|
||||||
|
<script src="/mobile/oidc/callback.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
(function () {
|
||||||
|
var target = "han-chat://auth/callback" + window.location.search + window.location.hash;
|
||||||
|
var link = document.getElementById("han-open-app");
|
||||||
|
if (link) link.href = target;
|
||||||
|
window.location.replace(target);
|
||||||
|
})();
|
||||||
@@ -139,6 +139,23 @@ server {
|
|||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
include /etc/nginx/generated/security-headers.conf;
|
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/ {
|
location ^~ /auth/resources/ {
|
||||||
include /etc/nginx/snippets/proxy-keycloak.conf;
|
include /etc/nginx/snippets/proxy-keycloak.conf;
|
||||||
proxy_pass http://keycloak_upstream;
|
proxy_pass http://keycloak_upstream;
|
||||||
|
|||||||
@@ -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")
|
errors.append(f"{key}: ожидается http URL с Docker DNS service name")
|
||||||
if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"):
|
if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"):
|
||||||
errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /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(
|
errors.append(
|
||||||
"MESSAGE_SAFETY_URL: ожидается remote TLS endpoint "
|
"MESSAGE_SAFETY_URL: ожидается remote TLS endpoint "
|
||||||
"https://processing.internal:8443"
|
"https://<private VM2 hostname>:8443, не local/stub service"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
extra_host, extra_ip = env.get("MESSAGE_SAFETY_EXTRA_HOST", "").rsplit("=", 1)
|
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)
|
address = ipaddress.ip_address(extra_ip)
|
||||||
if extra_host != safety_host or not address.is_private:
|
if extra_host != safety_host or not address.is_private:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
|||||||
@@ -152,6 +152,28 @@ class SecretHygieneTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(result.returncode, 0)
|
self.assertNotEqual(result.returncode, 0)
|
||||||
self.assertIn(expected_error, result.stderr)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1085,7 +1085,7 @@ Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый
|
|||||||
|
|
||||||
`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04.
|
`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04.
|
||||||
|
|
||||||
В production validator принимает только remote `https://<private-vm2-name>:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и cross-host Docker hostname. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`.
|
В production validator принимает remote `https://<private-vm2-name>:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и local/stub Docker hostname. Конкретное private DNS-имя ВМ2 определяется внутренним доменом окружения и задаётся в `MESSAGE_SAFETY_URL` и согласованном `MESSAGE_SAFETY_EXTRA_HOST`; каноническое имя не требуется. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`.
|
||||||
|
|
||||||
## 19. Rate limiting
|
## 19. Rate limiting
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Nginx ВМ1 — публичная точка входа приложения: f
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
||||||
| `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer |
|
| `/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 |
|
| 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 |
|
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
|
||||||
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
|
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ han-chat-frontend
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
https://tohin.ru/auth/callback
|
https://tohin.ru/auth/callback
|
||||||
|
https://tohin.ru/mobile/oidc/callback
|
||||||
han-chat://auth/callback
|
han-chat://auth/callback
|
||||||
<Expo native scheme/callback, exact value после сборки>
|
<Expo native scheme/callback, exact value после сборки>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ webhooks собственного host. Compose, IAM principal и secret bundle
|
|||||||
performance, egress и rollback gates своего runbook.
|
performance, egress и rollback gates своего runbook.
|
||||||
2. ВМ1 использует
|
2. ВМ1 использует
|
||||||
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443` и root-owned
|
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443` и root-owned
|
||||||
`MESSAGE_SAFETY_CA_HOST_PATH`.
|
`MESSAGE_SAFETY_CA_HOST_PATH`. Значение `<VM2_PRIVATE_DNS>` определяется
|
||||||
|
внутренним DNS-доменом окружения и задаётся в `.env`; фиксированное
|
||||||
|
каноническое имя не требуется.
|
||||||
3. Internal CA читается фактическим UID API и не читается посторонним UID.
|
3. Internal CA читается фактическим UID API и не читается посторонним UID.
|
||||||
4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops.
|
4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops.
|
||||||
5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback
|
5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
-119
@@ -1,7 +1,8 @@
|
|||||||
# SigNoz для HAN Chat
|
# SigNoz для HAN Chat
|
||||||
|
|
||||||
Одноузловой self-hosted SigNoz на Ubuntu 24.04 через официальный
|
Одноузловой 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`;
|
- рабочий приватный интерфейс с адресом `192.168.0.5`;
|
||||||
- доступ к ВМ по SSH через приватную сеть до отключения внешнего IP.
|
- доступ к ВМ по 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-туннель;
|
- [NETWORK.md](docs/NETWORK.md) — сеть, группа безопасности, SSH-туннель;
|
||||||
- [BACKEND_OTLP.md](docs/BACKEND_OTLP.md) — передача телеметрии HAN Chat;
|
- [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
|
- [MVP_DASHBOARDS_ALERTS.md](docs/MVP_DASHBOARDS_ALERTS.md) — versioned
|
||||||
спецификация первых dashboards и alerts.
|
спецификация первых dashboards и alerts.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Сеть и доступ
|
# Сеть и доступ
|
||||||
|
|
||||||
|
Исполняемый порядок раскатки, включая Netplan, SG и lockdown — в
|
||||||
|
[RUNBOOK.ru.md](RUNBOOK.ru.md). Этот файл фиксирует сетевую диагностику и
|
||||||
|
проверки доступа.
|
||||||
|
|
||||||
## Сбор диагностики
|
## Сбор диагностики
|
||||||
|
|
||||||
До изменения Netplan выполните и сохраните вывод:
|
До изменения Netplan выполните и сохраните вывод:
|
||||||
@@ -50,29 +54,28 @@ sudo ./scripts/05-configure-private-network.sh
|
|||||||
```bash
|
```bash
|
||||||
ip -br -4 address show eth1
|
ip -br -4 address show eth1
|
||||||
ip route get 192.168.0.1
|
ip route get 192.168.0.1
|
||||||
ping -c 3 192.168.0.1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
С другой VM приватной сети проверьте:
|
С другой VM приватной сети проверьте SSH. ICMP в группе безопасности обычно
|
||||||
|
закрыт, `ping` не является проверкой связности:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ping -c 3 192.168.0.5
|
ssh -i ~/.ssh/hansel-private -o ConnectTimeout=5 root@192.168.0.5
|
||||||
ssh -i ~/.ssh/hansel-private root@192.168.0.5
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Если новая SSH-сессия работает, вернитесь в первую и подтвердите Netplan
|
Если новая SSH-сессия работает, вернитесь в первую и подтвердите Netplan
|
||||||
клавишей Enter. Если нет — не подтверждайте: через 120 секунд произойдёт откат.
|
клавишей Enter. Если нет — не подтверждайте: через 120 секунд произойдёт откат.
|
||||||
Отсутствие ответа на ping само по себе может означать запрет ICMP; SSH является
|
|
||||||
основной проверкой.
|
|
||||||
|
|
||||||
## Группа безопасности
|
## Группа безопасности
|
||||||
|
|
||||||
Минимальные входящие правила для VM SigNoz:
|
Минимальные входящие правила для VM SigNoz:
|
||||||
|
|
||||||
- TCP 22 от административного узла/подсети приватной сети;
|
- TCP 22: на хосте UFW разрешает любой источник; конкретные IP/подсети
|
||||||
- TCP 4317 от security groups/private IP ВМ1 и ВМ2;
|
задаются в SG или файрволе приватной сети провайдера;
|
||||||
- TCP 4318 от ВМ1/ВМ2 только если планируется OTLP/HTTP;
|
- TCP 4317 от приватной подсети `192.168.0.0/24` (ВМ1 и ВМ2);
|
||||||
|
- TCP 4318 от той же подсети, если нужен OTLP/HTTP;
|
||||||
- никаких входящих правил для 8080, 5432, 8123, 9000, 9181.
|
- никаких входящих правил для 8080, 5432, 8123, 9000, 9181.
|
||||||
|
UI только через SSH `-L 8080:127.0.0.1:8080`.
|
||||||
|
|
||||||
Для текущего backend используется OTLP/gRPC, поэтому после проверки 4318 можно
|
Для текущего backend используется OTLP/gRPC, поэтому после проверки 4318 можно
|
||||||
закрыть. Исходящий доступ к приватной сети оставьте. Для обновления временно
|
закрыть. Исходящий доступ к приватной сети оставьте. Для обновления временно
|
||||||
|
|||||||
@@ -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@<IP_ВМ3> -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, не в этом же
|
||||||
|
окне раскатки.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Работа с SigNoz для HAN Chat
|
# Работа с SigNoz для HAN Chat
|
||||||
|
|
||||||
|
Это эксплуатационный runbook UI: фильтры, ежедневная проверка и разбор
|
||||||
|
инцидентов. Раскатка ВМ3, lockdown и обновление стека — только в
|
||||||
|
[RUNBOOK.ru.md](RUNBOOK.ru.md).
|
||||||
|
|
||||||
## Базовые фильтры
|
## Базовые фильтры
|
||||||
|
|
||||||
Во всех разделах начинайте с:
|
Во всех разделах начинайте с:
|
||||||
|
|||||||
@@ -36,18 +36,35 @@ else
|
|||||||
fail "DNS не разрешает адреса репозиториев"
|
fail "DNS не разрешает адреса репозиториев"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for url in \
|
probe_https() {
|
||||||
https://archive.ubuntu.com \
|
local label="$1"
|
||||||
https://download.docker.com \
|
local url="$2"
|
||||||
https://registry-1.docker.io \
|
shift 2
|
||||||
https://github.com \
|
local code
|
||||||
https://signoz.io; do
|
code="$(curl -4sS -o /dev/null -w '%{http_code}' \
|
||||||
if curl -4fsSI --connect-timeout 8 --max-time 15 "$url" >/dev/null; then
|
--connect-timeout 8 --max-time 15 "$url" || true)"
|
||||||
ok "доступен $url"
|
local expected
|
||||||
else
|
for expected in "$@"; do
|
||||||
fail "нет доступа к $url"
|
if [[ "$code" == "$expected" ]]; then
|
||||||
fi
|
ok "доступен $label"
|
||||||
done
|
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
|
||||||
echo "=== Занятость портов ==="
|
echo "=== Занятость портов ==="
|
||||||
|
|||||||
@@ -2,43 +2,70 @@
|
|||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
PRIVATE_IP="${PRIVATE_IP:-192.168.0.5}"
|
PRIVATE_IP="${PRIVATE_IP:-192.168.0.5}"
|
||||||
ADMIN_CIDR="${ADMIN_CIDR:-192.168.0.0/24}"
|
PRIVATE_CIDR="${PRIVATE_CIDR:-192.168.0.0/24}"
|
||||||
OTLP_SOURCE="${OTLP_SOURCE:-}"
|
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "Запустите через sudo." >&2
|
echo "Запустите через sudo." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ -z "$OTLP_SOURCE" ]]; then
|
[[ "$PRIVATE_CIDR" =~ ^[0-9.]+/[0-9]+$ ]] || {
|
||||||
echo "Укажите приватный IP backend, например:" >&2
|
echo "Некорректный PRIVATE_CIDR: ${PRIVATE_CIDR}" >&2
|
||||||
echo " sudo OTLP_SOURCE=192.168.0.1 $0" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
}
|
||||||
ip -4 addr show | grep -Fq "inet ${PRIVATE_IP}/" || {
|
ip -4 addr show | grep -Fq "inet ${PRIVATE_IP}/" || {
|
||||||
echo "Приватный адрес ${PRIVATE_IP} не найден." >&2
|
echo "Приватный адрес ${PRIVATE_IP} не найден." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "SSH будет разрешён только из ${ADMIN_CIDR}."
|
cat <<EOF
|
||||||
echo "OTLP будет разрешён только от ${OTLP_SOURCE}."
|
Будут применены правила UFW:
|
||||||
echo "До продолжения проверьте отдельную SSH-сессию через приватную сеть."
|
|
||||||
|
входящий по умолчанию: deny
|
||||||
|
исходящий по умолчанию: allow
|
||||||
|
TCP 22 с любого источника (ограничение IP — в SG провайдера)
|
||||||
|
TCP 4317/4318 только из ${PRIVATE_CIDR} на ${PRIVATE_IP} (ВМ1 и ВМ2)
|
||||||
|
TCP 8080 не открывается: UI только через SSH-туннель на 127.0.0.1:8080
|
||||||
|
|
||||||
|
Пример туннеля с рабочей станции:
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/hansel -L 8080:127.0.0.1:8080 root@<IP_ВМ3> -N
|
||||||
|
|
||||||
|
До продолжения откройте вторую SSH-сессию, чтобы не потерять доступ.
|
||||||
|
EOF
|
||||||
read -r -p "Введите APPLY для применения правил: " answer
|
read -r -p "Введите APPLY для применения правил: " answer
|
||||||
[[ "$answer" == APPLY ]] || { echo "Отменено."; exit 1; }
|
[[ "$answer" == APPLY ]] || { echo "Отменено."; exit 1; }
|
||||||
|
|
||||||
apt-get update
|
if ! command -v ufw >/dev/null; then
|
||||||
apt-get install -y ufw
|
apt-get update
|
||||||
|
apt-get install -y ufw
|
||||||
|
fi
|
||||||
|
|
||||||
|
ufw --force reset
|
||||||
ufw default deny incoming
|
ufw default deny incoming
|
||||||
ufw default allow outgoing
|
ufw default allow outgoing
|
||||||
ufw logging medium
|
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"
|
# Источник SSH на хосте не фильтруем: allow-list делает файрвол приватной
|
||||||
ufw allow from "$OTLP_SOURCE" to "$PRIVATE_IP" port 4318 proto tcp comment "HAN OTLP HTTP"
|
# сети / 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 --force enable
|
||||||
ufw status verbose
|
ufw status verbose
|
||||||
|
|
||||||
cat <<'EOF'
|
cat <<EOF
|
||||||
|
|
||||||
Важно: Docker-публикации могут обходить цепочку UFW INPUT. Основная фильтрация
|
UI не публикуется. Туннель:
|
||||||
источника OTLP должна оставаться в облачной группе безопасности. casting.yaml
|
|
||||||
дополнительно привязывает OTLP только к приватному IP, а UI — к loopback.
|
ssh -i ~/.ssh/hansel -L 8080:127.0.0.1:8080 root@<IP_ВМ3> -N
|
||||||
|
|
||||||
|
затем http://127.0.0.1:8080 на рабочей станции.
|
||||||
|
|
||||||
|
Docker-публикации могут обходить UFW INPUT. Если 4317 доступен вне
|
||||||
|
${PRIVATE_CIDR}, ограничьте источник в SG провайдера. casting.yaml биндит
|
||||||
|
OTLP на ${PRIVATE_IP}, UI — на loopback.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await SecureStore.setItemAsync(PENDING_CONSENTS_KEY, JSON.stringify(consents));
|
await SecureStore.setItemAsync(PENDING_CONSENTS_KEY, JSON.stringify(consents));
|
||||||
const result = await beginAuthorization();
|
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") {
|
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
|
||||||
setAuthStatus("guest");
|
setAuthStatus("guest");
|
||||||
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
|
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
|
||||||
|
|||||||
+68
-15
@@ -1,8 +1,10 @@
|
|||||||
import * as AuthSession from "expo-auth-session";
|
import * as AuthSession from "expo-auth-session";
|
||||||
import * as Crypto from "expo-crypto";
|
import * as Crypto from "expo-crypto";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from "expo-secure-store";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
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 { buildOidcDeviceMetadata } from "./oidc-device";
|
||||||
import { SingleFlight } from "./single-flight";
|
import { SingleFlight } from "./single-flight";
|
||||||
import type { TokenSet } from "./types";
|
import type { TokenSet } from "./types";
|
||||||
@@ -34,10 +36,66 @@ const secureStore = {
|
|||||||
del: (key: string) => SecureStore.deleteItemAsync(key),
|
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 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`;
|
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<WebBrowser.WebBrowserAuthSessionResult>((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) {
|
export function configureAuthFailure(callback: () => void) {
|
||||||
authFailure = callback;
|
authFailure = callback;
|
||||||
}
|
}
|
||||||
@@ -78,12 +136,15 @@ export async function beginAuthorization() {
|
|||||||
const verifier = random();
|
const verifier = random();
|
||||||
const state = random();
|
const state = random();
|
||||||
const nonce = random();
|
const nonce = random();
|
||||||
|
const redirectUri = oauthRedirectUri();
|
||||||
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
|
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
|
||||||
encoding: Crypto.CryptoEncoding.BASE64,
|
encoding: Crypto.CryptoEncoding.BASE64,
|
||||||
});
|
});
|
||||||
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||||
const deviceMetadata = await buildOidcDeviceMetadata(secureStore);
|
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({
|
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
|
||||||
client_id: env.clientId,
|
client_id: env.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
@@ -95,17 +156,9 @@ export async function beginAuthorization() {
|
|||||||
nonce,
|
nonce,
|
||||||
...deviceMetadata,
|
...deviceMetadata,
|
||||||
})}`;
|
})}`;
|
||||||
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
|
const result = await openAuthSession(url);
|
||||||
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
|
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
|
||||||
const callback = new URL(result.url);
|
return { type: "success" as const, params: paramsFromCallbackUrl(result.url) };
|
||||||
return {
|
|
||||||
type: "success" as const,
|
|
||||||
params: {
|
|
||||||
code: callback.searchParams.get("code"),
|
|
||||||
state: callback.searchParams.get("state"),
|
|
||||||
error: callback.searchParams.get("error"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function completeAuthorization(code: string, state: string) {
|
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);
|
const raw = await secureStore.get(PKCE_KEY);
|
||||||
await secureStore.del(PKCE_KEY);
|
await secureStore.del(PKCE_KEY);
|
||||||
if (!raw) throw new Error("pkce_state_missing");
|
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) {
|
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
|
||||||
throw new Error("pkce_state_invalid");
|
throw new Error("pkce_state_invalid");
|
||||||
}
|
}
|
||||||
@@ -127,7 +180,7 @@ async function completeAuthorizationOnce(code: string, state: string) {
|
|||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
client_id: env.clientId,
|
client_id: env.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: saved.redirectUri || oauthRedirectUri(),
|
||||||
code,
|
code,
|
||||||
code_verifier: saved.verifier,
|
code_verifier: saved.verifier,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
|
|||||||
@@ -12,5 +12,7 @@ export const env = Object.freeze({
|
|||||||
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
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 oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
|
||||||
export const isProduction = env.appEnv === "production";
|
export const isProduction = env.appEnv === "production";
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ vi.mock("expo-secure-store", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("expo-web-browser", () => ({
|
vi.mock("expo-web-browser", () => ({
|
||||||
maybeCompleteAuthSession: vi.fn(),
|
maybeCompleteAuthSession: vi.fn(),
|
||||||
|
openAuthSessionAsync: vi.fn(),
|
||||||
|
openBrowserAsync: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("expo-document-picker", () => ({
|
vi.mock("expo-document-picker", () => ({
|
||||||
getDocumentAsync: vi.fn(),
|
getDocumentAsync: vi.fn(),
|
||||||
@@ -35,6 +37,7 @@ vi.mock("expo-file-system/legacy", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("expo-linking", () => ({
|
vi.mock("expo-linking", () => ({
|
||||||
openURL: vi.fn(),
|
openURL: vi.fn(),
|
||||||
|
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
vi.mock("expo-sharing", () => ({
|
vi.mock("expo-sharing", () => ({
|
||||||
isAvailableAsync: vi.fn(),
|
isAvailableAsync: vi.fn(),
|
||||||
@@ -42,7 +45,7 @@ vi.mock("expo-sharing", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("react-native", () => ({
|
vi.mock("react-native", () => ({
|
||||||
Platform: { OS: "android", Version: "test", constants: {} },
|
Platform: { OS: "android", Version: "test", constants: {} },
|
||||||
AppState: { currentState: "active" },
|
AppState: { currentState: "active", addEventListener: vi.fn(() => ({ remove: vi.fn() })) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildOidcDeviceMetadata } from "../../src/oidc-device";
|
import { buildOidcDeviceMetadata } from "../../src/oidc-device";
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
33. #BACK_BUSINESS Создать поле "Номер телефона в приложении". Подробности реализации ниже (на подумать)
|
33. #BACK_BUSINESS Создать поле "Номер телефона в приложении". Подробности реализации ниже (на подумать)
|
||||||
34. #BACK_BUSINESS реализация смены номера телефона. Подробности реализации ниже (на подумать)
|
34. #BACK_BUSINESS реализация смены номера телефона. Подробности реализации ниже (на подумать)
|
||||||
35. #BACK_BUSINESS схлапывание контактов. Подробности реализации ниже (на подумать)
|
35. #BACK_BUSINESS схлапывание контактов. Подробности реализации ниже (на подумать)
|
||||||
|
36. #INFRASTRUCTURE положить в облако секреты ВМ
|
||||||
|
|
||||||
# Критично для релиза:
|
# Критично для релиза:
|
||||||
~~1. Разработка message-safety~~
|
~~1. Разработка message-safety~~
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
# Release #0: безопасный порядок развертывания
|
|
||||||
|
|
||||||
Этот файл больше не является журналом реальных адресов, SSH-ключей и локальных
|
|
||||||
путей. Фактические значения инфраструктуры хранятся в защищённой CMDB/ops wiki,
|
|
||||||
а не в Git.
|
|
||||||
|
|
||||||
## 1. Подготовка ВМ
|
|
||||||
|
|
||||||
Разрешите извне только 80/443 и SSH из административной сети. PostgreSQL
|
|
||||||
доступен ВМ только через приватную сеть.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scp -i <DEPLOY_SSH_KEY> deployment/scripts/setup-vm.sh \
|
|
||||||
<CLOUD_USER>@<VM_IP>:/tmp/setup-vm.sh
|
|
||||||
ssh -i <DEPLOY_SSH_KEY> <CLOUD_USER>@<VM_IP>
|
|
||||||
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 <DEPLOY_SSH_KEY> han-chat-backend.tar.gz deploy@<VM_IP>:/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 <PREVIOUS_RELEASE>
|
|
||||||
```
|
|
||||||
|
|
||||||
Откат секрета выполняется активацией предыдущей версии в Selectel, повторным
|
|
||||||
sync и пересозданием только затронутых сервисов. Snapshot старого `.env` не
|
|
||||||
создаётся.
|
|
||||||
|
|
||||||
## 7. Break-glass
|
|
||||||
|
|
||||||
Только при подтверждённом инциденте доставьте root-only recovery file из
|
|
||||||
защищённой офлайн-копии, установите `SECRETS_SOURCE=file`, выполните
|
|
||||||
sync/validate/recreate и зафиксируйте событие. Автоматический fallback запрещён.
|
|
||||||
После восстановления Selectel верните штатный режим и удалите recovery file.
|
|
||||||
@@ -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:<URL_ENCODED_PASSWORD>@<PG_HOST>:<PG_PORT>/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
|
||||||
|
|
||||||
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
|
|
||||||
SMS_SERVICE_TOKEN=<openssl rand -hex 32>
|
|
||||||
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://<PUBLIC_HOST>/callbacks/idgtl/sms
|
|
||||||
IDGTL_SMS_CALLBACK_USERNAME=<openssl rand -hex 16>
|
|
||||||
IDGTL_SMS_CALLBACK_PASSWORD=<openssl rand -hex 32>
|
|
||||||
|
|
||||||
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('<APPROVED_SENDER>'::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` автоматически не переотправлять — их необходимо разбирать вручную.
|
|
||||||
@@ -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 '<OPERATOR>'
|
|
||||||
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
|
||||||
--entrypoint message-safety-config message-safety-migrate \
|
|
||||||
activate --version 1 --approved-by '<APPROVER>'
|
|
||||||
```
|
|
||||||
|
|
||||||
Потом права отзываем
|
|
||||||
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 </dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
С VM1 или ops host, имеющего private route, проверьте internal chain и SAN:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
openssl s_client -connect 192.168.0.4:8443 \
|
|
||||||
-servername processing.internal \
|
|
||||||
-verify_hostname processing.internal \
|
|
||||||
-CAfile /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt \
|
|
||||||
-verify_return_error </dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
Обе команды должны завершить certificate verification без ошибки.
|
|
||||||
|
|
||||||
Переключите renewal с первоначального `standalone` на webroot, который nginx
|
|
||||||
обслуживает по `/.well-known/acme-challenge/`. `certbot reconfigure` сам
|
|
||||||
проверит новый способ через staging CA:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
PUBLIC_HOST='service4chat.han0107.ru'
|
|
||||||
certbot reconfigure \
|
|
||||||
--cert-name "$PUBLIC_HOST" \
|
|
||||||
--authenticator webroot \
|
|
||||||
--webroot-path /var/lib/han-chat/acme
|
|
||||||
```
|
|
||||||
|
|
||||||
Повторный setup после активации релиза устанавливает deploy-hook
|
|
||||||
`/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`: после успешного
|
|
||||||
обновления он атомарно размещает certificate/key с группой `han-nginx-tls` в
|
|
||||||
`/var/lib/han-chat/public-tls`, проверяет конфигурацию nginx и отправляет
|
|
||||||
контейнеру `HUP`.
|
|
||||||
Проверьте полный цикл и включите штатное расписание Certbot:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
test -x /etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx
|
|
||||||
certbot renew --dry-run --run-deploy-hooks
|
|
||||||
systemctl enable --now certbot.timer
|
|
||||||
systemctl --no-pager status certbot.timer
|
|
||||||
systemctl list-timers certbot.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
`certbot.timer` проверяет необходимость продления дважды в сутки; сертификат
|
|
||||||
перевыпускается только при приближении срока. Ошибка dry-run или deploy-hook —
|
|
||||||
блокер. Итог `Congratulations, all simulated renewals succeeded` означает
|
|
||||||
успех. Со старым hook сообщение `Hook 'deploy-hook' ran with error output`
|
|
||||||
могло быть ложным: Compose писал успешный `HUP` как `Killing/Killed`, а
|
|
||||||
успешный `nginx -t` — `syntax is ok` в stderr. Исправленный hook выводит
|
|
||||||
config-test только при ошибке и отправляет HUP без progress-вывода. Порт `80`
|
|
||||||
после этого остаётся доступен для HTTP-01 renewal.
|
|
||||||
|
|
||||||
|
|
||||||
### Gate 7 — public routing
|
|
||||||
|
|
||||||
С внешней тестовой машины:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
|
||||||
http://service4chat.han0107.ru/not-a-route
|
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
|
||||||
https://service4chat.han0107.ru/not-a-route
|
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
|
||||||
http://service4chat.han0107.ru/bitrix/sync/webhook/contact
|
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
|
||||||
https://service4chat.han0107.ru/internal/safety/status
|
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' -X GET \
|
|
||||||
https://service4chat.han0107.ru/bitrix/sync/webhook/contact
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаемые коды по порядку: `308`, `404`, `426`, `404`, `405`. Для HTTPS
|
|
||||||
используйте только валидный public certificate, без `-k`.
|
|
||||||
|
|
||||||
POST к webhook с адреса вне Bitrix allow-list должен получить `403`; если
|
|
||||||
cloud firewall настроен на drop, допустим timeout. Затем повторите с
|
|
||||||
разрешённого source IP и заведомо неверным receiver token: upstream должен
|
|
||||||
ответить `403`, не `2xx`.
|
|
||||||
|
|
||||||
|
|
||||||
### Gate 8 — private API только с VM1/ops
|
|
||||||
|
|
||||||
Следующие команды выполняются **на VM1** или approved ops host, не на VM2.
|
|
||||||
Используйте private DNS/SAN и внутреннюю CA.
|
|
||||||
|
|
||||||
Safety status:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl --fail --silent --show-error \
|
|
||||||
--cacert /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt \
|
|
||||||
https://processing.internal:8443/internal/safety/status
|
|
||||||
```
|
|
||||||
|
|
||||||
Benign text check через тот же private listener:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
SAFETY_TOKEN="$(cat /run/han-chat/secrets/MESSAGE_SAFETY_SERVICE_TOKEN)"
|
|
||||||
MESSAGE_ID="$(uuidgen)"
|
|
||||||
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
|
|
||||||
url = "https://processing.internal:8443/internal/safety/v2/messages/check"
|
|
||||||
cacert = "/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt"
|
|
||||||
request = "POST"
|
|
||||||
header = "X-Service-Token: ${SAFETY_TOKEN}"
|
|
||||||
header = "Content-Type: application/json"
|
|
||||||
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM2 safety canary\",\"attachment\":null}"
|
|
||||||
EOF
|
|
||||||
unset SAFETY_TOKEN MESSAGE_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
В standard mode ожидается `HTTP 200`, `verdict=allow` и непустые
|
|
||||||
`config_version`/`rules_version`. Проверку `202 → Location → task GET`
|
|
||||||
выполняйте отдельным file smoke только с реальным versioned quarantine object:
|
|
||||||
выдуманные S3 key/version/ETag не являются валидным тестом.
|
|
||||||
|
|
||||||
Для Bitrix status прочитайте token из уже защищённого secret file VM1 в
|
|
||||||
переменную и передайте curl config через stdin, чтобы значение не попало в
|
|
||||||
argv/history:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
BITRIX_TOKEN="$(cat /run/han-chat/secrets/BITRIX_SYNC_SERVICE_TOKEN)"
|
|
||||||
curl --silent --show-error --output /tmp/vm2-sync-status.json \
|
|
||||||
--write-out '%{http_code}\n' --config - <<EOF
|
|
||||||
url = "https://processing.internal:8443/internal/sync/v1/status"
|
|
||||||
cacert = "/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt"
|
|
||||||
header = "Authorization: Bearer ${BITRIX_TOKEN}"
|
|
||||||
EOF
|
|
||||||
unset BITRIX_TOKEN
|
|
||||||
cat /tmp/vm2-sync-status.json
|
|
||||||
rm -f /tmp/vm2-sync-status.json
|
|
||||||
```
|
|
||||||
|
|
||||||
При `BITRIX_SYNC_ENABLED=false` ожидается закрытая/неготовая синхронизация, а
|
|
||||||
не ложный успешный full-mode status. С машины вне `VM1_PRIVATE_CIDRS` и
|
|
||||||
необязательных приватных/VPN-сетей `OPS_CIDRS` подключение к `8443` должно
|
|
||||||
завершиться timeout/reject.
|
|
||||||
|
|
||||||
### Gate 9 — canary на отсутствие секретов в логах и traces
|
|
||||||
|
|
||||||
Создайте **фейковый**, не production token marker и отправьте его с
|
|
||||||
разрешённого тестового source IP:
|
|
||||||
|
|
||||||
CANARY можно ставить любой
|
|
||||||
|
|
||||||
```sh
|
|
||||||
CANARY="HAN_VM2_REDACTION_260813"
|
|
||||||
curl -sS -o /dev/null \
|
|
||||||
-H "Authorization: Bearer ${CANARY}" \
|
|
||||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
|
||||||
--data-urlencode "auth[application_token]=${CANARY}" \
|
|
||||||
"https://service4chat.han0107.ru/bitrix/sync/webhook/contact?token=${CANARY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
На VM2 под `root`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
CANARY='HAN_VM2_REDACTION_260813'
|
|
||||||
if /usr/local/sbin/han-vm2-compose logs --no-color \
|
|
||||||
nginx bitrix-sync message-safety-api otel-collector |
|
|
||||||
grep -F -- "$CANARY"; then
|
|
||||||
echo 'FAIL: canary попал в логи' >&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.
|
|
||||||
@@ -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 @- <<JSON
|
|
||||||
{
|
|
||||||
"user_id": "${USER_ID}",
|
|
||||||
"notification_type": "news",
|
|
||||||
"source": "producer_test",
|
|
||||||
"external_id": "${EXTERNAL_ID}",
|
|
||||||
"notification_datetime": "${NOW}",
|
|
||||||
"header": "Тестовое уведомление",
|
|
||||||
"text": "Уведомление создано через Internal API",
|
|
||||||
"details": {
|
|
||||||
"details_header": "Проверка Notification Center",
|
|
||||||
"details_text": "Это тестовое уведомление от producer_test."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
Reference in New Issue
Block a user