Compare commits

..

5 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
f319133cee Add notification feature to backup scripts for various services
Enhance backup scripts for Nextcloud, Gitea, Paperless, Vaultwarden, Immich, and VPS configurations by adding Telegram notifications upon completion. Include details such as backup size and objects backed up. Update backup documentation to reflect these changes and ensure clarity on backup processes and retention policies.
2026-02-27 20:42:30 +03:00
56cee83198 Enhance backup documentation for Proxmox and VPS configurations. Add details for MTProto proxy setup on VPS, clarify backup processes for Immich photos, and update restic backup scripts to exclude photo directories. Include test recovery results and refine instructions for restoring various services and configurations. 2026-02-26 23:06:02 +03:00
112 changed files with 5594 additions and 436 deletions

View File

@@ -4,7 +4,7 @@
**Точка входа:** [Архитектура и подключение](docs/architecture/architecture.md) — схема сети, IP, домены, таблица всех хостов. **Точка входа:** [Архитектура и подключение](docs/architecture/architecture.md) — схема сети, IP, домены, таблица всех хостов.
**Топология и риски:** [Схема сети и зависимости](docs/network/network-topology.md) — узлы, маршруты NPM, зависимости сервисов, единые точки отказа (SPOF). **Топология и риски:** [Схема сети и зависимости](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 - **Внешний IP:** 185.35.193.144
- **Домашний сервер (Proxmox):** 192.168.1.150 (LAN) - **Домашний сервер (Proxmox):** 192.168.1.150 (LAN)
- Подключение: `ssh root@192.168.1.150` - Подключение: `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 - **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. - **Reverse proxy и SSL:** Nginx Proxy Manager (NPM) на контейнере 100.
**Поддомены katykhin.ru:** **Поддомены katykhin.ru:**
@@ -23,6 +24,7 @@
| cloud.katykhin.ru | — | | cloud.katykhin.ru | — |
| docs.katykhin.ru | — | | docs.katykhin.ru | — |
| git.katykhin.ru | — | | git.katykhin.ru | — |
| healthchecks.katykhin.ru | Healthchecks (Dead man's switch для бэкапов; на VPS Миран) |
| home.katykhin.ru | Homepage | | home.katykhin.ru | Homepage |
| immich.katykhin.ru | — | | immich.katykhin.ru | — |
| mini-lm.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). - **Схема сети и зависимости:** полная топология (роутер, 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). - **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). - **Роутер:** 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). - **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: **/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/ /mnt/backup/
├── proxmox/ ├── proxmox/
@@ -25,28 +35,32 @@
│ └── ct105-vectors/ ← векторы RAG (vectors.npz) из CT 105 │ └── ct105-vectors/ ← векторы RAG (vectors.npz) из CT 105
├── restic/ ← (опционально) ├── restic/ ← (опционально)
└── vps/ └── vps/
── miran/ ← VPS Миран: БД бота, voice_users, копия S3 (telegram-helper-bot) ── miran/ ← VPS Миран: БД бота, voice_users, копия S3 (telegram-helper-bot)
└── mtproto-germany/ ← VPS Германия: конфиги MTProto + сайт katykhin.store (mtg, nginx, certs)
``` ```
--- ---
## Что, откуда, куда, когда ## Что, откуда, куда, когда
| Что | Откуда | Куда (локально) | Когда | Хранение |
|-----|--------|------------------|------|----------|
| **LXC и VM** | Все выбранные контейнеры (100109) и VM 200 | `/mnt/backup/proxmox/dump/` | **02:00** ежедневно (задание в Proxmox UI) | По настройкам задания (например: 7 daily, 4 weekly, 6 monthly) |
| **Конфиги хоста** | `/etc/pve`, `/etc/network/interfaces`, `/etc/hosts`, `/etc/resolv.conf` | `/mnt/backup/proxmox/etc-pve/` | **02:15** ежедневно (cron: `backup-etc-pve.sh`) | 30 дней |
| **БД Nextcloud (PostgreSQL)** | CT 101, контейнер `nextcloud-db-1` в `/opt/nextcloud` | `/mnt/backup/databases/ct101-nextcloud/` | **01:15** ежедневно (cron: `backup-ct101-pgdump.sh`) | 14 дней |
| **БД Gitea (PostgreSQL)** | CT 103, контейнер `gitea-db-1` в `/opt/gitea` | `/mnt/backup/databases/ct103-gitea/` | **03:00** ежедневно (cron: `backup-ct103-gitea-pgdump.sh`) | 14 дней |
| **БД Paperless (PostgreSQL)** | CT 104, контейнер `paperless-db-1` в `/opt/paperless` | `/mnt/backup/databases/ct104-paperless/` | **02:30** ежедневно (cron: `backup-ct104-pgdump.sh`) | 14 дней |
| **Данные Vaultwarden (пароли)** | CT 103, `/opt/docker/vaultwarden/data` | `/mnt/backup/other/vaultwarden/` | **02:45** ежедневно (cron: `backup-vaultwarden-data.sh`); каталог в restic → Yandex | 14 дней |
| **БД Immich (PostgreSQL)** | VM 200, контейнер `database` в `/opt/immich` | `/mnt/backup/databases/vm200-immich/` | **03:15** ежедневно (cron: `backup-vm200-pgdump.sh`) | 14 дней |
| **Векторы 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 дней |
| **Оригиналы фото Immich** | VM 200, `/mnt/data/library/` | `/mnt/backup/photos/library/` | **01:30** ежедневно (cron: `backup-immich-photos.sh`, rsync) | Все копии (без автоудаления) |
| **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 — перезапись |
| **Выгрузка в Yandex (restic)** | `/mnt/backup` целиком | Yandex Object Storage | **04:00** ежедневно (cron: `backup-restic-yandex.sh`) | 3 daily, 2 weekly, 2 monthly |
**Окно бэкапов:** внутренние копии (синк внутри сервера) — **01:0003:30**; выгрузка в облако — **04:00**. **05:00** зарезервировано под плановую перезагрузку сервера. Задание vzdump — из веб-интерфейса Proxmox (Центр обработки данных → Резервная копия). | Что | Откуда | Куда (локально) | Когда | Хранение | Уведомление |
|-----|--------|------------------|------|----------|--------------|
| **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 фото). **04:35** — ping Healthchecks (Dead man's switch). **05:00** зарезервировано под плановую перезагрузку сервера. Задание vzdump — из веб-интерфейса Proxmox (Центр обработки данных → Резервная копия).
--- ---
@@ -56,36 +70,56 @@
**Когда нужно:** потеря или поломка одной/нескольких гостевых систем. **Когда нужно:** потеря или поломка одной/нескольких гостевых систем.
1. В Proxmox: **Центр обработки данных → Резервная копия** (или узел → Резервная копия). **Через веб-интерфейс:**
1. **Центр обработки данных → Резервная копия** (или узел → Резервная копия).
2. Выбрать хранилище **backup** (или то, куда пишет задание). 2. Выбрать хранилище **backup** (или то, куда пишет задание).
3. Найти нужный бэкап по VMID и дате. 3. Найти нужный бэкап по VMID и дате.
4. **Восстановить** → указать новый VMID (если восстанавливаем как копию) или тот же (если заменяем сломанный), узел и storage для дисков. 4. **Восстановить** → указать новый VMID (если восстанавливаем как копию) или тот же (если заменяем сломанный), узел и storage для дисков.
5. Запустить ВМ/контейнер и проверить доступность. 5. Запустить ВМ/контейнер и проверить доступность.
**С CLI (на хосте):** **С CLI (на хосте Proxmox):**
- LXC: `pct restore <vmid> /path/to/backup.vma.zst --storage local-lvm` (и т.п., см. `pct restore --help`). Путь к файлам бэкапа: `/mnt/backup/proxmox/dump/dump/` (имя вида `vzdump-lxc-100-YYYY_MM_DD-HH_MM_SS.tar.zst` или `vzdump-qemu-200-...`).
- VM: `qm restore <vmid> /path/to/backup.vma.zst` (и т.п., см. `qm restore --help`).
Путь к файлу бэкапа на хосте: `/mnt/backup/proxmox/dump/` (имя файла вида `vzdump-lxc-100-...` или `vzdump-qemu-200-...`). - **LXC** — восстановить в новый VMID (например 999) на storage `local-lvm`:
```bash
pct create 999 /mnt/backup/proxmox/dump/dump/vzdump-lxc-107-2026_02_26-02_03_14.tar.zst --restore 1 --storage local-lvm
```
Если восстанавливаем поверх существующего контейнера: сначала удалить его (`pct destroy 107`), затем в команде указать тот же VMID (107). Доп. опции: `pct create --help` (режим restore).
- **VM (KVM)** — порядок аргументов: сначала архив, потом VMID:
```bash
qm restore /mnt/backup/proxmox/dump/dump/vzdump-qemu-200-YYYY_MM_DD-HH_MM_SS.vma.zst 200 --storage local-lvm
```
У VM файлы бэкапа обычно с расширением `.vma.zst` или `.vma`. Подробнее: `qm restore --help`.
**После восстановления (пример для LXC):**
- Если восстановили в новый слот (например 999) и не нужен конфликт IP с оригиналом — сменить IP:
`pct set 999 --net0 name=eth0,bridge=vmbr0,gw=192.168.1.1,ip=192.168.1.199/24,type=veth`
- Запуск: `pct start 999` (LXC) или `qm start 200` (VM).
- Проверка: пинг, консоль (`pct exec 999 -- bash`), при необходимости сервисы и порты внутри контейнера.
Если vzdump есть только в Yandex (локального диска нет) — см. раздел **Восстановление из restic (Yandex)** → «Восстановление одного контейнера (vzdump)».
--- ---
### 2. Восстановление конфигов хоста (/etc/pve и сеть) ### 2. Восстановление конфигов хоста (/etc/pve и сеть)
**Когда нужно:** переустановка Proxmox или потеря конфигов узла (при этом диск с бэкапами доступен). **Когда нужно:** переустановка Proxmox или потеря конфигов узла (при этом диск с бэкапами доступен).
Если конфигов нет локально, но есть в Yandex — см. раздел **Восстановление из restic** → «Восстановление конфигов хоста (/etc/pve)».
1. Скопировать нужный архив с хоста, например: 1. Скопировать нужный архив с хоста, например:
`etc-pve-YYYYMMDD-HHMM.tar.gz` и/или `etc-host-configs-YYYYMMDD-HHMM.tar.gz` из `/mnt/backup/proxmox/etc-pve/`. `etc-pve-YYYYMMDD-HHMM.tar.gz` и/или `etc-host-configs-YYYYMMDD-HHMM.tar.gz` из `/mnt/backup/proxmox/etc-pve/`.
2. **Восстановление /etc/pve** (на переустановленном хосте, от root): 2. **Восстановление /etc/pve** (на переустановленном хосте, от root):
```bash ```bash
tar -xzf etc-pve-YYYYMMDD-HHMM.tar.gz -C / tar -xzf etc-pve-YYYYMMDD-HHMM.tar.gz -C /
``` ```
При одномузловой установке обычно достаточно распаковать в `/`. При кластере — аккуратно с нодами и storage. При одномузловой установке обычно достаточно распаковать в `/`. При кластере — аккуратно с нодами и storage.
3. **Восстановление конфигов сети/хоста** (interfaces, hosts, resolv.conf): 3. **Восстановление конфигов сети/хоста** (interfaces, hosts, resolv.conf):
```bash ```bash
tar -xzf etc-host-configs-YYYYMMDD-HHMM.tar.gz -C / tar -xzf etc-host-configs-YYYYMMDD-HHMM.tar.gz -C /
``` ```
При необходимости поправить под текущее железо (интерфейсы, IP) и перезапустить сеть. При необходимости поправить под текущее железо (интерфейсы, IP) и перезапустить сеть.
После восстановления конфигов — заново добавить storage для бэкапов (если переустанавливали с нуля) и восстанавливать гостей из vzdump по шагу 1. После восстановления конфигов — заново добавить storage для бэкапов (если переустанавливали с нуля) и восстанавливать гостей из vzdump по шагу 1.
@@ -96,17 +130,14 @@
**Когда нужно:** повреждение или потеря базы Immich при рабочей ВМ (образ VM можно не трогать, восстанавливаем только БД). **Когда нужно:** повреждение или потеря базы Immich при рабочей ВМ (образ VM можно не трогать, восстанавливаем только БД).
1. Скопировать нужный дамп на VM 200, например: 1. Скопировать нужный дамп на VM 200, например:
`immich-db-YYYYMMDD-HHMM.sql.gz` из `/mnt/backup/databases/vm200-immich/`. `immich-db-YYYYMMDD-HHMM.sql.gz` из `/mnt/backup/databases/vm200-immich/`.
2. На VM 200 (ssh admin@192.168.1.200): 2. На VM 200 (ssh [admin@192.168.1.200](mailto:admin@192.168.1.200)):
```bash ```bash
cd /opt/immich cd /opt/immich
gunzip -c /path/to/immich-db-YYYYMMDD-HHMM.sql.gz | docker compose exec -T database psql -U <DB_USERNAME> -d <DB_DATABASE_NAME> gunzip -c /path/to/immich-db-YYYYMMDD-HHMM.sql.gz | docker compose exec -T database psql -U <DB_USERNAME> -d <DB_DATABASE_NAME>
``` ```
Или распаковать `.sql.gz`, затем: Или распаковать `.sql.gz`, затем:
```bash
docker compose exec -T database psql -U <DB_USERNAME> -d <DB_DATABASE_NAME> < backup.sql
```
`<DB_USERNAME>` и `<DB_DATABASE_NAME>` — из `/opt/immich/.env` (обычно `postgres` и `immich`). `<DB_USERNAME>` и `<DB_DATABASE_NAME>` — из `/opt/immich/.env` (обычно `postgres` и `immich`).
Перед восстановлением лучше остановить приложение Immich (или как минимум не писать в БД). При полной пересоздании БД — очистить каталог данных PostgreSQL в контейнере и затем загрузить дамп. Перед восстановлением лучше остановить приложение Immich (или как минимум не писать в БД). При полной пересоздании БД — очистить каталог данных PostgreSQL в контейнере и затем загрузить дамп.
@@ -117,6 +148,8 @@
**Когда нужно:** потеря данных на диске VM 200 (например `/mnt/data/library`). **Когда нужно:** потеря данных на диске VM 200 (например `/mnt/data/library`).
**Требование для бэкапа:** на VM 200 должен быть установлен rsync (`sudo apt install rsync`), т.к. скрипт запускает rsync по SSH с хоста.
На VM 200 (или с хоста через rsync в обратную сторону): скопировать содержимое `/mnt/backup/photos/library/` обратно в каталог библиотеки Immich на VM 200 (в .env указан `UPLOAD_LOCATION`, обычно `/mnt/data/library`). Пример с хоста Proxmox: На VM 200 (или с хоста через rsync в обратную сторону): скопировать содержимое `/mnt/backup/photos/library/` обратно в каталог библиотеки Immich на VM 200 (в .env указан `UPLOAD_LOCATION`, обычно `/mnt/data/library`). Пример с хоста Proxmox:
```bash ```bash
@@ -125,6 +158,8 @@ rsync -av /mnt/backup/photos/library/ admin@192.168.1.200:/mnt/data/library/
После копирования на VM 200 выставить владельца/права под контейнер Immich (если нужно) и перезапустить сервисы. После копирования на VM 200 выставить владельца/права под контейнер Immich (если нужно) и перезапустить сервисы.
Если фото есть только в Yandex — см. раздел **Восстановление из restic** → «Восстановление фото (библиотека Immich)».
--- ---
### 5. Восстановление БД Paperless (CT 104), Gitea (CT 103) ### 5. Восстановление БД Paperless (CT 104), Gitea (CT 103)
@@ -133,12 +168,16 @@ rsync -av /mnt/backup/photos/library/ admin@192.168.1.200:/mnt/data/library/
**Gitea:** дамп из `/mnt/backup/databases/ct103-gitea/gitea-db-*.sql.gz`. На CT 103: остановить Gitea, восстановить в контейнер `gitea-db-1` (psql -U gitea -d gitea), запустить стек. **Gitea:** дамп из `/mnt/backup/databases/ct103-gitea/gitea-db-*.sql.gz`. На CT 103: остановить Gitea, восстановить в контейнер `gitea-db-1` (psql -U gitea -d gitea), запустить стек.
Если дампов нет локально — см. раздел **Восстановление из restic** → «Восстановление дампов БД».
--- ---
### 6. Восстановление данных Vaultwarden (CT 103) ### 6. Восстановление данных Vaultwarden (CT 103)
Архив из `/mnt/backup/other/vaultwarden/vaultwarden-data-*.tar.gz`. На CT 103: остановить Vaultwarden, распаковать в `/opt/docker/vaultwarden/` (получится каталог `data/`), выставить владельца/права под контейнер, запустить Vaultwarden. Архив из `/mnt/backup/other/vaultwarden/vaultwarden-data-*.tar.gz`. На CT 103: остановить Vaultwarden, распаковать в `/opt/docker/vaultwarden/` (получится каталог `data/`), выставить владельца/права под контейнер, запустить Vaultwarden.
Если архива нет локально (есть только в Yandex) — см. раздел **Восстановление из restic** → «Восстановление данных Vaultwarden (пароли)».
--- ---
### 7. Восстановление бэкапа VPS Миран (telegram-helper-bot) ### 7. Восстановление бэкапа VPS Миран (telegram-helper-bot)
@@ -146,40 +185,68 @@ rsync -av /mnt/backup/photos/library/ admin@192.168.1.200:/mnt/data/library/
**Когда нужно:** потеря данных на VPS или перенос бота на другой хост. **Когда нужно:** потеря данных на VPS или перенос бота на другой хост.
В бэкапе есть: В бэкапе есть:
- **БД:** `/mnt/backup/vps/miran/db/tg-bot-database-YYYYMMDD.db` — копии SQLite. - **БД:** `/mnt/backup/vps/miran/db/tg-bot-database-YYYYMMDD.db` — копии SQLite.
- **Голосовые сообщения:** `/mnt/backup/vps/miran/voice_users/` — каталог .ogg. - **Голосовые сообщения:** `/mnt/backup/vps/miran/voice_users/` — каталог .ogg.
- **S3 (контент бота):** `/mnt/backup/vps/miran/s3/` — полная копия бакета (photos, videos, voice и т.д.). - **S3 (контент бота):** `/mnt/backup/vps/miran/s3/` — полная копия бакета (photos, videos, voice и т.д.).
**Восстановление на VPS:** **Восстановление на VPS:**
1. Скопировать выбранный файл БД на VPS:
`scp -P 15722 /mnt/backup/vps/miran/db/tg-bot-database-YYYYMMDD.db deploy@185.147.80.190:/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db` 1. Скопировать выбранный файл БД на VPS:
2. Восстановить `voice_users`: `scp -P 15722 /mnt/backup/vps/miran/db/tg-bot-database-YYYYMMDD.db deploy@185.147.80.190:/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db`
`rsync -avz -e "ssh -p 15722" /mnt/backup/vps/miran/voice_users/ deploy@185.147.80.190:/home/prod/bots/telegram-helper-bot/voice_users/` 2. Восстановить `voice_users`:
`rsync -avz -e "ssh -p 15722" /mnt/backup/vps/miran/voice_users/ deploy@185.147.80.190:/home/prod/bots/telegram-helper-bot/voice_users/`
3. При потере данных в S3 — загрузить из бэкапа в бакет Miran (через aws s3 sync или панель), используя endpoint `https://api.s3.miran.ru` и креды из [VPS Миран](vps-miran-bots.md). 3. При потере данных в S3 — загрузить из бэкапа в бакет Miran (через aws s3 sync или панель), используя endpoint `https://api.s3.miran.ru` и креды из [VPS Миран](vps-miran-bots.md).
**Требования для бэкапа:** на хосте Proxmox — SSH-ключ root → deploy@185.147.80.190 (порт 15722); для S3 — установленный `aws` cli и файл `/root/.vps-miran-s3.env` с переменными S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET_NAME (см. [VPS Миран](../vps/vps-miran-bots.md)). **Требования для бэкапа:** на хосте Proxmox — SSH-ключ root → [deploy@185.147.80.190](mailto:deploy@185.147.80.190) (порт 15722); для S3 — установленный `aws` cli и файл `/root/.vps-miran-s3.env` с переменными S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET_NAME (см. [VPS Миран](../vps/vps-miran-bots.md)).
--- ---
### 8. Восстановление векторов RAG (CT 105) ### 8. Восстановление конфигов MTProto (VPS Германия)
**Когда нужно:** потеря конфигов на VPS 185.103.253.99 или перенос MTProto и сайта-заглушки на другой хост.
В архиве `mtproto-config-YYYYMMDD-HHMM.tar.gz` из `/mnt/backup/vps/mtproto-germany/` лежат:
- **mtg:** `etc/systemd/system/mtg.service` (в т.ч. секрет и cloak-port).
- **nginx:** `etc/nginx/sites-available/`, `etc/nginx/sites-enabled/` (конфиг для katykhin.store на порту 993).
- **Let's Encrypt:** `etc/letsencrypt/live/katykhin.store/`, `archive/katykhin.store/`, `renewal/katykhin.store.conf`.
- **Сайт:** `var/www/katykhin.store/`.
**Восстановление на VPS (от root):** скопировать архив на сервер и распаковать в корень:
```bash
scp /mnt/backup/vps/mtproto-germany/mtproto-config-YYYYMMDD-HHMM.tar.gz root@185.103.253.99:/tmp/
ssh root@185.103.253.99 "tar -xzf /tmp/mtproto-config-YYYYMMDD-HHMM.tar.gz -C /"
```
После распаковки: `systemctl daemon-reload && systemctl restart mtg nginx`. На новом хосте дополнительно установить mtg, nginx, certbot и настроить ufw (см. [план MTProto + сайт](../vps/vpn-vps-mtproto-site-plan.md)).
**Требования для бэкапа:** на хосте Proxmox — SSH по ключу root → [root@185.103.253.99](mailto:root@185.103.253.99) (порт 22). Ключ хоста должен быть добавлен в `authorized_keys` на VPS.
---
### 9. Восстановление векторов RAG (CT 105)
Архив из `/mnt/backup/other/ct105-vectors/vectors-*.tar.gz`. Распаковать на хосте и скопировать в контейнер: `tar -xzf vectors-*.tar.gz` → затем `pct push 105 ./vectors /home/rag-service/data/` или распаковать внутри CT 105 в `/home/rag-service/data/` (получится каталог `vectors/` с `vectors.npz`). Архив из `/mnt/backup/other/ct105-vectors/vectors-*.tar.gz`. Распаковать на хосте и скопировать в контейнер: `tar -xzf vectors-*.tar.gz` → затем `pct push 105 ./vectors /home/rag-service/data/` или распаковать внутри CT 105 в `/home/rag-service/data/` (получится каталог `vectors/` с `vectors.npz`).
--- ---
### 9. Восстановление VM 200 (Immich) с нуля ### 10. Восстановление VM 200 (Immich) с нуля
VM 200 **не входит** в задание vzdump (образ ~380 ГБ, не помещается в политику 7 копий). В бэкапе есть: **конфиг ВМ** (в архивах `/etc/pve`), **БД** (pg_dump), **фото** (rsync в `photos/library`). Восстановление — создание новой ВМ с теми же параметрами и перенос данных. VM 200 **не входит** в задание vzdump (образ ~380 ГБ, не помещается в политику 7 копий). В бэкапе есть: **конфиг ВМ** (в архивах `/etc/pve`), **БД** (pg_dump), **фото** (rsync в `photos/library`). Восстановление — создание новой ВМ с теми же параметрами и перенос данных.
**Что есть после восстановления хоста:** **Что есть после восстановления хоста:**
- Из бэкапа `etc-pve`: файл `/etc/pve/qemu-server/200.conf` — полное описание ВМ (CPU, память, диски, **hostpci для GPU**, сеть). Его можно использовать как образец при создании новой ВМ. - Из бэкапа `etc-pve`: файл `/etc/pve/qemu-server/200.conf` — полное описание ВМ (CPU, память, диски, **hostpci для GPU**, сеть). Его можно использовать как образец при создании новой ВМ.
- Дамп БД: `/mnt/backup/databases/vm200-immich/immich-db-*.sql.gz`. - Дамп БД: `/mnt/backup/databases/vm200-immich/immich-db-*.sql.gz`.
- Фото: `/mnt/backup/photos/library/`. - Фото: `/mnt/backup/photos/library/`.
**Ключевые параметры VM 200** (если восстанавливать вручную без конфига): **Ключевые параметры VM 200** (если восстанавливать вручную без конфига):
- **Ресурсы:** 3 ядра, 10 GB RAM. - **Ресурсы:** 3 ядра, 10 GB RAM.
- **GPU:** проброс видеокарты (hostpci) — в Proxmox: Hardware → Add → PCI Device → выбрать VGA/NVIDIA, поставить «All Functions» и «ROM-Bar» при необходимости. В конфиге это выглядит как `hostpci0: 0000:xx:00.0` и т.п. - **GPU:** проброс видеокарты (hostpci) — в Proxmox: Hardware → Add → PCI Device → выбрать VGA/NVIDIA, поставить «All Functions» и «ROM-Bar» при необходимости. В конфиге это выглядит как `hostpci0: 0000:xx:00.0` и т.п.
- **Диски:** первый — системный (~35 GB), второй — данные (~350 GB) под `/mnt/data` (библиотека, PostgreSQL, Docker). - **Диски:** первый — системный (~~35 GB), второй — данные (~~350 GB) под `/mnt/data` (библиотека, PostgreSQL, Docker).
- **Сеть:** статический IP 192.168.1.200/24, шлюз 192.168.1.1. - **Сеть:** статический IP 192.168.1.200/24, шлюз 192.168.1.1.
- **ОС:** Debian 13 (trixie), пользователь **admin**, SSH. - **ОС:** Debian 13 (trixie), пользователь **admin**, SSH.
@@ -190,8 +257,8 @@ VM 200 **не входит** в задание vzdump (образ ~380 ГБ, н
3. **Разметить второй диск** и смонтировать в `/mnt/data` (как в [container-200](../containers/container-200.md)). 3. **Разметить второй диск** и смонтировать в `/mnt/data` (как в [container-200](../containers/container-200.md)).
4. **Установить Docker**, склонировать/восстановить каталоги Immich: `/opt/immich/` (docker-compose.yml, .env — из своих заметок или копии; секреты из Vaultwarden). 4. **Установить Docker**, склонировать/восстановить каталоги Immich: `/opt/immich/` (docker-compose.yml, .env — из своих заметок или копии; секреты из Vaultwarden).
5. **Создать каталоги** `/mnt/data/library`, `/mnt/data/postgres` (и др. по .env). 5. **Создать каталоги** `/mnt/data/library`, `/mnt/data/postgres` (и др. по .env).
6. **Скопировать фото** с хоста бэкапов на ВМ: 6. **Скопировать фото** с хоста бэкапов на ВМ:
`rsync -av /mnt/backup/photos/library/ admin@192.168.1.200:/mnt/data/library/` `rsync -av /mnt/backup/photos/library/ admin@192.168.1.200:/mnt/data/library/`
7. **Запустить только контейнер БД** (database), восстановить дамп (см. раздел 3 выше), затем поднять весь стек Immich. 7. **Запустить только контейнер БД** (database), восстановить дамп (см. раздел 3 выше), затем поднять весь стек Immich.
8. Проверить NPM (прокси на 192.168.1.200:2283), при необходимости заново включить ML и настройки в Immich. 8. Проверить NPM (прокси на 192.168.1.200:2283), при необходимости заново включить ML и настройки в Immich.
@@ -201,31 +268,278 @@ VM 200 **не входит** в задание vzdump (образ ~380 ГБ, н
## Restic и Yandex ## Restic и Yandex
Скрипт **`backup-restic-yandex.sh`** выгружает весь каталог `/mnt/backup` в Yandex Object Storage (S3). Retention: **3 daily, 2 weekly, 2 monthly** (`restic forget --keep-daily 3 --keep-weekly 2 --keep-monthly 2`). Пароли и дампы — чувствительные данные; не выкладывать в открытый доступ. Два задания в одном репозитории: `**backup-restic-yandex.sh`** выгружает `/mnt/backup` **без** каталога `photos`; `**backup-restic-yandex-photos.sh`** выгружает только `/mnt/backup/photos` (отдельный снимок, больше всего данных). Retention у обоих: **3 daily, 2 weekly, 2 monthly**. Пароли и дампы — чувствительные данные; не выкладывать в открытый доступ.
--- ---
## Скрипты на хосте Proxmox ## Восстановление из restic (Yandex)
| Скрипт | Назначение | Cron | **Когда нужно:** локальных бэкапов нет (потеря диска, другой хост), данные есть только в Yandex Object Storage.
|--------|------------|------|
| `/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/.restic-yandex.env`** (RESTIC_REPOSITORY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), `**/root/.restic-password**`.
| `/root/scripts/backup-etc-pve.sh` | Бэкап /etc/pve и конфигов хоста | 15 2 * * * | - Установлены **restic** и **FUSE** (для `restic mount`): `apt install restic fuse`.
| `/root/scripts/backup-ct104-pgdump.sh` | Логический дамп БД Paperless из CT 104 | 30 2 * * * | - Восстановление делаем **на раздел с достаточным местом** (например `/mnt/backup/restore-...`), не в `/tmp`.
| `/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/backup-vm200-pgdump.sh` | Логический дамп БД Immich с VM 200 | 15 3 * * * |
| `/root/scripts/backup-ct105-vectors.sh` | Копирование векторов RAG (vectors.npz) из CT 105 | 30 3 * * * | В репозитории два вида снимков (различаются по полю **Paths** в `restic snapshots`):
| `/root/scripts/backup-restic-yandex.sh` | Выгрузка /mnt/backup в Yandex S3 (restic), retention 3/2/2 | 0 4 * * * |
| Paths в снимке | Откуда | Что внутри |
| -------------------- | ------------------------------ | ------------------------------------------------------------------------- |
| `/mnt/backup` | backup-restic-yandex.sh | Всё кроме photos: proxmox/dump, proxmox/etc-pve, databases/, other/, vps/ |
| `/mnt/backup/photos` | backup-restic-yandex-photos.sh | Только каталог photos (библиотека Immich) |
При восстановлении **конфигов, паролей, дампов БД, vzdump** — брать снимок с path **/mnt/backup**. При восстановлении **фото** — брать снимок с path **/mnt/backup/photos**.
```bash
# Список снимков (указать нужный по Paths и дате)
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
```
### Сводка: что откуда восстанавливать
| Что восстановить | Путь в снимке (основной репо) | Снимок | Способ |
| -------------------- | --------------------------------- | ---------------------- | ------------------------------- |
| Один LXC/VM (vzdump) | /mnt/backup/proxmox/dump/dump/... | /mnt/backup | Скрипт mount + cp (см. ниже) |
| Конфиги /etc/pve | /mnt/backup/proxmox/etc-pve/ | /mnt/backup | restic restore --path ... |
| Vaultwarden (пароли) | /mnt/backup/other/vaultwarden/ | /mnt/backup | restic restore --path ... |
| Дампы БД | /mnt/backup/databases/... | /mnt/backup | restic restore --path ... |
| VPS, other | /mnt/backup/vps/, other/ | /mnt/backup | restic restore --path ... |
| Фото Immich | /mnt/backup/photos/ | **/mnt/backup/photos** | restic restore из снимка photos |
---
### Восстановление одного контейнера (vzdump) из restic
Чтобы не выкачивать весь репо, используется **mount** и копирование одного файла.
1. Узнать имя нужного архива в снимке (например CT 107):
```bash
restic ls latest --path /mnt/backup/proxmox/dump/dump | grep vzdump-lxc-107
```
Использовать снимок с path `/mnt/backup` (не photos).
2. Запустить скрипт (он сам монтирует, копирует файл, размонтирует):
```bash
/root/scripts/restore-one-vzdump-from-restic.sh latest /mnt/backup/proxmox/dump/dump/vzdump-lxc-107-YYYY_MM_DD-HH_MM_SS.tar.zst /mnt/backup
```
Файл появится в `/mnt/backup/vzdump-lxc-107-....tar.zst`.
3. Восстановить контейнер из файла (как в разделе 1 выше):
```bash
pct create 999 /mnt/backup/vzdump-lxc-107-....tar.zst --restore 1 --storage local-lvm
pct set 999 --net0 name=eth0,bridge=vmbr0,gw=192.168.1.1,ip=192.168.1.199/24,type=veth
pct start 999
```
Если скрипта нет — вручную: `restic mount /mnt/backup/restic-mount &`, подождать, скопировать из `.../restic-mount/ids/<SNAPSHOT_ID>/mnt/backup/proxmox/dump/dump/vzdump-lxc-107-....tar.zst` в нужное место, затем `fusermount -u /mnt/backup/restic-mount`.
---
### Восстановление конфигов хоста (/etc/pve) из restic
1. Выбрать снимок с path **/mnt/backup** (по дате): `restic snapshots`.
2. Восстановить только каталог etc-pve:
```bash
restic restore SNAPSHOT_ID --target /mnt/backup/restore-etc-pve --path /mnt/backup/proxmox/etc-pve
```
Файлы появятся в `/mnt/backup/restore-etc-pve/mnt/backup/proxmox/etc-pve/` (архивы `etc-pve-*.tar.gz`, `etc-host-configs-*.tar.gz`).
3. Распаковать нужный архив в корень (как в разделе 2 выше):
```bash
tar -xzf /mnt/backup/restore-etc-pve/mnt/backup/proxmox/etc-pve/etc-pve-YYYYMMDD-HHMM.tar.gz -C /
tar -xzf /mnt/backup/restore-etc-pve/mnt/backup/proxmox/etc-pve/etc-host-configs-YYYYMMDD-HHMM.tar.gz -C /
```
4. При необходимости поправить сеть и перезапустить сервисы.
---
### Восстановление данных Vaultwarden (пароли) из restic
1. Снимок с path **/mnt/backup**.
2. Восстановить каталог vaultwarden:
```bash
restic restore SNAPSHOT_ID --target /mnt/backup/restore-vw --path /mnt/backup/other/vaultwarden
```
Результат: `/mnt/backup/restore-vw/mnt/backup/other/vaultwarden/vaultwarden-data-*.tar.gz`.
3. Скопировать архив на хост, откуда можно отправить на CT 103, либо распаковать во временный каталог и скопировать каталог `data/` на CT 103 в `/opt/docker/vaultwarden/` (остановив Vaultwarden). Детали — раздел 6 выше.
---
### Восстановление фото (библиотека Immich) из restic
Фото лежат в **отдельном снимке** (path `/mnt/backup/photos`). Сначала выбрать этот снимок:
```bash
restic snapshots | grep photos
```
Затем восстановить в каталог с достаточным местом:
```bash
restic restore SNAPSHOT_ID --target /mnt/backup/restore-photos --path /mnt/backup/photos
```
Фото окажутся в `/mnt/backup/restore-photos/mnt/backup/photos/library/`. Дальше — скопировать на VM 200 (раздел 4 выше):
`rsync -av /mnt/backup/restore-photos/mnt/backup/photos/library/ admin@192.168.1.200:/mnt/data/library/`
---
### Восстановление дампов БД из restic
Снимок с path **/mnt/backup**. Восстановить нужный подкаталог, например только ct104-paperless:
```bash
restic restore SNAPSHOT_ID --target /mnt/backup/restore-db --path /mnt/backup/databases/ct104-paperless
```
Файлы появятся в `/mnt/backup/restore-db/mnt/backup/databases/ct104-paperless/`. Дальше — восстановление БД по разделам 3 или 5 выше (скопировать дамп на контейнер и загрузить в PostgreSQL).
Аналогично для других БД: `--path /mnt/backup/databases/ct101-nextcloud`, `ct103-gitea`, `vm200-immich`.
---
### Восстановление прочего (VPS, векторы RAG) из restic
- **VPS Миран / MTProto:**
`restic restore SNAPSHOT_ID --target /mnt/backup/restore-vps --path /mnt/backup/vps`
Дальше — копировать нужные файлы на VPS по разделам 78.
- **Векторы RAG (ct105-vectors):**
`restic restore SNAPSHOT_ID --target /mnt/backup/restore-other --path /mnt/backup/other/ct105-vectors`
Дальше — по разделу 9.
---
## Скрипты и 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>`.
| Скрипт | 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** оставлено свободным для плановой перезагрузки сервера. Задание vzdump (LXC/VM) настраивается в Proxmox UI (расписание 02:00). **05:00** оставлено свободным для плановой перезагрузки сервера.
### Диагностика пустых дампов БД и архива Vaultwarden
Если дампы БД (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-secrets.md](../vaultwarden-secrets.md)).
**Проверка вручную (без подавления stderr):** зайти в контейнер и выполнить дамп, чтобы увидеть сообщение об ошибке:
```bash
# CT 101 (Nextcloud)
pct exec 101 -- docker exec nextcloud-db-1 pg_dump -U nextcloud nextcloud | head -5
# CT 104 (Paperless)
pct exec 104 -- docker exec paperless-db-1 pg_dump -U paperless paperless | head -5
# CT 103 (Gitea)
pct exec 103 -- docker exec gitea-db-1 pg_dump -U gitea gitea | head -5
# CT 103 (Vaultwarden) — каталог data
pct exec 103 -- ls -la /opt/docker/vaultwarden/data
pct exec 103 -- tar cf - -C /opt/docker/vaultwarden data | wc -c
```
**Запуск из 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.
### Почему размер дампа меньше размера БД на диске
`pg_database_size()` показывает **размер БД на диске**: данные таблиц + **индексы** + TOAST (сжатые длинные значения) + свободное место и bloat. **pg_dump** выводит только логические данные в виде SQL: самих данных индексов в дампе нет (только команды `CREATE INDEX`), поэтому несжатый дамп часто **меньше** размера БД. После gzip сжатие даёт ещё примерно 2,54×. Итог: БД 2GB на диске → несжатый дамп 200600MB → сжатый 50200 MB нормален (особенно для Nextcloud с большими индексами по `oc_filecache`).
Если сомневаетесь, проверьте несжатый размер и число таблиц:
```bash
# Несжатый размер дампа (на хосте)
gunzip -c /mnt/backup/databases/ct101-nextcloud/nextcloud-db-YYYYMMDD-HHMM.sql.gz | wc -c
# Таблиц в дампе
gunzip -c /mnt/backup/databases/ct101-nextcloud/nextcloud-db-YYYYMMDD-HHMM.sql.gz | grep -c '^CREATE TABLE '
# Таблиц в живой БД (в контейнере)
pct exec 101 -- docker exec nextcloud-db-1 psql -U nextcloud -d nextcloud -t -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';"
```
Числа таблиц должны совпадать. Несжатый размер для Nextcloud 2GB на диске обычно 200600 MB. При необходимости запустите бэкап с проверкой: `VERIFY_BACKUP=1 /root/scripts/backup-ct101-pgdump.sh` — скрипт выведет несжатый размер и число таблиц в дампе.
---
## Уведомления в Telegram
После **успешного** выполнения каждого бэкапа в Telegram отправляется короткое сообщение (заголовок с эмодзи + краткая сводка). Уведомления приходят по завершении соответствующего скрипта; для локального vzdump — таймер `notify-vzdump-success` в **03:00** (проверка файлов за последние 2 часа).
| Заголовок | Когда | Тело сообщения |
|-----------|------|----------------|
| 🖥️ VPS Миран | после 01:00 | Резервное копирование завершено. БД, voice_users, S3 (telegram-helper-bot). Размер копии: X. |
| 🗄️ Nextcloud (БД) | после 01:15 | Резервное копирование завершено. Дамп БД Nextcloud. Размер: X. |
| 📷 Фото Immich (rsync) | после 01:30 | Резервное копирование завершено. Библиотека фото синхронизирована. Размер: X. |
| 🌐 VPS MTProto (DE) | после 01:45 | Резервное копирование завершено. Конфиги MTProto и сайт (VPS DE). Размер архива: X. |
| 💾 Backup local | 03:00 | Резервное копирование завершено. Локальный vzdump (LXC/VM). Контейнеров/ВМ: N, объём: X ГБ. Время завершения: HH:MM. |
| ⚙️ Конфиги хоста | после 02:15 | Резервное копирование завершено. Архивы /etc/pve и конфигов сети. Размер: X. |
| 🗄️ Paperless (БД) | после 02:30 | Резервное копирование завершено. Дамп БД Paperless. Размер: X. |
| 🔐 Vaultwarden | после 02:45 | Резервное копирование завершено. Данные Vaultwarden. Размер архива: X. |
| 🗄️ Gitea (БД) | после 03:00 | Резервное копирование завершено. Дамп БД Gitea. Размер: X. |
| 🗄️ Immich (БД) | после 03:15 | Резервное копирование завершено. Дамп БД Immich. Размер: X. |
| 📐 Векторы RAG | после 03:30 | Резервное копирование завершено. Архив векторов RAG. Размер: X. |
| ☁️ Restic Yandex | после 04:00 | Резервное копирование завершено. Снимок в Yandex: N файлов, размер X. |
| 📷 Restic Yandex (photos) | после 04:10 | Резервное копирование завершено. Снимок фото в Yandex: N файлов, размер X. |
**Размер в сообщениях** — фактический размер файла (apparent size), а не занятое место на диске. Если видите **4.0K** или **8.0K** у дампа БД или архива Vaultwarden — копия может быть пустой или почти пустой (ошибка доступа к контейнеру, пустая БД, неверный путь). Скрипты при размере ниже порога (10KB для дампов БД и Vaultwarden, 1KB для MTProto) добавляют в сообщение строку: *«⚠️ Подозрительно малый размер — проверьте…»*. В этом случае проверьте на хосте: имя контейнера БД, путь к данным, логи скрипта.
**Единая точка отправки (шлюз):** скрипт **`/root/scripts/notify-telegram.sh`**. Все источники уведомлений вызывают только его и не обращаются к Telegram API напрямую. Токен и chat_id хранятся в одном конфиге на хосте Proxmox.
**Конфиг на хосте:** `/root/.telegram-notify.env` с переменными `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`. В репозитории лежит пример: **`scripts/telegram-notify.env.example`** — скопируйте его на хост в `/root/.telegram-notify.env` и подставьте свои значения:
```bash
cp /path/to/scripts/telegram-notify.env.example /root/.telegram-notify.env
chmod 600 /root/.telegram-notify.env
```
**Как получить креды:**
1. **Токен бота:** в Telegram написать [@BotFather](https://t.me/BotFather), команда `/newbot`, следовать подсказкам — получите токен вида `123456789:ABCdef...`.
2. **Chat ID:** отправить боту любое сообщение, затем в браузере открыть
`https://api.telegram.org/bot<TOKEN>/getUpdates`
В ответе в `updates[].message.chat.id` — ваш chat_id (число; для групп — отрицательное).
Если конфига или кредов нет, шлюз тихо выходит с 0 и не ломает вызывающие скрипты.
--- ---
## Связанные документы ## Связанные документы
- [Стратегия бэкапов (фаза 1)](proxmox-phase1-backup.md) — общий план и принятые решения. - [Vaultwarden и секреты](../vaultwarden-secrets.md) — получение паролей через `bw` для скриптов бэкапов.
- [Архитектура](../architecture/architecture.md) — хост, IP, доступ. - [Архитектура](../architecture/architecture.md) — хост, IP, доступ.
- [VM 200 (Immich)](../containers/container-200.md) — сервисы, пути, .env. - [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,260 +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 и обновлять при смене паролей.
Это не «шаг бэкапа», но обязательная часть восстановления: без паролей восстановленные контейнеры не войдут в сервисы.
---
### Шаг 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).*
- [ ] Проверен ручной запуск Backup now — файлы появляются в storage. *(рекомендуется проверить разово.)*
- [x] Настроен бэкап `/etc/pve` (скрипт + cron) → `/mnt/backup/proxmox/etc-pve`. *(backup-etc-pve.sh, 03:00, 30 дней.)*
- [ ] Restic: cron на хосте, выгрузка нужных каталогов из `/mnt/backup` в Yandex S3, retention 7/4/6.
- [ ] Yandex: ключи и endpoint зафиксированы, restic успешно пишет в бакет.
- [x] Vaultwarden развёрнут (CT 103).
- [ ] Секреты перенесены в Vaultwarden. *(на усмотрение: root PVE, пароли БД, API и т.д.)*
- [ ] Бэкап данных Vaultwarden включён в restic (Yandex S3). *Локально данные уже копируются в `/mnt/backup/other/vaultwarden/` (backup-vaultwarden-data.sh); при настройке restic — включить этот каталог в источники.*
- [ ] Выполнено тестовое восстановление одного контейнера (другой VMID), проверена работоспособность.
- [x] В документации зафиксирована процедура полного восстановления Proxmox «с нуля». *[backup-howto.md](backup-howto.md): восстановление из vzdump, конфигов, БД, VM 200 с нуля, Vaultwarden, VPS и др.*
---
## Ссылки
- [Архитектура и подключение](../architecture/architecture.md) — хосты, IP, домены.
- [Схема сети и зависимости](../network/network-topology.md) — SPOF, зависимость от Proxmox и бэкапов.
- Документация контейнеров (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). |
---
## Осталось сделать
- **Yandex + Restic:** подготовить Static Key (Access Key + Secret), записать endpoint и имя бакета; настроить restic на хосте (cron): выгрузка `/mnt/backup` в Yandex S3, retention 3 daily / 2 weekly / 2 monthly. Скрипт: `scripts/backup-restic-yandex.sh`.
- **Проверка:** один раз запустить Backup now в Proxmox UI и убедиться, что файлы появляются в storage.
- **Тестовое восстановление:** восстановить один контейнер (например 105 или 107) под другим VMID, проверить доступ и сервисы, затем удалить тестовый.
- **Секреты:** при необходимости перенести пароли/ключи (root PVE, БД, API) в Vaultwarden и обновлять при смене.

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`. - **Debian (CT 100):** логин `root`. Пароль — в Vaultwarden (объект **CT_100_ROOT_PASSWORD**).
- **AdGuard Home (через домен):** https://adguard.katykhin.ru (через NPM, сертификат Let's Encrypt), пользователь `kerrad`, пароль `waccEk-fyqbux-rarja3`. Прямой доступ по порту 3000 больше не используется. - **AdGuard Home (через домен):** https://adguard.katykhin.ru (через NPM, сертификат Let's Encrypt). Пользователь и пароль — в Vaultwarden (объект **ADGUARD**). Прямой доступ по порту 3000 больше не используется.
- **Nginx Proxy Manager:** http://192.168.1.100:81, имя `Kerrad`, email `j3tears100@gmail.com`, пароль `kqEUubVq02DJTS8`. - **Nginx Proxy Manager:** http://192.168.1.100:81. Имя, email и пароль — в Vaultwarden (объект **NPM_ADMIN**).
--- ---
@@ -56,8 +56,10 @@
**Certbot на хосте (внутри CT 100):** **Certbot на хосте (внутри CT 100):**
- Установлен в системе, таймер `certbot.timer` (проверка продления дважды в день). - Установлен в системе, таймер `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 и т.д.) копируют `fullchain.pem` и `privkey.pem` в соответствующий каталог `custom_ssl/npm-<id>/` и делают `docker exec npm nginx -s reload`. - 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/...`.
Подробнее по SSL: [Выпуск сертификата Let's Encrypt (DNS-01)](../network/ssl-letsencrypt-dns01.md). Подробнее по SSL: [Выпуск сертификата Let's Encrypt (DNS-01)](../network/ssl-letsencrypt-dns01.md).
@@ -179,14 +181,23 @@ docker restart wallos
Проверяет, идут ли запросы к заданным доменам через VPN или через основное подключение (подключение к роутеру по telnet, разбор маршрутов). Результаты отдаёт на порту **8765** (на хосте). В Homepage добавлена ссылка на http://192.168.1.100:8765. Проверяет, идут ли запросы к заданным доменам через 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` (в контейнере). **Том:** volume `vpn-route-check-data` → `/data` (в контейнере).
**Команды:** **Команды:**
```bash ```bash
cd /opt/docker/vpn-route-check && docker compose up -d # Деплой (с хоста Proxmox)
docker logs vpn-route-check /root/scripts/deploy-vpn-route-check.sh
# Логи
pct exec 100 -- docker logs vpn-route-check
``` ```
--- ---
@@ -222,7 +233,7 @@ docker logs vpn-route-check
1. Создать сеть (если ещё нет): `docker network create proxy_network`. 1. Создать сеть (если ещё нет): `docker network create proxy_network`.
2. NPM: `cd /opt/docker/nginx-proxy && docker compose up -d`. 2. NPM: `cd /opt/docker/nginx-proxy && docker compose up -d`.
3. AdGuard: `cd /opt/docker/adguard && docker compose up -d` (создаёт свою сеть и подключается к proxy_network). 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. 5. Log-dashboard: при необходимости запустить контейнер с монтом html и портом 8088.
После изменений в NPM (proxy, SSL): перезагрузка nginx внутри контейнера — `docker exec npm nginx -s reload`. Certbot продлевает сертификаты по таймеру; deploy-hookи копируют их в NPM и перезагружают nginx. После изменений в NPM (proxy, SSL): перезагрузка nginx внутри контейнера — `docker exec npm nginx -s reload`. Certbot продлевает сертификаты по таймеру; deploy-hookи копируют их в NPM и перезагружают nginx.
@@ -232,7 +243,7 @@ docker logs vpn-route-check
## Уязвимости и риски ## Уязвимости и риски
1. **Пароли и креды в конфигах:** В `services.yaml` (Homepage) хранятся пароли виджетов (AdGuard, NPM, Proxmox). Файл лежит только на сервере; не помещать в публичный репозиторий. 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). 3. **AdGuard на 3000:** Веб-интерфейс доступен по порту 3000 на хосте. Доступ из LAN; при необходимости закрыть фаерволом снаружи или использовать только через NPM (proxy).
4. **NPM на 81:** Админка NPM по порту 81. Убедиться, что с интернета доступ только через VPN или не пробрасывать 81 наружу. 4. **NPM на 81:** Админка NPM по порту 81. Убедиться, что с интернета доступ только через VPN или не пробрасывать 81 наружу.
5. **Логи NPM:** Часть логов (fallback_*) не ротируется — возможен рост и заполнение диска (см. TODO). 5. **Логи NPM:** Часть логов (fallback_*) не ротируется — возможен рост и заполнение диска (см. TODO).
@@ -243,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] **Логи 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 дней). - [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. - [ ] **Log-dashboard:** Зафиксировать способ запуска контейнера (отдельный compose или скрипт) и добавить его в документацию/автозапуск при перезагрузке CT.
- [ ] **Мониторинг диска:** Настроить оповещение (например, из Prometheus/Alertmanager или скрипт по крону) при заполнении корня или `/opt/docker` выше порога (например 80%). - [ ] **Мониторинг диска:** Настроить оповещение (например, из Prometheus/Alertmanager или скрипт по крону) при заполнении корня или `/opt/docker` выше порога (например 80%).
- [ ] **Резервное копирование:** Регулярный бэкап критичных папок (оценка размеров на момент документации): - [ ] **Резервное копирование:** Регулярный бэкап критичных папок (оценка размеров на момент документации):

View File

@@ -16,6 +16,18 @@
--- ---
## Как подключиться к серверу (CT 103)
- **С хоста Proxmox (192.168.1.150):**
`pct exec 103 -- bash` — попадаете в shell контейнера 103 под root.
- **По SSH (если настроен доступ на 103):**
`ssh root@192.168.1.103`с машины, с которой настроен ключ/пароль на 103.
- **Логин в Debian:** `root`, пароль — из менеджера паролей или как задавали при установке.
После входа в CT 103 все команды (Docker, логи и т.д.) выполняются уже внутри контейнера.
---
## Доступ и логины ## Доступ и логины
- **Debian (CT 103):** логин `root` (пароль — в менеджере паролей или как настраивал при установке). - **Debian (CT 103):** логин `root` (пароль — в менеджере паролей или как настраивал при установке).
@@ -162,7 +174,7 @@ docker compose up -d
**Доступ по домену (опционально):** если нужен **https://vault.katykhin.ru** и из LAN, и по VPN, в NPM (контейнер 100) настраивают: **Доступ по домену (опционально):** если нужен **https://vault.katykhin.ru** и из LAN, и по VPN, в NPM (контейнер 100) настраивают:
- **Proxy Host:** `vault.katykhin.ru` → upstream `192.168.1.103:8280`, включить SSL (Let's Encrypt или custom). - **Proxy Host:** `vault.katykhin.ru` → upstream `192.168.1.103:8280`, включить SSL (Let's Encrypt или custom).
- **Access List:** создать список, разрешающий только подсети **192.168.1.0/24** (LAN) и **10.10.99.0/24** (WireGuard VPN); для всех остальных — отказ. Эту access list привязать к proxy host `vault.katykhin.ru`. Тогда с интернета без VPN доступ к домену будет закрыт; из дома и по VPN — открыт. - **Access List:** создать список, разрешающий только подсети **192.168.1.0/24** (LAN) и **10.10.99.0/24** (WireGuard VPN); для всех остальных — отказ. Эту access list привязать к proxy host `vault.katykhin.ru`. Тогда с интернета без VPN доступ к домену будет закрыт; из дома и по VPN — открыт.
- В compose Vaultwarden при использовании домена задать `DOMAIN=https://vault.katykhin.ru` и перезапустить контейнер. - В compose Vaultwarden при использовании домена **обязательно** задать `DOMAIN=https://vault.katykhin.ru` и перезапустить контейнер. Если оставить `DOMAIN=http://192.168.1.103:8280`, веб-вход по https://vault.katykhin.ru может не работать (запрос prelogin падает, в DevTools — «Provisional headers»).
**Тома:** **Тома:**
- `/opt/docker/vaultwarden/data``/data` (все данные Vaultwarden: база, вложения, и т.п.). - `/opt/docker/vaultwarden/data``/data` (все данные Vaultwarden: база, вложения, и т.п.).
@@ -190,6 +202,8 @@ curl -s http://127.0.0.1:8280/ | head -c 200
После этого интерфейс открывается по **`http://192.168.1.103:8280`** из домашней сети. Клиенты Bitwarden (ПК, телефон в LAN) настраивают на этот URL — сервис уже открыт в локальной сети без NPM. Если позже добавить домен в NPM (см. выше), в клиентах можно перейти на `https://vault.katykhin.ru`. После этого интерфейс открывается по **`http://192.168.1.103:8280`** из домашней сети. Клиенты Bitwarden (ПК, телефон в LAN) настраивают на этот URL — сервис уже открыт в локальной сети без NPM. Если позже добавить домен в NPM (см. выше), в клиентах можно перейти на `https://vault.katykhin.ru`.
**Если веб-вход по https://vault.katykhin.ru не работает (prelogin ошибка, «Provisional headers» в DevTools):** проверьте, что в compose задано `DOMAIN=https://vault.katykhin.ru`. На CT 103: `grep DOMAIN /opt/docker/vaultwarden/docker-compose.yml`. Если там `DOMAIN=http://192.168.1.103:8280`, замените на `DOMAIN=https://vault.katykhin.ru`, затем `cd /opt/docker/vaultwarden && docker compose up -d` и повторите вход.
--- ---
## Порты (сводка на хосте) ## Порты (сводка на хосте)
@@ -271,3 +285,4 @@ curl -s http://127.0.0.1:8280/ | head -c 200
- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домены (в т.ч. obsidian.katykhin.ru, home.katykhin.ru, wallos.katykhin.ru), схема сети. - [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домены (в т.ч. obsidian.katykhin.ru, home.katykhin.ru, wallos.katykhin.ru), схема сети.
- [Контейнер 100 (nginx)](container-100.md) — NPM и AdGuard; через NPM проксируются git.katykhin.ru, obsidian.katykhin.ru, vault.katykhin.ru, home.katykhin.ru и wallos.katykhin.ru. - [Контейнер 100 (nginx)](container-100.md) — NPM и AdGuard; через NPM проксируются git.katykhin.ru, obsidian.katykhin.ru, vault.katykhin.ru, home.katykhin.ru и wallos.katykhin.ru.
- [Vaultwarden и использование секретов](../vaultwarden-secrets.md) — как получать пароли и поля из Vaultwarden через CLI (bw) в скриптах.

View File

@@ -43,20 +43,22 @@
**Порты:** 3000 (хост) → 3000 (контейнер). NPM (контейнер 100) проксирует https://video.katykhin.ru → 192.168.1.107:3000. **Порты:** 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 контейнера (см. раздел «Логи и ротация»). - Отдельных каталогов с логами Invidious на хосте нет — логи идут в stdout контейнера (см. раздел «Логи и ротация»).
**Основная конфигурация (в docker-compose.yml, секция `environment / INVIDIOUS_CONFIG`):** **Секреты:** `POSTGRES_USER`, `POSTGRES_PASSWORD`, `INVIDIOUS_COMPANION_KEY`, `HMAC_KEY` берутся из Vaultwarden (объект **INVIDIOUS**). Деплой с хоста Proxmox:
- `db`: dbname=invidious, user=kemal, password=kemal, host=invidious-db, port=5432, check_tables=true. ```bash
- `invidious_companion`: URL сервиса companion (`http://companion:8282/companion`). /root/scripts/deploy-invidious-credentials.sh
- `invidious_companion_key` и `SERVER_SECRET_KEY` (в companion) — общий секрет между Invidious и Companion (сейчас заданы прямо в compose; **не выкладывать в публичный репозиторий**). ```
- `external_port: 443`, `domain: "video.katykhin.ru"`, `https_only: true` — Invidious знает про внешний домен и порт, отдаёт ссылки на https. Скрипт генерирует `.env` из Vaultwarden, атомарно пушит в CT 107, запускает `docker compose up -d --force-recreate`. **Ротация:** сменил пароль/ключи в Vaultwarden → запустил скрипт.
- Прочие опции (feeds, captions, hmac_key, default_user_preferences и т.д.).
**Команды:** **Команды:**
```bash ```bash
cd /opt/invidious && docker compose up -d # Деплой (с хоста Proxmox)
docker logs invidious-invidious-1 /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 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). - 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. - Контейнер запущен с `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/config/sql``/config/sql` — SQLскрипты инициализации/миграций из репозитория Invidious (~40 KB).
- `/opt/invidious/docker/init-invidious-db.sh``/docker-entrypoint-initdb.d/init-invidious-db.sh` — скрипт инициализации БД при первом запуске. - `/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 ```bash
@@ -124,16 +126,10 @@ Companion и PostgreSQL доступны только внутри docker-сет
## Запуск и порядок поднятия ## Запуск и порядок поднятия
1. Зайти в каталог: `cd /opt/invidious`. 1. С хоста Proxmox: `/root/scripts/deploy-invidious-credentials.sh` (генерирует `.env` из Vaultwarden, пушит в CT 107, запускает compose).
2. Проверить/при необходимости подредактировать `docker-compose.yml` (секция `INVIDIOUS_CONFIG`, домен video.katykhin.ru, секреты). 2. Порядок: `invidious-db``invidious` (depends_on с healthcheck), параллельно Companion.
3. Запуск/перезапуск:
```bash
docker compose up -d
```
Порядок: сначала поднимается `invidious-db`, затем `invidious` (depends_on с healthcheck), параллельно Companion.
После изменения конфигурации (секция `INVIDIOUS_CONFIG` или окружения Companion/DB): После изменения секретов в Vaultwarden: запустить `deploy-invidious-credentials.sh` снова.
`cd /opt/invidious && docker compose up -d` — конфигурация применяется при перезапуске контейнеров.
--- ---

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/` (операторы и участники). - **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 ...`. - **Доступ:** SSH под пользователем **admin** (не root): `ssh admin@192.168.1.200` с хоста 192.168.1.150 или из LAN. Для выполнения команд с правами root: `sudo ...`.
**Диски:** **Диски:**
- **Корневой диск** (sda1): 35 GB, занято **~29 GB (87%)** — система, образы/кэш в пределах корня. **Критично:** мало свободного места; при росте логов или обновлениях возможны сбои. Следить за местом и логированием (см. TODO). - **Корневой диск** (sda1): 50 GB — система, образы/кэш в пределах корня. Логи Docker ограничены (см. ниже).
- **Данные** (sdb1): 344 GB, смонтирован в **/mnt/data**, занято ~177 GB (55%). Здесь: библиотека Immich, БД PostgreSQL, Docker root, containerd, Ollama, данные deduper. - **Данные** (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). - **Базовая политика (как в 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 нет. - **Системный 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), хранить бэкапы в защищённом месте. 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). 2. **Корневой диск:** Расширен до 50 GB; логи Docker ограничены (10m × 3 файла на контейнер). Следить за местом при обновлениях.
3. **Логи Docker без лимитов:** Ротация не настроена — возможен рост логов и заполнение диска. 3. **Доступ по портам:** Сервисы доступны по 2283, 2284, 8001, 8501, 8086 из LAN. Снаружи доступ только через NPM (https://immich.katykhin.ru → 2283). Не пробрасывать порты напрямую в интернет.
4. **Доступ по портам:** Сервисы доступны по 2283, 2284, 8001, 8501, 8086 из LAN. Снаружи доступ только через NPM (https://immich.katykhin.ru → 2283). Не пробрасывать порты напрямую в интернет. 4. **GPU и PCI-passthrough:** ВМ использует hostpci0 (VGA). Убедиться, что драйверы NVIDIA и доступ к GPU корректны для immich_machine_learning.
5. **GPU и PCI-passthrough:** ВМ использует hostpci0 (VGA). Убедиться, что драйверы NVIDIA и доступ к GPU корректны для immich_machine_learning.
--- ---
## TODO по ВМ 200 ## TODO по ВМ 200
- [x] **Базовая политика logrotate:** для системных логов настроена (homelab-lxc.conf — 14 дней, 50 MB, 5 архивов, как в LXC). См. [Logrotate — базовая политика homelab](../maintenance/logrotate/README.md). - [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%. - [x] **Корневой диск:** Расширен до 50 GB (было 35 GB). Логи Docker ограничены.
- [ ] **Логи 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] **Логи Docker:** В `/etc/docker/daemon.json` заданы `log-driver: json-file`, `max-size: "10m"`, `max-file: "3"`. Логи в /mnt/data/docker/containers.
- [ ] **Права на конфиги:** Ограничить доступ к .env (chmod 600), не коммитить в публичные репозитории. - [ ] **Права на конфиги:** Ограничить доступ к .env (chmod 600), не коммитить в публичные репозитории.
- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации): - [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации):
- **`/mnt/data/library`** — библиотека Immich (фото, видео, превью). ~148 GB. Основной объём; бэкап обязателен (внешний диск, сетевое хранилище). - **`/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 Миран (СПБ) │ │ VPS DE │ │ VPS US │ │ VPS Миран (СПБ) │
│ 185.103.253.99 │ │ 147.45.124.117 │ │ 185.147.80.190 │ │ 185.103.253.99 │ │ 147.45.124.117 │ │ 185.147.80.190 │
│ AmneziaWG │ │ AmneziaWG │ │ coTURN (Galene), │ │ 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 | | **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 DE** | 185.103.253.99 | AmneziaWG (обход блокировок) | Туннель с роутера (10.8.1.x) |
| **VPS US** | 147.45.124.117 | AmneziaWG (второй выход) | Туннель с роутера | | **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 | | **DNS** | Beget.com | Домен katykhin.ru, поддомены, API для DNS-01 | Все *.katykhin.ru |
--- ---
@@ -241,6 +241,7 @@ flowchart TB
## Связь с другими документами ## Связь с другими документами
- [Архитектура и подключение](../architecture/architecture.md) — общее описание, таблица контейнеров, поток запросов. - [Архитектура и подключение](../architecture/architecture.md) — общее описание, таблица контейнеров, поток запросов.
- [Хост Proxmox](../containers/host-proxmox.md) — скрипты, таймеры, пути на 192.168.1.150.
- [Контейнер 100](../containers/container-100.md) — NPM, AdGuard, Homepage, порядок запуска. - [Контейнер 100](../containers/container-100.md) — NPM, AdGuard, Homepage, порядок запуска.
- [Контейнер 109](../containers/container-109.md) — WireGuard VPN (local-vpn), доступ к vault и LAN. - [Контейнер 109](../containers/container-109.md) — WireGuard VPN (local-vpn), доступ к vault и LAN.
- [Генерация .mobileconfig для WireGuard (On-Demand)](vpn-mobileconfig-wireguard.md) — как собрать профиль для iOS/macOS с автоматическим подключением вне дома. - [Генерация .mobileconfig для WireGuard (On-Demand)](vpn-mobileconfig-wireguard.md) — как собрать профиль для iOS/macOS с автоматическим подключением вне дома.

View File

@@ -41,6 +41,12 @@
chmod 600 /root/.secrets/certbot/beget.ini 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. **Запрос сертификата:** 3. **Запрос сертификата:**
```bash ```bash
certbot certonly \ certbot certonly \

287
docs/vaultwarden-secrets.md Normal file
View File

@@ -0,0 +1,287 @@
# Vaultwarden и использование секретов
Краткое руководство по **Vaultwarden** в homelab и по тому, как получать секреты из него в скриптах и при восстановлении.
---
## Что такое Vaultwarden
**Vaultwarden** — это self-hosted реализация API Bitwarden: менеджер паролей, совместимый с официальными клиентами Bitwarden (десктоп, мобильные приложения, браузерные расширения). Данные хранятся на вашем сервере, а не в облаке Bitwarden.
В нашей схеме Vaultwarden развёрнут на **контейнере 103** (Gitea). Доступ:
- **Веб:** https://vault.katykhin.ru (из LAN и по VPN; из интернета без VPN закрыт).
- **По IP в LAN:** http://192.168.1.103:8280.
Подробнее про установку, порты и NPM — в [Контейнер 103 (Gitea, Vaultwarden)](containers/container-103.md#5-vaultwarden-менеджер-паролей).
**Зачем хранить секреты в Vaultwarden:**
- Один источник правды для паролей хоста, БД, API-ключей и т.д.
- При восстановлении после сбоя не нужно искать креды по разным файлам.
- Скрипты бэкапов и уведомлений могут брать секреты через Bitwarden CLI без хранения паролей в репозитории.
---
## Доступ к секретам: веб и CLI
- **Веб-интерфейс** — для ручного просмотра и редактирования записей (логины, пароли, кастомные поля). Вход по email и мастер-паролю.
- **Bitwarden CLI (`bw`)** — для скриптов и командной строки: разблокировка хранилища, получение логина/пароля/полей по имени записи.
Далее в статье речь идёт в основном о **CLI**.
---
## Установка и настройка Bitwarden CLI (bw)
На машине, с которой нужно получать секреты (например, хост Proxmox), должны быть установлены **`bw`** и **`jq`**.
### Установка bw (Linux, вручную)
1. Скачать архив с [релизов Bitwarden CLI](https://github.com/bitwarden/cli/releases) (например `bw-linux-1.22.1.zip` для x86_64).
2. Распаковать и положить бинарник в PATH, например:
```bash
unzip bw-linux-*.zip
install -m 755 bw /usr/local/bin/bw
```
3. Установить `jq` (для разбора кастомных полей):
```bash
apt install jq # Debian/Proxmox
```
### Настройка сервера и первый вход
1. Указать URL вашего Vaultwarden:
```bash
bw config server https://vault.katykhin.ru
```
2. Войти (интерактивно, один раз):
```bash
bw login
```
Ввести email и мастер-пароль от vault.katykhin.ru. Данные сессии сохранятся локально.
3. Проверить:
```bash
bw status
bw sync
```
После ввода мастер-пароля (если хранилище было locked) синхронизация подтянет актуальные данные с сервера.
### Разблокировка для скриптов (файл с мастер-паролем)
В cron или в скриптах пароль вводить вручную нельзя. Используют **файл с мастер-паролем** с строгими правами:
```bash
echo -n 'ВАШ_МАСТЕРАРОЛЬ' > /root/.bw-master
chmod 600 /root/.bw-master
```
- Файл **не коммитить** в репозиторий и не копировать в открытые места.
- Владелец — пользователь, под которым запускаются скрипты (например `root`); только он должен иметь доступ к файлу.
Проверка разблокировки без интерактивного ввода:
```bash
bw unlock "$(cat /root/.bw-master)" --raw
```
Команда должна вернуть длинную строку (session key). Эту строку скрипты передают в `BW_SESSION` (см. ниже).
---
## Как получать секреты из Vaultwarden
### Состояние и синхронизация
- **`bw status`** — показать URL сервера, последнюю синхронизацию, email пользователя и состояние: `unlocked` / `locked`.
- **`bw sync`** — обновить локальный кэш с сервера (при необходимости перед чтением актуальных данных).
Если хранилище **locked**, перед любыми `bw get ...` нужно разблокировать:
```bash
export BW_SESSION=$(bw unlock --passwordfile /root/.bw-master --raw)
```
Дальше в этой же сессии (пока переменная `BW_SESSION` экспортирована) можно вызывать `bw get ...`.
### Логин и пароль записи
Для записей типа «логин» (Login) в Vaultwarden:
- **Логин (username):**
`bw get username "ИМЯ_ЗАПИСИ"`
- **Пароль:**
`bw get password "ИМЯ_ЗАПИСИ"`
Примеры: `bw get password "GITEA"`, `bw get username "PAPERLESS"`. Имя записи — то, как она называется в веб-интерфейсе (чувствительно к регистру).
### Кастомные поля (custom fields)
В Bitwarden/Vaultwarden у записи могут быть **кастомные поля** (например `RESTIC_REPOSITORY`, `TELEGRAM_SELF_CHAT_ID`). Они не выводятся через `bw get username/password`, их достают через **`bw get item`** и **`jq`**:
```bash
bw get item "ИМЯ_ЗАПИСИ" | jq -r '.fields[] | select(.name=="ИМЯ_ПОЛЯ") | .value'
```
Примеры:
- Поле `RESTIC_BACKUP_KEY` из записи **RESTIC:**
`bw get item "RESTIC" | jq -r '.fields[] | select(.name=="RESTIC_BACKUP_KEY") | .value'`
- Поле `TELEGRAM_SELF_CHAT_ID` из **RESTIC:**
`bw get item "RESTIC" | jq -r '.fields[] | select(.name=="TELEGRAM_SELF_CHAT_ID") | .value'`
Полный JSON записи (все поля):
`bw get item "ИМЯ_ЗАПИСИ" | jq '.'`
---
## Использование в скриптах
### Общий подход
1. В начале скрипта (если ещё не разблокировано) прочитать мастер-пароль из файла и разблокировать:
```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
```
2. При необходимости выполнить `bw sync`.
3. Получить нужные значения через `bw get username`, `bw get password`, `bw get item ... | jq ...` и присвоить переменным окружения или использовать в командах.
Переменная **`BW_SESSION`** передаётся в дочерние процессы, поэтому все вызовы `bw` в том же процессе и в дочерних скриптах будут видеть разблокированное хранилище.
### Пример: Restic (репозиторий и ключ из Vaultwarden)
```bash
# Разблокировать (мастер-пароль из файла с chmod 600)
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
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
# Дальше: restic backup ... и т.д.
```
Пароль restic в файле — временный; `trap` удаляет его по выходу из скрипта.
### Пример: Telegram (токен и chat_id из Vaultwarden)
Токен бота хранится в записи **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')
# Дальше: 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.
---
## Безопасность
- **Файл с мастер-паролем:** только владелец (например root), права `chmod 600`. Не хранить в git и не копировать на общие ресурсы.
- **Переменная BW_SESSION:** не логировать и не выводить в скриптах; не передавать в ненадёжные процессы.
- **Временные файлы с паролями** (как RESTIC_PASSWORD_FILE выше): создавать с `chmod 600`, удалять по завершении (`trap ... EXIT`).
- **Бэкап данных Vaultwarden:** каталог `/opt/docker/vaultwarden/data` на CT 103 входит в план бэкапов (restic → Yandex), см. [backup-howto](backup/backup-howto.md). Без этого при потере сервера теряется и хранилище паролей.
---
## Инвентаризация записей и полей
В 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`).
**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.
- [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

@@ -92,4 +92,12 @@ ss -ulnp | grep 33118
Параметры обфускации на обоих серверах (Германия и США) **одинаковые** — конфиг можно полностью перенести на новый сервер при переезде. На роутере создаётся второе VPN-подключение; переключение между Germany и USA — выбор нужного профиля. Параметры обфускации на обоих серверах (Германия и США) **одинаковые** — конфиг можно полностью перенести на новый сервер при переезде. На роутере создаётся второе VPN-подключение; переключение между Germany и USA — выбор нужного профиля.
**Подробно:** [Перенос конфигурации AmneziaWG между серверами](vpn-migrate-config.md). **Подробно:** [Перенос конфигурации AmneziaWG между серверами](vpn-migrate-config.md).
---
## MTProto и сайт-заглушка (план)
На этом же VPS можно развернуть MTProto proxy с маскировкой под обычный HTTPS-сайт (один порт 443): при заходе на домен — статический сайт, при подключении Telegram с секретом — прокси. Домен: katykhin.store. VPN (AmneziaWG) при этом остаётся на порту 33118/UDP без изменений.
**План развёртывания:** [MTProto + сайт на VPS Германия (план)](vpn-vps-mtproto-site-plan.md).

View File

@@ -0,0 +1,112 @@
# Отчёт проверки MTProto + маскировка (katykhin.store)
Проверки выполнены **с внешней консоли** (без SSH на сервер) 2026-02-26. Цель: убедиться, что на 443 выглядит как обычный HTTPS, а MTProto срабатывает только при наличии секрета.
---
## 1) HTTPS сайта
**Команда:** `curl -I https://katykhin.store/`
**Результат:**
- **HTTP/1.1 200 OK**
- Server: nginx/1.24.0 (Ubuntu)
- Content-Type: text/html
**Вывод:** Сайт отдаётся по **HTTPS**, не по HTTP. Маскировка по TLS выполнена.
---
## 2) Порт 443 и TLS
### Порт 443 открыт
**Команда:** `nc -vz katykhin.store 443`
**Результат:** `Connection to katykhin.store port 443 [tcp/https] succeeded!`
### TLS-рукопожатие и сертификат
**Команда:** `openssl s_client -connect katykhin.store:443 -servername katykhin.store`
**Результат:**
- **Verification: OK**
- **Сертификат:** Let's Encrypt (E8), CN=katykhin.store
- **Срок:** NotBefore 26 Feb 2026, NotAfter 27 May 2026
- **Протокол:** TLSv1.3, Cipher TLS_AES_256_GCM_SHA384
- **SAN:** host "katykhin.store" в сертификате
**Вывод:** Внешний вид — обычный HTTPS с валидным доверенным сертификатом, без самоподписи.
---
## 3) Однострочная проверка
**Команда:** `curl -Iv https://katykhin.store/`
**Результат:**
- TLS handshake успешен
- Сертификат проверен (SSL certificate verify ok)
- **HTTP/1.1 200 OK**
- subject: CN=katykhin.store, issuer: Let's Encrypt
**Вывод:** Важнейшая часть маскировки выполнена: **katykhin.store:443 = обычный HTTPS**.
---
## 4) Поведение при «не-TLS» подключении к 443
### Сырой TCP без данных
**Команда:** подключение к 443 без отправки данных (nc без ввода).
**Результат:** Ответа нет (сервер ждёт данные). mtg перенаправил соединение на nginx:993; nginx ждёт TLS ClientHello — логично.
### Plain HTTP на порт 443
**Команда:** отправка `GET / HTTP/1.0` на 443 (без TLS).
**Результат:** Ответ от nginx:
- **HTTP/1.1 400 Bad Request**
- «The plain HTTP request was sent to HTTPS port»
**Вывод:** На 443 приходит трафик без MTProto-секрета → mtg отдаёт его nginx (cloak). Nginx на 993 принимает только TLS, поэтому на plain HTTP отвечает 400. Для DPI/активной проверки, которые делают **нормальный TLS** (как браузер или `openssl s_client`), будет обычный HTTPS и страница сайта. Plain HTTP на 443 даёт только 400 от nginx, а не MTProto.
---
## 5) Итоговая таблица поведения
| Подключение | Ожидание по плану | Факт |
|--------------------------|--------------------------|-------------------------------|
| Браузер / curl HTTPS | Сайт-заглушка, 200 | ✅ HTTP/1.1 200, nginx, TLS |
| TLS (openssl s_client) | Валидный HTTPS | ✅ Let's Encrypt, TLS 1.3 |
| Telegram с MTProto | Работа прокси | ✅ Подтверждено пользователем |
| Plain HTTP на 443 | Не MTProto | ✅ 400 от nginx (HTTPS port) |
| Соединение без данных | Нет «сырого» HTTP-ответа| ✅ Нет ответа (ожидание TLS) |
---
## 6) Что не проверялось из консоли (нужен сервер или устройство)
- **Логи nginx** (access.log / error.log) — нужен SSH.
- **Логи mtg** (принятые соединения с секретом) — нужен SSH.
- **Проверка из РФ** (iPhone/устройство в целевой сети, без общего VPN, с MTProto) — нужно выполнить у тебя.
- **Симуляция активного probing** уже по сути сделана: `openssl s_client` к 443 ведёт себя как обычный HTTPS-клиент и получает нормальный TLS и сайт.
---
## 7) Частые ошибки — статус
| Риск | Статус |
|-----------------------------|--------|
| Сайт только по HTTP | ❌ Нет: HTTPS отвечает 200 |
| Самоподписанный сертификат | ❌ Нет: Let's Encrypt |
| MTProto на нестандартном порту | ❌ Нет: используется 443 |
| Не-MTProto видит прокси | ❌ Нет: без секрета — nginx/сайт или 400 |
---
## Краткий вывод
**Сеть видит katykhin.store:443 как обычный HTTPS** (валидный сертификат, TLS 1.3, ответ 200 на GET /).
Только клиент с правильным MTProto-секретом попадает на прокси; остальное идёт в nginx (сайт или 400 на plain HTTP). Маскировка настроена корректно.

View File

@@ -0,0 +1,172 @@
# План: MTProto + сайт-заглушка на VPS Германия (один порт 443)
Развёртывание MTProto proxy с TLS-camouflage и статическим сайтом на одном порту 443 на VPS в Германии (185.103.253.99). VPN (AmneziaWG) остаётся на текущем кастомном порту без изменений.
---
## Цель
- **Один порт 443:** при заходе по HTTPS на домен — отдаётся обычный сайт; при подключении Telegram с секретом — трафик идёт в MTProto. Для DPI/проверок сервер выглядит как обычный веб-сайт.
- **Домен:** katykhin.store (A-запись на 185.103.253.99).
- **VPN:** без изменений, порт 33118/UDP (AmneziaWG), текущие конфиги клиентов и роутера не трогаем.
---
## Архитектура
```
Клиент (браузер) → 443 → mtg → (нет секрета) → nginx:993 → статический сайт
Клиент (Telegram + proxy) → 443 → mtg → (есть секрет) → MTProto → Telegram DC
```
- **mtg** слушает 0.0.0.0:443. По первым байтам определяет: если передан корректный dd-secret — обрабатывает как MTProto; иначе перенаправляет соединение на **cloak-port** (nginx на 993).
- **Nginx** слушает 127.0.0.1:993 (или 0.0.0.0:993) с TLS, отдаёт статический сайт по Let's Encrypt для katykhin.store.
- **AmneziaWG** — как сейчас, порт 33118/UDP, конфигурация не меняется.
Выбор **mtg** (а не nginx stream + официальный MTProxy): один компонент на 443, встроенная логика «секрет → MTProto, иначе → cloak», меньше точек отказа и проще отладка. Избегаем связки обфускация + MTProxy на одном сервере.
---
## Как это работает
### Потоки трафика
- **Обычный HTTPS (браузер / curl):**
- Клиент устанавливает TLSсоединение на `katykhin.store:443`.
- `mtg` принимает соединение, не видит корректного MTProtoсекрета и прокидывает его на nginx по `localhost:993` (cloakпорт).
- Nginx завершает TLS, отдаёт статический сайт (`/var/www/katykhin.store`), сертификат — Let's Encrypt для `katykhin.store`.
- **Telegram с MTProtoproxy (с правильным секретом):**
- Клиент шлёт fakeTLS трафик с секретом (`ee…`).
- `mtg` распознаёт секрет и работает как MTProtoпрокси: устанавливает соединение с Telegram DC и пересылает трафик через себя.
- Для внешнего наблюдателя подключение всё равно идёт на `katykhin.store:443` по TLS.
- **Неверный/отсутствующий секрет, “чужой” клиент или plain HTTP на 443:**
- Соединение уходит в nginx на 993.
- TLSклиенты (обычный `openssl s_client`, браузер) получают нормальный сертификат и ответ 200.
- Plain HTTP на 443 получает от nginx стандартный `400 The plain HTTP request was sent to HTTPS port`.
### Роль отдельных компонентов
- **mtg (fake TLS / obfuscated secret):**
- Слушает `0.0.0.0:443`.
- Секрет сгенерирован как `mtg generate-secret -c katykhin.store tls` (префикс `ee…`, fake TLS).
- Если секрет корректный — MTProto; если нет — прокидка на nginx (cloakпорт).
- **nginx:**
- Слушает `993/tcp` с TLS, работает только как backend для mtg.
- Для любого валидного TLSклиента выглядит как обычный сайт с Lets Encryptсертификатом.
- **AmneziaWG:**
- Продолжает слушать `33118/udp` на VPS, используется для VPNтуннеля и никак не завязан на MTProto/HTTPS.
---
## Маскировка и безопасность
- **TLSмаскировка:**
- Сертификат: Lets Encrypt, CN=`katykhin.store`, TLS 1.3.
- При проверке через `curl -Iv` и `openssl s_client` сервер ведёт себя как одиночный нормальный HTTPSхост.
- Никаких самоподписанных сертификатов или нестандартных портов.
- **Fake TLS / obfuscated secret:**
- Используется `mtg` с режимом fake TLS, секрет вида `ee…`.
- Без секрета MTProto не “торчит наружу”: active probing с обычным TLS видит только сайт или стандартную ошибку HTTPSпорта.
- **Firewall (ufw):**
- Входящие разрешены только:
- `22/tcp` — SSH.
- `80/tcp` — HTTP (для выдачи/продления сертификатов).
- `443/tcp` — MTProto+HTTPS (mtg).
- `33118/udp` — AmneziaWG.
- `993/tcp` не открыт наружу — им пользуется только mtg локально.
- **Rate limiting и fail2ban:**
- В nginx включён лимит запросов и соединений на сервере `katykhin.store` (cloakпорт 993):
- `limit_req_zone` ~ 10 r/s на IP, `burst=20`.
- `limit_conn` ~ 10 одновременных соединений на IP.
- `fail2ban`:
- jail `sshd` — защита от перебора паролей по SSH.
- jail `nginx-limit-req` настроен, но в текущей схеме почти не срабатывает (см. примечание ниже).
**Важно:** запросы к nginx на 993 приходят от mtg (`127.0.0.1`), поэтому в логах nginx реальные клиентские IP не видны, и `nginx-limit-req` практически не пригоден для блокировки внешних адресов. Основная практическая защита здесь — rate limiting самого nginx и jail `sshd` в `fail2ban`.
---
## Использование
### MTProtoproxy
- **Параметры прокси:**
- Сервер: `katykhin.store` (или напрямую IP `185.103.253.99`).
- Порт: `443`.
- Секрет: `eecf027cbd3a05d5ff99a18796a51958516b6174796b68696e2e73746f7265`.
- **Готовые ссылки:**
- Через t.me:
`https://t.me/proxy?server=katykhin.store&port=443&secret=eecf027cbd3a05d5ff99a18796a51958516b6174796b68696e2e73746f7265`
- Через tg:// по IP:
`tg://proxy?server=185.103.253.99&port=443&secret=eecf027cbd3a05d5ff99a18796a51958516b6174796b68696e2e73746f7265`
- **Ожидаемое поведение:**
- При включении прокси Telegram устанавливает соединение на `katykhin.store:443`.
- Секрет распознаётся mtg, трафик уходит в Telegram DC.
- Снаружи это выглядит как обычный HTTPSтрафик к сайту.
### VPN (AmneziaWG)
- Конфигурация VPN не менялась:
- Сервер: `185.103.253.99`.
- Порт: `33118/udp`.
- Все существующие клиенты/роутер продолжают использовать старые конфиги.
MTProto и VPN живут независимо: MTProto занимает 443/tcp, AmneziaWG — 33118/udp.
---
## Мониторинг и отладка (на уже развёрнутой схеме)
- **mtg:**
- Текущий статус сервиса: `systemctl status mtg`.
- Живые логи: `journalctl -u mtg -f`.
- **nginx:**
- Доступы/ошибки по сайту:
`tail -f /var/log/nginx/access.log`
`tail -f /var/log/nginx/error.log`
- **fail2ban:**
- Общий статус: `fail2ban-client status`.
- jail `sshd`: `fail2ban-client status sshd`.
- Разбан IP: `fail2ban-client set sshd unbanip <IP>`.
---
---
## Итоговая схема портов
| Порт | Служба | Доступ |
|-------------|-------------|---------------|
| 22/tcp | SSH | интернет |
| 80/tcp | HTTP | для certbot |
| 443/tcp | mtg | интернет |
| 993/tcp | nginx (SSL) | только localhost (для mtg cloak) |
| 33118/udp | AmneziaWG | интернет |
---
## Ссылки и ссылка для Telegram
- Документация mtg: https://github.com/9seconds/mtg
- Руководство с cloak: https://v2how.github.io/post/2021-02-18-camouflage-telegram-mtproto-proxy-ubuntu-20-04/
- Ссылка для подключения к прокси (развёрнуто 2026-02-26):
`https://t.me/proxy?server=katykhin.store&port=443&secret=eecf027cbd3a05d5ff99a18796a51958516b6174796b68696e2e73746f7265`
Альтернативно по IP: `tg://proxy?server=185.103.253.99&port=443&secret=eecf027cbd3a05d5ff99a18796a51958516b6174796b68696e2e73746f7265`
---
## Связь с другими документами
- Текущий VPN и доступ к VPS: [VPN-сервер (VPS, AmneziaWG)](vpn-vps-amneziawg.md).
- После выполнения плана имеет смысл добавить в `vpn-vps-amneziawg.md` краткий раздел «MTProto и сайт» со ссылкой на этот документ и указанием домена/порта.

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

View File

@@ -3,6 +3,8 @@
# Запускать на хосте Proxmox под root. Использует pct exec (SSH не нужен). # Запускать на хосте Proxmox под root. Использует pct exec (SSH не нужен).
# Результат: /mnt/backup/databases/ct101-nextcloud/nextcloud-db-YYYYMMDD-HHMM.sql.gz # Результат: /mnt/backup/databases/ct101-nextcloud/nextcloud-db-YYYYMMDD-HHMM.sql.gz
set -e set -e
# Чтобы из cron находились bw и jq (часто в /usr/local/bin)
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
CT_ID=101 CT_ID=101
BACKUP_DIR="/mnt/backup/databases/ct101-nextcloud" BACKUP_DIR="/mnt/backup/databases/ct101-nextcloud"
@@ -16,18 +18,56 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1 exit 1
fi fi
# Минимальный размер дампа (байт). Пустой gzip ≈ 20 байт — значит pg_dump не отдал данные.
MIN_BACKUP_BYTES=512
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d-%H%M) DATE=$(date +%Y%m%d-%H%M)
OUTPUT="$BACKUP_DIR/nextcloud-db-$DATE.sql.gz" OUTPUT="$BACKUP_DIR/nextcloud-db-$DATE.sql.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
pct exec $CT_ID -- docker exec "$PG_CONTAINER" pg_dump -U nextcloud nextcloud 2>/dev/null | gzip > "$OUTPUT" # 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
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || true
if [ -n "${BW_SESSION:-}" ]; then
PGPASS=$(bw get item "NEXTCLOUD" 2>/dev/null | jq -r '.fields[] | select(.name=="dbpassword") | .value')
[ -z "$PGPASS" ] && PGPASS=$(bw get password "NEXTCLOUD" 2>/dev/null)
[ -n "$PGPASS" ] && PG_ENV_ARGS="-e PGPASSWORD=$PGPASS"
fi
fi
pct exec $CT_ID -- docker exec $PG_ENV_ARGS "$PG_CONTAINER" pg_dump -U nextcloud nextcloud 2>"$ERR" | gzip > "$OUTPUT"
if [ -s "$OUTPUT" ]; then SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
echo "Создан: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" if [ -s "$OUTPUT" ] && [ "$SIZE_BYTES" -ge "$MIN_BACKUP_BYTES" ]; then
echo "Создан: $OUTPUT ($(du --apparent-size -h "$OUTPUT" | cut -f1))"
# Проверка: несжатый размер дампа (2GB БД на диске → 200600MB SQL нормально: индексы не в дампе, потом gzip)
if [ "${VERIFY_BACKUP:-0}" = "1" ]; then
UNCOMPRESSED=$(gunzip -c "$OUTPUT" 2>/dev/null | wc -c)
UNCOMPRESSED_MB=$(( UNCOMPRESSED / 1024 / 1024 ))
echo "Несжатый размер дампа: ${UNCOMPRESSED_MB} MB (${UNCOMPRESSED} B)"
TABLES_IN_DUMP=$(gunzip -c "$OUTPUT" 2>/dev/null | grep -c '^CREATE TABLE ' || true)
echo "Таблиц в дампе: $TABLES_IN_DUMP"
fi
else else
echo "Ошибка: дамп пустой или контейнер недоступен." echo "Ошибка: дамп пустой или слишком мал (${SIZE_BYTES} байт). Проверьте контейнер $PG_CONTAINER и пароль БД в Vaultwarden (NEXTCLOUD)."
[ -s "$ERR" ] && cat "$ERR" >&2
rm -f "$OUTPUT" rm -f "$OUTPUT"
exit 1 exit 1
fi fi
find "$BACKUP_DIR" -name 'nextcloud-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_DIR" -name 'nextcloud-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du --apparent-size -h "$OUTPUT" | cut -f1)
SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
BODY="Резервное копирование завершено.
Объекты: дамп БД Nextcloud (PostgreSQL).
Размер копии: ${SIZE}."
[ "$SIZE_BYTES" -lt 10240 ] 2>/dev/null && BODY="${BODY}
⚠️ Подозрительно малый размер — проверьте контейнер nextcloud-db-1 и наличие данных в БД."
"$NOTIFY_SCRIPT" "🗄️ Nextcloud (БД)" "$BODY" || true
fi

View File

@@ -3,6 +3,7 @@
# Запускать на хосте Proxmox под root. Использует pct exec. # Запускать на хосте Proxmox под root. Использует pct exec.
# Результат: /mnt/backup/databases/ct103-gitea/gitea-db-YYYYMMDD-HHMM.sql.gz # Результат: /mnt/backup/databases/ct103-gitea/gitea-db-YYYYMMDD-HHMM.sql.gz
set -e set -e
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
CT_ID=103 CT_ID=103
BACKUP_DIR="/mnt/backup/databases/ct103-gitea" BACKUP_DIR="/mnt/backup/databases/ct103-gitea"
@@ -14,18 +15,47 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1 exit 1
fi fi
# Минимальный размер дампа (байт). Пустой gzip ≈ 20 байт — значит pg_dump не отдал данные.
MIN_BACKUP_BYTES=512
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d-%H%M) DATE=$(date +%Y%m%d-%H%M)
OUTPUT="$BACKUP_DIR/gitea-db-$DATE.sql.gz" OUTPUT="$BACKUP_DIR/gitea-db-$DATE.sql.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
pct exec $CT_ID -- docker exec "$PG_CONTAINER" pg_dump -U gitea gitea 2>/dev/null | gzip > "$OUTPUT" # 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
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || true
if [ -n "${BW_SESSION:-}" ]; then
PGPASS=$(bw get password "GITEA" 2>/dev/null)
[ -n "$PGPASS" ] && PG_ENV_ARGS="-e PGPASSWORD=$PGPASS"
fi
fi
pct exec $CT_ID -- docker exec $PG_ENV_ARGS "$PG_CONTAINER" pg_dump -U gitea gitea 2>"$ERR" | gzip > "$OUTPUT"
if [ -s "$OUTPUT" ]; then SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
echo "Создан: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" if [ -s "$OUTPUT" ] && [ "$SIZE_BYTES" -ge "$MIN_BACKUP_BYTES" ]; then
echo "Создан: $OUTPUT ($(du --apparent-size -h "$OUTPUT" | cut -f1))"
else else
echo "Ошибка: дамп пустой или контейнер недоступен." echo "Ошибка: дамп пустой или слишком мал (${SIZE_BYTES} байт). Проверьте контейнер $PG_CONTAINER и пароль БД в Vaultwarden (GITEA)."
[ -s "$ERR" ] && cat "$ERR" >&2
rm -f "$OUTPUT" rm -f "$OUTPUT"
exit 1 exit 1
fi fi
find "$BACKUP_DIR" -name 'gitea-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_DIR" -name 'gitea-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du --apparent-size -h "$OUTPUT" | cut -f1)
SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
BODY="Резервное копирование завершено.
Объекты: дамп БД Gitea (PostgreSQL).
Размер копии: ${SIZE}."
[ "$SIZE_BYTES" -lt 10240 ] 2>/dev/null && BODY="${BODY}
⚠️ Подозрительно малый размер — проверьте контейнер gitea-db-1 и наличие данных в БД."
"$NOTIFY_SCRIPT" "🗄️ Gitea (БД)" "$BODY" || true
fi

