Compare commits
20
Commits
ru1-tproxy
...
e02a9a8f1b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e02a9a8f1b | ||
|
|
b915a58ad9 | ||
|
|
1769608355 | ||
|
|
bc0537a2d9 | ||
|
|
931219ad47 | ||
|
|
d780c91bd5 | ||
|
|
629b11f03f | ||
|
|
4965f70299 | ||
|
|
464e12abdb | ||
|
|
3811483577 | ||
|
|
550c359e4a | ||
|
|
c1f16ebc38 | ||
|
|
0a6d728851 | ||
|
|
bba3858894 | ||
|
|
7d0a891793 | ||
|
|
e645377916 | ||
|
|
ef2891323c | ||
|
|
65a01740e8 | ||
|
|
2bb74f7430 | ||
|
|
d1269162e7 |
@@ -6,6 +6,7 @@
|
|||||||
- [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан.
|
- [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан.
|
||||||
- [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Основная нода (Ubuntu 24.04): мониторинг, gitea, AmneziaWG exit для ru1, боты (claude-channels, openclaw), статика `www`/`share`. Держит апекс `ruzzy.dev`.
|
- [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Основная нода (Ubuntu 24.04): мониторинг, gitea, AmneziaWG exit для ru1, боты (claude-channels, openclaw), статика `www`/`share`. Держит апекс `ruzzy.dev`.
|
||||||
- [`servers/ru1.md`](servers/ru1.md) — `85.198.109.86` (VDSina, РФ). VPN entry-нода (OpenVPN ×2, AmneziaWG **до fr1** после переключения, WireGuard клиентов), умный роутинг RU/зарубеж; WEB-прокси Telegram (tproxy-server + MTProxy) на `ru.ruzzy.dev:443`, telemt удалён 2026-10-09. ⚠️ Файл описания сверялся до переключения.
|
- [`servers/ru1.md`](servers/ru1.md) — `85.198.109.86` (VDSina, РФ). VPN entry-нода (OpenVPN ×2, AmneziaWG **до fr1** после переключения, WireGuard клиентов), умный роутинг RU/зарубеж; WEB-прокси Telegram (tproxy-server + MTProxy) на `ru.ruzzy.dev:443`, telemt удалён 2026-10-09. ⚠️ Файл описания сверялся до переключения.
|
||||||
|
- [`servers/rpi.md`](servers/rpi.md) — `rpi.local` (192.168.10.25, дом, за NAT). Raspberry Pi 4: бэкап личного OneDrive на HDD 4 ТБ (rclone, вечный архив версий, еженедельная сверка хэшей); SMART и статус бэкапов пушит в pushgateway fr1 по SSH. Код — `stacks/rpi-backup/`. Агент Astra (Codex) с управлением из Telegram — репо `ruzzy/astra-bot`. Медиацентр в Docker (Jellyfin, Seerr, qBittorrent, Radarr/Sonarr/Prowlarr/Bazarr, стартовая страница Homepage :3000) — `stacks/rpi-media/`.
|
||||||
|
|
||||||
## Поддержание описаний в актуальном состоянии
|
## Поддержание описаний в актуальном состоянии
|
||||||
Тронул сервер — обнови `servers/<хост>.md` в том же прогоне. Контракт и
|
Тронул сервер — обнови `servers/<хост>.md` в том же прогоне. Контракт и
|
||||||
|
|||||||
+17
-3
@@ -15,6 +15,7 @@
|
|||||||
- ⚠️ **Группа `docker` = root-эквивалент.** `docker:x:988:rus,tgclaude` — любой её член монтирует `/` в контейнер и делает что угодно от рута (этим же способом чистились root-овы файлы 2026-09-08). Поэтому «у `tgclaude` нет sudo» **не** означает ограничения прав на хосте.
|
- ⚠️ **Группа `docker` = root-эквивалент.** `docker:x:988:rus,tgclaude` — любой её член монтирует `/` в контейнер и делает что угодно от рута (этим же способом чистились root-овы файлы 2026-09-08). Поэтому «у `tgclaude` нет sudo» **не** означает ограничения прав на хосте.
|
||||||
- Вход с Mac: `ssh rus-161` (алиас в `~/.ssh/config`) → ключ `~/.ssh/id_161_97_93_252` (ed25519).
|
- Вход с Mac: `ssh rus-161` (алиас в `~/.ssh/config`) → ключ `~/.ssh/id_161_97_93_252` (ed25519).
|
||||||
- root-доступ по SSH закрыт; для root изнутри — `sudo -i`.
|
- root-доступ по SSH закрыт; для root изнутри — `sudo -i`.
|
||||||
|
- **Пуш-ключ `metrics-push@rpi`** (с 2026-10-11) в `~rus/.ssh/authorized_keys` с `restrict,command="/home/rus/bin/pushgw-ingest rpi"`: этим ключом домашняя малина может только залить метрики в pushgateway — шелла, форвардинга и pty нет. Бэкап прежнего файла — `~/.ssh/authorized_keys.bak-20261011`.
|
||||||
|
|
||||||
## SSH (`/etc/ssh/sshd_config.d/00-hardening.conf`)
|
## SSH (`/etc/ssh/sshd_config.d/00-hardening.conf`)
|
||||||
⚠️ В основном `/etc/ssh/sshd_config` строкой 42 стоит `PermitRootLogin yes` (правка шаблона Contabo). Побеждает всё равно дроп-ин: `Include /etc/ssh/sshd_config.d/*.conf` стоит строкой 12, а sshd берёт **первое** встреченное значение. То есть root-вход закрыт, но держится на порядке строк — уберёшь дроп-ин, и root-вход по ключу включится. ⏳ Рядом лежит `50-cloud-init.conf`, который из-под `rus` не читается: под root стоит проверить `sshd -T | grep -i permitrootlogin`.
|
⚠️ В основном `/etc/ssh/sshd_config` строкой 42 стоит `PermitRootLogin yes` (правка шаблона Contabo). Побеждает всё равно дроп-ин: `Include /etc/ssh/sshd_config.d/*.conf` стоит строкой 12, а sshd берёт **первое** встреченное значение. То есть root-вход закрыт, но держится на порядке строк — уберёшь дроп-ин, и root-вход по ключу включится. ⏳ Рядом лежит `50-cloud-init.conf`, который из-под `rus` не читается: под root стоит проверить `sshd -T | grep -i permitrootlogin`.
|
||||||
@@ -104,15 +105,18 @@ backend=systemd banaction=ufw
|
|||||||
- Сертификаты LE: `mon`, `git`, `share`, `www`. `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam.
|
- Сертификаты LE: `mon`, `git`, `share`, `www`. `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam.
|
||||||
|
|
||||||
## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`)
|
## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`)
|
||||||
- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager** (docker compose).
|
- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager + Pushgateway** (docker compose).
|
||||||
- **Алерты:** Alertmanager → Telegram (бот @ultimate_log_bot, chat 607015). Правила `prometheus/rules/alerts.yml`: TargetDown, HostHighCPU, HostLowMemory, HostDiskLow/Critical. Токен — в `~/monitoring/alertmanager/telegram_token` (chown 65534, не в git).
|
- **Алерты:** Alertmanager → Telegram (бот @ultimate_log_bot, chat 607015). Правила `prometheus/rules/alerts.yml`: TargetDown, HostHighCPU, HostLowMemory, HostDiskLow/Critical; группа `rpi` (с 2026-10-11) — SMART и бэкапы малины: PushMetricsStale, SmartHealthFailed, SmartBadSectors(+Growing), SmartUdmaCrcErrors, SmartSelftestFailed, SmartTemperatureHigh, BackupFailed, BackupStale. Токен — в `~/monitoring/alertmanager/telegram_token` (chown 65534, не в git).
|
||||||
- Prometheus: retention 90d, слушает `127.0.0.1:9090`. Job'ы: `node` и `cadvisor` (file_sd, instance'ы **fr1 и ru1**), `openvpn` (file_sd, только ru1 — `85.198.109.86:9176`), `gitea` (static, `gitea:3000` через внешнюю docker-сеть `monitoring_monitoring`), `prometheus` (self). На 2026-09-08 все таргеты up, активных алертов нет.
|
- Prometheus: retention 90d, слушает `127.0.0.1:9090`. Job'ы: `node` и `cadvisor` (file_sd, instance'ы **fr1 и ru1**), `openvpn` (file_sd, только ru1 — `85.198.109.86:9176`), `gitea` (static, `gitea:3000` через внешнюю docker-сеть `monitoring_monitoring`), `prometheus` (self). На 2026-09-08 все таргеты up, активных алертов нет.
|
||||||
- Grafana: `127.0.0.1:3000`, доступ снаружи через **https://mon.ruzzy.dev** (nginx + Let's Encrypt). Datasource Prometheus (uid `prometheus`) и дашборды (Node Exporter Full, cAdvisor, Network/Traffic, Gitea, OpenVPN — папка Infra) провижатся автоматически. Пароль admin — в `~/monitoring/docker-compose.yml` на сервере (в репо вынесен в `${GF_ADMIN_PASSWORD}`).
|
- Grafana: `127.0.0.1:3000`, доступ снаружи через **https://mon.ruzzy.dev** (nginx + Let's Encrypt). Datasource Prometheus (uid `prometheus`) и дашборды (Node Exporter Full, cAdvisor, Network/Traffic, Gitea, OpenVPN, **RPi — диск и бэкап OneDrive** (`rpi-disk.json`, с 2026-10-11) — папка Infra) провижатся автоматически. Пароль admin — в `~/monitoring/docker-compose.yml` на сервере (в репо вынесен в `${GF_ADMIN_PASSWORD}`).
|
||||||
- **Тома:** `monitoring_prometheus_data` — **6.5 ГБ** (при retention 90d; самый крупный том на сервере, учитывать при планировании диска), `monitoring_grafana_data` — 50 МБ, `monitoring_alertmanager_data` — пустой.
|
- **Тома:** `monitoring_prometheus_data` — **6.5 ГБ** (при retention 90d; самый крупный том на сервере, учитывать при планировании диска), `monitoring_grafana_data` — 50 МБ, `monitoring_alertmanager_data` — пустой.
|
||||||
- **Multi-server:** таргеты через file_sd (`prometheus/targets/{node,cadvisor}/*.yml`) — новый сервер = добавить файл + `curl -X POST .../-/reload`. См. `stacks/monitoring/README.md`.
|
- **Multi-server:** таргеты через file_sd (`prometheus/targets/{node,cadvisor}/*.yml`) — новый сервер = добавить файл + `curl -X POST .../-/reload`. См. `stacks/monitoring/README.md`.
|
||||||
- **node-exporter — `network_mode: host`** (видит реальные интерфейсы хоста: eth0, wg0-туннель и т.д.; иначе показывал бы только сеть docker-бриджа). Слушает host:9100, доступ ограничен ufw (`from 172.16.0.0/12 to port 9100`), Prometheus скрейпит через `host.docker.internal:9100`. nodename берётся с хоста (fr1).
|
- **node-exporter — `network_mode: host`** (видит реальные интерфейсы хоста: eth0, wg0-туннель и т.д.; иначе показывал бы только сеть docker-бриджа). Слушает host:9100, доступ ограничен ufw (`from 172.16.0.0/12 to port 9100`), Prometheus скрейпит через `host.docker.internal:9100`. nodename берётся с хоста (fr1).
|
||||||
- Дашборд **Network / Traffic** (`network.json`): RX/TX по интерфейсам, пакеты, ошибки/дропы, статус — с фильтром по instance/device (lo/veth/docker скрыты).
|
- Дашборд **Network / Traffic** (`network.json`): RX/TX по интерфейсам, пакеты, ошибки/дропы, статус — с фильтром по instance/device (lo/veth/docker скрыты).
|
||||||
- DNS: `mon.ruzzy.dev` → 161.97.93.252.
|
- DNS: `mon.ruzzy.dev` → 161.97.93.252.
|
||||||
|
- **Pushgateway** (с 2026-10-11) — для хостов за NAT, которых не скрейпнуть (домашняя `rpi`, см. `servers/rpi.md`). Слушает только `127.0.0.1:9091`; job `pushgateway` с `honor_labels: true` (иначе job/instance заменились бы на `pushgateway:9091`). Персистентность в томе `monitoring_pushgateway_data` — рестарт не обнуляет метрики. **Как приходят данные:** хост делает `ssh rus@fr1 <job> < metrics.prom`, forced command `~/bin/pushgw-ingest <instance>` делает `PUT /metrics/job/<job>/instance/<instance>`. instance зашит в authorized_keys, а не берётся от клиента — хост не может писать от чужого имени; имя job фильтруется по `[a-z0-9_]`. Наружу порт не открыт, nginx/ufw не трогали.
|
||||||
|
- ⚠️ Pushgateway хранит последнее значение вечно: если хост умер, метрики не исчезнут, а «застынут». Поэтому свежесть ловит отдельный алерт `PushMetricsStale` по `push_time_seconds`.
|
||||||
|
- ⚠️ Живой `~/monitoring/docker-compose.yml` расходится с репо (пароль Grafana вписан литералом), поэтому при деплое 2026-10-11 compose **не копировался**, а блок pushgateway вставлен на месте. Бэкапы: `docker-compose.yml.bak-20261011`, `prometheus/prometheus.yml.bak-20261011`.
|
||||||
- Подключён `ru1` (node + cadvisor + openvpn-exporter, напрямую по публичному IP). `de1` выведен — его таргеты удалены. Планируется: экспортёры wireguard/awg и telemt.
|
- Подключён `ru1` (node + cadvisor + openvpn-exporter, напрямую по публичному IP). `de1` выведен — его таргеты удалены. Планируется: экспортёры wireguard/awg и telemt.
|
||||||
|
|
||||||
## Gitea (`~/gitea`, stack в репо `stacks/gitea/`)
|
## Gitea (`~/gitea`, stack в репо `stacks/gitea/`)
|
||||||
@@ -127,6 +131,8 @@ backend=systemd banaction=ufw
|
|||||||
- `ROOT_URL`/`SSH_DOMAIN` = `git.ruzzy.dev` (не менялись). Host-ключи SSH и user/deploy-ключи перенесены с данными — `ssh://git@git.ruzzy.dev:2222` работает как раньше.
|
- `ROOT_URL`/`SSH_DOMAIN` = `git.ruzzy.dev` (не менялись). Host-ключи SSH и user/deploy-ключи перенесены с данными — `ssh://git@git.ruzzy.dev:2222` работает как раньше.
|
||||||
- Docker-томов нет — все данные на bind-mount `/srv/gitea` (1.9 ГБ), это и есть точка бэкапа.
|
- Docker-томов нет — все данные на bind-mount `/srv/gitea` (1.9 ГБ), это и есть точка бэкапа.
|
||||||
- Это origin репо `infra` и vault openclaw.
|
- Это origin репо `infra` и vault openclaw.
|
||||||
|
- **Пользователь `astra`** (id 5, с 2026-10-11) — агент на домашней малине (`servers/rpi.md`): коллаборатор с записью в `ruzzy/infra` и `ruzzy/astra-bot`, свои репо в `astra/…`, постоянный токен `astra-rpi` (хранится только на малине). Пароль случайный и нигде не сохранён — вход только ключом/токеном; сбросить — `gitea admin user change-password`. Deploy-ключи astra (id 10, 11) удалены.
|
||||||
|
- Как сделать разовую операцию через API без следов: `gitea admin user generate-access-token --scopes write:repository --raw` (конфиг по умолчанию верный — работающая Gitea тоже читает `/data/gitea/conf/app.ini`, БД `/data/gitea.db`) → `POST /api/v1/repos/<owner>/<repo>/keys` → удалить токен прямо из БД: `docker exec -u 1000 gitea sqlite3 /data/gitea.db "delete from access_token where name='<имя>'"` (API требует basic-auth, как выяснилось с imgbot-токенами). Проверено 2026-10-11: после удаления токен получает 401.
|
||||||
|
|
||||||
## AmneziaWG exit (для ru1) — перенос VPN, фаза 1
|
## AmneziaWG exit (для ru1) — перенос VPN, фаза 1
|
||||||
- Зеркало exit-узла `de1`: AmneziaWG server `wg0` = **10.8.1.0/24**, ListenPort **36360/udp**, те же server-ключи/PSK и obfuscation-параметры (Jc/Jmin/Jmax/S1/S2/H1-4), что и на `de1` (server-pubkey `Lumb1o…`) → на `ru1` свитч = только смена Endpoint.
|
- Зеркало exit-узла `de1`: AmneziaWG server `wg0` = **10.8.1.0/24**, ListenPort **36360/udp**, те же server-ключи/PSK и obfuscation-параметры (Jc/Jmin/Jmax/S1/S2/H1-4), что и на `de1` (server-pubkey `Lumb1o…`) → на `ru1` свитч = только смена Endpoint.
|
||||||
@@ -230,6 +236,14 @@ backend=systemd banaction=ufw
|
|||||||
- Таймеры `fwupd-refresh.timer` (раз в сутки ходил в сеть за метаданными прошивок LVFS) и `motd-news.timer` (`/etc/default/motd-news`: `ENABLED=1`, `URLS=https://motd.ubuntu.com` — раз в сутки запрашивал у Canonical новостной текст для приветствия) — `disabled`.
|
- Таймеры `fwupd-refresh.timer` (раз в сутки ходил в сеть за метаданными прошивок LVFS) и `motd-news.timer` (`/etc/default/motd-news`: `ENABLED=1`, `URLS=https://motd.ubuntu.com` — раз в сутки запрашивал у Canonical новостной текст для приветствия) — `disabled`.
|
||||||
- Демоны `ModemManager`, `fwupd`, `multipathd`, `udisks2` — `masked`. Все четыре висели с 0.0% CPU и ~100 МБ RSS суммарно, и обслуживать им было нечего: multipath-устройств нет (`/dev/mapper` — только `control`, диск один), модемов нет (в `/sys/class/net` только `eth0`, `wg0`, `lo` и docker-интерфейсы), прошивок в KVM-гостe нет, съёмных носителей тоже.
|
- Демоны `ModemManager`, `fwupd`, `multipathd`, `udisks2` — `masked`. Все четыре висели с 0.0% CPU и ~100 МБ RSS суммарно, и обслуживать им было нечего: multipath-устройств нет (`/dev/mapper` — только `control`, диск один), модемов нет (в `/sys/class/net` только `eth0`, `wg0`, `lo` и docker-интерфейсы), прошивок в KVM-гостe нет, съёмных носителей тоже.
|
||||||
|
|
||||||
|
## Бэкап (с 2026-10-11)
|
||||||
|
До этого регулярных бэкапов fr1 **не было** (только разовый снимок Gitea перед апгрейдом).
|
||||||
|
- Домашняя `rpi` каждую ночь (02:15 MSK) забирает зашифрованную выгрузку — `stacks/server-backup/`. Ключ `server-backup@rpi` в `~rus/.ssh/authorized_keys` с `restrict,command="/home/rus/bin/backup-export"` (бэкап прежнего файла — `authorized_keys.bak-20261011b`).
|
||||||
|
- `~/bin/backup-export` читает root-файлы через `docker run --rm --network none -v /:/host:ro debian:stable-slim` — sudo тут по паролю, а `rus` в группе docker. Шифрует `~/bin/age` (статический v1.3.2) открытым ключом; закрытый только у владельца.
|
||||||
|
- daily: `/etc`, `/srv/gitea` + согласованный `.backup` `gitea.db` (живой файл исключён), `/usr/local`, `~rus` (без backups/openclaw/кэшей), `/root`, crontab. weekly (вс): тома `claude-channels_claude-home`, grafana, alertmanager, pushgateway + `~tgclaude` (без backups/openclaw-deploy/.local).
|
||||||
|
- ⚠️ Prometheus (том 7.9 ГБ) **не бэкапится** сознательно. ⚠️ Новые важные данные вне перечисленных путей в бэкап не попадут — добавлять в `fr1-backup-export`.
|
||||||
|
- В образах добавился `debian:stable-slim` (нужен для выгрузки).
|
||||||
|
|
||||||
## Открытые хвосты (на 2026-09-08)
|
## Открытые хвосты (на 2026-09-08)
|
||||||
Мелочи, оставленные сознательно, — чтобы не искать их заново.
|
Мелочи, оставленные сознательно, — чтобы не искать их заново.
|
||||||
- ⚠️ **В Gitea живут два временных токена** — `imgbot-setup` и `imgbot-setup2`, scope `write:repository,write:user`. Созданы через `docker exec -u 1000 gitea gitea admin user generate-access-token` для создания репо `ruzzy/imgbot` и deploy-ключа. Удалить через API не удалось: Gitea отвечает 401 на управление токенами по токен-авторизации, нужна basic-auth. **Отозвать вручную:** Настройки → Приложения.
|
- ⚠️ **В Gitea живут два временных токена** — `imgbot-setup` и `imgbot-setup2`, scope `write:repository,write:user`. Созданы через `docker exec -u 1000 gitea gitea admin user generate-access-token` для создания репо `ruzzy/imgbot` и deploy-ключа. Удалить через API не удалось: Gitea отвечает 401 на управление токенами по токен-авторизации, нужна basic-auth. **Отозвать вручную:** Настройки → Приложения.
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# rpi — домашняя Raspberry Pi 4 (`rpi.local`, 192.168.10.25)
|
||||||
|
|
||||||
|
Поднята 2026-10-11. Домашний сервер за NAT: первая задача — бэкап личного
|
||||||
|
OneDrive на внешний HDD; с того же дня ещё и медиацентр (Jellyfin + *arr, Docker). Мониторится с fr1 через pushgateway (скрейпить
|
||||||
|
её снаружи нельзя — она за домашним роутером).
|
||||||
|
|
||||||
|
## Железо и ОС
|
||||||
|
- Raspberry Pi 4, SD 64 ГБ (`mmcblk0`), питание штатное — `vcgencmd get_throttled` = `0x0`.
|
||||||
|
- Raspberry Pi OS Lite 64-bit **Trixie** (Debian 13), образ `2026-10-06-raspios-trixie-arm64-lite`.
|
||||||
|
- Headless-настройка — **cloud-init**, а не старые `ssh`/`userconf.txt`: в Trixie Pi Imager пишет `user-data`/`network-config` на `bootfs`. Наш `user-data` создал пользователя, ключ, hostname, таймзону MSK.
|
||||||
|
- Сеть: Ethernet 1 Гбит/с, DHCP. MAC `dc:a6:32:db:b0:f9`, роутер `192.168.10.254` (Huawei HG8245X6-10, Cyta). Адрес `192.168.10.25` закреплён за MAC статической DHCP-привязкой на роутере (2026-10-11, запись `DHCPStaticAddress.2`; была без резерва).
|
||||||
|
|
||||||
|
## Доступ
|
||||||
|
- С Mac: `ssh rpi` (алиас → `rpi.local`, user `rus`, ключ `~/.ssh/id_ed25519`). Резолвится через mDNS (avahi в образе по умолчанию).
|
||||||
|
- Только по ключу: `passwordauthentication no`, у `rus` пароль заблокирован, **sudo NOPASSWD** (иначе sudo был бы недоступен вовсе).
|
||||||
|
|
||||||
|
## Диски
|
||||||
|
| Устройство | Модель | Роль |
|
||||||
|
|---|---|---|
|
||||||
|
| `sda` | WD My Passport 4 ТБ (WD40NDZW, s/n WD-WX82D6228YCD) | ext4 `backup` → `/mnt/backup` |
|
||||||
|
| — | WD Elements 1 ТБ (WD10SDRW) | **в USB роутера**, не малины: NTFS/MBR, метка `ROUTER` → `router:` / `/mnt/router` (см. «Вторая копия») |
|
||||||
|
|
||||||
|
- fstab по UUID с `nofail,x-systemd.device-timeout=30s` (`stacks/rpi-backup/fstab.line`): без диска малина грузится, бэкап просто пропускается (`mountpoint -q`).
|
||||||
|
- ⚠️ **Два 2.5" HDD от USB Pi 4 не тянут.** Проверено 2026-10-11: при раскрутке обоих — `over-current` на всех USB3-портах; под нагрузкой 4-ТБ диск отваливался (`USB disconnect`) и залипал в `Unit Not Ready`. Один диск стабилен (20 ГБ чтения 103 МБ/с, mkfs ~60 ГБ записи без сбросов). Второй диск — только через USB3-хаб с собственным питанием.
|
||||||
|
- Залипший после отвала диск лечится power-cycle всех USB (Pi 4 переключает питание портов только разом): `sudo uhubctl -l 2 -a off; sudo uhubctl -l 1-1 -a off; sleep 8; …-a on`.
|
||||||
|
- SMART на момент ввода: 9 897 ч, 0 переназначенных/ожидающих, Start_Stop 21 946, Load_Cycle 42 530.
|
||||||
|
|
||||||
|
## Бэкап OneDrive (код — `stacks/rpi-backup/`)
|
||||||
|
- rclone (официальный .deb, v1.75.2 — в Debian 1.60, старый), remote `onedrive:` = личный OneDrive (drive_type personal). Токен — в `~rus/.config/rclone/rclone.conf` (600), только на малине. Получен через `rclone authorize onedrive` на Mac.
|
||||||
|
- `onedrive-backup` (таймер каждые 4 ч, `00/4:30`): `rclone sync` в `/mnt/backup/onedrive/current`, всё удалённое/перезаписанное в облаке — в `archive/<ts>/` **навсегда** (пользователь хочет иметь всё, что когда-либо было в OneDrive). Хэши архива — `manifests/archive-<ts>.qxh`, лог каждого прогона — `logs/sync-<ts>.log` (INFO: поштучно что скачано/убрано в архив).
|
||||||
|
- Целостность: rclone сверяет QuickXorHash каждого скачанного файла с OneDrive; `onedrive-verify` (вс 05:00) перечитывает весь HDD: `rclone check` current↔облако + `rclone checksum` archive↔манифесты. Отчёт — `logs/verify-<ts>*`.
|
||||||
|
- Оба юнита под общим `flock /run/lock/onedrive-backup.lock` — не идут параллельно и не убивают друг друга.
|
||||||
|
- ⚠️ Файл, загруженный и удалённый в облаке между прогонами (<4 ч), в бэкап не попадёт.
|
||||||
|
- ⚠️ `Personal Vault` исключён — rclone через API туда не ходит.
|
||||||
|
- ⚠️ Архив без ротации: растёт вечно. Скрипт выходит с кодом 2 при заполнении >90 % (→ алерт BackupFailed), плюс HostDisk* если когда-то поставим node-exporter.
|
||||||
|
- Первая полная выгрузка (368 ГБ) запущена 2026-10-11 ~13:13, ~27 МБ/с (упор в домашний канал).
|
||||||
|
|
||||||
|
## Вторая копия — 1 ТБ диск в роутере (с 2026-10-11)
|
||||||
|
- Диск воткнут в роутер Huawei (`192.168.10.254`), а не в малину — **не ест питание USB малины** (два HDD от неё не тянут, см. «Диски»). Доступ — SFTP роутера на порту **8022**, пользователь `root`, remote `router:` в `~rus/.config/rclone/rclone.conf` (пароль только там), host key — `~rus/.ssh/known_hosts_router`.
|
||||||
|
- ⚠️ Роутер не считает хэши (`shell_type = none`, `md5sum_command = none`) — rclone сравнивает по размеру+mtime, целостность проверяется перечиткой.
|
||||||
|
- `mnt-router.service` — `rclone mount router: /mnt/router` (user rus, `--vfs-cache-mode writes`, кэш в `/mnt/backup/.rclone-cache`). Для просмотра/ручной работы; бэкап пишет **напрямую в remote**, не через FUSE (коды ошибок, без кэша).
|
||||||
|
- Скорость (замер 2026-10-11, параллельно с выгрузкой OneDrive): запись ~8 МБ/с, чтение ~11 МБ/с — первичная копия ~370 ГБ ≈ 13 ч. Диск 932 ГБ.
|
||||||
|
- `router-backup` (`stacks/rpi-backup/`, таймер 06:00, `flock`): в `router:rpi-backup/` — `onedrive/{archive,manifests,logs}` только `copy` (там ничего не удаляется), затем зеркало `onedrive/current` с `--max-delete 1000`, затем зеркало зашифрованных `servers/` с `--max-delete 50`. Порядок важен: удалённое из current к этому моменту уже скопировано в archive. Логи — `/mnt/backup/router-logs/`.
|
||||||
|
- ⚠️ `--max-delete` — предохранитель: если локально «пропадёт» много файлов (сбой диска/монтирования), копия на роутере не зачистится, а прогон упадёт с ошибкой → алерт `BackupFailed`.
|
||||||
|
- По воскресеньям `verify`: сверка размеров деревьев + перечитать с роутера последний daily каждого сервера и сравнить sha256.
|
||||||
|
- **Ежемесячно (1-го, 08:00) `router-sample`**: случайные файлы OneDrive из `current/` и `archive/` старше суток, ~2 ГБ / до 300 шт., перечитываются с роутера и сверяются sha256 с HDD — ловит тихую порчу, которую сверка размеров не видит. Отсутствующий на роутере файл = ошибка. Итог — `backup_*{job="backup_router_sample"}` → `BackupFailed`, `BackupStale` (>35 дней). Проверено 2026-10-11: 20 из 20 скопированных файлов совпали.
|
||||||
|
- Мониторинг: `router_mount_up` и заполнение `/mnt/router` (в `smart-metrics`, `df` под `timeout 20`), итог прогона — `backup_*{job="backup_router"}`; алерты `RouterDiskMountDown` (30 мин), `BackupDiskFull` (>90 %, любой диск бэкапа), `BackupStale` (>30 ч); ряд «Вторая копия» на дашборде rpi-disk.
|
||||||
|
- ⚠️ 1 ТБ меньше основного диска: сейчас влезает (~370 ГБ OneDrive + ~100 ГБ серверов), но архив версий растёт вечно — следить за `BackupDiskFull`.
|
||||||
|
|
||||||
|
- Почему NTFS (2026-10-11): прошивка Cyta видит FAT32 и NTFS, **exFAT — нет** (диск в списке USB не появлялся); FAT32 отпал из-за лимита 4 ГБ на файл. Файл 4,4 ГБ через SFTP записан и прочитан. На Mac NTFS штатно не создать — делали `mkntfs` из ntfs-3g, собранного без FUSE (`--disable-ntfs-3g`), на блочное `/dev/diskNs1` (на `rdisk` mkntfs отказывается).
|
||||||
|
- ⚠️ FTP роутера (порт 21) включён в его конфиге, но из LAN **filtered**: доступ к FTP со стороны LAN открывается только на странице ONT Access Control, а у пользователя `cytauser` её нет (нужен суперадмин Cyta). Поэтому SFTP: включён 2026-10-11 через веб-API роутера — сервер `8022`, LAN=1, **WAN=0**, аккаунт `root` с корнем `/mnt/usb/usb-1058-363434_1`. Пароли SFTP и веб-интерфейса — только у пользователя и в `rclone.conf`.
|
||||||
|
- ⚠️ Потолок ~10 МБ/с — CPU роутера (dropbear): `aes128/256-ctr` вдвое медленнее `chacha20` (нет аппаратного AES), параллельные потоки почти не помогают. Быстрее только диск на малине через хаб с питанием (~100 МБ/с).
|
||||||
|
|
||||||
|
## Бэкап серверов fr1/ru1 (с 2026-10-11, код — `stacks/server-backup/`)
|
||||||
|
- Малина — внешняя площадка для VPS: `servers-backup.timer` (02:15) забирает по SSH выгрузки, которые **сервер сам шифрует открытым ключом age** (`tar | zstd | age`). Закрытого ключа на малине нет — ни она, ни Astra, ни взломщик архивы не прочитают. Ключ у владельца: Mac `~/.config/age/servers-backup.key`.
|
||||||
|
- Хранилище `/mnt/backup/servers/<host>/{daily,weekly,monthly}` (root 700): 14 / 8 / 12, monthly — хардлинк первого daily месяца. fr1 daily ~2.1 ГБ (5.5 мин), ru1 ~17 МБ, fr1 weekly (вс) — тома ботов и imgbot.
|
||||||
|
- Ключ `/etc/server-backup/id_ed25519` (root 600): на серверах ограничен forced command — только запустить выгрузку. Host keys закреплены в `/etc/server-backup/known_hosts`.
|
||||||
|
- Целостность: sha256 шифротекста считает сервер, малина сверяет при приёме и по воскресеньям (`servers-backup verify`). Итог — метрики `backup_*{job="backup_servers"}`, алерт `BackupStale` >26 ч, панель на дашборде rpi-disk.
|
||||||
|
- Пробное восстановление 2026-10-11 на Mac: оба архива расшифрованы, на месте CA OpenVPN (`easy-rsa/pki/private/ca.key`), ключи AWG, конфиги прокси, репо gitea, снимок `gitea.db`.
|
||||||
|
|
||||||
|
## Мониторинг → fr1
|
||||||
|
- `smartd` (`stacks/rpi-backup/smartd.conf`): `DEVICESCAN -d removable`, `-n standby,q` (не будить диск), короткий самотест сб 03:00, длинный 1-го числа 05:00. ⚠️ `-d sat` с DEVICESCAN не работает («Not a SAT device») — тип моста пусть определяет сам.
|
||||||
|
- `smart-metrics` (таймер 15 мин): smartctl `-n standby` → метрики `smart_*` → job `smart`. Спящий диск не будится: отдаются значения из кэша `/var/lib/smart-metrics/` + `smart_disk_standby=1`.
|
||||||
|
- `backup-metrics <job>` — `ExecStopPost=+` у юнитов бэкапа: `backup_last_exit_code`, `backup_last_run_timestamp`, `backup_last_success_timestamp` (время успеха хранится локально в `/var/lib/backup-metrics/`, т.к. PUT заменяет группу целиком).
|
||||||
|
- Доставка: `metrics-push <job>` = ssh с ключом `/etc/metrics-push/id_ed25519` (root, 600) на `rus@161.97.93.252`; host key fr1 закреплён в `/etc/metrics-push/known_hosts`. На fr1 ключ ограничен forced command (`servers/fr1.md` → Мониторинг).
|
||||||
|
- `smart-metrics` заодно отдаёт `backup_disk_size_bytes`/`backup_disk_avail_bytes` для `/mnt/backup` (statfs, диск не будит).
|
||||||
|
- Дашборд: https://mon.ruzzy.dev/d/rpi-disk (SMART, износ за сутки, свежесть бэкапов, заполнение).
|
||||||
|
- ⚠️ `BackupStale` видит только **существующие** серии — задача, ни разу не отчитавшаяся, им не ловится. Для этого `BackupNeverReported` на `absent()` (с 2026-10-11): OneDrive 12 ч, серверы 26 ч, роутер 30 ч, проверка OneDrive 8 дней; и `PushMetricsStale` на отсутствие SMART-метрик 1 ч. Доставка в Telegram проверена тестовым алертом 2026-10-11.
|
||||||
|
- Алерты — группа `rpi` в `stacks/monitoring/prometheus/rules/alerts.yml`.
|
||||||
|
|
||||||
|
## Домашняя печать (с 2026-10-11)
|
||||||
|
- Brother DCP-L2620DW обнаружен через mDNS: `192.168.10.24`, `BRW2C9C58C1EEA7.local`, IPP порт 631 `/ipp/print`; чёрно-белый, A4, двусторонняя печать. Ранее службы печати на rpi не было.
|
||||||
|
- Установлены `cups`, `cups-client`, `cups-filters`, `cups-ipp-utils` (CUPS 2.4.10); включён `cups.service`. Очередь по умолчанию `home-brother`, драйвер `everywhere`, URI `ipp://BRW2C9C58C1EEA7.local:631/ipp/print`. По умолчанию A4, одна сторона; `printer-is-shared=false`, CUPS слушает только `127.0.0.1:631` и `[::1]:631`. Доступ из Telegram — команда агента, без отдельного открытого веб-интерфейса.
|
||||||
|
- Код настройки и команда — `stacks/rpi-print/`; `home-print` установлен в `/usr/local/bin/` (root, 755). `home-print status` — состояние/очередь; `home-print [--duplex] <PDF/PNG/JPEG>` — одна копия, A4 с подгонкой. Печать только по прямой просьбе пользователя; приём задания в очередь не означает, что бумага уже вышла.
|
||||||
|
- **Навык `home-print` установлен 2026-10-11** в `~astra/.codex/skills/home-print/`; источник — [astra-bot `c01ed22`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/c01ed22), `codex/skills/home-print/`. Ранее обязательного предпросмотра не было. При просьбе печати извлекает текст из скриншотов и перевёрстывает чёрным по белому, обычные картинки сохраняет и готовит к ч/б печати, формирует PDF и PNG каждой страницы. Отправляет PNG в Telegram отдельным скриптом (токен только из `/etc/astra-bot.env`, без вывода). **Печатает только после явного подтверждения пользователем доставленного предпросмотра**, причём тот же PDF, из которого получены PNG. Исторические сведения не дописывает; неразборчивые/обрезанные места отмечает. Скрипты проверены на двухстраничном PDF, вся серия — 28 тестов; установленный навык валидирован. Подключение к новым разговорам (`/new`), перезапуск бота не нужен.
|
||||||
|
- ⚠️ DOCX напрямую не печатается — сначала преобразовать в PDF. Hostname выбран вместо DHCP-IP, чтобы пережить смену адреса; требуется mDNS и включённый принтер. `printer-error-policy=abort-job` исключает неожиданную автоматическую печать старых заданий после ошибки; ошибочный прогон нужно отправить заново.
|
||||||
|
- Проверено: запрос атрибутов IPP (`ipptool`) PASS, принтер idle/enabled, A4/Gray/duplex доступны. Портрет успешно преобразован фильтрами CUPS PNG → PDF → PWG Raster (A4, 300 dpi), без ошибок. Тестовая страница A4 отправлена 2026-10-11 в 15:20 MSK: `home-brother-1`, CUPS сообщил `job-completed-successfully`, принтер вернулся в idle, очередь пуста; качество на бумаге проверяет пользователь.
|
||||||
|
|
||||||
|
## Медиацентр (с 2026-10-11, код — `stacks/rpi-media/`)
|
||||||
|
Docker CE 29.9 + Compose v5.6 из репо download.docker.com (в Debian старее). `/etc/docker/daemon.json`: json-file `10m×3` — чтобы логи контейнеров не точили SD. `rus` в группе `docker` (= root-эквивалент; у него и так sudo NOPASSWD). Стек — `/opt/media/docker-compose.yml`, конфиги — `/opt/media/config/<сервис>` (SD, ~20 МБ), образы ~7.8 ГБ на SD.
|
||||||
|
|
||||||
|
| Сервис | Порт | Зачем | Версия на 2026-10-11 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Jellyfin | 8096 (host-сеть) | медиасервер; клиенты LG webOS, Infuse/Swiftfin на iOS | 12.2 |
|
||||||
|
| Seerr | 5055 | каталог «хочу посмотреть» → Radarr/Sonarr | v3.5.0 |
|
||||||
|
| qBittorrent | 8080, торренты 6881 tcp/udp | загрузка | 5.2.4 |
|
||||||
|
| Radarr / Sonarr | 7878 / 8989 | фильмы / сериалы: поиск, загрузка, импорт | 6.4.4 / 4.0.20 |
|
||||||
|
| Prowlarr | 9696 | индексаторы, раздаёт их в Radarr/Sonarr | 2.6.5 |
|
||||||
|
| FlareSolverr | 8191 | обход Cloudflare для индексаторов с тегом `flaresolverr` | v3.5.2 |
|
||||||
|
| Bazarr | 6767 | субтитры, профиль `ru+en` | v1.6.2 |
|
||||||
|
| Homepage | 3000 | **стартовая страница**: все сервисы с описанием по-русски, живой статус, CPU/температура/диск | v2.5.0 |
|
||||||
|
|
||||||
|
- **Данные — `/mnt/backup/media`** (4-ТБ диск, решение пользователя): `torrents/{movies,tv,other}`, `library/{movies,tv}`, `.cache/jellyfin` (кэш и транскоды Jellyfin — не на SD). qBittorrent и *arr монтируют **один и тот же** корень как `/data` — иначе импорт из `torrents/` в `library/` превращается из хардлинка в копию и раздача занимает место дважды. `copyUsingHardlinks` включён. В бэкапы (`router-backup`, `onedrive-*`) `media/` не входит.
|
||||||
|
- ⚠️ **Медиа делят диск с бэкапами.** `onedrive-backup` падает с кодом 2 при заполнении >90 % (~370 ГБ свободно) → `BackupFailed`. В Radarr/Sonarr стоит `minimumFreeSpaceWhenImporting = 500 ГБ`: ниже этого импорт останавливается. **qBittorrent своего порога не имеет** — ручная заливка торрентов мимо *arr может забить диск.
|
||||||
|
- Jellyfin в `network_mode: host` — иначе LG/iOS не находят сервер автообнаружением (UDP 7359 и DLNA-мультикаст не проходят через бридж). Аппаратного транскодинга на Pi 4 нет (V4L2 убран в Jellyfin 10.9), `EncodingThreadCount=3`: расчёт на Direct Play. ⚠️ Транскод 4K/HEVC Pi не тянет — клиенту нужен нативный кодек (Infuse играет почти всё сам).
|
||||||
|
- Метаданные и интерфейс — ru, предпочтительные аудио/субтитры у пользователя — `rus`, режим субтитров Smart. Библиотеки «Фильмы» (`/data/library/movies`) и «Сериалы» (`/data/library/tv`), в Jellyfin только чтение.
|
||||||
|
- Индексаторы (публичные, без аккаунтов): NoNaMe Club, The Pirate Bay, YTS — синхронизированы в Radarr/Sonarr; Rutor и EZTV есть в Prowlarr, но в *arr не доехали (Rutor не проходит тест по категориям, EZTV на момент настройки лежал); 1337x не добавился (таймаут даже через FlareSolverr). **RuTracker** (добавлен 2026-10-11, аккаунт пользователя; логин/пароль — только в БД Prowlarr): через FlareSolverr, опции «убрать кириллицу», «добавить RUS», «первые теги в конец» — чтобы *arr сопоставляли английские названия и видели русскую дорожку; приоритет 10 (выше публичных, 25). Синхронизирован в Radarr и Sonarr. Kinozal и прочие приватные — руками.
|
||||||
|
- ⚠️ RuTracker закрывает `login.php`/`tracker.php` челленджем Cloudflare (по IPv4; IPv6 у малины нет маршрута). FlareSolverr на Pi решает его **80–180 с** — у прокси в Prowlarr `requestTimeout=180` (было 60, с ним добавление падало по таймауту). После решения Prowlarr держит cookie и поиск идёт ~4 с; когда cookie протухнет, первый поиск снова будет минутами, и *arr могут на время пометить индексатор недоступным.
|
||||||
|
- Bazarr: провайдеры без аккаунтов (`embeddedsubtitles`, `podnapisi`, `yifysubtitles`); для русских субтитров лучший — OpenSubtitles.com, нужен аккаунт.
|
||||||
|
- **Авторизация:** везде пользователь `rus`, один пароль — `/opt/media/.admin-password` (rus 600). *arr — формы, пароль **всегда** (режим «без пароля из LAN» в новых *arr требует `AllowedHosts`). Между собой сервисы ходят по API-ключам из `config/<app>/config.xml`.
|
||||||
|
- **Точка входа для пользователя — http://192.168.10.25:3000** (Homepage, конфиги `stacks/rpi-media/homepage/` → `/opt/media/homepage/`). Ключи виджетов — `/opt/media/homepage.env` (rus 600, `HOMEPAGE_VAR_*`, генерит `provision.py homepage`). ⚠️ Там же пароль qBittorrent: после смены общего пароля перегенерировать env и `docker compose up -d homepage`, иначе виджет qBittorrent отвалится. `HOMEPAGE_ALLOWED_HOSTS` обязателен — без него 400 на любой Host, кроме localhost.
|
||||||
|
- ⚠️ Встроенный виджет `jellyfin` Homepage 2.5 с Jellyfin 12 не работает: ходит на `/emby/…?api_key=` — пути нет (404), ключ в query отключён (`EnableLegacyAuthorization=false`, включать не стали). Вместо него `customapi` на `/Items/Counts` с заголовком `Authorization: MediaBrowser Token=…`.
|
||||||
|
- Radarr/Sonarr → Jellyfin (Connect «Jellyfin», тип MediaBrowser, ключ Jellyfin `homepage`; `provision.py connect` на чистой установке заводит ключ `arr-connect`): библиотека обновляется сразу после импорта. Без этого новый фильм висел невидимым до скана по расписанию — realtime-монитор хардлинк не заметил (проверено 2026-10-11 на первом фильме).
|
||||||
|
- Первичная связка — `stacks/rpi-media/provision.py` (идемпотентный, пароль и ключи читает на месте): `python3 /opt/media/provision.py [qbit jellyfin arr prowlarr bazarr seerr connect homepage]`.
|
||||||
|
- ⚠️ Каталог `config/<сервис>`, которого нет до `up`, docker создаёт **от root** — Jellyfin/Seerr (uid 1000) падают с `Permission denied`. Новые каталоги создавать заранее или `chown -R rus:rus /opt/media/config`.
|
||||||
|
- ⚠️ `fallenbagel/jellyseerr` заброшен (последний образ 2025-08) и не логинится в Jellyfin 12 — заменён на `ghcr.io/seerr-team/seerr`.
|
||||||
|
- Торренты идут **напрямую** с домашнего IP (решение пользователя 2026-10-11; вариант через AWG fr1 отложен). Порт 6881 на роутере **не проброшен** — входящих соединений нет, раздача и скорость хуже.
|
||||||
|
- Образы на `:latest`, автообновления нет: `cd /opt/media && docker compose pull && docker compose up -d`.
|
||||||
|
|
||||||
|
## Агент Astra (с 2026-10-11)
|
||||||
|
Агент на базе **Codex** (OpenAI), работает на самой малине, управление — из Telegram-бота **@rasp_contol_bot**. Код бота — отдельный репо **`ruzzy/astra-bot`** (там же схема работы).
|
||||||
|
- Пользователь `astra` (группы `sudo`, `adm`, `systemd-journal`, `users`), пароль заблокирован, **sudo NOPASSWD** (`/etc/sudoers.d/90-astra`) — по решению пользователя. SSH-вход под ним — ключом `~/.ssh/id_ed25519` с Mac (алиас `ssh rpi-astra`).
|
||||||
|
- ⚠️ sudo = агент видит всё: токен OneDrive (`~rus/.config/rclone/rclone.conf`), пуш-ключ метрик (`/etc/metrics-push/`), deploy-ключ с записью в `infra`, токен бота. Утечка контекста агента = утечка этих секретов.
|
||||||
|
- **Codex CLI 0.162.1** — полный пакет в `/opt/codex`, симлинк `/usr/local/bin/codex`. ⚠️ Голый бинарник из релиза (`codex-aarch64-…tar.gz`) не годится: `remote-control`/app-server требуют пакет (`codex-package-aarch64-unknown-linux-musl.tar.gz`), иначе «this CLI has no complete local package». Логин — подпиской ChatGPT (`codex login --device-auth`, аккаунт ruzzyrullezz@gmail.com, Plus), токен `~astra/.codex/auth.json` (600). Ключ API не используется.
|
||||||
|
- **Агенту разрешено пушить в `main` `ruzzy/infra` напрямую** (решение пользователя 2026-10-11, перекрывает правило из `CLAUDE.md` только для него): любые изменения сервера он обязан внести в `servers/rpi.md`/`stacks/` и запушить в том же прогоне, конфликты — `pull --rebase` без `--force`, неразрешимые — отдать пользователю. Источник инструкций — `codex/AGENTS.md` в `ruzzy/astra-bot`.
|
||||||
|
- Конфиг `~astra/.codex/config.toml`: `approval_policy=on-request`, `sandbox_mode=workspace-write`; инструкции агенту — `~astra/.codex/AGENTS.md` (паспорт, правила infra, что не трогать).
|
||||||
|
- **astra-bot** (`astra-bot.service`, user `astra`): `/opt/astra-bot/bot.py` ⇄ `codex app-server` (JSON-RPC stdio) ⇄ Telegram long polling. Подтверждения команд/правок — кнопками в чате. Конфиг `/etc/astra-bot.env` (root 600): `TG_TOKEN`, `TG_ALLOWED_IDS=607015`. Тред Codex на чат — `~astra/.local/state/astra-bot.json`. Бот лежит в `/opt` под root, а не в `~/infra`, чтобы агент не правил свой код без `sudo`.
|
||||||
|
- **Приём вложений исправлен 2026-10-11** (раньше бот читал только `text`, фото и подписи молча пропадали): код — `ruzzy/astra-bot`. Фото и JPEG/PNG/WebP-документы до 20 МиБ скачиваются через Telegram `getFile` в `~astra/.local/share/astra-bot/incoming/` (файлы 600, UUID), передаются Codex как `localImage`; подпись и источник пересылки — текстом. Пересланные `/stop` и другие команды читаются как цитата. Во время работы сообщения ставятся в очередь процесса, реальная доставка картинки с подписью и источником подтверждена пользователем 2026-10-11.
|
||||||
|
- **Пачки изображений добавлены 2026-10-11** (раньше каждый элемент альбома запускал отдельную задачу и отдельное уведомление очереди): установлен [коммит `6276bf8`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/6276bf8). Бот собирает альбом по `media_group_id`/чату/отправителю, быстро пересланные картинки без ID — по чату/отправителю; после 1,5 секунды тишины передаёт все картинки, подписи и источники одним запросом Codex в порядке `message_id`. Служебные уведомления ожидания убраны 2026-10-11 ([коммит `c4e3cd1`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/c4e3cd1)): прежняя проверка пустой очереди повторяла их между пачками; очередь сохранена и работает молча. Проверка включает повторное заполнение опустевшей очереди. Предыдущий код — `/opt/astra-bot/bot.py.bak-20261011-silent-queue`. 23 теста прошли; изменение загружается при перезапуске через `restart-check.py`. ⚠️ Пауза больше 1,5 секунды разделяет серию на пачки; буфер и очередь не переживают перезапуск. Предыдущий код — `/opt/astra-bot/bot.py.bak-20261011-batches`.
|
||||||
|
- Код бота с доработкой Astra перенесён в `ruzzy/astra-bot` (`f9f6c39`; текущая установленная версия — `c4e3cd1`); временная копия `stacks/astra-bot/` удалена 2026-10-11. Клон — `~astra/astra-bot`. Резервная копия прежнего кода — `/opt/astra-bot/bot.py.bak-20261011-incoming`.
|
||||||
|
- ⚠️ **Почему не штатный Codex Remote из ChatGPT:** телефон к безголовому Linux не сопрягается — официально хостом может быть только macOS/Windows; `codex remote-control pair` выдаёт код, но и QR, и ручной ввод дают «Pair Failed», до демона попытка даже не доходит (проверено с debug-логом 2026-10-11). Рабочая альтернатива — ChatGPT Desktop на Mac → SSH-хост `rpi-astra`, но тогда Mac должен быть включён.
|
||||||
|
- `/boot/firmware/cmdline.txt`: добавлен `cfg80211.ieee80211_regdom=RU` (бэкап `.bak-20261011`) — только чтобы `/etc/profile.d/wifi-check.sh` не печатал в stdout login-shell предупреждение про rfkill: оно ломало бы stdio-протоколы (Codex app-server по SSH). Wi-Fi не используется.
|
||||||
|
- **Доступ к Gitea — пользователь `astra`** (с 2026-10-11, вместо двух deploy-ключей): один SSH-ключ `~astra/.ssh/gitea`, коллаборатор с записью в `ruzzy/infra` (`~astra/infra`) и `ruzzy/astra-bot` (`~astra/astra-bot`), может создавать свои репо в `astra/…` (API-токен `~astra/.config/gitea/token`, 600, scopes `write:repository,write:user`, в Gitea — `astra-rpi`). Почему пользователь, а не deploy-ключи: deploy-ключом нельзя создавать репо, а права коллаборатора видны и меняются в настройках репо. Создавать репо от имени `ruzzy` нельзя — он пользователь, не организация. git identity `Astra (rpi) <astra@rpi.ruzzy.dev>`, host key Gitea (RSA) закреплён. Автопулла нет.
|
||||||
|
|
||||||
|
## Открытые хвосты (на 2026-10-11)
|
||||||
|
- USB3-хаб с питанием — теперь только ради скорости: WD Elements 1 ТБ работает второй копией в роутере (~10 МБ/с); на малине через хаб было бы ~100 МБ/с (обновлено 2026-10-11, было «копия одна»).
|
||||||
|
- Нет node-exporter (CPU/RAM/диск самой малины в Prometheus не видны) — пуш через pushgateway для node-метрик неудобен; решить, нужен ли.
|
||||||
|
- Первый прогон бэкапа стартовал до появления `ExecStopPost` — его итог может не дойти в метрики; `backup_last_success_timestamp` появится с первого прогона по таймеру. До этого `BackupStale` молчит (серии нет).
|
||||||
|
- ⏳ `codex-remote.service` (демон `codex remote-control` с `RUST_LOG=debug`) всё ещё включён и работает, хотя сопряжение не взлетело — убрать или оставить ждать поддержки Linux от OpenAI (решение за пользователем).
|
||||||
|
- Токен бота передавался через чат с ассистентом — при желании перевыпустить в @BotFather (`/revoke`) и вписать в `/etc/astra-bot.env`.
|
||||||
|
- ⚠️ **Закрытый ключ age бэкапа серверов — одна копия, на Mac.** Положить вторую вне малины и вне Mac (менеджер паролей/распечатка), иначе потеря Mac = бэкапы fr1/ru1 бесполезны.
|
||||||
|
- Разово скопировать на малину `~rus/backups` (5.4 ГБ) и `~tgclaude/backups` (4.2 ГБ) с fr1 — тогда их можно убрать с fr1.
|
||||||
|
- Пробное восстановление бэкапа серверов — раз в квартал (следующее ~январь 2027), см. `stacks/server-backup/README.md`.
|
||||||
|
- Через неделю снять SMART и посчитать реальный прирост Start_Stop/Load_Cycle — прогноз ресурса пока оценочный.
|
||||||
|
|
||||||
|
- ⏳ Определить хранение входящих фото: сейчас автоматически не удаляются ради ссылок в старых тредах; очередь сообщений только в памяти и теряется при перезапуске.
|
||||||
|
- Медиацентр: проброс 6881 tcp/udp на роутере (раздача/скорость); пароль SFTP роутера слабый (только LAN) — сменить; Kinozal и OpenSubtitles — аккаунты пользователя; решить про автообновление образов.
|
||||||
@@ -86,6 +86,11 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D
|
|||||||
- `wg1.conf` обязан иметь `Table = off` — иначе wg-quick при рестарте сносит default route и сервер теряет SSH (восстановление: `ip route add default via 85.198.109.1 dev ens3`).
|
- `wg1.conf` обязан иметь `Table = off` — иначе wg-quick при рестарте сносит default route и сервер теряет SSH (восстановление: `ip route add default via 85.198.109.1 dev ens3`).
|
||||||
- Таблица маршрутизации — `vpn` (100), не `awg`.
|
- Таблица маршрутизации — `vpn` (100), не `awg`.
|
||||||
|
|
||||||
|
## Бэкап (с 2026-10-11)
|
||||||
|
- Домашняя `rpi` каждую ночь забирает зашифрованную (age, открытый ключ) выгрузку — `stacks/server-backup/`. Ключ `server-backup@rpi` в `~mik/.ssh/authorized_keys` с `restrict,command="sudo -n /usr/local/sbin/backup-export"` (бэкап — `authorized_keys.bak-20261011`).
|
||||||
|
- `/usr/local/sbin/backup-export` (root 750) пакует `/etc`, `/opt`, `/srv`, `/usr/local`, `~mik` (без сборок badvpn/telemt-build, логов, `.claude`), `/root`, crontab; `/usr/local/bin/age` — статический v1.3.2. Архив ~17 МБ.
|
||||||
|
- Главное в нём — **`~mik/easy-rsa` (CA OpenVPN)**: без него пришлось бы перевыпускать все 43 клиентских конфига.
|
||||||
|
|
||||||
## Открытые хвосты
|
## Открытые хвосты
|
||||||
- **Умный роутинг для `wg1` не настроен** (2026-10-09): mangle-правила есть только для `-i tun0`, для `wg1` / `10.8.2.0/24` — ни одного, хотя раздел «Умный роутинг» заявляет tun0 и wg1. В `wg1` один пир, рукопожатий не было ни разу — сервисом, похоже, никто не пользуется. Не правили.
|
- **Умный роутинг для `wg1` не настроен** (2026-10-09): mangle-правила есть только для `-i tun0`, для `wg1` / `10.8.2.0/24` — ни одного, хотя раздел «Умный роутинг» заявляет tun0 и wg1. В `wg1` один пир, рукопожатий не было ни разу — сервисом, похоже, никто не пользуется. Не правили.
|
||||||
- Drop-in `10-keep-foreign-routing.conf` фактически не проверен рестартом networkd (рестарт ens3 по SSH на этой ноде — риск). Проверить при следующей плановой перезагрузке: после неё `ip rule | grep 0x64` должен показать правило.
|
- Drop-in `10-keep-foreign-routing.conf` фактически не проверен рестартом networkd (рестарт ens3 по SSH на этой ноде — риск). Проверить при следующей плановой перезагрузке: после неё `ip rule | grep 0x64` должен показать правило.
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ services:
|
|||||||
- '127.0.0.1:9093:9093'
|
- '127.0.0.1:9093:9093'
|
||||||
networks: [monitoring]
|
networks: [monitoring]
|
||||||
|
|
||||||
|
# pushgateway: метрики от хостов за NAT (rpi), которые не скрейпнуть.
|
||||||
|
# Порт только на 127.0.0.1 — пуш идёт по SSH через forced command ~/bin/pushgw-ingest
|
||||||
|
# (см. servers/fr1.md), наружу не торчит. Персистентность — чтобы рестарт не обнулял алерты.
|
||||||
|
pushgateway:
|
||||||
|
image: prom/pushgateway:latest
|
||||||
|
container_name: pushgateway
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- '--persistence.file=/data/metrics'
|
||||||
|
- '--persistence.interval=1m'
|
||||||
|
volumes:
|
||||||
|
- pushgateway_data:/data
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:9091:9091'
|
||||||
|
networks: [monitoring]
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:latest
|
image: grafana/grafana:latest
|
||||||
container_name: grafana
|
container_name: grafana
|
||||||
@@ -93,6 +109,7 @@ services:
|
|||||||
networks: [monitoring]
|
networks: [monitoring]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
pushgateway_data:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
alertmanager_data:
|
alertmanager_data:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -35,3 +35,9 @@ scrape_configs:
|
|||||||
- job_name: openvpn
|
- job_name: openvpn
|
||||||
file_sd_configs:
|
file_sd_configs:
|
||||||
- files: ["/etc/prometheus/targets/openvpn/*.yml"]
|
- files: ["/etc/prometheus/targets/openvpn/*.yml"]
|
||||||
|
|
||||||
|
# honor_labels: сохраняем job/instance, с которыми хост запушил, а не pushgateway:9091
|
||||||
|
- job_name: pushgateway
|
||||||
|
honor_labels: true
|
||||||
|
static_configs:
|
||||||
|
- targets: ["pushgateway:9091"]
|
||||||
|
|||||||
@@ -35,3 +35,125 @@ groups:
|
|||||||
labels: {severity: critical}
|
labels: {severity: critical}
|
||||||
annotations:
|
annotations:
|
||||||
summary: "КРИТИЧНО: диск почти полон на {{ $labels.instance }} ({{ $labels.mountpoint }}): свободно {{ printf \"%.0f\" $value }}%"
|
summary: "КРИТИЧНО: диск почти полон на {{ $labels.instance }} ({{ $labels.mountpoint }}): свободно {{ printf \"%.0f\" $value }}%"
|
||||||
|
|
||||||
|
# Метрики от rpi приходят через pushgateway (SSH-пуш, см. servers/rpi.md).
|
||||||
|
# push_time_seconds — когда группа последний раз обновлялась.
|
||||||
|
- name: rpi
|
||||||
|
rules:
|
||||||
|
- alert: PushMetricsStale
|
||||||
|
expr: time() - push_time_seconds{job=~"smart|backup_.*"} > 3 * 3600
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: метрики {{ $labels.job }} не приходят {{ $value | humanizeDuration }} — хост/пуш лежит"
|
||||||
|
|
||||||
|
- alert: SmartHealthFailed
|
||||||
|
expr: smart_health_passed == 0
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: SMART FAILED на диске {{ $labels.model }} ({{ $labels.serial }}) — менять диск"
|
||||||
|
|
||||||
|
- alert: SmartBadSectors
|
||||||
|
expr: smart_attribute_raw{id=~"5|197|198"} > 0
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: {{ $labels.name }} = {{ $value }} на {{ $labels.model }} ({{ $labels.serial }}) — диск начал сыпаться"
|
||||||
|
|
||||||
|
- alert: SmartBadSectorsGrowing
|
||||||
|
expr: increase(smart_attribute_raw{id=~"5|197|198"}[1d]) > 0
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: {{ $labels.name }} растёт на {{ $labels.model }} — срочно вторую копию"
|
||||||
|
|
||||||
|
- alert: SmartUdmaCrcErrors
|
||||||
|
expr: increase(smart_attribute_raw{id="199"}[1d]) > 0
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: CRC-ошибки на {{ $labels.model }} — кабель/USB/питание"
|
||||||
|
|
||||||
|
- alert: SmartSelftestFailed
|
||||||
|
expr: smart_selftest_last_passed == 0
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: самотест SMART провален на {{ $labels.model }} ({{ $labels.serial }})"
|
||||||
|
|
||||||
|
- alert: SmartTemperatureHigh
|
||||||
|
expr: smart_temperature_celsius > 55
|
||||||
|
for: 15m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: диск {{ $labels.model }} греется: {{ $value }}°C"
|
||||||
|
|
||||||
|
- alert: BackupFailed
|
||||||
|
expr: backup_last_exit_code != 0
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: {{ $labels.job }} завершился с кодом {{ $value }} — см. journalctl на хосте"
|
||||||
|
|
||||||
|
# sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней; бэкап серверов ежедневно → 26ч; копия на роутер ежедневно → 30ч (первичная ~13ч)
|
||||||
|
- alert: BackupStale
|
||||||
|
expr: |
|
||||||
|
(time() - backup_last_success_timestamp{job="backup_onedrive"} > 12 * 3600)
|
||||||
|
or (time() - backup_last_success_timestamp{job="backup_onedrive_verify"} > 8 * 86400)
|
||||||
|
or (time() - backup_last_success_timestamp{job="backup_servers"} > 26 * 3600)
|
||||||
|
or (time() - backup_last_success_timestamp{job="backup_router"} > 30 * 3600)
|
||||||
|
or (time() - backup_last_success_timestamp{job="backup_router_sample"} > 35 * 86400)
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: {{ $labels.job }} не было успешного прогона {{ $value | humanizeDuration }}"
|
||||||
|
|
||||||
|
# /mnt/backup (HDD 4 ТБ) и /mnt/router (1 ТБ диск в роутере) — заполнение
|
||||||
|
- alert: BackupDiskFull
|
||||||
|
expr: 1 - backup_disk_avail_bytes / backup_disk_size_bytes > 0.9
|
||||||
|
for: 15m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: диск бэкапа {{ $labels.mountpoint }} заполнен на {{ $value | humanizePercentage }}"
|
||||||
|
|
||||||
|
- alert: RouterDiskMountDown
|
||||||
|
expr: router_mount_up == 0
|
||||||
|
for: 30m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: диск в роутере (/mnt/router, SFTP) недоступен 30+ мин — вторая копия бэкапа не пишется"
|
||||||
|
|
||||||
|
# BackupStale видит только существующие серии: если задача ни разу не отчиталась
|
||||||
|
# (таймер сломан, первый прогон упал до хука) — серии нет, и он молчит. Ловим отсутствие.
|
||||||
|
- alert: BackupNeverReported
|
||||||
|
expr: |
|
||||||
|
absent(backup_last_success_timestamp{instance="rpi",job="backup_onedrive"})
|
||||||
|
for: 12h
|
||||||
|
labels: {severity: critical, instance: rpi, job: backup_onedrive}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: синхронизация OneDrive ни разу не отчиталась об успехе за 12+ ч — проверь onedrive-backup"
|
||||||
|
- alert: BackupNeverReported
|
||||||
|
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_servers"})
|
||||||
|
for: 26h
|
||||||
|
labels: {severity: critical, instance: rpi, job: backup_servers}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: бэкап серверов fr1/ru1 не отчитывался 26+ ч — проверь servers-backup"
|
||||||
|
- alert: BackupNeverReported
|
||||||
|
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_router"})
|
||||||
|
for: 30h
|
||||||
|
labels: {severity: critical, instance: rpi, job: backup_router}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: копия на диск роутера не отчитывалась 30+ ч — проверь router-backup"
|
||||||
|
- alert: BackupNeverReported
|
||||||
|
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_onedrive_verify"})
|
||||||
|
for: 8d
|
||||||
|
labels: {severity: warning, instance: rpi, job: backup_onedrive_verify}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: проверка целостности OneDrive не отчитывалась 8+ дней — проверь onedrive-verify"
|
||||||
|
- alert: PushMetricsStale
|
||||||
|
expr: absent(push_time_seconds{instance="rpi",job="smart"})
|
||||||
|
for: 1h
|
||||||
|
labels: {severity: warning, instance: rpi, job: smart}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: метрики SMART в pushgateway отсутствуют 1+ ч — малина/пуш лежат или pushgateway потерял данные"
|
||||||
|
- alert: BackupNeverReported
|
||||||
|
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_router_sample"})
|
||||||
|
for: 35d
|
||||||
|
labels: {severity: warning, instance: rpi, job: backup_router_sample}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: ежемесячная перечитка файлов с диска роутера не отчитывалась 35+ дней — проверь router-sample"
|
||||||
|
|||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Forced command для пуш-ключей хостов за NAT (authorized_keys: command="~/bin/pushgw-ingest <instance>").
|
||||||
|
# Клиент передаёт только имя job (ssh ... <job>), метрики — в stdin в text-формате Prometheus.
|
||||||
|
# instance задаётся ключом, а не клиентом: хост не может писать метрики от чужого имени.
|
||||||
|
instance="$1"
|
||||||
|
job="$SSH_ORIGINAL_COMMAND"
|
||||||
|
case "$instance" in ""|*[!a-z0-9_-]*) echo "bad instance" >&2; exit 2;; esac
|
||||||
|
case "$job" in ""|*[!a-z0-9_]*) echo "bad job" >&2; exit 2;; esac
|
||||||
|
exec curl -sS --fail-with-body --max-time 30 -X PUT --data-binary @- \
|
||||||
|
"http://127.0.0.1:9091/metrics/job/$job/instance/$instance"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# backup-metrics <job> — из ExecStopPost юнита бэкапа: пушит итог прогона (job=backup_<name>).
|
||||||
|
# last_success хранится локально: PUT в pushgateway заменяет группу целиком,
|
||||||
|
# и после неудачи время последнего успеха иначе бы потерялось.
|
||||||
|
set -uo pipefail
|
||||||
|
job="$1"; state=/var/lib/backup-metrics; mkdir -p "$state"
|
||||||
|
now=$(date +%s)
|
||||||
|
code=${EXIT_STATUS:-1}; [ "${SERVICE_RESULT:-}" = success ] && code=0
|
||||||
|
[ "$code" = 0 ] && echo "$now" > "$state/$job.success"
|
||||||
|
last_ok=$(cat "$state/$job.success" 2>/dev/null || echo 0)
|
||||||
|
metrics-push "$job" <<M
|
||||||
|
# TYPE backup_last_exit_code gauge
|
||||||
|
backup_last_exit_code $code
|
||||||
|
backup_last_run_timestamp $now
|
||||||
|
backup_last_success_timestamp $last_ok
|
||||||
|
M
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# metrics-push <job> < metrics.prom — отправка в pushgateway на fr1.
|
||||||
|
# На fr1 ключ привязан к forced command ~/bin/pushgw-ingest rpi: можно только запушить, больше ничего.
|
||||||
|
exec ssh -i /etc/metrics-push/id_ed25519 \
|
||||||
|
-o UserKnownHostsFile=/etc/metrics-push/known_hosts -o StrictHostKeyChecking=yes \
|
||||||
|
-o BatchMode=yes -o ConnectTimeout=20 \
|
||||||
|
rus@161.97.93.252 "$1"
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# OneDrive -> /mnt/backup. Ничего не удаляется навсегда:
|
||||||
|
# current/ — точное зеркало OneDrive
|
||||||
|
# archive/<время>/ — всё, что в OneDrive удалили или перезаписали (хранится вечно)
|
||||||
|
# manifests/ — quickxor-хэши архивных файлов (для onedrive-verify)
|
||||||
|
# logs/ — журнал каждого прогона: какие файлы скачаны/изменены/убраны в архив
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT=/mnt/backup/onedrive
|
||||||
|
TS=$(date +%F_%H%M)
|
||||||
|
|
||||||
|
mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован — пропуск"; exit 1; }
|
||||||
|
mkdir -p "$ROOT"/{current,archive,manifests,logs}
|
||||||
|
LOG="$ROOT/logs/sync-$TS.log"
|
||||||
|
|
||||||
|
# rclone сам сверяет хэш каждого скачанного файла с хэшем OneDrive
|
||||||
|
rclone sync onedrive: "$ROOT/current" \
|
||||||
|
--backup-dir "$ROOT/archive/$TS" \
|
||||||
|
--exclude "/Personal Vault/**" \
|
||||||
|
--transfers 4 --checkers 8 \
|
||||||
|
--retries 5 --low-level-retries 20 \
|
||||||
|
--log-file "$LOG" --log-level INFO \
|
||||||
|
--stats 10m --stats-one-line
|
||||||
|
|
||||||
|
if [ -d "$ROOT/archive/$TS" ]; then
|
||||||
|
rclone hashsum quickxor "$ROOT/archive/$TS" > "$ROOT/manifests/archive-$TS.qxh"
|
||||||
|
echo "в архив ушло: $(wc -l < "$ROOT/manifests/archive-$TS.qxh") файлов"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: current=$(du -sh "$ROOT/current" | cut -f1), диск занят на $(df --output=pcent /mnt/backup | tail -1 | tr -d " ")"
|
||||||
|
[ "$(df --output=pcent /mnt/backup | tail -1 | tr -dc 0-9)" -lt 90 ] || { echo "ВНИМАНИЕ: диск заполнен >90%"; exit 2; }
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Еженедельная проверка целостности: перечитывает весь HDD и сверяет хэши
|
||||||
|
# current/ — с хэшами OneDrive (ловит битые/недокачанные/лишние файлы)
|
||||||
|
# archive/ — с сохранёнными манифестами (ловит порчу данных на диске)
|
||||||
|
set -uo pipefail
|
||||||
|
ROOT=/mnt/backup/onedrive
|
||||||
|
TS=$(date +%F_%H%M)
|
||||||
|
LOG="$ROOT/logs/verify-$TS.log"
|
||||||
|
mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован — пропуск"; exit 1; }
|
||||||
|
rc=0
|
||||||
|
|
||||||
|
rclone check onedrive: "$ROOT/current" --exclude "/Personal Vault/**" \
|
||||||
|
--checkers 8 --combined "$ROOT/logs/verify-$TS.current.txt" \
|
||||||
|
--log-file "$LOG" --log-level NOTICE || rc=1
|
||||||
|
|
||||||
|
for m in "$ROOT"/manifests/archive-*.qxh; do
|
||||||
|
[ -e "$m" ] || continue
|
||||||
|
d=$(basename "$m" .qxh); d=${d#archive-}
|
||||||
|
rclone checksum quickxor "$m" "$ROOT/archive/$d" --log-file "$LOG" --log-level NOTICE || { echo "архив $d: несовпадение"; rc=1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
grep -vc "^=" "$ROOT/logs/verify-$TS.current.txt" | xargs echo "расхождений в current:"
|
||||||
|
[ $rc -eq 0 ] && echo "OK: целостность подтверждена" || echo "ЕСТЬ РАСХОЖДЕНИЯ — см. $LOG и verify-$TS.current.txt"
|
||||||
|
exit $rc
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Вторая копия /mnt/backup на 1 ТБ диск в роутере Huawei (SFTP :8022, remote `router:` в
|
||||||
|
# ~rus/.config/rclone/rclone.conf). Диск питается от роутера — не нагружает USB малины.
|
||||||
|
#
|
||||||
|
# router-backup run — ежедневно (таймер 06:00)
|
||||||
|
# router-backup verify — по воскресеньям после run: размеры + перечитать свежие архивы серверов по sha256
|
||||||
|
# router-backup sample — ежемесячно: случайные файлы OneDrive (~2 ГБ) перечитать с роутера и сверить sha256
|
||||||
|
#
|
||||||
|
# Пишем прямо в remote, а не через FUSE /mnt/router: так надёжнее и есть коды ошибок.
|
||||||
|
# Роутер не умеет хэши (shell_type=none) → сравнение по размеру+mtime, целостность — перечиткой.
|
||||||
|
set -uo pipefail
|
||||||
|
C=/home/rus/.config/rclone/rclone.conf
|
||||||
|
R=router:rpi-backup
|
||||||
|
SRC=/mnt/backup
|
||||||
|
TS=$(date +%F_%H%M)
|
||||||
|
LOGDIR=$SRC/router-logs; mkdir -p "$LOGDIR"
|
||||||
|
LOG="$LOGDIR/router-$TS.log"
|
||||||
|
rc() { rclone --config "$C" --log-file "$LOG" --log-level INFO --stats 10m --stats-log-level NOTICE \
|
||||||
|
--transfers 2 --checkers 4 --sftp-concurrency 8 --retries 5 --low-level-retries 20 "$@"; }
|
||||||
|
|
||||||
|
mountpoint -q "$SRC" || { echo "$SRC не смонтирован — пропуск"; exit 1; }
|
||||||
|
rclone --config "$C" lsd router: >/dev/null 2>&1 || { echo "роутер недоступен по SFTP"; exit 1; }
|
||||||
|
|
||||||
|
run() {
|
||||||
|
local r=0
|
||||||
|
# 1) то, что никогда не удаляется: архив версий, манифесты, логи — только copy
|
||||||
|
for d in archive manifests logs; do
|
||||||
|
[ -d "$SRC/onedrive/$d" ] && { rc copy "$SRC/onedrive/$d" "$R/onedrive/$d" || r=1; }
|
||||||
|
done
|
||||||
|
# 2) зеркало current. Удалённое из current локально уже лежит в archive (скопированном выше).
|
||||||
|
# --max-delete: если локально «пропало» много файлов (сбой диска/монтирования) — стоп, а не зачистка копии
|
||||||
|
rc sync "$SRC/onedrive/current" "$R/onedrive/current" --exclude '*.partial' --max-delete 1000 || r=1
|
||||||
|
# 3) зашифрованные бэкапы серверов — зеркало (ротация 14/8/12 → удаляется пара файлов в день)
|
||||||
|
rc sync "$SRC/servers" "$R/servers" --max-delete 50 || r=1
|
||||||
|
echo "run: $([ $r = 0 ] && echo OK || echo 'ЕСТЬ ОШИБКИ') — лог $LOG"
|
||||||
|
return $r
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
local r=0 s rel want got
|
||||||
|
# размеры всего дерева (только листинг, без чтения данных)
|
||||||
|
rc check "$SRC/onedrive/current" "$R/onedrive/current" --size-only --one-way --exclude '*.partial' || r=1
|
||||||
|
rc check "$SRC/servers" "$R/servers" --size-only --one-way || r=1
|
||||||
|
# перечитать с роутера последний daily каждого сервера и сверить с sha256
|
||||||
|
for s in $(ls -1t "$SRC"/servers/*/daily/*.age.sha256 2>/dev/null | awk -F/ '!seen[$(NF-2)]++'); do
|
||||||
|
rel=${s#"$SRC/servers/"}; rel=${rel%.sha256}
|
||||||
|
want=$(cut -d' ' -f1 "$s")
|
||||||
|
got=$(rclone --config "$C" cat "$R/servers/$rel" 2>>"$LOG" | sha256sum | cut -d' ' -f1)
|
||||||
|
if [ "$want" = "$got" ]; then echo "read-back OK $rel"; else echo "read-back BAD $rel"; r=1; fi
|
||||||
|
done
|
||||||
|
echo "verify: $([ $r = 0 ] && echo OK || echo 'ЕСТЬ РАСХОЖДЕНИЯ')"
|
||||||
|
return $r
|
||||||
|
}
|
||||||
|
|
||||||
|
sample() {
|
||||||
|
# Роутер хэшей не считает, verify сверяет OneDrive только по размеру — тихую порчу внутри
|
||||||
|
# файла того же размера ловит только перечитка. Берём файлы старше суток (точно должны
|
||||||
|
# быть скопированы), до SAMPLE_BYTES суммарно и не больше SAMPLE_MAX штук.
|
||||||
|
local budget=${SAMPLE_BYTES:-2147483648} max=${SAMPLE_MAX:-300} total=0 n=0 bad=0 miss=0 size rel want got
|
||||||
|
while IFS=' ' read -r size rel; do
|
||||||
|
[ $n -ge $max ] && break
|
||||||
|
[ $total -gt 0 ] && [ $((total + size)) -gt $budget ] && continue
|
||||||
|
want=$(sha256sum "$SRC/onedrive/$rel" | cut -d' ' -f1)
|
||||||
|
if ! got=$(rclone --config "$C" cat "$R/onedrive/$rel" 2>>"$LOG" | sha256sum | cut -d' ' -f1) \
|
||||||
|
|| [ "$got" = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -a "$size" != 0 ]; then
|
||||||
|
echo "MISSING $rel"; miss=$((miss + 1))
|
||||||
|
elif [ "$want" != "$got" ]; then
|
||||||
|
echo "BAD $rel"; bad=$((bad + 1))
|
||||||
|
fi
|
||||||
|
n=$((n + 1)); total=$((total + size))
|
||||||
|
done < <(cd "$SRC/onedrive" && find current archive -type f ! -name '*.partial' -mmin +1440 -printf '%s %p\n' | shuf)
|
||||||
|
echo "sample: проверено $n файлов, $((total / 1048576)) МиБ; повреждено $bad, нет на роутере $miss"
|
||||||
|
[ $bad = 0 ] && { [ $miss = 0 ] || [ -n "${SAMPLE_ALLOW_MISSING:-}" ]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-run}" in
|
||||||
|
run) run; r=$?; [ "$(date +%u)" = 7 ] && { verify || r=1; }; exit $r ;;
|
||||||
|
verify) verify ;;
|
||||||
|
sample) sample ;;
|
||||||
|
*) echo "usage: router-backup run|verify|sample" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# SMART всех USB/SATA-дисков → pushgateway (job=smart).
|
||||||
|
# -n standby: спящий диск НЕ будим (иначе опрос раз в 15 мин не дал бы ему спать) —
|
||||||
|
# для него отдаём последние снятые значения из кэша и smart_disk_standby=1.
|
||||||
|
set -uo pipefail
|
||||||
|
CACHE=/var/lib/smart-metrics; mkdir -p "$CACHE"
|
||||||
|
out=$(mktemp); trap 'rm -f "$out"' EXIT
|
||||||
|
echo "# TYPE smart_disk_standby gauge" >> "$out"
|
||||||
|
for dev in /dev/sd?; do
|
||||||
|
[ -b "$dev" ] || continue
|
||||||
|
json=$(smartctl -d sat -n standby,7 -j -H -i -A -l selftest "$dev"); rc=$?
|
||||||
|
serial=$(udevadm info -q property -n "$dev" | sed -n 's/^ID_SERIAL_SHORT=//p')
|
||||||
|
[ -n "$serial" ] || continue
|
||||||
|
f="$CACHE/$serial.prom"
|
||||||
|
if [ $rc -eq 7 ]; then
|
||||||
|
standby=1
|
||||||
|
else
|
||||||
|
standby=0
|
||||||
|
echo "$json" | jq -r --arg dev "${dev#/dev/}" '
|
||||||
|
(.model_name // "unknown") as $m | (.serial_number // "unknown") as $s |
|
||||||
|
"{model=\"\($m)\",serial=\"\($s)\",device=\"\($dev)\"" as $l |
|
||||||
|
"smart_health_passed\($l)} \(if .smart_status.passed then 1 else 0 end)",
|
||||||
|
"smart_temperature_celsius\($l)} \(.temperature.current // "NaN")",
|
||||||
|
"smart_power_on_hours\($l)} \(.power_on_time.hours // "NaN")",
|
||||||
|
(.ata_smart_self_test_log.standard.table[0]? // empty |
|
||||||
|
"smart_selftest_last_passed\($l)} \(if .status.passed then 1 else 0 end)"),
|
||||||
|
(.ata_smart_attributes.table[]? |
|
||||||
|
"smart_attribute_raw\($l),id=\"\(.id)\",name=\"\(.name)\"} \(.raw.value)",
|
||||||
|
"smart_attribute_value\($l),id=\"\(.id)\",name=\"\(.name)\"} \(.value)"),
|
||||||
|
"smart_last_read_timestamp\($l)} \(now|floor)"
|
||||||
|
' > "$f.tmp" && [ -s "$f.tmp" ] && mv "$f.tmp" "$f"
|
||||||
|
fi
|
||||||
|
[ -f "$f" ] && cat "$f" >> "$out"
|
||||||
|
echo "smart_disk_standby{serial=\"$serial\",device=\"${dev#/dev/}\"} $standby" >> "$out"
|
||||||
|
done
|
||||||
|
# заполнение диска бэкапа (statfs — из памяти, спящий диск не будит)
|
||||||
|
if mountpoint -q /mnt/backup; then
|
||||||
|
read -r size avail < <(df -B1 --output=size,avail /mnt/backup | tail -1)
|
||||||
|
echo "backup_disk_size_bytes{mountpoint=\"/mnt/backup\"} $size" >> "$out"
|
||||||
|
echo "backup_disk_avail_bytes{mountpoint=\"/mnt/backup\"} $avail" >> "$out"
|
||||||
|
fi
|
||||||
|
# диск в роутере (rclone SFTP-монтирование /mnt/router): жив ли и сколько занято.
|
||||||
|
# timeout — зависший FUSE не должен вешать сбор метрик
|
||||||
|
echo "# TYPE router_mount_up gauge" >> "$out"
|
||||||
|
if mountpoint -q /mnt/router && r=$(timeout 20 df -B1 --output=size,avail /mnt/router 2>/dev/null | tail -1) && [ -n "$r" ]; then
|
||||||
|
read -r size avail <<< "$r"
|
||||||
|
echo "router_mount_up 1" >> "$out"
|
||||||
|
echo "backup_disk_size_bytes{mountpoint=\"/mnt/router\"} $size" >> "$out"
|
||||||
|
echo "backup_disk_avail_bytes{mountpoint=\"/mnt/router\"} $avail" >> "$out"
|
||||||
|
else
|
||||||
|
echo "router_mount_up 0" >> "$out"
|
||||||
|
fi
|
||||||
|
metrics-push smart < "$out"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UUID=6eea3421-da3f-4a0f-b66d-26000b9a1b01 /mnt/backup ext4 defaults,noatime,nofail,x-systemd.device-timeout=30s 0 2
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# smartd: следит за всеми дисками (DEVICESCAN подхватит и второй диск после хаба).
|
||||||
|
# -d removable — тип моста определяется сам; диска может не быть (не падать)
|
||||||
|
# -n standby,q — не будить спящий диск ради опроса
|
||||||
|
# -s — короткий самотест по субботам 03:00, длинный (~10ч на 4ТБ) 1-го числа в 05:00
|
||||||
|
# -W 4,50,55 — фиксировать скачки температуры ≥4°, порог предупреждения 50, критичный 55
|
||||||
|
# Почту не шлём (-m нет): тревоги идут через Prometheus на fr1 (smart-metrics → pushgateway).
|
||||||
|
DEVICESCAN -d removable -a -o on -S on -n standby,q -s (S/../../6/03|L/../01/./05) -W 4,50,55
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OneDrive -> HDD backup (rclone)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
RequiresMountsFor=/mnt/backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=rus
|
||||||
|
ExecStart=/usr/bin/flock /run/lock/onedrive-backup.lock /usr/local/bin/onedrive-backup
|
||||||
|
ExecStopPost=+/usr/local/bin/backup-metrics backup_onedrive
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
TimeoutStartSec=infinity
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OneDrive backup every 4h
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00/4:30
|
||||||
|
RandomizedDelaySec=5m
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OneDrive backup integrity check
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target onedrive-backup.service
|
||||||
|
RequiresMountsFor=/mnt/backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=rus
|
||||||
|
ExecStart=/usr/bin/flock /run/lock/onedrive-backup.lock /usr/local/bin/onedrive-verify
|
||||||
|
ExecStopPost=+/usr/local/bin/backup-metrics backup_onedrive_verify
|
||||||
|
Nice=15
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
TimeoutStartSec=infinity
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Weekly OneDrive backup integrity check
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Sun *-*-* 05:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Second copy of /mnt/backup to router USB disk (rclone SFTP)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
RequiresMountsFor=/mnt/backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/flock -n /run/lock/router-backup.lock /usr/local/bin/router-backup run
|
||||||
|
ExecStopPost=/usr/local/bin/backup-metrics backup_router
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
# первичная копия ~370 ГБ при ~8 МБ/с — около 13 ч
|
||||||
|
TimeoutStartSec=infinity
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily copy of /mnt/backup to router disk
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 06:00
|
||||||
|
RandomizedDelaySec=10m
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Monthly random read-back of OneDrive files from router disk (sha256)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
RequiresMountsFor=/mnt/backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# ждёт, если идёт router-backup (общий lock), а не падает
|
||||||
|
ExecStart=/usr/bin/flock /run/lock/router-backup.lock /usr/local/bin/router-backup sample
|
||||||
|
ExecStopPost=/usr/local/bin/backup-metrics backup_router_sample
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
TimeoutStartSec=6h
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Monthly router disk read-back sample
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-01 08:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Push SMART metrics to pushgateway on fr1
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/smart-metrics
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SMART metrics push every 15 min
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=15min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Binary file not shown.
@@ -0,0 +1,130 @@
|
|||||||
|
# Медиацентр на rpi. Разворачивается в /opt/media (docker compose up -d).
|
||||||
|
# Подробности и грабли — servers/rpi.md → «Медиацентр».
|
||||||
|
#
|
||||||
|
# Все *arr и qBittorrent видят один и тот же корень /data (= /mnt/backup/media),
|
||||||
|
# иначе импорт из torrents/ в library/ превращается в копирование вместо хардлинка
|
||||||
|
# и раздача занимает место дважды.
|
||||||
|
|
||||||
|
x-common: &common
|
||||||
|
restart: unless-stopped
|
||||||
|
environment: &env
|
||||||
|
PUID: "1000"
|
||||||
|
PGID: "1000"
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
UMASK: "002"
|
||||||
|
|
||||||
|
services:
|
||||||
|
jellyfin:
|
||||||
|
image: jellyfin/jellyfin:latest
|
||||||
|
container_name: jellyfin
|
||||||
|
# host-сеть: иначе LG/iOS не находят сервер автообнаружением (UDP 7359 и DLNA-мультикаст
|
||||||
|
# не проходят через docker-бридж)
|
||||||
|
network_mode: host
|
||||||
|
user: "1000:1000"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
JELLYFIN_PublishedServerUrl: http://192.168.10.25:8096
|
||||||
|
volumes:
|
||||||
|
- ./config/jellyfin:/config
|
||||||
|
# кэш и транскоды — на HDD, а не на SD
|
||||||
|
- /mnt/backup/media/.cache/jellyfin:/cache
|
||||||
|
- /mnt/backup/media/library:/data/library:ro
|
||||||
|
|
||||||
|
qbittorrent:
|
||||||
|
<<: *common
|
||||||
|
image: lscr.io/linuxserver/qbittorrent:latest
|
||||||
|
container_name: qbittorrent
|
||||||
|
environment:
|
||||||
|
<<: *env
|
||||||
|
WEBUI_PORT: "8080"
|
||||||
|
TORRENTING_PORT: "6881"
|
||||||
|
volumes:
|
||||||
|
- ./config/qbittorrent:/config
|
||||||
|
- /mnt/backup/media:/data
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "6881:6881"
|
||||||
|
- "6881:6881/udp"
|
||||||
|
|
||||||
|
prowlarr:
|
||||||
|
<<: *common
|
||||||
|
image: lscr.io/linuxserver/prowlarr:latest
|
||||||
|
container_name: prowlarr
|
||||||
|
volumes:
|
||||||
|
- ./config/prowlarr:/config
|
||||||
|
ports:
|
||||||
|
- "9696:9696"
|
||||||
|
|
||||||
|
flaresolverr:
|
||||||
|
# нужен Prowlarr для трекеров за Cloudflare (часть публичных)
|
||||||
|
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||||
|
container_name: flaresolverr
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
LOG_LEVEL: info
|
||||||
|
ports:
|
||||||
|
- "8191:8191"
|
||||||
|
|
||||||
|
sonarr:
|
||||||
|
<<: *common
|
||||||
|
image: lscr.io/linuxserver/sonarr:latest
|
||||||
|
container_name: sonarr
|
||||||
|
volumes:
|
||||||
|
- ./config/sonarr:/config
|
||||||
|
- /mnt/backup/media:/data
|
||||||
|
ports:
|
||||||
|
- "8989:8989"
|
||||||
|
|
||||||
|
radarr:
|
||||||
|
<<: *common
|
||||||
|
image: lscr.io/linuxserver/radarr:latest
|
||||||
|
container_name: radarr
|
||||||
|
volumes:
|
||||||
|
- ./config/radarr:/config
|
||||||
|
- /mnt/backup/media:/data
|
||||||
|
ports:
|
||||||
|
- "7878:7878"
|
||||||
|
|
||||||
|
bazarr:
|
||||||
|
<<: *common
|
||||||
|
image: lscr.io/linuxserver/bazarr:latest
|
||||||
|
container_name: bazarr
|
||||||
|
volumes:
|
||||||
|
- ./config/bazarr:/config
|
||||||
|
- /mnt/backup/media/library:/data/library
|
||||||
|
ports:
|
||||||
|
- "6767:6767"
|
||||||
|
|
||||||
|
seerr:
|
||||||
|
# Seerr = слившиеся Jellyseerr + Overseerr. ⚠️ образ fallenbagel/jellyseerr заброшен
|
||||||
|
# (последний — 2025-08) и не логинится в Jellyfin 12 (400 на AuthenticateByName)
|
||||||
|
image: ghcr.io/seerr-team/seerr:latest
|
||||||
|
container_name: seerr
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
volumes:
|
||||||
|
- ./config/seerr:/app/config
|
||||||
|
ports:
|
||||||
|
- "5055:5055"
|
||||||
|
|
||||||
|
homepage:
|
||||||
|
# стартовая страница со всеми сервисами, описаниями и живым статусом
|
||||||
|
image: ghcr.io/gethomepage/homepage:latest
|
||||||
|
container_name: homepage
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: ./homepage.env # HOMEPAGE_VAR_* — API-ключи и пароль qBittorrent, rus 600, только на малине
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
PUID: "1000"
|
||||||
|
PGID: "1000"
|
||||||
|
# без этого отдаёт 400 на любой Host, кроме localhost
|
||||||
|
HOMEPAGE_ALLOWED_HOSTS: 192.168.10.25:3000,rpi.local:3000,localhost:3000
|
||||||
|
volumes:
|
||||||
|
- ./homepage:/app/config
|
||||||
|
- /mnt/backup:/mnt/backup:ro # только чтобы виджет resources видел заполнение диска
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Ключи — из /opt/media/homepage.env (HOMEPAGE_VAR_*), в репо их нет.
|
||||||
|
# Ссылки (href) — для браузера, адрес малины; виджеты ходят изнутри docker-сети по именам
|
||||||
|
# контейнеров, кроме Jellyfin (он в host-сети).
|
||||||
|
|
||||||
|
- Как это работает:
|
||||||
|
- 1. Найти:
|
||||||
|
icon: mdi-magnify
|
||||||
|
description: "Открой Seerr, найди фильм или сериал (можно по-русски) и нажми Request"
|
||||||
|
- 2. Скачать:
|
||||||
|
icon: mdi-download
|
||||||
|
description: "Radarr/Sonarr сами ищут раздачу на трекерах, qBittorrent качает. Минуты–час"
|
||||||
|
- 3. Смотреть:
|
||||||
|
icon: mdi-television-play
|
||||||
|
description: "Готовое появляется в Jellyfin; субтитры ru/en Bazarr докачает сам"
|
||||||
|
- Если застряло:
|
||||||
|
icon: mdi-help-circle-outline
|
||||||
|
description: "Radarr/Sonarr → Activity → Queue; красная карточка = раздачи нет → Interactive Search"
|
||||||
|
|
||||||
|
- Смотреть:
|
||||||
|
- Jellyfin:
|
||||||
|
icon: jellyfin.svg
|
||||||
|
href: http://192.168.10.25:8096
|
||||||
|
description: "Медиатека. На LG — приложение Jellyfin, на iPhone/iPad — Infuse или Swiftfin, сервер 192.168.10.25:8096"
|
||||||
|
# ⚠️ встроенный виджет jellyfin (Homepage 2.5) ходит на /emby/…?api_key= — в Jellyfin 12 этого
|
||||||
|
# пути нет (404), а ключ в query отключён (EnableLegacyAuthorization=false). Поэтому customapi.
|
||||||
|
widget:
|
||||||
|
type: customapi
|
||||||
|
url: http://192.168.10.25:8096/Items/Counts
|
||||||
|
headers:
|
||||||
|
Authorization: 'MediaBrowser Token="{{HOMEPAGE_VAR_JELLYFIN_KEY}}"'
|
||||||
|
mappings:
|
||||||
|
- field: MovieCount
|
||||||
|
label: Фильмы
|
||||||
|
format: number
|
||||||
|
- field: SeriesCount
|
||||||
|
label: Сериалы
|
||||||
|
format: number
|
||||||
|
- field: EpisodeCount
|
||||||
|
label: Серии
|
||||||
|
format: number
|
||||||
|
- Seerr:
|
||||||
|
icon: jellyseerr.svg
|
||||||
|
href: http://192.168.10.25:5055
|
||||||
|
description: "Каталог «хочу посмотреть»: Request → скачается само. Статусы: Processing → Available"
|
||||||
|
widget:
|
||||||
|
type: jellyseerr
|
||||||
|
url: http://seerr:5055
|
||||||
|
key: "{{HOMEPAGE_VAR_SEERR_KEY}}"
|
||||||
|
|
||||||
|
- Добавить фильм или сериал:
|
||||||
|
- Radarr — фильмы:
|
||||||
|
icon: radarr.svg
|
||||||
|
href: http://192.168.10.25:7878
|
||||||
|
description: "Очередь: Activity → Queue. Выбрать раздачу вручную: карточка → Interactive Search"
|
||||||
|
widget:
|
||||||
|
type: radarr
|
||||||
|
url: http://radarr:7878
|
||||||
|
key: "{{HOMEPAGE_VAR_RADARR_KEY}}"
|
||||||
|
- Sonarr — сериалы:
|
||||||
|
icon: sonarr.svg
|
||||||
|
href: http://192.168.10.25:8989
|
||||||
|
description: "Новые серии отслеживаемых сериалов качает сам. Очередь: Activity → Queue"
|
||||||
|
widget:
|
||||||
|
type: sonarr
|
||||||
|
url: http://sonarr:8989
|
||||||
|
key: "{{HOMEPAGE_VAR_SONARR_KEY}}"
|
||||||
|
- Bazarr — субтитры:
|
||||||
|
icon: bazarr.svg
|
||||||
|
href: http://192.168.10.25:6767
|
||||||
|
description: "Субтитры ru/en для всего в библиотеке; чего не хватает — Wanted"
|
||||||
|
widget:
|
||||||
|
type: bazarr
|
||||||
|
url: http://bazarr:6767
|
||||||
|
key: "{{HOMEPAGE_VAR_BAZARR_KEY}}"
|
||||||
|
|
||||||
|
- Загрузки и служебное:
|
||||||
|
- qBittorrent:
|
||||||
|
icon: qbittorrent.svg
|
||||||
|
href: http://192.168.10.25:8080
|
||||||
|
description: "Сами загрузки: скорость, сиды. Торренты вручную сюда — мимо Jellyfin и контроля места"
|
||||||
|
widget:
|
||||||
|
type: qbittorrent
|
||||||
|
url: http://qbittorrent:8080
|
||||||
|
username: "{{HOMEPAGE_VAR_QBIT_USER}}"
|
||||||
|
password: "{{HOMEPAGE_VAR_QBIT_PASS}}"
|
||||||
|
enableLeechProgress: true
|
||||||
|
- Prowlarr — трекеры:
|
||||||
|
icon: prowlarr.svg
|
||||||
|
href: http://192.168.10.25:9696
|
||||||
|
description: "RuTracker, NoNaMe Club, TPB, YTS… Ручной поиск по всем трекерам сразу"
|
||||||
|
widget:
|
||||||
|
type: prowlarr
|
||||||
|
url: http://prowlarr:9696
|
||||||
|
key: "{{HOMEPAGE_VAR_PROWLARR_KEY}}"
|
||||||
|
- FlareSolverr:
|
||||||
|
icon: flaresolverr.svg
|
||||||
|
href: http://192.168.10.25:8191
|
||||||
|
description: "Проходит проверку Cloudflare для RuTracker (на Pi — минуты). Трогать не нужно"
|
||||||
|
siteMonitor: http://flaresolverr:8191
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Homepage — стартовая страница медиацентра, http://192.168.10.25:3000
|
||||||
|
title: Домашний медиацентр
|
||||||
|
language: ru
|
||||||
|
theme: dark
|
||||||
|
color: slate
|
||||||
|
headerStyle: clean
|
||||||
|
hideVersion: true
|
||||||
|
statusStyle: dot
|
||||||
|
quicklaunch:
|
||||||
|
searchDescriptions: true
|
||||||
|
layout:
|
||||||
|
Как это работает:
|
||||||
|
style: row
|
||||||
|
columns: 4
|
||||||
|
Смотреть:
|
||||||
|
style: row
|
||||||
|
columns: 2
|
||||||
|
Добавить фильм или сериал:
|
||||||
|
style: row
|
||||||
|
columns: 3
|
||||||
|
Загрузки и служебное:
|
||||||
|
style: row
|
||||||
|
columns: 4
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
- greeting:
|
||||||
|
text_size: xl
|
||||||
|
text: "Логин везде: rus · пароль общий"
|
||||||
|
- resources:
|
||||||
|
label: Малина
|
||||||
|
cpu: true
|
||||||
|
memory: true
|
||||||
|
cputemp: true
|
||||||
|
uptime: true
|
||||||
|
- resources:
|
||||||
|
label: Диск медиа (4 ТБ)
|
||||||
|
disk: /mnt/backup
|
||||||
|
- datetime:
|
||||||
|
text_size: l
|
||||||
|
locale: ru
|
||||||
|
format:
|
||||||
|
dateStyle: long
|
||||||
|
timeStyle: short
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Первичная связка медиастека на rpi через API. Идемпотентно: повторный запуск
|
||||||
|
ничего не ломает, уже сделанное пропускает.
|
||||||
|
|
||||||
|
Запуск на малине: python3 /opt/media/provision.py [шаг ...]
|
||||||
|
Шаги: qbit jellyfin arr prowlarr bazarr seerr connect homepage (без аргументов — все).
|
||||||
|
Пароль админа берётся из /opt/media/.admin-password, API-ключи — из config.xml.
|
||||||
|
"""
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
import json, re, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
BASE = "/opt/media"
|
||||||
|
USER = "rus"
|
||||||
|
PW = open(f"{BASE}/.admin-password").read().strip()
|
||||||
|
LANGS = ["ru", "en"] # языки аудио/субтитров по умолчанию
|
||||||
|
|
||||||
|
|
||||||
|
def http(method, url, data=None, headers=None, form=False, opener=None, timeout=60):
|
||||||
|
body = None
|
||||||
|
h = dict(headers or {})
|
||||||
|
if data is not None:
|
||||||
|
if form:
|
||||||
|
body = urllib.parse.urlencode(data).encode()
|
||||||
|
h.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
else:
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
h.setdefault("Content-Type", "application/json")
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
r = opener.open(req, timeout=timeout) if opener else urllib.request.urlopen(req, timeout=timeout)
|
||||||
|
raw = r.read().decode()
|
||||||
|
try:
|
||||||
|
return r.status, json.loads(raw) if raw else None
|
||||||
|
except ValueError:
|
||||||
|
return r.status, raw
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode()[:500]
|
||||||
|
|
||||||
|
|
||||||
|
def apikey(app):
|
||||||
|
return re.search(r"<ApiKey>([^<]+)", open(f"{BASE}/config/{app}/config.xml").read()).group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def log(*a):
|
||||||
|
print(*a, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- qBittorrent
|
||||||
|
def qbit():
|
||||||
|
cj = CookieJar()
|
||||||
|
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||||
|
q = "http://127.0.0.1:8080"
|
||||||
|
hdr = {"Referer": q}
|
||||||
|
def ok(st, body): # 5.x отвечает 204 без тела, старые — 200 "Ok."
|
||||||
|
return body == "Ok." or (st in (200, 204) and any("SID" in c.name for c in cj))
|
||||||
|
|
||||||
|
st, body = http("POST", q + "/api/v2/auth/login", {"username": USER, "password": PW}, hdr, form=True, opener=op)
|
||||||
|
if not ok(st, body): # первый запуск: admin + временный пароль из лога
|
||||||
|
logs = subprocess.run(["docker", "logs", "qbittorrent"], capture_output=True, text=True).stdout
|
||||||
|
m = re.findall(r"temporary password is provided for this session: (\S+)", logs)
|
||||||
|
if not m:
|
||||||
|
sys.exit("qbit: не могу войти ни с админским, ни с временным паролем")
|
||||||
|
st, body = http("POST", q + "/api/v2/auth/login", {"username": "admin", "password": m[-1]}, hdr, form=True, opener=op)
|
||||||
|
assert ok(st, body), (st, body)
|
||||||
|
prefs = {
|
||||||
|
"web_ui_username": USER,
|
||||||
|
"web_ui_password": PW,
|
||||||
|
"save_path": "/data/torrents/other",
|
||||||
|
"temp_path_enabled": False,
|
||||||
|
"auto_tmm_enabled": True, # путь по категории — иначе *arr не найдут файлы
|
||||||
|
"torrent_changed_tmm_enabled": True,
|
||||||
|
"category_changed_tmm_enabled": True,
|
||||||
|
"listen_port": 6881,
|
||||||
|
"upnp": False,
|
||||||
|
"max_active_downloads": 3,
|
||||||
|
"max_active_torrents": 10,
|
||||||
|
"max_active_uploads": 5,
|
||||||
|
"preallocate_all": False,
|
||||||
|
"bypass_local_auth": False,
|
||||||
|
}
|
||||||
|
http("POST", q + "/api/v2/app/setPreferences", {"json": json.dumps(prefs)}, hdr, form=True, opener=op)
|
||||||
|
for cat in ("movies", "tv"):
|
||||||
|
http("POST", q + "/api/v2/torrents/createCategory", {"category": cat, "savePath": f"/data/torrents/{cat}"}, hdr, form=True, opener=op)
|
||||||
|
# перелогин уже новым пользователем — проверка
|
||||||
|
cj.clear()
|
||||||
|
st, body = http("POST", q + "/api/v2/auth/login", {"username": USER, "password": PW}, hdr, form=True, opener=op)
|
||||||
|
st, cats = http("GET", q + "/api/v2/torrents/categories", headers=hdr, opener=op)
|
||||||
|
log("qbit: login", ok(st, body), "categories", sorted(cats) if isinstance(cats, dict) else cats)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Jellyfin
|
||||||
|
JF = "http://127.0.0.1:8096"
|
||||||
|
JF_AUTH = 'MediaBrowser Client="provision", Device="rpi", DeviceId="provision-rpi", Version="1.0"'
|
||||||
|
|
||||||
|
|
||||||
|
def jf_token():
|
||||||
|
st, r = http("POST", JF + "/Users/AuthenticateByName", {"Username": USER, "Pw": PW}, {"Authorization": JF_AUTH})
|
||||||
|
assert st == 200, (st, r)
|
||||||
|
return r["AccessToken"]
|
||||||
|
|
||||||
|
|
||||||
|
def jellyfin():
|
||||||
|
st, info = http("GET", JF + "/System/Info/Public")
|
||||||
|
if not info.get("StartupWizardCompleted"):
|
||||||
|
h = {"Authorization": JF_AUTH}
|
||||||
|
http("POST", JF + "/Startup/Configuration",
|
||||||
|
{"UICulture": "ru", "MetadataCountryCode": "RU", "PreferredMetadataLanguage": "ru"}, h)
|
||||||
|
http("GET", JF + "/Startup/User", headers=h)
|
||||||
|
st, r = http("POST", JF + "/Startup/User", {"Name": USER, "Password": PW}, h)
|
||||||
|
log("jellyfin: user", st)
|
||||||
|
http("POST", JF + "/Startup/RemoteAccess", {"EnableRemoteAccess": True, "EnableAutomaticPortMapping": False}, h)
|
||||||
|
st, r = http("POST", JF + "/Startup/Complete", headers=h)
|
||||||
|
log("jellyfin: wizard complete", st)
|
||||||
|
tok = jf_token()
|
||||||
|
h = {"Authorization": f'{JF_AUTH}, Token="{tok}"'}
|
||||||
|
st, folders = http("GET", JF + "/Library/VirtualFolders", headers=h)
|
||||||
|
have = {f["Name"] for f in folders}
|
||||||
|
for name, ctype, path in (("Фильмы", "movies", "/data/library/movies"), ("Сериалы", "tvshows", "/data/library/tv")):
|
||||||
|
if name in have:
|
||||||
|
continue
|
||||||
|
q = urllib.parse.urlencode({"name": name, "collectionType": ctype, "paths": path, "refreshLibrary": "true"})
|
||||||
|
st, r = http("POST", f"{JF}/Library/VirtualFolders?{q}",
|
||||||
|
{"LibraryOptions": {"PreferredMetadataLanguage": "ru", "MetadataCountryCode": "RU",
|
||||||
|
"EnableRealtimeMonitor": True,
|
||||||
|
"SubtitleDownloadLanguages": [], "SaveSubtitlesWithMedia": True}}, h)
|
||||||
|
log("jellyfin: library", name, st)
|
||||||
|
# язык аудио/субтитров по умолчанию у пользователя
|
||||||
|
st, me = http("GET", JF + "/Users/Me", headers=h)
|
||||||
|
cfg = me["Configuration"]
|
||||||
|
cfg.update({"AudioLanguagePreference": "rus", "SubtitleLanguagePreference": "rus",
|
||||||
|
"PlayDefaultAudioTrack": False, "SubtitleMode": "Smart"})
|
||||||
|
st, r = http("POST", f"{JF}/Users/{me['Id']}/Configuration", cfg, h)
|
||||||
|
log("jellyfin: user prefs", st)
|
||||||
|
# аппаратного транскодинга на Pi 4 нет (V4L2 убран в 10.9) — ограничиваем потоки, чтобы не задушить малину
|
||||||
|
st, enc = http("GET", JF + "/System/Configuration/encoding", headers=h)
|
||||||
|
enc.update({"HardwareAccelerationType": "none", "EncodingThreadCount": 3,
|
||||||
|
"TranscodingTempPath": "/cache/transcodes"})
|
||||||
|
st, r = http("POST", JF + "/System/Configuration/encoding", enc, h)
|
||||||
|
log("jellyfin: encoding", st)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Sonarr / Radarr
|
||||||
|
ARR = {
|
||||||
|
"radarr": {"url": "http://127.0.0.1:7878", "api": "v3", "root": "/data/library/movies", "cat": "movies",
|
||||||
|
"catfield": "movieCategory", "inner": "http://radarr:7878"},
|
||||||
|
"sonarr": {"url": "http://127.0.0.1:8989", "api": "v3", "root": "/data/library/tv", "cat": "tv",
|
||||||
|
"catfield": "tvCategory", "inner": "http://sonarr:8989"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def arr():
|
||||||
|
for name, a in ARR.items():
|
||||||
|
k = apikey(name)
|
||||||
|
h = {"X-Api-Key": k}
|
||||||
|
u = f"{a['url']}/api/{a['api']}"
|
||||||
|
# авторизация: формы, пароль всегда («без пароля из LAN» требует AllowedHosts в новых версиях)
|
||||||
|
st, hc = http("GET", u + "/config/host", headers=h)
|
||||||
|
if hc.get("authenticationMethod") != "forms":
|
||||||
|
hc.update({"authenticationMethod": "forms", "authenticationRequired": "enabled",
|
||||||
|
"username": USER, "password": PW, "passwordConfirmation": PW})
|
||||||
|
st, r = http("PUT", u + "/config/host", hc, h)
|
||||||
|
log(name, "auth", st)
|
||||||
|
st, roots = http("GET", u + "/rootfolder", headers=h)
|
||||||
|
if not any(r["path"].rstrip("/") == a["root"] for r in roots):
|
||||||
|
st, r = http("POST", u + "/rootfolder", {"path": a["root"]}, h)
|
||||||
|
log(name, "rootfolder", st, r if st >= 300 else "")
|
||||||
|
st, dcs = http("GET", u + "/downloadclient", headers=h)
|
||||||
|
if not any(d["implementation"] == "QBittorrent" for d in dcs):
|
||||||
|
st, schema = http("GET", u + "/downloadclient/schema", headers=h)
|
||||||
|
dc = next(s for s in schema if s["implementation"] == "QBittorrent")
|
||||||
|
vals = {"host": "qbittorrent", "port": 8080, "username": USER, "password": PW, a["catfield"]: a["cat"]}
|
||||||
|
for f in dc["fields"]:
|
||||||
|
if f["name"] in vals:
|
||||||
|
f["value"] = vals[f["name"]]
|
||||||
|
dc.update({"name": "qBittorrent", "enable": True, "protocol": "torrent", "priority": 1})
|
||||||
|
st, r = http("POST", u + "/downloadclient", dc, h)
|
||||||
|
log(name, "downloadclient", st, r if st >= 300 else "")
|
||||||
|
# хардлинки вместо копирования при импорте
|
||||||
|
st, mm = http("GET", u + "/config/mediamanagement", headers=h)
|
||||||
|
# ⚠️ медиа живут на диске бэкапов: onedrive-backup падает при заполнении >90 % (~370 ГБ свободно).
|
||||||
|
# 500 ГБ — порог с запасом, ниже которого *arr перестают импортировать.
|
||||||
|
want = {"copyUsingHardlinks": True, "minimumFreeSpaceWhenImporting": 500_000}
|
||||||
|
if any(mm.get(k) != v for k, v in want.items()):
|
||||||
|
mm.update(want)
|
||||||
|
st, r = http("PUT", u + "/config/mediamanagement", mm, h)
|
||||||
|
log(name, "mediamanagement", st)
|
||||||
|
log(name, "ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Prowlarr
|
||||||
|
def prowlarr():
|
||||||
|
k = apikey("prowlarr")
|
||||||
|
h = {"X-Api-Key": k}
|
||||||
|
u = "http://127.0.0.1:9696/api/v1"
|
||||||
|
st, hc = http("GET", u + "/config/host", headers=h)
|
||||||
|
if hc.get("authenticationMethod") != "forms":
|
||||||
|
hc.update({"authenticationMethod": "forms", "authenticationRequired": "enabled",
|
||||||
|
"username": USER, "password": PW, "passwordConfirmation": PW})
|
||||||
|
st, r = http("PUT", u + "/config/host", hc, h)
|
||||||
|
log("prowlarr auth", st)
|
||||||
|
# FlareSolverr как прокси с тегом flaresolverr
|
||||||
|
st, tags = http("GET", u + "/tag", headers=h)
|
||||||
|
tag = next((t for t in tags if t["label"] == "flaresolverr"), None)
|
||||||
|
if not tag:
|
||||||
|
st, tag = http("POST", u + "/tag", {"label": "flaresolverr"}, h)
|
||||||
|
st, proxies = http("GET", u + "/indexerProxy", headers=h)
|
||||||
|
if not any(p["implementation"] == "FlareSolverr" for p in proxies):
|
||||||
|
st, schema = http("GET", u + "/indexerProxy/schema", headers=h)
|
||||||
|
p = next(s for s in schema if s["implementation"] == "FlareSolverr")
|
||||||
|
for f in p["fields"]:
|
||||||
|
if f["name"] == "host":
|
||||||
|
f["value"] = "http://flaresolverr:8191/"
|
||||||
|
if f["name"] == "requestTimeout":
|
||||||
|
f["value"] = 180 # Chromium на Pi решает челлендж 80–180 с; дефолтных 60 не хватает
|
||||||
|
p.update({"name": "FlareSolverr", "tags": [tag["id"]]})
|
||||||
|
st, r = http("POST", u + "/indexerProxy", p, h)
|
||||||
|
log("prowlarr flaresolverr", st, r if st >= 300 else "")
|
||||||
|
# приложения: Prowlarr сам раздаёт индексаторы в Sonarr/Radarr
|
||||||
|
st, apps = http("GET", u + "/applications", headers=h)
|
||||||
|
have = {a["implementation"] for a in apps}
|
||||||
|
st, schema = http("GET", u + "/applications/schema", headers=h)
|
||||||
|
for name, impl in (("radarr", "Radarr"), ("sonarr", "Sonarr")):
|
||||||
|
if impl in have:
|
||||||
|
continue
|
||||||
|
a = next(s for s in schema if s["implementation"] == impl)
|
||||||
|
vals = {"prowlarrUrl": "http://prowlarr:9696", "baseUrl": ARR[name]["inner"], "apiKey": apikey(name)}
|
||||||
|
for f in a["fields"]:
|
||||||
|
if f["name"] in vals:
|
||||||
|
f["value"] = vals[f["name"]]
|
||||||
|
a.update({"name": impl, "syncLevel": "fullSync"})
|
||||||
|
st, r = http("POST", u + "/applications", a, h)
|
||||||
|
log("prowlarr app", impl, st, r if st >= 300 else "")
|
||||||
|
# публичные индексаторы без регистрации; приватные (RuTracker, Kinozal) — руками, нужен аккаунт
|
||||||
|
st, idx = http("GET", u + "/indexer", headers=h)
|
||||||
|
have = {i["definitionName"] for i in idx}
|
||||||
|
st, schema = http("GET", u + "/indexer/schema", headers=h)
|
||||||
|
by = {s["definitionName"]: s for s in schema}
|
||||||
|
for d in ("rutor", "noname-club", "1337x", "thepiratebay", "eztv", "yts", "kinozal"):
|
||||||
|
if d in have or d not in by:
|
||||||
|
if d not in by:
|
||||||
|
log("prowlarr indexer", d, "нет в схеме")
|
||||||
|
continue
|
||||||
|
s = by[d]
|
||||||
|
if s.get("privacy") != "public":
|
||||||
|
continue
|
||||||
|
s.update({"name": s["name"], "enable": True, "appProfileId": 1, "priority": 25})
|
||||||
|
if d == "1337x":
|
||||||
|
s["tags"] = [tag["id"]] # за Cloudflare
|
||||||
|
try: # Prowlarr тестирует индексатор при добавлении; через FlareSolverr это долго
|
||||||
|
st, r = http("POST", u + "/indexer", s, h, timeout=240)
|
||||||
|
except OSError as e:
|
||||||
|
log("prowlarr indexer", d, "не добавлен:", e)
|
||||||
|
continue
|
||||||
|
log("prowlarr indexer", d, st, (r if isinstance(r, str) else "")[:200] if st >= 300 else "")
|
||||||
|
http("POST", u + "/command", {"name": "ApplicationIndexerSync"}, h)
|
||||||
|
log("prowlarr ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Homepage + Jellyfin connect
|
||||||
|
def jf_apikey(name):
|
||||||
|
"""API-ключ Jellyfin для приложения name (создаётся при отсутствии)."""
|
||||||
|
tok = jf_token()
|
||||||
|
h = {"Authorization": f'{JF_AUTH}, Token="{tok}"'}
|
||||||
|
find = lambda: next((x["AccessToken"] for x in http("GET", JF + "/Auth/Keys", headers=h)[1]["Items"]
|
||||||
|
if x["AppName"] == name), None)
|
||||||
|
if not find():
|
||||||
|
http("POST", f"{JF}/Auth/Keys?app={name}", headers=h)
|
||||||
|
return find()
|
||||||
|
|
||||||
|
|
||||||
|
def homepage():
|
||||||
|
"""/opt/media/homepage.env (rus 600) — ключи для виджетов, в репо их нет."""
|
||||||
|
import os, yaml
|
||||||
|
env = {
|
||||||
|
"HOMEPAGE_VAR_JELLYFIN_KEY": jf_apikey("homepage"),
|
||||||
|
"HOMEPAGE_VAR_SEERR_KEY": json.load(open(f"{BASE}/config/seerr/settings.json"))["main"]["apiKey"],
|
||||||
|
"HOMEPAGE_VAR_RADARR_KEY": apikey("radarr"),
|
||||||
|
"HOMEPAGE_VAR_SONARR_KEY": apikey("sonarr"),
|
||||||
|
"HOMEPAGE_VAR_PROWLARR_KEY": apikey("prowlarr"),
|
||||||
|
"HOMEPAGE_VAR_BAZARR_KEY": yaml.safe_load(open(f"{BASE}/config/bazarr/config/config.yaml"))["auth"]["apikey"],
|
||||||
|
"HOMEPAGE_VAR_QBIT_USER": USER,
|
||||||
|
"HOMEPAGE_VAR_QBIT_PASS": PW,
|
||||||
|
}
|
||||||
|
old = os.umask(0o077)
|
||||||
|
open(f"{BASE}/homepage.env", "w").write("".join(f"{k}={v}\n" for k, v in env.items()))
|
||||||
|
os.umask(old)
|
||||||
|
log("homepage env written (перезапусти контейнер homepage, если ключи сменились)")
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
"""Radarr/Sonarr → Jellyfin: обновить библиотеку сразу после импорта (иначе ждать скан по расписанию)."""
|
||||||
|
k = jf_apikey("arr-connect")
|
||||||
|
for name, a in ARR.items():
|
||||||
|
h = {"X-Api-Key": apikey(name)}
|
||||||
|
u = f"{a['url']}/api/{a['api']}"
|
||||||
|
st, ns = http("GET", u + "/notification", headers=h)
|
||||||
|
if any(n["implementation"] == "MediaBrowser" for n in ns):
|
||||||
|
continue
|
||||||
|
st, schema = http("GET", u + "/notification/schema", headers=h)
|
||||||
|
n = next(x for x in schema if x["implementation"] == "MediaBrowser")
|
||||||
|
vals = {"host": "192.168.10.25", "port": 8096, "useSsl": False, "apiKey": k, "notify": False, "updateLibrary": True}
|
||||||
|
for f in n["fields"]:
|
||||||
|
if f["name"] in vals:
|
||||||
|
f["value"] = vals[f["name"]]
|
||||||
|
n.update({"name": "Jellyfin", "onDownload": True, "onUpgrade": True, "onRename": True,
|
||||||
|
"onMovieDelete": True, "onMovieFileDelete": True, "onSeriesDelete": True, "onEpisodeFileDelete": True})
|
||||||
|
st, r = http("POST", u + "/notification", n, h)
|
||||||
|
log(name, "jellyfin connect", st, r if st >= 300 else "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Bazarr
|
||||||
|
def bazarr():
|
||||||
|
import yaml # python3-yaml есть в Raspberry Pi OS
|
||||||
|
cfg = yaml.safe_load(open(f"{BASE}/config/bazarr/config/config.yaml"))
|
||||||
|
h = {"X-API-KEY": cfg["auth"]["apikey"]}
|
||||||
|
u = "http://127.0.0.1:6767/api"
|
||||||
|
profile = {"profileId": 1, "name": "ru+en", "cutoff": None, "mustContain": [], "mustNotContain": [],
|
||||||
|
"originalFormat": False, "tag": None,
|
||||||
|
"items": [{"id": i + 1, "language": l, "forced": "False", "hi": "False", "audio_exclude": "False",
|
||||||
|
"audio_only_include": "False"} for i, l in enumerate(LANGS)]}
|
||||||
|
form = [
|
||||||
|
("languages-enabled", json.dumps(LANGS)),
|
||||||
|
("languages-profiles", json.dumps([profile])),
|
||||||
|
("settings-general-serie_default_enabled", "true"),
|
||||||
|
("settings-general-serie_default_profile", "1"),
|
||||||
|
("settings-general-movie_default_enabled", "true"),
|
||||||
|
("settings-general-movie_default_profile", "1"),
|
||||||
|
# без аккаунтов; OpenSubtitles.com (лучший для ru) требует логин — добавить руками
|
||||||
|
("settings-general-enabled_providers", json.dumps(["embeddedsubtitles", "podnapisi", "yifysubtitles"])),
|
||||||
|
("settings-general-use_sonarr", "true"),
|
||||||
|
("settings-general-use_radarr", "true"),
|
||||||
|
("settings-sonarr-ip", "sonarr"), ("settings-sonarr-port", "8989"), ("settings-sonarr-apikey", apikey("sonarr")),
|
||||||
|
("settings-radarr-ip", "radarr"), ("settings-radarr-port", "7878"), ("settings-radarr-apikey", apikey("radarr")),
|
||||||
|
("settings-auth-type", "form"), ("settings-auth-username", USER), ("settings-auth-password", PW),
|
||||||
|
]
|
||||||
|
body = urllib.parse.urlencode(form).encode()
|
||||||
|
req = urllib.request.Request(u + "/system/settings", data=body, headers=h, method="POST")
|
||||||
|
try:
|
||||||
|
st = urllib.request.urlopen(req, timeout=60).status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
st = (e.code, e.read().decode()[:300])
|
||||||
|
log("bazarr settings", st)
|
||||||
|
st, prof = http("GET", u + "/system/languages/profiles", headers=h)
|
||||||
|
log("bazarr profiles", [p["name"] for p in prof] if isinstance(prof, list) else prof)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Jellyseerr
|
||||||
|
def jellyseerr():
|
||||||
|
js = "http://127.0.0.1:5055/api/v1"
|
||||||
|
cj = CookieJar()
|
||||||
|
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||||
|
st, pub = http("GET", js + "/settings/public", opener=op)
|
||||||
|
if not pub.get("initialized"):
|
||||||
|
st, r = http("POST", js + "/auth/jellyfin",
|
||||||
|
{"username": USER, "password": PW, "hostname": "192.168.10.25", "port": 8096,
|
||||||
|
"useSsl": False, "urlBase": "", "email": "rus@localhost", "serverType": 2}, opener=op)
|
||||||
|
log("seerr: jellyfin login", st, r if st >= 300 else "")
|
||||||
|
st, libs = http("GET", js + "/settings/jellyfin/library?sync=true", opener=op)
|
||||||
|
ids = ",".join(l["id"] for l in libs) if isinstance(libs, list) else ""
|
||||||
|
http("GET", js + f"/settings/jellyfin/library?enable={ids}", opener=op)
|
||||||
|
st, r = http("POST", js + "/settings/initialize", {}, opener=op)
|
||||||
|
log("seerr: initialize", st)
|
||||||
|
else:
|
||||||
|
st, r = http("POST", js + "/auth/jellyfin", {"username": USER, "password": PW}, opener=op)
|
||||||
|
log("seerr: login", st)
|
||||||
|
for name, path, port in (("radarr", "/settings/radarr", 7878), ("sonarr", "/settings/sonarr", 8989)):
|
||||||
|
st, cur = http("GET", js + path, opener=op)
|
||||||
|
if cur:
|
||||||
|
continue
|
||||||
|
k = apikey(name)
|
||||||
|
base = f"http://127.0.0.1:{port}/api/v3"
|
||||||
|
st, profiles = http("GET", base + "/qualityprofile", headers={"X-Api-Key": k})
|
||||||
|
prof = next((p for p in profiles if p["name"] in ("HD-1080p", "HD - 720p/1080p")), profiles[0])
|
||||||
|
body = {"name": name.capitalize(), "hostname": name, "port": port, "apiKey": k, "useSsl": False,
|
||||||
|
"baseUrl": "", "activeProfileId": prof["id"], "activeProfileName": prof["name"],
|
||||||
|
"activeDirectory": ARR[name]["root"], "is4k": False, "isDefault": True,
|
||||||
|
"externalUrl": "", "syncEnabled": True, "preventSearch": False}
|
||||||
|
if name == "radarr":
|
||||||
|
body["minimumAvailability"] = "released"
|
||||||
|
else:
|
||||||
|
body.update({"activeAnimeProfileId": prof["id"], "activeAnimeDirectory": ARR[name]["root"],
|
||||||
|
"enableSeasonFolders": True})
|
||||||
|
st, r = http("POST", js + path, body, opener=op)
|
||||||
|
log("seerr:", name, st, r if st >= 300 else "")
|
||||||
|
log("seerr ok")
|
||||||
|
|
||||||
|
|
||||||
|
STEPS = {"qbit": qbit, "jellyfin": jellyfin, "arr": arr, "prowlarr": prowlarr, "bazarr": bazarr, "connect": connect, "homepage": homepage, "seerr": jellyseerr}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for s in sys.argv[1:] or list(STEPS):
|
||||||
|
log(f"=== {s}")
|
||||||
|
STEPS[s]()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Домашняя печать с rpi
|
||||||
|
|
||||||
|
Принтер: Brother DCP-L2620DW, обнаружен 2026-10-11 через mDNS,
|
||||||
|
`192.168.10.24`, hostname `BRW2C9C58C1EEA7.local`.
|
||||||
|
Чёрно-белый, A4, двусторонняя печать; IPP `/ipp/print`, порт 631.
|
||||||
|
Код настройки — `setup.sh`; очередь `home-brother`, драйвер IPP Everywhere.
|
||||||
|
|
||||||
|
Установка: `sudo apt-get install --no-install-recommends cups cups-client cups-filters cups-ipp-utils`,
|
||||||
|
затем `sudo sh stacks/rpi-print/setup.sh` и
|
||||||
|
`sudo install -o root -g root -m 755 stacks/rpi-print/home-print /usr/local/bin/home-print`.
|
||||||
|
|
||||||
|
Печать PDF/PNG/JPEG: `lp -d home-brother -o media=A4 -o fit-to-page /path/to/file`.
|
||||||
|
Двусторонняя: добавить `-o sides=two-sided-long-edge`.
|
||||||
|
Очередь: `lpstat -W not-completed -o home-brother`; отмена: `cancel <job-id>`.
|
||||||
|
Word DOCX перед печатью нужно преобразовать в PDF: напрямую CUPS его не понимает.
|
||||||
|
|
||||||
|
⚠️ hostname через mDNS позволяет пережить смену DHCP-адреса; если принтер
|
||||||
|
не объявляет mDNS или выключен, отправка упадёт. Политика `abort-job` исключает
|
||||||
|
неожиданную автоматическую печать давно забытых заданий после включения.
|
||||||
|
Очередь не публикуется в сеть (`printer-is-shared=false`); CUPS остаётся локальным.
|
||||||
|
|
||||||
|
Отправлять на бумагу только по прямой просьбе пользователя. Сообщение о принятии
|
||||||
|
задания означает приём в очередь; итог следует проверить через `lpstat`.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Usage: home-print status | home-print [--duplex] FILE.pdf|FILE.png|FILE.jpg
|
||||||
|
set -eu
|
||||||
|
printer=home-brother
|
||||||
|
if [ "${1:-}" = status ]; then
|
||||||
|
lpstat -p "$printer"
|
||||||
|
lpstat -W not-completed -o "$printer"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
sides=one-sided
|
||||||
|
if [ "${1:-}" = --duplex ]; then
|
||||||
|
sides=two-sided-long-edge
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||||
|
echo 'Укажите один существующий PDF/PNG/JPEG-файл' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
case "$1" in
|
||||||
|
*.pdf|*.PDF|*.png|*.PNG|*.jpg|*.JPG|*.jpeg|*.JPEG) ;;
|
||||||
|
*) echo 'Поддерживаются PDF/PNG/JPEG; Word сначала преобразуйте в PDF' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
# Absolute path prevents a filename from becoming an lp option.
|
||||||
|
file=$(realpath -- "$1")
|
||||||
|
exec lp -d "$printer" -n 1 -o media=A4 -o fit-to-page -o sides="$sides" "$file"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run as root after installing cups cups-client cups-filters cups-ipp-utils.
|
||||||
|
set -eu
|
||||||
|
systemctl enable --now cups.service
|
||||||
|
lpadmin -p home-brother -E -v ipp://BRW2C9C58C1EEA7.local:631/ipp/print -m everywhere \
|
||||||
|
-D 'Brother DCP-L2620DW' -L 'Дом' \
|
||||||
|
-o printer-is-shared=false -o PageSize=A4 -o sides-default=one-sided \
|
||||||
|
-o printer-error-policy=abort-job
|
||||||
|
lpadmin -d home-brother
|
||||||
|
lpstat -p home-brother -v
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Бэкап серверов на rpi (fr1, ru1)
|
||||||
|
|
||||||
|
Внешняя площадка для VPS — домашняя малина. Схема «малина забирает, сервер шифрует»:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as rpi (таймер 02:15)
|
||||||
|
participant S as сервер (fr1 / ru1)
|
||||||
|
R->>S: ssh -i /etc/server-backup/id_ed25519 <профиль>
|
||||||
|
Note over S: forced command backup-export<br/>tar | zstd | age -r <ОТКРЫТЫЙ ключ>
|
||||||
|
S-->>R: stdout: шифротекст
|
||||||
|
S-->>R: stderr: "SHA256 <hex>" шифротекста
|
||||||
|
Note over R: сверяет sha256, кладёт<br/>/mnt/backup/servers/<host>/<профиль>/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Малина не может расшифровать то, что хранит.** Шифрует сервер открытым ключом age,
|
||||||
|
закрытый ключ только у владельца: Mac `~/.config/age/servers-backup.key` (+ резервная копия
|
||||||
|
вне малины). Взлом малины или промпт-инъекция агента Astra = утечка только шифротекста.
|
||||||
|
- **Сервер не получает доступа к малине** (в отличие от схемы «сервер пушит через обратный
|
||||||
|
туннель»): ключ малины на сервере умеет только запустить выгрузку (`restrict,command=…`).
|
||||||
|
- Целостность: sha256 шифротекста считает сервер, малина сверяет при приёме и раз в неделю
|
||||||
|
(`servers-backup verify`). Подмену/порчу внутри поймает и age при расшифровке (AEAD).
|
||||||
|
|
||||||
|
## Что входит
|
||||||
|
|
||||||
|
| Хост/профиль | Когда | Содержимое | Размер |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ru1 daily | ежедневно | `/etc` целиком (OpenVPN, AWG, mtproxy, tproxy, iptables, юниты), `/opt` (MTProxy, xray), `/srv`, `/usr/local`, `~mik` (**easy-rsa — CA OpenVPN**, клиентские .ovpn, wireguard, gost+LE, smart-routing), `/root`, crontab | ~17 МБ |
|
||||||
|
| fr1 daily | ежедневно | `/etc` целиком (AWG-ключи, nginx, letsencrypt, tg-notify…), `/srv/gitea` + согласованный снимок `gitea.db` (`snap/gitea.db`, через sqlite `.backup`), `/usr/local`, `~rus` (monitoring с секретами, bin, MyVault, .ssh), `/root`, crontab | ~2.1 ГБ |
|
||||||
|
| fr1 weekly | вс | docker-тома `claude-channels_claude-home` (⚠️ ключи Astra к хостам), grafana, alertmanager, pushgateway; `~tgclaude` (imgbot-data, imgbot, боты) | ~10 ГБ |
|
||||||
|
|
||||||
|
Не входит сознательно: том Prometheus (метрики, 7.9 ГБ, потеря не критична), `~rus/backups`
|
||||||
|
и `~tgclaude/backups` (разовые архивы — скопировать один раз отдельно), спящий openclaw,
|
||||||
|
кэши `.claude`/`.cache`, сборки (`badvpn`, `telemt-build`).
|
||||||
|
|
||||||
|
Хранение: `daily` — 14, `weekly` — 8, `monthly` — 12 (хардлинк первого daily месяца).
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
- `fr1-backup-export` → fr1 `~rus/bin/backup-export` (читает root-файлы через `docker run -v /:/host:ro`:
|
||||||
|
sudo на fr1 по паролю, а rus в группе docker). `age` — статический бинарник `~rus/bin/age`.
|
||||||
|
- `ru1-backup-export` → ru1 `/usr/local/sbin/backup-export` (root, 750), forced command
|
||||||
|
`sudo -n …` у `mik` (у него sudo NOPASSWD). `age` — `/usr/local/bin/age`.
|
||||||
|
- `rpi/servers-backup`, `rpi/servers-backup.{service,timer}` → rpi `/usr/local/bin`, `/etc/systemd/system`.
|
||||||
|
Ключ `/etc/server-backup/id_ed25519` (root 600), host keys серверов закреплены в
|
||||||
|
`/etc/server-backup/known_hosts`. Итог прогона → метрики `backup_*{job="backup_servers"}`,
|
||||||
|
алерт `BackupStale` (>26 ч) и `BackupFailed`.
|
||||||
|
|
||||||
|
## Восстановление
|
||||||
|
|
||||||
|
Нужен закрытый ключ (на Mac). Архив можно расшифровывать потоком, не храня открытым на диске:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# список файлов
|
||||||
|
ssh rpi 'sudo sh -c "cat /mnt/backup/servers/ru1/daily/<файл>.age"' \
|
||||||
|
| age -d -i ~/.config/age/servers-backup.key | zstd -dq | tar -tvf -
|
||||||
|
|
||||||
|
# достать один файл/каталог (например CA OpenVPN)
|
||||||
|
ssh rpi 'sudo sh -c "cat /mnt/backup/servers/ru1/daily/<файл>.age"' \
|
||||||
|
| age -d -i ~/.config/age/servers-backup.key | zstd -dq | tar -xvf - home/mik/easy-rsa
|
||||||
|
|
||||||
|
# fr1: пути в архиве с префиксом host/ (tar шёл из контейнера с / на /host):
|
||||||
|
# репозитории — host/srv/gitea/data/git/repositories/…, БД gitea — snap/gitea.db
|
||||||
|
# (живой host/srv/gitea/data/gitea.db исключён — он был бы рваной копией)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Потеря закрытого ключа = все бэкапы бесполезны.** Минимум две копии в разных местах,
|
||||||
|
и пробное восстановление раз в квартал.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Выгрузка бэкапа fr1 для rpi. Forced command в ~rus/.ssh/authorized_keys:
|
||||||
|
# restrict,command="/home/rus/bin/backup-export" <ключ server-backup@rpi>
|
||||||
|
# Клиент передаёт профиль: daily | weekly. В stdout — tar | zstd | age (шифрует ОТКРЫТЫМ
|
||||||
|
# ключом, закрытый только у владельца вне малины — rpi хранит то, что сама прочитать не может).
|
||||||
|
# В stderr последней строкой — "SHA256 <hex>" шифротекста: rpi сверяет, что дошло целиком.
|
||||||
|
#
|
||||||
|
# Читает root-файлы через docker (rus в группе docker = root-эквивалент), а не через sudo:
|
||||||
|
# sudo на fr1 по паролю. Монтирование / только read-only.
|
||||||
|
set -euo pipefail
|
||||||
|
PUB=age1qfsgzyglv0vmq4kskg888hzgq032mry5kxz9kptkvzsrz0fz0v7q6kxwk2
|
||||||
|
AGE=/home/rus/bin/age
|
||||||
|
IMG=debian:stable-slim # GNU tar: --exclude/--warning, в alpine — busybox
|
||||||
|
profile="${SSH_ORIGINAL_COMMAND:-daily}"
|
||||||
|
tmp=$(mktemp -d /tmp/backup-export.XXXXXX)
|
||||||
|
cleanup() { rm -rf "$tmp"; docker exec gitea rm -f /tmp/gitea-snap.db >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
case "$profile" in
|
||||||
|
daily)
|
||||||
|
# Gitea на SQLite: копировать живой gitea.db нельзя (рваная копия) — .backup даёт согласованный снимок
|
||||||
|
docker exec -u 1000 gitea sqlite3 /data/gitea.db ".backup '/tmp/gitea-snap.db'"
|
||||||
|
docker cp gitea:/tmp/gitea-snap.db "$tmp/gitea.db" >/dev/null
|
||||||
|
paths=(host/etc host/srv/gitea host/usr/local host/home/rus host/var/spool/cron host/root snap)
|
||||||
|
excl=(--exclude=host/srv/gitea/data/gitea.db --exclude='host/srv/gitea/data/gitea.db-*'
|
||||||
|
--exclude=host/home/rus/backups --exclude=host/home/rus/.openclaw
|
||||||
|
--exclude=host/home/rus/openclaw --exclude=host/home/rus/openclaw-work
|
||||||
|
--exclude=host/home/rus/.cache --exclude=host/home/rus/.claude --exclude='*.bak-*' --exclude='*.tar.gz')
|
||||||
|
;;
|
||||||
|
weekly)
|
||||||
|
paths=(host/var/lib/docker/volumes/claude-channels_claude-home
|
||||||
|
host/var/lib/docker/volumes/monitoring_grafana_data
|
||||||
|
host/var/lib/docker/volumes/monitoring_alertmanager_data
|
||||||
|
host/var/lib/docker/volumes/monitoring_pushgateway_data
|
||||||
|
host/home/tgclaude)
|
||||||
|
excl=(--exclude=host/home/tgclaude/backups --exclude=host/home/tgclaude/openclaw-deploy
|
||||||
|
--exclude=host/home/tgclaude/.local --exclude=host/home/tgclaude/.cache --exclude=host/home/tgclaude/chrome-libs)
|
||||||
|
;;
|
||||||
|
*) echo "профиль: daily|weekly" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# tar exit 1 = «файл изменился при чтении» (живые логи/репо) — не ошибка для бэкапа
|
||||||
|
docker run --rm --network none -v /:/host:ro -v "$tmp":/snap:ro "$IMG" \
|
||||||
|
sh -c 'tar --numeric-owner --ignore-failed-read --warning=no-file-changed -C / -cf - "$@"; rc=$?; [ $rc -le 1 ] || exit $rc' tar "${excl[@]}" "${paths[@]}" \
|
||||||
|
| zstd -3 -T0 -q | "$AGE" -r "$PUB" | tee >(sha256sum > "$tmp/sum")
|
||||||
|
# процесс-подстановка живёт отдельно от конвейера — ждём, пока sha256sum допишет итог
|
||||||
|
for _ in $(seq 300); do [ -s "$tmp/sum" ] && break; sleep 0.1; done
|
||||||
|
echo "SHA256 $(cut -d' ' -f1 "$tmp/sum")" >&2
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Бэкап серверов на rpi (вариант «малина забирает»): ssh к серверу → forced command отдаёт
|
||||||
|
# tar|zstd|age, зашифрованный ОТКРЫТЫМ ключом. Закрытого ключа на малине нет — расшифровать
|
||||||
|
# здесь ничего нельзя (ключ у владельца: Mac ~/.config/age/servers-backup.key + резервная копия).
|
||||||
|
#
|
||||||
|
# servers-backup all — ежедневный прогон (таймер): fr1 daily, ru1 daily, по воскресеньям fr1 weekly
|
||||||
|
# servers-backup verify — сверка sha256 всех хранимых архивов (порча на диске)
|
||||||
|
#
|
||||||
|
# Хранение: <host>/daily — 14 последних, <host>/weekly — 8, <host>/monthly — 12 (хардлинк
|
||||||
|
# первого daily за месяц, места не занимает).
|
||||||
|
set -uo pipefail
|
||||||
|
ROOT=/mnt/backup/servers
|
||||||
|
KEY=/etc/server-backup/id_ed25519
|
||||||
|
KH=/etc/server-backup/known_hosts
|
||||||
|
declare -A TARGET=([fr1]=rus@161.97.93.252 [ru1]=mik@85.198.109.86)
|
||||||
|
declare -A KEEP=([daily]=14 [weekly]=8 [monthly]=12)
|
||||||
|
|
||||||
|
log() { echo "$*"; }
|
||||||
|
|
||||||
|
pull() { # pull <host> <profile>
|
||||||
|
local host=$1 profile=$2 dir="$ROOT/$1/$2" ts f rc want got
|
||||||
|
mkdir -p "$dir"
|
||||||
|
ts=$(date +%F_%H%M); f="$dir/$host-$profile-$ts.tar.zst.age"
|
||||||
|
ssh -i "$KEY" -o UserKnownHostsFile="$KH" -o StrictHostKeyChecking=yes -o BatchMode=yes \
|
||||||
|
-o ConnectTimeout=30 -o ServerAliveInterval=30 -o ServerAliveCountMax=6 \
|
||||||
|
"${TARGET[$host]}" "$profile" > "$f.part" 2> "$f.stderr"
|
||||||
|
rc=$?
|
||||||
|
want=$(grep '^SHA256 ' "$f.stderr" | tail -1 | cut -d' ' -f2)
|
||||||
|
got=$(sha256sum "$f.part" | cut -d' ' -f1)
|
||||||
|
if [ $rc -ne 0 ] || [ -z "$want" ] || [ "$want" != "$got" ] || [ ! -s "$f.part" ]; then
|
||||||
|
log "FAIL $host/$profile: ssh rc=$rc, sha сервера=${want:-нет}, sha принятого=$got"
|
||||||
|
sed 's/^/ stderr: /' "$f.stderr" | tail -20
|
||||||
|
rm -f "$f.part" "$f.stderr"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
mv "$f.part" "$f"
|
||||||
|
echo "$got $(basename "$f")" > "$f.sha256"
|
||||||
|
grep -v '^SHA256 ' "$f.stderr" | tail -50 > "$f.log"; [ -s "$f.log" ] || rm -f "$f.log"
|
||||||
|
rm -f "$f.stderr"
|
||||||
|
log "OK $host/$profile: $(du -h "$f" | cut -f1) $(basename "$f")"
|
||||||
|
# первый daily за месяц → monthly (хардлинк)
|
||||||
|
if [ "$profile" = daily ] && ! ls "$ROOT/$host/monthly/$host-daily-$(date +%Y-%m)"-* >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$ROOT/$host/monthly"; ln "$f" "$f.sha256" "$ROOT/$host/monthly/"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prune() {
|
||||||
|
local d keep
|
||||||
|
for d in "$ROOT"/*/{daily,weekly,monthly}; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
keep=${KEEP[$(basename "$d")]}
|
||||||
|
ls -1t "$d"/*.age 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
|
||||||
|
rm -f "$old" "$old.sha256" "$old.log"; log "prune $(basename "$old")"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
local bad=0 n=0 s
|
||||||
|
while read -r s; do
|
||||||
|
n=$((n + 1))
|
||||||
|
(cd "$(dirname "$s")" && sha256sum --quiet -c "$(basename "$s")") || { log "BAD $s"; bad=1; }
|
||||||
|
done < <(find "$ROOT" -name '*.sha256')
|
||||||
|
log "verify: проверено $n архивов, $([ $bad = 0 ] && echo 'всё цело' || echo 'ЕСТЬ ПОВРЕЖДЁННЫЕ')"
|
||||||
|
return $bad
|
||||||
|
}
|
||||||
|
|
||||||
|
mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован — пропуск"; exit 1; }
|
||||||
|
install -d -m 700 "$ROOT"
|
||||||
|
case "${1:-all}" in
|
||||||
|
all)
|
||||||
|
rc=0
|
||||||
|
pull fr1 daily || rc=1
|
||||||
|
pull ru1 daily || rc=1
|
||||||
|
[ "$(date +%u)" = 7 ] && { pull fr1 weekly || rc=1; }
|
||||||
|
prune
|
||||||
|
[ "$(date +%u)" = 7 ] && { verify || rc=1; }
|
||||||
|
exit $rc ;;
|
||||||
|
verify) verify ;;
|
||||||
|
pull) pull "$2" "$3" ;;
|
||||||
|
*) echo "usage: servers-backup all|verify|pull <host> <profile>" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pull encrypted backups of fr1/ru1 (age, key not on this host)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
RequiresMountsFor=/mnt/backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/servers-backup all
|
||||||
|
ExecStopPost=/usr/local/bin/backup-metrics backup_servers
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
TimeoutStartSec=6h
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily servers backup (fr1, ru1)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 02:15
|
||||||
|
RandomizedDelaySec=10m
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Выгрузка бэкапа ru1 для rpi. Forced command в ~mik/.ssh/authorized_keys:
|
||||||
|
# restrict,command="sudo -n /usr/local/sbin/backup-export" <ключ server-backup@rpi>
|
||||||
|
# В stdout — tar | zstd | age (открытый ключ; закрытый только у владельца вне малины).
|
||||||
|
# В stderr последней строкой — "SHA256 <hex>" шифротекста.
|
||||||
|
# Главное здесь — ~mik/easy-rsa: CA OpenVPN; без него — перевыпуск всех клиентских конфигов.
|
||||||
|
set -euo pipefail
|
||||||
|
PUB=age1qfsgzyglv0vmq4kskg888hzgq032mry5kxz9kptkvzsrz0fz0v7q6kxwk2
|
||||||
|
tmp=$(mktemp -d /tmp/backup-export.XXXXXX); trap 'rm -rf "$tmp"' EXIT
|
||||||
|
cd /
|
||||||
|
# tar exit 1 = «файл изменился при чтении» — не ошибка
|
||||||
|
{ rc=0; tar --numeric-owner --ignore-failed-read --warning=no-file-changed -cf - \
|
||||||
|
--exclude=home/mik/badvpn --exclude=home/mik/telemt-build --exclude=home/mik/cloudflared.deb \
|
||||||
|
--exclude=home/mik/.claude --exclude=home/mik/.cache --exclude='home/mik/*.log' --exclude=home/mik/nohup.out \
|
||||||
|
etc opt srv usr/local home/mik root var/spool/cron 2>/dev/null || rc=$?; [ $rc -le 1 ] || exit $rc; } \
|
||||||
|
| zstd -3 -T0 -q | /usr/local/bin/age -r "$PUB" | tee >(sha256sum > "$tmp/sum")
|
||||||
|
# процесс-подстановка живёт отдельно от конвейера — ждём, пока sha256sum допишет итог
|
||||||
|
for _ in $(seq 300); do [ -s "$tmp/sum" ] && break; sleep 0.1; done
|
||||||
|
echo "SHA256 $(cut -d' ' -f1 "$tmp/sum")" >&2
|
||||||
Reference in New Issue
Block a user