Compare commits

..

2 Commits

Author SHA1 Message Date
604f0c705f Update container documentation to reflect disk space adjustments and Docker log management
Expand the root disk size from 35 GB to 50 GB and implement log size limits for Docker containers. Add details about the new monitoring dashboard for homelab services, including deployment instructions and access URL. Ensure clarity on log rotation policies and risks associated with disk space usage.
2026-02-28 17:10:34 +03:00
53769e6832 Update architecture and backup documentation to include Healthchecks integration
Add Healthchecks service details to architecture and backup documentation, including its role as a Dead man's switch for backups. Update backup scripts to utilize systemd timers instead of cron for improved scheduling. Enhance network topology documentation to reflect Healthchecks integration in the VPS Miran setup. This update clarifies backup processes and enhances overall system reliability.
2026-02-28 15:43:39 +03:00
69 changed files with 2380 additions and 49 deletions

View File

@@ -24,6 +24,7 @@
| cloud.katykhin.ru | — |
| docs.katykhin.ru | — |
| git.katykhin.ru | — |
| healthchecks.katykhin.ru | Healthchecks (Dead man's switch для бэкапов; на VPS Миран) |
| home.katykhin.ru | Homepage |
| immich.katykhin.ru | — |
| mini-lm.katykhin.ru | — |
@@ -94,8 +95,9 @@ pct create 105 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst \
## Дополнительно
- **Хост Proxmox:** скрипты, таймеры, пути — [host-proxmox.md](../containers/host-proxmox.md).
- **Схема сети и зависимости:** полная топология (роутер, Proxmox, контейнеры, VPS), таблица IP/доменов, маршруты NPM, кто от кого зависит, единые точки отказа (SPOF). → [Схема сети и зависимости](../network/network-topology.md).
- **Homepage:** на контейнере 103, конфиг сервисов в `/opt/docker/homepage/config/services.yaml` (ссылки на NPM, Invidious, AdGuard, Immich, Galene, Vaultwarden и т.д.).
- **Homepage:** на контейнере 103, конфиг сервисов в `/opt/docker/homepage/config/services.yaml` (ссылки на NPM, Invidious, AdGuard, Immich, Galene, Vaultwarden, Healthchecks, Netdata и т.д.).
- **VPN (VPS):** отдельный сервер 185.103.253.99, AmneziaWG для обхода блокировок. → [VPN-сервер (VPS, AmneziaWG)](../vps/vpn-vps-amneziawg.md).
- **Роутер:** Netcraze Speedster, два WireGuard/AmneziaWG (Германия / США), маршрутизация части трафика через VPN. → [Роутер Netcraze Speedster](../network/router-netcraze-speedster.md).
- **VPS Миран (СПБ):** боты (telegram-helper-bot, anonBot), prod-инфраструктура, STUN/TURN для Galene. → [VPS Миран: боты и STUN/TURN](../vps/vps-miran-bots.md).

View File

@@ -8,6 +8,16 @@
Все локальные бэкапы лежат на отдельном диске хоста Proxmox: **/dev/sdb1**, смонтирован в **/mnt/backup**.
### Карта дисков (Proxmox host)
| Устройство | Тип | Размер | Использование |
|--------------|------|--------|----------------------------------|
| /dev/nvme0n1 | NVMe | 256 GB | LVM (система, local-lvm) |
| /dev/sda | HDD | 2 TB | ZFS |
| /dev/sdb | SSD | 2 TB | ext4, /mnt/backup |
| /dev/sdc | HDD | 2 TB | ZFS (RAID1 с sda) |
| /dev/sdd | HDD | 8 TB | ext4 (внешний) |
```
/mnt/backup/
├── proxmox/
@@ -36,21 +46,21 @@
| Что | Откуда | Куда (локально) | Когда | Хранение | Уведомление |
|-----|--------|------------------|------|----------|--------------|
| **VPS Миран (telegram-helper-bot)** | VPS 185.147.80.190: БД `tg-bot-database.db`, каталог `voice_users`, бакет S3 (Miran) | `/mnt/backup/vps/miran/` (db/, voice_users/, s3/) | **01:00** (cron: `backup-vps-miran.sh`) | БД: 14 дней; voice_users и S3 — перезапись | 🖥️ VPS Миран |
| **БД Nextcloud (PostgreSQL)** | CT 101, контейнер `nextcloud-db-1` в `/opt/nextcloud` | `/mnt/backup/databases/ct101-nextcloud/` | **01:15** (cron: `backup-ct101-pgdump.sh`) | 14 дней | 🗄️ Nextcloud (БД) |
| **Оригиналы фото Immich** | VM 200, `/mnt/data/library/` | `/mnt/backup/photos/library/` | **01:30** (cron: `backup-immich-photos.sh`, rsync) | Все копии (без автоудаления) | 📷 Фото Immich (rsync) |
| **Конфиги MTProto (VPS Германия)** | VPS 185.103.253.99: mtg.service, nginx (katykhin.store), Let's Encrypt, `/var/www/katykhin.store` | `/mnt/backup/vps/mtproto-germany/` (архивы mtproto-config-*.tar.gz) | **01:45** (cron: `backup-vps-mtproto.sh`) | 14 дней | 🌐 VPS MTProto (DE) |
| **LXC и VM** | Все выбранные контейнеры (100109) и VM 200 | `/mnt/backup/proxmox/dump/` | **02:00** (задание в Proxmox UI) | По настройкам задания (7 daily, 4 weekly, 6 monthly) | 💾 Backup local (cron 03:00) |
| **Конфиги хоста** | `/etc/pve`, `/etc/network/interfaces`, `/etc/hosts`, `/etc/resolv.conf` | `/mnt/backup/proxmox/etc-pve/` | **02:15** (cron: `backup-etc-pve.sh`) | 30 дней | ⚙️ Конфиги хоста |
| **БД Paperless (PostgreSQL)** | CT 104, контейнер `paperless-db-1` в `/opt/paperless` | `/mnt/backup/databases/ct104-paperless/` | **02:30** (cron: `backup-ct104-pgdump.sh`) | 14 дней | 🗄️ Paperless (БД) |
| **Данные Vaultwarden (пароли)** | CT 103, `/opt/docker/vaultwarden/data` | `/mnt/backup/other/vaultwarden/`; каталог в restic → Yandex | **02:45** (cron: `backup-vaultwarden-data.sh`) | 14 дней | 🔐 Vaultwarden |
| **БД Gitea (PostgreSQL)** | CT 103, контейнер `gitea-db-1` в `/opt/gitea` | `/mnt/backup/databases/ct103-gitea/` | **03:00** (cron: `backup-ct103-gitea-pgdump.sh`) | 14 дней | 🗄️ Gitea (БД) |
| **БД Immich (PostgreSQL)** | VM 200, контейнер `database` в `/opt/immich` | `/mnt/backup/databases/vm200-immich/` | **03:15** (cron: `backup-vm200-pgdump.sh`) | 14 дней | 🗄️ Immich (БД) |
| **Векторы RAG (CT 105)** | CT 105, `/home/rag-service/data/vectors/` (vectors.npz) | `/mnt/backup/other/ct105-vectors/` | **03:30** (cron: `backup-ct105-vectors.sh`) | 14 дней | 📐 Векторы RAG |
| **Выгрузка в Yandex (restic)** | `/mnt/backup` без photos | Yandex Object Storage | **04:00** (cron: `backup-restic-yandex.sh`) | 3 daily, 2 weekly, 2 monthly | ☁️ Restic Yandex |
| **Выгрузка в Yandex (restic, фото)** | `/mnt/backup/photos` | Yandex Object Storage (тот же репо) | **04:10** (cron: `backup-restic-yandex-photos.sh`) | 3 daily, 2 weekly, 2 monthly | 📷 Restic Yandex (photos) |
| **VPS Миран (telegram-helper-bot)** | VPS 185.147.80.190: БД `tg-bot-database.db`, каталог `voice_users`, бакет S3 (Miran) | `/mnt/backup/vps/miran/` (db/, voice_users/, s3/) | **01:00** (timer: `backup-vps-miran`) | БД: 14 дней; voice_users и S3 — перезапись | 🖥️ VPS Миран |
| **БД Nextcloud (PostgreSQL)** | CT 101, контейнер `nextcloud-db-1` в `/opt/nextcloud` | `/mnt/backup/databases/ct101-nextcloud/` | **01:15** (timer: `backup-ct101-pgdump`) | 14 дней | 🗄️ Nextcloud (БД) |
| **Оригиналы фото Immich** | VM 200, `/mnt/data/library/` | `/mnt/backup/photos/library/` | **01:30** (timer: `backup-immich-photos`) | Все копии (без автоудаления) | 📷 Фото Immich (rsync) |
| **Конфиги MTProto (VPS Германия)** | VPS 185.103.253.99: mtg.service, nginx (katykhin.store), Let's Encrypt, `/var/www/katykhin.store` | `/mnt/backup/vps/mtproto-germany/` (архивы mtproto-config-*.tar.gz) | **01:45** (timer: `backup-vps-mtproto`) | 14 дней | 🌐 VPS MTProto (DE) |
| **LXC и VM** | Все выбранные контейнеры (100109) и VM 200 | `/mnt/backup/proxmox/dump/` | **02:00** (задание в Proxmox UI) | По настройкам задания (7 daily, 4 weekly, 6 monthly) | 💾 Backup local (timer 03:00) |
| **Конфиги хоста** | `/etc/pve`, `/etc/network/interfaces`, `/etc/hosts`, `/etc/resolv.conf` | `/mnt/backup/proxmox/etc-pve/` | **02:15** (timer: `backup-etc-pve`) | 30 дней | ⚙️ Конфиги хоста |
| **БД Paperless (PostgreSQL)** | CT 104, контейнер `paperless-db-1` в `/opt/paperless` | `/mnt/backup/databases/ct104-paperless/` | **02:30** (timer: `backup-ct104-pgdump`) | 14 дней | 🗄️ Paperless (БД) |
| **Данные Vaultwarden (пароли)** | CT 103, `/opt/docker/vaultwarden/data` | `/mnt/backup/other/vaultwarden/`; каталог в restic → Yandex | **02:45** (timer: `backup-vaultwarden-data`) | 14 дней | 🔐 Vaultwarden |
| **БД Gitea (PostgreSQL)** | CT 103, контейнер `gitea-db-1` в `/opt/gitea` | `/mnt/backup/databases/ct103-gitea/` | **03:00** (timer: `backup-ct103-gitea-pgdump`) | 14 дней | 🗄️ Gitea (БД) |
| **БД Immich (PostgreSQL)** | VM 200, контейнер `database` в `/opt/immich` | `/mnt/backup/databases/vm200-immich/` | **03:15** (timer: `backup-vm200-pgdump`) | 14 дней | 🗄️ Immich (БД) |
| **Векторы RAG (CT 105)** | CT 105, `/home/rag-service/data/vectors/` (vectors.npz) | `/mnt/backup/other/ct105-vectors/` | **03:30** (timer: `backup-ct105-vectors`) | 14 дней | 📐 Векторы RAG |
| **Выгрузка в Yandex (restic)** | `/mnt/backup` без photos | Yandex Object Storage | **04:00** (timer: `backup-restic-yandex`) | 3 daily, 2 weekly, 2 monthly | ☁️ Restic Yandex |
| **Выгрузка в Yandex (restic, фото)** | `/mnt/backup/photos` | Yandex Object Storage (тот же репо) | **04:10** (timer: `backup-restic-yandex-photos`) | 3 daily, 2 weekly, 2 monthly | 📷 Restic Yandex (photos) |
**Окно бэкапов:** внутренние копии — **01:0003:30**; выгрузка в облако — **04:00** (основной restic), **04:10** (restic фото). **05:00** зарезервировано под плановую перезагрузку сервера. Задание vzdump — из веб-интерфейса Proxmox (Центр обработки данных → Резервная копия).
**Окно бэкапов:** внутренние копии — **01:0003:30**; выгрузка в облако — **04:00** (основной restic), **04:10** (restic фото). **04:35** — ping Healthchecks (Dead man's switch). **05:00** зарезервировано под плановую перезагрузку сервера. Задание vzdump — из веб-интерфейса Proxmox (Центр обработки данных → Резервная копия).
---
@@ -406,26 +416,31 @@ restic restore SNAPSHOT_ID --target /mnt/backup/restore-db --path /mnt/backup/da
---
## Скрипты на хосте Proxmox
## Скрипты и systemd timers на хосте Proxmox
Бэкапы запускаются через **systemd timers** (миграция с cron). Unit-файлы: `scripts/systemd/`. Копировать на хост: `cp scripts/systemd/*.service scripts/systemd/*.timer /etc/systemd/system/`, затем `systemctl daemon-reload` и `systemctl enable --now <timer>`.
| Скрипт | Назначение | Cron |
|--------|------------|------|
| `/root/scripts/backup-vps-miran.sh` | Бэкап VPS Миран: БД бота, voice_users, S3 (Miran) | 0 1 * * * |
| `/root/scripts/backup-ct101-pgdump.sh` | Логический дамп БД Nextcloud из CT 101 | 15 1 * * * |
| `/root/scripts/backup-immich-photos.sh` | Копирование библиотеки фото Immich (rsync с VM 200) | 30 1 * * * |
| `/root/scripts/backup-vps-mtproto.sh` | Копирование конфигов MTProto + сайт с VPS Германия (185.103.253.99) | 45 1 * * * |
| `/root/scripts/backup-etc-pve.sh` | Бэкап /etc/pve и конфигов хоста | 15 2 * * * |
| `/root/scripts/backup-ct104-pgdump.sh` | Логический дамп БД Paperless из CT 104 | 30 2 * * * |
| `/root/scripts/backup-vaultwarden-data.sh` | Копирование данных Vaultwarden (пароли) из CT 103 | 45 2 * * * |
| `/root/scripts/backup-ct103-gitea-pgdump.sh` | Логический дамп БД Gitea из CT 103 | 0 3 * * * |
| `/root/scripts/notify-vzdump-success.sh` | Проверка локального vzdump за последние 2 ч, отправка сводки в Telegram | 0 3 * * * |
| `/root/scripts/backup-vm200-pgdump.sh` | Логический дамп БД Immich с VM 200 | 15 3 * * * |
| `/root/scripts/backup-ct105-vectors.sh` | Копирование векторов RAG (vectors.npz) из CT 105 | 30 3 * * * |
| `/root/scripts/backup-restic-yandex.sh` | Выгрузка /mnt/backup (без photos) в Yandex S3 (restic), retention 3/2/2 | 0 4 * * * |
| `/root/scripts/backup-restic-yandex-photos.sh` | Выгрузка только /mnt/backup/photos в Yandex S3 (тот же репо), retention 3/2/2 | 10 4 * * * |
| `/root/scripts/notify-telegram.sh` | Шлюз отправки уведомлений в Telegram (вызывают скрипты бэкапов) | — |
| Скрипт | Timer | Расписание |
|--------|-------|------------|
| `backup-vps-miran.sh` | backup-vps-miran.timer | 01:00 |
| `backup-ct101-pgdump.sh` | backup-ct101-pgdump.timer | 01:15 |
| `backup-immich-photos.sh` | backup-immich-photos.timer | 01:30 |
| `backup-vps-mtproto.sh` | backup-vps-mtproto.timer | 01:45 |
| `backup-etc-pve.sh` | backup-etc-pve.timer | 02:15 |
| `backup-ct104-pgdump.sh` | backup-ct104-pgdump.timer | 02:30 |
| `backup-vaultwarden-data.sh` | backup-vaultwarden-data.timer | 02:45 |
| `backup-ct103-gitea-pgdump.sh` | backup-ct103-gitea-pgdump.timer | 03:00 |
| `notify-vzdump-success.sh` | notify-vzdump-success.timer | 03:00 |
| `backup-vm200-pgdump.sh` | backup-vm200-pgdump.timer | 03:15 |
| `backup-ct105-vectors.sh` | backup-ct105-vectors.timer | 03:30 |
| `backup-restic-yandex.sh` | backup-restic-yandex.timer | 04:00 |
| `backup-restic-yandex-photos.sh` | backup-restic-yandex-photos.timer | 04:10 |
| `healthcheck-ping.sh` | backup-healthcheck-ping.timer | 04:35 (Healthchecks) |
| `watchdog-timers.sh` | backup-watchdog-timers.timer | 12:00 (проверка failed timers, .ok) |
**Healthcheck-файлы:** при успешном завершении каждый скрипт бэкапа пишет `echo $(date -Iseconds) > /var/run/backup-<name>.ok`. Watchdog проверяет раз в день: если файл старше 24 ч — алерт в Telegram.
**Тест восстановления:** см. [restore-test-manual.md](restore-test-manual.md). Автоматические скрипты: `verify-restore-level1.sh` (restic check, дамп Nextcloud), `verify-vzdump-level2.sh` (vzdump CT 107). Таймеры: `verify-restore-level1-weekly`, `-monthly-check`, `-full-check`, `-monthly-dump`, `verify-vzdump-level2`.
Задание vzdump (LXC/VM) настраивается в Proxmox UI (расписание 02:00). **05:00** оставлено свободным для плановой перезагрузки сервера.
@@ -452,7 +467,7 @@ pct exec 103 -- ls -la /opt/docker/vaultwarden/data
pct exec 103 -- tar cf - -C /opt/docker/vaultwarden data | wc -c
```
**Запуск из cron и доступ к Vaultwarden (bw):** В cron окружение ограничено: часто `PATH` не содержит `/usr/local/bin`, где обычно установлен `bw`. Скрипты дампов БД (ct101, ct104, ct103) в начале задают `export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"`, поэтому при запуске из cron `bw` и `jq` находятся без правки crontab. Нужно: 1) файл с мастер-паролем, например `/root/.bw-master` (chmod 600), и при необходимости переменная `BW_MASTER_PASSWORD_FILE=/root/.bw-master`; 2) один раз с интерактивной сессии: `bw config server https://vault.katykhin.ru`, `bw login` (сохранит сессию в конфиг); 3) при каждом запуске скрипт делает `bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw` и подставляет пароль БД. Если вручную дамп идёт, а из cron — нет, проверьте: наличие `/root/.bw-master`, права доступа, что `bw` доступен по этому PATH (запустите скрипт через `env -i PATH=/usr/local/bin:/usr/bin:/bin /root/scripts/backup-ct103-gitea-pgdump.sh` для имитации cron).
**Запуск из systemd timer и доступ к Vaultwarden (bw):** В окружении systemd timer `PATH` может не содержать `/usr/local/bin`, где обычно установлен `bw`. Скрипты дампов БД задают `export PATH="/usr/local/bin:..."`, поэтому `bw` и `jq` находятся. Нужно: 1) файл `/root/.bw-master` (chmod 600) с мастер-паролем; 2) один раз: `bw config server https://vault.katykhin.ru`, `bw login`; 3) при каждом запуске скрипт делает `bw unlock --passwordfile /root/.bw-master --raw`. Если вручную дамп идёт, а из таймера — нет, проверьте `/root/.bw-master` и PATH.
### Почему размер дампа меньше размера БД на диске
@@ -477,7 +492,7 @@ pct exec 101 -- docker exec nextcloud-db-1 psql -U nextcloud -d nextcloud -t -c
## Уведомления в Telegram
После **успешного** выполнения каждого бэкапа в Telegram отправляется короткое сообщение (заголовок с эмодзи + краткая сводка). Уведомления приходят по завершении соответствующего скрипта; для локального vzdump — по cron в **03:00** (проверка файлов за последние 2 часа).
После **успешного** выполнения каждого бэкапа в Telegram отправляется короткое сообщение (заголовок с эмодзи + краткая сводка). Уведомления приходят по завершении соответствующего скрипта; для локального vzdump — таймер `notify-vzdump-success` в **03:00** (проверка файлов за последние 2 часа).
| Заголовок | Когда | Тело сообщения |
@@ -516,8 +531,6 @@ chmod 600 /root/.telegram-notify.env
Если конфига или кредов нет, шлюз тихо выходит с 0 и не ломает вызывающие скрипты.
**Позже** тот же шлюз можно вызывать с VM 200 или с VPS (например по SSH на хост Proxmox) — отдельно не реализовано, архитектура это допускает.
---
## Связанные документы
@@ -525,4 +538,8 @@ chmod 600 /root/.telegram-notify.env
- [Vaultwarden и секреты](../vaultwarden-secrets.md) — получение паролей через `bw` для скриптов бэкапов.
- [Архитектура](../architecture/architecture.md) — хост, IP, доступ.
- [VM 200 (Immich)](../containers/container-200.md) — сервисы, пути, .env.
- [Ручной тест восстановления](restore-test-manual.md) — пошаговые команды для полной проверки restore.
- [Healthchecks на VPS Миран](../vps/healthchecks-miran-setup.md) — Dead man's switch, ping после бэкапов.
- [Netdata на Proxmox](../monitoring/netdata-proxmox-setup.md) — мониторинг CPU, RAM, дисков, алерты в Telegram.
- [SMART и smartd](../monitoring/smartd-setup.md) — мониторинг дисков, уведомления при отклонениях.

View File

@@ -0,0 +1,140 @@
# Ручной тест восстановления (уровень 3)
Пошаговые команды для полной проверки восстановления после потери данных или миграции. Выполнять периодически (раз в 612 месяцев) или после значительных изменений инфраструктуры.
---
## 1. Полный restore на отдельный диск
**Когда нужно:** проверка, что все бэкапы доступны и можно восстановить систему на новом диске.
### Подготовка
1. Подключить диск с достаточным объёмом (например 2 TB) или использовать временный раздел.
2. Смонтировать в `/mnt/restore-test` (или аналогичный путь).
3. Убедиться, что есть креды restic: `/root/.restic-yandex.env`, `/root/.restic-password` или Vaultwarden (объект RESTIC).
### Восстановление из restic (Yandex)
```bash
# Список снимков
set -a; source /root/.restic-yandex.env; set +a
export RESTIC_PASSWORD_FILE=/root/.restic-password
export AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-ru-central1}
restic snapshots
# Восстановить основной снимок (без photos) в каталог
restic restore latest --target /mnt/restore-test --path /mnt/backup
# Восстановить фото (отдельный снимок)
restic snapshots | grep photos
restic restore <SNAPSHOT_ID> --target /mnt/restore-test --path /mnt/backup/photos
```
Файлы появятся в `/mnt/restore-test/mnt/backup/`. Проверить наличие:
- `proxmox/dump/dump/` — vzdump
- `proxmox/etc-pve/` — конфиги хоста
- `databases/` — дампы БД
- `other/vaultwarden/` — архив Vaultwarden
- `photos/library/` — фото Immich
---
## 2. Проверка Immich (веб, загрузка фото)
**Цель:** убедиться, что Immich работает, веб-интерфейс доступен, загрузка фото проходит.
### Подготовка
- Immich доступен по https://immich.katykhin.ru (через NPM).
- ВМ 200: `ssh admin@192.168.1.200`.
### Шаги
1. **Открыть:** https://immich.katykhin.ru
2. **Войти** под учётной записью (логин/пароль из менеджера паролей).
3. **Загрузить тестовое фото:**
- Нажать «Upload» (или загрузить через drag-and-drop).
- Выбрать небольшое изображение (например 12 MB).
- Дождаться завершения загрузки и появления в библиотеке.
4. **Проверить:** фото появилось в галерее, превью отображается, метаданные доступны.
### Если Immich не загружается
- Проверить: `ssh admin@192.168.1.200 "cd /opt/immich && docker compose ps"` — все контейнеры running.
- Логи: `docker logs immich_server` (или `immich_upload_optimizer`).
- NPM: прокси на 192.168.1.200:2283.
---
## 3. Проверка Nextcloud (веб, загрузка файла)
**Цель:** убедиться, что Nextcloud доступен и загрузка файлов работает.
### Подготовка
- Nextcloud: https://cloud.katykhin.ru
- Контейнер 101: `ssh root@192.168.1.101` или `pct exec 101 -- bash`.
### Шаги
1. **Открыть:** https://cloud.katykhin.ru
2. **Войти** под учётной записью (логин/пароль из менеджера паролей).
3. **Загрузить тестовый файл:**
- Перейти в «Files» (или «Файлы»).
- Нажать «Upload» или перетащить файл (например .txt или .pdf).
- Дождаться завершения загрузки.
4. **Проверить:** файл отображается в списке, можно скачать.
### Если Nextcloud не работает
- Проверить: `pct exec 101 -- docker ps` — контейнеры nextcloud и nextcloud-db-1 running.
- Логи: `docker logs nextcloud-app-1` (или имя контейнера из compose).
---
## 4. Проверка GPU passthrough на VM 200
**Цель:** убедиться, что GPU проброшена в Immich ML и распознавание работает.
### Подготовка
- VM 200: `ssh admin@192.168.1.200`
- В Immich: включить ML (Settings → Machine Learning).
### Шаги
1. **Проверить GPU в контейнере ML:**
```bash
ssh admin@192.168.1.200
cd /opt/immich
docker exec immich_machine_learning nvidia-smi
```
Ожидаемый вывод: информация о GPU (модель, память, драйвер).
2. **Проверить распознавание в Immich:**
- Загрузить фото с лицами или объектами.
- Дождаться обработки ML (иконка «Scan» в интерфейсе).
- Проверить: объекты/лица распознаны, теги добавлены.
3. **Если nvidia-smi не работает:**
- На хосте Proxmox: проверить `hostpci0` в конфиге VM 200: `cat /etc/pve/qemu-server/200.conf`
- Убедиться, что PCI-устройство GPU передано в ВМ (`hostpci0: 0000:xx:00.0` и т.п.).
- Перезапустить ВМ при необходимости: `qm stop 200 && qm start 200`.
---
## 5. Дополнительные проверки (по желанию)
- **Vaultwarden:** https://vault.katykhin.ru — вход, синхронизация.
- **Gitea:** https://git.katykhin.ru — вход, список репозиториев.
- **Paperless:** https://docs.katykhin.ru — вход, поиск документов.
- **Galene:** https://call.katykhin.ru — вход в комнату.
---
## Связанные документы
- [backup-howto](backup-howto.md) — восстановление из vzdump, restic, дампов БД, расписание таймеров.
- [container-200](../containers/container-200.md) — VM 200 (Immich), GPU, пути.
- [architecture](../architecture/architecture.md) — хост, IP, доступ.

View File

@@ -14,8 +14,8 @@
- **Доступ:** SSH под пользователем **admin** (не root): `ssh admin@192.168.1.200` с хоста 192.168.1.150 или из LAN. Для выполнения команд с правами root: `sudo ...`.
**Диски:**
- **Корневой диск** (sda1): 35 GB, занято **~29 GB (87%)** — система, образы/кэш в пределах корня. **Критично:** мало свободного места; при росте логов или обновлениях возможны сбои. Следить за местом и логированием (см. TODO).
- **Данные** (sdb1): 344 GB, смонтирован в **/mnt/data**, занято ~177 GB (55%). Здесь: библиотека Immich, БД PostgreSQL, Docker root, containerd, Ollama, данные deduper.
- **Корневой диск** (sda1): 50 GB — система, образы/кэш в пределах корня. Логи Docker ограничены (см. ниже).
- **Данные** (sdb1): 350 GB, смонтирован в **/mnt/data**. Здесь: библиотека Immich, БД PostgreSQL, Docker root, containerd, Ollama, данные deduper.
---
@@ -184,10 +184,10 @@ sudo resize2fs /dev/sdb1
## Логи и ротация
- **Базовая политика (как в LXC):** на ВМ настроен logrotate `/etc/logrotate.d/homelab-lxc.conf` — 14 дней, 50 MB, 5 архивов, сжатие (системные логи в `/var/log`). На ВМ 200 пакет `logrotate` был установлен вручную (в образе по умолчанию не было); после установки активен таймер `logrotate.timer`. Подробнее: [Logrotate — базовая политика homelab](../maintenance/logrotate/README.md).
- **Docker:** данные Docker (образы, контейнеры, overlay) хранятся в **/mnt/data/docker** (Docker Root Dir). Логи контейнеров — драйвер **json-file** без ограничения размера и количества файлов (Config:{} у immich_server и immich_postgres). При активной работе логи могут разрастаться и занимать место на **корневом** разделе (если логи пишутся на корень) или в overlay на /mnt/data — уточнить расположение логов контейнеров (часто в /mnt/data/docker/containers). В любом случае ограничение логов не задано (см. TODO).
- **Docker:** данные Docker (образы, контейнеры, overlay) хранятся в **/mnt/data/docker** (Docker Root Dir). Логи контейнеров — драйвер **json-file** с ограничениями в `/etc/docker/daemon.json`: `max-size: "10m"`, `max-file: "3"` (до 30 MB на контейнер). Логи пишутся в `/mnt/data/docker/containers`.
- **Системный logrotate:** стандартные правила (apt, dpkg, cloud-init, unattended-upgrades, wtmp) плюс homelab-lxc.conf. Отдельных правил для Immich или Docker нет.
**Риск:** корневой диск заполнен на 87%. Рост логов, обновления и кэш могут привести к нехватке места. Необходимо ограничить логи Docker и следить за местом на корне (см. TODO).
Корневой диск расширен до 50 GB; логи Docker ограничены.
---
@@ -208,18 +208,17 @@ sudo resize2fs /dev/sdb1
## Уязвимости и риски
1. **Секреты в .env:** В `/opt/immich/.env` и `/opt/immich-deduper/.env` хранятся пароли БД, API-ключи (IMMICH_API_KEY, GEMINI_API_KEY), креды для deduper (PSQL_*). Файлы не должны попадать в публичный репозиторий. Ограничить права (chmod 600), хранить бэкапы в защищённом месте.
2. **Корневой диск 87%:** Критично мало свободного места. При 100% возможны сбои обновлений и работы сервисов. Срочно: освободить место и/или перенести часть данных на /mnt/data, ограничить логи Docker (см. TODO).
3. **Логи Docker без лимитов:** Ротация не настроена — возможен рост логов и заполнение диска.
4. **Доступ по портам:** Сервисы доступны по 2283, 2284, 8001, 8501, 8086 из LAN. Снаружи доступ только через NPM (https://immich.katykhin.ru → 2283). Не пробрасывать порты напрямую в интернет.
5. **GPU и PCI-passthrough:** ВМ использует hostpci0 (VGA). Убедиться, что драйверы NVIDIA и доступ к GPU корректны для immich_machine_learning.
2. **Корневой диск:** Расширен до 50 GB; логи Docker ограничены (10m × 3 файла на контейнер). Следить за местом при обновлениях.
3. **Доступ по портам:** Сервисы доступны по 2283, 2284, 8001, 8501, 8086 из LAN. Снаружи доступ только через NPM (https://immich.katykhin.ru → 2283). Не пробрасывать порты напрямую в интернет.
4. **GPU и PCI-passthrough:** ВМ использует hostpci0 (VGA). Убедиться, что драйверы NVIDIA и доступ к GPU корректны для immich_machine_learning.
---
## TODO по ВМ 200
- [x] **Базовая политика logrotate:** для системных логов настроена (homelab-lxc.conf — 14 дней, 50 MB, 5 архивов, как в LXC). См. [Logrotate — базовая политика homelab](../maintenance/logrotate/README.md).
- [ ] **Корневой диск:** Снизить использование корня (87%). Варианты: перенести логи Docker на /mnt/data (если сейчас пишутся на корень), очистить старые образы/кэш (`docker system prune` с осторожностью), увеличить размер корневого диска ВМ в Proxmox. Настроить мониторинг и оповещение при заполнении >90%.
- [ ] **Логи Docker:** Включить ограничение размера логов для всех контейнеров Immich и deduper: в `docker-compose.yml` добавить для каждого сервиса `logging: driver: json-file options: max-size: "100m" max-file: "3"` или задать default в `/etc/docker/daemon.json`. Убедиться, что Docker Root Dir остаётся на /mnt/data и логи не пишутся на корень. После изменений перезапустить контейнеры.
- [x] **Корневой диск:** Расширен до 50 GB (было 35 GB). Логи Docker ограничены.
- [x] **Логи Docker:** В `/etc/docker/daemon.json` заданы `log-driver: json-file`, `max-size: "10m"`, `max-file: "3"`. Логи в /mnt/data/docker/containers.
- [ ] **Права на конфиги:** Ограничить доступ к .env (chmod 600), не коммитить в публичные репозитории.
- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации):
- **`/mnt/data/library`** — библиотека Immich (фото, видео, превью). ~148 GB. Основной объём; бэкап обязателен (внешний диск, сетевое хранилище).

View File

@@ -0,0 +1,157 @@
# Хост Proxmox (192.168.1.150)
Описание хоста Proxmox VE: скрипты, systemd-сервисы, пути и демоны. Контейнеры и ВМ описаны в отдельных статьях (container-100.md и т.д.).
---
## Общие сведения
- **IP:** 192.168.1.150/24
- **Доступ:** `ssh root@192.168.1.150`
- **Роль:** гипервизор (LXC + KVM), точка запуска бэкапов, деплой секретов в контейнеры
---
## Диски
| Устройство | Тип | Размер | Использование |
|--------------|------|--------|----------------------------------|
| /dev/nvme0n1 | NVMe | 256 GB | LVM (система, local-lvm) |
| /dev/sda | HDD | 2 TB | ZFS |
| /dev/sdb | SSD | 2 TB | ext4, /mnt/backup |
| /dev/sdc | HDD | 2 TB | ZFS (RAID1 с sda) |
| /dev/sdd | HDD | 8 TB | ext4 (внешний) |
---
## Каталог скриптов: /root/scripts/
Скрипты копируются из репозитория в `/root/scripts/` на хосте.
### Бэкапы (backup-*)
| Скрипт | Таймер | Назначение |
|--------|--------|------------|
| backup-vps-miran.sh | 01:00 | VPS Миран: БД бота, voice_users, S3 |
| backup-ct101-pgdump.sh | 01:15 | Nextcloud PostgreSQL |
| backup-immich-photos.sh | 01:30 | rsync фото Immich с VM 200 |
| backup-vps-mtproto.sh | 01:45 | Конфиги MTProto (VPS DE) |
| backup-etc-pve.sh | 02:15 | /etc/pve, interfaces, hosts, resolv.conf |
| backup-ct104-pgdump.sh | 02:30 | Paperless PostgreSQL |
| backup-vaultwarden-data.sh | 02:45 | Данные Vaultwarden |
| backup-ct103-gitea-pgdump.sh | 03:00 | Gitea PostgreSQL |
| backup-vm200-pgdump.sh | 03:15 | Immich PostgreSQL (SSH на VM 200) |
| backup-ct105-vectors.sh | 03:30 | Векторы RAG (vectors.npz) |
| backup-restic-yandex.sh | 04:00 | restic → Yandex (без photos) |
| backup-restic-yandex-photos.sh | 04:10 | restic → Yandex (только photos) |
### Дашборд мониторинга
| Компонент | Назначение |
|-----------|------------|
| homelab-dashboard.service | Дашборд homelab (хост, контейнеры, сервисы) на порту 19998 |
| /root/scripts/dashboard/ | Скрипты: dashboard-exporter.py, dashboard-server.py, index.html |
| deploy-dashboard.sh | Деплой дашборда на хост |
| add-to-homepage.sh | Добавить ссылку в Homepage (CT 103) |
**URL:** http://192.168.1.150:19998
### Мониторинг и уведомления
| Скрипт | Назначение |
|--------|------------|
| healthcheck-ping.sh | Ping Healthchecks (04:35) — Dead man's switch |
| watchdog-timers.sh | Проверка .ok файлов (12:00), алерт в Telegram при отсутствии |
| smartd-notify.sh | Вызывается smartd при проблемах с дисками |
| notify-telegram.sh | Общий шлюз уведомлений в Telegram |
| notify-vzdump-success.sh | Уведомление после успешного vzdump (по systemd path) |
### Деплой (deploy-*)
Скрипты разворачивают секреты из Vaultwarden в контейнеры и на VPS:
| Скрипт | Куда |
|--------|------|
| deploy-beget-credentials.sh | CT 100 (certbot) |
| deploy-nextcloud-credentials.sh | CT 101 |
| deploy-gitea-credentials.sh | CT 103 |
| deploy-paperless-credentials.sh | CT 104 |
| deploy-rag-credentials.sh | CT 105 |
| deploy-invidious-credentials.sh | CT 107 |
| deploy-galene-credentials.sh | CT 108 |
| deploy-wireguard-credentials.sh | CT 109 |
| deploy-immich-credentials.sh | VM 200 |
| deploy-vpn-route-check.sh | CT 100 (vpn-route-check) |
### Прочее
| Скрипт | Назначение |
|--------|------------|
| immich-pgdump-remote.sh | Копируется на VM 200, вызывается backup-vm200-pgdump.sh по SSH |
| restore-one-vzdump-from-restic.sh | Восстановление одного vzdump из Yandex |
| verify-restore-level1.sh, verify-vzdump-level2.sh | Проверка восстановления |
| backup-setup-sdb1-mount.sh | Монтирование /dev/sdb1 в /mnt/backup |
| setup-vps-miran-backup-on-proxmox.sh | Настройка бэкапа VPS Миран на хосте |
| npm-add-proxy.sh, npm-add-proxy-vault.sh, npm-cert-cloud.sh | Вспомогательные для NPM |
---
## Systemd: таймеры и сервисы
Unit-файлы лежат в репозитории в `scripts/systemd/`, на хосте — в `/etc/systemd/system/`.
### Бэкапы (backup-*.timer)
Все бэкапы запускаются через systemd timers (cron не используется). Расписание: [backup-howto.md](../backup/backup-howto.md).
После успешного выполнения сервис создаёт файл `/var/run/backup-<name>.ok` с timestamp.
### Watchdog (backup-watchdog-timers.timer)
Ежедневно в **12:00** запускается `watchdog-timers.sh`: проверяет, что все `.ok` файлы свежие (не старше 24 ч). При отсутствии или устаревании — уведомление в Telegram.
### Healthcheck ping (backup-healthcheck-ping.timer)
Ежедневно в **04:35** — ping в Healthchecks (Dead man's switch). Если бэкапы не прошли и ping не отправился, Healthchecks шлёт алерт.
### Vzdump (notify-vzdump-success)
Задание vzdump настраивается в Proxmox UI. После успешного выполнения срабатывает path-юнит `notify-vzdump-success.service` → уведомление в Telegram.
### Проверка восстановления (verify-*)
Таймеры для периодической проверки restic и vzdump: `verify-restore-level1-*`, `verify-vzdump-level2`.
---
## Ключевые пути
| Путь | Назначение |
|------|------------|
| /mnt/backup/ | Локальные бэкапы (см. [backup-howto](../backup/backup-howto.md)) |
| /var/run/backup-*.ok | Healthcheck-файлы для watchdog (timestamp последнего успешного запуска) |
| /root/.healthchecks.env | URL и UUID для healthcheck-ping |
| /root/.bw-master | Мастер-пароль Bitwarden CLI (chmod 600; для restic, pg_dump) |
| /root/.restic-yandex.env | Переменные restic (репозиторий, ключи) |
| /etc/smartd.conf | Конфигурация smartd |
| /etc/pve/ | Конфиги Proxmox (бэкапятся в backup-etc-pve) |
---
## Демоны и сервисы
| Сервис | Назначение |
|--------|------------|
| smartd | Мониторинг SMART дисков, при проблемах — smartd-notify.sh → Telegram |
| pveproxy, pvedaemon | Proxmox API и веб-интерфейс |
| corosync, pve-cluster | Кластер Proxmox (при одномузловой установке — локально) |
---
## Связанные документы
- [Архитектура](../architecture/architecture.md) — обзор, контейнеры, поток запросов
- [Бэкапы](../backup/backup-howto.md) — что, куда, когда, восстановление
- [Схема сети](../network/network-topology.md) — топология, зависимости
- [smartd](../monitoring/smartd-setup.md) — мониторинг дисков
- [Healthchecks](../vps/healthchecks-miran-setup.md) — Dead man's switch на VPS Миран

View File

@@ -0,0 +1,146 @@
# План реализации дашборда мониторинга homelab
Дашборд для Netdata (http://192.168.1.150:19999) с блоками: хост, контейнеры, критические сервисы.
---
## Текущее состояние (по результатам проверки на сервере)
### Netdata
- **Версия:** v2.9.0
- **Режим:** локальный, Cloud отключён
- **API:** http://localhost:19999/api/v1/ — доступен
### Доступные метрики
| Блок | Метрика | Chart / источник | Статус |
|------|---------|------------------|--------|
| **Хост** | CPU total | `system.cpu` (user, system, nice, iowait, …) | ✅ |
| | RAM total | `system.ram` | ✅ |
| | Disk usage | `disk_space./`, `disk_space./mnt/backup`, `disk_space./mnt/nextcloud-hdd`, `disk_space./tank`, … (в API: URL-encode слэши) | ✅ |
| | iowait | `system.cpu` dimension `iowait` | ✅ |
| | load | `system.load` (load1, load5, load15) | ✅ |
| **Контейнеры** | CPU % | `cgroup_<name>.cpu_limit` (used) | ✅ |
| | RAM % | `cgroup_<name>.mem_utilization` | ✅ |
| | Disk % | скрипт `pct exec ID -- df -P /` | ✅ кастомный экспортер |
| | OOM count | `/sys/fs/cgroup/lxc/ID/memory.events` (oom_kill) | ✅ кастомный экспортер |
| **Сервисы** | Immich, Nextcloud, nginx, VPN | ссылки на charts Netdata | ✅ без response time/connections |
### Контейнеры в cgroups (по данным Netdata)
- `nginx` (CT 100)
- `nextcloud` (CT 101)
- `gitea` (CT 103)
- `paperless` (CT 104)
- `rag-service` (CT 105)
- `misc` (CT 107, Invidious)
- `galene` (CT 108)
- `local-vpn` (CT 109)
- `qemu_immich` (VM 200)
---
## Решения (по ответам пользователя)
1. **Disk % по контейнерам** — в приоритете. I/O не нужен. Реализация: скрипт на хосте, `pct exec ID -- df -P /` для каждого LXC, VM 200 — отдельно (`qm guest exec` или аналог).
2. **OOM**`cgroup memory.events` (oom_kill) по каждому контейнеру. Путь: `/sys/fs/cgroup/lxc/ID/memory.events` (LXC), для VM — cgroup QEMU.
3. **Response time / open connections** — отложено, не требуется.
4. **Размещение** — на хосте (192.168.1.150).
5. **Netdata Cloud** — не рассматривается.
---
## Варианты реализации дашборда
### Вариант A: Netdata Cloud — не используется (Cloud отключён)
### Вариант B: Кастомная HTML-страница (выбран)
- Страница на хосте (192.168.1.150), которая:
- запрашивает Netdata API (`/api/v1/data?chart=...`)
- рендерит блоки: хост, таблица контейнеров, сервисы
- Плюсы: полный контроль, работает без Cloud
- Минусы: нужна разработка и хостинг страницы
### Вариант C: Доработка стандартного дашборда Netdata
- `dashboard_info.js` — изменение порядка/группировки charts
- Плюсы: используем встроенный UI
- Минусы: ограниченная кастомизация, в v2 подход мог измениться
---
## Рекомендуемый план (поэтапно)
### Этап 1: Дашборд на базе Netdata API (Вариант B)
Создать кастомную HTML-страницу с тремя блоками.
**Блок 1 — Хост**
- CPU total: `system.cpu` (сумма user+system или 100-idle)
- RAM total: `system.ram` (used, cached, free)
- Disk usage: `disk_space./`, `disk_space./mnt/backup`, `disk_space./mnt/nextcloud-hdd`, `disk_space./tank` (avail/used %)
- iowait: `system.cpu` dimension iowait
- load: `system.load` (load15)
**Блок 2 — Контейнеры (таблица)**
- Колонки: имя, CPU %, RAM %, Disk %, OOM count
- CPU/RAM: `cgroup_<name>.cpu_limit`, `cgroup_<name>.mem_utilization` (Netdata API)
- Disk %: кастомный API (скрипт `pct exec ID -- df -P /` + парсинг)
- OOM: кастомный API (LXC: `/sys/fs/cgroup/lxc/ID/memory.events`, VM 200: `/sys/fs/cgroup/qemu.slice/200.scope/memory.events` → oom_kill)
**Блок 3 — Критические сервисы**
- Immich, Nextcloud, nginx, VPN — ссылки на charts Netdata (cgroup_*, app.nginx_*)
- Response time / open connections — не требуются
### Этап 2: Кастомный экспортер (скрипт + HTTP API)
Скрипт на хосте, запускаемый по таймеру или по запросу:
- **Disk %:** для каждого LXC (100109) — `pct exec ID -- df -P /`; для VM 200 — `qm guest exec` или fallback (lvs/zfs)
- **OOM:** чтение `oom_kill` из `/sys/fs/cgroup/lxc/ID/memory.events` (LXC), `/sys/fs/cgroup/qemu.slice/200.scope/memory.events` (VM 200)
- Отдача JSON на HTTP (например, порт 19998 или через nginx на хосте)
### Этап 3: Размещение и интеграция
- Дашборд: статика на хосте (nginx или python -m http.server), запросы к Netdata API (localhost:19999) и кастомному API
- Добавить ссылку в Homepage (services.yaml)
---
## VM 200 (Immich) — RAM после увеличения
При увеличении RAM с 6 до 10 GB «потребление» может визуально «упасть» по нескольким причинам:
1. **Процент vs абсолютное значение** — 32% от 10 GB ≈ 3.2 GB. Та же нагрузка при 6 GB давала бы ~53%. Дашборд показывает RAM % (cgroup mem_utilization).
2. **Сброс кэша** — при нехватке памяти гость держит кэш; после добавления RAM ядро может освободить кэш, и «used» уменьшается.
3. **Balloon** — virtio-balloon мог забирать память при 6 GB; после увеличения лимита balloon отдаёт память гостю, но реальное использование приложений может остаться ~3 GB.
**Проверка:** `qm guest exec 200 -- free -h` (требует qemu-guest-agent в гостевой ОС) — смотреть `Mem: used` внутри гостя.
---
## Реализовано (2026-02-28)
- **URL дашборда:** http://192.168.1.150:19998
- **Ссылка в Homepage:** добавлена (Сервисы → Homelab Dashboard)
- **Скрипты:** `scripts/dashboard/` (exporter, server, index.html, deploy, add-to-homepage)
- **Systemd:** `homelab-dashboard.service` (порт 19998)
---
## Маппинг CT/VM → cgroup name (Netdata)
| ID | Назначение | cgroup name |
|----|------------|-------------|
| 100 | nginx | cgroup_nginx |
| 101 | nextcloud | cgroup_nextcloud |
| 103 | gitea | cgroup_gitea |
| 104 | paperless | cgroup_paperless |
| 105 | rag-service | cgroup_rag-service |
| 107 | misc (Invidious) | cgroup_misc |
| 108 | galene | cgroup_galene |
| 109 | local-vpn | cgroup_local-vpn |
| 200 | immich (VM) | cgroup_qemu_immich |
---
## Связанные документы
- [netdata-proxmox-setup.md](netdata-proxmox-setup.md) — установка и алерты
- [smartd-setup.md](smartd-setup.md) — SMART дисков
- [container-100](../containers/container-100.md) — NPM, log-dashboard
- [architecture](../architecture/architecture.md) — обзор контейнеров

View File

@@ -0,0 +1,211 @@
# Netdata на хосте Proxmox
Мониторинг CPU, RAM, дисков, load average, swap. Алерты в Telegram.
---
## Доступ
| Параметр | Значение |
|----------|----------|
| **URL** | http://192.168.1.150:19999 |
| **Режим** | Локальный, анонимный (Cloud отключён) |
---
## Установка
На хосте Proxmox (root):
```bash
# Официальный установщик
wget -O /tmp/netdata-kickstart.sh https://get.netdata.cloud/kickstart.sh
sh /tmp/netdata-kickstart.sh --stable-channel --disable-telemetry
```
Или через пакетный менеджер (если доступен):
```bash
apt update && apt install -y netdata
```
---
## Конфигурация Telegram
Редактировать: `/etc/netdata/health_alarm_notify.conf`
```bash
cd /etc/netdata
./edit-config health_alarm_notify.conf
```
Добавить/изменить:
```
SEND_TELEGRAM="YES"
TELEGRAM_BOT_TOKEN="<токен из Vaultwarden: HOME_BOT_TOKEN>"
DEFAULT_RECIPIENT_TELEGRAM="<chat_id из Vaultwarden: RESTIC.TELEGRAM_SELF_CHAT_ID>"
```
Креды можно взять из Vaultwarden (объекты HOME_BOT_TOKEN, RESTIC) или из `/root/.telegram-notify.env`.
---
## Алерты (health.d)
Файлы в `/etc/netdata/health.d/`. Создать или переопределить:
### cpu.conf — CPU > 90% более 10 минут
```conf
# Переопределение: CPU > 90% = warning
template: cpu_usage
on: system.cpu
lookup: average -10m percentage of usage
warn: $this > 90
crit: $this > 95
```
### ram.conf — RAM > 90%
```conf
template: ram_usage
on: system.ram
lookup: average -10m percentage of used
warn: $this > 90
crit: $this > 95
```
### load.conf — Load average > cores × 2
```conf
# Load average: warn если load > 2 × число ядер
# Число ядер: nproc или lscpu
template: load_average
on: system.load
lookup: average -10m of load15
# Порог задаётся вручную под хост (cores × 2). Пример для 8 ядер: 16
warn: $this > 16
crit: $this > 24
```
**Важно:** заменить `16` и `24` на `cores × 2` и `cores × 3` для вашего хоста. Узнать ядра: `nproc`.
### swap.conf — Swap > 0 стабильно
```conf
template: swap_usage
on: system.swap
lookup: average -10m percentage of used
warn: $this > 0
crit: $this > 10
```
### disk.conf — Диск > 80% (avail < 20%)
Мониторить: `/` (root, NVMe), `/mnt/backup` (sdb), внешний диск (sdd). Netdata использует `percentage of avail` — warn при avail < 20% (т.е. used > 80%).
```conf
# Шаблон для важных дисков: warn при avail < 20%, crit при avail < 10%
template: disk_space_critical
on: disk.space
lookup: max -1m percentage of avail
warn: $this < 20
crit: $this < 10
```
Или для конкретных путей (chart ID: disk_space._ с подчёркиваниями вместо слешей):
```conf
# / (root, NVMe)
alarm: disk_space_root
on: disk_space._
lookup: max -1m percentage of avail
chart labels: mount_point=/
warn: $this < 20
crit: $this < 10
# /mnt/backup (sdb)
alarm: disk_space_backup
on: disk_space._mnt_backup
lookup: max -1m percentage of avail
warn: $this < 20
crit: $this < 10
```
Узнать точные chart ID: `curl -s "http://localhost:19999/api/v1/charts" | grep disk_space`
---
## SMART (smartmontools)
Для мониторинга SMART через Netdata:
```bash
apt install -y smartmontools
```
Плагин `smartd` в Netdata автоматически обнаруживает диски. Дополнительно см. [smartd-setup.md](smartd-setup.md).
---
## Применение изменений
```bash
netdatacli reload-health
# или
systemctl restart netdata
```
Тест алертов:
```bash
sudo su -s /bin/bash netdata
export NETDATA_ALARM_NOTIFY_DEBUG=1
/usr/libexec/netdata/plugins.d/alarm-notify.sh test
```
---
## Мониторинг контейнеров и VM
Netdata на хосте видит общие метрики (CPU, RAM, диск хоста). Для детального мониторинга каждого LXC/VM:
- **Вариант 1:** Netdata parent/child — установить агент в каждый CT/VM, связать с родителем.
- **Вариант 2:** Один Netdata на хосте — мониторит хост и агрегирует по контейнерам через cgroups (если включено).
Для homelab обычно достаточно мониторинга хоста. При необходимости — см. [Netdata Parent-Child](https://learn.netdata.cloud/docs/agent/streaming).
---
## Отключение Netdata Cloud
Если не нужен trial/Cloud:
1. **Полное отключение:** удалить `/var/lib/netdata/cloud.d/` (токены, ключи) и создать заново только `cloud.conf`:
```bash
rm -rf /var/lib/netdata/cloud.d
mkdir -p /var/lib/netdata/cloud.d
echo -e "[global]\nenabled = no" > /var/lib/netdata/cloud.d/cloud.conf
chown -R netdata:netdata /var/lib/netdata/cloud.d
systemctl restart netdata
```
2. **Локальный дашборд** — http://host:19999 (анонимный доступ, без Cloud). Не использовать app.netdata.cloud — иначе снова появится claim/Cloud.
---
## Дашборд homelab
Кастомный дашборд с метриками хоста, контейнеров и сервисов: **http://192.168.1.150:19998**
Ссылка добавлена в Homepage (Сервисы → Homelab Dashboard). Деплой: `scripts/dashboard/deploy-dashboard.sh`. Подробнее: [dashboard-plan.md](dashboard-plan.md).
---
## Связанные документы
- [dashboard-plan.md](dashboard-plan.md) — план и реализация кастомного дашборда
- [smartd-setup.md](smartd-setup.md) — SMART и диски
- [backup-howto](../backup/backup-howto.md) — бэкапы

View File

@@ -0,0 +1,109 @@
# SMART и smartd — мониторинг дисков
Настройка `smartd` для мониторинга дисков Proxmox: NVMe, HDD, SSD. При отклонениях — уведомление в Telegram через `notify-telegram.sh`.
---
## Диски (из контекста homelab)
| Устройство | Тип | Размер | Использование |
|--------------|------|--------|----------------------------------|
| /dev/nvme0n1 | NVMe | 256 GB | LVM (система, local-lvm) |
| /dev/sda | HDD | 2 TB | ZFS |
| /dev/sdb | SSD | 2 TB | ext4, /mnt/backup |
| /dev/sdc | HDD | 2 TB | ZFS (RAID1 с sda) |
| /dev/sdd | HDD | 8 TB | ext4 (внешний) |
---
## Установка
```bash
apt install -y smartmontools
```
---
## Конфигурация smartd
Файл: `/etc/smartd.conf`. Текущая конфигурация на хосте:
```conf
# NVMe (система)
/dev/nvme0n1 -a -W 4,45,55 -m root -M exec /root/scripts/smartd-notify.sh
# HDD sda (ZFS)
/dev/sda -a -d ata -R 5 -R 197 -R 198 -W 4,45,55 -m root -M exec /root/scripts/smartd-notify.sh
# SSD sdb (backup)
/dev/sdb -a -d ata -R 5 -R 197 -R 198 -W 4,45,55 -m root -M exec /root/scripts/smartd-notify.sh
# HDD sdc (ZFS)
/dev/sdc -a -d ata -R 5 -R 197 -R 198 -W 4,45,55 -m root -M exec /root/scripts/smartd-notify.sh
# HDD sdd (внешний, USB/SAT)
/dev/sdd -a -d sat -R 5 -R 197 -R 198 -W 4,45,55 -m root -M exec /root/scripts/smartd-notify.sh
```
**Параметры:**
- `-a` — мониторить все атрибуты
- `-d ata` / `-d sat` — тип устройства (ata для SATA, sat для USB/SAT)
- `-R 5` — Reallocated_Sector_Ct
- `-R 197` — Current_Pending_Sector
- `-R 198` — Offline_Uncorrectable
- `-W 4,45,55` — температура: delta 4°C, warn 45°C, crit 55°C
- `-M exec` — выполнить скрипт при проблеме
---
## Скрипт уведомления в Telegram
Создать `/root/scripts/smartd-notify.sh`:
```bash
#!/bin/bash
# Вызывается smartd при обнаружении проблемы.
# Аргументы: device, type (health/usage/fail), message
# См. man smartd.conf (-M exec)
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
DEVICE="${1:-unknown}"
# smartd передаёт полный вывод в stdin
MSG=$(cat)
SUMMARY="${2:-SMART problem}"
if [ -x "$NOTIFY_SCRIPT" ]; then
"$NOTIFY_SCRIPT" "⚠️ SMART" "Диск $DEVICE: $SUMMARY
$MSG" || true
fi
# Передать дальше в mail (если настроен)
exit 0
```
Сделать исполняемым: `chmod +x /root/scripts/smartd-notify.sh`
**Примечание:** smartd при `-M exec` передаёт в скрипт до 3 аргументов и stdin. Точный формат см. в `man smartd.conf` (раздел -M exec).
---
## Запуск smartd
```bash
systemctl enable --now smartd
systemctl status smartd
```
Проверка вручную:
```bash
smartctl -a /dev/sda
smartctl -a /dev/nvme0n1
```
---
## Интеграция с Netdata
Netdata имеет плагин smartmontools. После установки smartmontools и настройки smartd Netdata может отображать метрики SMART на дашборде. См. [netdata-proxmox-setup.md](netdata-proxmox-setup.md).

View File

@@ -132,7 +132,7 @@ flowchart TB
│ VPS DE │ │ VPS US │ │ VPS Миран (СПБ) │
│ 185.103.253.99 │ │ 147.45.124.117 │ │ 185.147.80.190 │
│ AmneziaWG │ │ AmneziaWG │ │ coTURN (Galene), │
│ (обход блок.) │ │ (обход блок.) │ │ боты, Prometheus
│ (обход блок.) │ │ (обход блок.) │ │ Healthchecks, боты
└──────────────────┘ └──────────────────┘ └──────────────────────┘
```
@@ -160,7 +160,7 @@ flowchart TB
| **VM 200** | 192.168.1.200 | Immich, PostgreSQL, Redis, ML, deduper, Power Tools, Public Share | immich.katykhin.ru, immich-pt.katykhin.ru, share.katykhin.ru |
| **VPS DE** | 185.103.253.99 | AmneziaWG (обход блокировок) | Туннель с роутера (10.8.1.x) |
| **VPS US** | 147.45.124.117 | AmneziaWG (второй выход) | Туннель с роутера |
| **VPS Миран** | 185.147.80.190 | coTURN (STUN/TURN для Galene), боты, prod | call.katykhin.ru использует STUN/TURN |
| **VPS Миран** | 185.147.80.190 | coTURN (STUN/TURN для Galene), Healthchecks, боты, prod | call.katykhin.ru (STUN/TURN), healthchecks.katykhin.ru |
| **DNS** | Beget.com | Домен katykhin.ru, поддомены, API для DNS-01 | Все *.katykhin.ru |
---
@@ -241,6 +241,7 @@ flowchart TB
## Связь с другими документами
- [Архитектура и подключение](../architecture/architecture.md) — общее описание, таблица контейнеров, поток запросов.
- [Хост Proxmox](../containers/host-proxmox.md) — скрипты, таймеры, пути на 192.168.1.150.
- [Контейнер 100](../containers/container-100.md) — NPM, AdGuard, Homepage, порядок запуска.
- [Контейнер 109](../containers/container-109.md) — WireGuard VPN (local-vpn), доступ к vault и LAN.
- [Генерация .mobileconfig для WireGuard (On-Demand)](vpn-mobileconfig-wireguard.md) — как собрать профиль для iOS/macOS с автоматическим подключением вне дома.

View File

@@ -0,0 +1,112 @@
# Healthchecks на VPS Миран
Self-hosted [Healthchecks.io](https://healthchecks.io/) на VPS 185.147.80.190 — Dead man's switch для homelab. Если Proxmox не отправляет ping после окна бэкапов, Healthchecks шлёт алерт в Telegram.
---
## Доступ
| Параметр | Значение |
|----------|----------|
| **URL** | https://healthchecks.katykhin.ru/healthchecks/ |
| **Логин** | admin@katykhin.ru |
| **Пароль** | в Vaultwarden (Healthchecks admin) |
Доступ настроен по домену. Telegram webhook требует валидный SSL — без домена с Let's Encrypt бот не отвечает на `/start`.
---
## Развёртывание (для переустановки)
### 1. Подготовка
```bash
ssh -p 15722 deploy@185.147.80.190
mkdir -p /home/prod/healthchecks
cd /home/prod/healthchecks
```
Скопировать из репозитория: `scripts/healthchecks-docker/docker-compose.yml`, `scripts/healthchecks-docker/.env.example``.env`
### 2. Конфигурация .env
```env
SITE_ROOT=https://healthchecks.katykhin.ru/healthchecks
SECRET_KEY=<openssl rand -hex 32>
ALLOWED_HOSTS=healthchecks.katykhin.ru,185.147.80.190,localhost
DB_HOST=db
DB_NAME=hc
DB_USER=postgres
DB_PASSWORD=<надёжный пароль>
TELEGRAM_TOKEN=<токен из Vaultwarden: HOME_BOT_TOKEN>
TELEGRAM_BOT_NAME=<username бота из @BotFather, напр. Katykhinhomebot>
REGISTRATION_OPEN=False
DEFAULT_FROM_EMAIL=healthchecks@katykhin.ru
```
### 3. Запуск
```bash
docker-compose up -d
docker-compose run web /opt/healthchecks/manage.py createsuperuser --email admin@katykhin.ru --password <password>
docker-compose run web python /opt/healthchecks/manage.py settelegramwebhook
```
### 4. Nginx
Отдельный server block для `healthchecks.katykhin.ru` с Let's Encrypt. Референс: `scripts/healthchecks-nginx-server.conf`. Proxy на 127.0.0.1:8000; нужны location для `/healthchecks/`, `/static/`, `/projects/`, `/accounts/`, `/integrations/`, `/ping/` и др. (Django редиректы без префикса).
### 5. DNS
A-запись: `healthchecks.katykhin.ru``185.147.80.190`. Сертификат: `certbot --nginx -d healthchecks.katykhin.ru`.
---
## Привязка Telegram к check
1. Войти в Healthchecks → **Integrations****Add Integration****Telegram**
2. Писать **своему** боту (из TELEGRAM_TOKEN), не @HealthchecksBot
3. В Telegram: `/start` боту → перейти по ссылке → **Connect** в веб-интерфейсе
Check **homelab-backups** (UUID: 9451b52b-89f5-4a6c-b922-247a775bbf45).
---
## Ping с Proxmox
Скрипт `/root/scripts/healthcheck-ping.sh`, таймер `backup-healthcheck-ping.timer` — 04:35 ежедневно.
Конфиг `/root/.healthchecks.env`:
```env
HEALTHCHECKS_URL=https://healthchecks.katykhin.ru/healthchecks
HEALTHCHECKS_HOMELAB_UUID=<uuid из Healthchecks>
```
---
## Смена пароля без SMTP
Healthchecks требует SMTP для смены пароля через веб. Без SMTP — через Django:
```bash
cd /home/prod/healthchecks
docker-compose run web python /opt/healthchecks/manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.get(email='admin@katykhin.ru')
u.set_password('NEW_PASSWORD')
u.save()
print('OK')
"
```
---
## Связанные документы
- [vps-miran-bots](vps-miran-bots.md) — VPS Миран, порты
- [backup-howto](../backup/backup-howto.md) — бэкапы, расписание

View File

@@ -81,6 +81,9 @@ VPS в ЦОД Миран (Санкт-Петербург). Развёрнуты
| 9100 | node-exporter | TCP |
| 3000 | Grafana | TCP |
| 3001 | Uptime Kuma | TCP |
| 8000 | Healthchecks (внутр.) | TCP |
**Healthchecks** — self-hosted Dead man's switch для homelab. Развёртывание: [healthchecks-miran-setup.md](healthchecks-miran-setup.md). Доступ через nginx (healthchecks.katykhin.ru).
---
@@ -110,7 +113,7 @@ docker compose logs -f telegram-bot
## Бэкап VPS (telegram-helper-bot)
Бэкап выполняется **с хоста Proxmox** скриптом `backup-vps-miran.sh` (cron 01:00). Копируются:
Бэкап выполняется **с хоста Proxmox** скриптом `backup-vps-miran.sh` (systemd timer 01:00). Копируются:
1. **БД:** `/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db``/mnt/backup/vps/miran/db/` (с датой в имени, хранение 14 дней).
2. **Голосовые сообщения:** `/home/prod/bots/telegram-helper-bot/voice_users/``/mnt/backup/vps/miran/voice_users/` (rsync).

View File

@@ -4,6 +4,8 @@
# Запускать на хосте Proxmox под root. Секреты из Vaultwarden (объект RESTIC), файл /root/.bw-master (chmod 600).
# Cron: 10 4 * * * (04:10, после основного restic в 04:00).
set -e
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export HOME="${HOME:-/root}"
BACKUP_PATH="/mnt/backup/photos"
# Время запуска (для логов и уведомлений)

View File

@@ -6,6 +6,9 @@
# Перед первым запуском: установить restic, bw (Bitwarden CLI), jq; bw config server https://vault.katykhin.ru; restic init.
# Cron: 0 4 * * * (04:00, после окна 01:0003:30; 05:00 зарезервировано под перезагрузку).
set -e
# При запуске из systemd PATH и HOME могут быть пустыми
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export HOME="${HOME:-/root}"
BACKUP_PATH="/mnt/backup"
# Время запуска (для логов и уведомлений)

View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Добавить Homelab Dashboard в Homepage (services.yaml на CT 103)
# Запуск: с хоста Proxmox — pct exec 103 -- bash -s < /root/scripts/dashboard/add-to-homepage.sh
set -e
SERVICES_YAML="${SERVICES_YAML:-/opt/docker/homepage/config/services.yaml}"
if [ ! -f "$SERVICES_YAML" ]; then
echo "ERROR: $SERVICES_YAML not found"
exit 1
fi
if grep -q "Homelab Dashboard" "$SERVICES_YAML" 2>/dev/null; then
echo "Homelab Dashboard already in services.yaml"
exit 0
fi
# Вставить после блока Netdata (ping: http://192.168.1.150:19999)
sed -i '/ping: http:\/\/192.168.1.150:19999$/a\
- Homelab Dashboard:\
icon: mdi-chart-box\
href: http://192.168.1.150:19998\
description: Мониторинг хоста, контейнеров, сервисов\
target: _blank
' "$SERVICES_YAML"
echo "Added Homelab Dashboard to services.yaml"

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Экспортер метрик для дашборда homelab: disk % и OOM по контейнерам/VM.
Запуск: python3 dashboard-exporter.py (выводит JSON в stdout)
"""
import json
import subprocess
import sys
from pathlib import Path
# Маппинг: (vmid, type) -> (name, cgroup_name для Netdata)
CONTAINERS = [
(100, "lxc", "nginx", "cgroup_nginx"),
(101, "lxc", "nextcloud", "cgroup_nextcloud"),
(103, "lxc", "gitea", "cgroup_gitea"),
(104, "lxc", "paperless", "cgroup_paperless"),
(105, "lxc", "rag-service", "cgroup_rag-service"),
(107, "lxc", "misc", "cgroup_misc"),
(108, "lxc", "galene", "cgroup_galene"),
(109, "lxc", "local-vpn", "cgroup_local-vpn"),
(200, "qemu", "immich", "cgroup_qemu_immich"),
]
LXC_CGROUP = Path("/sys/fs/cgroup/lxc")
QEMU_CGROUP_200 = Path("/sys/fs/cgroup/qemu.slice/200.scope")
def get_disk_pct_lxc(vmid: int) -> float | None:
"""Disk % для LXC через pct exec df."""
try:
r = subprocess.run(
["pct", "exec", str(vmid), "--", "df", "-P", "/"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return None
lines = r.stdout.strip().split("\n")
if len(lines) < 2:
return None
# Формат: Filesystem 1K-blocks Used Available Use% Mounted
parts = lines[-1].split()
if len(parts) >= 5:
use_pct = parts[4].rstrip("%")
return float(use_pct)
except (subprocess.TimeoutExpired, ValueError):
pass
return None
def get_disk_pct_vm200() -> float | None:
"""Disk % для VM 200 через lvs (fallback, т.к. qm guest exec часто недоступен)."""
try:
r = subprocess.run(
["lvs", "-o", "data_percent", "--noheadings", "pve/vm-200-disk-0"],
capture_output=True,
text=True,
timeout=5,
)
if r.returncode != 0:
return None
val = r.stdout.strip()
if val:
return float(val)
except (subprocess.TimeoutExpired, ValueError):
pass
return None
def get_oom_count(vmid: int, vmtype: str) -> int | None:
"""OOM count из cgroup memory.events."""
if vmtype == "lxc":
path = LXC_CGROUP / str(vmid) / "memory.events"
elif vmtype == "qemu" and vmid == 200:
path = QEMU_CGROUP_200 / "memory.events"
else:
return None
if not path.exists():
return None
try:
text = path.read_text()
for line in text.splitlines():
if line.startswith("oom_kill "):
return int(line.split()[1])
except (OSError, ValueError):
pass
return None
def main() -> None:
result = {"containers": [], "ok": True}
for vmid, vmtype, name, cgroup_name in CONTAINERS:
disk_pct = None
if vmtype == "lxc":
disk_pct = get_disk_pct_lxc(vmid)
elif vmtype == "qemu" and vmid == 200:
disk_pct = get_disk_pct_vm200()
oom = get_oom_count(vmid, vmtype)
result["containers"].append({
"vmid": vmid,
"name": name,
"cgroup_name": cgroup_name,
"disk_pct": disk_pct,
"oom_count": oom,
})
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
HTTP-сервер дашборда homelab: статика, /api/containers, прокси к Netdata.
Порт: 19998 (по умолчанию).
"""
import json
import os
import subprocess
import sys
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
PORT = int(os.environ.get("DASHBOARD_PORT", "19998"))
NETDATA_URL = os.environ.get("NETDATA_URL", "http://127.0.0.1:19999")
SCRIPT_DIR = Path(__file__).resolve().parent
EXPORTER = SCRIPT_DIR / "dashboard-exporter.py"
class DashboardHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # подавить вывод в консоль
def send_json(self, data: dict, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
def send_html(self, html: bytes, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(html)
def do_GET(self):
path = self.path.split("?")[0].rstrip("/") or "/"
if path == "/":
self.serve_index()
elif path == "/api/containers":
self.serve_containers()
elif path.startswith("/api/netdata"):
self.proxy_netdata()
else:
self.send_error(404)
def serve_index(self):
html_file = SCRIPT_DIR / "index.html"
if html_file.exists():
self.send_html(html_file.read_bytes())
else:
self.send_error(404, "index.html not found")
def serve_containers(self):
try:
r = subprocess.run(
[sys.executable, str(EXPORTER)],
capture_output=True,
text=True,
timeout=30,
cwd=str(SCRIPT_DIR),
)
if r.returncode != 0:
self.send_json({"ok": False, "error": r.stderr or "exporter failed"}, 500)
return
data = json.loads(r.stdout)
self.send_json(data)
except subprocess.TimeoutExpired:
self.send_json({"ok": False, "error": "timeout"}, 504)
except json.JSONDecodeError as e:
self.send_json({"ok": False, "error": str(e)}, 500)
except Exception as e:
self.send_json({"ok": False, "error": str(e)}, 500)
def proxy_netdata(self):
qs = self.path.split("?", 1)[1] if "?" in self.path else ""
url = f"{NETDATA_URL}/api/v1/data?{qs}" if qs else f"{NETDATA_URL}/api/v1/data"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as resp:
data = resp.read()
self.send_response(200)
self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json"))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
except Exception as e:
self.send_json({"error": str(e)}, 502)
def main():
server = HTTPServer(("0.0.0.0", PORT), DashboardHandler)
print(f"Dashboard server on http://0.0.0.0:{PORT}", file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Деплой дашборда homelab на хост Proxmox
# Запуск: с хоста Proxmox или ssh root@192.168.1.150 'bash -s' < scripts/dashboard/deploy-dashboard.sh
# Или из репозитория: ./scripts/dashboard/deploy-dashboard.sh (копирует из текущей директории)
set -e
# REPO_ROOT: корень репозитория (содержит scripts/dashboard/)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
DASHBOARD_SRC="${REPO_ROOT}/scripts/dashboard"
DEST="/root/scripts/dashboard"
SYSTEMD_DEST="/etc/systemd/system"
log() { echo "[$(date -Iseconds)] $*"; }
log "Deploying homelab dashboard..."
mkdir -p "$DEST"
if [ "$(realpath "$DASHBOARD_SRC")" != "$(realpath "$DEST")" ]; then
cp -v "${DASHBOARD_SRC}/dashboard-exporter.py" "$DEST/"
cp -v "${DASHBOARD_SRC}/dashboard-server.py" "$DEST/"
cp -v "${DASHBOARD_SRC}/index.html" "$DEST/"
fi
chmod +x "${DEST}/dashboard-exporter.py" "${DEST}/dashboard-server.py"
if [ -f "${REPO_ROOT}/scripts/systemd/homelab-dashboard.service" ]; then
cp -v "${REPO_ROOT}/scripts/systemd/homelab-dashboard.service" "$SYSTEMD_DEST/"
fi
systemctl daemon-reload
systemctl enable homelab-dashboard.service
systemctl restart homelab-dashboard.service
log "Dashboard deployed. URL: http://192.168.1.150:19998"
log "Status: $(systemctl is-active homelab-dashboard.service)"

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab Dashboard</title>
<style>
:root { --bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff; --ok: #3fb950; --warn: #d29922; --err: #f85149; }
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; line-height: 1.5; }
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
h2 { font-size: 1rem; margin: 0 0 0.5rem; color: var(--muted); font-weight: 500; }
.card { background: var(--card); border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; }
.metric { text-align: center; }
.metric-value { font-size: 1.5rem; font-weight: 600; }
.metric-label { font-size: 0.75rem; color: var(--muted); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #30363d; }
th { color: var(--muted); font-weight: 500; font-size: 0.85rem; }
.pct-ok { color: var(--ok); }
.pct-warn { color: var(--warn); }
.pct-err { color: var(--err); }
.links { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
.links a { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
.links a:hover { text-decoration: underline; }
.loading { color: var(--muted); }
.error { color: var(--err); }
.updated { font-size: 0.75rem; color: var(--muted); margin-top: 0.5rem; }
</style>
</head>
<body>
<h1>Homelab Dashboard</h1>
<div class="card">
<h2>Блок 1 — Хост</h2>
<div class="grid" id="host-metrics">
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">CPU %</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">RAM</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Load</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">iowait %</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk /</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk backup</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk nextcloud-hdd</span></div>
<div class="metric"><span class="metric-value loading"></span><span class="metric-label">Disk tank</span></div>
</div>
</div>
<div class="card">
<h2>Блок 2 — Контейнеры</h2>
<table>
<thead>
<tr><th>Контейнер</th><th>CPU %</th><th>RAM %</th><th>Disk %</th><th>OOM</th></tr>
</thead>
<tbody id="containers-table"></tbody>
</table>
</div>
<div class="card">
<h2>Блок 3 — Критические сервисы</h2>
<div class="links">
<a href="http://192.168.1.150:19999/#menu_system_submenu_cpu;netdata" target="_blank">Netdata (CPU)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_nginx;netdata" target="_blank">nginx (CT 100)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_nextcloud;netdata" target="_blank">Nextcloud (CT 101)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_qemu_immich;netdata" target="_blank">Immich (VM 200)</a>
<a href="http://192.168.1.150:19999/#menu_cgroup_local-vpn;netdata" target="_blank">VPN (CT 109)</a>
</div>
</div>
<div class="updated" id="updated"></div>
<div id="status" class="updated" style="color:var(--muted)"></div>
<script>
const API = window.location.origin; // явно использовать текущий origin
function pctClass(v) {
if (v == null) return '';
if (v >= 90) return 'pct-err';
if (v >= 75) return 'pct-warn';
return 'pct-ok';
}
function fmt(v, suffix = '') {
if (v == null || v === undefined) return '—';
if (typeof v === 'number') return v.toFixed(1) + suffix;
return String(v) + suffix;
}
async function fetchNetdata(chart, points = 1) {
const url = `${API}/api/netdata?chart=${encodeURIComponent(chart)}&points=${points}&format=json`;
const r = await fetch(url);
if (!r.ok) throw new Error(`${chart}: ${r.status}`);
return r.json();
}
async function loadHost() {
try {
const results = await Promise.allSettled([
fetchNetdata('system.cpu'),
fetchNetdata('system.ram'),
fetchNetdata('system.load'),
fetchNetdata('disk_space./'),
fetchNetdata('disk_space./mnt/backup'),
fetchNetdata('disk_space./mnt/nextcloud-hdd'),
fetchNetdata('disk_space./tank'),
]);
const [cpu, ram, load, diskRoot, diskBackup, diskNextcloud, diskTank] = results.map(r => r.status === 'fulfilled' ? r.value : null);
const cpuData = cpu?.data?.[0];
const ramData = ram?.data?.[0];
const loadData = load?.data?.[0];
const li = cpu?.labels || [];
const cpuTotal = cpuData ? (li.indexOf('user') >= 0 ? (cpuData[li.indexOf('user')] || 0) + (cpuData[li.indexOf('system')] || 0) + (cpuData[li.indexOf('nice')] || 0) + (cpuData[li.indexOf('iowait')] || 0) + (cpuData[li.indexOf('irq')] || 0) + (cpuData[li.indexOf('softirq')] || 0) + (cpuData[li.indexOf('steal')] || 0) + (cpuData[li.indexOf('guest')] || 0) + (cpuData[li.indexOf('guest_nice')] || 0) : 0) : null;
const iowait = cpuData && li.indexOf('iowait') >= 0 ? cpuData[li.indexOf('iowait')] : null;
const ramUsed = ramData && ram?.labels ? ramData[ram.labels.indexOf('used')] : null;
const load15 = loadData && load?.labels ? loadData[load.labels.indexOf('load15')] : null;
// disk_space возвращает avail/used в GiB, считаем %: used/(used+avail)*100
const diskPct = (d) => {
if (!d?.data?.[0] || !d?.labels) return null;
const idxU = d.labels.indexOf('used'), idxA = d.labels.indexOf('avail');
if (idxU < 0 || idxA < 0) return null;
const used = d.data[0][idxU], avail = d.data[0][idxA];
const total = used + avail;
return total > 0 ? (used / total * 100) : null;
};
const diskRootUsed = diskPct(diskRoot);
const diskBackupUsed = diskPct(diskBackup);
const diskNextcloudUsed = diskPct(diskNextcloud);
const diskTankUsed = diskPct(diskTank);
document.getElementById('host-metrics').innerHTML = `
<div class="metric"><span class="metric-value">${fmt(cpuTotal, '%')}</span><span class="metric-label">CPU %</span></div>
<div class="metric"><span class="metric-value">${fmt(ramUsed, ' MiB')}</span><span class="metric-label">RAM used</span></div>
<div class="metric"><span class="metric-value">${fmt(load15)}</span><span class="metric-label">Load 15</span></div>
<div class="metric"><span class="metric-value">${fmt(iowait, '%')}</span><span class="metric-label">iowait %</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskRootUsed)}">${fmt(diskRootUsed, '%')}</span><span class="metric-label">Disk /</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskBackupUsed)}">${fmt(diskBackupUsed, '%')}</span><span class="metric-label">Disk backup</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskNextcloudUsed)}">${fmt(diskNextcloudUsed, '%')}</span><span class="metric-label">Disk nextcloud-hdd</span></div>
<div class="metric"><span class="metric-value ${pctClass(diskTankUsed)}">${fmt(diskTankUsed, '%')}</span><span class="metric-label">Disk tank</span></div>
`;
} catch (e) {
document.getElementById('host-metrics').innerHTML = `<div class="error">Ошибка: ${e.message}</div>`;
}
}
const CGROUP_CHARTS = {
'cgroup_nginx': { cpu: 'cgroup_nginx.cpu_limit', mem: 'cgroup_nginx.mem_utilization' },
'cgroup_nextcloud': { cpu: 'cgroup_nextcloud.cpu_limit', mem: 'cgroup_nextcloud.mem_utilization' },
'cgroup_gitea': { cpu: 'cgroup_gitea.cpu_limit', mem: 'cgroup_gitea.mem_utilization' },
'cgroup_paperless': { cpu: 'cgroup_paperless.cpu_limit', mem: 'cgroup_paperless.mem_utilization' },
'cgroup_rag-service': { cpu: 'cgroup_rag-service.cpu_limit', mem: 'cgroup_rag-service.mem_utilization' },
'cgroup_misc': { cpu: 'cgroup_misc.cpu_limit', mem: 'cgroup_misc.mem_utilization' },
'cgroup_galene': { cpu: 'cgroup_galene.cpu_limit', mem: 'cgroup_galene.mem_utilization' },
'cgroup_local-vpn': { cpu: 'cgroup_local-vpn.cpu_limit', mem: 'cgroup_local-vpn.mem_utilization' },
'cgroup_qemu_immich': { cpu: 'cgroup_qemu_immich.cpu_limit', mem: 'cgroup_qemu_immich.mem_utilization' },
};
async function loadContainers() {
try {
const containersRes = await fetch(`${API}/api/containers`);
if (!containersRes.ok) throw new Error(`API ${containersRes.status}`);
const containersData = await containersRes.json();
if (!containersData.ok || !containersData.containers) {
document.getElementById('containers-table').innerHTML = `<tr><td colspan="5" class="error">Ошибка загрузки</td></tr>`;
return;
}
const containers = containersData.containers;
const cpuPromises = containers.map(c => {
const charts = CGROUP_CHARTS[c.cgroup_name];
if (!charts) return [null, null];
return Promise.all([
fetchNetdata(charts.cpu).then(d => d.data?.[0]?.[d.labels?.indexOf('used') ?? 0] != null ? d.data[0][d.labels.indexOf('used')] * 100 : null),
fetchNetdata(charts.mem).then(d => d.data?.[0]?.[d.labels?.indexOf('utilization') ?? 0] != null ? d.data[0][d.labels.indexOf('utilization')] : null),
]);
});
const netdataRows = await Promise.all(cpuPromises);
const rows = containers.map((c, i) => {
const [cpuPct, ramPct] = netdataRows[i] || [null, null];
return `<tr>
<td>${c.name} (${c.vmid})</td>
<td class="${pctClass(cpuPct)}">${fmt(cpuPct, '%')}</td>
<td class="${pctClass(ramPct)}">${fmt(ramPct, '%')}</td>
<td class="${pctClass(c.disk_pct)}">${fmt(c.disk_pct, '%')}</td>
<td>${c.oom_count != null ? c.oom_count : '—'}</td>
</tr>`;
});
document.getElementById('containers-table').innerHTML = rows.join('');
} catch (e) {
document.getElementById('containers-table').innerHTML = `<tr><td colspan="5" class="error">Ошибка: ${e.message}. Проверьте доступ к ${API}</td></tr>`;
}
}
async function refresh() {
const statusEl = document.getElementById('status');
statusEl.textContent = 'Загрузка...';
try {
await Promise.all([loadHost(), loadContainers()]);
document.getElementById('updated').textContent = 'Обновлено: ' + new Date().toLocaleString('ru');
statusEl.textContent = '';
} catch (e) {
document.getElementById('updated').textContent = '';
statusEl.textContent = 'Ошибка: ' + e.message;
statusEl.style.color = 'var(--err)';
}
}
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>

20
scripts/healthcheck-ping.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Ping Healthchecks после успешного окна бэкапов (Dead man's switch).
# Если ping не пришёл — Healthchecks шлёт алерт в Telegram.
# Конфиг: /root/.healthchecks.env (HEALTHCHECKS_URL, HEALTHCHECKS_HOMELAB_UUID)
CONFIG="${HEALTHCHECKS_CONFIG:-/root/.healthchecks.env}"
if [ -f "$CONFIG" ]; then
set -a
# shellcheck source=/dev/null
source "$CONFIG"
set +a
fi
HC_URL="${HEALTHCHECKS_URL:-https://healthchecks.katykhin.ru}"
HC_UUID="${HEALTHCHECKS_HOMELAB_UUID:-}"
[ -z "$HC_UUID" ] && exit 0
curl -fsS --retry 3 --max-time 10 "${HC_URL}/ping/${HC_UUID}" >/dev/null 2>&1 || true
exit 0

View File

@@ -0,0 +1,23 @@
# Healthchecks на VPS Миран
# Копировать: cp .env.example .env
SITE_ROOT=https://healthchecks.katykhin.ru/healthchecks
SECRET_KEY=CHANGE_ME_openssl_rand_hex_32
ALLOWED_HOSTS=healthchecks.katykhin.ru,185.147.80.190,localhost
DB=postgres
DB_HOST=db
DB_NAME=hc
DB_USER=postgres
DB_PASSWORD=CHANGE_ME_secure_password
# Свой бот (не @HealthchecksBot!) — создать через @BotFather, username бота
TELEGRAM_TOKEN=
TELEGRAM_BOT_NAME=YourBotUsername
REGISTRATION_OPEN=False
EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
DEFAULT_FROM_EMAIL=healthchecks@katykhin.ru

View File

@@ -0,0 +1,31 @@
# Healthchecks на VPS Миран
# Копировать: cp -r scripts/healthchecks-docker /home/prod/healthchecks
# cd /home/prod/healthchecks && cp .env.example .env && редактировать .env
volumes:
db-data:
services:
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${DB_NAME:-hc}
- POSTGRES_PASSWORD=${DB_PASSWORD}
restart: unless-stopped
web:
image: healthchecks/healthchecks:latest
env_file:
- .env
environment:
- DB_HOST=db
- DB_NAME=${DB_NAME:-hc}
- DB_USER=postgres
- DB_PASSWORD=${DB_PASSWORD}
ports:
- "127.0.0.1:8000:8000"
depends_on:
- db
restart: unless-stopped

View File

@@ -0,0 +1,25 @@
# Референс: server block для healthchecks.katykhin.ru (Let's Encrypt, Telegram webhook)
# Вставить в nginx.conf после HTTP redirect server block
server {
listen 443 ssl http2;
server_name healthchecks.katykhin.ru;
ssl_certificate /etc/letsencrypt/live/healthchecks.katykhin.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/healthchecks.katykhin.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location = / { return 302 /healthchecks/; }
location /static/ { proxy_pass http://127.0.0.1:8000/static/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /projects/ { proxy_pass http://127.0.0.1:8000/projects/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /accounts/ { proxy_pass http://127.0.0.1:8000/accounts/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /integrations/ { proxy_pass http://127.0.0.1:8000/integrations/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /ping/ { proxy_pass http://127.0.0.1:8000/ping/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /admin/ { proxy_pass http://127.0.0.1:8000/admin/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /badge/ { proxy_pass http://127.0.0.1:8000/badge/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /checks/ { proxy_pass http://127.0.0.1:8000/checks/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /docs/ { proxy_pass http://127.0.0.1:8000/docs/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /tv/ { proxy_pass http://127.0.0.1:8000/tv/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location = /healthchecks/ { return 302 /healthchecks/accounts/login/; }
location = /healthchecks { return 302 /healthchecks/accounts/login/; }
location /healthchecks/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
}

View File

@@ -0,0 +1,6 @@
# Конфиг для healthcheck-ping.sh (Proxmox)
# Копировать: cp healthchecks.env.example /root/.healthchecks.env
# UUID — из веб-интерфейса Healthchecks после создания check "homelab-backups"
HEALTHCHECKS_URL=https://healthchecks.katykhin.ru
HEALTHCHECKS_HOMELAB_UUID=

30
scripts/smartd-notify.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Вызывается smartd при обнаружении проблемы (-M exec).
# Аргументы: $1 = device, $2 = type (1=health, 2=usage, 3=fail), $3 = message
# См. man smartd.conf
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
DEVICE="${1:-unknown}"
TYPE="${2:-}"
MSG="${3:-}"
# Дополнительный вывод smartd может быть в stdin
EXTRA=$(cat 2>/dev/null || true)
case "$TYPE" in
1) SUMMARY="Health check failed" ;;
2) SUMMARY="Usage attribute warning" ;;
3) SUMMARY="Usage attribute failure" ;;
*) SUMMARY="SMART problem" ;;
esac
if [ -x "$NOTIFY_SCRIPT" ]; then
BODY="Диск $DEVICE: $SUMMARY"
[ -n "$MSG" ] && BODY="${BODY}
$MSG"
[ -n "$EXTRA" ] && BODY="${BODY}
$EXTRA"
"$NOTIFY_SCRIPT" "⚠️ SMART" "$BODY" || true
fi
exit 0

24
scripts/systemd/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Systemd unit-файлы для бэкапов и мониторинга
Копировать на хост Proxmox в `/etc/systemd/system/`:
```bash
cp *.service *.timer /etc/systemd/system/
systemctl daemon-reload
```
Включить все таймеры:
```bash
for t in backup-*.timer notify-vzdump-success.timer verify-*.timer backup-watchdog-timers.timer backup-healthcheck-ping.timer; do
systemctl enable --now "$t" 2>/dev/null || true
done
```
Проверка:
```bash
systemctl list-timers --all | grep backup
```
Перед миграцией с cron — отключить задания в crontab (`crontab -e`).

View File

@@ -0,0 +1,14 @@
# Бэкап БД Nextcloud (CT 101)
[Unit]
Description=Backup Nextcloud PostgreSQL (CT 101)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-ct101-pgdump.sh && echo $(date -Iseconds) > /var/run/backup-ct101-pgdump.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup Nextcloud DB daily at 01:15
[Timer]
OnCalendar=*-*-* 01:15:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап БД Gitea (CT 103)
[Unit]
Description=Backup Gitea PostgreSQL (CT 103)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-ct103-gitea-pgdump.sh && echo $(date -Iseconds) > /var/run/backup-ct103-gitea-pgdump.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup Gitea DB daily at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап БД Paperless (CT 104)
[Unit]
Description=Backup Paperless PostgreSQL (CT 104)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-ct104-pgdump.sh && echo $(date -Iseconds) > /var/run/backup-ct104-pgdump.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup Paperless DB daily at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап векторов RAG (CT 105)
[Unit]
Description=Backup RAG vectors (CT 105)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-ct105-vectors.sh && echo $(date -Iseconds) > /var/run/backup-ct105-vectors.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup RAG vectors daily at 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап /etc/pve и конфигов хоста
[Unit]
Description=Backup Proxmox host config (/etc/pve, interfaces, hosts)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-etc-pve.sh && echo $(date -Iseconds) > /var/run/backup-etc-pve.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup etc-pve daily at 02:15
[Timer]
OnCalendar=*-*-* 02:15:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Ping Healthchecks после окна бэкапов (Dead man's switch)
[Unit]
Description=Ping Healthchecks (homelab backups)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/healthcheck-ping.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Ping Healthchecks daily at 04:35 (after backup window)
[Timer]
OnCalendar=*-*-* 04:35:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап библиотеки фото Immich (rsync с VM 200)
[Unit]
Description=Backup Immich photos (rsync from VM 200)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-immich-photos.sh && echo $(date -Iseconds) > /var/run/backup-immich-photos.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup Immich photos daily at 01:30
[Timer]
OnCalendar=*-*-* 01:30:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,16 @@
# Выгрузка /mnt/backup/photos в Yandex S3 через restic
[Unit]
Description=Backup photos to Yandex S3 (restic)
After=network-online.target
[Service]
Type=oneshot
Environment=HOME=/root
Environment=PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/bin/sh -c '/root/scripts/backup-restic-yandex-photos.sh && echo $(date -Iseconds) > /var/run/backup-restic-yandex-photos.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Restic backup photos to Yandex daily at 04:10
[Timer]
OnCalendar=*-*-* 04:10:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,16 @@
# Выгрузка /mnt/backup (без photos) в Yandex S3 через restic
[Unit]
Description=Backup to Yandex S3 (restic, main)
After=network-online.target
[Service]
Type=oneshot
Environment=HOME=/root
Environment=PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/bin/sh -c '/root/scripts/backup-restic-yandex.sh && echo $(date -Iseconds) > /var/run/backup-restic-yandex.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Restic backup to Yandex daily at 04:00
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап данных Vaultwarden (CT 103)
[Unit]
Description=Backup Vaultwarden data (CT 103)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-vaultwarden-data.sh && echo $(date -Iseconds) > /var/run/backup-vaultwarden-data.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup Vaultwarden daily at 02:45
[Timer]
OnCalendar=*-*-* 02:45:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап БД Immich (VM 200)
[Unit]
Description=Backup Immich PostgreSQL (VM 200)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-vm200-pgdump.sh && echo $(date -Iseconds) > /var/run/backup-vm200-pgdump.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup Immich DB daily at 03:15
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,17 @@
# Копировать на Proxmox: /etc/systemd/system/
# systemctl daemon-reload && systemctl enable --now backup-vps-miran.timer
# Удалить из cron: 0 1 * * *
[Unit]
Description=Backup VPS Miran (БД бота, voice_users, S3)
After=network-online.target
[Service]
Type=oneshot
# Запись .ok только при успехе (для watchdog)
ExecStart=/bin/sh -c '/root/scripts/backup-vps-miran.sh && echo $(date -Iseconds) > /var/run/backup-vps-miran.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup VPS Miran daily at 01:00
[Timer]
OnCalendar=*-*-* 01:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Бэкап конфигов MTProto + сайт (VPS Германия)
[Unit]
Description=Backup VPS MTProto (Germany)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/root/scripts/backup-vps-mtproto.sh && echo $(date -Iseconds) > /var/run/backup-vps-mtproto.ok'
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup VPS MTProto daily at 01:45
[Timer]
OnCalendar=*-*-* 01:45:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Watchdog: проверка failed timers и устаревших healthcheck-файлов
[Unit]
Description=Backup watchdog (failed timers, stale .ok files)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/watchdog-timers.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Backup watchdog daily at 12:00
[Timer]
OnCalendar=*-*-* 12:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,19 @@
# Дашборд мониторинга homelab (хост, контейнеры, сервисы)
# Порт 19998, статика + API + прокси к Netdata
[Unit]
Description=Homelab Dashboard (monitoring)
After=network-online.target netdata.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /root/scripts/dashboard/dashboard-server.py
WorkingDirectory=/root/scripts/dashboard
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
# Проверка локального vzdump за последние 2 ч, отправка сводки в Telegram
# Задание vzdump в Proxmox UI выполняется в 02:00
[Unit]
Description=Notify vzdump success (check dump dir, send Telegram)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/notify-vzdump-success.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Notify vzdump success daily at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Restic check --read-data (раз в 6 мес: 1 янв и 1 июля)
[Unit]
Description=Verify restic repository (full read-data, semiannual)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/verify-restore-level1.sh full-check
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Restic full check semiannual (Jan 1, Jul 1 at 10:00)
[Timer]
OnCalendar=*-01-01 10:00:00
OnCalendar=*-07-01 10:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Restic check --read-data-subset=10% (ежемесячно, 1-е число)
[Unit]
Description=Verify restic repository (monthly read-data-subset)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/verify-restore-level1.sh monthly-check
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Restic check read-data-subset monthly (1st at 10:00)
[Timer]
OnCalendar=*-*-01 10:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Тест restore дампа Nextcloud из restic (ежемесячно)
[Unit]
Description=Verify Nextcloud dump restore from restic (monthly)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/verify-restore-level1.sh monthly-dump
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Verify Nextcloud dump restore monthly (1st at 11:00)
[Timer]
OnCalendar=*-*-01 11:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Restic check (еженедельно)
[Unit]
Description=Verify restic repository (weekly check)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/verify-restore-level1.sh weekly
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Restic check weekly (Sunday 03:00)
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=yes
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Автотест vzdump CT 107 (ежемесячно)
[Unit]
Description=Verify vzdump restore (CT 107, monthly)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/root/scripts/verify-vzdump-level2.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Verify vzdump restore monthly (1st at 12:00)
[Timer]
OnCalendar=*-*-01 12:00:00
Persistent=yes
[Install]
WantedBy=timers.target

147
scripts/verify-restore-level1.sh Executable file
View File

@@ -0,0 +1,147 @@
#!/bin/bash
# Тест восстановления уровня 1: restic check и проверка дампа Nextcloud из restic.
# Запускать на хосте Proxmox под root.
# Режимы (аргумент): weekly | monthly-check | full-check | monthly-dump
# weekly — restic check (еженедельно)
# monthly-check — restic check --read-data-subset=10% (ежемесячно, 1-е число)
# full-check — restic check --read-data (раз в 612 мес, 1 янв и 1 июля)
# monthly-dump — restore дампа Nextcloud из restic, проверка целостности (ежемесячно)
# Секреты: из Vaultwarden (объект RESTIC), как в backup-restic-yandex.sh.
# Cron/Timer: отдельные таймеры для каждого режима.
set -e
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
MODE="${1:-weekly}"
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
RESTORE_TARGET="/tmp/restore-test"
RESTIC_PATH_NEXTCLOUD="/mnt/backup/databases/ct101-nextcloud"
MIN_DUMP_SIZE_MB=1
if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root."
exit 1
fi
# Загрузка кредов restic из Vaultwarden (как в backup-restic-yandex.sh)
setup_restic_env() {
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ ! -f "$BW_MASTER_PASSWORD_FILE" ]; then
echo "Нет файла с мастер-паролем Vaultwarden: $BW_MASTER_PASSWORD_FILE"
return 1
fi
if ! command -v bw >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
echo "Установите bw (Bitwarden CLI) и jq."
return 1
fi
export BW_SESSION
BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || return 1
RESTIC_ITEM=$(bw get item "RESTIC" 2>/dev/null) || return 1
export RESTIC_REPOSITORY
RESTIC_REPOSITORY=$(echo "$RESTIC_ITEM" | jq -r '.fields[] | select(.name=="RESTIC_REPOSITORY") | .value')
export AWS_ACCESS_KEY_ID
AWS_ACCESS_KEY_ID=$(echo "$RESTIC_ITEM" | jq -r '.fields[] | select(.name=="AWS_ACCESS_KEY_ID") | .value')
export AWS_SECRET_ACCESS_KEY
AWS_SECRET_ACCESS_KEY=$(echo "$RESTIC_ITEM" | jq -r '.fields[] | select(.name=="AWS_SECRET_ACCESS_KEY") | .value')
export AWS_DEFAULT_REGION
AWS_DEFAULT_REGION=$(echo "$RESTIC_ITEM" | jq -r '.fields[] | select(.name=="AWS_DEFAULT_REGION") | .value')
[ -z "$AWS_DEFAULT_REGION" ] && AWS_DEFAULT_REGION="ru-central1"
RESTIC_PASS=$(echo "$RESTIC_ITEM" | jq -r '.fields[] | select(.name=="RESTIC_BACKUP_KEY") | .value')
for var in RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
[ -z "${!var}" ] && return 1
done
RESTIC_PASSWORD_FILE=$(mktemp -u)
echo -n "$RESTIC_PASS" > "$RESTIC_PASSWORD_FILE"
chmod 600 "$RESTIC_PASSWORD_FILE"
trap 'rm -f "$RESTIC_PASSWORD_FILE"' EXIT INT TERM
export RESTIC_PASSWORD_FILE
return 0
}
notify_ok() {
[ -x "$NOTIFY_SCRIPT" ] && "$NOTIFY_SCRIPT" "$1" "$2" || true
}
notify_err() {
[ -x "$NOTIFY_SCRIPT" ] && "$NOTIFY_SCRIPT" "⚠️ $1" "$2" || true
}
case "$MODE" in
weekly)
echo "[verify-restore-level1] Режим: weekly (restic check)"
setup_restic_env || { notify_err "Restic check" "Не удалось загрузить креды restic."; exit 1; }
if restic check 2>&1; then
echo "[verify-restore-level1] restic check OK"
# При еженедельном успехе — не спамим (только при ошибке)
else
notify_err "Restic check" "Ошибка проверки репозитория restic."
exit 1
fi
;;
monthly-check)
echo "[verify-restore-level1] Режим: monthly-check (restic check --read-data-subset=10%)"
setup_restic_env || { notify_err "Restic check (read-data-subset)" "Не удалось загрузить креды restic."; exit 1; }
if restic check --read-data-subset=10% 2>&1; then
echo "[verify-restore-level1] restic check --read-data-subset=10% OK"
notify_ok "Тест restic (read-data-subset)" "OK, 10% данных проверено."
else
notify_err "Restic check (read-data-subset)" "Ошибка проверки 10% данных репозитория."
exit 1
fi
;;
full-check)
echo "[verify-restore-level1] Режим: full-check (restic check --read-data)"
setup_restic_env || { notify_err "Restic check (read-data)" "Не удалось загрузить креды restic."; exit 1; }
if restic check --read-data 2>&1; then
echo "[verify-restore-level1] restic check --read-data OK"
notify_ok "Тест restic (full read-data)" "OK, полная проверка данных завершена."
else
notify_err "Restic check (read-data)" "Ошибка полной проверки данных репозитория."
exit 1
fi
;;
monthly-dump)
echo "[verify-restore-level1] Режим: monthly-dump (restore и проверка дампа Nextcloud)"
setup_restic_env || { notify_err "Тест дампа Nextcloud" "Не удалось загрузить креды restic."; exit 1; }
rm -rf "$RESTORE_TARGET"
mkdir -p "$RESTORE_TARGET"
trap 'rm -f "${RESTIC_PASSWORD_FILE:-}" 2>/dev/null; rm -rf "$RESTORE_TARGET"' EXIT INT TERM
if ! restic restore latest --target "$RESTORE_TARGET" --path "$RESTIC_PATH_NEXTCLOUD" 2>&1; then
notify_err "Тест дампа Nextcloud" "Ошибка restic restore: не удалось восстановить $RESTIC_PATH_NEXTCLOUD"
exit 1
fi
# Путь после restore: RESTORE_TARGET/mnt/backup/databases/ct101-nextcloud/
RESTORED_DIR="$RESTORE_TARGET/mnt/backup/databases/ct101-nextcloud"
if [ ! -d "$RESTORED_DIR" ]; then
notify_err "Тест дампа Nextcloud" "Ошибка: каталог $RESTORED_DIR не найден после restore."
exit 1
fi
LATEST_SQL=$(ls -t "$RESTORED_DIR"/nextcloud-db-*.sql.gz 2>/dev/null | head -1)
if [ -z "$LATEST_SQL" ] || [ ! -f "$LATEST_SQL" ]; then
notify_err "Тест дампа Nextcloud" "Ошибка: не найден .sql.gz в $RESTORED_DIR"
exit 1
fi
SIZE_BYTES=$(stat -c%s "$LATEST_SQL" 2>/dev/null || echo 0)
SIZE_MB=$(( SIZE_BYTES / 1024 / 1024 ))
if [ "$SIZE_MB" -lt "$MIN_DUMP_SIZE_MB" ]; then
notify_err "Тест дампа Nextcloud" "Ошибка: размер дампа ${SIZE_MB} MB < ${MIN_DUMP_SIZE_MB} MB (файл: $LATEST_SQL)"
exit 1
fi
if ! gunzip -t "$LATEST_SQL" 2>/dev/null; then
notify_err "Тест дампа Nextcloud" "Ошибка: gunzip -t не прошёл для $LATEST_SQL"
exit 1
fi
if ! gunzip -c "$LATEST_SQL" 2>/dev/null | grep -q 'CREATE TABLE'; then
notify_err "Тест дампа Nextcloud" "Ошибка: в распакованном дампе нет CREATE TABLE (возможно не SQL дамп)"
exit 1
fi
echo "[verify-restore-level1] Дамп Nextcloud OK: $LATEST_SQL, размер ${SIZE_MB} MB"
notify_ok "Тест дампа Nextcloud" "OK, размер ${SIZE_MB} MB."
;;
*)
echo "Использование: $0 {weekly|monthly-check|full-check|monthly-dump}"
exit 1
;;
esac
exit 0

88
scripts/verify-vzdump-level2.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
# Тест восстановления уровня 2: автотест vzdump CT 107.
# Восстанавливает последний vzdump-lxc-107 в временный CT 999, проверяет запуск, удаляет.
# Запускать на хосте Proxmox под root. Ежемесячно (systemd timer).
# При успехе/ошибке — уведомление в Telegram.
set -e
DUMP_DIR="/mnt/backup/proxmox/dump/dump"
TEST_VMID=999
TEST_IP="192.168.1.199/24"
TEST_GW="192.168.1.1"
STORAGE="local-lvm"
WAIT_START_SEC=60
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root."
exit 1
fi
# Очистка при выходе (успех или ошибка)
cleanup() {
if pct status "$TEST_VMID" &>/dev/null; then
echo "[verify-vzdump] Останавливаем и удаляем CT $TEST_VMID..."
pct stop "$TEST_VMID" --skiplock 2>/dev/null || true
sleep 2
pct destroy "$TEST_VMID" --purge 1 --force 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
notify_ok() {
[ -x "$NOTIFY_SCRIPT" ] && "$NOTIFY_SCRIPT" "✅ Тест vzdump CT 107" "$1" || true
}
notify_err() {
[ -x "$NOTIFY_SCRIPT" ] && "$NOTIFY_SCRIPT" "⚠️ Тест vzdump CT 107" "Ошибка: $1" || true
}
if [ ! -d "$DUMP_DIR" ]; then
notify_err "Каталог $DUMP_DIR не найден."
exit 1
fi
# Последний vzdump-lxc-107
ARCHIVE=$(ls -t "$DUMP_DIR"/vzdump-lxc-107-*.tar.zst 2>/dev/null | head -1)
if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then
notify_err "Не найден vzdump-lxc-107-*.tar.zst в $DUMP_DIR"
exit 1
fi
echo "[verify-vzdump] Архив: $ARCHIVE"
# Убедиться, что CT 999 не существует (остаток от прошлого запуска)
if pct status "$TEST_VMID" &>/dev/null; then
pct destroy "$TEST_VMID" --purge 1 --force 2>/dev/null || true
sleep 2
fi
echo "[verify-vzdump] Создаём CT $TEST_VMID из архива..."
if ! pct create "$TEST_VMID" "$ARCHIVE" --restore 1 --storage "$STORAGE" 2>&1; then
notify_err "pct create не удался."
exit 1
fi
# Другой IP, чтобы не конфликтовать с оригиналом 107
echo "[verify-vzdump] Настраиваем сеть (IP $TEST_IP)..."
pct set "$TEST_VMID" --net0 "name=eth0,bridge=vmbr0,gw=$TEST_GW,ip=$TEST_IP,type=veth"
echo "[verify-vzdump] Запускаем CT $TEST_VMID..."
if ! pct start "$TEST_VMID" 2>&1; then
notify_err "pct start не удался."
exit 1
fi
echo "[verify-vzdump] Ожидание $WAIT_START_SEC сек..."
sleep "$WAIT_START_SEC"
STATUS=$(pct exec "$TEST_VMID" -- systemctl is-system-running 2>/dev/null || echo "unknown")
if [ "$STATUS" != "running" ]; then
notify_err "systemctl is-system-running вернул: $STATUS (ожидалось running)"
exit 1
fi
echo "[verify-vzdump] CT 999 запущен, system running. Тест пройден."
notify_ok "OK"
exit 0

58
scripts/watchdog-timers.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Watchdog: проверка провалившихся systemd timers.
# Запускать раз в день (например 12:00). При наличии failed → notify в Telegram.
# Timer: backup-watchdog-timers.timer
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
MAX_AGE_HOURS=24
BACKUP_OK_DIR="/var/run"
if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root."
exit 1
fi
# 1. Проверка systemctl list-timers --failed
FAILED=$(systemctl list-timers --failed --no-legend --no-pager 2>/dev/null | grep -v '^$' || true)
if [ -n "$FAILED" ]; then
MSG="Провалившиеся таймеры:
$FAILED"
if [ -x "$NOTIFY_SCRIPT" ]; then
"$NOTIFY_SCRIPT" "⚠️ Systemd timers" "$MSG" || true
fi
echo "[watchdog] Найдены провалившиеся таймеры"
echo "$FAILED"
exit 1
fi
# 2. Проверка healthcheck-файлов (если файл старше 24 ч — алерт)
BACKUP_NAMES="vps-miran ct101-pgdump immich-photos vps-mtproto etc-pve ct104-pgdump vaultwarden-data ct103-gitea-pgdump vm200-pgdump ct105-vectors restic-yandex restic-yandex-photos"
STALE=""
for name in $BACKUP_NAMES; do
OK_FILE="$BACKUP_OK_DIR/backup-$name.ok"
if [ -f "$OK_FILE" ]; then
AGE_SEC=$(( $(date +%s) - $(stat -c %Y "$OK_FILE" 2>/dev/null || echo 0) ))
AGE_HOURS=$(( AGE_SEC / 3600 ))
if [ "$AGE_HOURS" -ge "$MAX_AGE_HOURS" ]; then
STALE="${STALE}backup-$name.ok (${AGE_HOURS}h)
"
fi
else
STALE="${STALE}backup-$name.ok (отсутствует)
"
fi
done
if [ -n "$STALE" ]; then
MSG="Файлы .ok старше ${MAX_AGE_HOURS} ч или отсутствуют (последний успешный бэкап):
$STALE"
if [ -x "$NOTIFY_SCRIPT" ]; then
"$NOTIFY_SCRIPT" "⚠️ Backup watchdog" "$MSG" || true
fi
echo "[watchdog] Устаревшие healthcheck-файлы"
echo "$STALE"
exit 1
fi
echo "[watchdog] OK: таймеры и healthcheck-файлы в порядке"
exit 0