View File

@@ -3,6 +3,7 @@
# Запускать на хосте Proxmox под root. Использует pct exec. # Запускать на хосте Proxmox под root. Использует pct exec.
# Результат: /mnt/backup/databases/ct104-paperless/paperless-db-YYYYMMDD-HHMM.sql.gz # Результат: /mnt/backup/databases/ct104-paperless/paperless-db-YYYYMMDD-HHMM.sql.gz
set -e set -e
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
CT_ID=104 CT_ID=104
BACKUP_DIR="/mnt/backup/databases/ct104-paperless" BACKUP_DIR="/mnt/backup/databases/ct104-paperless"
@@ -14,18 +15,47 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1 exit 1
fi fi
# Минимальный размер дампа (байт). Пустой gzip ≈ 20 байт — значит pg_dump не отдал данные.
MIN_BACKUP_BYTES=512
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d-%H%M) DATE=$(date +%Y%m%d-%H%M)
OUTPUT="$BACKUP_DIR/paperless-db-$DATE.sql.gz" OUTPUT="$BACKUP_DIR/paperless-db-$DATE.sql.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
pct exec $CT_ID -- docker exec "$PG_CONTAINER" pg_dump -U paperless paperless 2>/dev/null | gzip > "$OUTPUT" # 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
export BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || true
if [ -n "${BW_SESSION:-}" ]; then
PGPASS=$(bw get password "PAPERLESS" 2>/dev/null)
[ -n "$PGPASS" ] && PG_ENV_ARGS="-e PGPASSWORD=$PGPASS"
fi
fi
pct exec $CT_ID -- docker exec $PG_ENV_ARGS "$PG_CONTAINER" pg_dump -U paperless paperless 2>"$ERR" | gzip > "$OUTPUT"
if [ -s "$OUTPUT" ]; then SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
echo "Создан: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" if [ -s "$OUTPUT" ] && [ "$SIZE_BYTES" -ge "$MIN_BACKUP_BYTES" ]; then
echo "Создан: $OUTPUT ($(du --apparent-size -h "$OUTPUT" | cut -f1))"
else else
echo "Ошибка: дамп пустой или контейнер недоступен." echo "Ошибка: дамп пустой или слишком мал (${SIZE_BYTES} байт). Проверьте контейнер $PG_CONTAINER и пароль БД в Vaultwarden (PAPERLESS)."
[ -s "$ERR" ] && cat "$ERR" >&2
rm -f "$OUTPUT" rm -f "$OUTPUT"
exit 1 exit 1
fi fi
find "$BACKUP_DIR" -name 'paperless-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_DIR" -name 'paperless-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du --apparent-size -h "$OUTPUT" | cut -f1)
SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
BODY="Резервное копирование завершено.
Объекты: дамп БД Paperless (PostgreSQL).
Размер копии: ${SIZE}."
[ "$SIZE_BYTES" -lt 10240 ] 2>/dev/null && BODY="${BODY}
⚠️ Подозрительно малый размер — проверьте контейнер paperless-db-1 и наличие данных в БД."
"$NOTIFY_SCRIPT" "🗄️ Paperless (БД)" "$BODY" || true
fi

