Compare commits

..

3 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
16c254510a Update documentation to centralize Vaultwarden integration details and enhance backup scripts
Refactor README, architecture, and backup documentation to emphasize the use of Vaultwarden for credential management across various services. Update scripts for Nextcloud, Gitea, Paperless, and others to reference Vaultwarden for sensitive information. Remove outdated references to previous backup strategies and ensure clarity on credential retrieval processes. This improves security practices and streamlines backup operations.
2026-02-28 00:52:56 +03:00
98 changed files with 4057 additions and 486 deletions

View File

@@ -4,7 +4,7 @@
**Точка входа:** [Архитектура и подключение](docs/architecture/architecture.md) — схема сети, IP, домены, таблица всех хостов.
**Топология и риски:** [Схема сети и зависимости](docs/network/network-topology.md) — узлы, маршруты NPM, зависимости сервисов, единые точки отказа (SPOF).
**Приоритет №1:** [Бэкапы Proxmox (фаза 1)](docs/backup/proxmox-phase1-backup.md) — стратегия бэкапов LXC/VM и /etc/pve, тестовое восстановление.
**Приоритет №1:** [Бэкапы: как устроены и как восстанавливать](docs/backup/backup-howto.md) — что бэкапится, куда, когда и как восстановить.
---

View File

@@ -9,8 +9,9 @@
- **Внешний IP:** 185.35.193.144
- **Домашний сервер (Proxmox):** 192.168.1.150 (LAN)
- Подключение: `ssh root@192.168.1.150`
- **Прямой SSH на контейнеры и ВМ:** `ssh root@192.168.1.{100,101,103,104,105,107,108,109}`; ВМ 200: `ssh admin@192.168.1.200`. Ключи развёртываются скриптом `scripts/deploy-ssh-keys-homelab.sh`.
- **DNS домена katykhin.ru:** Beget.com
- Учётная запись: логин `amauri7g`, пароль `QgkaKL3RykeI`, ID аккаунта 2536839. Режим API включён. Домен **katykhin.store** в аккаунте есть, но не используется (поддоменов нет).
- Учётная запись: логин и пароль в Vaultwarden (объект **beget**). Режим API включён. Домен **katykhin.store** в аккаунте есть, но не используется (поддоменов нет).
- **Reverse proxy и SSL:** Nginx Proxy Manager (NPM) на контейнере 100.
**Поддомены katykhin.ru:**
@@ -23,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 | — |
@@ -93,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** оставлено свободным для плановой перезагрузки сервера.
@@ -433,7 +448,7 @@ restic restore SNAPSHOT_ID --target /mnt/backup/restore-db --path /mnt/backup/da
Если дампы БД (Nextcloud, Paperless, Gitea), архив Vaultwarden или векторы RAG (CT 105) получаются по 20 байт — в копию попал только пустой gzip/tar, команда внутри контейнера не отдала данные. Скрипты при размере < 512 байт завершаются с ошибкой и выводят stderr. Для векторов проверьте путь `/home/rag-service/data/vectors` в CT 105: `pct exec 105 -- ls -la /home/rag-service/data/vectors`.
**Частая причина дампов БД:** PostgreSQL в контейнере требует пароль (md5/scram). Скрипты берут пароль из **Vaultwarden** (Bitwarden CLI `bw`): объекты **NEXTCLOUD** (поле `dbpassword` или пароль), **PAPERLESS**, **GITEA**. На хосте нужны: `bw`, при необходимости `jq`, разблокировка по мастер-паролю из файла `/root/.bw-master` (см. [Переключение скриптов на секреты из Vaultwarden](proxmox-phase1-backup.md#переключение-скриптов-на-секреты-из-vaultwarden) в proxmox-phase1-backup.md).
**Частая причина дампов БД:** PostgreSQL в контейнере требует пароль (md5/scram). Скрипты берут пароль из **Vaultwarden** (Bitwarden CLI `bw`): объекты **NEXTCLOUD** (поле `dbpassword` или пароль), **PAPERLESS**, **GITEA**. На хосте нужны: `bw`, при необходимости `jq`, разблокировка по мастер-паролю из файла `/root/.bw-master` (см. [vaultwarden-secrets.md](../vaultwarden-secrets.md)).
**Проверка вручную (без подавления stderr):** зайти в контейнер и выполнить дамп, чтобы увидеть сообщение об ошибке:
@@ -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,13 +531,15 @@ chmod 600 /root/.telegram-notify.env
Если конфига или кредов нет, шлюз тихо выходит с 0 и не ломает вызывающие скрипты.
**Позже** тот же шлюз можно вызывать с VM 200 или с VPS (например по SSH на хост Proxmox) — отдельно не реализовано, архитектура это допускает.
---
## Связанные документы
- [Стратегия бэкапов (фаза 1)](proxmox-phase1-backup.md) — общий план и принятые решения.
- [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

@@ -1,377 +0,0 @@
# Фаза 1: Стратегия бэкапов Proxmox
Цель: при смерти SSD с системой или потере `/etc/pve` — развернуть Proxmox, восстановить контейнеры/ВМ, поднять сервисы без многочасового восстановления и угадывания паролей.
**Приоритет №1.** ИБП уже есть; защищаемся от смерти диска.
---
## Что бэкапить
| Объект | Зачем |
|--------|--------|
| **LXC и VM целиком** (vzdump) | Восстановление контейнера/ВМ из одного архива: ОС, конфиги, данные на корневом томе. Не только данные внутри — сам образ для restore. |
| **/etc/pve** | Конфиги кластера, VM/LXC (ID, сеть, диски, задачи), пользователи Proxmox, права. Без этого после переустановки Proxmox не восстановить привязку дисков и настройки. |
---
## Пошаговый план
### Шаг 1. Определить хранилище для бэкапов
**Выбранная схема:**
| Место | Описание | Что туда |
|-------|----------|----------|
| **Локально: /dev/sdb1 (1 ТБ под бэкапы)** | Отдельный SSD 2 ТБ; под копии выделен 1 ТБ, смонтирован в `/mnt/backup`. Второй ТБ — в запас. | Proxmox vzdump (через UI), затем те же данные (dump, etc-pve, фотки, VPS) в Yandex через restic. Фотки: оригиналы + метаданные + БД Immich; остальное пересчитать можно. VPS: Amnezia — конфиг; Миран — БД бота + контент (контент можно в S3 Мирана; копию на sdb1 — опционально). Конфигурацию серверов не бэкапим — есть Ansible. |
| **Офсайт: Yandex Object Storage (S3)** | Арендованный бакет, S3-совместимый API. [Yandex Object Storage](https://yandex.cloud/ru/docs/storage/s3/). | **Restic** с хоста (cron на ноде Proxmox): выгрузка содержимого `/mnt/backup`. Retention: 3 daily, 2 weekly, 2 monthly. |
**Принято:** Вариант A — отдельный диск/раздел на хосте (sdb1, 1 ТБ в `/mnt/backup`). Варианты B (NFS/SMB) и C (внешний USB) в текущей схеме не используются; USB опционально для параноидального 3-2-1 (см. выше).
**3-2-1:** Три копии: (1) прод — система и данные на основном диске; (2) локальный бэкап — sdb1; (3) офсайт — Yandex. Два типа носителей: локальный SSD и облачное object storage. Один офсайт — да. **Стратегия удовлетворяет 3-2-1.** Опционально: четвёртая копия на внешнем USB/другом ПК для параноидального сценария (пожар/кража) — по желанию.
**Принято:** Точка монтирования — `/mnt/backup`. На диске 2 ТБ выделено 1 ТБ под бэкапы; второй ТБ пока в запас, назначение не определено.
**Действие:** Разметить 1 ТБ на /dev/sdb1, создать ФС (ext4/xfs), смонтировать в `/mnt/backup`. Структуру каталогов — см. раздел «Структура локального хранилища» ниже.
---
### Шаг 2. Добавить Backup Storage в Proxmox
1. В веб-интерфейсе: **Datacenter → Storage → Add**.
2. Тип: **Directory** (если папка на локальном диске) или **NFS/CIFS** (если сетевое).
3. Указать:
- **ID** (например `backup-local` или `backup-nfs`),
- **Directory** — путь, например `/mnt/backup/dump`,
- Включить опции **Content: VZDump backup file** (и при необходимости **ISO**, **Container template** — по желанию).
4. Сохранить. Убедиться, что storage виден и доступен для записи.
Если используешь NFS: сначала смонтировать NFS в `/mnt/backup` на хосте (fstab или systemd mount), затем добавить Storage с Directory `/mnt/backup/dump`.
**Для выбранной схемы:** Directory = `/mnt/backup/proxmox/dump` (см. структуру ниже).
---
### Структура локального хранилища (1 ТБ на sdb1)
«Аналог S3» на одном диске — это просто понятная иерархия каталогов. **MinIO не используем:** лишний сервис; достаточно директорий и restic с бэкендом `local` (если понадобится локальный restic) и `s3` для Yandex. Proxmox пишет в **Directory** — ему достаточно пути.
Пример структуры под `/mnt/backup`:
```
/mnt/backup/
├── proxmox/
│ ├── dump/ ← Proxmox Backup Job (VZDump) — сюда добавляем Storage в PVE
│ └── etc-pve/ ← архивы tar.gz из cron: etc-pve-*, etc-host-configs-* (backup-etc-pve.sh)
├── restic/
│ ├── local/ ← репозитории restic для локальных снапшотов (опционально)
│ └── ... ← или restic только в Yandex, локально только сырые копии
├── photos/ ← Immich: оригиналы фото + метаданные + БД (остальное пересчитать)
├── vps/ ← Amnezia: конфиг. Миран: БД бота (+ контент при необходимости; основной контент можно в S3 Мирана)
└── other/ ← прочие важные данные (конфиги, скрипты, что ещё решите)
```
Квоты: при необходимости ограничить размер по каталогам (например `proxmox/dump` — не более 500 ГБ) через отдельные подразделы или скрипты очистки (retention).
---
### Шифрование диска бэкапов (LUKS)
**LUKS** (Linux Unified Key Setup) — стандартное шифрование раздела в Linux. Если диск с бэкапами украдут или вынесут, без пароля/ключа данные не прочитать. Минусы: нужно вводить пароль при загрузке (или хранить ключ на другом носителе), небольшая нагрузка на CPU.
**Принято:** LUKS пока не используем. Раздел sdb1 — без шифрования. При необходимости можно добавить позже (потребуется перенос данных).
---
### Restic и Yandex Object Storage
- **Restic** поддерживает бэкенд **S3**. Yandex Object Storage совместим с S3 API — используешь endpoint бакета и ключи (Access Key / Secret).
- **Retention в Yandex:** 3 daily, 2 weekly, 2 monthly — `restic forget --keep-daily 3 --keep-weekly 2 --keep-monthly 2` и затем `prune`.
- **Что гнать в Yandex через restic:** Proxmox dump (каталог `proxmox/dump`), `/etc/pve` (архивы из `proxmox/etc-pve`), фотки (оригиналы + метаданные + БД Immich), бэкапы с VPS — см. «Принятые решения: что куда» ниже.
**Локально отдельной политики retention для restic не нужно:** на sdb1 retention задаётся в самом Proxmox Backup Job (например «keep last 7») и в скрипте бэкапа `/etc/pve` (удалять архивы старше N дней). Restic используется только для отправки в Yandex с политикой 7/4/6. Локальных restic-репозиториев можно не заводить — только каталоги и выгрузка их содержимого в облако.
Документация Yandex: [Object Storage S3](https://yandex.cloud/ru/docs/storage/s3/). Нужны: bucket name, region, endpoint, Static Key (Access Key ID + Secret Access Key). **Бакет создан; ключи и endpoint зафиксировать при настройке restic.**
---
### Хранилище паролей
Чтобы не терять пароли при восстановлении и держать креды в одном месте. Рассматривались варианты: Vaultwarden (self-hosted), Bitwarden Cloud, KeePass/KeePassXC, 1Password и др.
**Принято и сделано:** **Vaultwarden** развёрнут на **CT 103** LXC. Домен через NPM (HTTPS), клиенты Bitwarden на ПК/телефоне. Бэкап данных Vaultwarden включён в общий план (restic → Yandex).
---
### Где запускать бэкапы (централизация)
**С хоста Proxmox (cron на ноде):**
- **Proxmox Backup Job** уже выполняется на хосте и пишет vzdump всех выбранных LXC/VM в `/mnt/backup/proxmox/dump` — это и есть «бэкапы всех контейнеров и ВМ», централизованно.
- **Restic** (backup/forget/prune) тоже запускаем с хоста по cron: бэкапит каталоги на хосте (например весь `/mnt/backup` или выбранные подкаталоги) в Yandex S3. Данные для бэкапа — локальные пути (dump, etc-pve, а фотки и данные с VPS нужно либо копировать на хост в `/mnt/backup` скриптами, либо монтировать и тогда restic будет их читать с хоста). Контейнеры и ВМ целиком не бэкапим через restic — ими занимается только Proxmox Backup Job.
---
### Шаг 3. Настроить расписание бэкапа LXC и VM
1. **Datacenter → Backup** (или **Backup** в меню узла).
2. **Add** — создаётся задача (Job).
3. Параметры:
- **Storage** — выбранное хранилище (шаг 2).
- **Schedule** — например `0 2 * * *` (каждую ночь в 02:00). Подстроить под окно, когда нагрузка минимальна.
- **Selection mode:** включить нужные узлы (или **All**), затем отметить **галочками** конкретные **LXC (100108) и VM (200)**. Либо выбрать "Backup all" для всех VMs/containers.
- **Mode:**
- **Snapshot** — контейнер/ВМ не останавливается, создаётся снимок (рекомендуется для минимизации даунтайма).
- **Suspend** — ВМ приостанавливается на время бэкапа (более консистентно для БД, но даунтайм).
Для LXC обычно достаточно **Snapshot**. Для **VM 200** (PostgreSQL и др.): Snapshot **не гарантирует консистентность БД** — PostgreSQL может быть в середине транзакции. **Правильная стратегия:** внутри VM делать логический бэкап БД (`pg_dump`), а **vzdump snapshot** использовать для остального (ОС, конфиги, файлы). Итого: VM 200 — vzdump snapshot ок для образа; консистентность БД — отдельно через `pg_dump` внутри гостя.
- **Compression:** ZSTD (хороший компромисс скорость/размер).
- **Retention:** например «Keep last 7» или «Keep last 4 weekly» — чтобы не забивать диск.
4. Сохранить job. Проверить по кнопке **Backup now**, что задача запускается и файлы появляются в Storage.
Важно: бэкап должен включать **и LXC, и VM 200**. Не только данные внутри них (те уже описаны в документации контейнеров), а именно полный dump для restore.
---
### Шаг 4. Бэкап /etc/pve и конфигов хоста
Конфиги кластера и виртуалок лежат в `/etc/pve`. Плюс для восстановления хоста полезны: `/etc/network/interfaces`, `/etc/hosts`, `/etc/resolv.conf`. Всё это нужно копировать регулярно и хранить в безопасном месте (желательно не только на том же диске, что и система).
**Принято: вариант A — cron на хосте Proxmox.**
1. Создать скрипт, например `/root/scripts/backup-etc-pve.sh`:
```bash
#!/bin/bash
BACKUP_ROOT="/mnt/backup/proxmox/etc-pve" # по структуре выше
DATE=$(date +%Y%m%d-%H%M)
mkdir -p "$BACKUP_ROOT"
tar -czf "$BACKUP_ROOT/etc-pve-$DATE.tar.gz" -C / etc/pve
tar -czf "$BACKUP_ROOT/etc-host-configs-$DATE.tar.gz" -C / etc/network/interfaces etc/hosts etc/resolv.conf
# опционально: удалять бэкапы старше N дней
# find "$BACKUP_ROOT" -name 'etc-pve-*.tar.gz' -mtime +30 -delete
# find "$BACKUP_ROOT" -name 'etc-host-configs-*.tar.gz' -mtime +30 -delete
```
2. Сделать исполняемым: `chmod +x /root/scripts/backup-etc-pve.sh`.
3. Добавить в cron: `crontab -e`. Окно внутренних бэкапов 01:0003:30; пример для etc-pve: `15 2 * * * /root/scripts/backup-etc-pve.sh`
**Вариант B:** Тот же скрипт можно вызывать из задачи в Proxmox (Script/Command в задаче типа Hook script), но проще и надёжнее — отдельный cron на хосте.
Бэкапы (`etc-pve-*.tar.gz`, `etc-host-configs-*.tar.gz`) хранить **локально** (`/mnt/backup/proxmox/etc-pve`) и **в Yandex** — включить этот каталог в источники restic (мало весит, критично при потере хоста). Файлы с ограниченными правами (chmod 600); `/etc/pve` содержит секреты — не выкладывать в открытый доступ.
---
### Шаг 5. Хранить секреты отдельно (пароли, ключи)
Чтобы «не вспоминать пароли 3 часа» после восстановления:
**Секретное хранилище** — Vaultwarden на **CT 103** (см. выше). Туда: root Proxmox, пользователи PVE, пароли БД и сервисов (Nextcloud, Gitea, Paperless, Immich, NPM, Galene и т.д.), API-ключи (Beget, certbot, Wallos и др.). Полный список кредов по контейнерам — в статьях container-100 … container-200; свести в один список в Vaultwarden и обновлять при смене паролей.
Это не «шаг бэкапа», но обязательная часть восстановления: без паролей восстановленные контейнеры не войдут в сервисы.
**Инвентаризация секретов для переноса в Vaultwarden** — ниже сводная таблица: где лежат креды сейчас и какой объект в Vaultwarden им соответствует. Команды для получения значений из Vaultwarden и переключение скриптов — в разделе «Получение секретов из Vaultwarden» ниже.
| Хост / CT / VM | Текущее место | Объект Vaultwarden |
|----------------|----------------|---------------------|
| Proxmox (хост) | root, пользователи PVE | (в менеджере вручную) |
| Proxmox (хост) | `/root/.restic-yandex.env`, `/root/.restic-password` | **RESTIC** (поля: RESTIC_BACKUP_KEY, RESTIC_REPOSITORY, AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY, TELEGRAM_SELF_CHAT_ID) |
| Proxmox (хост) | `/root/.telegram-notify.env` | **HOME_BOT_TOKEN** (пароль = токен бота), **RESTIC** (поле TELEGRAM_SELF_CHAT_ID = chat_id) |
| CT 100 | `/root/.secrets/certbot/beget.ini` | **beget** (логин, пароль) |
| CT 100 | NPM админка | (в менеджере вручную) |
| CT 100 | VPN Route Check compose | **localhost** (логин admin, пароль, поле ROUTER_TELNET_HOST) |
| CT 100 | custom_ssl / letsencrypt | (восстановление из /etc/letsencrypt; в Vaultwarden не храним) |
| CT 101 | Nextcloud compose, config.php | **NEXTCLOUD** (логин nextcloud, пароль; поля: NEXTCLOUD_TRUSTED_DOMAINS, instanceid, passwordsalt, secret, dbpassword) |
| CT 103 | Gitea compose, .env, app.ini | **GITEA** (логин gitea, пароль; поля: GITEA__database__DB_TYPE, GITEA__database__HOST, GITEA_RUNNER_REGISTRATION_TOKEN, LFS_JWT_SECRET, INTERNAL_TOKEN) |
| CT 103 | CouchDB local.ini | **OBSIDIAN** (логин obsidian, пароль) |
| CT 103 | Vaultwarden .env | **VAULTWARDEN** (пароль = ADMIN_TOKEN, поле SIGNUPS_ALLOWED) |
| CT 104 | Paperless compose, docker-compose.env | **PAPERLESS** (логин paperless, пароль; поля: PAPERLESS_URL, PAPERLESS_SECRET_KEY, PAPERLESS_TIME_ZONE, PAPERLESS_OCR_LANGUAGE, PAPERLESS_OCR_LANGUAGES) |
| CT 107 | Invidious compose | **INVIDIOUS** (логин kemal, пароль; поля: SERVER_SECRET_KEY, test) |
| CT 108 | ice-servers.json | **GALENE** (поле config — JSON TURN) |
| VM 200 | `/opt/immich/.env` | **IMMICH** (логин/пароль и поля по .env) |
| VM 200 | `/opt/immich-deduper/.env` | **IMMICH_DEDUPER** (логин postgres, пароль; поля: DEDUP_PORT, DEDUP_DATA, DEDUP_IMAGE, IMMICH_PATH, PSQL_HOST, PSQL_PORT, PSQL_DB) |
| Proxmox (хост) | `/root/.vps-miran-s3.env` | **MIRAN_S3** (поля: S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET_NAME) |
---
#### Получение секретов из Vaultwarden
**Требования:** установлены `bw` (Bitwarden CLI) и `jq`; настроен сервер: `bw config server https://vault.katykhin.ru`. Мастер-пароль задаётся через переменную `BW_MASTER_PASSWORD` или через **файл с доступом только для текущего пользователя** (`chmod 600`), например `/root/.bw-master`; файл не хранить в репозитории. Перед запросами: `bw sync` и разблокировка: `export BW_SESSION=$(bw unlock --passwordenv BW_MASTER_PASSWORD --raw)` или `bw unlock --passwordfile /path/to/file --raw`.
**Команды по объектам** (выполнять после `bw unlock` в той же сессии):
| Объект | Логин / пароль | Кастомное поле |
|--------|----------------|----------------|
| **beget** | `bw get username "beget"`, `bw get password "beget"` | — |
| **GALENE** | — | `bw get item "GALENE" \| jq -r '.fields[] \| select(.name=="config") \| .value'` |
| **GITEA** | `bw get username "GITEA"`, `bw get password "GITEA"` | `bw get item "GITEA" \| jq -r '.fields[] \| select(.name=="ИМЯ_ПОЛЯ") \| .value'` — подставить GITEA_RUNNER_REGISTRATION_TOKEN, LFS_JWT_SECRET, INTERNAL_TOKEN, GITEA__database__DB_TYPE, GITEA__database__HOST |
| **HOME_BOT_TOKEN** | — | пароль = токен: `bw get password "HOME_BOT_TOKEN"` |
| **IMMICH** | по структуре в Vaultwarden | `bw get item "IMMICH" \| jq '.fields'` |
| **IMMICH_DEDUPER** | `bw get username "IMMICH_DEDUPER"`, `bw get password "IMMICH_DEDUPER"` | поля DEDUP_*, IMMICH_PATH, PSQL_* — через `jq -r '.fields[] \| select(.name=="X") \| .value'` |
| **INVIDIOUS** | `bw get username "INVIDIOUS"`, `bw get password "INVIDIOUS"` | `bw get item "INVIDIOUS" \| jq -r '.fields[] \| select(.name=="SERVER_SECRET_KEY") \| .value'` |
| **localhost** | `bw get username "localhost"`, `bw get password "localhost"` | `bw get item "localhost" \| jq -r '.fields[] \| select(.name=="ROUTER_TELNET_HOST") \| .value'` |
| **MIRAN_S3** | — | S3_ACCESS_KEY: `bw get item "MIRAN_S3" \| jq -r '.fields[] \| select(.name=="S3_ACCESS_KEY") \| .value'`; аналогично S3_SECRET_KEY, S3_BUCKET_NAME |
| **NEXTCLOUD** | `bw get username "NEXTCLOUD"`, `bw get password "NEXTCLOUD"` | secret: `bw get item "NEXTCLOUD" \| jq -r '.fields[] \| select(.name=="secret") \| .value'`; dbpassword, passwordsalt, instanceid, NEXTCLOUD_TRUSTED_DOMAINS — то же с `.name=="..."` |
| **OBSIDIAN** | `bw get username "OBSIDIAN"`, `bw get password "OBSIDIAN"` | — |
| **PAPERLESS** | `bw get username "PAPERLESS"`, `bw get password "PAPERLESS"` | PAPERLESS_SECRET_KEY, PAPERLESS_URL и др. — `bw get item "PAPERLESS" \| jq -r '.fields[] \| select(.name=="PAPERLESS_SECRET_KEY") \| .value'` |
| **RESTIC** | — | RESTIC_BACKUP_KEY: `bw get item "RESTIC" \| jq -r '.fields[] \| select(.name=="RESTIC_BACKUP_KEY") \| .value'`; RESTIC_REPOSITORY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, TELEGRAM_SELF_CHAT_ID — то же с нужным `.name` |
| **VAULTWARDEN** | — | пароль = ADMIN_TOKEN: `bw get password "VAULTWARDEN"`; SIGNUPS_ALLOWED — из полей |
**Универсальный шаблон для поля по имени:**
`bw get item "ИМЯ_ОБЪЕКТА" | jq -r '.fields[] | select(.name=="ИМЯ_ПОЛЯ") | .value'`
---
#### Переключение скриптов на секреты из Vaultwarden
Ниже — как перейти с чтения из файлов на подстановку из `bw` на **хосте Proxmox**. Мастер-пароль хранить **только в файле с доступом для текущего пользователя:** `chmod 600 /root/.bw-master` (владелец root — только root читает/пишет); в репозиторий файл не коммитить. Либо задавать переменную окружения при запуске по крону.
**1. Restic (backup-restic-yandex.sh, backup-restic-yandex-photos.sh, restore-one-vzdump-from-restic.sh)**
Сейчас: `source /root/.restic-yandex.env`, пароль из `/root/.restic-password`.
Переключение: в начале скрипта (после `set -e`) разблокировать BW и выставить переменные из объекта **RESTIC**:
```bash
# Разблокировать Vaultwarden (мастер-пароль из файла с chmod 600 или env)
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ -f "$BW_MASTER_PASSWORD_FILE" ]; then
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw)
fi
# Подставить секреты из RESTIC
ITEM=$(bw get item "RESTIC")
export RESTIC_REPOSITORY=$(echo "$ITEM" | jq -r '.fields[] | select(.name=="RESTIC_REPOSITORY") | .value')
export AWS_ACCESS_KEY_ID=$(echo "$ITEM" | jq -r '.fields[] | select(.name=="AWS_ACCESS_KEY_ID") | .value')
export AWS_SECRET_ACCESS_KEY=$(echo "$ITEM" | jq -r '.fields[] | select(.name=="AWS_SECRET_ACCESS_KEY") | .value')
export AWS_DEFAULT_REGION=$(echo "$ITEM" | jq -r '.fields[] | select(.name=="AWS_DEFAULT_REGION") | .value')
RESTIC_PASSWORD=$(echo "$ITEM" | jq -r '.fields[] | select(.name=="RESTIC_BACKUP_KEY") | .value')
export RESTIC_PASSWORD_FILE=$(mktemp -u)
echo -n "$RESTIC_PASSWORD" > "$RESTIC_PASSWORD_FILE"
chmod 600 "$RESTIC_PASSWORD_FILE"
trap 'rm -f "$RESTIC_PASSWORD_FILE"' EXIT
```
Убрать из скрипта: проверку/чтение `ENV_FILE` и `RESTIC_PASSWORD_FILE` из файлов; оставить использование переменных `RESTIC_REPOSITORY`, `AWS_*`, `RESTIC_PASSWORD_FILE` как выше. Для restore-one-vzdump-from-restic.sh — тот же блок в начале.
**2. Telegram (notify-telegram.sh)**
Сейчас: `source /root/.telegram-notify.env`, переменные `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`.
Переключение: читать токен из **HOME_BOT_TOKEN** (пароль), chat_id из объекта **RESTIC** (поле TELEGRAM_SELF_CHAT_ID). В начале скрипта (если нет уже разблокировки):
```bash
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ -f "$BW_MASTER_PASSWORD_FILE" ]; then
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw)
fi
TELEGRAM_BOT_TOKEN=$(bw get password "HOME_BOT_TOKEN")
TELEGRAM_CHAT_ID=$(bw get item "RESTIC" | jq -r '.fields[] | select(.name=="TELEGRAM_SELF_CHAT_ID") | .value')
```
Дальше в скрипте использовать `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID` как раньше (проверка на пустоту, вызов curl). Файл `/root/.telegram-notify.env` можно не использовать.
**3. Дампы БД (backup-ct101-pgdump.sh, backup-ct104-pgdump.sh, backup-ct103-gitea-pgdump.sh)**
Скрипты уже берут PGPASSWORD из Vaultwarden: в начале разблокировка `bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw`, затем для pg_dump передаётся `-e PGPASSWORD=...` в `docker exec`. Источники паролей:
- **Nextcloud (CT 101):** объект **NEXTCLOUD** — поле `dbpassword` или пароль записи (`bw get password "NEXTCLOUD"`).
- **Paperless (CT 104):** объект **PAPERLESS** — пароль (`bw get password "PAPERLESS"`).
- **Gitea (CT 103):** объект **GITEA** — пароль (`bw get password "GITEA"`).
Требования на хосте: `bw`, для Nextcloud — `jq`; файл `/root/.bw-master` с мастер-паролем (chmod 600). При ошибке (дамп < 512 байт) скрипт завершается с кодом 1 и выводит stderr; уведомление в Telegram при ошибке не отправляется.
**4. Остальные места**
Конфиги сервисов (Nextcloud config.php, Gitea compose, Paperless .env, Immich .env и т.д.) подставлять вручную при восстановлении или написать небольшие скрипты-обёртки, которые один раз получают нужные значения через `bw get item` / `bw get password` и пишут в .env или конфиг. Имена объектов и полей — по таблице выше.
После переключения: обновить чек-лист (отметить «Секреты перенесены в Vaultwarden») и при необходимости добавить в cron установку `BW_MASTER_PASSWORD_FILE` или вызов разблокировки в общем wrapperе.
---
### Шаг 6. Тестовое восстановление одного контейнера
Без проверки восстановления нельзя считать стратегию рабочей.
1. Выбрать **некритичный** контейнер (например 105 — RAG, или 107 — Invidious), для которого краткий даунтайм допустим.
2. Убедиться, что есть свежий backup этого контейнера в Storage (после шага 3).
3. **Восстановление:**
- В Proxmox UI: **Datacenter → Backup** → выбрать storage → найти backup нужного CT → **Restore**. Указать **new VMID** (например 999 для теста) и **Storage** для дисков.
- Или с CLI:
`qmrestore` для VM, для LXC — через GUI или `pct restore` (см. справку `pct restore`).
Для LXC типично: Backup → Restore → задать новый ID (например 999), node, storage.
4. Запустить восстановленный контейнер (ID 999), проверить:
- заходит по SSH/консоль;
- сервисы внутри запускаются (docker/ systemd);
- с хоста пинг и при необходимости один сервис по порту.
5. После проверки удалить тестовый контейнер (999), освободить место.
Если что-то пошло не так (не находится диск, ошибка прав, сеть) — зафиксировать и поправить стратегию (пути storage, режим backup, права).
---
### Шаг 7. Документировать процедуру восстановления «с нуля»
Кратко зафиксировать в отдельном разделе (здесь или в architecture):
1. Установка Proxmox на новое железо (или новый диск).
2. Восстановление конфигов: распаковать последний `etc-pve-*.tar.gz` в `/etc/pve` (с учётом того, что нужно корректно подставить ноды и storage; при одномузловой установке обычно достаточно скопировать файлы).
3. Подключение storage с backup (или копирование последних vzdump на новый storage).
4. Восстановление контейнеров и ВМ из backup по одному (Restore с указанием VMID и storage).
5. Запуск контейнеров/ВМ, проверка сети и сервисов.
6. Использование сохранённых паролей/ключей для входа и проверки сервисов.
После первого успешного тестового восстановления (шаг 6) эту процедуру можно уточнить и дописать по факту.
---
## Чек-лист фазы 1
- [x] Разметка: 1 ТБ на sdb1, ФС, монтирование в `/mnt/backup` (без LUKS). *(скрипт `scripts/backup-setup-sdb1-mount.sh`, каталоги созданы.)*
- [x] В Proxmox добавлен Storage для VZDump → `/mnt/backup/proxmox/dump`.
- [x] Настроена регулярная задача Backup: LXC (100108), расписание ночь (02:00), retention задан. *VM 200 исключена из задания (образ ~380 ГБ); восстановление VM 200 — по инструкции «с нуля» в [backup-howto](backup-howto.md).*
- [x] Проверен ручной запуск Backup now — файлы появляются в storage. *(рекомендуется проверить разово.)*
- [x] Настроен бэкап `/etc/pve` (скрипт + cron) → `/mnt/backup/proxmox/etc-pve`. *(backup-etc-pve.sh, 02:15, 30 дней.)*
- [x] Restic: cron на хосте, выгрузка каталогов из `/mnt/backup` в Yandex S3. *(backup-restic-yandex.sh 04:00, backup-restic-yandex-photos.sh 04:10, retention 3 daily / 2 weekly / 2 monthly.)*
- [x] Yandex: ключи и endpoint зафиксированы в `/root/.restic-yandex.env`, restic пишет в бакет.
- [x] Vaultwarden развёрнут (CT 103).
- [ ] Секреты перенесены в Vaultwarden. *(на усмотрение: root PVE, пароли БД, API и т.д.)*
- [x] Бэкап данных Vaultwarden включён в restic (Yandex S3). *Локально: backup-vaultwarden-data.sh → `/mnt/backup/other/vaultwarden/`; restic выгружает весь `/mnt/backup` (кроме photos), каталог vaultwarden входит в снимок.*
- [x] Выполнено тестовое восстановление одного контейнера (другой VMID), проверена работоспособность. *(26.02.2026: восстановлен CT 107 в слот 999 из `/mnt/backup/proxmox/dump/dump/vzdump-lxc-107-*.tar.zst`, проверены консоль, пинг, Docker, Invidious на 3000; тестовый CT удалён.)*
- [x] В документации зафиксирована процедура полного восстановления Proxmox «с нуля». *[backup-howto.md](backup-howto.md): восстановление из vzdump, конфигов, БД, VM 200 с нуля, Vaultwarden, VPS и др.*
---
## Ссылки
- [Архитектура и подключение](../architecture/architecture.md) — хосты, IP, домены.
- [Схема сети и зависимости](../network/network-topology.md) — SPOF, зависимость от Proxmox и бэкапов.
- [Vaultwarden и использование секретов](../vaultwarden-secrets.md) — установка bw, разблокировка, получение секретов в скриптах.
- Документация контейнеров (100108, 200) — бэкапы *данных внутри* сервисов (БД, тома); фаза 1 дополняет это бэкапом на уровне PVE.
---
## Принятые решения (сводка)
| Вопрос | Решение |
|--------|---------|
| Точка монтирования, второй ТБ | `/mnt/backup`; второй ТБ на sdb1 — в запас, назначение позже. |
| Шифрование (LUKS) | Пока не делаем; раздел без шифрования. |
| Proxmox vzdump | Локально в `proxmox/dump` + дублировать в Yandex через restic. |
| Фотки | Оригиналы + метаданные + БД Immich; остальное пересчитать. |
| VPS | Amnezia — конфиг. Миран — БД бота + контент (контент можно в S3 Мирана; копия на sdb1 — по желанию). Конфиг серверов не бэкапим — Ansible. |
| Где запускать бэкапы | Cron на хосте Proxmox: Backup Job (vzdump) + restic в Yandex. |
| Retention локально | Только в Proxmox Job и в скрипте etc-pve; отдельного restic-репозитория локально не делаем. |
| /etc/pve + конфиги хоста (interfaces, hosts, resolv.conf) | Вариант A: cron на хосте → `etc-pve` и `etc-host-configs` в `/mnt/backup/proxmox/etc-pve`; локально и в Yandex (restic). |
| Пароли | Vaultwarden на CT 103. |
| VM 200 (БД PostgreSQL) | vzdump snapshot — для образа ВМ; консистентность БД — отдельно: внутри VM логический бэкап (`pg_dump`). |
| Yandex | Бакет создан; ключи и endpoint зафиксировать при настройке restic. |
| MinIO | Не используем; директории + restic (s3 для Yandex). |
---
## Осталось сделать
- **Проверка ручного Backup:** один раз запустить «Backup now» в Proxmox UI (Datacenter → Backup) и убедиться, что файлы появляются в `/mnt/backup/proxmox/dump/dump/`.
- **Секреты (по желанию):** перенести пароли/ключи (root PVE, БД, API) в Vaultwarden и обновлять при смене.
*Выполнено ранее: Yandex + Restic (cron, retention 3/2/2), тестовое восстановление CT 107 → 999 (26.02.2026).*

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

@@ -18,9 +18,9 @@
## Доступ и логины
- **Debian (CT 100):** логин `root` (или консольный пользователь Debian), пароль `waccEk-fyqbux-rarja3`.
- **AdGuard Home (через домен):** https://adguard.katykhin.ru (через NPM, сертификат Let's Encrypt), пользователь `kerrad`, пароль `waccEk-fyqbux-rarja3`. Прямой доступ по порту 3000 больше не используется.
- **Nginx Proxy Manager:** http://192.168.1.100:81, имя `Kerrad`, email `j3tears100@gmail.com`, пароль `kqEUubVq02DJTS8`.
- **Debian (CT 100):** логин `root`. Пароль — в Vaultwarden (объект **CT_100_ROOT_PASSWORD**).
- **AdGuard Home (через домен):** https://adguard.katykhin.ru (через NPM, сертификат Let's Encrypt). Пользователь и пароль — в Vaultwarden (объект **ADGUARD**). Прямой доступ по порту 3000 больше не используется.
- **Nginx Proxy Manager:** http://192.168.1.100:81. Имя, email и пароль — в Vaultwarden (объект **NPM_ADMIN**).
---
@@ -56,7 +56,7 @@
**Certbot на хосте (внутри CT 100):**
- Установлен в системе, таймер `certbot.timer` (проверка продления дважды в день).
- Учётные данные Beget API: `/root/.secrets/certbot/beget.ini`.
- Учётные данные Beget API: `/root/.secrets/certbot/beget.ini` (генерируется из Vaultwarden скриптом `deploy-beget-credentials.sh` с хоста Proxmox).
- Deploy-hookи: `/etc/letsencrypt/renewal-hooks/deploy/` — скрипты `copy-*-to-npm.sh` (video, docs, immich, mini-lm, vault и т.д.) копируют `fullchain.pem` и `privkey.pem` в соответствующий каталог `custom_ssl/npm-<id>/` и делают `docker exec npm nginx -s reload`.
**vault.katykhin.ru:** сертификат выпускается certbotом в `/etc/letsencrypt/live/vault.katykhin.ru/`, deploy-hook `copy-vault-to-npm.sh` копирует его в `custom_ssl/npm-18/`. В NPM у proxy hostа vault.katykhin.ru должен быть выбран именно этот сертификат (Custom SSL → каталог npm-18). Если в NPM по ошибке привязать другой сертификат (например от другого домена), браузер покажет ошибку «нет сертификата» или неверный домен; тогда в конфиге proxy hostа должны быть пути `ssl_certificate /data/custom_ssl/npm-18/...`.
@@ -181,14 +181,23 @@ docker restart wallos
Проверяет, идут ли запросы к заданным доменам через VPN или через основное подключение (подключение к роутеру по telnet, разбор маршрутов). Результаты отдаёт на порту **8765** (на хосте). В Homepage добавлена ссылка на http://192.168.1.100:8765.
**Переменные окружения в compose:** `ROUTER_TELNET_HOST`, `ROUTER_TELNET_USER`, `ROUTER_TELNET_PASSWORD` — **заданы в самом файле** (не в .env). Рекомендация: вынести в `.env` и не коммитить пароль (см. TODO).
**Секреты:** `ROUTER_TELNET_HOST`, `ROUTER_TELNET_USER`, `ROUTER_TELNET_PASSWORD` берутся из Vaultwarden (объект **localhost**). Деплой — единым скриптом на Proxmox:
```bash
/root/scripts/deploy-vpn-route-check.sh
```
Скрипт: разблокирует bw, получает креды из Vaultwarden, атомарно пишет `.env` в CT 100, запускает `docker compose up -d`. Режим проверки без записи: `--dry-run`. Шаблон compose: `scripts/vpn-route-check/docker-compose.yml`.
**Том:** volume `vpn-route-check-data` → `/data` (в контейнере).
**Команды:**
```bash
cd /opt/docker/vpn-route-check && docker compose up -d
docker logs vpn-route-check
# Деплой (с хоста Proxmox)
/root/scripts/deploy-vpn-route-check.sh
# Логи
pct exec 100 -- docker logs vpn-route-check
```
---
@@ -224,7 +233,7 @@ docker logs vpn-route-check
1. Создать сеть (если ещё нет): `docker network create proxy_network`.
2. NPM: `cd /opt/docker/nginx-proxy && docker compose up -d`.
3. AdGuard: `cd /opt/docker/adguard && docker compose up -d` (создаёт свою сеть и подключается к proxy_network).
4. VPN Route Check: `cd /opt/docker/vpn-route-check && docker compose up -d`.
4. VPN Route Check: `/root/scripts/deploy-vpn-route-check.sh` (с хоста Proxmox).
5. Log-dashboard: при необходимости запустить контейнер с монтом html и портом 8088.
После изменений в NPM (proxy, SSL): перезагрузка nginx внутри контейнера — `docker exec npm nginx -s reload`. Certbot продлевает сертификаты по таймеру; deploy-hookи копируют их в NPM и перезагружают nginx.
@@ -234,7 +243,7 @@ docker logs vpn-route-check
## Уязвимости и риски
1. **Пароли и креды в конфигах:** В `services.yaml` (Homepage) хранятся пароли виджетов (AdGuard, NPM, Proxmox). Файл лежит только на сервере; не помещать в публичный репозиторий.
2. **VPN Route Check:** Логин и пароль роутера прописаны в `docker-compose.yml`. Доступ к compose = доступ к роутеру. Рекомендуется вынести в `.env` и ограничить права на файл.
2. **VPN Route Check:** Креды роутера в `.env` (генерируется из Vaultwarden скриптом `deploy-vpn-route-check.sh`). Файл `.env` не коммитить.
3. **AdGuard на 3000:** Веб-интерфейс доступен по порту 3000 на хосте. Доступ из LAN; при необходимости закрыть фаерволом снаружи или использовать только через NPM (proxy).
4. **NPM на 81:** Админка NPM по порту 81. Убедиться, что с интернета доступ только через VPN или не пробрасывать 81 наружу.
5. **Логи NPM:** Часть логов (fallback_*) не ротируется — возможен рост и заполнение диска (см. TODO).
@@ -245,7 +254,7 @@ docker logs vpn-route-check
- [x] **Логи NPM:** Добавить в logrotate ротацию для `fallback_http_access.log`, `fallback_http_error.log` (и при необходимости других fallback_*) по размеру или по дням — настроено в `npm-nginx.conf` (30 дней / ~512 MB).
- [x] **Логи AdGuard:** Ограничить хранение логов запросов/статистики — настроено в `AdGuardHome.yaml` (`querylog.interval = 336h`, `statistics.interval = 336h` ≈ 14 дней).
- [ ] **VPN Route Check:** Вынести `ROUTER_TELNET_*` в `.env`, подключать в compose через `env_file`, не коммитить .env в репозиторий.
- [x] **VPN Route Check:** Секреты из Vaultwarden (объект localhost), деплой через `deploy-vpn-route-check.sh`.
- [ ] **Log-dashboard:** Зафиксировать способ запуска контейнера (отдельный compose или скрипт) и добавить его в документацию/автозапуск при перезагрузке CT.
- [ ] **Мониторинг диска:** Настроить оповещение (например, из Prometheus/Alertmanager или скрипт по крону) при заполнении корня или `/opt/docker` выше порога (например 80%).
- [ ] **Резервное копирование:** Регулярный бэкап критичных папок (оценка размеров на момент документации):

View File

@@ -43,20 +43,22 @@
**Порты:** 3000 (хост) → 3000 (контейнер). NPM (контейнер 100) проксирует https://video.katykhin.ru → 192.168.1.107:3000.
**Тома и конфиги:**
- Invidious не использует отдельные bindтома для конфигов/данных — данные хранятся в PostgreSQL (`invidious_postgresdata`), а конфиг задаётся через переменную `INVIDIOUS_CONFIG` в compose (inline YAML).
- Invidious не использует отдельные bindтома для конфигов/данных — данные хранятся в PostgreSQL (`invidious_postgresdata`), а конфиг задаётся через переменную `INVIDIOUS_CONFIG` в compose.
- Отдельных каталогов с логами Invidious на хосте нет — логи идут в stdout контейнера (см. раздел «Логи и ротация»).
**Основная конфигурация (в docker-compose.yml, секция `environment / INVIDIOUS_CONFIG`):**
- `db`: dbname=invidious, user=kemal, password=kemal, host=invidious-db, port=5432, check_tables=true.
- `invidious_companion`: URL сервиса companion (`http://companion:8282/companion`).
- `invidious_companion_key` и `SERVER_SECRET_KEY` (в companion) — общий секрет между Invidious и Companion (сейчас заданы прямо в compose; **не выкладывать в публичный репозиторий**).
- `external_port: 443`, `domain: "video.katykhin.ru"`, `https_only: true` — Invidious знает про внешний домен и порт, отдаёт ссылки на https.
- Прочие опции (feeds, captions, hmac_key, default_user_preferences и т.д.).
**Секреты:** `POSTGRES_USER`, `POSTGRES_PASSWORD`, `INVIDIOUS_COMPANION_KEY`, `HMAC_KEY` берутся из Vaultwarden (объект **INVIDIOUS**). Деплой с хоста Proxmox:
```bash
/root/scripts/deploy-invidious-credentials.sh
```
Скрипт генерирует `.env` из Vaultwarden, атомарно пушит в CT 107, запускает `docker compose up -d --force-recreate`. **Ротация:** сменил пароль/ключи в Vaultwarden → запустил скрипт.
**Команды:**
```bash
cd /opt/invidious && docker compose up -d
docker logs invidious-invidious-1
# Деплой (с хоста Proxmox)
/root/scripts/deploy-invidious-credentials.sh
# Логи
pct exec 107 -- docker logs invidious-invidious-1
curl -s http://127.0.0.1:3000/api/v1/stats
```
@@ -71,7 +73,7 @@ curl -s http://127.0.0.1:3000/api/v1/stats
- volume `companioncache``/var/tmp/youtubei.js` (кэш jsресурсов YouTube / youtubei).
**Безопасность:**
- `SERVER_SECRET_KEY` совпадает с `invidious_companion_key` в конфиге Invidious — это shared secret для обмена.
- `SERVER_SECRET_KEY` совпадает с `invidious_companion_key` оба берутся из `.env` (генерируется из Vaultwarden).
- Контейнер запущен с `read_only: true`, `cap_drop: [ALL]`, `no-new-privileges:true` — хорошая практика sandboxing.
**Команды:**
@@ -89,7 +91,7 @@ docker logs invidious-companion-1
- `/opt/invidious/config/sql``/config/sql` — SQLскрипты инициализации/миграций из репозитория Invidious (~40 KB).
- `/opt/invidious/docker/init-invidious-db.sh``/docker-entrypoint-initdb.d/init-invidious-db.sh` — скрипт инициализации БД при первом запуске.
**Переменные окружения:** POSTGRES_DB=invidious, POSTGRES_USER=kemal, POSTGRES_PASSWORD=kemal (заданы в compose; не публиковать).
**Переменные окружения:** из `.env` (генерируется `deploy-invidious-credentials.sh` из Vaultwarden).
**Команды:**
```bash
@@ -124,16 +126,10 @@ Companion и PostgreSQL доступны только внутри docker-сет
## Запуск и порядок поднятия
1. Зайти в каталог: `cd /opt/invidious`.
2. Проверить/при необходимости подредактировать `docker-compose.yml` (секция `INVIDIOUS_CONFIG`, домен video.katykhin.ru, секреты).
3. Запуск/перезапуск:
```bash
docker compose up -d
```
Порядок: сначала поднимается `invidious-db`, затем `invidious` (depends_on с healthcheck), параллельно Companion.
1. С хоста Proxmox: `/root/scripts/deploy-invidious-credentials.sh` (генерирует `.env` из Vaultwarden, пушит в CT 107, запускает compose).
2. Порядок: `invidious-db``invidious` (depends_on с healthcheck), параллельно Companion.
После изменения конфигурации (секция `INVIDIOUS_CONFIG` или окружения Companion/DB):
`cd /opt/invidious && docker compose up -d` — конфигурация применяется при перезапуске контейнеров.
После изменения секретов в Vaultwarden: запустить `deploy-invidious-credentials.sh` снова.
---

View File

@@ -18,7 +18,7 @@
## Доступ и логины
- **Debian (CT 108):** логин `root`, пароль `Galene108!`.
- **Debian (CT 108):** логин `root`. Пароль — в Vaultwarden (объект **CT_108_ROOT_PASSWORD**).
- **Galene (веб):** https://call.katykhin.ru (через NPM → 192.168.1.108:8443). Вход в группы — по паролям, заданным в конфигах групп в `/opt/galene-data/groups/` (операторы и участники).
---

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

@@ -41,6 +41,12 @@
chmod 600 /root/.secrets/certbot/beget.ini
```
**Homelab (Vaultwarden):** креды хранятся в Vaultwarden (объект **beget**). Деплой с хоста Proxmox:
```bash
/root/scripts/deploy-beget-credentials.sh
```
Скрипт генерирует `beget.ini` из Vaultwarden, атомарно пушит в CT 100, ставит права 600 и pre-hook проверки. **Ротация:** сменил пароль в Vaultwarden → запустил `deploy-beget-credentials.sh` → готово.
3. **Запрос сертификата:**
```bash
certbot certonly \

View File

@@ -191,6 +191,53 @@ TELEGRAM_CHAT_ID=$(bw get item "RESTIC" | jq -r '.fields[] | select(.name=="TELE
# Дальше: curl к Telegram API с TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID
```
### Пример: Beget (certbot DNS-01, CT 100)
Скрипт `deploy-beget-credentials.sh` на Proxmox генерирует `beget.ini` из объекта **beget** (username → `dns_beget_api_username`, password → `dns_beget_api_password`), атомарно пушит в CT 100 (`beget.ini.tmp` → `mv` → `beget.ini`), ставит chmod 600. Pre-hook certbot проверяет наличие файла и права перед каждым renew. **Ротация:** сменил пароль в Vaultwarden → `deploy-beget-credentials.sh` → готово.
### Пример: Invidious (CT 107)
Скрипт `deploy-invidious-credentials.sh` генерирует `.env` из объекта **INVIDIOUS** (username, password, поля `SERVER_SECRET_KEY`, `HMAC_KEY`), атомарно пушит в CT 107, запускает `docker compose up -d --force-recreate`. **Ротация:** сменил пароль/ключи в Vaultwarden → запустил скрипт.
### Пример: Paperless (CT 104)
Скрипт `deploy-paperless-credentials.sh` генерирует `docker-compose.env` из объекта **PAPERLESS** (password = POSTGRES_PASSWORD; поля `PAPERLESS_URL`, `PAPERLESS_SECRET_KEY`, `PAPERLESS_TIME_ZONE`, `PAPERLESS_OCR_LANGUAGE`, `PAPERLESS_OCR_LANGUAGES`), пушит compose и env в CT 104, запускает `docker compose up -d --force-recreate`. **Ротация:** сменил пароль/ключи в Vaultwarden → запустил скрипт.
### Пример: RAG-service (CT 105)
Скрипт `deploy-rag-credentials.sh` генерирует `.env` из объекта **RAG_SERVICE** (поле `RAG_API_KEY`), атомарно пушит в CT 105, запускает `docker compose up -d --force-recreate`. **Перед первым запуском:** создать в Vaultwarden запись **RAG_SERVICE** (тип Login), добавить кастомное поле `RAG_API_KEY` (hidden) с текущим ключом из `/home/rag-service/.env`. **Ротация:** сменил ключ в Vaultwarden → запустил скрипт.
### Пример: Gitea (CT 103)
Скрипт `deploy-gitea-credentials.sh` генерирует `.env` из объекта **GITEA** (password = POSTGRES_PASSWORD; поле `GITEA_RUNNER_REGISTRATION_TOKEN`), пушит compose и env в CT 103, запускает `docker compose up -d --force-recreate`. **Ротация:** сменил пароль/токен в Vaultwarden → запустил скрипт.
### Пример: Nextcloud (CT 101)
Скрипт `deploy-nextcloud-credentials.sh` генерирует `.env` и `docker-compose.yml` из объекта **NEXTCLOUD** (password = POSTGRES_PASSWORD; поля `dbpassword`, `secret`, `passwordsalt`, `instanceid`), пушит в CT 101, обновляет config.php через occ, запускает compose. **Ротация:** сменил в Vaultwarden → запустил скрипт.
### Пример: Galene (CT 108)
Скрипт `deploy-galene-credentials.sh` берёт поле `config` (JSON ice-servers) из объекта **GALENE**, записывает в `/opt/galene-data/data/ice-servers.json`, перезапускает `galene.service`. **Ротация:** сменил TURN username/credential в Vaultwarden → запустил скрипт.
### Пример: Immich (VM 200)
Скрипт `deploy-immich-credentials.sh` генерирует `.env` для Immich и immich-deduper из объектов **IMMICH** и **IMMICH_DEDUPER**, пушит по SSH на VM 200, запускает compose. **Требования:** SSH без пароля root@Proxmox → admin@192.168.1.200. **Ротация:** сменил в Vaultwarden → запустил скрипт.
### Пример: WireGuard (CT 109)
Скрипт `deploy-wireguard-credentials.sh` берёт поле `wg0_conf` (полный конфиг) из объекта **LOCAL_VPN_SERVER_WG**, записывает в `/etc/wireguard/wg0.conf`, перезапускает `wg-quick@wg0`. **Перед первым запуском:** создать в Vaultwarden запись **LOCAL_VPN_SERVER_WG**, добавить кастомное поле `wg0_conf` (hidden) с содержимым текущего `/etc/wireguard/wg0.conf` (скопировать с CT 109). **Ротация:** сменил ключи в Vaultwarden → запустил скрипт.
### Пример: VPN Route Check (деплой с Proxmox в CT 100)
Скрипт `deploy-vpn-route-check.sh` на хосте Proxmox:
1. Разблокирует bw (или переиспользует сессию).
2. Получает из объекта **localhost**: `ROUTER_TELNET_HOST` (кастомное поле), `ROUTER_TELNET_USER` (username), `ROUTER_TELNET_PASSWORD` (password).
3. Генерирует `.env` во временный файл, атомарно (`mv .env.tmp .env`) пушит в CT 100.
4. Запускает `docker compose up -d` в каталоге vpn-route-check.
Режим проверки без записи: `deploy-vpn-route-check.sh --dry-run`. Подробнее: [Контейнер 100](containers/container-100.md#7-vpn-route-check).
### Fallback на старые конфиги
Если Vaultwarden недоступен или разблокировка не удалась, скрипты могут загружать креды из прежних файлов (например `/root/.telegram-notify.env`, `/root/.restic-yandex.env`). Так можно обеспечить работу бэкапов даже при временной недоступности vault.
@@ -208,14 +255,33 @@ TELEGRAM_CHAT_ID=$(bw get item "RESTIC" | jq -r '.fields[] | select(.name=="TELE
## Инвентаризация записей и полей
В Vaultwarden удобно хранить записи с именами, совпадающими с сервисами: **RESTIC**, **GITEA**, **PAPERLESS**, **NEXTCLOUD**, **HOME_BOT_TOKEN**, **VAULTWARDEN**, **MIRAN_S3** и т.д. У записей типа «логин» — логин/пароль; у записей с множеством значений — кастомные поля (например `RESTIC_REPOSITORY`, `AWS_ACCESS_KEY_ID`).
В Vaultwarden удобно хранить записи с именами, совпадающими с сервисами: **RESTIC**, **GITEA**, **PAPERLESS**, **NEXTCLOUD**, **HOME_BOT_TOKEN**, **VAULTWARDEN**, **MIRAN_S3**, **RAG_SERVICE**, **ADGUARD**, **NPM_ADMIN** и т.д. У записей типа «логин» — логин/пароль; у записей с множеством значений — кастомные поля (например `RESTIC_REPOSITORY`, `AWS_ACCESS_KEY_ID`, `RAG_API_KEY`).
Полная таблица «где лежат креды сейчас → какой объект в Vaultwarden» и готовые команды `bw get ...` / `jq` по каждому объекту описаны в [Фаза 1: Стратегия бэкапов](backup/proxmox-phase1-backup.md) — разделы «Инвентаризация секретов для переноса в Vaultwarden», «Получение секретов из Vaultwarden» и «Переключение скриптов на секреты из Vaultwarden».
**ADGUARD** — веб-интерфейс AdGuard Home (https://adguard.katykhin.ru): username = логин администратора, password = пароль. Тип записи: Login.
**NPM_ADMIN** — админка Nginx Proxy Manager (http://192.168.1.100:81): username = email (используется как identity при входе), password = пароль. Тип записи: Login. Скрипты `npm-add-proxy.sh`, `npm-add-proxy-vault.sh` используют `NPM_EMAIL` и `NPM_PASSWORD` — брать из этого объекта.
### Команды bw по объектам (для скриптов бэкапов и деплоя)
| Объект | Логин / пароль | Кастомные поля |
|--------|----------------|----------------|
| **ADGUARD** | `bw get username "ADGUARD"`, `bw get password "ADGUARD"` | — |
| **beget** | `bw get username "beget"`, `bw get password "beget"` | — |
| **GALENE** | — | `bw get item "GALENE" \| jq -r '.fields[] \| select(.name=="config") \| .value'` |
| **GITEA** | `bw get username "GITEA"`, `bw get password "GITEA"` | GITEA_RUNNER_REGISTRATION_TOKEN и др. |
| **HOME_BOT_TOKEN** | — | пароль = токен: `bw get password "HOME_BOT_TOKEN"` |
| **localhost** | `bw get username "localhost"`, `bw get password "localhost"` | ROUTER_TELNET_HOST |
| **NEXTCLOUD** | `bw get username "NEXTCLOUD"`, `bw get password "NEXTCLOUD"` | dbpassword, secret, passwordsalt, instanceid |
| **NPM_ADMIN** | username = email, `bw get password "NPM_ADMIN"` | — |
| **PAPERLESS** | `bw get password "PAPERLESS"` (= POSTGRES_PASSWORD) | PAPERLESS_SECRET_KEY, PAPERLESS_URL и др. |
| **RESTIC** | — | RESTIC_BACKUP_KEY, RESTIC_REPOSITORY, AWS_*, TELEGRAM_SELF_CHAT_ID |
| **VAULTWARDEN** | — | пароль = ADMIN_TOKEN: `bw get password "VAULTWARDEN"` |
Универсальный шаблон для поля: `bw get item "ИМЯ" | jq -r '.fields[] | select(.name=="ПОЛЕ") | .value'`
---
## См. также
- [Контейнер 103 (Gitea, Vaultwarden)](containers/container-103.md) — развёртывание Vaultwarden, порты, домен, NPM.
- [Фаза 1: Стратегия бэкапов](backup/proxmox-phase1-backup.md) — инвентаризация секретов, команды по объектам, переключение скриптов на Vaultwarden.
- [backup-howto](backup/backup-howto.md) — общий план бэкапов и восстановления, в том числе данных Vaultwarden.

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

@@ -7,8 +7,8 @@ VPS в ЦОД Миран (Санкт-Петербург). Развёрнуты
## Доступ и логины
- **SSH:** `ssh -p 15722 deploy@185.147.80.190` (пользователь deploy, в группе docker). IP: 185.147.80.190, хостнейм vm220416.vds.miran.ru, ОС Ubuntu.
- **S3 (контент ботов):** URL https://api.s3.miran.ru, порт 443. Access key: `j3tears100@gmail.com`, Secret key: `wQ1-6sZEPs92sbZTSf96` (полная таблица — в разделе «S3» ниже).
- **Админка Миран (панель хостинга VPS):** логин `j3tears100@gmail.com`, пароль `gonPok-xifrys-4nuxde`.
- **S3 (контент ботов):** URL https://api.s3.miran.ru, порт 443. Access key и Secret key — в Vaultwarden (объект **MIRAN_S3**).
- **Админка Миран (панель хостинга VPS):** логин и пароль — в Vaultwarden (отдельная запись для панели Миран).
- **Grafana, Uptime Kuma, админки ботов:** логины и пароли — в `.env` проекта prod или в менеджере паролей.
---
@@ -50,8 +50,8 @@ VPS в ЦОД Миран (Санкт-Петербург). Развёрнуты
|-------------|----------|
| URL | https://api.s3.miran.ru |
| Порт | 443 (HTTPS) |
| Access key | j3tears100@gmail.com |
| Secret key | wQ1-6sZEPs92sbZTSf96 |
| Access key | см. Vaultwarden, объект **MIRAN_S3** |
| Secret key | см. Vaultwarden, объект **MIRAN_S3** |
В ботаx (переменные окружения prod) заданы `S3_ENDPOINT_URL=https://api.s3.miran.ru`, регион и креды для загрузки/выдачи контента. Для локальной разработки или других клиентов использовать те же endpoint и ключи.
@@ -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).
@@ -119,12 +122,6 @@ docker compose logs -f telegram-bot
**Что нужно на Proxmox:**
- **SSH:** с хоста (root) должен работать вход без пароля на `deploy@185.147.80.190 -p 15722` (добавить публичный ключ хоста в `~/.ssh/authorized_keys` пользователя deploy на VPS).
- **S3:** установить `awscli` (`apt install awscli`) и создать файл `/root/.vps-miran-s3.env` с содержимым (подставить свои креды):
```bash
S3_ACCESS_KEY=j3tears100@gmail.com
S3_SECRET_KEY=...
S3_BUCKET_NAME=9829-telegram-helper-bot
```
Файл читается только root; в репозиторий не коммитить.
- **S3:** установить `awscli` (`apt install awscli`). Креды S3 — в Vaultwarden (объект **MIRAN_S3**). Файл `/root/.vps-miran-s3.env` с `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET_NAME` генерируется скриптами или создаётся вручную из Vaultwarden. Файл читается только root; в репозиторий не коммитить.
Подробности и восстановление — в [Бэкапы: как устроены и как восстанавливать](../backup/backup-howto.md).

View File

@@ -27,7 +27,7 @@ OUTPUT="$BACKUP_DIR/nextcloud-db-$DATE.sql.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
# PGPASSWORD из Vaultwarden (объект NEXTCLOUD: поле dbpassword или пароль). См. docs/backup/proxmox-phase1-backup.md
# PGPASSWORD из Vaultwarden (объект NEXTCLOUD: поле dbpassword или пароль). См. docs/vaultwarden-secrets.md
PG_ENV_ARGS=""
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ -f "$BW_MASTER_PASSWORD_FILE" ] && command -v bw >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then

View File

@@ -24,7 +24,7 @@ OUTPUT="$BACKUP_DIR/gitea-db-$DATE.sql.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
# PGPASSWORD из Vaultwarden (объект GITEA, пароль). См. docs/backup/proxmox-phase1-backup.md
# PGPASSWORD из Vaultwarden (объект GITEA, пароль). См. docs/vaultwarden-secrets.md
PG_ENV_ARGS=""
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ -f "$BW_MASTER_PASSWORD_FILE" ] && command -v bw >/dev/null 2>&1; then

View File

@@ -24,7 +24,7 @@ OUTPUT="$BACKUP_DIR/paperless-db-$DATE.sql.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
# PGPASSWORD из Vaultwarden (объект PAPERLESS, пароль). См. docs/backup/proxmox-phase1-backup.md
# PGPASSWORD из Vaultwarden (объект PAPERLESS, пароль). См. docs/vaultwarden-secrets.md
PG_ENV_ARGS=""
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ -f "$BW_MASTER_PASSWORD_FILE" ] && command -v bw >/dev/null 2>&1; then

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"
# Время запуска (для логов и уведомлений)
@@ -18,7 +20,7 @@ fi
# Секреты из Vaultwarden (объект RESTIC)
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ ! -f "$BW_MASTER_PASSWORD_FILE" ]; then
echo "Нет файла с мастер-паролем Vaultwarden: $BW_MASTER_PASSWORD_FILE (chmod 600). См. docs/backup/proxmox-phase1-backup.md"
echo "Нет файла с мастер-паролем Vaultwarden: $BW_MASTER_PASSWORD_FILE (chmod 600). См. docs/vaultwarden-secrets.md"
exit 1
fi
if ! command -v bw >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then

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"
# Время запуска (для логов и уведомлений)
@@ -22,7 +25,7 @@ fi
# Секреты из Vaultwarden (объект RESTIC)
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ ! -f "$BW_MASTER_PASSWORD_FILE" ]; then
echo "Нет файла с мастер-паролем Vaultwarden: $BW_MASTER_PASSWORD_FILE (chmod 600). См. docs/backup/proxmox-phase1-backup.md"
echo "Нет файла с мастер-паролем Vaultwarden: $BW_MASTER_PASSWORD_FILE (chmod 600). См. docs/vaultwarden-secrets.md"
exit 1
fi
if ! command -v bw >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Pre-hook для certbot: проверка beget.ini перед renew
# Путь: /etc/letsencrypt/renewal-hooks/pre/check-beget-credentials.sh
# При отсутствии файла или неверных правах — exit 1, certbot не выполнит renew.
BEGET_INI="/root/.secrets/certbot/beget.ini"
if [ ! -f "$BEGET_INI" ]; then
echo "check-beget-credentials: $BEGET_INI not found" >&2
exit 1
fi
mode=$(stat -c '%a' "$BEGET_INI" 2>/dev/null)
owner=$(stat -c '%u' "$BEGET_INI" 2>/dev/null)
if [ "$mode" != "600" ]; then
echo "check-beget-credentials: $BEGET_INI has mode $mode, expected 600" >&2
exit 1
fi
if [ "$owner" != "0" ]; then
echo "check-beget-credentials: $BEGET_INI owner $owner, expected root (0)" >&2
exit 1
fi
exit 0

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>

View File

@@ -0,0 +1,116 @@
#!/bin/bash
# deploy-beget-credentials.sh — деплой кредов Beget для certbot DNS-01 в CT 100
# Секреты из Vaultwarden (объект beget). Атомарная запись beget.ini.
#
# Использование:
# /root/scripts/deploy-beget-credentials.sh # деплой
# /root/scripts/deploy-beget-credentials.sh --dry-run # проверка без записи
#
# Ротация: сменил пароль в Vaultwarden → запустил скрипт → готово.
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=100
BEGET_INI_PATH="/root/.secrets/certbot/beget.ini"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
BEGET_USER=$(bw get username "beget" 2>/dev/null)
BEGET_PASS=$(bw get password "beget" 2>/dev/null)
if [ -z "$BEGET_USER" ] || [ -z "$BEGET_PASS" ]; then
err "beget: missing username or password in Vaultwarden"
exit 1
fi
}
gen_ini() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
dns_beget_api_username = ${BEGET_USER}
dns_beget_api_password = ${BEGET_PASS}
EOF
echo "$tmp"
}
push_ini_atomic() {
local tmp="$1"
local dir
dir=$(dirname "$BEGET_INI_PATH")
pct exec "$CT_ID" -- mkdir -p "$dir"
pct push "$CT_ID" "$tmp" "${BEGET_INI_PATH}.tmp"
pct exec "$CT_ID" -- bash -c "mv ${BEGET_INI_PATH}.tmp ${BEGET_INI_PATH} && chmod 600 ${BEGET_INI_PATH} && chown root:root ${BEGET_INI_PATH}"
log "beget.ini written (atomic), chmod 600, owner root"
}
deploy_pre_hook() {
local hook_path="/etc/letsencrypt/renewal-hooks/pre/check-beget-credentials.sh"
local hook_src
hook_src="$(cd "$(dirname "$0")" && pwd)/certbot-hooks/check-beget-credentials.sh"
if [ ! -f "$hook_src" ]; then
log "pre-hook source not found ($hook_src), skip"
return 0
fi
if pct exec "$CT_ID" -- test -f "$hook_path" 2>/dev/null; then
pct push "$CT_ID" "$hook_src" "$hook_path"
pct exec "$CT_ID" -- chmod +x "$hook_path"
log "pre-hook updated"
else
pct push "$CT_ID" "$hook_src" "$hook_path"
pct exec "$CT_ID" -- chmod +x "$hook_path"
log "pre-hook deployed"
fi
}
main() {
log "deploy-beget-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push beget.ini and deploy pre-hook"
log " dns_beget_api_username=$BEGET_USER"
log " dns_beget_api_password=***"
exit 0
fi
tmp=$(gen_ini)
trap "rm -f $tmp" EXIT
push_ini_atomic "$tmp"
deploy_pre_hook
log "done"
}
main

View File

@@ -0,0 +1,94 @@
#!/bin/bash
# deploy-galene-credentials.sh — деплой TURN-кредов Galene в CT 108
# Секреты из Vaultwarden (объект GALENE, поле config — JSON ice-servers).
#
# Использование:
# /root/scripts/deploy-galene-credentials.sh
# /root/scripts/deploy-galene-credentials.sh --dry-run
#
# Ротация: сменил TURN username/credential в Vaultwarden → запустил скрипт → systemctl restart galene
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=108
ICE_SERVERS_PATH="/opt/galene-data/data/ice-servers.json"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
local config
config=$(bw get item "GALENE" 2>/dev/null | jq -r '.fields[] | select(.name=="config") | .value // empty')
if [ -z "$config" ]; then
err "GALENE: missing config field (JSON ice-servers)"
exit 1
fi
if ! echo "$config" | jq . >/dev/null 2>&1; then
err "GALENE config: invalid JSON"
exit 1
fi
ICE_CONFIG="$config"
}
push_ice_servers() {
local tmp
tmp=$(mktemp)
echo "$ICE_CONFIG" | jq -c . > "$tmp"
pct push "$CT_ID" "$tmp" "${ICE_SERVERS_PATH}.tmp"
rm -f "$tmp"
pct exec "$CT_ID" -- bash -c "chmod 600 ${ICE_SERVERS_PATH}.tmp && mv ${ICE_SERVERS_PATH}.tmp ${ICE_SERVERS_PATH}"
log "ice-servers.json written (atomic), chmod 600"
}
restart_galene() {
pct exec "$CT_ID" -- systemctl restart galene
log "galene restarted"
}
main() {
log "deploy-galene-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push ice-servers.json and restart galene"
log " config: $(echo "$ICE_CONFIG" | jq -c .)"
exit 0
fi
push_ice_servers
restart_galene
log "done"
}
main

