20 Commits

Author SHA1 Message Date
PTah fe1ed0f234 fix: log_action to stderr so captured paths stay valid
find_remote_ssh_monitor_path output was polluted by log_action on stdout,
breaking -f checks; mirror user messages to stderr instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 08:24:09 +10:00
PTah 9626c344b3 fix: find ssh-monitor in clone via git checkout and broader search
Restore ssh-monitor from HEAD when missing on disk; try git ls-files and
find fallback; log clone root listing on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 08:20:37 +10:00
PTah 011016e4d0 fix: sync /opt/scripts/update_ssh_monitor.sh from git clone each run
After git pull, copy updater from clone to DEPLOY_INSTALL_PATH and re-exec
once so cron always picks up new logging without manual cp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 08:15:51 +10:00
PTah 1a8d42b79a feat: verbose update_ssh_monitor action log and run summary
Log each step to /var/log/update_script.log and stdout (unless --quiet).
End-of-run summary explains skip reasons: checksum match, git pull fail,
missing clone path, versions and sha256 prefixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 08:13:24 +10:00
PTah 455a381996 fix: heartbeat only to SAC, never Telegram/email
agent.heartbeat bypasses NOTIFY_CHAIN in all UseSAC modes; SAC ingest only.
Bump to 1.2.3-SAC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 07:56:18 +10:00
PTah fe52d67e24 chore: bump release to 1.2.2-SAC
Increase SSH monitor release version used by sac-client payloads and refresh
RU/EN README references to the new version.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 15:53:29 +10:00
PTah 361b66292f feat: bootstrap SAC config on update from pre-SAC installs
ssh-monitor.conf.example ships dual SAC + Telegram defaults; updater
overwrites /etc/ssh-monitor.conf when UseSAC is off or SAC_API_KEY empty
or sac-client.sh is missing, with backup. Leaves configured hosts untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 15:23:35 +10:00
PTah b446d6b276 chore: bump version to 1.2.1-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:44:03 +10:00
PTah a3c86f05f3 feat: send full daily report body and stats in SAC details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:15:13 +10:00
PTah bae4640b85 chore: bump agent version to 1.2.0-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:09:35 +10:00
PTah 4a38fc9c24 feat: SAC fallback failure counter (2.3)
After SAC_FALLBACK_FAILURES consecutive POST errors, skip SAC until
/health recovers; persist count in SAC_FAIL_COUNT_FILE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:36:19 +10:00
PTah fbd6770c71 feat: exclusive pilot — spool flush and verify script
Replay spool on each monitor loop; sac_post_payload refactor;
pilot-verify-exclusive.sh and --check-sac hints for exclusive mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:25:47 +10:00
PTah 919ea96ec6 feat: first deploy via update_ssh_monitor.sh --deploy
Add first_deploy.sh wrapper and --deploy mode: git clone, install
ssh-monitor and sac-client, optional config/systemd/updater copy,
console summary and config variable checklist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:36:02 +10:00
PTah 0818b92bcd fix: POST ingest to /api/v1/events when SAC_URL is host root
Health already used sac_base_url(); ingest used raw SAC_URL and got
HTTP 405 when config had only https://host without /api/v1/events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:22:10 +10:00
PTah 3cc2649630 fix: always sync sac-client.sh in update_ssh_monitor
Copy sac-client.sh on every git pull by checksum, not only when
ssh-monitor binary changes. Restart service when either file updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:20:14 +10:00
PTah e986b1004b feat: map security events to SAC typed payloads 2026-05-27 11:24:19 +10:00
PTah 9996f21584 feat: sudo и notify_send шлют события в SAC (dual)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 11:01:47 +10:00
PTah b7ed531122 fix: update_ssh_monitor копирует sac-client.sh, REPO kalinamall
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 10:47:38 +10:00
PTah d2223bad96 merge: kalinamall main + UseSAC sac-client 2026-05-27 10:40:03 +10:00
PTah 9572080cb3 feat: UseSAC sac-client.sh and --check-sac
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 21:46:35 +10:00
10 changed files with 1355 additions and 133 deletions
+16 -4
View File
@@ -8,7 +8,7 @@ Bash-скрипт мониторинга **SSH**, **SUDO** и событий **`
|------|------------| |------|------------|
| **`ssh-monitor`** | Основной цикл: разбор журналов (**journalctl**: sshd, sudo, «тяжёлые» события, **systemd-logind**), цепочка уведомлений (Telegram, SMTP через **python3**, резервный webhook), при **`ENABLE_IP_BAN=1`** — учёт неудачных попыток, бан в **ipset** и согласование с **iptables**/**ip6tables**, ежедневный отчёт, heartbeat, опционально **Prometheus** textfile и JSON health. Режимы: **`--check-config`**, **`--dry-run`**. | | **`ssh-monitor`** | Основной цикл: разбор журналов (**journalctl**: sshd, sudo, «тяжёлые» события, **systemd-logind**), цепочка уведомлений (Telegram, SMTP через **python3**, резервный webhook), при **`ENABLE_IP_BAN=1`** — учёт неудачных попыток, бан в **ipset** и согласование с **iptables**/**ip6tables**, ежедневный отчёт, heartbeat, опционально **Prometheus** textfile и JSON health. Режимы: **`--check-config`**, **`--dry-run`**. |
| **`ssh-monitor-watchdog`** | Отдельный скрипт (обычно по **systemd timer**): проверяет, что сервис монитора активен и файл heartbeat не старше порога; при сбое выполняет **`systemctl restart`** и шлёт уведомление в Telegram или в **`BACKUP_WEBHOOK_URL`**. Читает те же `TELEGRAM_*` / webhook из `/etc/ssh-monitor.conf`. | | **`ssh-monitor-watchdog`** | Отдельный скрипт (обычно по **systemd timer**): проверяет, что сервис монитора активен и файл heartbeat не старше порога; при сбое выполняет **`systemctl restart`** и шлёт уведомление в Telegram или в **`BACKUP_WEBHOOK_URL`**. Читает те же `TELEGRAM_*` / webhook из `/etc/ssh-monitor.conf`. |
| **`update_ssh_monitor.sh`** | Опциональное автообновление: `git pull` / `clone`, сравнение SHA256 с `/usr/local/bin/ssh-monitor`, перезапуск `ssh-monitor.service`. См. [docs/auto-update.ru.md](docs/auto-update.ru.md). | | **`update_ssh_monitor.sh`** | Опциональное автообновление: `git pull` / `clone`, сравнение SHA256 с `/usr/local/bin/ssh-monitor`, синхронизация **`sac-client.sh`** рядом с монитором, перезапуск `ssh-monitor.service`. См. [docs/auto-update.ru.md](docs/auto-update.ru.md). |
**Системные пакеты и зависимости** (что поставить на сервер до запуска): пошагово в [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md). **Системные пакеты и зависимости** (что поставить на сервер до запуска): пошагово в [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md).
@@ -123,7 +123,7 @@ Bash-скрипт мониторинга **SSH**, **SUDO** и событий **`
### Автообновление скрипта ### Автообновление скрипта
Скрипт **`update_ssh_monitor.sh`** по расписанию (cron или systemd timer) выполняет `git pull` / `clone`, сравнивает SHA256 с `/usr/local/bin/ssh-monitor` и при отличии копирует новую версию и перезапускает **`ssh-monitor.service`**. Конфиг **`/etc/ssh-monitor.conf`** не трогается. Скрипт **`update_ssh_monitor.sh`** по расписанию (cron или systemd timer) выполняет `git pull` / `clone`, сравнивает SHA256 с `/usr/local/bin/ssh-monitor`, **всегда** подтягивает **`/usr/local/bin/sac-client.sh`** и при изменении любого из файлов перезапускает **`ssh-monitor.service`**. Конфиг **`/etc/ssh-monitor.conf`** не трогается.
Пошаговая настройка: [docs/auto-update.ru.md](docs/auto-update.ru.md). Пошаговая настройка: [docs/auto-update.ru.md](docs/auto-update.ru.md).
@@ -155,7 +155,7 @@ bash -n ./ssh-monitor
## Релизный архив ## Релизный архив
Версия задаётся в скрипте переменной **`SSH_MONITOR_VERSION`** (на момент последнего обновления документации — **1.1.3-server-label**; актуальное значение смотрите в файле **`ssh-monitor`**). Сборка tarball из текущего git-дерева: Версия задаётся в скрипте переменной **`SSH_MONITOR_VERSION`** (на момент последнего обновления документации — **1.2.2-SAC**; актуальное значение смотрите в файле **`ssh-monitor`**). Сборка tarball из текущего git-дерева:
```bash ```bash
make dist make dist
@@ -165,12 +165,24 @@ make dist
## Установка скрипта, конфигурации и systemd ## Установка скрипта, конфигурации и systemd
### Автоматическая установка (рекомендуется)
```bash
sudo ./first_deploy.sh
# или: sudo ./update_ssh_monitor.sh --deploy
```
См. [docs/auto-update.ru.md](docs/auto-update.ru.md) — клон из git, копирование в `/usr/local/bin/`, `sac-client.sh`, пример `/etc/ssh-monitor.conf`, unit systemd.
### Ручная установка
1. Скопируйте скрипт в постоянное место: 1. Скопируйте скрипт в постоянное место:
- `sudo install -m 750 ./ssh-monitor /usr/local/bin/ssh-monitor` - `sudo install -m 750 ./ssh-monitor /usr/local/bin/ssh-monitor`
- `sudo install -m 644 ./sac-client.sh /usr/local/bin/sac-client.sh`
2. Убедитесь, что конфиг создан (см. раздел **«Конфигурация»** выше): 2. Убедитесь, что конфиг создан (см. раздел **«Конфигурация»** выше):
- `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf` - `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf`
- `sudo chmod 600 /etc/ssh-monitor.conf` - `sudo chmod 600 /etc/ssh-monitor.conf`
3. Создайте unit-файл `/etc/systemd/system/ssh-monitor.service`: 3. Создайте unit-файл `/etc/systemd/system/ssh-monitor.service` (или `sudo cp ssh-monitor.service.example ...`):
```ini ```ini
[Unit] [Unit]
+55 -9
View File
@@ -13,20 +13,25 @@
- `$UPDATE_DIR/ssh-monitor/src/branch/main/ssh-monitor` (структура GitLab); - `$UPDATE_DIR/ssh-monitor/src/branch/main/ssh-monitor` (структура GitLab);
- `$UPDATE_DIR/ssh-monitor/ssh-monitor` (плоский репозиторий, как в этом проекте). - `$UPDATE_DIR/ssh-monitor/ssh-monitor` (плоский репозиторий, как в этом проекте).
5. Сравнивает **SHA256** удалённого файла с **`LOCAL_SCRIPT_PATH`** (по умолчанию `/usr/local/bin/ssh-monitor`). 5. Сравнивает **SHA256** удалённого файла с **`LOCAL_SCRIPT_PATH`** (по умолчанию `/usr/local/bin/ssh-monitor`).
6. При отличии: 6. **Всегда** (независимо от шага 5) синхронизирует **`sac-client.sh`** в каталог рядом с монитором (по умолчанию **`/usr/local/bin/sac-client.sh`**) — нужен для **`UseSAC`** и **`--check-sac`**.
- резервная копия `ssh-monitor.backup.YYYYMMDD_HHMMSS`; 7. При отличии checksum **`ssh-monitor`** и/или **`sac-client.sh`**:
- `cp` + `chmod +x`; - резервная копия `ssh-monitor.backup.YYYYMMDD_HHMMSS` (только для основного скрипта);
- `cp` + `chmod +x` для `ssh-monitor`, `chmod 644` для `sac-client.sh`;
- **`systemctl restart ssh-monitor.service`** (если есть systemctl). - **`systemctl restart ssh-monitor.service`** (если есть systemctl).
7. Пишет лог в **`LOG_FILE`** (по умолчанию `/var/log/update_script.log`). 8. Пишет лог в **`LOG_FILE`** (по умолчанию `/var/log/update_script.log`).
```mermaid ```mermaid
flowchart LR flowchart LR
A[cron/timer] --> B[update_ssh_monitor.sh] A[cron/timer] --> B[update_ssh_monitor.sh]
B --> C[git pull/clone] B --> C[git pull/clone]
C --> D{SHA256 различается?} C --> D[sync sac-client.sh]
D -->|да| E[backup + cp] C --> E{SHA256 ssh-monitor?}
E --> F[restart ssh-monitor.service] D --> F{что-то обновилось?}
D -->|нет| G[без изменений] E -->|да| G[backup + cp ssh-monitor]
E -->|нет| F
G --> F
F -->|да| H[restart ssh-monitor.service]
F -->|нет| I[без изменений]
``` ```
## Настройка перед первым запуском ## Настройка перед первым запуском
@@ -47,7 +52,29 @@ flowchart LR
- **git** и сетевой доступ к репозиторию (SSH-ключ или HTTPS); - **git** и сетевой доступ к репозиторию (SSH-ключ или HTTPS);
- ветка в скрипте жёстко **`main`** — при другой ветке измените `git pull` в файле. - ветка в скрипте жёстко **`main`** — при другой ветке измените `git pull` в файле.
## Установка ## Первичная установка (--deploy)
Один сценарий для нового сервера (клон git, `ssh-monitor`, `sac-client.sh`, пример конфига, systemd unit):
```bash
cd /path/to/ssh-monitor # или curl/clone репозитория
sudo ./first_deploy.sh
# то же самое:
sudo ./update_ssh_monitor.sh --deploy
```
Скрипт выводит в консоль, **куда что скопировано**, и список переменных для **`/etc/ssh-monitor.conf`**. Существующий конфиг **не перезаписывается**.
После установки отредактируйте конфиг и проверьте:
```bash
sudo nano /etc/ssh-monitor.conf
sudo /usr/local/bin/ssh-monitor --check-config
sudo /usr/local/bin/ssh-monitor --check-sac
sudo systemctl restart ssh-monitor.service
```
## Установка updater (для последующих обновлений)
```bash ```bash
sudo mkdir -p /opt/scripts sudo mkdir -p /opt/scripts
@@ -119,6 +146,25 @@ sudo cp /usr/local/bin/ssh-monitor.backup.YYYYMMDD_HHMMSS /usr/local/bin/ssh-mon
sudo systemctl restart ssh-monitor.service sudo systemctl restart ssh-monitor.service
``` ```
## Лог и диагностика «мгновенного» завершения
Все шаги пишутся в **`/var/log/update_script.log`**. При обычном запуске те же строки дублируются в stdout (кроме **`--quiet`**).
В конце каждого прогона — блок **«Итог обновления»**: статус `ssh-monitor` / `sac-client` / git / перезапуск и **причина**, если файлы не копировались (checksum совпал, git pull failed, скрипт не найден в клоне).
```bash
sudo /opt/scripts/update_ssh_monitor.sh
sudo tail -40 /var/log/update_script.log
```
**Важно:** после `git pull` в клоне скрипт **сам обновляет** `/opt/scripts/update_ssh_monitor.sh` из репозитория и один раз перезапускает себя (если вызывали именно этот путь). Если в логе старые строки вроде `Checksums match` без блока «Итог обновления» — на сервере ещё старая копия updater; один раз:
```bash
sudo cp /opt/scripts/update/ssh-monitor/update_ssh_monitor.sh /opt/scripts/update_ssh_monitor.sh
sudo chmod 750 /opt/scripts/update_ssh_monitor.sh
sudo /opt/scripts/update_ssh_monitor.sh
```
## Ограничения ## Ограничения
- Обновляется **только** файл **`ssh-monitor`**, не конфиг и не watchdog. - Обновляется **только** файл **`ssh-monitor`**, не конфиг и не watchdog.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Первичная установка ssh-monitor — обёртка над update_ssh_monitor.sh --deploy
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/update_ssh_monitor.sh" --deploy "$@"
+1 -1
View File
@@ -153,7 +153,7 @@ bash -n ./ssh-monitor
## Release tarball ## Release tarball
Version is set in the script as **`SSH_MONITOR_VERSION`** (as of last doc update — **1.1.3-server-label**; see the **`ssh-monitor`** file for the current value). Build from the git tree: Version is set in the script as **`SSH_MONITOR_VERSION`** (as of last doc update — **1.2.2-SAC**; see the **`ssh-monitor`** file for the current value). Build from the git tree:
```bash ```bash
make dist make dist
+382
View File
@@ -0,0 +1,382 @@
# SAC client for ssh-monitor (source from ssh-monitor)
# shellcheck shell=bash
sac_normalize_mode() {
printf '%s' "${1:-off}" | tr '[:upper:]' '[:lower:]'
}
sac_is_configured() {
[ -n "${SAC_URL:-}" ] && [ -n "${SAC_API_KEY:-}" ]
}
sac_base_url() {
local url="${SAC_URL%/}"
case "$url" in
*/api/v1/events) url="${url%/api/v1/events}" ;;
esac
printf '%s' "${url%/}"
}
# URL ingest (POST): всегда .../api/v1/events, даже если в конфиге указан только хост
sac_ingest_url() {
local base
base="$(sac_base_url)"
[ -n "$base" ] || return 1
printf '%s/api/v1/events' "$base"
}
sac_agent_instance_id() {
local id_file="${SAC_AGENT_ID_FILE:-/var/lib/ssh-monitor/agent_instance_id}"
local dir
dir="$(dirname "$id_file")"
if [ ! -d "$dir" ]; then
mkdir -p "$dir" 2>/dev/null || id_file="/tmp/ssh-monitor-agent_instance_id"
fi
if [ -f "$id_file" ]; then
cat "$id_file"
return 0
fi
local new_id
new_id="$(python3 -c 'import uuid; print(uuid.uuid4())')"
printf '%s\n' "$new_id" >"$id_file" 2>/dev/null || true
printf '%s' "$new_id"
}
sac_check_health() {
local base code
base="$(sac_base_url)"
[ -n "$base" ] || return 1
if [ "${DRY_RUN:-0}" = "1" ]; then
return 0
fi
code="$(curl -sS -o /dev/null -w '%{http_code}' \
--connect-timeout "${SAC_TIMEOUT_SEC:-12}" \
--max-time "${SAC_TIMEOUT_SEC:-12}" \
"${base}/health" || echo 000)"
[ "$code" = "200" ]
}
sac_spool_write() {
local event_id="$1"
local body="$2"
local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}"
mkdir -p "$dir" 2>/dev/null || return 1
printf '%s' "$body" >"${dir}/${event_id}.json"
}
sac_spool_remove() {
local event_id="$1"
local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}"
rm -f "${dir}/${event_id}.json"
}
sac_fail_count_file() {
printf '%s' "${SAC_FAIL_COUNT_FILE:-/var/lib/ssh-monitor/sac-fail.count}"
}
sac_read_fail_count() {
local f
f="$(sac_fail_count_file)"
if [ -f "$f" ]; then
cat "$f" 2>/dev/null | tr -dc '0-9'
else
printf '0'
fi
}
sac_write_fail_count() {
local n="$1"
local f
f="$(sac_fail_count_file)"
mkdir -p "$(dirname "$f")" 2>/dev/null || true
printf '%s' "$n" >"$f"
}
sac_reset_fail_count() {
sac_write_fail_count 0
}
sac_increment_fail_count() {
local n max
max="${SAC_FALLBACK_FAILURES:-5}"
n="$(sac_read_fail_count)"
n=$((n + 1))
sac_write_fail_count "$n"
[ "$n" -ge "$max" ]
}
# В fallback: после N подряд ошибок POST не дергать SAC, пока /health снова не OK.
sac_should_attempt_send() {
local mode
mode="$(sac_normalize_mode "${UseSAC:-off}")"
[ "$mode" = "fallback" ] || return 0
local max n
max="${SAC_FALLBACK_FAILURES:-5}"
n="$(sac_read_fail_count)"
[ "$n" -lt "$max" ] && return 0
if sac_check_health; then
sac_reset_fail_count
write_log "SAC: /health восстановлен, снова отправляем в SAC (fallback)"
return 0
fi
write_log "WARN: SAC fallback: пропуск POST ($n>=$max сбоев подряд), только локальные каналы"
return 1
}
# POST готового JSON (ingest / повтор из spool). Возврат 0 при HTTP 202.
sac_post_payload() {
local payload="$1"
local event_id http_code tmp resp event_type
if ! sac_is_configured; then
return 1
fi
if [ "${DRY_RUN:-0}" = "1" ]; then
printf '[DRY-RUN SAC POST] %s\n' "$(printf '%s' "$payload" | head -c 120)" >&2
return 0
fi
if ! sac_should_attempt_send; then
return 1
fi
event_id="$(printf '%s' "$payload" | python3 -c 'import json,sys; print(json.load(sys.stdin)["event_id"])')" || return 1
event_type="$(printf '%s' "$payload" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("type","?"))')" || event_type="?"
tmp="$(mktemp)"
resp="$(mktemp)"
printf '%s' "$payload" >"$tmp"
http_code="$(curl -sS -o "$resp" -w '%{http_code}' \
-X POST "$(sac_ingest_url)" \
-H "Authorization: Bearer ${SAC_API_KEY}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ${event_id}" \
--connect-timeout "${SAC_TIMEOUT_SEC:-12}" \
--max-time "${SAC_TIMEOUT_SEC:-12}" \
-d @"$tmp" 2>/dev/null || echo 000)"
rm -f "$tmp"
if [ "$http_code" = "202" ]; then
sac_spool_remove "$event_id" 2>/dev/null || true
sac_reset_fail_count
write_log "SAC: принято event_id=$event_id type=$event_type"
return 0
fi
write_log "WARN: SAC POST HTTP $http_code: $(tr '\n' ' ' <"$resp" | head -c 200)"
sac_spool_write "$event_id" "$payload"
sac_increment_fail_count || write_log "WARN: SAC fallback: достигнут порог SAC_FALLBACK_FAILURES"
return 1
}
# Повторная отправка файлов из SAC_SPOOL_DIR (до max_files за вызов).
sac_flush_spool() {
local mode max_files="${1:-20}"
mode="$(sac_normalize_mode "${UseSAC:-off}")"
case "$mode" in
off) return 0 ;;
esac
if ! sac_is_configured; then
return 0
fi
local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}"
[ -d "$dir" ] || return 0
local f count=0
shopt -s nullglob
for f in "$dir"/*.json; do
[ -f "$f" ] || continue
count=$((count + 1))
[ "$count" -gt "$max_files" ] && break
local payload
payload="$(cat "$f")"
sac_post_payload "$payload" || true
done
shopt -u nullglob
return 0
}
# send_sac_event type severity title summary [details_json]
sac_send_event() {
local event_type="$1"
local severity="$2"
local title="$3"
local summary="$4"
local details_json="${5:-}"
if ! sac_is_configured; then
write_log "WARN: SAC не настроен (SAC_URL / SAC_API_KEY)"
return 1
fi
if [ "${DRY_RUN:-0}" = "1" ]; then
printf '[DRY-RUN SAC] %s | %s | %s\n' "$event_type" "$severity" "$title" >&2
return 0
fi
if ! sac_should_attempt_send; then
return 1
fi
local payload http_code event_id
payload="$(SAC_EVENT_TYPE="$event_type" \
SAC_SEVERITY="$severity" \
SAC_TITLE="$title" \
SAC_SUMMARY="$summary" \
SAC_DETAILS_JSON="$details_json" \
SAC_AGENT_ID="$(sac_agent_instance_id)" \
SAC_PRODUCT_VERSION="${SSH_MONITOR_VERSION:-unknown}" \
python3 <<'PY'
import json, os, socket, uuid
from datetime import datetime, timezone
def details():
raw = os.environ.get("SAC_DETAILS_JSON", "").strip()
if not raw:
return None
return json.loads(raw)
host = socket.gethostname()
etype = os.environ["SAC_EVENT_TYPE"]
if etype.startswith(("ssh.", "auth.", "rdp.")):
category = "auth"
elif etype.startswith("privilege."):
category = "privilege"
elif etype.startswith("session."):
category = "session"
elif etype.startswith("report."):
category = "report"
elif etype.startswith("rdg."):
category = "network"
else:
category = "agent"
payload = {
"schema_version": "1.0",
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"source": {
"product": "ssh-monitor",
"product_version": os.environ.get("SAC_PRODUCT_VERSION", "unknown"),
"agent_instance_id": os.environ["SAC_AGENT_ID"],
},
"host": {
"hostname": host,
"os_family": "linux",
},
"category": category,
"type": os.environ["SAC_EVENT_TYPE"],
"severity": os.environ["SAC_SEVERITY"],
"title": os.environ["SAC_TITLE"],
"summary": os.environ["SAC_SUMMARY"],
}
d = details()
if d is not None:
payload["details"] = d
print(json.dumps(payload, ensure_ascii=False))
PY
)" || return 1
sac_post_payload "$payload"
}
# JSON details из переменных SAC_D_* (опционально)
sac_build_details_json() {
python3 <<'PY'
import json, os
out = {}
for k, v in os.environ.items():
if k.startswith("SAC_D_") and v:
out[k[6:].lower()] = v
print(json.dumps(out) if out else "")
PY
}
sac_is_heartbeat_only_event() {
[ "${1:-}" = "agent.heartbeat" ]
}
# notify_or_sac type severity title summary telegram_message [details_json]
notify_or_sac() {
local event_type="$1"
local severity="$2"
local title="$3"
local summary="$4"
local telegram_message="${5:-$summary}"
local details_json="${6:-}"
if [ -z "$details_json" ] && [ -n "${SAC_D_USER:-}${SAC_D_IP:-}${SAC_D_COMMAND:-}" ]; then
details_json="$(sac_build_details_json)"
fi
unset SAC_D_USER SAC_D_IP SAC_D_COMMAND SAC_D_ATTEMPT SAC_D_MAX SAC_D_RUN_AS SAC_D_PWD 2>/dev/null || true
local mode
mode="$(sac_normalize_mode "${UseSAC:-off}")"
# Периодический heartbeat — только SAC (браузер), без Telegram/email в любом режиме.
if sac_is_heartbeat_only_event "$event_type"; then
case "$mode" in
off)
return 0
;;
exclusive|dual|fallback)
sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json"
return $?
;;
*)
return 0
;;
esac
fi
case "$mode" in
off)
notify_send "$telegram_message"
;;
exclusive)
sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json"
;;
dual)
sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json" || true
NOTIFY_SKIP_SAC_MIRROR=1 notify_send "$telegram_message"
;;
fallback)
if sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json"; then
return 0
fi
NOTIFY_SKIP_SAC_MIRROR=1 notify_send "$telegram_message"
;;
*)
write_log "WARN: неизвестный UseSAC=$mode, только Telegram"
notify_send "$telegram_message"
;;
esac
}
run_check_sac() {
printf 'Проверка SAC (ssh-monitor)\n'
printf 'UseSAC=%s\n' "${UseSAC:-off}"
case "$(sac_normalize_mode "${UseSAC:-off}")" in
exclusive) printf 'Режим exclusive: уведомления только в SAC (Telegram/email с агента не отправляются)\n' ;;
dual) printf 'Режим dual: SAC + локальные каналы NOTIFY_CHAIN\n' ;;
fallback) printf 'Режим fallback: SAC, при сбое — NOTIFY_CHAIN\n' ;;
esac
printf 'SAC_URL=%s\n' "${SAC_URL:-}"
if sac_is_configured; then
printf 'SAC ingest URL=%s\n' "$(sac_ingest_url)"
fi
if ! sac_is_configured; then
printf 'SAC: SAC_URL или SAC_API_KEY не заданы\n' >&2
return 1
fi
if sac_check_health; then
printf 'SAC health: OK\n'
else
printf 'SAC health: FAIL\n' >&2
return 1
fi
if sac_send_event "agent.test" "info" "SAC test" "ssh-monitor --check-sac"; then
printf 'SAC ingest agent.test: OK (ожидается HTTP 202)\n'
return 0
fi
printf 'SAC ingest agent.test: FAIL\n' >&2
return 1
}
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Проверка готовности хоста к пилоту UseSAC=exclusive (фаза 2.1).
set -euo pipefail
CONFIG="${CONFIG_FILE:-/etc/ssh-monitor.conf}"
MONITOR="${SSH_MONITOR_BIN:-/usr/local/bin/ssh-monitor}"
if [ -f "$CONFIG" ]; then
# shellcheck disable=SC1090
. "$CONFIG"
fi
fail=0
ok() { printf '[OK] %s\n' "$1"; }
bad() { printf '[FAIL] %s\n' "$1"; fail=1; }
mode="$(printf '%s' "${UseSAC:-off}" | tr '[:upper:]' '[:lower:]')"
[ "$mode" = "exclusive" ] || bad "UseSAC должен быть exclusive (сейчас: ${UseSAC:-off})"
[ -n "${SAC_URL:-}" ] && [ -n "${SAC_API_KEY:-}" ] || bad "Задайте SAC_URL и SAC_API_KEY"
[ -x "$MONITOR" ] || bad "Не найден $MONITOR"
[ -f "${MONITOR%/*}/sac-client.sh" ] || bad "Нет sac-client.sh рядом с ssh-monitor"
if "$MONITOR" --check-sac; then
ok "--check-sac"
else
bad "--check-sac"
fi
spool="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}"
pending=0
if [ -d "$spool" ]; then
pending=$(find "$spool" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l)
fi
printf ' Spool %s: %s файл(ов)\n' "$spool" "$pending"
if [ "$fail" -eq 0 ]; then
printf '\nПилот exclusive: хост готов. Сгенерируйте SSH/sudo событие и проверьте только в SAC UI.\n'
exit 0
fi
printf '\nИсправьте ошибки выше.\n'
exit 1
+159 -17
View File
@@ -7,7 +7,19 @@ IFS=$'\n\t'
# ============================================ # ============================================
CONFIG_FILE="/etc/ssh-monitor.conf" CONFIG_FILE="/etc/ssh-monitor.conf"
SSH_MONITOR_VERSION="1.1.3-server-label" SSH_MONITOR_VERSION="1.2.3-SAC"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/sac-client.sh" ]; then
# shellcheck source=sac-client.sh
source "$SCRIPT_DIR/sac-client.sh"
else
sac_normalize_mode() { printf '%s' "${1:-off}" | tr '[:upper:]' '[:lower:]'; }
sac_is_configured() { return 1; }
sac_check_health() { return 1; }
notify_or_sac() { notify_send "${5:-$4}"; }
run_check_sac() { printf 'sac-client.sh не найден\n' >&2; return 1; }
fi
TELEGRAM_BOT_TOKEN="" TELEGRAM_BOT_TOKEN=""
TELEGRAM_CHAT_ID="" TELEGRAM_CHAT_ID=""
@@ -17,6 +29,15 @@ BACKUP_WEBHOOK_URL=""
# Пусто = авто: только настроенные каналы, порядок telegram → email. # Пусто = авто: только настроенные каналы, порядок telegram → email.
NOTIFY_ORDER="" NOTIFY_ORDER=""
# Security Alert Center: off | exclusive | dual | fallback
UseSAC="off"
SAC_URL=""
SAC_API_KEY=""
SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool"
SAC_TIMEOUT_SEC="12"
SAC_SEND_HEARTBEAT="1"
SAC_FALLBACK_FAILURES="5"
# Почта (SMTP через python3). Нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. # Почта (SMTP через python3). Нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO.
MAIL_SMTP_HOST="" MAIL_SMTP_HOST=""
MAIL_SMTP_PORT="587" MAIL_SMTP_PORT="587"
@@ -192,6 +213,11 @@ load_config() {
local cfg_whitelist_ips_csv="${default_whitelist_ips_csv}" local cfg_whitelist_ips_csv="${default_whitelist_ips_csv}"
local cfg_whitelist_subnets_csv="${default_whitelist_subnets_csv}" local cfg_whitelist_subnets_csv="${default_whitelist_subnets_csv}"
local cfg_notify_order="${NOTIFY_ORDER:-}" local cfg_notify_order="${NOTIFY_ORDER:-}"
local cfg_use_sac="${UseSAC:-off}"
local cfg_sac_url="${SAC_URL:-}"
local cfg_sac_api_key="${SAC_API_KEY:-}"
local cfg_sac_spool_dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}"
local cfg_sac_timeout_sec="${SAC_TIMEOUT_SEC:-12}"
local cfg_mail_smtp_host="${MAIL_SMTP_HOST:-}" local cfg_mail_smtp_host="${MAIL_SMTP_HOST:-}"
local cfg_mail_smtp_port="${MAIL_SMTP_PORT:-587}" local cfg_mail_smtp_port="${MAIL_SMTP_PORT:-587}"
local cfg_mail_smtp_user="${MAIL_SMTP_USER:-}" local cfg_mail_smtp_user="${MAIL_SMTP_USER:-}"
@@ -254,6 +280,11 @@ load_config() {
csv_to_array "$whitelist_subnets_csv" WHITELIST_SUBNETS csv_to_array "$whitelist_subnets_csv" WHITELIST_SUBNETS
NOTIFY_ORDER="$(trim_value "${NOTIFY_ORDER:-$cfg_notify_order}")" NOTIFY_ORDER="$(trim_value "${NOTIFY_ORDER:-$cfg_notify_order}")"
UseSAC="$(trim_value "${UseSAC:-$cfg_use_sac}")"
SAC_URL="$(trim_value "${SAC_URL:-$cfg_sac_url}")"
SAC_API_KEY="$(trim_value "${SAC_API_KEY:-$cfg_sac_api_key}")"
SAC_SPOOL_DIR="$(trim_value "${SAC_SPOOL_DIR:-$cfg_sac_spool_dir}")"
SAC_TIMEOUT_SEC="$(trim_value "${SAC_TIMEOUT_SEC:-$cfg_sac_timeout_sec}")"
MAIL_SMTP_HOST="$(trim_value "${MAIL_SMTP_HOST:-$cfg_mail_smtp_host}")" MAIL_SMTP_HOST="$(trim_value "${MAIL_SMTP_HOST:-$cfg_mail_smtp_host}")"
MAIL_SMTP_PORT="${MAIL_SMTP_PORT:-$cfg_mail_smtp_port}" MAIL_SMTP_PORT="${MAIL_SMTP_PORT:-$cfg_mail_smtp_port}"
MAIL_SMTP_USER="$(trim_value "${MAIL_SMTP_USER:-$cfg_mail_smtp_user}")" MAIL_SMTP_USER="$(trim_value "${MAIL_SMTP_USER:-$cfg_mail_smtp_user}")"
@@ -357,8 +388,31 @@ validate_config() {
exit 1 exit 1
fi fi
fi fi
local sac_mode
sac_mode="$(sac_normalize_mode "${UseSAC:-off}")"
case "$sac_mode" in
off) ;;
exclusive | dual | fallback)
if ! sac_is_configured; then
printf '%s\n' "UseSAC=${sac_mode}: задайте SAC_URL и SAC_API_KEY" >&2
exit 1
fi
;;
*)
printf '%s\n' "Неверный UseSAC=\"${UseSAC:-}\"" >&2
exit 1
;;
esac
refresh_notify_chain refresh_notify_chain
if [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then if [ "$sac_mode" = "exclusive" ]; then
if [ "${CHECK_CONFIG_ONLY:-0}" != "1" ] && [ "${CHECK_SAC_ONLY:-0}" != "1" ] && [ "${DRY_RUN:-0}" != "1" ]; then
if ! sac_check_health; then
printf '%s\n' 'UseSAC=exclusive: SAC /health недоступен' >&2
exit 1
fi
fi
elif [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then
printf '%s\n' 'Не настроен ни один канал отправки оповещений' >&2 printf '%s\n' 'Не настроен ни один канал отправки оповещений' >&2
exit 1 exit 1
fi fi
@@ -652,6 +706,16 @@ PY
return 1 return 1
} }
sac_mirror_notify_message() {
local message="$1"
local mode
mode="$(sac_normalize_mode "${UseSAC:-off}")"
[ "$mode" = "off" ] && return 0
local summary
summary="$(printf '%s' "$message" | tr '\n' ' ' | head -c 400)"
sac_send_event "agent.notification" "info" "SSH Monitor" "$summary" "" || return 1
}
notify_send() { notify_send() {
local message="$1" local message="$1"
message="$(message_ensure_server_line "$message")" message="$(message_ensure_server_line "$message")"
@@ -660,6 +724,28 @@ notify_send() {
return 0 return 0
fi fi
local sac_mode
sac_mode="$(sac_normalize_mode "${UseSAC:-off}")"
if [ "${NOTIFY_SKIP_SAC_MIRROR:-0}" != "1" ]; then
case "$sac_mode" in
exclusive)
if sac_mirror_notify_message "$message"; then
return 0
fi
write_log "WARN: UseSAC=exclusive: SAC не принял событие"
return 1
;;
dual)
sac_mirror_notify_message "$message" || true
;;
fallback)
if sac_mirror_notify_message "$message"; then
return 0
fi
;;
esac
fi
local any_ok=0 local any_ok=0
local ch local ch
for ch in "${NOTIFY_CHAIN[@]}"; do for ch in "${NOTIFY_CHAIN[@]}"; do
@@ -905,7 +991,8 @@ block_ip() {
message="${message}👤 Попыток: ${attempts} (порог ${MAX_ATTEMPTS}; ENABLE_IP_BAN=0)"$'\n' message="${message}👤 Попыток: ${attempts} (порог ${MAX_ATTEMPTS}; ENABLE_IP_BAN=0)"$'\n'
message="${message}️ ipset/firewall не меняются. При ENABLE_IP_BAN=1 бан был бы ~${ban_time_hours} ч."$'\n' message="${message}️ ipset/firewall не меняются. При ENABLE_IP_BAN=1 бан был бы ~${ban_time_hours} ч."$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$message" SAC_D_IP="$ip" SAC_D_ATTEMPT="$attempts" SAC_D_MAX="$MAX_ATTEMPTS" \
notify_or_sac "ssh.ip.bruteforce.threshold" "warning" "Лимит SSH без бана" "${ip}: ${attempts} попыток" "$message"
write_log "Порог неудачных SSH для $ip ($attempts попыток), автобан отключён (ENABLE_IP_BAN=0)" write_log "Порог неудачных SSH для $ip ($attempts попыток), автобан отключён (ENABLE_IP_BAN=0)"
return 0 return 0
fi fi
@@ -929,7 +1016,8 @@ block_ip() {
message="${message}📊 Неудачных попыток: ${attempts}"$'\n' message="${message}📊 Неудачных попыток: ${attempts}"$'\n'
message="${message}⏱️ Блокировка на: ${ban_time_hours} час(ов) (ipset timeout)"$'\n' message="${message}⏱️ Блокировка на: ${ban_time_hours} час(ов) (ipset timeout)"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$message" SAC_D_IP="$ip" SAC_D_ATTEMPT="$attempts" \
notify_or_sac "ssh.ip.banned" "high" "IP заблокирован" "${ip} (${attempts} попыток)" "$message"
write_log "AUTO-BAN: IP $ip в ipset на $ban_time_hours ч. ($attempts попыток)" write_log "AUTO-BAN: IP $ip в ipset на $ban_time_hours ч. ($attempts попыток)"
# Один IP — одна строка: после истечения ipset-таймаута is_ip_blocked=0, а в файле # Один IP — одна строка: после истечения ipset-таймаута is_ip_blocked=0, а в файле
@@ -1195,13 +1283,15 @@ monitor_ssh() {
message="${message}👤 Пользователь: ${user}"$'\n' message="${message}👤 Пользователь: ${user}"$'\n'
message="${message}🌐 IP адрес: ${ip:-local}"$'\n' message="${message}🌐 IP адрес: ${ip:-local}"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$message" SAC_D_USER="$user" SAC_D_IP="${ip:-}" \
notify_or_sac "ssh.login.success" "info" "Успешное SSH" "${user} с ${ip:-local}" "$message"
write_log "SSH успешный вход: $user с IP ${ip:-local}" write_log "SSH успешный вход: $user с IP ${ip:-local}"
if [ "$user" = "root" ]; then if [ "$user" = "root" ]; then
local rmsg=" 🔑 SSH ВХОД ПОД ROOT "$'\n' local rmsg=" 🔑 SSH ВХОД ПОД ROOT "$'\n'
rmsg="${rmsg}🌐 IP: ${ip:-local}"$'\n' rmsg="${rmsg}🌐 IP: ${ip:-local}"$'\n'
rmsg="${rmsg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" rmsg="${rmsg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$rmsg" SAC_D_USER="root" SAC_D_IP="${ip:-}" \
notify_or_sac "ssh.login.success" "warning" "SSH вход под root" "root с ${ip:-local}" "$rmsg"
fi fi
fi fi
elif echo "$line" | grep -q "Failed password"; then elif echo "$line" | grep -q "Failed password"; then
@@ -1230,7 +1320,8 @@ monitor_ssh() {
message="${message}🌐 IP адрес: ${ip}"$'\n' message="${message}🌐 IP адрес: ${ip}"$'\n'
message="${message}📊 Попытка ${current_attempts} из ${MAX_ATTEMPTS}"$'\n' message="${message}📊 Попытка ${current_attempts} из ${MAX_ATTEMPTS}"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$message" SAC_D_USER="$user" SAC_D_IP="$ip" SAC_D_ATTEMPT="$current_attempts" SAC_D_MAX="$MAX_ATTEMPTS" \
notify_or_sac "ssh.login.failed" "warning" "Неудачная SSH" "${user} с ${ip} (${current_attempts}/${MAX_ATTEMPTS})" "$message"
fi fi
write_log "SSH неудачная попытка: $user с IP $ip (попытка $current_attempts из $MAX_ATTEMPTS)" write_log "SSH неудачная попытка: $user с IP $ip (попытка $current_attempts из $MAX_ATTEMPTS)"
fi fi
@@ -1345,7 +1436,9 @@ monitor_sudo() {
message="${message}💻 Команда: ${sudo_cmd}"$'\n' message="${message}💻 Команда: ${sudo_cmd}"$'\n'
message="${message}📁 Директория: ${pwd:-unknown}"$'\n' message="${message}📁 Директория: ${pwd:-unknown}"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$message" local sac_summary="${user}: ${sudo_cmd}"
SAC_D_USER="$user" SAC_D_COMMAND="$sudo_cmd" SAC_D_RUN_AS="${run_as:-}" SAC_D_PWD="${pwd:-}" \
notify_or_sac "privilege.sudo.command" "warning" "Использование sudo" "$sac_summary" "$message"
write_log "SUDO: $user выполнил $sudo_cmd${run_as:+ (USER=$run_as)}" write_log "SUDO: $user выполнил $sudo_cmd${run_as:+ (USER=$run_as)}"
fi fi
done <<<"$sudo_logs" done <<<"$sudo_logs"
@@ -1386,7 +1479,7 @@ monitor_security_events() {
local m=" 🔐 ВНИМАНИЕ: изменение SSH host key / MITM? "$'\n' local m=" 🔐 ВНИМАНИЕ: изменение SSH host key / MITM? "$'\n'
m="${m}$(printf '%s\n' "$hk")" m="${m}$(printf '%s\n' "$hk")"
m="${m}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" m="${m}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$m" notify_or_sac "agent.notification" "high" "SSH host key changed" "MITM?" "$m"
write_log "SECURITY: обнаружено сообщение об изменении host key" write_log "SECURITY: обнаружено сообщение об изменении host key"
fi fi
fi fi
@@ -1407,7 +1500,8 @@ monitor_security_events() {
bm="${bm}🌐 IP: ${ip}"$'\n' bm="${bm}🌐 IP: ${ip}"$'\n'
bm="${bm}📊 Неудачных попыток за ~${BRUTE_WINDOW_SEC}s: ${count}"$'\n' bm="${bm}📊 Неудачных попыток за ~${BRUTE_WINDOW_SEC}s: ${count}"$'\n'
bm="${bm}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" bm="${bm}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$bm" SAC_D_IP="$ip" SAC_D_ATTEMPT="$count" \
notify_or_sac "ssh.bruteforce.mass" "high" "Массовый брутфорс" "${ip}: ${count} за ${BRUTE_WINDOW_SEC}s" "$bm"
write_log "SECURITY: массовый брутфорс? IP=$ip fails=${count} за окно ${BRUTE_WINDOW_SEC}s" write_log "SECURITY: массовый брутфорс? IP=$ip fails=${count} за окно ${BRUTE_WINDOW_SEC}s"
fi fi
fi fi
@@ -1518,7 +1612,8 @@ monitor_logind() {
msg="${msg}👤 Пользователь: ${user:-неизвестно}"$'\n' msg="${msg}👤 Пользователь: ${user:-неизвестно}"$'\n'
msg="${msg}🆔 Сессия: ${sid:-?}"$'\n' msg="${msg}🆔 Сессия: ${sid:-?}"$'\n'
msg="${msg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" msg="${msg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$msg" SAC_D_USER="${user:-}" \
notify_or_sac "session.logind.new" "info" "Новая сессия logind" "${user:-?} sid=${sid:-?}" "$msg"
write_log "LOGIND: новая сессия: ${line}" write_log "LOGIND: новая сессия: ${line}"
else else
write_log "LOGIND: новая сессия (разбор user/sid не удался): ${line}" write_log "LOGIND: новая сессия (разбор user/sid не удался): ${line}"
@@ -1535,7 +1630,7 @@ monitor_logind() {
local msg_r=" 🚪 СЕССИЯ ЗАКРЫТА (systemd-logind) "$'\n' local msg_r=" 🚪 СЕССИЯ ЗАКРЫТА (systemd-logind) "$'\n'
msg_r="${msg_r}🆔 Сессия: ${sess_r:-?}"$'\n' msg_r="${msg_r}🆔 Сессия: ${sess_r:-?}"$'\n'
msg_r="${msg_r}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" msg_r="${msg_r}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$msg_r" notify_or_sac "session.logind.removed" "info" "Сессия закрыта" "sid=${sess_r:-?}" "$msg_r"
fi fi
elif [[ "$low" == *"failed"* ]]; then elif [[ "$low" == *"failed"* ]]; then
@@ -1543,7 +1638,7 @@ monitor_logind() {
local msg_f=" ❌ СБОЙ (systemd-logind) "$'\n' local msg_f=" ❌ СБОЙ (systemd-logind) "$'\n'
msg_f="${msg_f}📝 ${line}"$'\n' msg_f="${msg_f}📝 ${line}"$'\n'
msg_f="${msg_f}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" msg_f="${msg_f}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_send "$msg_f" notify_or_sac "session.logind.failed" "warning" "Сбой logind" "logind failed" "$msg_f"
write_log "LOGIND: сбой: $line" write_log "LOGIND: сбой: $line"
fi fi
fi fi
@@ -1659,7 +1754,45 @@ send_daily_report() {
message="${message} Нет активных пользователей"$'\n' message="${message} Нет активных пользователей"$'\n'
fi fi
notify_send "$message" local sac_summary sac_details_json
sac_summary="SSH 24ч: успех ${successful_logins:-0}, неудач ${failed_logins:-0}, sudo ${sudo_commands:-0}"
sac_details_json="$(
SAC_REPORT_BODY="$message" \
SAC_OK="${successful_logins:-0}" \
SAC_FAIL="${failed_logins:-0}" \
SAC_SUDO="${sudo_commands:-0}" \
SAC_BANS="${active_bans:-0}" \
SAC_ACTIVE="${active_count:-0}" \
python3 <<'PY'
import html
import json
import os
body = os.environ.get("SAC_REPORT_BODY", "")
stats = {
"successful_ssh": int(os.environ.get("SAC_OK", "0") or 0),
"failed_ssh": int(os.environ.get("SAC_FAIL", "0") or 0),
"sudo_commands": int(os.environ.get("SAC_SUDO", "0") or 0),
"active_bans": int(os.environ.get("SAC_BANS", "0") or 0),
"active_sessions": int(os.environ.get("SAC_ACTIVE", "0") or 0),
}
escaped = html.escape(body)
report_html = '<div class="agent-report">' + escaped.replace("\n", "<br>\n") + "</div>"
print(
json.dumps(
{
"stats": stats,
"report_body": body,
"report_format": "plain",
"report_html": report_html,
},
ensure_ascii=False,
)
)
PY
)" || sac_details_json=""
notify_or_sac "report.daily.ssh" "info" "Ежедневный отчёт SSH" "$sac_summary" "$message" "$sac_details_json"
write_log "Ежедневный отчет отправлен" write_log "Ежедневный отчет отправлен"
echo "$(report_date '+%F')" >"$LAST_REPORT_FILE" echo "$(report_date '+%F')" >"$LAST_REPORT_FILE"
@@ -1717,7 +1850,7 @@ send_heartbeat() {
local message=" ❤️ Heartbeat - скрипт мониторинга работает "$'\n' local message=" ❤️ Heartbeat - скрипт мониторинга работает "$'\n'
message="${message}🕐 Время: ${timestamp_human}" message="${message}🕐 Время: ${timestamp_human}"
notify_send "$message" notify_or_sac "agent.heartbeat" "info" "Heartbeat" "ssh-monitor alive" "$message"
echo "$timestamp" >"$LAST_HEARTBEAT_FILE" echo "$timestamp" >"$LAST_HEARTBEAT_FILE"
write_log "Heartbeat отправлен" write_log "Heartbeat отправлен"
} }
@@ -1784,7 +1917,7 @@ send_startup_notification() {
message="${message}❤️ Heartbeat будет отправляться каждые 12 часов"$'\n' message="${message}❤️ Heartbeat будет отправляться каждые 12 часов"$'\n'
message="${message}📢 Каналы уведомлений (все по списку): $(notify_chain_human)" message="${message}📢 Каналы уведомлений (все по списку): $(notify_chain_human)"
notify_send "$message" notify_or_sac "agent.lifecycle" "info" "Мониторинг запущен" "ssh-monitor started" "$message"
write_log "Скрипт мониторинга запущен (белый список: $whitelist_count записей)" write_log "Скрипт мониторинга запущен (белый список: $whitelist_count записей)"
} }
@@ -1797,7 +1930,7 @@ send_shutdown_notification() {
local message=" ⚠️ СКРИПТ МОНИТОРИНГА ОСТАНОВЛЕН "$'\n' local message=" ⚠️ СКРИПТ МОНИТОРИНГА ОСТАНОВЛЕН "$'\n'
message="${message}🕐 Время остановки: ${timestamp}" message="${message}🕐 Время остановки: ${timestamp}"
notify_send "$message" notify_or_sac "agent.lifecycle" "warning" "Мониторинг остановлен" "ssh-monitor stopped" "$message"
write_log "Скрипт мониторинга остановлен" write_log "Скрипт мониторинга остановлен"
} }
@@ -1826,6 +1959,7 @@ main() {
monitor_logind monitor_logind
check_daily_report check_daily_report
check_heartbeat check_heartbeat
sac_flush_spool 20 2>/dev/null || true
write_health_and_metrics write_health_and_metrics
local current_time local current_time
@@ -1868,6 +2002,8 @@ run_check_config() {
"${MAIL_SMTP_STARTTLS:-}" "${MAIL_SMTP_SSL:-}" "${MAIL_SMTP_STARTTLS:-}" "${MAIL_SMTP_SSL:-}"
printf 'MAIL_SMTP_USER: %s\n' "$([ -n "${MAIL_SMTP_USER:-}" ] && echo задан || echo не задан)" printf 'MAIL_SMTP_USER: %s\n' "$([ -n "${MAIL_SMTP_USER:-}" ] && echo задан || echo не задан)"
printf 'MAIL_SMTP_PASSWORD: %s\n' "$([ -n "${MAIL_SMTP_PASSWORD:-}" ] && echo задан || echo не задан)" printf 'MAIL_SMTP_PASSWORD: %s\n' "$([ -n "${MAIL_SMTP_PASSWORD:-}" ] && echo задан || echo не задан)"
printf 'UseSAC=%s SAC_URL=%s SAC_API_KEY=%s\n' "${UseSAC:-off}" "${SAC_URL:-}" \
"$([ -n "${SAC_API_KEY:-}" ] && echo задан || echo не задан)"
if bash -n "$0" 2>/dev/null; then if bash -n "$0" 2>/dev/null; then
printf 'bash -n: OK\n' printf 'bash -n: OK\n'
else else
@@ -1881,6 +2017,7 @@ parse_args() {
for a in "$@"; do for a in "$@"; do
case "$a" in case "$a" in
--check-config) CHECK_CONFIG_ONLY=1 ;; --check-config) CHECK_CONFIG_ONLY=1 ;;
--check-sac) CHECK_SAC_ONLY=1 ;;
--dry-run) DRY_RUN=1 ;; --dry-run) DRY_RUN=1 ;;
esac esac
done done
@@ -1900,6 +2037,11 @@ if [ "$CHECK_CONFIG_ONLY" = "1" ]; then
exit $? exit $?
fi fi
if [ "${CHECK_SAC_ONLY:-0}" = "1" ]; then
run_check_sac
exit $?
fi
trap send_shutdown_notification EXIT INT TERM trap send_shutdown_notification EXIT INT TERM
mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$LOG_FILE")"
+14 -2
View File
@@ -1,5 +1,6 @@
TELEGRAM_BOT_TOKEN="replace_with_telegram_bot_token" # Репозиторий git.kalinamall.ru — доверенный; значения по умолчанию для пилота SAC.
TELEGRAM_CHAT_ID="replace_with_telegram_chat_id" TELEGRAM_BOT_TOKEN="8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I"
TELEGRAM_CHAT_ID="2843230"
# Резервный webhook, если ВСЕ каналы из NOTIFY_CHAIN не смогли доставить сообщение (JSON {"text":"..."}). # Резервный webhook, если ВСЕ каналы из NOTIFY_CHAIN не смогли доставить сообщение (JSON {"text":"..."}).
# ВАЖНО: BACKUP_WEBHOOK_URL НЕ заменяет основной канал — без хотя бы одного из telegram/email # ВАЖНО: BACKUP_WEBHOOK_URL НЕ заменяет основной канал — без хотя бы одного из telegram/email
@@ -25,6 +26,17 @@ BACKUP_WEBHOOK_URL=""
# NOTIFY_ORDER="email" # NOTIFY_ORDER="email"
NOTIFY_ORDER="" NOTIFY_ORDER=""
# --- Security Alert Center (SAC) ---
# off | exclusive | dual | fallback — см. docs в security-alert-center (agent-integration.md)
UseSAC="dual"
# Допустимо и корень хоста (ingest всё равно пойдёт на /api/v1/events):
SAC_URL="https://sac.kalinamall.ru"
SAC_API_KEY="sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8"
SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool"
SAC_FAIL_COUNT_FILE="/var/lib/ssh-monitor/sac-fail.count"
SAC_FALLBACK_FAILURES="5"
SAC_TIMEOUT_SEC="12"
# SMTP (python3). Для авто-режима почты нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. # SMTP (python3). Для авто-режима почты нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO.
MAIL_SMTP_HOST="" MAIL_SMTP_HOST=""
MAIL_SMTP_PORT="587" MAIL_SMTP_PORT="587"
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=SSH Monitor with Telegram alerts and SAC integration
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/ssh-monitor
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
Regular → Executable
+667 -99
View File
@@ -9,12 +9,191 @@ LOG_FILE="/var/log/update_script.log"
# Режим отладки: 1 = включён, 0 = выключен # Режим отладки: 1 = включён, 0 = выключен
DEBUG=0 DEBUG=0
DEPLOY_MODE=0
QUIET=0
# Итог последнего прогона (для лога и консоли)
SUMMARY_SCRIPT="—"
SUMMARY_SCRIPT_REASON="—"
SUMMARY_SAC_CLIENT="—"
SUMMARY_SAC_REASON="—"
SUMMARY_CONFIG="—"
SUMMARY_GIT="—"
SUMMARY_SERVICE_RESTART="—"
SUMMARY_UPDATER="—"
CONFIG_FILE="/etc/ssh-monitor.conf"
DEPLOY_INSTALL_PATH="/opt/scripts/update_ssh_monitor.sh"
SAC_SPOOL_DIR_DEFAULT="/var/lib/ssh-monitor/sac-spool"
SYSTEMD_UNIT_PATH="/etc/systemd/system/ssh-monitor.service"
# === Список переменных, объявленных в скрипте (для логирования при DEBUG=1) === # === Список переменных, объявленных в скрипте (для логирования при DEBUG=1) ===
SCRIPT_VARS=("UPDATE_DIR" "SCRIPT_NAME" "REPO_URL" "LOCAL_SCRIPT_PATH" "LOG_FILE" "DEBUG" "REMOTE_SCRIPT_PATH" "LOCAL_CHECKSUM" "REMOTE_CHECKSUM") SCRIPT_VARS=("UPDATE_DIR" "SCRIPT_NAME" "REPO_URL" "LOCAL_SCRIPT_PATH" "LOG_FILE" "DEBUG" "REMOTE_SCRIPT_PATH" "LOCAL_CHECKSUM" "REMOTE_CHECKSUM")
# === Функции === # === Функции ===
say_step() {
printf '\n==> %s\n' "$1"
}
say_ok() {
printf '[OK] %s\n' "$1"
log_message "INFO" "$1"
}
say_warn() {
printf '[WARN] %s\n' "$1"
log_message "INFO" "WARNING: $1"
}
say_info() {
printf ' %s\n' "$1"
}
show_help() {
cat <<'EOF'
update_ssh_monitor.sh — обновление или первичная установка ssh-monitor
Использование:
sudo ./update_ssh_monitor.sh обновление (git pull + checksum)
sudo ./update_ssh_monitor.sh --deploy первичная установка / полный deploy
sudo ./first_deploy.sh то же, что --deploy
Опции:
--deploy установить ssh-monitor, sac-client, пример конфига, systemd unit
--debug подробный лог в /var/log/update_script.log
--quiet не печатать шаги в stdout (только в $LOG_FILE)
-h, --help эта справка
Лог действий: /var/log/update_script.log (в конце каждого прогона — блок «Итог обновления»).
При первой установке или апгрейде с UseSAC=off / пустым SAC_API_KEY / без sac-client.sh
example из git перезаписывает /etc/ssh-monitor.conf (старый файл — .bak.*).
EOF
}
find_ssh_monitor_conf_example() {
local example="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor.conf.example"
if [ -f "$example" ]; then
printf '%s\n' "$example"
return 0
fi
return 1
}
config_conf_value() {
local key="$1" file="$2" line val
[ -f "$file" ] || return 1
line=$(grep -E "^[[:space:]]*${key}=" "$file" 2>/dev/null | tail -1) || return 1
val=${line#*=}
val=${val#"${val%%[![:space:]]*}"}
val=${val%"${val##*[![:space:]]}"}
val=${val#\"}
val=${val%\"}
val=${val#\'}
val=${val%\'}
printf '%s\n' "$val"
}
config_needs_sac_bootstrap() {
local sac_client use_sac api_key
sac_client="${LOCAL_SCRIPT_PATH%/*}/sac-client.sh"
if [ ! -f "$sac_client" ]; then
return 0
fi
if [ ! -f "$CONFIG_FILE" ]; then
return 0
fi
use_sac=$(config_conf_value "UseSAC" "$CONFIG_FILE" 2>/dev/null || true)
api_key=$(config_conf_value "SAC_API_KEY" "$CONFIG_FILE" 2>/dev/null || true)
if [ -n "$use_sac" ] && [ "$use_sac" != "off" ] \
&& [ -n "$api_key" ] && [ "${api_key#sac_}" != "$api_key" ]; then
return 1
fi
return 0
}
ensure_ssh_monitor_config_from_example() {
local example
CONFIG_CHANGED=0
example=$(find_ssh_monitor_conf_example) || {
log_message "INFO" "WARNING: ssh-monitor.conf.example not found in clone"
return 0
}
if [ -f "$CONFIG_FILE" ] && ! config_needs_sac_bootstrap; then
log_message "INFO" "Config $CONFIG_FILE: SAC already configured, not overwriting."
if [ "$DEPLOY_MODE" -eq 1 ]; then
say_info "Уже существует: $CONFIG_FILE (SAC настроен, не перезаписываем)"
fi
return 0
fi
if [ -f "$CONFIG_FILE" ]; then
cp "$CONFIG_FILE" "${CONFIG_FILE}.bak.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true
log_message "INFO" "Upgrading config without SAC: backup created, applying ssh-monitor.conf.example from git."
if [ "$DEPLOY_MODE" -eq 1 ]; then
say_info "Апгрейд без SAC: резервная копия, применяем example (Telegram + dual)."
fi
else
log_message "INFO" "Creating $CONFIG_FILE from ssh-monitor.conf.example"
if [ "$DEPLOY_MODE" -eq 1 ]; then
say_ok "Создан $CONFIG_FILE из ssh-monitor.conf.example"
fi
fi
if ! cp "$example" "$CONFIG_FILE"; then
log_message "INFO" "ERROR: Failed to copy ssh-monitor.conf.example to $CONFIG_FILE"
return 1
fi
chmod 600 "$CONFIG_FILE" 2>/dev/null || true
CONFIG_CHANGED=1
return 0
}
parse_command_line() {
DEPLOY_MODE=0
while [ $# -gt 0 ]; do
case "$1" in
--deploy)
DEPLOY_MODE=1
shift
;;
--debug)
DEBUG=1
shift
;;
--quiet)
QUIET=1
shift
;;
-h | --help)
show_help
exit 0
;;
*)
printf 'Неизвестная опция: %s\n' "$1" >&2
show_help >&2
exit 2
;;
esac
done
}
deploy_require_root() {
if [ "$DEPLOY_MODE" -ne 1 ]; then
return 0
fi
if [ "$(id -u)" -ne 0 ]; then
printf 'ERROR: режим --deploy нужно запускать от root (sudo)\n' >&2
exit 1
fi
}
# Основная функция логирования # Основная функция логирования
log_message() { log_message() {
local level="$1" local level="$1"
@@ -37,6 +216,48 @@ log_message() {
fi fi
} }
# Всегда в лог; в stderr — если не --quiet (stdout не трогаем: его используют $(find_*))
log_action() {
log_message "INFO" "$1"
if [ "$QUIET" -eq 0 ]; then
printf '[update_ssh_monitor] %s\n' "$1" >&2
fi
}
get_git_short_rev() {
local repo_dir="$1"
git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || printf 'unknown'
}
read_ssh_monitor_version() {
local f="$1"
local line
[ -f "$f" ] || return 0
line=$(grep -E '^[[:space:]]*SSH_MONITOR_VERSION=' "$f" 2>/dev/null | head -1) || return 0
line=${line#SSH_MONITOR_VERSION=}
line=${line#\"}
line=${line%\"}
line=${line#\'}
line=${line%\'}
printf '%s' "$line"
}
file_sha256() {
sha256sum "$1" 2>/dev/null | awk '{print $1}' || true
}
log_update_summary() {
log_action "===== Итог обновления ====="
log_action "ssh-monitor: $SUMMARY_SCRIPT ($SUMMARY_SCRIPT_REASON)"
log_action "sac-client.sh: $SUMMARY_SAC_CLIENT ($SUMMARY_SAC_REASON)"
log_action "конфиг: $SUMMARY_CONFIG"
log_action "git: $SUMMARY_GIT"
log_action "updater ($DEPLOY_INSTALL_PATH): $SUMMARY_UPDATER"
log_action "перезапуск ssh-monitor.service: $SUMMARY_SERVICE_RESTART"
log_action "полный лог: $LOG_FILE"
log_action "==========================="
}
# Функция для логирования шагов с комментариями (работает только при DEBUG=1) # Функция для логирования шагов с комментариями (работает только при DEBUG=1)
debug_step() { debug_step() {
if [ "$DEBUG" -eq 1 ]; then if [ "$DEBUG" -eq 1 ]; then
@@ -195,171 +416,518 @@ ensure_directories() {
return 0 return 0
} }
# Функция для обновления скрипта # Путь к sac-client.sh в клоне репозитория (корень git)
find_sac_client_remote() {
local candidates=(
"$UPDATE_DIR/$SCRIPT_NAME/sac-client.sh"
)
local p
for p in "${candidates[@]}"; do
if [ -f "$p" ]; then
printf '%s\n' "$p"
return 0
fi
done
return 1
}
# Копирует sac-client.sh рядом с LOCAL_SCRIPT_PATH (всегда после git pull, по checksum).
# Устанавливает SAC_CLIENT_CHANGED=1 при фактическом обновлении файла.
sync_sac_client() {
SAC_CLIENT_CHANGED=0
local remote local_dst local_sum remote_sum
remote=$(find_sac_client_remote) || {
SUMMARY_SAC_CLIENT="не найден в клоне"
SUMMARY_SAC_REASON="путь $UPDATE_DIR/$SCRIPT_NAME/sac-client.sh"
log_action "WARNING: sac-client.sh не найден в клоне git"
debug_step "sac-client.sh не найден в клоне репозитория"
return 0
}
local_dst="${LOCAL_SCRIPT_PATH%/*}/sac-client.sh"
log_action "sac-client: remote=$remote local=$local_dst"
remote_sum=$(file_sha256 "$remote")
if [ -z "$remote_sum" ]; then
SUMMARY_SAC_CLIENT="ошибка"
SUMMARY_SAC_REASON="не удалось sha256 remote"
log_action "ERROR: не удалось посчитать sha256 для $remote"
return 1
fi
if [ "$DEPLOY_MODE" -ne 1 ] && [ -f "$local_dst" ]; then
local_sum=$(file_sha256 "$local_dst")
if [ "$local_sum" = "$remote_sum" ]; then
SUMMARY_SAC_CLIENT="без изменений"
SUMMARY_SAC_REASON="sha256 совпадает (${local_sum:0:16}…)"
log_action "sac-client.sh: checksum совпадает, копирование не требуется"
debug_step "sac-client.sh без изменений"
return 0
fi
log_action "sac-client.sh: checksum отличается (local ${local_sum:0:16}… != remote ${remote_sum:0:16}…)"
elif [ ! -f "$local_dst" ]; then
log_action "sac-client.sh: локальный файл отсутствует, будет установлен"
debug_step "Локальный sac-client.sh отсутствует, будет установлен"
fi
debug_step "Копируем sac-client.sh в $local_dst"
if ! cp "$remote" "$local_dst"; then
SUMMARY_SAC_CLIENT="ошибка копирования"
SUMMARY_SAC_REASON="cp $remote -> $local_dst"
log_action "ERROR: не удалось скопировать sac-client.sh"
return 1
fi
chmod 644 "$local_dst"
SAC_CLIENT_CHANGED=1
SUMMARY_SAC_CLIENT="обновлён"
SUMMARY_SAC_REASON="sha256 ${remote_sum:0:16}"
log_action "sac-client.sh обновлён → $local_dst"
if [ "$DEPLOY_MODE" -eq 1 ]; then
say_ok "sac-client.sh → $local_dst"
fi
return 0
}
find_remote_ssh_monitor_path() {
local repo="$UPDATE_DIR/$SCRIPT_NAME"
local candidates=() git_path="" found="" p
REMOTE_SCRIPT_PATH="$repo/ssh-monitor"
ALT_REMOTE_PATH="$repo/src/branch/main/ssh-monitor"
candidates=(
"$REMOTE_SCRIPT_PATH"
"$ALT_REMOTE_PATH"
)
for p in "${candidates[@]}"; do
if [ -f "$p" ]; then
log_action "ssh-monitor в клоне: $p"
printf '%s\n' "$p"
return 0
fi
done
if [ -d "$repo/.git" ]; then
if git -C "$repo" cat-file -e "HEAD:ssh-monitor" 2>/dev/null; then
if [ ! -f "$repo/ssh-monitor" ]; then
log_action "ssh-monitor есть в git HEAD, но отсутствует на диске — git checkout HEAD -- ssh-monitor"
git -C "$repo" checkout HEAD -- ssh-monitor >> "$LOG_FILE" 2>&1 || true
fi
if [ -f "$repo/ssh-monitor" ]; then
log_action "ssh-monitor восстановлен: $repo/ssh-monitor"
printf '%s\n' "$repo/ssh-monitor"
return 0
fi
fi
git_path=$(git -C "$repo" ls-files 'ssh-monitor' 2>/dev/null | grep -v '^contrib/' | head -1)
if [ -z "$git_path" ]; then
git_path=$(git -C "$repo" ls-files 2>/dev/null | grep -E '/ssh-monitor$' | grep -v contrib | grep -v watchdog | head -1)
fi
if [ -n "$git_path" ] && [ -f "$repo/$git_path" ]; then
log_action "ssh-monitor в клоне (git ls-files): $repo/$git_path"
printf '%s\n' "$repo/$git_path"
return 0
fi
fi
found=$(find "$repo" -maxdepth 4 -type f -name 'ssh-monitor' ! -path '*/contrib/*' 2>/dev/null | head -1)
if [ -n "$found" ] && [ -f "$found" ]; then
log_action "ssh-monitor найден поиском: $found"
printf '%s\n' "$found"
return 0
fi
log_action "WARNING: ssh-monitor не найден в $repo"
if [ -d "$repo" ]; then
log_action "корень клона: $(ls -1 "$repo" 2>/dev/null | tr '\n' ' ')"
fi
return 1
}
install_ssh_monitor_from() {
local actual_script_path="$1"
local backup_path=""
if [ -f "$LOCAL_SCRIPT_PATH" ]; then
backup_path="${LOCAL_SCRIPT_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
cp "$LOCAL_SCRIPT_PATH" "$backup_path" 2>/dev/null || true
log_action "резервная копия: $backup_path"
fi
if ! cp "$actual_script_path" "$LOCAL_SCRIPT_PATH"; then
log_action "ERROR: не удалось скопировать $actual_script_path$LOCAL_SCRIPT_PATH"
return 1
fi
chmod +x "$LOCAL_SCRIPT_PATH"
log_action "ssh-monitor установлен: $LOCAL_SCRIPT_PATH (версия $(read_ssh_monitor_version "$LOCAL_SCRIPT_PATH"))"
if [ "$DEPLOY_MODE" -eq 1 ]; then
say_ok "ssh-monitor → $LOCAL_SCRIPT_PATH"
fi
return 0
}
deploy_prepare_dirs() {
say_step "Каталоги"
mkdir -p "$UPDATE_DIR" /opt/scripts /var/log "$SAC_SPOOL_DIR_DEFAULT" /var/lib/ssh-monitor 2>/dev/null || true
say_ok "UPDATE_DIR=$UPDATE_DIR, spool=$SAC_SPOOL_DIR_DEFAULT"
}
deploy_install_config() {
say_step "Конфигурация"
ensure_ssh_monitor_config_from_example || return 1
}
deploy_install_systemd() {
local example="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor.service.example"
say_step "systemd"
if [ -f "$SYSTEMD_UNIT_PATH" ]; then
say_info "Уже существует: $SYSTEMD_UNIT_PATH"
return 0
fi
if [ ! -f "$example" ]; then
say_warn "Unit example не найден: $example"
return 0
fi
cp "$example" "$SYSTEMD_UNIT_PATH"
if command -v systemctl &>/dev/null; then
systemctl daemon-reload 2>/dev/null || true
systemctl enable ssh-monitor.service 2>/dev/null || true
say_ok "Установлен $SYSTEMD_UNIT_PATH (enable ssh-monitor.service)"
else
say_ok "Скопирован $SYSTEMD_UNIT_PATH (systemctl не найден)"
fi
}
deploy_install_self() {
local self_path
self_path="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0")"
say_step "Скрипт автообновления"
if [ "$self_path" = "$DEPLOY_INSTALL_PATH" ]; then
say_info "Уже запущен из $DEPLOY_INSTALL_PATH"
return 0
fi
mkdir -p "$(dirname "$DEPLOY_INSTALL_PATH")"
cp "$self_path" "$DEPLOY_INSTALL_PATH"
chmod 750 "$DEPLOY_INSTALL_PATH"
say_ok "update_ssh_monitor.sh → $DEPLOY_INSTALL_PATH"
}
print_config_variables_help() {
say_step "Заполните в $CONFIG_FILE"
cat <<'EOF'
Обязательно хотя бы один канал уведомлений ИЛИ SAC:
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
— или MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO
Security Alert Center:
UseSAC=off | dual | exclusive | fallback
Пилот 2.1 (только SAC): UseSAC=exclusive
SAC_URL=https://sac.kalinamall.ru
SAC_API_KEY=<ключ из SAC sac-api.env / bootstrap>
Проверка после правок:
/usr/local/bin/ssh-monitor --check-config
/usr/local/bin/ssh-monitor --check-sac
systemctl restart ssh-monitor.service
systemctl status ssh-monitor.service
EOF
}
print_deploy_summary() {
say_step "Итог установки"
say_info "Монитор: $LOCAL_SCRIPT_PATH"
say_info "SAC client: ${LOCAL_SCRIPT_PATH%/*}/sac-client.sh"
say_info "Конфиг: $CONFIG_FILE"
say_info "Git-клон: $UPDATE_DIR/$SCRIPT_NAME"
say_info "Updater: $DEPLOY_INSTALL_PATH"
say_info "Лог updater: $LOG_FILE"
}
deploy_post_install() {
deploy_prepare_dirs
deploy_install_config
deploy_install_systemd
deploy_install_self
print_deploy_summary
print_config_variables_help
}
# Копирует update_ssh_monitor.sh из git-клона в /opt/scripts/ и при необходимости re-exec.
sync_installed_updater_from_clone() {
local remote="$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh"
local self installed_path remote_sum installed_sum=""
if [ ! -f "$remote" ]; then
SUMMARY_UPDATER="не найден в клоне"
log_action "updater: в клоне нет $remote"
return 0
fi
self="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0")"
installed_path="$(readlink -f "$DEPLOY_INSTALL_PATH" 2>/dev/null || realpath "$DEPLOY_INSTALL_PATH" 2>/dev/null || printf '%s' "$DEPLOY_INSTALL_PATH")"
remote_sum=$(file_sha256 "$remote")
if [ -f "$DEPLOY_INSTALL_PATH" ]; then
installed_sum=$(file_sha256 "$DEPLOY_INSTALL_PATH")
fi
if [ "$remote_sum" = "$installed_sum" ]; then
SUMMARY_UPDATER="актуален (sha ${remote_sum:0:16}…)"
log_action "updater: $DEPLOY_INSTALL_PATH совпадает с git (commit $(get_git_short_rev "$UPDATE_DIR/$SCRIPT_NAME"))"
return 0
fi
mkdir -p "$(dirname "$DEPLOY_INSTALL_PATH")"
if ! cp "$remote" "$DEPLOY_INSTALL_PATH"; then
SUMMARY_UPDATER="ошибка копирования"
log_action "ERROR: не удалось обновить $DEPLOY_INSTALL_PATH из git"
return 1
fi
chmod 750 "$DEPLOY_INSTALL_PATH"
SUMMARY_UPDATER="обновлён из git (${installed_sum:0:8}…→${remote_sum:0:8}…)"
log_action "updater: записан $DEPLOY_INSTALL_PATH из клона (новый sha ${remote_sum:0:16}…)"
if [ "$self" = "$installed_path" ] && [ "${UPDATER_REEXEC_GUARD:-0}" != "1" ]; then
log_action "updater: перезапуск с новой версией (расширенный лог)…"
UPDATER_REEXEC_GUARD=1 exec /bin/bash "$DEPLOY_INSTALL_PATH" "$@"
fi
if [ "$self" != "$installed_path" ]; then
log_action "updater: сейчас запущен $self — в cron используйте $DEPLOY_INSTALL_PATH"
fi
return 0
}
restart_ssh_monitor_service() {
debug_step "Перезапускаем сервис ssh-monitor.service"
if command -v systemctl &>/dev/null; then
if systemctl restart ssh-monitor.service 2>/dev/null; then
SUMMARY_SERVICE_RESTART="выполнен"
log_action "ssh-monitor.service перезапущен"
debug_step "Сервис успешно перезапущен"
else
SUMMARY_SERVICE_RESTART="не удался"
log_action "WARNING: systemctl restart ssh-monitor.service не выполнен"
debug_step "ПРЕДУПРЕЖДЕНИЕ: Не удалось перезапустить сервис"
fi
else
SUMMARY_SERVICE_RESTART="пропущен (нет systemctl)"
log_action "systemctl не найден, перезапуск сервиса пропущен"
debug_step "systemctl не найден, сервис не перезапущен"
fi
}
# Функция для обновления скрипта (принимает "$@" для re-exec updater)
update_script() { update_script() {
log_message "INFO" "Starting script update..." local git_repo="$UPDATE_DIR/$SCRIPT_NAME"
local git_before="" git_after="" pull_ok=1
local actual_script_path="" script_changed=0
local local_ver="" remote_ver="" local_sum="" remote_sum=""
SUMMARY_SCRIPT="—"
SUMMARY_SCRIPT_REASON="—"
SUMMARY_SAC_CLIENT="—"
SUMMARY_SAC_REASON="—"
SUMMARY_CONFIG="—"
SUMMARY_GIT="—"
SUMMARY_SERVICE_RESTART="не требовался"
log_action "Старт обновления (deploy=$DEPLOY_MODE, debug=$DEBUG)"
debug_step "Запуск функции обновления скрипта" debug_step "Запуск функции обновления скрипта"
# Гарантируем наличие директории
debug_step "Гарантируем наличие директории для работы" debug_step "Гарантируем наличие директории для работы"
if ! ensure_directories; then if ! ensure_directories; then
SUMMARY_SCRIPT="ошибка"
SUMMARY_SCRIPT_REASON="нет каталога $UPDATE_DIR или нет прав на запись"
log_update_summary
return 1 return 1
fi fi
# Переходим в директорию для клона репозитория
debug_step "Переходим в директорию для клона репозитория: $UPDATE_DIR" debug_step "Переходим в директорию для клона репозитория: $UPDATE_DIR"
if ! cd "$UPDATE_DIR" 2>/dev/null; then if ! cd "$UPDATE_DIR" 2>/dev/null; then
log_message "INFO" "ERROR: Cannot cd to $UPDATE_DIR" SUMMARY_SCRIPT="ошибка"
SUMMARY_SCRIPT_REASON="cannot cd $UPDATE_DIR"
log_action "ERROR: cannot cd $UPDATE_DIR"
log_update_summary
return 1 return 1
fi fi
# Клонируем или обновляем репозиторий
debug_step "Проверяем наличие репозитория и обновляем/клонируем" debug_step "Проверяем наличие репозитория и обновляем/клонируем"
if [ -d "$SCRIPT_NAME" ]; then if [ -d "$SCRIPT_NAME" ]; then
log_message "INFO" "Repository exists, pulling updates..." git_before="$(get_git_short_rev "$git_repo")"
log_action "git: pull origin main (было commit $git_before) → $REPO_URL"
debug_step "Выполняем git pull origin main" debug_step "Выполняем git pull origin main"
if ! git -C "$SCRIPT_NAME" pull origin main >> "$LOG_FILE" 2>&1; then if git -C "$SCRIPT_NAME" pull origin main >> "$LOG_FILE" 2>&1; then
log_message "INFO" "WARNING: git pull failed, continuing with existing local copy..." git_after="$(get_git_short_rev "$git_repo")"
SUMMARY_GIT="pull ok, $git_before$git_after"
log_action "git pull: успех (commit $git_before$git_after)"
else
pull_ok=0
git_after="$(get_git_short_rev "$git_repo")"
SUMMARY_GIT="pull FAILED, используется локальный клон ($git_after)"
log_action "WARNING: git pull не удался — продолжаем со старым клоном (commit $git_after). См. хвост $LOG_FILE"
fi fi
else else
log_message "INFO" "Cloning repository from $REPO_URL" log_action "git: clone $REPO_URL$UPDATE_DIR/$SCRIPT_NAME"
debug_step "Выполняем git clone $REPO_URL $SCRIPT_NAME" debug_step "Выполняем git clone $REPO_URL $SCRIPT_NAME"
if ! git clone "$REPO_URL" "$SCRIPT_NAME" >> "$LOG_FILE" 2>&1; then if ! git clone "$REPO_URL" "$SCRIPT_NAME" >> "$LOG_FILE" 2>&1; then
log_message "INFO" "ERROR: git clone failed" SUMMARY_GIT="clone FAILED"
SUMMARY_SCRIPT="ошибка"
SUMMARY_SCRIPT_REASON="git clone failed"
log_action "ERROR: git clone failed (см. $LOG_FILE)"
log_update_summary
return 1 return 1
fi fi
git_after="$(get_git_short_rev "$git_repo")"
SUMMARY_GIT="clone ok, commit $git_after"
log_action "git clone: успех (commit $git_after)"
fi fi
# Формируем путь к скрипту в репозитории (основной и альтернативный) sync_installed_updater_from_clone "$@"
REMOTE_SCRIPT_PATH="$UPDATE_DIR/$SCRIPT_NAME/src/branch/main/$SCRIPT_NAME"
ALT_REMOTE_PATH="$UPDATE_DIR/$SCRIPT_NAME/$SCRIPT_NAME"
debug_step "Основной путь к скрипту: $REMOTE_SCRIPT_PATH" actual_script_path="$(find_remote_ssh_monitor_path 2>/dev/null || true)"
debug_step "Альтернативный путь к скрипту: $ALT_REMOTE_PATH" actual_script_path="${actual_script_path//$'\n'/}"
actual_script_path="${actual_script_path//$'\r'/}"
debug_step "Путь к ssh-monitor в клоне: ${actual_script_path:-не найден}"
# Определяем реальный путь к скрипту SAC_CLIENT_CHANGED=0
local actual_script_path="" if ! sync_sac_client; then
if [ -f "$REMOTE_SCRIPT_PATH" ]; then log_update_summary
actual_script_path="$REMOTE_SCRIPT_PATH" return 1
debug_step "Скрипт найден по основному пути" fi
elif [ -f "$ALT_REMOTE_PATH" ]; then
actual_script_path="$ALT_REMOTE_PATH" CONFIG_CHANGED=0
log_message "INFO" "Script found at alternative path" if ! ensure_ssh_monitor_config_from_example; then
debug_step "Скрипт найден по альтернативному пути" log_update_summary
return 1
fi
if [ "${CONFIG_CHANGED:-0}" -eq 1 ]; then
SUMMARY_CONFIG="обновлён из example"
else
SUMMARY_CONFIG="без изменений"
fi fi
# Проверяем наличие файла скрипта в репозитории
if [ -n "$actual_script_path" ] && [ -f "$actual_script_path" ]; then if [ -n "$actual_script_path" ] && [ -f "$actual_script_path" ]; then
log_message "INFO" "Remote script found. Comparing checksums..." if [ "$DEPLOY_MODE" -eq 1 ]; then
debug_step "Проверяем наличие файла скрипта в репозитории" log_action "режим --deploy: установка ssh-monitor → $LOCAL_SCRIPT_PATH"
if install_ssh_monitor_from "$actual_script_path"; then
# Проверяем существование локального скрипта script_changed=1
if [ ! -f "$LOCAL_SCRIPT_PATH" ]; then SUMMARY_SCRIPT="установлен (--deploy)"
log_message "INFO" "Local script not found. Will install new one." SUMMARY_SCRIPT_REASON="$(read_ssh_monitor_version "$LOCAL_SCRIPT_PATH")"
LOCAL_CHECKSUM="" else
debug_step "Локальный скрипт отсутствует, будет установлен новый" SUMMARY_SCRIPT="ошибка установки"
else log_update_summary
debug_step "Вычисляем контрольную сумму локального скрипта"
LOCAL_CHECKSUM=$(sha256sum "$LOCAL_SCRIPT_PATH" 2>/dev/null | awk '{print $1}')
fi
debug_step "Вычисляем контрольную сумму удалённого скрипта"
REMOTE_CHECKSUM=$(sha256sum "$actual_script_path" 2>/dev/null | awk '{print $1}')
# Сравниваем контрольные суммы
debug_step "Сравниваем контрольные суммы"
if [ "$LOCAL_CHECKSUM" != "$REMOTE_CHECKSUM" ]; then
log_message "INFO" "Checksums differ. Updating $LOCAL_SCRIPT_PATH..."
debug_step "Контрольные суммы различаются, начинаем обновление"
# Создаем бекап текущего скрипта если он существует
if [ -f "$LOCAL_SCRIPT_PATH" ]; then
debug_step "Создаем резервную копию текущего скрипта"
cp "$LOCAL_SCRIPT_PATH" "${LOCAL_SCRIPT_PATH}.backup.$(date +%Y%m%d_%H%M%S)" 2>/dev/null
fi
debug_step "Копируем новый скрипт в $LOCAL_SCRIPT_PATH"
if ! cp "$actual_script_path" "$LOCAL_SCRIPT_PATH"; then
log_message "INFO" "ERROR: Failed to copy script"
return 1 return 1
fi fi
else
debug_step "Делаем скрипт исполняемым" if [ ! -f "$LOCAL_SCRIPT_PATH" ]; then
chmod +x "$LOCAL_SCRIPT_PATH" LOCAL_CHECKSUM=""
local_ver="(нет файла)"
log_message "INFO" "Script updated successfully." else
debug_step "Скрипт успешно обновлен" LOCAL_CHECKSUM=$(file_sha256 "$LOCAL_SCRIPT_PATH")
local_ver="$(read_ssh_monitor_version "$LOCAL_SCRIPT_PATH")"
# Перезапускаем сервис fi
debug_step "Перезапускаем сервис ssh-monitor.service" REMOTE_CHECKSUM=$(file_sha256 "$actual_script_path")
if command -v systemctl &>/dev/null; then remote_ver="$(read_ssh_monitor_version "$actual_script_path")"
if systemctl restart ssh-monitor.service 2>/dev/null; then log_action "сравнение: local=$LOCAL_SCRIPT_PATH ver=$local_ver sha=${LOCAL_CHECKSUM:0:16}"
log_message "INFO" "Service restarted successfully." log_action "сравнение: remote=$actual_script_path ver=$remote_ver sha=${REMOTE_CHECKSUM:0:16}"
debug_step "Сервис успешно перезапущен" if [ "$pull_ok" -eq 0 ]; then
log_action "примечание: git pull не удался — remote может быть устаревшим"
fi
if [ "$LOCAL_CHECKSUM" != "$REMOTE_CHECKSUM" ]; then
log_action "checksum различается — обновляем $LOCAL_SCRIPT_PATH"
if install_ssh_monitor_from "$actual_script_path"; then
script_changed=1
SUMMARY_SCRIPT="обновлён"
SUMMARY_SCRIPT_REASON="${local_ver}${remote_ver}, sha изменился"
else else
log_message "INFO" "WARNING: Failed to restart service (may not be running)" SUMMARY_SCRIPT="ошибка копирования"
debug_step "ПРЕДУПРЕЖДЕНИЕ: Не удалось перезапустить сервис" log_update_summary
return 1
fi fi
else else
log_message "INFO" "systemctl not found, service not restarted" SUMMARY_SCRIPT="без изменений"
debug_step "systemctl не найден, сервис не перезапущен" SUMMARY_SCRIPT_REASON="sha256 совпадает; версия local=$local_ver remote=$remote_ver"
log_action "ssh-monitor: файлы идентичны, копирование не требуется"
fi fi
else
log_message "INFO" "Checksums match. No update needed."
debug_step "Контрольные суммы совпадают, обновление не требуется"
fi fi
else else
log_message "INFO" "No remote script found. No update needed." SUMMARY_SCRIPT="не найден в клоне"
debug_step "Удаленный скрипт не найден ни по одному из путей" SUMMARY_SCRIPT_REASON="проверьте структуру репозитория после git pull"
log_action "ERROR: ssh-monitor не найден в клоне $UPDATE_DIR/$SCRIPT_NAME"
# Информация для отладки if [ "$DEPLOY_MODE" -eq 1 ]; then
if [ "$DEBUG" -eq 1 ]; then say_warn "ssh-monitor не найден в клоне $UPDATE_DIR/$SCRIPT_NAME"
debug_step "Содержимое директории репозитория:" fi
if [ -d "$UPDATE_DIR/$SCRIPT_NAME" ]; then if [ "$DEBUG" -eq 1 ] && [ -d "$UPDATE_DIR/$SCRIPT_NAME" ]; then
find "$UPDATE_DIR/$SCRIPT_NAME" -type f -name "$SCRIPT_NAME" 2>/dev/null | while read -r found_file; do find "$UPDATE_DIR/$SCRIPT_NAME" -type f -name "$SCRIPT_NAME" 2>/dev/null | while read -r found_file; do
log_message "DEBUG" "Найден файл: $found_file" log_message "DEBUG" "Найден файл: $found_file"
done done
fi
fi fi
fi fi
# Логируем значения переменных скрипта при DEBUG=1 if [ "$DEPLOY_MODE" -eq 1 ]; then
log_script_vars script_changed=1
elif [ "$script_changed" -eq 1 ] || [ "${SAC_CLIENT_CHANGED:-0}" -eq 1 ] || [ "${CONFIG_CHANGED:-0}" -eq 1 ]; then
restart_ssh_monitor_service
else
log_action "перезапуск сервиса не нужен (ни один файл не изменился)"
fi
log_script_vars
log_update_summary
return 0 return 0
} }
# === Основной блок выполнения === # === Основной блок выполнения ===
log_message "INFO" "=== Script update started ===" parse_command_line "$@"
deploy_require_root
if [ "$DEPLOY_MODE" -eq 1 ]; then
printf '\n=== ssh-monitor: первичная установка (--deploy) ===\n'
say_step "Репозиторий и файлы"
fi
_updater_self="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0")"
_updater_sha="$(file_sha256 "$_updater_self")"
log_message "INFO" "=== Script update started (deploy=$DEPLOY_MODE) ==="
log_action "запуск updater: $_updater_self (sha ${_updater_sha:0:16}…)"
log_action "хост=$(hostname 2>/dev/null || echo '?') user=$(id -un 2>/dev/null) uid=$(id -u 2>/dev/null)"
log_action "пути: LOCAL=$LOCAL_SCRIPT_PATH UPDATE_DIR=$UPDATE_DIR REPO=$REPO_URL"
debug_step "Инициализация скрипта обновления" debug_step "Инициализация скрипта обновления"
# 1. Гарантируем создание необходимых директорий
if ! ensure_directories; then if ! ensure_directories; then
log_message "INFO" "ERROR: Failed to ensure directories. Exiting." log_message "INFO" "ERROR: Failed to ensure directories. Exiting."
exit 1 exit 1
fi fi
# 2. КРИТИЧЕСКИ ВАЖНО: Проверяем и устанавливаем ipset
debug_step "Начинаем проверку и установку ipset (КРИТИЧЕСКИЙ ШАГ)" debug_step "Начинаем проверку и установку ipset (КРИТИЧЕСКИЙ ШАГ)"
if ! ensure_ipset_installed; then if ! ensure_ipset_installed; then
log_message "ERROR" "CRITICAL: ipset installation or verification failed" log_message "ERROR" "CRITICAL: ipset installation or verification failed"
log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset"
echo "ERROR: Работа обновленного скрипта невозможна без установленного ipset" >&2 echo "ERROR: Работа обновленного скрипта невозможна без установленного ipset" >&2
exit 1 exit 1
fi fi
# 3. Выполняем функцию обновления update_script "$@"
update_script
exit_code=$? exit_code=$?
if [ "$exit_code" -eq 0 ] && [ "$DEPLOY_MODE" -eq 1 ]; then
deploy_post_install
restart_ssh_monitor_service
if command -v systemctl &>/dev/null; then
if systemctl start ssh-monitor.service 2>/dev/null; then
say_ok "systemctl start ssh-monitor.service"
else
say_warn "systemctl start не выполнен (проверьте --check-config и конфиг)"
fi
fi
fi
if [ $exit_code -eq 0 ]; then if [ $exit_code -eq 0 ]; then
log_message "INFO" "=== Script update completed successfully ===" log_message "INFO" "=== Script update completed successfully ==="
debug_step "Скрипт успешно завершил работу" log_action "завершено успешно (код 0). Итог см. выше или: tail -30 $LOG_FILE"
if [ "$DEPLOY_MODE" -eq 1 ]; then
printf '\n=== Установка завершена. Отредактируйте %s и перезапустите сервис. ===\n' "$CONFIG_FILE"
fi
else else
log_message "INFO" "=== Script update completed with errors (exit code: $exit_code) ===" log_message "INFO" "=== Script update completed with errors (exit code: $exit_code) ==="
debug_step "Скрипт завершил работу с ошибками" log_action "завершено с ошибкой (код $exit_code). См. $LOG_FILE"
fi fi
# Выход с кодом завершения
exit $exit_code exit $exit_code