View File

@@ -3,12 +3,15 @@
# Запускать на хосте Proxmox под root. Использует pct exec. # Запускать на хосте Proxmox под root. Использует pct exec.
# Результат: /mnt/backup/other/ct105-vectors/vectors-YYYYMMDD-HHMM.tar.gz # Результат: /mnt/backup/other/ct105-vectors/vectors-YYYYMMDD-HHMM.tar.gz
set -e set -e
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
CT_ID=105 CT_ID=105
REMOTE_PATH="/home/rag-service/data/vectors"
BACKUP_DIR="/mnt/backup/other/ct105-vectors" BACKUP_DIR="/mnt/backup/other/ct105-vectors"
RETENTION_DAYS=14 RETENTION_DAYS=14
# Минимальный размер архива (байт). Пустой gzip ≈ 20 байт — каталог пуст или путь неверный.
MIN_BACKUP_BYTES=512
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root." echo "Запускайте под root."
exit 1 exit 1
@@ -17,15 +20,31 @@ fi
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d-%H%M) DATE=$(date +%Y%m%d-%H%M)
OUTPUT="$BACKUP_DIR/vectors-$DATE.tar.gz" OUTPUT="$BACKUP_DIR/vectors-$DATE.tar.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
pct exec $CT_ID -- tar cf - -C /home/rag-service/data vectors 2>/dev/null | gzip > "$OUTPUT" pct exec $CT_ID -- tar cf - -C /home/rag-service/data vectors 2>"$ERR" | gzip > "$OUTPUT"
if [ -s "$OUTPUT" ]; then SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
echo "Создан: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" if [ -s "$OUTPUT" ] && [ "$SIZE_BYTES" -ge "$MIN_BACKUP_BYTES" ]; then
echo "Создан: $OUTPUT ($(du --apparent-size -h "$OUTPUT" | cut -f1))"
else else
echo "Ошибка: архив пустой или каталог недоступен." echo "Ошибка: архив пустой или слишком мал (${SIZE_BYTES} байт). Проверьте /home/rag-service/data/vectors в CT $CT_ID."
[ -s "$ERR" ] && cat "$ERR" >&2
rm -f "$OUTPUT" rm -f "$OUTPUT"
exit 1 exit 1
fi fi
find "$BACKUP_DIR" -name 'vectors-*.tar.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_DIR" -name 'vectors-*.tar.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du --apparent-size -h "$OUTPUT" | cut -f1)
SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
BODY="Резервное копирование завершено.
Объекты: архив векторов RAG (CT 105, vectors.npz).
Размер копии: ${SIZE}."
[ "$SIZE_BYTES" -lt 10240 ] 2>/dev/null && BODY="${BODY}
⚠️ Подозрительно малый размер — проверьте /home/rag-service/data/vectors в CT 105."
"$NOTIFY_SCRIPT" "📐 Векторы RAG" "$BODY" || true
fi