View File

@@ -0,0 +1,117 @@
#!/bin/bash
# deploy-gitea-credentials.sh — деплой кредов Gitea в CT 103
# Секреты из Vaultwarden (объект GITEA). Атомарная запись .env.
#
# Использование:
# /root/scripts/deploy-gitea-credentials.sh
# /root/scripts/deploy-gitea-credentials.sh --dry-run
#
# Ротация: сменил пароль/токен в Vaultwarden → запустил скрипт → docker compose up -d --force-recreate
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=103
GITEA_PATH="/opt/gitea"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
local item
item=$(bw get item "GITEA" 2>/dev/null)
POSTGRES_PASSWORD=$(bw get password "GITEA" 2>/dev/null)
GITEA_RUNNER_REGISTRATION_TOKEN=$(echo "$item" | jq -r '.fields[] | select(.name=="GITEA_RUNNER_REGISTRATION_TOKEN") | .value // empty')
if [ -z "$POSTGRES_PASSWORD" ]; then
err "GITEA: missing password (POSTGRES_PASSWORD)"
exit 1
fi
if [ -z "$GITEA_RUNNER_REGISTRATION_TOKEN" ]; then
err "GITEA: missing GITEA_RUNNER_REGISTRATION_TOKEN field"
exit 1
fi
}
gen_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
EOF
echo "$tmp"
}
push_env_atomic() {
local tmp="$1"
< "$tmp" pct exec "$CT_ID" -- bash -c "cat > ${GITEA_PATH}/.env.tmp && chmod 600 ${GITEA_PATH}/.env.tmp && mv ${GITEA_PATH}/.env.tmp ${GITEA_PATH}/.env"
log ".env written (atomic), chmod 600"
}
push_compose() {
local compose_src="${SCRIPT_DIR}/gitea/docker-compose.yml"
if [ -f "$compose_src" ]; then
pct push "$CT_ID" "$compose_src" "${GITEA_PATH}/docker-compose.yml"
log "docker-compose.yml pushed"
else
log "WARN: ${compose_src} not found, skipping compose push"
fi
}
run_compose() {
pct exec "$CT_ID" -- bash -c "cd ${GITEA_PATH} && docker compose up -d --force-recreate"
log "Gitea started"
}
main() {
log "deploy-gitea-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push .env and run compose"
log " POSTGRES_PASSWORD=***"
log " GITEA_RUNNER_REGISTRATION_TOKEN=***"
exit 0
fi
tmp=$(gen_env)
trap "rm -f $tmp" EXIT
push_env_atomic "$tmp"
push_compose
run_compose
log "done"
}
main

