правки в транспорт сообщений + немного мониторинга
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
В разработку:
|
||||||
|
1. Создание нового диалога через UI
|
||||||
|
2. При создании пользователя номер телефона копировать в профиль Russian_Phone
|
||||||
|
3. Убрать хеширование устройства клиента в devise_json - надо видеть его параметры.
|
||||||
|
4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс.
|
||||||
|
5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе
|
||||||
|
|
||||||
|
Выполнено:
|
||||||
|
1. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)
|
||||||
@@ -159,7 +159,7 @@ def retry_delay(attempt: int, maximum: int) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def safely_retryable(exc: Exception) -> bool:
|
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}
|
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:
|
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:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"outbound delivery failed",
|
"outbound delivery failed",
|
||||||
@@ -1257,7 +1260,10 @@ async def process_outbound(app: FastAPI) -> None:
|
|||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"outbound retry failed",
|
"outbound retry failed",
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ def test_fingerprint_ignores_signed_query_and_portal_validation():
|
|||||||
|
|
||||||
|
|
||||||
def test_network_timeouts_are_retryable():
|
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.ReadTimeout("Bitrix response timed out"))
|
||||||
assert safely_retryable(httpx.ConnectTimeout("Bitrix connection timed out"))
|
assert safely_retryable(httpx.ConnectTimeout("Bitrix connection timed out"))
|
||||||
|
|
||||||
|
|||||||
+152
-1
@@ -8,7 +8,7 @@
|
|||||||
- (без спец.символов) openssl rand -hex 16
|
- (без спец.символов) openssl rand -hex 16
|
||||||
- openssl rand -base64 16 | xclip -selection clipboard # Linux
|
- 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
|
wget https://storage.dbaas.selcloud.ru/CA.pem -O ~/.postgresql/root.crt
|
||||||
chmod 0600 ~/.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 в ВМ
|
### Проверка подключения к Postgers в ВМ
|
||||||
nc -zv 192.168.0.211 5433
|
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 <SERVICE_NAME>
|
docker compose --env-file .env logs --tail=200 <SERVICE_NAME>
|
||||||
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
|
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Первоначальный выпуск 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
|
||||||
@@ -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
|
||||||
@@ -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}}'
|
||||||
@@ -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: "<total>;<security>"
|
||||||
|
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 <<EOF
|
||||||
|
=== HAN VM metrics @ ${ts_utc} ===
|
||||||
|
host: ${hostname_f}
|
||||||
|
kernel: ${kernel}
|
||||||
|
uptime_sec: ${uptime_s}
|
||||||
|
cpu_cores: ${cpu_n}
|
||||||
|
loadavg: ${loadavg}
|
||||||
|
memory: ${mem_used_pct}% used ($(mb "$mem_used_kb")/$(mb "$mem_total_kb") MiB), avail $(mb "$mem_avail_kb") MiB
|
||||||
|
swap: $(mb "$swap_used_kb")/$(mb "$swap_total_kb") MiB
|
||||||
|
disk_root: ${disk_used_pct}% used ($(mb "$disk_used_k")/$(mb "$disk_total_k") MiB), avail $(mb "$disk_avail_k") MiB
|
||||||
|
disk_docker: ${docker_disk_used_pct}% used ($(mb "$docker_disk_used_k")/$(mb "$docker_disk_total_k") MiB), avail $(mb "$docker_disk_avail_k") MiB [${docker_disk_note}: ${DOCKER_ROOT}]
|
||||||
|
reboot_needed: ${reboot_required}
|
||||||
|
apt_upgradable: ${pending_pkgs} (security=${pending_security})
|
||||||
|
ufw: ${ufw_status}
|
||||||
|
listen_ports: ${listen_ports:-none}
|
||||||
|
fail2ban: ${f2b_active}, jails=${f2b_jails}, banned_now=${f2b_banned_now}, banned_total=${f2b_banned_total}, failed_total=${f2b_failed_total}
|
||||||
|
ssh_1h: failed=${ssh_failed_1h}, invalid_user=${ssh_invalid_1h}, accepted=${ssh_accepted_1h}
|
||||||
|
docker: running=${docker_running}, exited=${docker_exited}, unhealthy=${docker_unhealthy}${docker_names_unhealthy:+ ($docker_names_unhealthy)}
|
||||||
|
tls: domain=${DOMAIN}, cn=${cert_cn:-n/a}, days_left=${cert_days}
|
||||||
|
alerts(${alert_count}): ${alert_joined:-none}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
render_json() {
|
||||||
|
python3 - "$ts_utc" <<'PY' || true
|
||||||
|
import json, os, sys
|
||||||
|
ts = sys.argv[1]
|
||||||
|
data = {
|
||||||
|
"ts": ts,
|
||||||
|
"host": os.environ["M_HOST"],
|
||||||
|
"kernel": os.environ["M_KERNEL"],
|
||||||
|
"uptime_sec": int(os.environ["M_UPTIME"]),
|
||||||
|
"cpu_cores": int(os.environ["M_CPU"]),
|
||||||
|
"loadavg": os.environ["M_LOADAVG"],
|
||||||
|
"load1": float(os.environ["M_LOAD1"]),
|
||||||
|
"memory_used_pct": int(os.environ["M_MEM_PCT"]),
|
||||||
|
"disk_root_used_pct": int(os.environ["M_DISK_PCT"]),
|
||||||
|
"disk_docker": {
|
||||||
|
"path": os.environ["M_D_DISK_PATH"],
|
||||||
|
"used_pct": int(os.environ["M_D_DISK_PCT"]),
|
||||||
|
"same_fs_as_root": os.environ["M_D_DISK_SAME"] == "1",
|
||||||
|
},
|
||||||
|
"reboot_required": os.environ["M_REBOOT"] == "1",
|
||||||
|
"apt_upgradable": int(os.environ["M_APT"]),
|
||||||
|
"apt_security": int(os.environ["M_APT_SEC"]),
|
||||||
|
"ufw": os.environ["M_UFW"],
|
||||||
|
"listen_ports": [p for p in os.environ.get("M_PORTS","").split(",") if p],
|
||||||
|
"fail2ban": {
|
||||||
|
"active": os.environ["M_F2B"] == "active",
|
||||||
|
"jails": int(os.environ["M_F2B_JAILS"]),
|
||||||
|
"banned_now": int(os.environ["M_F2B_NOW"]),
|
||||||
|
"banned_total": int(os.environ["M_F2B_TOT"]),
|
||||||
|
"failed_total": int(os.environ["M_F2B_FAIL"]),
|
||||||
|
},
|
||||||
|
"ssh_1h": {
|
||||||
|
"failed": int(os.environ["M_SSH_FAIL"]),
|
||||||
|
"invalid_user": int(os.environ["M_SSH_INV"]),
|
||||||
|
"accepted": int(os.environ["M_SSH_OK"]),
|
||||||
|
},
|
||||||
|
"docker": {
|
||||||
|
"running": int(os.environ["M_D_RUN"]),
|
||||||
|
"exited": int(os.environ["M_D_EXIT"]),
|
||||||
|
"unhealthy": int(os.environ["M_D_BAD"]),
|
||||||
|
"unhealthy_names": [n for n in os.environ.get("M_D_BAD_N","").split(",") if n],
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"domain": os.environ["M_DOMAIN"],
|
||||||
|
"cn": os.environ.get("M_CERT_CN") or None,
|
||||||
|
"days_left": int(os.environ["M_CERT_DAYS"]),
|
||||||
|
},
|
||||||
|
"alerts": [a for a in os.environ.get("M_ALERTS","").split(",") if a],
|
||||||
|
}
|
||||||
|
print(json.dumps(data, ensure_ascii=False))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
export M_HOST="$hostname_f" M_KERNEL="$kernel" M_UPTIME="$uptime_s" M_CPU="$cpu_n"
|
||||||
|
export M_LOADAVG="$loadavg" M_LOAD1="$load1" M_MEM_PCT="$mem_used_pct" M_DISK_PCT="$disk_used_pct"
|
||||||
|
export M_D_DISK_PATH="$DOCKER_ROOT" M_D_DISK_PCT="$docker_disk_used_pct" M_D_DISK_SAME="$docker_disk_same_as_root"
|
||||||
|
export M_REBOOT="$reboot_required" M_APT="$pending_pkgs" M_APT_SEC="$pending_security"
|
||||||
|
export M_UFW="$ufw_status" M_PORTS="$listen_ports"
|
||||||
|
export M_F2B="$f2b_active" M_F2B_JAILS="$f2b_jails" M_F2B_NOW="$f2b_banned_now"
|
||||||
|
export M_F2B_TOT="$f2b_banned_total" M_F2B_FAIL="$f2b_failed_total"
|
||||||
|
export M_SSH_FAIL="$ssh_failed_1h" M_SSH_INV="$ssh_invalid_1h" M_SSH_OK="$ssh_accepted_1h"
|
||||||
|
export M_D_RUN="$docker_running" M_D_EXIT="$docker_exited" M_D_BAD="$docker_unhealthy" M_D_BAD_N="$docker_names_unhealthy"
|
||||||
|
export M_DOMAIN="$DOMAIN" M_CERT_CN="$cert_cn" M_CERT_DAYS="$cert_days" M_ALERTS="$alert_joined"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "json" ]]; then
|
||||||
|
out="$(render_json)"
|
||||||
|
else
|
||||||
|
out="$(render_text)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
|
||||||
|
if [[ -n "$LOG_FILE" ]]; then
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
printf '%s\n' "$out" >>"$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ALERT" -eq 1 && "$alert_count" -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user