View File

@@ -21,3 +21,12 @@ chmod 600 "$BACKUP_ROOT"/etc-pve-*.tar.gz "$BACKUP_ROOT"/etc-host-configs-*.tar.
find "$BACKUP_ROOT" -name 'etc-pve-*.tar.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_ROOT" -name 'etc-pve-*.tar.gz' -mtime +$RETENTION_DAYS -delete
find "$BACKUP_ROOT" -name 'etc-host-configs-*.tar.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_ROOT" -name 'etc-host-configs-*.tar.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du -sh "$BACKUP_ROOT" 2>/dev/null | cut -f1) || true
BODY="Резервное копирование завершено.
Объекты: архивы /etc/pve, конфиги сети (interfaces, hosts, resolv.conf).
Размер копии: ${SIZE:-}."
"$NOTIFY_SCRIPT" "⚙️ Конфиги хоста" "$BODY" || true
fi

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Копирование библиотеки фото Immich (оригиналы) с VM 200 на диск бэкапов хоста. # Копирование библиотеки фото Immich (оригиналы) с VM 200 на диск бэкапов хоста.
# Запускать на хосте Proxmox под root. Требуется SSH без пароля: root → admin@192.168.1.200. # Запускать на хосте Proxmox под root. Требуется: SSH без пароля root → admin@192.168.1.200; на VM 200 установлен rsync (apt install rsync).
# Результат: /mnt/backup/photos/library/ (зеркало /mnt/data/library с VM 200). # Результат: /mnt/backup/photos/library/ (зеркало /mnt/data/library с VM 200).
# Без --delete: удалённые в Immich фото в бэкапе остаются (страховка). # Без --delete: удалённые в Immich фото в бэкапе остаются (страховка).
set -e set -e
@@ -19,3 +19,12 @@ mkdir -p "$BACKUP_PATH"
rsync -az --timeout=3600 \ rsync -az --timeout=3600 \
--exclude=".stfolder" \ --exclude=".stfolder" \
"$VM_SSH:$REMOTE_PATH/" "$BACKUP_PATH/" "$VM_SSH:$REMOTE_PATH/" "$BACKUP_PATH/"
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du -sh "$BACKUP_PATH" 2>/dev/null | cut -f1) || true
BODY="Резервное копирование завершено.
Объекты: библиотека фото Immich (rsync с VM 200).
Размер копии: ${SIZE:-}."
"$NOTIFY_SCRIPT" "📷 Фото Immich (rsync)" "$BODY" || true
fi