View File

@@ -0,0 +1,176 @@
#!/bin/bash
# deploy-immich-credentials.sh — деплой кредов Immich и immich-deduper на VM 200
# Секреты из Vaultwarden (объекты IMMICH, IMMICH_DEDUPER).
#
# Использование:
# /root/scripts/deploy-immich-credentials.sh
# /root/scripts/deploy-immich-credentials.sh --dry-run
#
# Требования: bw, jq, /root/.bw-master, SSH без пароля root@host → admin@192.168.1.200
#
# Vaultwarden: IMMICH — поля DB_PASSWORD, IMMICH_API_KEY, GEMINI_API_KEY и др. (см. .env).
# IMMICH_DEDUPER — поля PSQL_PASS, DEDUP_*, IMMICH_PATH, PSQL_*.
set -e
VM_SSH="admin@192.168.1.200"
IMMICH_PATH="/opt/immich"
DEDUPER_PATH="/opt/immich-deduper"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_field() {
local item="$1" name="$2"
echo "$item" | jq -r ".fields[] | select(.name==\"$name\") | .value // empty"
}
get_immich_secrets() {
local id
id=$(bw list items --search IMMICH 2>/dev/null | jq -r '.[] | select(.name=="IMMICH") | .id' | head -1) || true
[ -z "$id" ] && id=$(bw list items 2>/dev/null | jq -r '.[] | select(.name=="IMMICH") | .id' | head -1) || true
[ -z "$id" ] && { err "IMMICH not found in Vaultwarden"; exit 1; }
IMMICH_ITEM=$(bw get item "$id" 2>/dev/null) || { err "IMMICH get item failed for id=$id"; exit 1; }
DB_PASSWORD=$(get_field "$IMMICH_ITEM" "DB_PASSWORD")
IMMICH_API_KEY=$(get_field "$IMMICH_ITEM" "IMMICH_API_KEY")
GEMINI_API_KEY=$(get_field "$IMMICH_ITEM" "GEMINI_API_KEY")
if [ -z "$DB_PASSWORD" ]; then err "IMMICH: missing DB_PASSWORD field"; exit 1; fi
if [ -z "$IMMICH_API_KEY" ]; then err "IMMICH: missing IMMICH_API_KEY field"; exit 1; fi
}
get_deduper_secrets() {
local id
id=$(bw list items 2>/dev/null | jq -r '.[] | select(.name=="IMMICH_DEDUPER") | .id' | head -1)
[ -z "$id" ] && { err "IMMICH_DEDUPER not found in Vaultwarden"; exit 1; }
DEDUP_ITEM=$(bw get item "$id" 2>/dev/null) || {
err "IMMICH_DEDUPER not found in Vaultwarden"
exit 1
}
PSQL_PASS=$(get_field "$DEDUP_ITEM" "PSQL_PASS")
[ -z "$PSQL_PASS" ] && PSQL_PASS=$(echo "$DEDUP_ITEM" | jq -r '.login.password // empty')
DEDUP_PORT=$(get_field "$DEDUP_ITEM" "DEDUP_PORT")
DEDUP_DATA=$(get_field "$DEDUP_ITEM" "DEDUP_DATA")
DEDUP_IMAGE=$(get_field "$DEDUP_ITEM" "DEDUP_IMAGE")
IMMICH_PATH_FIELD=$(get_field "$DEDUP_ITEM" "IMMICH_PATH")
PSQL_HOST=$(get_field "$DEDUP_ITEM" "PSQL_HOST")
PSQL_PORT=$(get_field "$DEDUP_ITEM" "PSQL_PORT")
PSQL_DB=$(get_field "$DEDUP_ITEM" "PSQL_DB")
[ -z "$PSQL_PASS" ] && PSQL_PASS="${DB_PASSWORD:-}"
DEDUP_PORT="${DEDUP_PORT:-8086}"
DEDUP_DATA="${DEDUP_DATA:-/opt/immich-deduper/data}"
DEDUP_IMAGE="${DEDUP_IMAGE:-razgrizhsu/immich-deduper:latest-cpu}"
IMMICH_PATH_FIELD="${IMMICH_PATH_FIELD:-/mnt/data/library}"
PSQL_HOST="${PSQL_HOST:-database}"
PSQL_PORT="${PSQL_PORT:-5432}"
PSQL_DB="${PSQL_DB:-immich}"
}
gen_immich_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
# Immich .env (generated from Vaultwarden)
UPLOAD_LOCATION=/mnt/data/library
DB_DATA_LOCATION=/mnt/data/postgres
IMMICH_VERSION=v2
DB_PASSWORD=${DB_PASSWORD}
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
IMMICH_URL=http://immich-server:2283
IMMICH_API_KEY=${IMMICH_API_KEY}
DB_HOST=immich_postgres
DB_PORT=5432
EXTERNAL_IMMICH_URL=https://immich.katykhin.ru
GEMINI_API_KEY=${GEMINI_API_KEY}
EOF
echo "$tmp"
}
gen_deduper_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
# Deduper .env (generated from Vaultwarden)
DEDUP_PORT=${DEDUP_PORT}
DEDUP_DATA=${DEDUP_DATA}
DEDUP_IMAGE=${DEDUP_IMAGE}
IMMICH_PATH=${IMMICH_PATH_FIELD}
PSQL_HOST=${PSQL_HOST}
PSQL_PORT=${PSQL_PORT}
PSQL_DB=${PSQL_DB}
PSQL_USER=postgres
PSQL_PASS=${PSQL_PASS}
EOF
echo "$tmp"
}
push_to_vm() {
local local_file="$1" remote_path="$2"
scp -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -q "$local_file" "${VM_SSH}:/tmp/deploy-env.tmp" || {
err "scp to ${VM_SSH} failed. Ensure SSH key from Proxmox: ssh-copy-id ${VM_SSH}"
exit 1
}
ssh -o BatchMode=yes -o ConnectTimeout=10 "$VM_SSH" "sudo mv /tmp/deploy-env.tmp ${remote_path} && sudo chmod 600 ${remote_path}" || {
err "ssh to ${VM_SSH} failed"
exit 1
}
}
run_compose() {
ssh -o BatchMode=yes "$VM_SSH" "cd ${IMMICH_PATH} && sudo docker compose up -d --force-recreate"
ssh -o BatchMode=yes "$VM_SSH" "cd ${DEDUPER_PATH} && sudo docker compose up -d --force-recreate"
log "Immich and deduper started"
}
main() {
log "deploy-immich-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_immich_secrets
get_deduper_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push .env files and run compose"
log " DB_PASSWORD=*** IMMICH_API_KEY=***"
exit 0
fi
tmp_immich=$(gen_immich_env)
tmp_deduper=$(gen_deduper_env)
trap "rm -f $tmp_immich $tmp_deduper" EXIT
push_to_vm "$tmp_immich" "${IMMICH_PATH}/.env"
log "Immich .env written"
push_to_vm "$tmp_deduper" "${DEDUPER_PATH}/.env"
log "Deduper .env written"
run_compose
log "done"
}
main

