diff --git a/backlog.md b/backlog.md new file mode 100644 index 0000000..5d5759f --- /dev/null +++ b/backlog.md @@ -0,0 +1,9 @@ +В разработку: +1. Создание нового диалога через UI +2. При создании пользователя номер телефона копировать в профиль Russian_Phone +3. Убрать хеширование устройства клиента в devise_json - надо видеть его параметры. +4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс. +5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе + +Выполнено: +1. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession) \ No newline at end of file diff --git a/codebase/backend/bitrix-local-app/app/main.py b/codebase/backend/bitrix-local-app/app/main.py index b78c938..324e0e6 100644 --- a/codebase/backend/bitrix-local-app/app/main.py +++ b/codebase/backend/bitrix-local-app/app/main.py @@ -159,7 +159,7 @@ def retry_delay(attempt: int, maximum: int) -> float: def safely_retryable(exc: Exception) -> bool: - return isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)) or ( + return isinstance(exc, (TimeoutError, httpx.ConnectError, httpx.TimeoutException)) or ( isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in {429, 502, 503, 504} ) @@ -751,7 +751,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: ), ) try: - result = await deliver_outbound(request.app, row.id) + result = await asyncio.wait_for( + deliver_outbound(request.app, row.id), + timeout=cfg.bitrix_http_timeout_sec, + ) except Exception as exc: logger.exception( "outbound delivery failed", @@ -1257,7 +1260,10 @@ async def process_outbound(app: FastAPI) -> None: if not row: return try: - await deliver_outbound(app, row.id) + await asyncio.wait_for( + deliver_outbound(app, row.id), + timeout=app.state.settings.bitrix_http_timeout_sec, + ) except Exception as exc: logger.exception( "outbound retry failed", diff --git a/codebase/backend/bitrix-local-app/tests/test_core.py b/codebase/backend/bitrix-local-app/tests/test_core.py index 2d54338..6796a34 100644 --- a/codebase/backend/bitrix-local-app/tests/test_core.py +++ b/codebase/backend/bitrix-local-app/tests/test_core.py @@ -208,6 +208,7 @@ def test_fingerprint_ignores_signed_query_and_portal_validation(): def test_network_timeouts_are_retryable(): + assert safely_retryable(TimeoutError("Delivery operation timed out")) assert safely_retryable(httpx.ReadTimeout("Bitrix response timed out")) assert safely_retryable(httpx.ConnectTimeout("Bitrix connection timed out")) diff --git a/deploy-steps.md b/deploy-steps.md index da2ae66..480a81e 100644 --- a/deploy-steps.md +++ b/deploy-steps.md @@ -8,7 +8,7 @@ - (без спец.символов) openssl rand -hex 16 - openssl rand -base64 16 | xclip -selection clipboard # Linux -Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5433 root@135.106.164.58 -N +Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5432 root@135.106.164.58 -N #Обновление проекта @@ -90,6 +90,28 @@ mkdir -p ~/.postgresql/ wget https://storage.dbaas.selcloud.ru/CA.pem -O ~/.postgresql/root.crt chmod 0600 ~/.postgresql/root.crt +### Правки DNS-маршрутизации (в прошлый раз, какие-то трабблы были с базовой маршрутизацией) - скрипт ниже создаст +sudo tee /etc/netplan/99-han-dns.yaml <<'EOF' +network: + version: 2 + ethernets: + eth0: + nameservers: + addresses: + - 8.8.8.8 + - 1.1.1.1 + search: + - selcloud.ru + eth1: + dhcp4-overrides: + use-dns: false + use-domains: false +EOF + +sudo chmod 600 /etc/netplan/99-han-dns.yaml +sudo netplan apply +resolvectl flush-caches + ### Проверка подключения к Postgers в ВМ nc -zv 192.168.0.211 5433 @@ -292,3 +314,132 @@ docker compose --env-file .env ps docker compose --env-file .env logs --tail=200 docker inspect "$(docker compose --env-file .env ps -q )" ``` + +## Первоначальный выпуск TLS-сертификата + +Для ACME требуется работающий nginx по HTTP. В `.env` оставьте +`NGINX_TLS_ENABLED=true`, но первый nginx запустите с временным переопределением: + +```sh +NGINX_TLS_ENABLED=false \ + docker compose --env-file .env up -d frontend-static nginx +``` + +Проверьте HTTP: + +```sh +curl -I http://chat.han0107.ru/ +``` + +Сначала рекомендуется проверить Certbot через staging: + +```sh +docker compose --env-file .env --profile certbot run --rm certbot certonly \ + --staging \ + --webroot -w /var/www/certbot \ + -d chat.han0107.ru \ + --cert-name chat.han0107.ru-staging \ + --email ap@han.ru \ + --agree-tos --no-eff-email --non-interactive +``` + +После успешного staging-теста выпустите рабочий сертификат с основным cert-name +без `--staging`: + +```sh +docker compose --env-file .env --profile certbot run --rm certbot certonly \ + --webroot -w /var/www/certbot \ + -d chat.han0107.ru \ + --cert-name chat.han0107.ru \ + --email ap@han.ru \ + --agree-tos --no-eff-email --non-interactive +``` + +Пересоздайте nginx уже с TLS: + +```sh +docker compose --env-file .env up -d --force-recreate nginx +docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf +curl -I https://chat.han0107.ru/ +``` + +Повторно запустите VM setup, чтобы он обнаружил проект и установил systemd-таймер +продления сертификата: + +```sh +sudo /opt/han-chat/backend/deployment/scripts/setup-vm.sh +systemctl status han-chat-ssl-renew.timer + +## Запуск всего контура + +Теперь можно привести весь проект к состоянию, описанному Compose: + +```sh +cd /opt/han-chat/backend +docker compose --env-file .env up -d +docker compose --env-file .env ps +``` + +Проверьте, что контейнеры не перезапускаются: + +```sh +docker compose --env-file .env ps +docker compose --env-file .env logs --since=10m +``` + + +## Публичная проверка + +Запустите smoke-тест: + +```sh +cd /opt/han-chat/backend +deployment/scripts/smoke.sh +``` + +Также вручную проверьте: + +```sh +curl -fsS https://chat.han0107.ru/api/v1/public/app-config | jq +curl -fsS https://chat.han0107.ru/api/v1/public/content | jq +curl -fsS \ + https://chat.han0107.ru/auth/realms/han-chat/.well-known/openid-configuration | jq +``` + +Внутренний API не должен быть опубликован: + +```sh +curl -i https://chat.han0107.ru/internal/safety/v1/messages/check +``` + +Ожидаемый статус — `404`. + +Откройте в браузере: + +```text +https://chat.example.ru/ +``` + +Для тестовой авторизации используйте значение `KEYCLOAK_OTP_MOCK_CODE` из `.env`. + + +Настройки keykcloack: + + + + +Для битрикса код установки приложения: +docker compose --env-file .env exec -T api-backend python - <<'PY' +import os +import urllib.request + +base = os.environ["BITRIX_LOCAL_APP_BASE_URL"] +token = os.environ["BITRIX_LOCAL_APP_INTERNAL_TOKEN"] + +request = urllib.request.Request( + base + "/internal/openlines/v1/setup/retry", + method="POST", + headers={"Authorization": "Bearer " + token}, +) +print(urllib.request.urlopen(request).read().decode()) +PY \ No newline at end of file diff --git a/faq.md b/faq.md new file mode 100644 index 0000000..7e4285d --- /dev/null +++ b/faq.md @@ -0,0 +1,28 @@ +# Логи по контейнерам +docker compose --env-file .env logs --tail 100 keycloak +docker compose --env-file .env logs --tail 100 api-backend +docker compose --env-file .env logs --tail 100 keycloak + + + +# Пересобрать образы и поднять всё заново +Из каталога проекта на ВМ: +cd /opt/han-chat/backend +1. Собрать все локальные образы (как в DEPLOYMENT_GUIDE) +docker compose --env-file .env build --pull +2. Поднять весь контур +docker compose --env-file .env up -d +Или одной командой: +cd /opt/han-chat/backend +docker compose --env-file .env up -d --build + +# Только пересоздать контейнеры (без пересборки образов) +Если меняли только .env (например, IP БД), образы пересобирать не нужно: +cd /opt/han-chat/backend +docker compose --env-file .env up -d --force-recreate + +# Полная пересборка + пересоздание +cd /opt/han-chat/backend +docker compose --env-file .env up -d --build --force-recreate +Проверка +docker compose --env-file .env ps \ No newline at end of file diff --git a/ops-monitoring/diagnose-han-chat.sh b/ops-monitoring/diagnose-han-chat.sh new file mode 100644 index 0000000..c42b72c --- /dev/null +++ b/ops-monitoring/diagnose-han-chat.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# diagnose-han-chat.sh — запускать из /opt/han-chat/backend +set -euo pipefail +cd /opt/han-chat/backend + +echo "=== 1. Статус сервисов ===" +docker compose --env-file .env ps api-backend keycloak redis message-safety + +echo +echo "=== 2. Healthcheck api-backend (что именно падает) ===" +docker compose --env-file .env exec -T api-backend \ + python -c " +import json, urllib.request +try: + r = urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=5) + print('HTTP', r.status) + print(json.dumps(json.loads(r.read()), indent=2, ensure_ascii=False)) +except urllib.error.HTTPError as e: + print('HTTP', e.code) + print(json.dumps(json.loads(e.read()), indent=2, ensure_ascii=False)) +" + +echo +echo "=== 3. Keycloak health (management port 9000) ===" +docker compose --env-file .env exec -T keycloak bash -c \ + "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/health/ready HTTP/1.0\r\n\r\n' >&3 && cat <&3" \ + 2>/dev/null | head -20 || echo "Keycloak health FAILED" + +echo +echo "=== 4. api-backend -> Keycloak (JWKS) ===" +docker compose --env-file .env exec -T api-backend \ + python -c " +import os, urllib.request, json +base = os.environ.get('KEYCLOAK_INTERNAL_URL','http://keycloak:8080/auth').rstrip('/') +realm = os.environ.get('KEYCLOAK_REALM','han-chat') +url = f'{base}/realms/{realm}/.well-known/openid-configuration' +try: + d = json.loads(urllib.request.urlopen(url, timeout=5).read()) + print('discovery OK, jwks_uri:', d.get('jwks_uri')) +except Exception as e: + print('discovery FAILED:', e) +" + +echo +echo "=== 5. Счётчик рестартов ===" +docker inspect --format '{{.Name}} restarts={{.RestartCount}} started={{.State.StartedAt}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' \ + han-chat-api-backend-1 han-chat-keycloak-1 + +echo +echo "=== 6. Последние ошибки в логах ===" +echo "--- api-backend ---" +docker logs --tail 80 han-chat-api-backend-1 2>&1 | grep -iE 'error|failed|exception|jwks|settings|postgres' || echo "(нет совпадений)" +echo "--- keycloak ---" +docker logs --tail 80 han-chat-keycloak-1 2>&1 | grep -iE 'error|failed|exception|FATAL|OutOfMemory' || echo "(нет совпадений)" + +echo +echo "=== 7. Память (Keycloak часто падает от OOM) ===" +free -h +docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' \ + han-chat-keycloak-1 han-chat-api-backend-1 2>/dev/null || true + +echo +echo "=== 8. Exited контейнеры (в метриках было exited=2) ===" +docker ps -a --filter status=exited --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' \ No newline at end of file diff --git a/ops-monitoring/han-vm-metrics.sh b/ops-monitoring/han-vm-metrics.sh new file mode 100644 index 0000000..f73e936 --- /dev/null +++ b/ops-monitoring/han-vm-metrics.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +# HAN Chat VM metrics snapshot +# Host: Ubuntu 24.04 + Docker + UFW + fail2ban +# +# Usage: +# sudo ./han-vm-metrics.sh # human report to stdout +# sudo ./han-vm-metrics.sh --json # JSON to stdout +# sudo ./han-vm-metrics.sh --alert # exit 1 if thresholds breached +# sudo ./han-vm-metrics.sh --log /var/log/han-vm-metrics.log +# +# Cron example (every 5 min): +# */5 * * * * root /opt/han-chat/ops/han-vm-metrics.sh --alert --log /var/log/han-vm-metrics.log +# +# Disk notes: +# Checks both / and /var/lib/docker. If Docker Root Dir is on a separate +# mount, its fill level is reported and alerted independently. + +set -euo pipefail + +MODE="text" +LOG_FILE="" +ALERT=0 +DISK_WARN_PCT="${DISK_WARN_PCT:-80}" +DISK_CRIT_PCT="${DISK_CRIT_PCT:-90}" +LOAD_WARN_MULT="${LOAD_WARN_MULT:-1.5}" # vs CPU count +MEM_WARN_PCT="${MEM_WARN_PCT:-90}" +CERT_WARN_DAYS="${CERT_WARN_DAYS:-21}" +SSH_FAIL_WARN="${SSH_FAIL_WARN:-50}" # failed attempts last hour +DOMAIN="${METRICS_DOMAIN:-chat.han0107.ru}" +DOCKER_ROOT="${DOCKER_ROOT:-/var/lib/docker}" + +require_value() { + local opt="$1" val="${2:-}" + if [[ -z "$val" || "$val" == -* ]]; then + echo "Error: $opt requires a value" >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --json) MODE="json"; shift ;; + --alert) ALERT=1; shift ;; + --log) + require_value "$1" "${2:-}" + LOG_FILE="$2" + shift 2 + ;; + --domain) + require_value "$1" "${2:-}" + DOMAIN="$2" + shift 2 + ;; + -h|--help) + sed -n '2,18p' "$0" + exit 0 + ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +ts_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +hostname_f="$(hostname -f 2>/dev/null || hostname)" +kernel="$(uname -r)" +uptime_s="$(cut -d. -f1 /proc/uptime)" +cpu_n="$(nproc)" +loadavg="$(cut -d' ' -f1-3 /proc/loadavg)" +load1="$(cut -d' ' -f1 /proc/loadavg)" + +# --- memory --- +mem_total_kb="$(awk '/MemTotal:/ {print $2}' /proc/meminfo)" +mem_avail_kb="$(awk '/MemAvailable:/ {print $2}' /proc/meminfo)" +mem_used_kb=$((mem_total_kb - mem_avail_kb)) +mem_used_pct=$((mem_used_kb * 100 / mem_total_kb)) +swap_total_kb="$(awk '/SwapTotal:/ {print $2}' /proc/meminfo)" +swap_free_kb="$(awk '/SwapFree:/ {print $2}' /proc/meminfo)" +swap_used_kb=$((swap_total_kb - swap_free_kb)) + +# --- disk helper: path -> total_k used_k avail_k used_pct --- +read_df() { + local path="$1" + local line total used avail pct_raw + line="$(df -P "$path" 2>/dev/null | awk 'NR==2 {print $2,$3,$4,$5}')" + if [[ -z "$line" ]]; then + echo "0 0 0 0" + return + fi + read -r total used avail pct_raw <<<"$line" + echo "$total $used $avail ${pct_raw%%%}" +} + +# --- disk root --- +read -r disk_total_k disk_used_k disk_avail_k disk_used_pct <<<"$(read_df /)" + +# --- disk Docker root (may be same FS as / or a separate mount) --- +docker_disk_same_as_root=1 +docker_disk_total_k=0 +docker_disk_used_k=0 +docker_disk_avail_k=0 +docker_disk_used_pct=0 +if [[ -d "$DOCKER_ROOT" ]]; then + read -r docker_disk_total_k docker_disk_used_k docker_disk_avail_k docker_disk_used_pct <<<"$(read_df "$DOCKER_ROOT")" + root_src="$(findmnt -n -o SOURCE / 2>/dev/null || true)" + docker_src="$(findmnt -n -o SOURCE --target "$DOCKER_ROOT" 2>/dev/null || true)" + if [[ -n "$root_src" && -n "$docker_src" && "$root_src" != "$docker_src" ]]; then + docker_disk_same_as_root=0 + fi +fi + +# --- reboot / updates (lightweight; avoid apt list --upgradable) --- +reboot_required=0 +[[ -f /var/run/reboot-required ]] && reboot_required=1 +pending_pkgs=0 +pending_security=0 +if [[ -x /usr/lib/update-notifier/apt-check ]]; then + # stderr: ";" + apt_check_out="$(/usr/lib/update-notifier/apt-check 2>&1 || true)" + pending_pkgs="${apt_check_out%%;*}" + pending_security="${apt_check_out##*;}" +elif [[ -f /var/lib/update-notifier/updates-available ]]; then + pending_pkgs="$(grep -oE '^[0-9]+' /var/lib/update-notifier/updates-available 2>/dev/null | head -1 || true)" +fi +[[ "$pending_pkgs" =~ ^[0-9]+$ ]] || pending_pkgs=0 +[[ "$pending_security" =~ ^[0-9]+$ ]] || pending_security=0 + +# --- firewall --- +ufw_status="unknown" +if command -v ufw >/dev/null 2>&1; then + ufw_status="$(ufw status 2>/dev/null | awk 'NR==1 {print tolower($2)}')" +fi + +# --- fail2ban --- +f2b_active="inactive" +f2b_jails=0 +f2b_banned_now=0 +f2b_banned_total=0 +f2b_failed_total=0 +if command -v fail2ban-client >/dev/null 2>&1; then + if systemctl is-active --quiet fail2ban 2>/dev/null; then + f2b_active="active" + f2b_status="$(fail2ban-client status 2>/dev/null || true)" + f2b_jails="$(echo "$f2b_status" | awk -F: '/Jail list/ {gsub(/ /,"",$2); n=split($2,a,","); print n; exit}')" + [[ -z "$f2b_jails" ]] && f2b_jails=0 + if fail2ban-client status sshd >/dev/null 2>&1; then + sshd_st="$(fail2ban-client status sshd 2>/dev/null || true)" + f2b_banned_now="$(echo "$sshd_st" | awk -F: '/Currently banned/ {gsub(/ /,"",$2); print $2; exit}')" + f2b_banned_total="$(echo "$sshd_st" | awk -F: '/Total banned/ {gsub(/ /,"",$2); print $2; exit}')" + f2b_failed_total="$(echo "$sshd_st" | awk -F: '/Total failed/ {gsub(/ /,"",$2); print $2; exit}')" + fi + fi +fi +f2b_banned_now="${f2b_banned_now:-0}" +f2b_banned_total="${f2b_banned_total:-0}" +f2b_failed_total="${f2b_failed_total:-0}" + +# --- SSH auth last hour (single journalctl pass) --- +ssh_failed_1h=0 +ssh_invalid_1h=0 +ssh_accepted_1h=0 +if command -v journalctl >/dev/null 2>&1; then + ssh_journal="$(journalctl -u ssh --since '1 hour ago' --no-pager 2>/dev/null || true)" + ssh_failed_1h="$(grep -cE 'Failed password|Failed publickey|Connection closed by authenticating' <<<"$ssh_journal" || true)" + ssh_invalid_1h="$(grep -c 'Invalid user' <<<"$ssh_journal" || true)" + ssh_accepted_1h="$(grep -c 'Accepted' <<<"$ssh_journal" || true)" +fi + +# --- listening public ports --- +listen_ports="$(ss -tuln 2>/dev/null | awk '/LISTEN/ && ($5 ~ /\*:|:0\.0\.0\.0:|\[::\]:/) { + split($5,a,":"); print a[length(a)] +}' | sort -n | uniq | tr '\n' ',' | sed 's/,$//')" + +# --- docker --- +docker_running=0 +docker_unhealthy=0 +docker_exited=0 +docker_names_unhealthy="" +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker_running="$(docker ps -q 2>/dev/null | wc -l | tr -d ' ')" + docker_exited="$(docker ps -aq -f status=exited 2>/dev/null | wc -l | tr -d ' ')" + while IFS= read -r line; do + [[ -z "$line" ]] && continue + name="${line%% *}" + health="${line##* }" + if [[ "$health" == "unhealthy" ]]; then + docker_unhealthy=$((docker_unhealthy + 1)) + docker_names_unhealthy+="${name}," + fi + done < <(docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | awk '{ + h="unknown" + if ($0 ~ /unhealthy/) h="unhealthy" + else if ($0 ~ /\(healthy\)/) h="healthy" + else if ($0 ~ /Up/) h="up" + print $1, h + }') + docker_names_unhealthy="${docker_names_unhealthy%,}" +fi + +# --- TLS cert days left (local nginx :443) --- +cert_days=-1 +cert_cn="" +if command -v openssl >/dev/null 2>&1; then + cert_pem="$(echo | openssl s_client -connect 127.0.0.1:443 -servername "$DOMAIN" 2>/dev/null | openssl x509 2>/dev/null || true)" + if [[ -n "$cert_pem" ]]; then + cert_cn="$(echo "$cert_pem" | openssl x509 -noout -subject 2>/dev/null | sed 's/.*CN *= *//')" + end_date="$(echo "$cert_pem" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)" + end_epoch="$(date -d "$end_date" +%s 2>/dev/null || true)" + now_epoch="$(date +%s)" + if [[ -n "${end_epoch:-}" ]]; then + cert_days=$(( (end_epoch - now_epoch) / 86400 )) + fi + fi +fi + +# --- alerts --- +alerts=() +load_warn="$(awk -v l="$load1" -v n="$cpu_n" -v m="$LOAD_WARN_MULT" 'BEGIN { print (l > n*m) ? 1 : 0 }')" +[[ "$load_warn" == "1" ]] && alerts+=("high_load:${load1}>${cpu_n}*${LOAD_WARN_MULT}") +[[ "$mem_used_pct" -ge "$MEM_WARN_PCT" ]] && alerts+=("high_memory:${mem_used_pct}%") +[[ "$disk_used_pct" -ge "$DISK_CRIT_PCT" ]] && alerts+=("disk_critical:${disk_used_pct}%") +[[ "$disk_used_pct" -ge "$DISK_WARN_PCT" && "$disk_used_pct" -lt "$DISK_CRIT_PCT" ]] && alerts+=("disk_warn:${disk_used_pct}%") +# Separate Docker mount: independent thresholds (avoid duplicate alerts when same FS) +if [[ "$docker_disk_same_as_root" -eq 0 ]]; then + [[ "$docker_disk_used_pct" -ge "$DISK_CRIT_PCT" ]] && alerts+=("docker_disk_critical:${docker_disk_used_pct}%") + [[ "$docker_disk_used_pct" -ge "$DISK_WARN_PCT" && "$docker_disk_used_pct" -lt "$DISK_CRIT_PCT" ]] && alerts+=("docker_disk_warn:${docker_disk_used_pct}%") +fi +[[ "$reboot_required" -eq 1 ]] && alerts+=("reboot_required") +[[ "$docker_unhealthy" -gt 0 ]] && alerts+=("docker_unhealthy:${docker_names_unhealthy}") +[[ "$cert_days" -ge 0 && "$cert_days" -le "$CERT_WARN_DAYS" ]] && alerts+=("cert_expiring:${cert_days}d") +[[ "$ssh_failed_1h" -ge "$SSH_FAIL_WARN" ]] && alerts+=("ssh_bruteforce_1h:${ssh_failed_1h}") +[[ "$f2b_active" != "active" ]] && alerts+=("fail2ban_inactive") +[[ "$ufw_status" != "active" ]] && alerts+=("ufw_inactive") + +alert_count=${#alerts[@]} +alert_joined="$(IFS=','; echo "${alerts[*]:-}")" + +# KiB -> MiB (integer; avoids spawning awk) +mb() { echo "$((${1:-0} / 1024))"; } + +render_text() { + local docker_disk_note="same_fs_as_root" + [[ "$docker_disk_same_as_root" -eq 0 ]] && docker_disk_note="separate_mount" + cat <>"$LOG_FILE" +fi + +if [[ "$ALERT" -eq 1 && "$alert_count" -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/ops-monitoring/instructions.md b/ops-monitoring/instructions.md new file mode 100644 index 0000000..e69de29 diff --git a/ops-monitoring/pg_connection_test.sh b/ops-monitoring/pg_connection_test.sh new file mode 100644 index 0000000..90e3b3b --- /dev/null +++ b/ops-monitoring/pg_connection_test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 1. Безопасная загрузка .env: убираем комментарии, удаляем \r (Windows-переносы) и делаем export +export $(grep -v '^#' .env | sed 's/\r$//' | xargs) + +echo "=== 1. Параметры из .env ===" +grep -E '^(HAN_PG_HOST|HAN_PG_PORT|KEYCLOAK_DB_)' .env | grep -v PASSWORD || echo "Переменные не найдены!" + +# 2. Проверка TCP (используем переменные, а не хардкод) +echo -e "\n=== 2. Проверка TCP (${HAN_PG_HOST}:${HAN_PG_PORT}) ===" +if command -v nc &> /dev/null; then + nc -zv "${HAN_PG_HOST}" "${HAN_PG_PORT}" || echo "❌ Не удалось подключиться по TCP" +else + echo "⚠️ Утилита nc не найдена, пропускаем проверку TCP" +fi + +# 3. Проверка подключения psql +echo -e "\n=== 3. Проверка подключения к БД ===" + +# ВАЖНО: Передаем пароль через переменную окружения, чтобы скрипт не зависал! +# Если KEYCLOAK_DB_PASSWORD пуст, psql все равно спросит его вручную. +export PGPASSWORD="${KEYCLOAK_DB_PASSWORD:-}" + +# Проверяем, установлен ли psql +if ! command -v psql &> /dev/null; then + echo "❌ Утилита psql не найдена на хост-машине. Установите postgresql-client." + exit 1 +fi + +# Выполняем запрос. Используем переменную порта и добавляем fallback для sslrootcert, если он не задан +psql "host=${HAN_PG_HOST} port=${HAN_PG_PORT} dbname=han_chat user=${KEYCLOAK_DB_USERNAME} sslmode=verify-full sslrootcert=${PG_CA_HOST_PATH:-}" \ + -c "SELECT current_user, current_schema(); SHOW search_path;" + +# Очищаем пароль из окружения после выполнения (хорошая практика) +unset PGPASSWORD \ No newline at end of file