View File

@@ -0,0 +1,113 @@
#!/bin/bash
# Выгрузка только /mnt/backup/photos в Yandex Object Storage (S3) через restic.
# Тот же репозиторий, что и backup-restic-yandex.sh; фото вынесены в отдельный снимок (больше всего данных).
# Запускать на хосте 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"
# Время запуска (для логов и уведомлений)
START_TS=$(date +%s)
START_HUMAN=$(date '+%Y-%m-%d %H:%M:%S')
if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root."
exit 1
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/vaultwarden-secrets.md"
exit 1
fi
if ! command -v bw >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
echo "Установите bw (Bitwarden CLI) и jq для получения секретов из Vaultwarden."
exit 1
fi
export BW_SESSION
BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || { echo "Не удалось разблокировать Vaultwarden."; exit 1; }
RESTIC_ITEM=$(bw get item "RESTIC" 2>/dev/null) || { echo "Не найден объект RESTIC в Vaultwarden."; exit 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
if [ -z "${!var}" ]; then
echo "В Vaultwarden (RESTIC) не задано поле для $var"
exit 1
fi
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
if ! command -v restic >/dev/null 2>&1; then
echo "restic не установлен. Установите: apt install restic."
exit 1
fi
if [ ! -d "$BACKUP_PATH" ]; then
echo "Каталог $BACKUP_PATH не найден. Пропуск."
exit 0
fi
echo "Restic backup (photos): $BACKUP_PATH -> $RESTIC_REPOSITORY"
# Показываем прогресс restic (без --quiet), чтобы был виден ход бэкапа
restic backup "$BACKUP_PATH"
echo "Restic forget (retention 3 daily, 2 weekly, 2 monthly)..."
restic forget --keep-daily 3 --keep-weekly 2 --keep-monthly 2 --prune --quiet
echo "Restic prune..."
restic prune --quiet
# Время окончания и длительность
END_TS=$(date +%s)
END_HUMAN=$(date '+%Y-%m-%d %H:%M:%S')
DURATION_SEC=$(( END_TS - START_TS ))
if [ "$DURATION_SEC" -lt 0 ] 2>/dev/null; then
DURATION_SEC=0
fi
DUR_MIN=$(( DURATION_SEC / 60 ))
DUR_SEC=$(( DURATION_SEC % 60 ))
echo "Restic photos backup done."
echo "Время запуска: $START_HUMAN"
echo "Время завершения: $END_HUMAN"
echo "Длительность: ${DUR_MIN} мин ${DUR_SEC} сек"
# Уведомление в Telegram (шлюз тихо выходит, если конфига нет)
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
STATS=$(restic stats latest 2>/dev/null) || true
FILES=$(echo "$STATS" | grep "Total File Count" | sed 's/.*:[[:space:]]*//')
SIZE=$(echo "$STATS" | grep "Total Size" | sed 's/.*:[[:space:]]*//')
if [ -n "$FILES" ] && [ -n "$SIZE" ]; then
BODY="Резервное копирование завершено.
Объекты: снимок /mnt/backup/photos в Yandex. Файлов в снимке: $FILES.
Размер копии: ${SIZE}.
Время запуска: ${START_HUMAN}.
Время завершения: ${END_HUMAN}.
Длительность: ${DUR_MIN} мин ${DUR_SEC} сек."
"$NOTIFY_SCRIPT" "📷 Restic Yandex (photos)" "$BODY" || true
else
BODY="Резервное копирование завершено.
Объекты: снимок /mnt/backup/photos в Yandex.
Размер копии: — (stats недоступны).
Время запуска: ${START_HUMAN}.
Время завершения: ${END_HUMAN}.
Длительность: ${DUR_MIN} мин ${DUR_SEC} сек."
"$NOTIFY_SCRIPT" "📷 Restic Yandex (photos)" "$BODY" || true
fi
fi

View File

@@ -1,56 +1,70 @@
#!/bin/bash #!/bin/bash
# Выгрузка /mnt/backup в Yandex Object Storage (S3) через restic. # Выгрузка /mnt/backup в Yandex Object Storage (S3) через restic (без каталога photos).
# Фото бэкапятся отдельно: backup-restic-yandex-photos.sh.
# Запускать на хосте Proxmox под root. # Запускать на хосте Proxmox под root.
# Перед первым запуском: установить restic, создать /root/.restic-yandex.env и /root/.restic-password, выполнить restic init. # Секреты: из Vaultwarden (объект RESTIC). Требуется файл с мастер-паролем: /root/.bw-master (chmod 600).
# Перед первым запуском: установить restic, bw (Bitwarden CLI), jq; bw config server https://vault.katykhin.ru; restic init.
# Cron: 0 4 * * * (04:00, после окна 01:0003:30; 05:00 зарезервировано под перезагрузку). # Cron: 0 4 * * * (04:00, после окна 01:0003:30; 05:00 зарезервировано под перезагрузку).
set -e set -e
# При запуске из systemd PATH и HOME могут быть пустыми
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export HOME="${HOME:-/root}"
ENV_FILE="/root/.restic-yandex.env"
BACKUP_PATH="/mnt/backup" BACKUP_PATH="/mnt/backup"
# Исключаем служебные каталоги # Время запуска (для логов и уведомлений)
EXCLUDE_OPTS=(--exclude="$BACKUP_PATH/lost+found") START_TS=$(date +%s)
START_HUMAN=$(date '+%Y-%m-%d %H:%M:%S')
# Исключаем служебные каталоги и photos (фото — отдельный бэкап)
EXCLUDE_OPTS=(--exclude="$BACKUP_PATH/lost+found" --exclude="$BACKUP_PATH/photos")
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root." echo "Запускайте под root."
exit 1 exit 1
fi fi
if [ ! -f "$ENV_FILE" ]; then # Секреты из Vaultwarden (объект RESTIC)
echo "Нет файла $ENV_FILE. Скопируйте restic-yandex-env.example и задайте ключи." 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/vaultwarden-secrets.md"
exit 1 exit 1
fi fi
if ! command -v bw >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
# shellcheck source=/dev/null echo "Установите bw (Bitwarden CLI) и jq для получения секретов из Vaultwarden."
source "$ENV_FILE" exit 1
fi
export BW_SESSION
BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || { echo "Не удалось разблокировать Vaultwarden. Проверьте мастер-пароль и доступ к vault.katykhin.ru."; exit 1; }
RESTIC_ITEM=$(bw get item "RESTIC" 2>/dev/null) || { echo "Не найден объект RESTIC в Vaultwarden."; exit 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 for var in RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var}" ]; then if [ -z "${!var}" ]; then
echo "В $ENV_FILE не задано: $var" echo "В Vaultwarden (RESTIC) не задано поле для $var"
exit 1 exit 1
fi fi
done done
RESTIC_PASSWORD_FILE=$(mktemp -u)
if [ -z "$RESTIC_PASSWORD_FILE" ]; then echo -n "$RESTIC_PASS" > "$RESTIC_PASSWORD_FILE"
RESTIC_PASSWORD_FILE="/root/.restic-password" chmod 600 "$RESTIC_PASSWORD_FILE"
fi trap 'rm -f "$RESTIC_PASSWORD_FILE"' EXIT INT TERM
if [ ! -f "$RESTIC_PASSWORD_FILE" ]; then
echo "Файл с паролем репозитория не найден: $RESTIC_PASSWORD_FILE. Создайте его и выполните restic init."
exit 1
fi
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
export RESTIC_PASSWORD_FILE export RESTIC_PASSWORD_FILE
export RESTIC_REPOSITORY
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-ru-central1}"
if ! command -v restic >/dev/null 2>&1; then if ! command -v restic >/dev/null 2>&1; then
echo "restic не установлен. Установите: apt install restic." echo "restic не установлен. Установите: apt install restic."
exit 1 exit 1
fi fi
echo "Restic backup: $BACKUP_PATH -> $RESTIC_REPOSITORY" echo "Restic backup: $BACKUP_PATH (excl. photos) -> $RESTIC_REPOSITORY"
restic backup "$BACKUP_PATH" "${EXCLUDE_OPTS[@]}" --quiet # Показываем прогресс restic (без --quiet), чтобы был виден ход бэкапа
restic backup "$BACKUP_PATH" "${EXCLUDE_OPTS[@]}"
echo "Restic forget (retention 3 daily, 2 weekly, 2 monthly)..." echo "Restic forget (retention 3 daily, 2 weekly, 2 monthly)..."
restic forget --keep-daily 3 --keep-weekly 2 --keep-monthly 2 --prune --quiet restic forget --keep-daily 3 --keep-weekly 2 --keep-monthly 2 --prune --quiet
@@ -58,4 +72,42 @@ restic forget --keep-daily 3 --keep-weekly 2 --keep-monthly 2 --prune --quiet
echo "Restic prune..." echo "Restic prune..."
restic prune --quiet restic prune --quiet
# Время окончания и длительность
END_TS=$(date +%s)
END_HUMAN=$(date '+%Y-%m-%d %H:%M:%S')
DURATION_SEC=$(( END_TS - START_TS ))
if [ "$DURATION_SEC" -lt 0 ] 2>/dev/null; then
DURATION_SEC=0
fi
DUR_MIN=$(( DURATION_SEC / 60 ))
DUR_SEC=$(( DURATION_SEC % 60 ))
echo "Restic backup done." echo "Restic backup done."
echo "Время запуска: $START_HUMAN"
echo "Время завершения: $END_HUMAN"
echo "Длительность: ${DUR_MIN} мин ${DUR_SEC} сек"
# Уведомление в Telegram (шлюз тихо выходит, если конфига нет)
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
STATS=$(restic stats latest 2>/dev/null) || true
FILES=$(echo "$STATS" | grep "Total File Count" | sed 's/.*:[[:space:]]*//')
SIZE=$(echo "$STATS" | grep "Total Size" | sed 's/.*:[[:space:]]*//')
if [ -n "$FILES" ] && [ -n "$SIZE" ]; then
BODY="Резервное копирование завершено.
Объекты: снимок /mnt/backup в Yandex (без photos). Файлов в снимке: $FILES.
Размер копии: ${SIZE}.
Время запуска: ${START_HUMAN}.
Время завершения: ${END_HUMAN}.
Длительность: ${DUR_MIN} мин ${DUR_SEC} сек."
"$NOTIFY_SCRIPT" "☁️ Restic Yandex" "$BODY" || true
else
BODY="Резервное копирование завершено.
Объекты: снимок /mnt/backup в Yandex (без photos).
Размер копии: — (stats недоступны).
Время запуска: ${START_HUMAN}.
Время завершения: ${END_HUMAN}.
Длительность: ${DUR_MIN} мин ${DUR_SEC} сек."
"$NOTIFY_SCRIPT" "☁️ Restic Yandex" "$BODY" || true
fi
fi