View File

@@ -0,0 +1,116 @@
#!/bin/bash
# deploy-invidious-credentials.sh — деплой кредов Invidious в CT 107
# Секреты из Vaultwarden (объект INVIDIOUS). Атомарная запись .env.
#
# Использование:
# /root/scripts/deploy-invidious-credentials.sh
# /root/scripts/deploy-invidious-credentials.sh --dry-run
#
# Ротация: сменил пароль/ключи в Vaultwarden → запустил скрипт → docker compose up -d --force-recreate
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=107
INVIDIOUS_PATH="/opt/invidious"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
local item
item=$(bw get item "INVIDIOUS" 2>/dev/null)
POSTGRES_USER=$(echo "$item" | jq -r '.login.username // empty')
POSTGRES_PASSWORD=$(bw get password "INVIDIOUS" 2>/dev/null)
INVIDIOUS_COMPANION_KEY=$(echo "$item" | jq -r '.fields[] | select(.name=="SERVER_SECRET_KEY") | .value // empty')
HMAC_KEY=$(echo "$item" | jq -r '.fields[] | select(.name=="HMAC_KEY") | .value // empty')
if [ -z "$POSTGRES_USER" ] || [ -z "$POSTGRES_PASSWORD" ]; then
err "INVIDIOUS: missing username or password"
exit 1
fi
if [ -z "$INVIDIOUS_COMPANION_KEY" ]; then
err "INVIDIOUS: missing SERVER_SECRET_KEY field"
exit 1
fi
if [ -z "$HMAC_KEY" ]; then
err "INVIDIOUS: missing HMAC_KEY field"
exit 1
fi
}
gen_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=invidious
INVIDIOUS_COMPANION_KEY=${INVIDIOUS_COMPANION_KEY}
HMAC_KEY=${HMAC_KEY}
EOF
echo "$tmp"
}
push_env_atomic() {
local tmp="$1"
< "$tmp" pct exec "$CT_ID" -- bash -c "cat > ${INVIDIOUS_PATH}/.env.tmp && chmod 600 ${INVIDIOUS_PATH}/.env.tmp && mv ${INVIDIOUS_PATH}/.env.tmp ${INVIDIOUS_PATH}/.env"
log ".env written (atomic), chmod 600"
}
run_compose() {
pct exec "$CT_ID" -- bash -c "cd ${INVIDIOUS_PATH} && docker compose up -d --force-recreate"
log "Invidious started"
}
main() {
log "deploy-invidious-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push .env and run compose"
log " POSTGRES_USER=$POSTGRES_USER"
log " POSTGRES_PASSWORD=***"
log " INVIDIOUS_COMPANION_KEY=***"
log " HMAC_KEY=***"
exit 0
fi
tmp=$(gen_env)
trap "rm -f $tmp" EXIT
push_env_atomic "$tmp"
run_compose
log "done"
}
main

