diff --git a/Makefile b/Makefile index b11f9d8..1a44af6 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ # Версия берётся из ssh-monitor (SSH_MONITOR_VERSION). -.PHONY: dist clean-dist +.PHONY: dist clean-dist manifest VERSION := $(shell sed -n 's/^SSH_MONITOR_VERSION="\(.*\)".*/\1/p' ssh-monitor | head -n1) DIST := ssh-monitor-$(VERSION).tar.gz dist: $(DIST) +manifest: + ./scripts/build-release-manifest.sh + $(DIST): ssh-monitor git archive --format=tar.gz --prefix=ssh-monitor-$(VERSION)/ -o $(DIST) HEAD diff --git a/README.md b/README.md index 6d0b0b1..1e5955f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ssh-monitor -**Версия:** `2.1.9-SAC` +**Версия:** `2.2.1-SAC` Bash-мониторинг **SSH**, **sudo**, **systemd-logind**: Telegram/email, бан IP (ipset), ежедневный отчёт, heartbeat. @@ -14,7 +14,7 @@ Bash-мониторинг **SSH**, **sudo**, **systemd-logind**: Telegram/email, |------|------------| | **`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`. | -| `update_ssh_monitor.sh` | Опциональное автообновление: `git pull` / `clone` (дефолт `REPO_URL=git.kalinamall.ru`, override через env), сравнение SHA256, **`sac-client.sh`**, перезапуск `ssh-monitor.service`. См. [docs/auto-update.ru.md](docs/auto-update.ru.md). | +| `update_ssh_monitor.sh` | Автообновление: git + manifest verify, `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). diff --git a/docs/auto-update.ru.md b/docs/auto-update.ru.md index 4821f51..754db40 100644 --- a/docs/auto-update.ru.md +++ b/docs/auto-update.ru.md @@ -1,113 +1,174 @@ -# Автообновление ssh-monitor - -Скрипт **`update_ssh_monitor.sh`** обновляет **`/usr/local/bin/ssh-monitor`** из git и перезапускает systemd-сервис. - -## Основной путь: Security Alert Center - -На продакшен-хостах обновление идёт **через SAC**, без cron: - -1. Админ нажимает **«Обновить ssh-monitor (SSH)»** в карточке хоста. -2. SAC по SSH запускает `/opt/scripts/update_ssh_monitor.sh` с `REPO_URL` / `GIT_BRANCH` из **Настройки → Обновления агентов**. -3. Updater сам выполняет `git pull`, синхронизирует `sac-client.sh` и watchdog, дописывает отсутствующие ключи в **`/etc/ssh-monitor.conf`**, **выставляет `chmod`/`chown`** на конфиг, SAC-spool, счётчики и state-файлы, перезапускает **`ssh-monitor.service`**. - -Участие админа на сервере **не требуется** (кроме первичной настройки `SAC_URL` / `SAC_API_KEY` в конфиге). - -При **первой установке** (нет updater на хосте) SAC выполняет bootstrap с **`--deploy`**: clone, ipset при необходимости, systemd unit, полный hardening. - -**Cron / systemd timer** — опциональная альтернатива с тем же скриптом и обязательным `REPO_URL` в окружении. - -Watchdog и таймеры watchdog **не изменяются** при обычном update (кроме синхронизации скрипта watchdog из git). - -**Конфиг `/etc/ssh-monitor.conf`:** при первой установке копируется из `ssh-monitor.conf.example`. При каждом update в **существующий** конфиг **дописываются** отсутствующие SAC-переменные и ключи безопасности; **`UseSAC=fallback`**. Telegram/SMTP и прочие ключи **не перезаписываются**. - -Установка монитора и unit-файлов: [README.md](../README.md), зависимости: [install-prerequisites.ru.md](install-prerequisites.ru.md). - -## Что делает скрипт - -1. Создаёт рабочий каталог **`UPDATE_DIR`** (по умолчанию `/opt/scripts/update`). -2. При **`--deploy`** / bootstrap SAC проверяет **`ipset`** и при отсутствии пытается установить через пакетный менеджер. Обычное обновление **не** вызывает `apt-get`. -3. В **`UPDATE_DIR`** выполняет **`git clone`** или **`git pull`** репозитория. -4. Ищет **`ssh-monitor`** в клоне (GitLab layout или плоский репозиторий). -5. Сравнивает **SHA256** с **`LOCAL_SCRIPT_PATH`** (`/usr/local/bin/ssh-monitor`). -6. **Всегда** синхронизирует **`sac-client.sh`**, **`ssh-monitor-perms.sh`**, watchdog. -7. Дописывает ключи конфига; **`apply_runtime_security_hardening`**: `chmod 600` конфиг, `700` spool, `600` state/SAC-файлы, whitelist. -8. При изменении **`ssh-monitor`**, **`sac-client.sh`** или конфига — **`systemctl restart ssh-monitor.service`**. -9. Лог: **`/var/log/update_script.log`** (блок «Итог обновления», строка `security hardening`). - -```mermaid -flowchart LR - A[SAC или cron] --> B[update_ssh_monitor.sh] - B --> C[git pull/clone] - C --> D[sync sac-client + perms lib] - C --> E{SHA256 ssh-monitor?} - D --> F[harden config/spool/state] - E -->|да| G[backup + cp ssh-monitor] - E -->|нет| F - G --> F - F --> H{что-то изменилось?} - H -->|да| I[restart ssh-monitor.service] - H -->|нет| J[без перезапуска] -``` - -## Настройка перед первым запуском - -| Переменная | По умолчанию | Назначение | -|------------|--------------|------------| -| `UPDATE_DIR` | `/opt/scripts/update` | Каталог для git-клона | -| `REPO_URL` | *(обязательно)* | URL git — задаётся SAC, export или `Environment=` в cron | -| `GIT_BRANCH` | `main` | Ветка для clone/pull | -| `LOCAL_SCRIPT_PATH` | `/usr/local/bin/ssh-monitor` | Куда копировать монитор | -| `LOG_FILE` | `/var/log/update_script.log` | Лог обновлений | -| `UPDATE_VIA_SAC` | *(опционально)* | SAC выставляет `1`; при отсутствии бинарника включается `--deploy` | - -Требования: **root**, **git**, доступ к репозиторию. - -## Первичная установка (--deploy) - -```bash -sudo REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git ./update_ssh_monitor.sh --deploy -# или first_deploy.sh с REPO_URL в окружении -``` - -Через SAC bootstrap выполняется автоматически с **`--deploy`**. - -## Установка updater (если не через SAC bootstrap) - -```bash -sudo mkdir -p /opt/scripts -sudo cp update_ssh_monitor.sh /opt/scripts/update_ssh_monitor.sh -sudo chmod 750 /opt/scripts/update_ssh_monitor.sh -``` - -Проверка: - -```bash -sudo REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git /opt/scripts/update_ssh_monitor.sh -sudo tail -30 /var/log/update_script.log -``` - -## Запуск по расписанию (опционально) - -### Cron - -``` -0 4 * * * REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git /opt/scripts/update_ssh_monitor.sh -``` - -### systemd timer - -См. предыдущие примеры `ssh-monitor-update.service` / `.timer` с `Environment=REPO_URL=...`. - -## Откат - -```bash -ls -la /usr/local/bin/ssh-monitor.backup.* -sudo cp /usr/local/bin/ssh-monitor.backup.YYYYMMDD_HHMMSS /usr/local/bin/ssh-monitor -sudo systemctl restart ssh-monitor.service -``` - -## Ограничения - -- Секреты (`SAC_API_KEY`, Telegram) SAC **не** прописывает — только в `/etc/ssh-monitor.conf` на хосте. -- При недоступном git **`git pull`** пишет предупреждение и продолжает с локальной копией. -- **`REPO_URL`** без дефолта в скрипте — только доверенное зеркало (kalinamall / своё Gitea). +# Автообновление ssh-monitor + +Скрипт **`update_ssh_monitor.sh`** обновляет **`/usr/local/bin/ssh-monitor`** из git и перезапускает systemd-сервис. + +## Основной путь: Security Alert Center + +На продакшен-хостах обновление идёт **через SAC**, без cron: + +1. Админ нажимает **«Обновить ssh-monitor (SSH)»** в карточке хоста. +2. SAC по SSH запускает `/opt/scripts/update_ssh_monitor.sh` с `REPO_URL` / `GIT_BRANCH` из **Настройки → Обновления агентов**. +3. Updater сам выполняет `git pull`, синхронизирует `sac-client.sh` и watchdog, дописывает отсутствующие ключи в **`/etc/ssh-monitor.conf`**, **выставляет `chmod`/`chown`** на конфиг, SAC-spool, счётчики и state-файлы, перезапускает **`ssh-monitor.service`**. + +Участие админа на сервере **не требуется** (кроме первичной настройки `SAC_URL` / `SAC_API_KEY` в конфиге). + +При **первой установке** (нет updater на хосте) SAC выполняет bootstrap с **`--deploy`**: clone, ipset при необходимости, systemd unit, полный hardening. + +**Cron / systemd timer** — опциональная альтернатива с тем же скриптом и обязательным `REPO_URL` в окружении. + +Watchdog и таймеры watchdog **не изменяются** при обычном update (кроме синхронизации скрипта watchdog из git). + +**Конфиг `/etc/ssh-monitor.conf`:** при первой установке копируется из `ssh-monitor.conf.example`. При каждом update в **существующий** конфиг **дописываются** отсутствующие SAC-переменные и ключи безопасности; **`UseSAC=fallback`**. Telegram/SMTP и прочие ключи **не перезаписываются**. + +Установка монитора и unit-файлов: [README.md](../README.md), зависимости: [install-prerequisites.ru.md](install-prerequisites.ru.md). + +## Что делает скрипт + +1. Создаёт рабочий каталог **`UPDATE_DIR`** (по умолчанию `/opt/scripts/update`). +2. При **`--deploy`** / bootstrap SAC проверяет **`ipset`** и при отсутствии пытается установить через пакетный менеджер. Обычное обновление **не** вызывает `apt-get`. +3. В **`UPDATE_DIR`** выполняет **`git clone`** или **`git fetch` + checkout / `git pull --ff-only`** (без слепого `reset --hard`). +4. Проверяет **`release/manifest-VERSION.json`** (SHA256 файлов + commit) **до** копирования в `/usr/local/bin`. +5. Ищет **`ssh-monitor`** в клоне (GitLab layout или плоский репозиторий). +6. Сравнивает **SHA256** с **`LOCAL_SCRIPT_PATH`** (`/usr/local/bin/ssh-monitor`). +7. **Всегда** синхронизирует **`sac-client.sh`**, **`ssh-monitor-perms.sh`**, watchdog. +8. Дописывает ключи конфига; **`apply_runtime_security_hardening`**: `chmod 600` конфиг, `700` spool, `600` state/SAC-файлы, whitelist. +9. При изменении **`ssh-monitor`**, **`sac-client.sh`** или конфига — **`systemctl restart ssh-monitor.service`**. +10. Лог: **`/var/log/update_script.log`** (блок «Итог обновления», строки `manifest` и `security hardening`). + +```mermaid +flowchart LR + A[SAC или cron] --> B[update_ssh_monitor.sh] + B --> C[git fetch/checkout] + C --> M{manifest OK?} + M -->|нет| X[exit 1] + M -->|да/skip| D[sync sac-client + perms lib] + C --> E{SHA256 ssh-monitor?} + D --> F[harden config/spool/state] + E -->|да| G[backup + cp ssh-monitor] + E -->|нет| F + G --> F + F --> H{что-то изменилось?} + H -->|да| I[restart ssh-monitor.service] + H -->|нет| J[без перезапуска] +``` + +## Настройка перед первым запуском + +| Переменная | По умолчанию | Назначение | +|------------|--------------|------------| +| `UPDATE_DIR` | `/opt/scripts/update` | Каталог для git-клона | +| `REPO_URL` | *(обязательно)* | URL git — задаётся SAC, export или `Environment=` в cron | +| `GIT_BRANCH` | `main` | Ветка для clone/pull, если **`GIT_REF`** не задан | +| `GIT_REF` | *(пусто)* | Тег (`2.2.0-SAC`), ветка или commit SHA для checkout | +| `GIT_VERIFY_MODE` | `off` | `off` \| `tag` \| `commit` — см. раздел ниже | +| `GIT_ALLOW_RESET` | `0` | `1` = разрешить `git reset --hard` при расхождении истории (риск) | +| `RELEASE_MANIFEST_STRICT` | `0` | `1` = exit 1, если manifest для версии не найден | +| `LOCAL_SCRIPT_PATH` | `/usr/local/bin/ssh-monitor` | Куда копировать монитор | +| `LOG_FILE` | `/var/log/update_script.log` | Лог обновлений | +| `UPDATE_VIA_SAC` | *(опционально)* | SAC выставляет `1`; при отсутствии бинарника включается `--deploy` | + +Требования: **root**, **git**, **python3** (manifest verify), доступ к репозиторию. + +## Pinned ref и manifest (2.2.0+) + +Updater читает **`GIT_REF`**, **`GIT_VERIFY_MODE`**, **`GIT_ALLOW_RESET`** из env или `/etc/ssh-monitor.conf`. + +### Режимы `GIT_VERIFY_MODE` + +| Режим | Поведение | Когда | +|-------|-----------|-------| +| **`off`** | После fetch принять ref как есть | Тест, legacy; дефолт в скрипте | +| **`tag`** | `GIT_REF` должен существовать как тег | **Рекомендуется для прода** | +| **`commit`** | `GIT_REF` = полный SHA commit | Жёсткий pin без тегов | + +Если **`GIT_REF`** пуст, используется **`GIT_BRANCH`** (`git pull --ff-only`). При невозможности fast-forward updater **не** делает `reset --hard` (только с **`GIT_ALLOW_RESET=1`**). + +### Release manifest + +В репозитории: **`release/manifest-VERSION.json`** — SHA256 ключевых файлов и `git_commit`. Updater сверяет manifest **после** git sync и **до** копирования бинарников. + +- Файл найден и hash не совпал → **exit 1**, ничего не перезаписывается. +- Файл не найден → WARN, update продолжается (или exit 1 при **`RELEASE_MANIFEST_STRICT=1`**). +- Копирование **`update_ssh_monitor.sh`** в `/opt/scripts/` и re-exec — только если manifest для версии был и прошёл verify. + +Генерация при релизе (после **финального** коммита кода, чтобы `git_commit` в manifest совпал с HEAD): + +```bash +git commit -m "release: 2.2.0-SAC" +./scripts/build-release-manifest.sh 2.2.0-SAC +git add release/manifest-2.2.0-SAC.json && git commit -m "release: manifest 2.2.0-SAC" +git tag 2.2.0-SAC +``` + +### Примеры для прода + +**SAC / env на хосте** (через настройки SAC или unit): + +```bash +REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git +GIT_REF=2.2.0-SAC +GIT_VERIFY_MODE=tag +``` + +**systemd timer** (`/etc/systemd/system/ssh-monitor-update.service`): + +```ini +[Service] +Type=oneshot +Environment=REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git +Environment=GIT_REF=2.2.0-SAC +Environment=GIT_VERIFY_MODE=tag +ExecStart=/opt/scripts/update_ssh_monitor.sh +``` + +При доверенном закрытом зеркале **manifest + `GIT_REF=tag`** — достаточный минимум; GPG-подписи тегов — backlog (см. [security-roadmap.ru.md](security-roadmap.ru.md)). + +## Первичная установка (--deploy) + +```bash +sudo REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git ./update_ssh_monitor.sh --deploy +# или first_deploy.sh с REPO_URL в окружении +``` + +Через SAC bootstrap выполняется автоматически с **`--deploy`**. + +## Установка updater (если не через SAC bootstrap) + +```bash +sudo mkdir -p /opt/scripts +sudo cp update_ssh_monitor.sh /opt/scripts/update_ssh_monitor.sh +sudo chmod 750 /opt/scripts/update_ssh_monitor.sh +``` + +Проверка: + +```bash +sudo REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git /opt/scripts/update_ssh_monitor.sh +sudo tail -30 /var/log/update_script.log +``` + +## Запуск по расписанию (опционально) + +### Cron + +``` +0 4 * * * REPO_URL=https://git.kalinamall.ru/PapaTramp/ssh-monitor.git /opt/scripts/update_ssh_monitor.sh +``` + +### systemd timer + +См. предыдущие примеры `ssh-monitor-update.service` / `.timer` с `Environment=REPO_URL=...`. + +## Откат + +```bash +ls -la /usr/local/bin/ssh-monitor.backup.* +sudo cp /usr/local/bin/ssh-monitor.backup.YYYYMMDD_HHMMSS /usr/local/bin/ssh-monitor +sudo systemctl restart ssh-monitor.service +``` + +## Ограничения + +- Секреты (`SAC_API_KEY`, Telegram) SAC **не** прописывает — только в `/etc/ssh-monitor.conf` на хосте. +- При недоступном git **`git pull`** пишет предупреждение и продолжает с локальной копией. +- **`REPO_URL`** без дефолта в скрипте — только доверенное зеркало (kalinamall / своё Gitea). diff --git a/docs/security-roadmap.ru.md b/docs/security-roadmap.ru.md index b8ea6a2..4ff6504 100644 --- a/docs/security-roadmap.ru.md +++ b/docs/security-roadmap.ru.md @@ -25,12 +25,13 @@ | **2.1.7-SAC** | Фаза 1 + выбранное из Фазы 4 | Права, `REPO_URL` обязателен, webhook, docs | ✅ реализовано | | **2.1.8-SAC** | Дополнение к 1.1 | SAC-first update: hardening прав на каждом прогоне updater | ✅ реализовано | | **2.1.9-SAC** | Дополнение к 1.x | Watchdog: `🖥️ Сервер:` в Telegram (как у агента) | ✅ реализовано | -| **2.2.0-SAC** | **Фаза 2** | Manifest, pinned ref, без слепого `reset --hard` | **следующий релиз** | +| **2.2.1-SAC** | Дополнение к 2.2.0 | Подавление lifecycle/watchdog TG при SAC-update | ✅ реализовано | +| **2.2.0-SAC** | **Фаза 2** | Manifest, pinned ref, без слепого `reset --hard` | ✅ реализовано | | **2.3.0-SAC** | Фаза 3 | Парсер конфига без `source` (minor breaking) | Bump: `ssh-monitor` (`SSH_MONITOR_VERSION`) + `version.txt` в каждом релизе. -**Текущий статус (2026-07-08):** фаза 1 закрыта (2.1.7–2.1.9). **Дальше — фаза 2 → релиз 2.2.0-SAC.** +**Текущий статус (2026-07-08):** фаза 2 закрыта (2.2.0-SAC). **Дальше — фаза 3 → релиз 2.3.0-SAC.** Backlog по ingest при массовом update (SAC + ops): [ingest-mass-update-backlog.ru.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/ingest-mass-update-backlog.ru.md) (отдельный ToDo, не блокирует 2.2.0). --- @@ -107,23 +108,23 @@ Backlog по ingest при массовом update (SAC + ops): [ingest-mass-upd | **`tag`** | `GIT_REF` должен быть **аннотированным тегом**; опционально `git tag -v` (см. 2.4) | **Рекомендуется для прода** | | **`commit`** | `GIT_REF` = полный SHA; сверка с manifest | Жёсткий pin без тегов | -- [ ] Реализовать чтение `GIT_REF` / `GIT_VERIFY_MODE` в `update_ssh_monitor.sh` -- [ ] Дефолты для 2.2.0: `GIT_REF` = тег из `version.txt` или `main` (утвердить завтра); `GIT_VERIFY_MODE=tag` для prod-документации -- [ ] **`docs/auto-update.ru.md`**: отдельный раздел с примерами systemd и таблицей режимов (после реализации) +- [x] Реализовать чтение `GIT_REF` / `GIT_VERIFY_MODE` в `update_ssh_monitor.sh` +- [x] Дефолты для 2.2.0: `GIT_REF` пуст → `GIT_BRANCH`; `GIT_VERIFY_MODE=off` в скрипте, **`tag`** в prod-документации +- [x] **`docs/auto-update.ru.md`**: отдельный раздел с примерами systemd и таблицей режимов --- ### 2.2 Без слепого `reset --hard` (C2) — **утверждено** -- [ ] При failed `git pull --ff-only`: **не** делать `reset --hard` автоматически -- [ ] Лог + stderr: «история разошлась, требуется ручное вмешательство или новый clone» -- [ ] Опционально: `GIT_ALLOW_RESET=1` только для ручного/CI (документировать риск) +- [x] При failed `git pull --ff-only`: **не** делать `reset --hard` автоматически +- [x] Лог + stderr: «история разошлась, требуется ручное вмешательство или новый clone» +- [x] Опционально: `GIT_ALLOW_RESET=1` только для ручного/CI (документировать риск) --- ### 2.3 Release manifest (C2, C4) — **утверждено** -- [ ] Файл в репо, например `release/manifest-2.2.0-SAC.json`: +- [x] Файл в репо, например `release/manifest-2.2.0-SAC.json`: ```json { @@ -138,10 +139,10 @@ Backlog по ingest при массовом update (SAC + ops): [ingest-mass-upd } ``` -- [ ] Публиковать manifest при каждом релизе (в git, рядом с тегом) -- [ ] Updater: после checkout сверять SHA256 файлов из клона с manifest **до** копирования в `/usr/local/bin` -- [ ] Несовпадение → exit 1, ничего не перезаписывать -- [ ] Скрипт/цель в `Makefile` или `scripts/build-release-manifest.sh` для генерации +- [x] Публиковать manifest при каждом релизе (в git, рядом с тегом) +- [x] Updater: после checkout сверять SHA256 файлов из клона с manifest **до** копирования в `/usr/local/bin` +- [x] Несовпадение → exit 1, ничего не перезаписывать +- [x] Скрипт/цель в `Makefile` или `scripts/build-release-manifest.sh` для генерации **Связь с 2.4:** manifest из **того же** `REPO_URL` защищает от подмены файлов в clone и от «не того» коммита; не защищает от полной компрометации git-сервера (для этого GPG). @@ -159,16 +160,16 @@ Backlog по ingest при массовом update (SAC + ops): [ingest-mass-upd **Решение для roadmap:** -- [ ] **2.2.0:** не блокировать релиз на GPG; `GIT_VERIFY_MODE=tag` без `-v` +- [x] **2.2.0:** не блокировать релиз на GPG; `GIT_VERIFY_MODE=tag` без `-v` - [ ] **Backlog:** `GIT_GPG_VERIFY=1` + ключ в `/etc/ssh-monitor/trusted-release-key.asc`, если позже понадобится -- [ ] Документировать: при доверенном закрытом зеркале manifest + `GIT_REF=tag` — **достаточный минимум** +- [x] Документировать: при доверенном закрытом зеркале manifest + `GIT_REF=tag` — **достаточный минимум** --- ### 2.5 Re-exec updater только после verify (C4) — **утверждено** -- [ ] `UPDATER_REEXEC` / копирование `update_ssh_monitor.sh` в `/opt/scripts/` — только если manifest SHA256 совпал -- [ ] Иначе: лог, старая версия updater остаётся, exit 1 +- [x] `UPDATER_REEXEC` / копирование `update_ssh_monitor.sh` в `/opt/scripts/` — только если manifest SHA256 совпал +- [x] Иначе: лог, старая версия updater остаётся, exit 1 --- @@ -216,12 +217,12 @@ Backlog по ingest при массовом update (SAC + ops): [ingest-mass-upd |-------|--------|---------| | **4.4** `ensure_ipset_installed` не на каждый update | 2.1.7 | **Делаем:** только `--deploy` / `first_deploy.sh`; обычный update не вызывает `apt-get` | ✅ | | **4.3** Права whitelist-файла | 2.1.7 | **Делаем:** при загрузке `/etc/ssh_monitor_whitelist.txt` — WARN если не root:root | ✅ | -| **4.5** JSON healthcheck через `json.dumps` | 2.2.0 | **Делаем:** мелкий fix L3 | +| **4.5** JSON healthcheck через `json.dumps` | 2.2.0 | **Делаем:** мелкий fix L3 | ✅ | | **4.1** Секреты не через environ в Python | — | **Не делаем** (мало выигрыша при root) | | **4.2** Telegram token в URL | — | **Не делаем** (ограничение Bot API) | | **4.6** systemd hardening (non-root) | — | **Не делаем** (ipset/iptables требуют root) | | **M6** Ротация логов | — | Уже есть `contrib/logrotate.d/` — только упоминание в README | -| **M7** `set_config_kv` sed | 2.2.0 | **Делаем:** экранирование `/` и `"` в `val` при merge конфига | +| **M7** `set_config_kv` sed | 2.2.0 | **Делаем:** экранирование `/` и `"` в `val` при merge конфига | ✅ | --- @@ -237,7 +238,7 @@ Backlog по ingest при массовом update (SAC + ops): [ingest-mass-upd ## Чек-лист «перед стартом работы завтра» - [x] **Релиз 2.1.7–2.1.9** — фаза 1 + SAC-first + watchdog server label -- [ ] **Релиз 2.2.0** — scope: 2.1–2.3, 2.5, 4.5, 4.7(M7); **2.4 GPG отложить** +- [x] **Релиз 2.2.0** — scope: 2.1–2.3, 2.5, 4.5, 4.7(M7); **2.4 GPG отложить** - [ ] **Релиз 2.3.0** — scope: 3.1–3.3, удаление webhook (если A) - [ ] **Дефолт `GIT_REF`** в 2.2.0: тег из `version.txt` vs `main` - [x] **Миграция хостов:** SAC-first update, `known_hosts`, `REPO_URL` через SAC diff --git a/release/manifest-2.2.0-SAC.json b/release/manifest-2.2.0-SAC.json new file mode 100644 index 0000000..8ac9e06 --- /dev/null +++ b/release/manifest-2.2.0-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.2.0-SAC", + "git_commit": "275d305d627b462cae141605a002002525aa474c", + "files": { + "ssh-monitor": "sha256:517e4a210f271f57717b77746352ce2c60579cf89958c1c36501951901b885d0", + "sac-client.sh": "sha256:ffcbb0ed28e4247ccb5e74dd6e77d020f93eab8a17f9716e8913a17bc899274b", + "update_ssh_monitor.sh": "sha256:8108dd703095d54d51070933f074c90805f383967a3d2a6d63825898ad483231", + "ssh-monitor-watchdog": "sha256:5299bdcb481e7ca1d1042579d5c1ad8b13f3f72c1a6e922bcffd070b6c30ec80", + "ssh-monitor-perms.sh": "sha256:c67b56060141f67c651906b31afc220a1040c151fb00f60cc5f2511d6664dc11" + } +} diff --git a/release/manifest-2.2.1-SAC.json b/release/manifest-2.2.1-SAC.json new file mode 100644 index 0000000..06ffc71 --- /dev/null +++ b/release/manifest-2.2.1-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.2.1-SAC", + "git_commit": "", + "files": { + "ssh-monitor": "sha256:9fa2eaadceffe70358d83029668cc9bdc423697626f1c84a62552901490adcd9", + "sac-client.sh": "sha256:ffcbb0ed28e4247ccb5e74dd6e77d020f93eab8a17f9716e8913a17bc899274b", + "update_ssh_monitor.sh": "sha256:5e27e3514532500b84d8eca67bb968ee1dde365564ce4c3710422c43e090dc72", + "ssh-monitor-watchdog": "sha256:d1e786247433b6340a8fd2f1578abb2933149fc328f7d0dd7dcfd2319b94dedd", + "ssh-monitor-perms.sh": "sha256:86c919032294d47a4daf6681fd6e527babfacf778340dd4c0bcb33f63e5d063e" + } +} diff --git a/scripts/build-release-manifest.sh b/scripts/build-release-manifest.sh new file mode 100644 index 0000000..70abf72 --- /dev/null +++ b/scripts/build-release-manifest.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Генерация release/manifest-VERSION.json (SHA256 файлов + git commit HEAD). +# Запуск из корня репозитория: ./scripts/build-release-manifest.sh [VERSION] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +version="${1:-}" +if [ -z "$version" ]; then + version="$(tr -d '\r\n' /dev/null || true)" +fi +if [ -z "$version" ]; then + version="$(grep -E '^[[:space:]]*SSH_MONITOR_VERSION=' ssh-monitor 2>/dev/null | head -1 | sed -E 's/^[^=]*=//; s/^["'\'']//; s/["'\'']$//; s/^[[:space:]]+//; s/[[:space:]]+$//')" +fi +[ -n "$version" ] || { + echo "ERROR: укажите VERSION или заполните version.txt" >&2 + exit 1 +} + +FILES=( + ssh-monitor + sac-client.sh + update_ssh_monitor.sh + ssh-monitor-watchdog + ssh-monitor-perms.sh +) + +for f in "${FILES[@]}"; do + [ -f "$f" ] || { + echo "ERROR: нет файла $f" >&2 + exit 1 + } +done + +git_commit="" +if git rev-parse HEAD >/dev/null 2>&1; then + git_commit="$(git rev-parse HEAD)" +fi + +out="release/manifest-${version}.json" +mkdir -p release + +export MANIFEST_ROOT="$ROOT" +export MANIFEST_VERSION="$version" +export MANIFEST_GIT_COMMIT="$git_commit" +export MANIFEST_OUT="$out" +export MANIFEST_FILES="${FILES[*]}" + +python3 <<'PY' +import hashlib +import json +import os + +root = os.environ["MANIFEST_ROOT"] +version = os.environ["MANIFEST_VERSION"] +git_commit = os.environ.get("MANIFEST_GIT_COMMIT", "") +files = os.environ["MANIFEST_FILES"].split() +out = os.environ["MANIFEST_OUT"] + +def sha256_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + +manifest = { + "version": version, + "git_commit": git_commit, + "files": {}, +} +for name in files: + path = os.path.join(root, name) + manifest["files"][name] = f"sha256:{sha256_file(path)}" + +with open(out, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + f.write("\n") + +print(f"Wrote {out} commit={git_commit[:12] if git_commit else '?'}") +PY diff --git a/ssh-monitor b/ssh-monitor index 54049a1..abf616e 100644 --- a/ssh-monitor +++ b/ssh-monitor @@ -7,7 +7,7 @@ IFS=$'\n\t' # ============================================ CONFIG_FILE="/etc/ssh-monitor.conf" -SSH_MONITOR_VERSION="2.1.9-SAC" +SSH_MONITOR_VERSION="2.2.1-SAC" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [ -f "$SCRIPT_DIR/ssh-monitor-perms.sh" ]; then @@ -2018,10 +2018,15 @@ write_health_and_metrics() { ts=$(date '+%s') if [ -n "${HEALTHCHECK_STATUS_FILE:-}" ]; then ssh_monitor_secure_dir "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || mkdir -p "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || true - printf '{"ts":%s,"hostname":"%s","dry_run":%s}\n' "$ts" "$(hostname | sed 's/"/\\"/g')" "$([[ "${DRY_RUN:-0}" = "1" ]] && echo true || echo false)" >"${HEALTHCHECK_STATUS_FILE}.tmp" - ssh_monitor_secure_file "${HEALTHCHECK_STATUS_FILE}.tmp" 2>/dev/null || true - mv "${HEALTHCHECK_STATUS_FILE}.tmp" "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true - ssh_monitor_secure_file "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json,sys; print(json.dumps({"ts": int(sys.argv[1]), "hostname": sys.argv[2], "dry_run": sys.argv[3] == "1"}))' \ + "$ts" "$(hostname)" "${DRY_RUN:-0}" >"${HEALTHCHECK_STATUS_FILE}.tmp" + ssh_monitor_secure_file "${HEALTHCHECK_STATUS_FILE}.tmp" 2>/dev/null || true + mv "${HEALTHCHECK_STATUS_FILE}.tmp" "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true + ssh_monitor_secure_file "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true + else + write_log "WARN: python3 недоступен — HEALTHCHECK_STATUS_FILE пропущен" + fi fi if [ -n "${PROMETHEUS_TEXTFILE_DIR:-}" ]; then mkdir -p "$PROMETHEUS_TEXTFILE_DIR" 2>/dev/null || true @@ -2040,6 +2045,10 @@ write_health_and_metrics() { # ============================================ send_startup_notification() { + if ssh_monitor_update_in_progress 2>/dev/null; then + write_log "lifecycle startup: пропуск (идёт agent-update)" + return 0 + fi local timestamp timestamp=$(notification_date '+%d.%m.%Y %H:%M:%S') local whitelist_count=$((${#WHITELIST_IPS[@]} + ${#WHITELIST_SUBNETS[@]})) @@ -2067,6 +2076,10 @@ send_startup_notification() { send_shutdown_notification() { [ "${SHUTDOWN_NOTIFIED:-0}" = "1" ] && return 0 + if ssh_monitor_update_in_progress 2>/dev/null; then + write_log "lifecycle shutdown: пропуск (идёт agent-update)" + return 0 + fi SHUTDOWN_NOTIFIED=1 local timestamp diff --git a/ssh-monitor-perms.sh b/ssh-monitor-perms.sh index 2bf4d59..b7c440d 100644 --- a/ssh-monitor-perms.sh +++ b/ssh-monitor-perms.sh @@ -171,3 +171,37 @@ ssh_monitor_message_ensure_server_line() { printf '%s\n🖥️ Сервер: %s\n%s' "$first" "$(ssh_monitor_server_display_name_with_ip)" "$rest" fi } + +# Флаг штатного SAC/cron-update: подавляет lifecycle/watchdog Telegram на время прогона updater. +SSH_MONITOR_UPDATE_STATE_DEFAULT="/var/lib/ssh-monitor/agent-update-in-progress" +SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC="${SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC:-900}" + +ssh_monitor_update_state_file() { + printf '%s\n' "${SSH_MONITOR_UPDATE_STATE_FILE:-$SSH_MONITOR_UPDATE_STATE_DEFAULT}" +} + +ssh_monitor_update_in_progress() { + local f ts now age + f="$(ssh_monitor_update_state_file)" + [ -f "$f" ] || return 1 + ts="$(tr -d '\r\n' <"$f" 2>/dev/null || true)" + [[ "$ts" =~ ^[0-9]+$ ]] || return 1 + now=$(date '+%s') + age=$((now - ts)) + [ "$age" -le "$SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC" ] +} + +ssh_monitor_update_mark_begin() { + local f dir + f="$(ssh_monitor_update_state_file)" + dir="$(dirname "$f")" + ssh_monitor_secure_dir "$dir" 2>/dev/null || mkdir -p "$dir" 2>/dev/null || true + date '+%s' >"$f" + ssh_monitor_secure_file "$f" 2>/dev/null || chmod 600 "$f" 2>/dev/null || true +} + +ssh_monitor_update_mark_end() { + local f + f="$(ssh_monitor_update_state_file)" + rm -f "$f" 2>/dev/null || true +} diff --git a/ssh-monitor-watchdog b/ssh-monitor-watchdog index 2c19e53..3d6fa5d 100644 --- a/ssh-monitor-watchdog +++ b/ssh-monitor-watchdog @@ -142,6 +142,11 @@ main() { mkdir -p "$(dirname "$WATCHDOG_LOG_FILE")" touch "$WATCHDOG_LOG_FILE" + if ssh_monitor_update_in_progress 2>/dev/null; then + write_log "Watchdog: пропуск — идёт agent-update (state file)" + exit 0 + fi + local reason="" if ! is_service_active; then reason="systemd reported inactive service" diff --git a/ssh-monitor.conf.example b/ssh-monitor.conf.example index e668c4f..3d685ba 100644 --- a/ssh-monitor.conf.example +++ b/ssh-monitor.conf.example @@ -108,6 +108,14 @@ PROMETHEUS_TEXTFILE_DIR="" # JSON-снимок состояния на каждой итерации (timestamp, hostname, dry_run). Пусто — отключено. HEALTHCHECK_STATUS_FILE="" +# --- Автообновление (update_ssh_monitor.sh; читается updater'ом, не агентом) --- +# Пусто = ветка GIT_BRANCH (main). Для прода рекомендуется тег релиза, напр. 2.2.0-SAC. +# GIT_REF="2.2.0-SAC" +# off | tag | commit — см. docs/auto-update.ru.md. Дефолт в скрипте: off (совместимость). +# GIT_VERIFY_MODE="tag" +# 1 = разрешить git reset --hard при расхождении истории (только ручной/CI; риск). +# GIT_ALLOW_RESET="0" + # Порог устаревания heartbeat для watchdog (сек): интервал + 1 ч запас (240 мин → 18000). WATCHDOG_MAX_HEARTBEAT_AGE="18000" WATCHDOG_LOG_FILE="/var/log/ssh_monitor_watchdog.log" diff --git a/update_ssh_monitor.sh b/update_ssh_monitor.sh index ebcf356..979d462 100755 --- a/update_ssh_monitor.sh +++ b/update_ssh_monitor.sh @@ -5,6 +5,11 @@ UPDATE_DIR="/opt/scripts/update" SCRIPT_NAME="ssh-monitor" REPO_URL="${REPO_URL:-}" GIT_BRANCH="${GIT_BRANCH:-main}" +GIT_REF="${GIT_REF:-}" +GIT_VERIFY_MODE="${GIT_VERIFY_MODE:-off}" +GIT_ALLOW_RESET="${GIT_ALLOW_RESET:-0}" +MANIFEST_VERIFIED=0 +MANIFEST_STRICT=0 LOCAL_SCRIPT_PATH="/usr/local/bin/ssh-monitor" LOG_FILE="/var/log/update_script.log" @@ -24,6 +29,7 @@ SUMMARY_SERVICE_RESTART="—" SUMMARY_UPDATER="—" SUMMARY_WATCHDOG="—" SUMMARY_HARDENING="—" +SUMMARY_MANIFEST="—" CONFIG_FILE="/etc/ssh-monitor.conf" DEPLOY_INSTALL_PATH="/opt/scripts/update_ssh_monitor.sh" @@ -401,11 +407,12 @@ maybe_enable_deploy_for_missing_install() { } set_config_kv() { - local key="$1" val="$2" file="$3" + local key="$1" val="$2" file="$3" esc + esc=$(printf '%s' "$val" | sed -e 's/[\\&/]/\\&/g' -e 's/"/\\"/g') if grep -qE "^[[:space:]]*${key}=" "$file" 2>/dev/null; then - sed -i -E "s/^[[:space:]]*${key}=.*/${key}=\"${val}\"/" "$file" + sed -i -E "s/^[[:space:]]*${key}=.*/${key}=\"${esc}\"/" "$file" else - printf '%s="%s"\n' "$key" "$val" >>"$file" + printf '%s="%s"\n' "$key" "$esc" >>"$file" fi } @@ -619,44 +626,238 @@ resolve_git_remote_name() { printf '%s' "$name" } -# fetch + ff-only pull; при расхождении истории (force-push) — reset --hard. +load_git_verify_settings() { + local v + if [ -f "$CONFIG_FILE" ]; then + if [ -z "${GIT_REF:-}" ]; then + v=$(config_conf_value GIT_REF "$CONFIG_FILE" 2>/dev/null || true) + [ -n "$v" ] && GIT_REF="$v" + fi + if [ -z "${GIT_VERIFY_MODE:-}" ] || [ "$GIT_VERIFY_MODE" = "off" ]; then + v=$(config_conf_value GIT_VERIFY_MODE "$CONFIG_FILE" 2>/dev/null || true) + [ -n "$v" ] && GIT_VERIFY_MODE="$v" + fi + if [ "${GIT_ALLOW_RESET:-0}" = "0" ]; then + v=$(config_conf_value GIT_ALLOW_RESET "$CONFIG_FILE" 2>/dev/null || true) + [ -n "$v" ] && GIT_ALLOW_RESET="$v" + fi + fi + GIT_VERIFY_MODE="$(printf '%s' "${GIT_VERIFY_MODE:-off}" | tr '[:upper:]' '[:lower:]')" + case "$GIT_VERIFY_MODE" in + off | tag | commit) ;; + *) + log_action "WARN: GIT_VERIFY_MODE=$GIT_VERIFY_MODE неизвестен — off" + GIT_VERIFY_MODE=off + ;; + esac + case "${GIT_ALLOW_RESET:-0}" in + 1 | yes | true | on) GIT_ALLOW_RESET=1 ;; + *) GIT_ALLOW_RESET=0 ;; + esac + case "${RELEASE_MANIFEST_STRICT:-0}" in + 1 | yes | true | on) MANIFEST_STRICT=1 ;; + *) MANIFEST_STRICT=0 ;; + esac +} + +verify_git_ref() { + local repo="$1" ref="$2" mode="$3" + local obj_type full + + [ "$mode" = "off" ] && return 0 + [ -n "$ref" ] || { + log_action "ERROR: GIT_VERIFY_MODE=$mode требует GIT_REF" + return 1 + } + + case "$mode" in + tag) + if ! git -C "$repo" rev-parse "refs/tags/$ref" >/dev/null 2>&1; then + log_action "ERROR: GIT_REF=$ref не найден как тег в клоне" + return 1 + fi + obj_type=$(git -C "$repo" cat-file -t "refs/tags/$ref" 2>/dev/null || true) + if [ "$obj_type" != "tag" ] && [ "$obj_type" != "commit" ]; then + log_action "ERROR: GIT_REF=$ref: неожиданный тип объекта ($obj_type)" + return 1 + fi + log_action "git verify: тег $ref OK (type=$obj_type)" + ;; + commit) + full=$(git -C "$repo" rev-parse "$ref^{commit}" 2>/dev/null || true) + if [ -z "$full" ] || [ "${#full}" -ne 40 ]; then + log_action "ERROR: GIT_REF=$ref не является полным commit SHA" + return 1 + fi + log_action "git verify: commit ${full:0:12}… OK" + ;; + esac + return 0 +} + +find_release_manifest_path() { + local repo="$1" version="$2" + local p="$repo/release/manifest-${version}.json" + if [ -f "$p" ]; then + printf '%s\n' "$p" + return 0 + fi + return 1 +} + +read_repo_release_version() { + local repo="$1" ver + ver=$(read_ssh_monitor_version "$repo/ssh-monitor") + if [ -z "$ver" ] && [ -f "$repo/version.txt" ]; then + ver=$(tr -d '\r\n' <"$repo/version.txt") + fi + printf '%s' "$ver" +} + +verify_release_manifest() { + local repo="$1" version manifest_path rc + + MANIFEST_VERIFIED=0 + version=$(read_repo_release_version "$repo") + if [ -z "$version" ]; then + SUMMARY_MANIFEST="версия не определена" + log_action "WARN: не удалось определить версию релиза для manifest" + [ "$MANIFEST_STRICT" -eq 1 ] && return 1 + return 0 + fi + + manifest_path=$(find_release_manifest_path "$repo" "$version" 2>/dev/null || true) + if [ -z "$manifest_path" ]; then + SUMMARY_MANIFEST="manifest-${version}.json не найден (skip)" + log_action "WARN: release/manifest-${version}.json не найден — проверка SHA пропущена" + if [ "$MANIFEST_STRICT" -eq 1 ]; then + log_action "ERROR: RELEASE_MANIFEST_STRICT=1 — manifest обязателен" + return 1 + fi + return 0 + fi + + log_action "manifest: проверка $manifest_path" + if ! MANIFEST_PATH="$manifest_path" REPO_DIR="$repo" python3 <<'PY' >>"$LOG_FILE" 2>&1 +import hashlib +import json +import os +import subprocess +import sys + +manifest_path = os.environ["MANIFEST_PATH"] +repo = os.environ["REPO_DIR"] + +with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + +expected_commit = (manifest.get("git_commit") or "").strip() +if expected_commit: + actual = subprocess.check_output(["git", "-C", repo, "rev-parse", "HEAD"], text=True).strip() + if actual != expected_commit: + print(f"commit mismatch: HEAD={actual[:12]} manifest={expected_commit[:12]}", file=sys.stderr) + sys.exit(2) + +files = manifest.get("files") or {} +for rel, spec in files.items(): + path = os.path.join(repo, rel) + if not os.path.isfile(path): + print(f"missing file: {rel}", file=sys.stderr) + sys.exit(3) + want = str(spec).removeprefix("sha256:") + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + got = h.hexdigest() + if got != want: + print(f"sha256 mismatch: {rel}", file=sys.stderr) + sys.exit(4) + +print("OK") +PY + then + SUMMARY_MANIFEST="ОШИБКА verify" + log_action "ERROR: manifest verify failed (см. $LOG_FILE)" + return 1 + fi + + MANIFEST_VERIFIED=1 + SUMMARY_MANIFEST="OK ($version)" + log_action "manifest: verify OK ($version)" + return 0 +} + +# fetch + checkout GIT_REF или fast-forward по GIT_BRANCH; без reset --hard (кроме GIT_ALLOW_RESET=1). sync_git_repository() { local repo="$1" branch="${2:-main}" - local remote upstream git_before git_after + local remote upstream git_before git_after ref="${GIT_REF:-}" remote="$(resolve_git_remote_name "$repo")" upstream="${remote}/${branch}" if [ -f "$repo/.git/MERGE_HEAD" ]; then log_action "git: незавершённый merge — abort" - git -C "$repo" merge --abort >> "$LOG_FILE" 2>&1 || true + git -C "$repo" merge --abort >>"$LOG_FILE" 2>&1 || true fi git_before="$(get_git_short_rev "$repo")" + + if [ -n "$ref" ]; then + log_action "git: fetch $remote (ref=$ref, verify=$GIT_VERIFY_MODE) → $REPO_URL" + if ! git -C "$repo" fetch --prune "$remote" \ + "+refs/heads/*:refs/remotes/${remote}/*" \ + "+refs/tags/*:refs/tags/*" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="fetch FAILED ($git_before)" + log_action "ERROR: git fetch failed (см. $LOG_FILE)" + return 1 + fi + if ! verify_git_ref "$repo" "$ref" "$GIT_VERIFY_MODE"; then + SUMMARY_GIT="verify FAILED ($ref)" + return 1 + fi + if ! git -C "$repo" checkout -f "$ref" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="checkout FAILED ($ref)" + log_action "ERROR: git checkout $ref failed (см. $LOG_FILE)" + return 1 + fi + git_after="$(get_git_short_rev "$repo")" + SUMMARY_GIT="checkout $ref ok, $git_before → $git_after" + log_action "git: checkout $ref OK ($git_before → $git_after)" + return 0 + fi + log_action "git: fetch --prune $remote $branch (было $git_before) → $REPO_URL" - if ! git -C "$repo" fetch --prune "$remote" "$branch" >> "$LOG_FILE" 2>&1; then + if ! git -C "$repo" fetch --prune "$remote" "$branch" >>"$LOG_FILE" 2>&1; then SUMMARY_GIT="fetch FAILED ($git_before)" log_action "ERROR: git fetch failed (см. $LOG_FILE)" return 1 fi - if git -C "$repo" pull --ff-only "$remote" "$branch" >> "$LOG_FILE" 2>&1; then + if git -C "$repo" pull --ff-only "$remote" "$branch" >>"$LOG_FILE" 2>&1; then git_after="$(get_git_short_rev "$repo")" SUMMARY_GIT="pull ok, $git_before → $git_after" log_action "git: fast-forward OK ($git_before → $git_after)" return 0 fi - log_action "WARN: fast-forward невозможен (часто после force-push) — reset --hard $upstream" - if ! git -C "$repo" reset --hard "$upstream" >> "$LOG_FILE" 2>&1; then - SUMMARY_GIT="reset FAILED ($git_before)" - log_action "ERROR: git reset --hard $upstream failed (см. $LOG_FILE)" - return 1 + if [ "$GIT_ALLOW_RESET" -eq 1 ]; then + log_action "WARN: fast-forward невозможен — GIT_ALLOW_RESET=1, reset --hard $upstream" + if ! git -C "$repo" reset --hard "$upstream" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="reset FAILED ($git_before)" + log_action "ERROR: git reset --hard $upstream failed (см. $LOG_FILE)" + return 1 + fi + git_after="$(get_git_short_rev "$repo")" + SUMMARY_GIT="reset ok, $git_before → $git_after" + log_action "git reset --hard: $git_before → $git_after" + return 0 fi - git_after="$(get_git_short_rev "$repo")" - SUMMARY_GIT="reset ok, $git_before → $git_after" - log_action "git reset --hard: $git_before → $git_after" - return 0 + + SUMMARY_GIT="diverged ($git_before)" + log_action "ERROR: git: история разошлась (ff-only невозможен). Ручной clone или GIT_ALLOW_RESET=1" + echo "ERROR: git pull --ff-only failed; история разошлась. Исправьте клон в $repo или задайте GIT_ALLOW_RESET=1 (риск)." >&2 + return 1 } read_ssh_monitor_version() { @@ -705,6 +906,7 @@ log_update_summary() { log_action "updater ($DEPLOY_INSTALL_PATH): $SUMMARY_UPDATER" log_action "watchdog: $SUMMARY_WATCHDOG" log_action "security hardening: $SUMMARY_HARDENING" + log_action "manifest: $SUMMARY_MANIFEST" log_action "перезапуск ssh-monitor.service: $SUMMARY_SERVICE_RESTART" log_action "полный лог: $LOG_FILE" log_action "===========================" @@ -1270,6 +1472,7 @@ deploy_post_install() { sync_installed_updater_from_clone() { local remote="$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" local self installed_path remote_sum installed_sum="" + local repo="$UPDATE_DIR/$SCRIPT_NAME" ver manifest_path="" if [ ! -f "$remote" ]; then SUMMARY_UPDATER="не найден в клоне" @@ -1277,6 +1480,16 @@ sync_installed_updater_from_clone() { return 0 fi + ver=$(read_repo_release_version "$repo") + if [ -n "$ver" ]; then + manifest_path=$(find_release_manifest_path "$repo" "$ver" 2>/dev/null || true) + if [ -n "$manifest_path" ] && [ "${MANIFEST_VERIFIED:-0}" != "1" ]; then + SUMMARY_UPDATER="manifest не verified" + log_action "ERROR: updater: $manifest_path не verified — копирование пропущено" + return 1 + fi + 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") @@ -1318,6 +1531,14 @@ restart_ssh_monitor_service() { SUMMARY_SERVICE_RESTART="выполнен" log_action "ssh-monitor.service перезапущен" debug_step "Сервис успешно перезапущен" + local i + for i in $(seq 1 30); do + if systemctl is-active --quiet ssh-monitor.service 2>/dev/null; then + sleep 2 + break + fi + sleep 1 + done else SUMMARY_SERVICE_RESTART="не удался" log_action "WARNING: systemctl restart ssh-monitor.service не выполнен" @@ -1344,9 +1565,16 @@ update_script() { SUMMARY_CONFIG="—" SUMMARY_GIT="—" SUMMARY_HARDENING="—" + SUMMARY_MANIFEST="—" SUMMARY_SERVICE_RESTART="не требовался" - log_action "Старт обновления (deploy=$DEPLOY_MODE, debug=$DEBUG)" + load_git_verify_settings + load_ssh_monitor_perms_lib || true + if declare -F ssh_monitor_update_mark_begin >/dev/null 2>&1; then + ssh_monitor_update_mark_begin + log_action "agent-update: state file установлен (подавление lifecycle/watchdog)" + fi + log_action "Старт обновления (deploy=$DEPLOY_MODE, debug=$DEBUG, git_ref=${GIT_REF:-$GIT_BRANCH})" debug_step "Запуск функции обновления скрипта" debug_step "Гарантируем наличие директории для работы" @@ -1375,9 +1603,10 @@ update_script() { log_action "WARNING: git sync не удался — remote в клоне может быть устаревшим" fi else - log_action "git: clone $REPO_URL → $UPDATE_DIR/$SCRIPT_NAME" - debug_step "Выполняем git clone $REPO_URL $SCRIPT_NAME" - if ! git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME" >> "$LOG_FILE" 2>&1; then + local clone_ref="${GIT_REF:-$GIT_BRANCH}" + log_action "git: clone -b $clone_ref $REPO_URL → $UPDATE_DIR/$SCRIPT_NAME" + debug_step "Выполняем git clone -b $clone_ref $REPO_URL $SCRIPT_NAME" + if ! git clone -b "$clone_ref" "$REPO_URL" "$SCRIPT_NAME" >>"$LOG_FILE" 2>&1; then SUMMARY_GIT="clone FAILED" SUMMARY_SCRIPT="ошибка" SUMMARY_SCRIPT_REASON="git clone failed" @@ -1385,11 +1614,25 @@ update_script() { log_update_summary return 1 fi + if [ -n "${GIT_REF:-}" ] && ! verify_git_ref "$git_repo" "$GIT_REF" "$GIT_VERIFY_MODE"; then + SUMMARY_GIT="verify FAILED ($GIT_REF)" + SUMMARY_SCRIPT="ошибка" + SUMMARY_SCRIPT_REASON="GIT_REF verify failed" + log_update_summary + return 1 + fi git_after="$(get_git_short_rev "$git_repo")" SUMMARY_GIT="clone ok, commit $git_after" log_action "git clone: успех (commit $git_after)" fi + if ! verify_release_manifest "$git_repo"; then + SUMMARY_SCRIPT="ошибка" + SUMMARY_SCRIPT_REASON="manifest verify failed" + log_update_summary + return 1 + fi + sync_installed_updater_from_clone "$@" actual_script_path="$(find_remote_ssh_monitor_path 2>/dev/null || true)" @@ -1534,6 +1777,13 @@ fi maybe_enable_deploy_for_missing_install +cleanup_update_state() { + if declare -F ssh_monitor_update_mark_end >/dev/null 2>&1; then + ssh_monitor_update_mark_end + fi +} +trap cleanup_update_state EXIT + debug_step "Начинаем проверку и установку ipset (только --deploy / first_deploy)" if [ "$DEPLOY_MODE" -eq 1 ]; then if ! ensure_ipset_installed; then diff --git a/version.txt b/version.txt index 4f49bf9..392f1b0 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.1.9-SAC +2.2.1-SAC