View File

@@ -4,6 +4,7 @@
# Запускать на хосте Proxmox под root. Использует pct exec. # Запускать на хосте Proxmox под root. Использует pct exec.
# Результат: /mnt/backup/other/vaultwarden/vaultwarden-data-YYYYMMDD-HHMM.tar.gz # Результат: /mnt/backup/other/vaultwarden/vaultwarden-data-YYYYMMDD-HHMM.tar.gz
set -e set -e
export PATH="/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
CT_ID=103 CT_ID=103
REMOTE_PATH="/opt/docker/vaultwarden/data" REMOTE_PATH="/opt/docker/vaultwarden/data"
@@ -15,19 +16,38 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1 exit 1
fi fi
# Минимальный размер архива (байт). Пустой tar.gz ≈ 20 байт — значит каталог пуст или путь неверный.
MIN_BACKUP_BYTES=512
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d-%H%M) DATE=$(date +%Y%m%d-%H%M)
OUTPUT="$BACKUP_DIR/vaultwarden-data-$DATE.tar.gz" OUTPUT="$BACKUP_DIR/vaultwarden-data-$DATE.tar.gz"
ERR=$(mktemp)
trap "rm -f '$ERR'" EXIT
pct exec $CT_ID -- tar cf - -C /opt/docker/vaultwarden data 2>/dev/null | gzip > "$OUTPUT" pct exec $CT_ID -- tar cf - -C /opt/docker/vaultwarden data 2>"$ERR" | gzip > "$OUTPUT"
if [ -s "$OUTPUT" ]; then SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
if [ -s "$OUTPUT" ] && [ "$SIZE_BYTES" -ge "$MIN_BACKUP_BYTES" ]; then
chmod 600 "$OUTPUT" chmod 600 "$OUTPUT"
echo "Создан: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" echo "Создан: $OUTPUT ($(du --apparent-size -h "$OUTPUT" | cut -f1))"
else else
echo "Ошибка: архив пустой или каталог недоступен." echo "Ошибка: архив пустой или слишком мал (${SIZE_BYTES} байт). Проверьте путь /opt/docker/vaultwarden/data в CT $CT_ID."
[ -s "$ERR" ] && cat "$ERR" >&2
rm -f "$OUTPUT" rm -f "$OUTPUT"
exit 1 exit 1
fi fi
find "$BACKUP_DIR" -name 'vaultwarden-data-*.tar.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_DIR" -name 'vaultwarden-data-*.tar.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du --apparent-size -h "$OUTPUT" | cut -f1)
SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
BODY="Резервное копирование завершено.
Объекты: данные Vaultwarden (пароли).
Размер копии: ${SIZE}."
[ "$SIZE_BYTES" -lt 10240 ] 2>/dev/null && BODY="${BODY}
⚠️ Подозрительно малый размер — проверьте /opt/docker/vaultwarden/data в CT 103."
"$NOTIFY_SCRIPT" "🔐 Vaultwarden" "$BODY" || true
fi