View File

@@ -0,0 +1,125 @@
#!/bin/bash
# deploy-nextcloud-credentials.sh — деплой кредов Nextcloud в CT 101
# Секреты из Vaultwarden (объект NEXTCLOUD). Атомарная запись .env, обновление config.php через occ.
#
# Использование:
# /root/scripts/deploy-nextcloud-credentials.sh
# /root/scripts/deploy-nextcloud-credentials.sh --dry-run
#
# Ротация: сменил пароль/ключи в Vaultwarden → запустил скрипт → docker compose up -d --force-recreate
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=101
NEXTCLOUD_PATH="/opt/nextcloud"
CONFIG_PATH="/mnt/nextcloud-data/html/config/config.php"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
local item
item=$(bw get item "NEXTCLOUD" 2>/dev/null)
POSTGRES_PASSWORD=$(bw get password "NEXTCLOUD" 2>/dev/null)
NEXTCLOUD_TRUSTED_DOMAINS=$(echo "$item" | jq -r '.fields[] | select(.name=="NEXTCLOUD_TRUSTED_DOMAINS") | .value // empty')
DBPASSWORD=$(echo "$item" | jq -r '.fields[] | select(.name=="dbpassword") | .value // empty')
SECRET=$(echo "$item" | jq -r '.fields[] | select(.name=="secret") | .value // empty')
PASSWORDSALT=$(echo "$item" | jq -r '.fields[] | select(.name=="passwordsalt") | .value // empty')
INSTANCEID=$(echo "$item" | jq -r '.fields[] | select(.name=="instanceid") | .value // empty')
if [ -z "$POSTGRES_PASSWORD" ]; then
err "NEXTCLOUD: missing password (POSTGRES_PASSWORD)"
exit 1
fi
NEXTCLOUD_TRUSTED_DOMAINS="${NEXTCLOUD_TRUSTED_DOMAINS:-cloud.katykhin.ru 192.168.1.101}"
}
gen_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS=${NEXTCLOUD_TRUSTED_DOMAINS}
EOF
echo "$tmp"
}
push_env_atomic() {
local tmp="$1"
< "$tmp" pct exec "$CT_ID" -- bash -c "cat > ${NEXTCLOUD_PATH}/.env.tmp && chmod 600 ${NEXTCLOUD_PATH}/.env.tmp && mv ${NEXTCLOUD_PATH}/.env.tmp ${NEXTCLOUD_PATH}/.env"
log ".env written (atomic), chmod 600"
}
push_compose() {
local compose_src="${SCRIPT_DIR}/nextcloud/docker-compose.yml"
if [ -f "$compose_src" ]; then
pct push "$CT_ID" "$compose_src" "${NEXTCLOUD_PATH}/docker-compose.yml"
log "docker-compose.yml pushed"
fi
}
update_config_occ() {
[ -n "$DBPASSWORD" ] && pct exec "$CT_ID" -- docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:system:set dbpassword --value="$DBPASSWORD" 2>/dev/null || true
[ -n "$SECRET" ] && pct exec "$CT_ID" -- docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:system:set secret --value="$SECRET" 2>/dev/null || true
[ -n "$PASSWORDSALT" ] && pct exec "$CT_ID" -- docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:system:set passwordsalt --value="$PASSWORDSALT" 2>/dev/null || true
[ -n "$INSTANCEID" ] && pct exec "$CT_ID" -- docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:system:set instanceid --value="$INSTANCEID" 2>/dev/null || true
log "config.php updated via occ"
}
run_compose() {
pct exec "$CT_ID" -- bash -c "cd ${NEXTCLOUD_PATH} && docker compose up -d --force-recreate"
log "Nextcloud started"
}
main() {
log "deploy-nextcloud-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push .env, compose, update config, run compose"
log " POSTGRES_PASSWORD=***"
exit 0
fi
tmp=$(gen_env)
trap "rm -f $tmp" EXIT
push_env_atomic "$tmp"
push_compose
run_compose
update_config_occ
log "done"
}
main

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# deploy-paperless-credentials.sh — деплой кредов Paperless в CT 104
# Секреты из Vaultwarden (объект PAPERLESS). Атомарная запись docker-compose.env.
#
# Использование:
# /root/scripts/deploy-paperless-credentials.sh
# /root/scripts/deploy-paperless-credentials.sh --dry-run
#
# Ротация: сменил пароль/ключи в Vaultwarden → запустил скрипт → docker compose up -d --force-recreate
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=104
PAPERLESS_PATH="/opt/paperless"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
local item
item=$(bw get item "PAPERLESS" 2>/dev/null)
POSTGRES_PASSWORD=$(bw get password "PAPERLESS" 2>/dev/null)
PAPERLESS_URL=$(echo "$item" | jq -r '.fields[] | select(.name=="PAPERLESS_URL") | .value // empty')
PAPERLESS_SECRET_KEY=$(echo "$item" | jq -r '.fields[] | select(.name=="PAPERLESS_SECRET_KEY") | .value // empty')
PAPERLESS_TIME_ZONE=$(echo "$item" | jq -r '.fields[] | select(.name=="PAPERLESS_TIME_ZONE") | .value // empty')
PAPERLESS_OCR_LANGUAGE=$(echo "$item" | jq -r '.fields[] | select(.name=="PAPERLESS_OCR_LANGUAGE") | .value // empty')
PAPERLESS_OCR_LANGUAGES=$(echo "$item" | jq -r '.fields[] | select(.name=="PAPERLESS_OCR_LANGUAGES") | .value // empty')
if [ -z "$POSTGRES_PASSWORD" ]; then
err "PAPERLESS: missing password (POSTGRES_PASSWORD)"
exit 1
fi
if [ -z "$PAPERLESS_SECRET_KEY" ]; then
err "PAPERLESS: missing PAPERLESS_SECRET_KEY field"
exit 1
fi
PAPERLESS_URL="${PAPERLESS_URL:-https://docs.katykhin.ru}"
PAPERLESS_TIME_ZONE="${PAPERLESS_TIME_ZONE:-Europe/Moscow}"
PAPERLESS_OCR_LANGUAGE="${PAPERLESS_OCR_LANGUAGE:-rus+eng}"
PAPERLESS_OCR_LANGUAGES="${PAPERLESS_OCR_LANGUAGES:-rus}"
}
gen_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
PAPERLESS_URL=${PAPERLESS_URL}
PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
PAPERLESS_TIME_ZONE=${PAPERLESS_TIME_ZONE}
PAPERLESS_OCR_LANGUAGE=${PAPERLESS_OCR_LANGUAGE}
PAPERLESS_OCR_LANGUAGES=${PAPERLESS_OCR_LANGUAGES}
EOF
echo "$tmp"
}
push_env_atomic() {
local tmp="$1"
< "$tmp" pct exec "$CT_ID" -- bash -c "cat > ${PAPERLESS_PATH}/docker-compose.env.tmp && chmod 600 ${PAPERLESS_PATH}/docker-compose.env.tmp && mv ${PAPERLESS_PATH}/docker-compose.env.tmp ${PAPERLESS_PATH}/docker-compose.env"
log "docker-compose.env written (atomic), chmod 600"
}
push_compose() {
local compose_src="${SCRIPT_DIR}/paperless/docker-compose.yml"
if [ -f "$compose_src" ]; then
pct push "$CT_ID" "$compose_src" "${PAPERLESS_PATH}/docker-compose.yml"
log "docker-compose.yml pushed"
else
log "WARN: ${compose_src} not found, skipping compose push"
fi
}
run_compose() {
pct exec "$CT_ID" -- bash -c "cd ${PAPERLESS_PATH} && docker compose up -d --force-recreate"
log "Paperless started"
}
main() {
log "deploy-paperless-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push docker-compose.env and run compose"
log " POSTGRES_PASSWORD=***"
log " PAPERLESS_URL=$PAPERLESS_URL"
exit 0
fi
tmp=$(gen_env)
trap "rm -f $tmp" EXIT
push_env_atomic "$tmp"
push_compose
run_compose
log "done"
}
main

View File

@@ -0,0 +1,130 @@
#!/bin/bash
# deploy-rag-credentials.sh — деплой кредов RAG-service в CT 105
# Секреты из Vaultwarden (объект RAG_SERVICE). Атомарная запись .env.
#
# Использование:
# /root/scripts/deploy-rag-credentials.sh
# /root/scripts/deploy-rag-credentials.sh --dry-run
#
# Ротация: сменил RAG_API_KEY в Vaultwarden → запустил скрипт → docker compose up -d --force-recreate
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
# Vaultwarden: создать запись RAG_SERVICE с полем RAG_API_KEY (тип hidden).
set -e
CT_ID=105
RAG_PATH="/home/rag-service"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
local item
item=$(bw get item "RAG_SERVICE" 2>/dev/null) || {
err "RAG_SERVICE not found in Vaultwarden. Create it: type Login, add custom field RAG_API_KEY (hidden)."
exit 1
}
RAG_API_KEY=$(echo "$item" | jq -r '.fields[] | select(.name=="RAG_API_KEY") | .value // empty')
if [ -z "$RAG_API_KEY" ]; then
err "RAG_SERVICE: missing RAG_API_KEY field"
exit 1
fi
}
gen_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
# RAG Service Configuration (generated from Vaultwarden)
# Модель
RAG_MODEL=sentence-transformers/all-MiniLM-L12-v2
RAG_CACHE_DIR=data/models
# VectorStore
RAG_VECTORS_PATH=data/vectors/vectors.npz
RAG_MAX_EXAMPLES=10000
RAG_SCORE_MULTIPLIER=5.0
# Батч-обработка
RAG_BATCH_SIZE=16
# Минимальная длина текста
RAG_MIN_TEXT_LENGTH=3
# API настройки
RAG_API_HOST=0.0.0.0
RAG_API_PORT=8000
# Безопасность
RAG_API_KEY=${RAG_API_KEY}
RAG_ALLOW_NO_AUTH=false
# Автосохранение векторов
RAG_AUTOSAVE_INTERVAL=600
# Логирование
LOG_LEVEL=INFO
EOF
echo "$tmp"
}
push_env_atomic() {
local tmp="$1"
< "$tmp" pct exec "$CT_ID" -- bash -c "cat > ${RAG_PATH}/.env.tmp && chmod 600 ${RAG_PATH}/.env.tmp && mv ${RAG_PATH}/.env.tmp ${RAG_PATH}/.env"
log ".env written (atomic), chmod 600"
}
run_compose() {
pct exec "$CT_ID" -- bash -c "cd ${RAG_PATH} && docker compose up -d --force-recreate"
log "RAG-service started"
}
main() {
log "deploy-rag-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push .env and run compose"
log " RAG_API_KEY=***"
exit 0
fi
tmp=$(gen_env)
trap "rm -f $tmp" EXIT
push_env_atomic "$tmp"
run_compose
log "done"
}
main

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Deploy SSH public key to all LXC containers and VM 200 in homelab.
# Run from machine that can reach Proxmox (192.168.1.150).
# Usage: ./deploy-ssh-keys-homelab.sh [path-to-public-key]
# Default: ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub
set -e
PROXMOX="${PROXMOX:-root@192.168.1.150}"
KEY_FILE="${1:-$HOME/.ssh/id_rsa.pub}"
[ -f "$HOME/.ssh/id_ed25519.pub" ] && [ ! -f "$KEY_FILE" ] && KEY_FILE="$HOME/.ssh/id_ed25519.pub"
if [ ! -f "$KEY_FILE" ]; then
echo "Usage: $0 [path-to-public-key]"
echo "No key found at $KEY_FILE"
exit 1
fi
CT_IDS="100 101 103 104 105 107 108 109"
echo "Deploying key from $KEY_FILE to homelab hosts..."
# Copy key to Proxmox temp, then deploy from there
TMP_KEY="/tmp/deploy-ssh-key-$$.pub"
scp -q "$KEY_FILE" "$PROXMOX:$TMP_KEY"
trap "ssh $PROXMOX 'rm -f $TMP_KEY'" EXIT
# Proxmox host
echo "Proxmox (192.168.1.150)..."
ssh "$PROXMOX" "mkdir -p /root/.ssh && chmod 700 /root/.ssh && grep -qF \"\$(cat $TMP_KEY)\" /root/.ssh/authorized_keys 2>/dev/null || cat $TMP_KEY >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys"
# LXC containers
for id in $CT_IDS; do
echo "CT $id (192.168.1.$id)..."
ssh "$PROXMOX" "pct exec $id -- bash -c 'mkdir -p /root/.ssh && chmod 700 /root/.ssh' && pct push $id $TMP_KEY /tmp/key.pub && pct exec $id -- bash -c 'grep -qF \"\$(cat /tmp/key.pub)\" /root/.ssh/authorized_keys 2>/dev/null || cat /tmp/key.pub >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && rm /tmp/key.pub'"
done
# VM 200 (admin user; root may be disabled)
echo "VM 200 (admin@192.168.1.200)..."
ssh "$PROXMOX" "scp -o StrictHostKeyChecking=accept-new $TMP_KEY admin@192.168.1.200:/tmp/key.pub && ssh admin@192.168.1.200 'mkdir -p /home/admin/.ssh /root/.ssh && chmod 700 /home/admin/.ssh /root/.ssh 2>/dev/null; grep -qF \"\$(cat /tmp/key.pub)\" /home/admin/.ssh/authorized_keys 2>/dev/null || cat /tmp/key.pub >> /home/admin/.ssh/authorized_keys; echo \"\$(cat /tmp/key.pub)\" | sudo tee -a /root/.ssh/authorized_keys >/dev/null; chmod 600 /home/admin/.ssh/authorized_keys /root/.ssh/authorized_keys 2>/dev/null; rm /tmp/key.pub'"
echo "Done. Connect: ssh root@192.168.1.{100,101,103,104,105,107,108,109}, ssh admin@192.168.1.200"