View File

@@ -30,7 +30,7 @@ else
fi fi
if [ -s "$OUTPUT" ]; then if [ -s "$OUTPUT" ]; then
echo "Создан: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" echo "Создан: $OUTPUT ($(du --apparent-size -h "$OUTPUT" | cut -f1))"
else else
echo "Ошибка: дамп пустой или не создан." echo "Ошибка: дамп пустой или не создан."
rm -f "$OUTPUT" rm -f "$OUTPUT"
@@ -39,3 +39,15 @@ fi
# Удалить дампы старше RETENTION_DAYS # Удалить дампы старше RETENTION_DAYS
find "$BACKUP_DIR" -name 'immich-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete find "$BACKUP_DIR" -name 'immich-db-*.sql.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du --apparent-size -h "$OUTPUT" | cut -f1)
SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)
BODY="Резервное копирование завершено.
Объекты: дамп БД Immich (PostgreSQL, VM 200).
Размер копии: ${SIZE}."
[ "$SIZE_BYTES" -lt 10240 ] 2>/dev/null && BODY="${BODY}
⚠️ Подозрительно малый размер — проверьте скрипт на VM 200 и наличие данных в БД."
"$NOTIFY_SCRIPT" "🗄️ Immich (БД)" "$BODY" || true
fi

View File

@@ -71,3 +71,12 @@ if [ -f "$S3_ENV" ]; then
else else
echo "Подсказка: для бэкапа S3 создайте $S3_ENV с S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET_NAME." echo "Подсказка: для бэкапа S3 создайте $S3_ENV с S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET_NAME."
fi fi
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du -sh "$BACKUP_ROOT" 2>/dev/null | cut -f1) || true
BODY="Резервное копирование завершено.
Объекты: БД, voice_users, S3 (telegram-helper-bot).
Размер копии: ${SIZE:-}."
"$NOTIFY_SCRIPT" "🖥️ VPS Миран" "$BODY" || true
fi

View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Бэкап конфигов MTProto + сайт-заглушка с VPS Германия (185.103.253.99).
# Запускать на хосте Proxmox под root.
# Требуется: SSH без пароля с хоста к root@185.103.253.99 (ключ в ~/.ssh).
# Результат: /mnt/backup/vps/mtproto-germany/mtproto-config-YYYYMMDD-HHMM.tar.gz
set -e
VPS_HOST="185.103.253.99"
VPS_USER="root"
SSH_OPTS=(-o ConnectTimeout=15 -o BatchMode=yes)
BACKUP_ROOT="/mnt/backup/vps/mtproto-germany"
RETENTION_DAYS=14
if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root."
exit 1
fi
mkdir -p "$BACKUP_ROOT"
DATE=$(date +%Y%m%d-%H%M)
ARCHIVE="$BACKUP_ROOT/mtproto-config-$DATE.tar.gz"
# Проверка доступа к VPS
if ! ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" "echo ok" >/dev/null 2>&1; then
echo "Ошибка: нет доступа по SSH к ${VPS_USER}@${VPS_HOST}. Настройте ключ: ssh-copy-id ${VPS_USER}@${VPS_HOST}"
exit 1
fi
# Архив с VPS: mtg, nginx, letsencrypt (katykhin.store), статика сайта
ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" "tar -chzf - -C / \
etc/systemd/system/mtg.service \
etc/nginx/sites-available \
etc/nginx/sites-enabled \
etc/letsencrypt/live/katykhin.store \
etc/letsencrypt/archive/katykhin.store \
etc/letsencrypt/renewal/katykhin.store.conf \
var/www/katykhin.store" > "$ARCHIVE"
chmod 600 "$ARCHIVE"
echo "Бэкап MTProto (VPS DE): $ARCHIVE ($(du --apparent-size -h "$ARCHIVE" | cut -f1))"
find "$BACKUP_ROOT" -name 'mtproto-config-*.tar.gz' -mtime +$RETENTION_DAYS -delete
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
if [ -x "$NOTIFY_SCRIPT" ]; then
SIZE=$(du --apparent-size -h "$ARCHIVE" | cut -f1)
SIZE_BYTES=$(stat -c%s "$ARCHIVE" 2>/dev/null || echo 0)
BODY="Резервное копирование завершено.
Объекты: конфиги MTProto, nginx, Let's Encrypt, сайт (VPS DE).
Размер копии: ${SIZE}."
[ "$SIZE_BYTES" -lt 1024 ] 2>/dev/null && BODY="${BODY}
⚠️ Подозрительно малый размер — проверьте SSH и наличие файлов на VPS."
"$NOTIFY_SCRIPT" "🌐 VPS MTProto (DE)" "$BODY" || true
fi

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

@@ -0,0 +1,63 @@
#!/bin/bash
# Единая точка отправки уведомлений в Telegram (шлюз).
# Вызывают скрипты бэкапов на хосте Proxmox. Позже тот же шлюз можно вызывать с VM 200 / VPS по SSH.
# Использование: notify-telegram.sh "Заголовок" "Текст сообщения"
# Секреты: из Vaultwarden (токен — пароль объекта HOME_BOT_TOKEN, chat_id — поле TELEGRAM_SELF_CHAT_ID объекта RESTIC).
# Файл с мастер-паролем: /root/.bw-master (chmod 600). Если его нет — тихо выходим с 0, не ломаем вызывающий скрипт.
set -e
TITLE="${1:-Notification}"
BODY="${2:-}"
# Креды из Vaultwarden или из старого конфига (fallback)
TELEGRAM_BOT_TOKEN=""
TELEGRAM_CHAT_ID=""
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
BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || true
if [ -n "$BW_SESSION" ]; then
export BW_SESSION
TELEGRAM_BOT_TOKEN=$(bw get password "HOME_BOT_TOKEN" 2>/dev/null) || true
RESTIC_ITEM=$(bw get item "RESTIC" 2>/dev/null) || true
if [ -n "$RESTIC_ITEM" ]; then
TELEGRAM_CHAT_ID=$(echo "$RESTIC_ITEM" | jq -r '.fields[] | select(.name=="TELEGRAM_SELF_CHAT_ID") | .value' 2>/dev/null) || true
fi
fi
fi
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
ENV_FILE="${TELEGRAM_NOTIFY_ENV:-/root/.telegram-notify.env}"
if [ -f "$ENV_FILE" ]; then
# shellcheck source=/dev/null
source "$ENV_FILE"
fi
fi
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
exit 0
fi
if [ -z "$BODY" ]; then
TEXT="$TITLE"
else
TEXT="$TITLE
$BODY"
fi
URL="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage"
if [ -n "${TELEGRAM_DEBUG:-}" ]; then
curl -s -w "\nHTTP_CODE:%{http_code}\n" -X POST "$URL" \
--data-urlencode "chat_id=$TELEGRAM_CHAT_ID" \
--data-urlencode "text=$TEXT" \
-d "disable_web_page_preview=true" \
--max-time 10
else
curl -sf -X POST "$URL" \
--data-urlencode "chat_id=$TELEGRAM_CHAT_ID" \
--data-urlencode "text=$TEXT" \
-d "disable_web_page_preview=true" \
--max-time 10 \
>/dev/null 2>&1 || true
fi
exit 0

View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Проверяет каталог локальных vzdump за последние 2 часа и отправляет в Telegram сводку.
# Задание Proxmox Backup выполняется в 02:00; этот скрипт запускают по cron в 03:00.
# Использование: notify-vzdump-success.sh [путь_к_dump]
# По умолчанию: /mnt/backup/proxmox/dump/dump/
DUMP_DIR="${1:-/mnt/backup/proxmox/dump/dump}"
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-/root/scripts/notify-telegram.sh}"
# Файлы, изменённые за последние 120 минут (2 часа)
MAX_AGE_MIN=120
if [ ! -d "$DUMP_DIR" ]; then
exit 0
fi
if [ ! -x "$NOTIFY_SCRIPT" ]; then
exit 0
fi
# Список файлов vzdump, изменённых за последние MAX_AGE_MIN минут
RECENT=$(find "$DUMP_DIR" -maxdepth 1 -type f \( -name 'vzdump-*.tar.zst' -o -name 'vzdump-*.vma.zst' -o -name 'vzdump-*.vma' \) -mmin "-$MAX_AGE_MIN" 2>/dev/null)
if [ -z "$RECENT" ]; then
exit 0
fi
COUNT=$(echo "$RECENT" | grep -c . 2>/dev/null || echo 0)
[ "$COUNT" -eq 0 ] && exit 0
TOTAL_BYTES=$(echo "$RECENT" | while read -r f; do stat -c %s "$f" 2>/dev/null; done | awk '{s+=$1} END {print s+0}')
[ -z "$TOTAL_BYTES" ] && TOTAL_BYTES=0
# Размер в ГБ (округление до 2 знаков; если bc нет — целое число)
TOTAL_GB=$(echo "scale=2; $TOTAL_BYTES / 1024 / 1024 / 1024" | bc 2>/dev/null)
[ -z "$TOTAL_GB" ] && TOTAL_GB="$((TOTAL_BYTES / 1024 / 1024 / 1024))"
# Время последнего изменения (последний записанный файл = время завершения бэкапа)
LATEST_MTIME=$(echo "$RECENT" | while read -r f; do stat -c %Y "$f" 2>/dev/null; done | sort -n | tail -1)
FINISH_TIME=""
[ -n "$LATEST_MTIME" ] && FINISH_TIME=$(date -d "@$LATEST_MTIME" +%H:%M 2>/dev/null) || true
BODY="Резервное копирование завершено.
Объекты: локальный vzdump (LXC/VM). Контейнеров/ВМ: $COUNT.
Размер копии: ${TOTAL_GB} ГБ."
[ -n "$FINISH_TIME" ] && BODY="${BODY}
Время завершения: ${FINISH_TIME}."
"$NOTIFY_SCRIPT" "💾 Backup local" "$BODY" || true
exit 0

View File

@@ -1,15 +1,17 @@
#!/bin/bash #!/bin/bash
# Add vault.katykhin.ru → 192.168.1.103:8280 via NPM API + Access List (LAN + VPN only) # 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 # 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) # (then set NPM_URL=http://127.0.0.1:81 and NPM_EMAIL/NPM_PASSWORD in env)
# NPM credentials: see docs/containers/container-100.md
set -e set -e
NPM_URL="${NPM_URL:-http://192.168.1.100:81}" NPM_URL="${NPM_URL:-http://192.168.1.100:81}"
API="$NPM_URL/api" API="$NPM_URL/api"
NPM_EMAIL="${NPM_EMAIL:-j3tears100@gmail.com}" if [ -z "$NPM_EMAIL" ] || [ -z "$NPM_PASSWORD" ]; then
NPM_PASSWORD="${NPM_PASSWORD:-kqEUubVq02DJTS8}" echo "Set NPM_EMAIL and NPM_PASSWORD (from Vaultwarden, объект NPM_ADMIN)"
exit 1
fi
echo "1. Getting token..." echo "1. Getting token..."
TOKEN=$(curl -s -X POST "$API/tokens" \ 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

@@ -0,0 +1,127 @@
#!/bin/bash
# Восстановление одного файла vzdump из restic (Yandex S3) через mount.
# Не выкачивает весь репозиторий — подгружаются только нужные данные для выбранного файла.
# Запускать на хосте Proxmox под root. Требуется FUSE (restic mount).
# Секреты из Vaultwarden (объект RESTIC), файл /root/.bw-master (chmod 600).
#
# Использование:
# restore-one-vzdump-from-restic.sh [SNAPSHOT] [ПУТЬ_В_СНИМКЕ] [КУДА_СОХРАНИТЬ]
#
# Пример (последний снимок, CT 107, сохранить в /mnt/backup):
# ./restore-one-vzdump-from-restic.sh
# ./restore-one-vzdump-from-restic.sh latest /mnt/backup/proxmox/dump/dump/vzdump-lxc-107-2026_02_26-02_03_14.tar.zst /mnt/backup
#
# Список снимков: restic snapshots
# Список файлов в снимке: restic ls SNAPSHOT
set -e
MOUNT_DIR="${MOUNT_DIR:-/mnt/backup/restic-mount}"
SNAPSHOT="${1:-latest}"
# Путь к файлу внутри снимка (как в restic ls) — бэкапим /mnt/backup, пути вида /mnt/backup/proxmox/dump/dump/vzdump-lxc-107-...
FILE_IN_SNAPSHOT="${2:-}"
OUTPUT_DIR="${3:-/mnt/backup}"
if [ "$(id -u)" -ne 0 ]; then
echo "Запускайте под root."
exit 1
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/vaultwarden-secrets.md"
exit 1
fi
if ! command -v bw >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
echo "Установите bw (Bitwarden CLI) и jq для получения секретов из Vaultwarden."
exit 1
fi
export BW_SESSION
BW_SESSION=$(bw unlock --passwordfile "$BW_MASTER_PASSWORD_FILE" --raw 2>/dev/null) || { echo "Не удалось разблокировать Vaultwarden."; exit 1; }
RESTIC_ITEM=$(bw get item "RESTIC" 2>/dev/null) || { echo "Не найден объект RESTIC в Vaultwarden."; exit 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
if [ -z "${!var}" ]; then
echo "В Vaultwarden (RESTIC) не задано поле для $var"
exit 1
fi
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
if ! command -v restic >/dev/null 2>&1; then
echo "restic не установлен. Установите: apt install restic."
exit 1
fi
if [ -z "$FILE_IN_SNAPSHOT" ]; then
echo "Использование: $0 [SNAPSHOT] ПУТЬ_В_СНИМКЕ [КУДА_СОХРАНИТЬ]"
echo "Пример: $0 latest /mnt/backup/proxmox/dump/dump/vzdump-lxc-107-2026_02_26-02_03_14.tar.zst /mnt/backup"
echo ""
echo "Доступные снимки:"
restic snapshots 2>/dev/null || true
exit 1
fi
# Resolve "latest" to snapshot ID (restic mount показывает ids/<short-id>/)
if [ "$SNAPSHOT" = "latest" ]; then
SNAPSHOT_ID=$(restic snapshots 2>/dev/null | grep -E '^[a-f0-9]{8}[[:space:]]' | tail -1 | awk '{print $1}')
else
SNAPSHOT_ID="$SNAPSHOT"
fi
[ -z "$SNAPSHOT_ID" ] && echo "Не удалось определить ID снимка." && exit 1
echo "Снимок: $SNAPSHOT_ID"
mkdir -p "$OUTPUT_DIR"
mkdir -p "$MOUNT_DIR"
# Проверяем, не смонтировано ли уже
if mountpoint -q "$MOUNT_DIR" 2>/dev/null; then
echo "Точка монтирования $MOUNT_DIR уже занята. Размонтируйте: fusermount -u $MOUNT_DIR"
exit 1
fi
echo "Монтируем репозиторий в $MOUNT_DIR ..."
restic mount "$MOUNT_DIR" &
MOUNT_PID=$!
trap 'rm -f "$RESTIC_PASSWORD_FILE"; kill $MOUNT_PID 2>/dev/null; fusermount -u "$MOUNT_DIR" 2>/dev/null; exit' EXIT INT TERM
# В смонтированном репо файлы снимка лежат в ids/<short_id>/<путь>
SOURCE_FILE="$MOUNT_DIR/ids/$SNAPSHOT_ID/$FILE_IN_SNAPSHOT"
for i in $(seq 1 45); do
if [ -e "$SOURCE_FILE" ] 2>/dev/null; then
break
fi
sleep 1
done
if [ ! -e "$SOURCE_FILE" ]; then
echo "Файл не найден: $SOURCE_FILE (подождали 45 с). Проверьте: restic ls $SNAPSHOT_ID"
exit 1
fi
BASENAME_FILE="$(basename "$FILE_IN_SNAPSHOT")"
OUTPUT_FILE="$OUTPUT_DIR/$BASENAME_FILE"
echo "Копируем $SOURCE_FILE -> $OUTPUT_FILE ..."
cp "$SOURCE_FILE" "$OUTPUT_FILE"
echo "Готово: $OUTPUT_FILE ($(du -sh "$OUTPUT_FILE" | cut -f1))"
# Размонтировать
kill $MOUNT_PID 2>/dev/null || true
wait $MOUNT_PID 2>/dev/null || true
fusermount -u "$MOUNT_DIR" 2>/dev/null || true
trap - EXIT INT TERM
echo "Размонтировано. Для восстановления контейнера:"
echo " pct create NEW_VMID $OUTPUT_FILE --restore 1 --storage local-lvm"

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

Some files were not shown because too many files have changed in this diff Show More