View File

@@ -0,0 +1,111 @@
#!/bin/bash
# deploy-vpn-route-check.sh — идемпотентный деплой vpn-route-check на CT 100
# Секреты берутся из Vaultwarden (объект localhost), .env генерируется на Proxmox и пушится в CT.
#
# Использование:
# /root/scripts/deploy-vpn-route-check.sh # деплой
# /root/scripts/deploy-vpn-route-check.sh --dry-run # только проверка, без записи и compose
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=100
CT_PATH="/opt/docker/vpn-route-check"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
# --- 1. Разблокировка bw (reuse session если возможно)
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
# --- 2. Получить секреты из Vaultwarden (localhost)
get_secrets() {
local host user pass
host=$(bw get item "localhost" 2>/dev/null | jq -r '.fields[] | select(.name=="ROUTER_TELNET_HOST") | .value // empty')
user=$(bw get username "localhost" 2>/dev/null)
pass=$(bw get password "localhost" 2>/dev/null)
if [ -z "$user" ] || [ -z "$pass" ]; then
err "localhost: missing username or password in Vaultwarden"
exit 1
fi
host="${host:-192.168.1.1}"
ROUTER_TELNET_HOST="$host"
ROUTER_TELNET_USER="$user"
ROUTER_TELNET_PASSWORD="$pass"
}
# --- 3. Сгенерировать .env во временный файл
gen_env() {
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
ROUTER_TELNET_HOST=${ROUTER_TELNET_HOST}
ROUTER_TELNET_USER=${ROUTER_TELNET_USER}
ROUTER_TELNET_PASSWORD=${ROUTER_TELNET_PASSWORD}
EOF
echo "$tmp"
}
# --- 4. Атомарно записать .env в CT 100
push_env_to_ct() {
local tmp="$1"
< "$tmp" pct exec "$CT_ID" -- bash -c "cat > ${CT_PATH}/.env.tmp && chmod 600 ${CT_PATH}/.env.tmp && mv ${CT_PATH}/.env.tmp ${CT_PATH}/.env"
log ".env written to CT $CT_ID (atomic)"
}
# --- 5. docker compose up -d
run_compose() {
pct exec "$CT_ID" -- bash -c "cd ${CT_PATH} && docker compose up -d --force-recreate"
log "vpn-route-check started"
}
# --- main
main() {
log "deploy-vpn-route-check start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push .env and run compose"
log " ROUTER_TELNET_HOST=$ROUTER_TELNET_HOST"
log " ROUTER_TELNET_USER=$ROUTER_TELNET_USER"
log " ROUTER_TELNET_PASSWORD=***"
exit 0
fi
tmp=$(gen_env)
trap "rm -f $tmp" EXIT
push_env_to_ct "$tmp"
run_compose
log "done"
}
main

View File

@@ -0,0 +1,95 @@
#!/bin/bash
# deploy-wireguard-credentials.sh — деплой конфига WireGuard в CT 109
# Секреты из Vaultwarden (объект LOCAL_VPN_SERVER_WG, поле wg0_conf — полный конфиг).
#
# Использование:
# /root/scripts/deploy-wireguard-credentials.sh
# /root/scripts/deploy-wireguard-credentials.sh --dry-run
#
# Перед первым запуском: создать в Vaultwarden запись LOCAL_VPN_SERVER_WG,
# добавить кастомное поле wg0_conf (hidden) с полным содержимым /etc/wireguard/wg0.conf.
#
# Ротация: сменил ключи в Vaultwarden → запустил скрипт → systemctl restart wg-quick@wg0
#
# Требования: bw, jq, /root/.bw-master (chmod 600)
set -e
CT_ID=109
WG_CONF_PATH="/etc/wireguard/wg0.conf"
BW_MASTER_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
esac
done
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}"
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
ensure_bw_unlocked() {
local status
status=$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || echo "unknown")
if [ "$status" = "unlocked" ]; then
log "bw already unlocked, reusing session"
return 0
fi
if [ ! -f "$BW_MASTER_FILE" ]; then
err "Missing $BW_MASTER_FILE"
exit 1
fi
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_FILE" --raw 2>/dev/null) || {
err "bw unlock failed"
exit 1
}
log "bw unlocked"
}
get_secrets() {
WG_CONF=$(bw get item "LOCAL_VPN_SERVER_WG" 2>/dev/null | jq -r '.fields[] | select(.name=="wg0_conf") | .value // empty')
if [ -z "$WG_CONF" ]; then
err "LOCAL_VPN_SERVER_WG not found or missing wg0_conf field. Create it in Vaultwarden, add field wg0_conf with full wg0.conf content."
exit 1
fi
if ! echo "$WG_CONF" | grep -q '\[Interface\]'; then
err "wg0_conf: invalid format (expected [Interface] section)"
exit 1
fi
}
push_conf() {
local tmp
tmp=$(mktemp)
echo "$WG_CONF" > "$tmp"
pct push "$CT_ID" "$tmp" "${WG_CONF_PATH}.tmp"
rm -f "$tmp"
pct exec "$CT_ID" -- bash -c "chmod 600 ${WG_CONF_PATH}.tmp && mv ${WG_CONF_PATH}.tmp ${WG_CONF_PATH}"
log "wg0.conf written (atomic), chmod 600"
}
restart_wg() {
pct exec "$CT_ID" -- systemctl restart wg-quick@wg0
log "wg-quick@wg0 restarted"
}
main() {
log "deploy-wireguard-credentials start (dry_run=$DRY_RUN)"
ensure_bw_unlocked
get_secrets
if [ "$DRY_RUN" = true ]; then
log "DRY-RUN: would push wg0.conf and restart WireGuard"
log " wg0_conf: $(echo "$WG_CONF" | head -3)..."
exit 0
fi
push_conf
restart_wg
log "done"
}
main

View File

@@ -0,0 +1,74 @@
# Шаблон для /opt/gitea/ на CT 103
# Секреты в .env (генерируется deploy-gitea-credentials.sh из Vaultwarden).
# .env не коммитить.
services:
db:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file: .env
environment:
POSTGRES_USER: gitea
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: gitea
volumes:
- gitea-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
interval: 10s
timeout: 5s
retries: 5
server:
image: docker.gitea.com/gitea:1.25
container_name: gitea
restart: unless-stopped
depends_on:
db:
condition: service_healthy
env_file: .env
environment:
USER_UID: 1000
USER_GID: 1000
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: db:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: ${POSTGRES_PASSWORD}
GITEA__server__DOMAIN: 192.168.1.103
GITEA__server__ROOT_URL: http://192.168.1.103:3000/
GITEA__server__SSH_PORT: 2222
volumes:
- gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "2222:22"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
runner:
image: docker.io/gitea/act_runner:latest
restart: unless-stopped
depends_on:
server:
condition: service_healthy
env_file: .env
environment:
GITEA_INSTANCE_URL: http://server:3000
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: gitea-103-runner
GITEA_RUNNER_LABELS: docker:docker://alpine:latest
volumes:
- runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
volumes:
gitea-data:
gitea-postgres:
runner-data:

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=

View File

@@ -0,0 +1,84 @@
# Шаблон для /opt/invidious/docker-compose.yml на CT 107
# Секреты в .env (генерируется deploy-invidious-credentials.sh из Vaultwarden).
# .env не коммитить.
services:
invidious:
image: quay.io/invidious/invidious:latest
restart: unless-stopped
ports:
- "3000:3000"
env_file: .env
environment:
INVIDIOUS_CONFIG: |
db:
dbname: invidious
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
host: invidious-db
port: 5432
check_tables: true
invidious_companion:
- private_url: "http://companion:8282/companion"
invidious_companion_key: "${INVIDIOUS_COMPANION_KEY}"
external_port: 443
domain: "video.katykhin.ru"
https_only: true
use_pubsub_feeds: true
use_innertube_for_captions: true
hmac_key: "${HMAC_KEY}"
default_user_preferences:
default_home: Popular
dark_mode: "light"
player_style: "youtube"
vr_mode: false
automatic_instance_redirect: false
healthcheck:
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/stats || exit 1
interval: 30s
timeout: 5s
retries: 2
logging:
options:
max-size: "1G"
max-file: "4"
depends_on:
invidious-db:
condition: service_healthy
companion:
image: quay.io/invidious/invidious-companion:latest
env_file: .env
environment:
SERVER_SECRET_KEY: ${INVIDIOUS_COMPANION_KEY}
restart: unless-stopped
logging:
options:
max-size: "1G"
max-file: "4"
cap_drop:
- ALL
read_only: true
volumes:
- companioncache:/var/tmp/youtubei.js:rw
security_opt:
- no-new-privileges:true
invidious-db:
image: docker.io/library/postgres:14
restart: unless-stopped
volumes:
- postgresdata:/var/lib/postgresql/data
- ./config/sql:/config/sql
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
env_file: .env
environment:
POSTGRES_DB: invidious
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
volumes:
postgresdata:
companioncache:

View File

@@ -0,0 +1,52 @@
# Шаблон для /opt/nextcloud/ на CT 101
# Секреты в .env (генерируется deploy-nextcloud-credentials.sh из Vaultwarden).
# .env не коммитить.
services:
db:
image: docker.io/library/postgres:16
restart: unless-stopped
volumes:
- /mnt/nextcloud-data/pgdata:/var/lib/postgresql/data
env_file: .env
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nextcloud"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: docker.io/library/redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
nextcloud:
image: docker.io/nextcloud:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
ports:
- "8080:80"
volumes:
- /mnt/nextcloud-data/html:/var/www/html
- /mnt/nextcloud-extra:/mnt/nextcloud-extra
- /opt/nextcloud/php-uploads.ini:/usr/local/etc/php/conf.d/zz-uploads.ini:ro
env_file: .env
environment:
APACHE_BODY_LIMIT: "0"
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
OVERWRITEPROTOCOL: https
OVERWRITEHOST: cloud.katykhin.ru
OVERWRITECLIURL: https://cloud.katykhin.ru
REDIS_HOST: redis
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

View File

@@ -1,15 +1,17 @@
#!/bin/bash
# Add vault.katykhin.ru → 192.168.1.103:8280 via NPM API + Access List (LAN + VPN only)
# Usage: NPM_EMAIL=j3tears100@gmail.com NPM_PASSWORD=xxx ./npm-add-proxy-vault.sh
# Usage: NPM_EMAIL=... NPM_PASSWORD=... ./npm-add-proxy-vault.sh
# NPM credentials: Vaultwarden, объект NPM_ADMIN (username=email, password)
# Run from host that can reach NPM, or: ssh root@192.168.1.150 "pct exec 100 -- bash -s" < scripts/npm-add-proxy-vault.sh
# (then set NPM_URL=http://127.0.0.1:81 and NPM_EMAIL/NPM_PASSWORD in env or below)
# NPM credentials: see docs/containers/container-100.md
# (then set NPM_URL=http://127.0.0.1:81 and NPM_EMAIL/NPM_PASSWORD in env)
set -e
NPM_URL="${NPM_URL:-http://192.168.1.100:81}"
API="$NPM_URL/api"
NPM_EMAIL="${NPM_EMAIL:-j3tears100@gmail.com}"
NPM_PASSWORD="${NPM_PASSWORD:-kqEUubVq02DJTS8}"
if [ -z "$NPM_EMAIL" ] || [ -z "$NPM_PASSWORD" ]; then
echo "Set NPM_EMAIL and NPM_PASSWORD (from Vaultwarden, объект NPM_ADMIN)"
exit 1
fi
echo "1. Getting token..."
TOKEN=$(curl -s -X POST "$API/tokens" \

View File

@@ -0,0 +1,41 @@
# Шаблон для /opt/paperless/ на CT 104
# Секреты в docker-compose.env (генерируется deploy-paperless-credentials.sh из Vaultwarden).
# docker-compose.env не коммитить.
services:
broker:
image: docker.io/library/redis:8
restart: unless-stopped
volumes:
- redisdata:/data
db:
image: docker.io/library/postgres:18
restart: unless-stopped
volumes:
- /mnt/paperless-data/pgdata:/var/lib/postgresql
env_file: docker-compose.env
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
restart: unless-stopped
depends_on:
- db
- broker
ports:
- "8000:8000"
volumes:
- /mnt/paperless-data/data:/usr/src/paperless/data
- /mnt/paperless-data/media:/usr/src/paperless/media
- ./export:/usr/src/paperless/export
- ./consume:/usr/src/paperless/consume
env_file: docker-compose.env
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
volumes:
redisdata:

View File

@@ -30,7 +30,7 @@ fi
# Секреты из Vaultwarden (объект RESTIC)
BW_MASTER_PASSWORD_FILE="${BW_MASTER_PASSWORD_FILE:-/root/.bw-master}"
if [ ! -f "$BW_MASTER_PASSWORD_FILE" ]; then
echo "Нет файла с мастер-паролем Vaultwarden: $BW_MASTER_PASSWORD_FILE (chmod 600). См. docs/backup/proxmox-phase1-backup.md"
echo "Нет файла с мастер-паролем Vaultwarden: $BW_MASTER_PASSWORD_FILE (chmod 600). См. docs/vaultwarden-secrets.md"
exit 1
fi
if ! command -v bw >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then

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

View File

@@ -0,0 +1,16 @@
# Шаблон для /opt/docker/vpn-route-check/docker-compose.yml на CT 100
# Секреты в .env (генерируется deploy-vpn-route-check.sh из Vaultwarden).
# .env не коммитить.
services:
vpn-route-check:
build: .
container_name: vpn-route-check
network_mode: host
env_file: .env
volumes:
- vpn-route-check-data:/data
restart: unless-stopped
volumes:
vpn-route-check-data:

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