Compare commits
39
Commits
b0faa0e019
...
rpi-media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
705a7dce99 | ||
|
|
94bc983401 | ||
|
|
f70d5c848f | ||
|
|
08aa787b95 | ||
|
|
880ad61253 | ||
|
|
7843c6e958 | ||
|
|
60558dc4b3 | ||
|
|
f9d027ba86 | ||
|
|
69c02162db | ||
|
|
6d7dc4c8f1 | ||
|
|
0ffcb63b3c | ||
|
|
89ca3cea03 | ||
|
|
facc6c1e65 | ||
|
|
b881d861f0 | ||
|
|
bc0537a2d9 | ||
|
|
931219ad47 | ||
|
|
d780c91bd5 | ||
|
|
629b11f03f | ||
|
|
4965f70299 | ||
|
|
464e12abdb | ||
|
|
3811483577 | ||
|
|
550c359e4a | ||
|
|
c1f16ebc38 | ||
|
|
0a6d728851 | ||
|
|
bba3858894 | ||
|
|
7d0a891793 | ||
|
|
e645377916 | ||
|
|
ef2891323c | ||
|
|
65a01740e8 | ||
|
|
2bb74f7430 | ||
|
|
d1269162e7 | ||
|
|
3604e843c8 | ||
|
|
2ab38bc1f8 | ||
|
|
123cdaa19b | ||
|
|
11531ceb16 | ||
|
|
b56c137b5c | ||
|
|
4308c5c7e1 | ||
|
|
a65d6a249e | ||
|
|
bf4708b775 |
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: server-audit
|
||||||
|
description: Сверить фактическое состояние сервера с его описанием в servers/*.md и обновить описание. Использовать после любых работ на сервере (поднял/погасил сервис, поменял конфиг, удалил каталог, завёл ключ), а также когда просят проверить, соответствует ли дока реальности, актуализировать описание хоста или разобраться, что на сервере вообще происходит.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Сверка описания сервера с фактом
|
||||||
|
|
||||||
|
Цель — чтобы `servers/<хост>.md` описывал сервер таким, какой он есть, и
|
||||||
|
чтобы по нему можно было принимать решения. Правила оформления — в
|
||||||
|
`CLAUDE.md` репозитория, здесь метод и грабли.
|
||||||
|
|
||||||
|
## Порядок
|
||||||
|
|
||||||
|
1. **Собери факт до чтения доки.** Сначала `./scripts/snapshot.sh <хост>`,
|
||||||
|
потом читай описание. Наоборот — начнёшь подгонять наблюдения под текст.
|
||||||
|
2. **Сверяй по пунктам, не по впечатлению.** Прогоняй утверждения доки
|
||||||
|
через факт по одному: версии, размеры, пути, порты, «включено/выключено».
|
||||||
|
При сверке fr1 2026-09-08 неверными оказались десять утверждений, включая
|
||||||
|
«IPv6 не работает» про работавший IPv6 и версию Gitea на два минора назад.
|
||||||
|
3. **Добери то, что требует root** — snapshot это помечает. Неинтерактивный
|
||||||
|
sudo не проходит: нужен `ssh -t <хост> 'sudo ...'`, то есть попроси
|
||||||
|
пользователя выполнить, а не пытайся обойти.
|
||||||
|
4. **Правь описание в том же прогоне.** Не откладывай на «потом опишу».
|
||||||
|
5. **Незакрытое — в «Открытые хвосты»** файла хоста. Токен, который не
|
||||||
|
удалось отозвать; права, которые не поправил; решение, которое за
|
||||||
|
пользователя не принимаешь.
|
||||||
|
|
||||||
|
## Где дока врёт чаще всего
|
||||||
|
|
||||||
|
- **Версии и размеры** — меняются молча при обновлении контейнера. Смотри
|
||||||
|
фактический тег у запущенного контейнера, а не запись в compose: они
|
||||||
|
расходятся, и тогда `docker compose up -d` откатывает сервис.
|
||||||
|
- **«Не работает»** — могло починиться на стороне провайдера. И наоборот:
|
||||||
|
IPv6 на fr1 за один день был `FAILED`, потом `REACHABLE`, потом снова
|
||||||
|
`FAILED` — такое надо описывать как мигающее, а не как состояние.
|
||||||
|
- **Открытые порты.** `ufw status` не отвечает на вопрос «что торчит
|
||||||
|
наружу»: docker публикует порты в iptables напрямую, минуя ufw. Только
|
||||||
|
`ss -tlnH` + `docker ps`. Непубликуемый порт контейнера всё равно
|
||||||
|
доступен с хоста по адресу контейнера — «внутренняя сеть» не периметр.
|
||||||
|
- **Процессы вне контейнеров.** `docker ps` их не покажет, а они не
|
||||||
|
переживут перезагрузку. Ищи `ps` с `PPID=1` и чужими пользователями.
|
||||||
|
- **Кроны других пользователей** — в `docker ps` и в `crontab -l` их нет.
|
||||||
|
Осиротевшее задание может годами падать молча: MTA не установлен, вывод
|
||||||
|
крона теряется.
|
||||||
|
- **Планы, записанные как факт.** «Планируется перенести DNS» при уже
|
||||||
|
перенесённом DNS. Проверяй раздел «планируется» отдельно.
|
||||||
|
|
||||||
|
## Грабли, которые стоили времени (не повторяй)
|
||||||
|
|
||||||
|
- **Пути в БД абсолютные.** Прежде чем контейнеризовать сервис, проверь
|
||||||
|
**все** префиксы путей в его базе, а не только основной каталог данных:
|
||||||
|
`select substr(file_path,1,30), count(*) from images group by 1;`
|
||||||
|
Монтируй в тот же путь, что на хосте, иначе часть данных пропадёт молча.
|
||||||
|
- **`up -d --dry-run` перед любым compose-действием.** Он показывает, что
|
||||||
|
compose собирается сделать, — например поднять забытый контейнер того же
|
||||||
|
проекта и словить конфликт портов.
|
||||||
|
- **Задание может глушить свои ошибки.** У imgbot `analyze` логирует
|
||||||
|
исключение и возвращается нормально, поэтому планировщик пишет
|
||||||
|
`executed successfully` после падения. Отсутствие ошибок не доказывает
|
||||||
|
работу — проверяй результат, а не статус.
|
||||||
|
- **Ретенция.** journald был на 500M (11 дней), sysstat на `HISTORY=7`.
|
||||||
|
Сервер, где разбирают аварии, обязан хранить историю дольше, чем живёт
|
||||||
|
проблема. У sysstat при `HISTORY>28` имена переключаются на
|
||||||
|
`saYYYYMMDD` — иначе поднятие числа бессмысленно.
|
||||||
|
- **Группа `docker` = root.** Пользователь без sudo, но в группе docker,
|
||||||
|
монтирует `/` в контейнер и делает что угодно. Учитывай при описании
|
||||||
|
доступа.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Перед отчётом прогони по получившемуся файлу: каждое ⚠️ подтверждено
|
||||||
|
наблюдением? у каждого изменения указана дата и прежнее значение? не
|
||||||
|
попали ли в текст секреты? если роль хоста изменилась — поправлена ли
|
||||||
|
строка в `AGENTS.md`?
|
||||||
@@ -5,3 +5,4 @@ secrets/
|
|||||||
alertmanager/telegram_token
|
alertmanager/telegram_token
|
||||||
**/telegram_token
|
**/telegram_token
|
||||||
stacks/ipcam/.env
|
stacks/ipcam/.env
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -3,8 +3,20 @@
|
|||||||
Описание состояния серверов.
|
Описание состояния серверов.
|
||||||
|
|
||||||
## Серверы
|
## Серверы
|
||||||
- [`servers/de1.md`](servers/de1.md) — `ruzzy.dev` (213.136.74.247). Действующая прод-нода: прокси/VPN (telemt, AmneziaWG, OpenVPN), gitea, openclaw, боты. Источник для переноса.
|
- [`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.
|
- [`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 до de1, WireGuard клиентов, telemt), умный роутинг RU/зарубеж.
|
- [`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` в том же прогоне. Контракт и
|
||||||
|
правила оформления — в [`CLAUDE.md`](CLAUDE.md), метод сверки и накопленные
|
||||||
|
грабли — в скилле [`server-audit`](.claude/skills/server-audit/SKILL.md),
|
||||||
|
инвентаризация живого состояния одной командой:
|
||||||
|
|
||||||
|
./scripts/snapshot.sh rus-161
|
||||||
|
|
||||||
|
Скрипт только читает, sudo не требует и секретов не печатает; то, что
|
||||||
|
доступно лишь под root, он помечает и подсказывает команду.
|
||||||
|
|
||||||
Секреты (пароли, токены) в репозитории не хранятся — только на серверах.
|
Секреты (пароли, токены) в репозитории не хранятся — только на серверах.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Работа с этим репозиторием
|
||||||
|
|
||||||
|
Здесь описано состояние серверов: один файл на хост в `servers/`, стеки
|
||||||
|
docker-compose в `stacks/`, индекс в `AGENTS.md`.
|
||||||
|
|
||||||
|
## Главное правило
|
||||||
|
|
||||||
|
**Тронул сервер — обнови `servers/<хост>.md` в том же прогоне, до отчёта
|
||||||
|
пользователю.** Не «потом», не «если попросят». Расхождение описания с
|
||||||
|
фактом опаснее отсутствия описания: по доке принимают решения, и неверная
|
||||||
|
строка стоит дороже пустой.
|
||||||
|
|
||||||
|
Это относится к любому изменению состояния: поднял или погасил сервис,
|
||||||
|
поменял конфиг, удалил каталог, завёл ключ, отключил юнит. Если менял
|
||||||
|
что-то в `stacks/`, проверь, что описание сервиса в `servers/` ему не
|
||||||
|
противоречит.
|
||||||
|
|
||||||
|
## Перед тем как что-то описывать — проверь
|
||||||
|
|
||||||
|
Доке не верь, сверяй с живым сервером. При последней сверке (2026-09-08)
|
||||||
|
десяток утверждений в `servers/fr1.md` оказались неверными: версии,
|
||||||
|
размеры, «не работает» про то, что уже работало.
|
||||||
|
|
||||||
|
Подробный чеклист и метод — в скилле `server-audit`
|
||||||
|
(`.claude/skills/server-audit/`), инвентаризация одной командой —
|
||||||
|
`scripts/snapshot.sh <ssh-хост>`.
|
||||||
|
|
||||||
|
## Как писать
|
||||||
|
|
||||||
|
Стиль этого репо — не список фактов, а факт плюс причина и грабли:
|
||||||
|
|
||||||
|
- **Почему так, а не иначе.** «`node-exporter` в host-network» бесполезно
|
||||||
|
без «иначе видел бы только сеть docker-бриджа».
|
||||||
|
- **Что сломается молча.** Помечай `⚠️`. Такие места — главная ценность
|
||||||
|
файла: `ufw` не управляет docker-портами; путь монтирования обязан
|
||||||
|
совпадать с абсолютными путями в БД; `HISTORY=7` у sysstat хранит неделю.
|
||||||
|
- **Дату и прежнее значение** при изменении: «поднято до 365 (было 7)».
|
||||||
|
- **Незакрытое — в «Открытые хвосты»**, а не в никуда. Если оставил
|
||||||
|
токен, задачу или сомнительные права — запиши, иначе это потеряется.
|
||||||
|
- **Секретов здесь нет.** Токены, пароли, ключи — только на сервере.
|
||||||
|
Ссылаться на путь к секрету можно, содержимое — нельзя.
|
||||||
|
- Роль хоста поменялась — поправь и строку в `AGENTS.md`.
|
||||||
|
|
||||||
|
## Чего не делать
|
||||||
|
|
||||||
|
- Не описывай планы как факт. «Планируется» и «сделано» — разные разделы.
|
||||||
|
- Не удаляй ⚠️-заметку, потому что «выглядит устаревшей». Сначала проверь
|
||||||
|
на сервере, потом переписывай, указав дату проверки.
|
||||||
|
- Не коммить в `main` напрямую, если пользователь не просил.
|
||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Инвентаризация живого состояния сервера — только чтение, sudo не требуется.
|
||||||
|
#
|
||||||
|
# Смысл: сверка описания с фактом стоит дорого не из-за писанины, а из-за
|
||||||
|
# выяснения, что на сервере вообще происходит. Этот скрипт собирает всё, что
|
||||||
|
# понадобилось при сверке fr1 2026-09-08, за один проход.
|
||||||
|
#
|
||||||
|
# ./scripts/snapshot.sh rus-161 # по ssh
|
||||||
|
# ./scripts/snapshot.sh rus-161 > /tmp/fr1.txt
|
||||||
|
#
|
||||||
|
# Секретов не печатает: содержимое .env, токенов и ключей не читается —
|
||||||
|
# только факт наличия файла и права.
|
||||||
|
#
|
||||||
|
# Часть данных требует root и будет помечена как недоступная: правила ufw,
|
||||||
|
# сертификаты, /var/log/syslog, кроны других пользователей. Их надо
|
||||||
|
# добирать вручную через `ssh -t <хост> sudo ...`.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HOST="${1:-}"
|
||||||
|
[ -z "$HOST" ] && { echo "usage: $0 <ssh-host>" >&2; exit 2; }
|
||||||
|
|
||||||
|
ssh -o BatchMode=yes "$HOST" 'bash -s' <<'REMOTE'
|
||||||
|
set +e
|
||||||
|
h() { printf "\n\n===== %s =====\n" "$1"; }
|
||||||
|
|
||||||
|
h "СИСТЕМА"
|
||||||
|
uname -srm; . /etc/os-release && echo "$PRETTY_NAME"
|
||||||
|
echo "hostname: $(hostname) / $(hostname -f 2>/dev/null)"
|
||||||
|
uptime; who -b
|
||||||
|
echo "vCPU: $(nproc)"; free -h | head -2; df -h / | tail -1
|
||||||
|
ls /var/run/reboot-required 2>/dev/null && echo "ТРЕБУЕТСЯ ПЕРЕЗАГРУЗКА"
|
||||||
|
|
||||||
|
h "ПОЛЬЗОВАТЕЛИ И ДОСТУП"
|
||||||
|
getent passwd | awk -F: '$3>=1000 && $3<65000 {print $1" uid="$3" shell="$7}'
|
||||||
|
echo "группа docker (= root-эквивалент): $(getent group docker)"
|
||||||
|
echo "группа sudo: $(getent group sudo)"
|
||||||
|
grep -hE "^(PermitRootLogin|PasswordAuthentication|AllowUsers|MaxAuthTries)" \
|
||||||
|
/etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null | sort -u
|
||||||
|
|
||||||
|
h "СЕТЬ"
|
||||||
|
ip -br addr | grep -vE "^(veth|br-|docker)"
|
||||||
|
ip route | head -5
|
||||||
|
ip -6 route | grep default
|
||||||
|
echo "-- NDP шлюза (FAILED = IPv6 не работает) --"; ip -6 neigh | grep -E "fe80::1 |router"
|
||||||
|
resolvectl status 2>/dev/null | grep -A1 "Current DNS"
|
||||||
|
|
||||||
|
h "ЧТО СЛУШАЕТ НАРУЖУ (а не ufw status!)"
|
||||||
|
# ufw НЕ управляет docker-портами: docker пишет их в iptables напрямую,
|
||||||
|
# поэтому единственный честный ответ даёт ss + docker ps.
|
||||||
|
ss -tlnH | awk '{print $4}' | sort -u
|
||||||
|
echo "-- udp --"; ss -ulnH | awk '{print $4}' | sort -u
|
||||||
|
|
||||||
|
h "DOCKER"
|
||||||
|
docker --version 2>/dev/null; docker compose version 2>/dev/null
|
||||||
|
docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null
|
||||||
|
echo; docker system df 2>/dev/null
|
||||||
|
echo "-- откуда запущен каждый контейнер (compose-проект и файл) --"
|
||||||
|
for c in $(docker ps -aq 2>/dev/null); do
|
||||||
|
docker inspect "$c" --format '{{.Name}} <- {{index .Config.Labels "com.docker.compose.project.config_files"}}'
|
||||||
|
done
|
||||||
|
echo "-- монтирования (host -> container) --"
|
||||||
|
for c in $(docker ps -aq 2>/dev/null); do
|
||||||
|
docker inspect "$c" --format '{{.Name}}:{{range .Mounts}} {{.Source}}->{{.Destination}}{{end}}'
|
||||||
|
done
|
||||||
|
echo "-- тома и их размер --"
|
||||||
|
for v in $(docker volume ls -q 2>/dev/null); do
|
||||||
|
printf "%s %s\n" "$(docker run --rm -v "$v":/v alpine du -sh /v 2>/dev/null | cut -f1)" "$v"
|
||||||
|
done
|
||||||
|
echo "-- сироты (dangling) --"; docker volume ls -qf dangling=true 2>/dev/null
|
||||||
|
|
||||||
|
h "ПРОЦЕССЫ ВНЕ КОНТЕЙНЕРОВ И ВНЕ SYSTEMD"
|
||||||
|
# Такие процессы не переживут перезагрузку и не видны cAdvisor.
|
||||||
|
ps -eo user:12,pid,ppid,etime:12,%cpu,rss,args --sort=-rss \
|
||||||
|
| grep -vE "\[|containerd-shim|/usr/lib/systemd|^USER" | head -25
|
||||||
|
|
||||||
|
h "РАСПИСАНИЯ"
|
||||||
|
echo "-- /etc/cron.d --"; for f in /etc/cron.d/*; do [ -f "$f" ] && { echo "# $f"; grep -vE '^#|^$|^SHELL|^PATH' "$f"; }; done
|
||||||
|
for d in hourly daily weekly monthly; do echo "-- cron.$d: $(ls /etc/cron.$d 2>/dev/null | tr '\n' ' ')"; done
|
||||||
|
echo "-- crontab текущего пользователя --"; crontab -l 2>/dev/null | grep -vE '^#|^$'
|
||||||
|
echo "-- кроны остальных пользователей: нужен root --"
|
||||||
|
sudo -n ls /var/spool/cron/crontabs 2>/dev/null || echo " (недоступно: ssh -t <хост> 'sudo ls /var/spool/cron/crontabs')"
|
||||||
|
echo "-- таймеры --"; systemctl list-timers --all --no-pager 2>/dev/null | head -20
|
||||||
|
|
||||||
|
h "NGINX / TLS"
|
||||||
|
nginx -v 2>&1; ls -l /etc/nginx/sites-enabled/ 2>/dev/null
|
||||||
|
grep -hE "server_name|proxy_pass|root |listen " /etc/nginx/sites-available/*.conf 2>/dev/null | sed 's/^ *//' | sort -u
|
||||||
|
echo "-- сертификаты: нужен root --"
|
||||||
|
sudo -n ls /etc/letsencrypt/live 2>/dev/null || echo " (недоступно)"
|
||||||
|
|
||||||
|
h "ЛОГИ И ИСТОРИЯ (для будущих разборов)"
|
||||||
|
cat /etc/systemd/journald.conf.d/*.conf 2>/dev/null | grep -vE '^#|^$'
|
||||||
|
journalctl --disk-usage 2>/dev/null | tail -1
|
||||||
|
echo "-- sysstat: глубина истории --"
|
||||||
|
grep -E '^HISTORY|^COMPRESSAFTER' /etc/sysstat/sysstat 2>/dev/null
|
||||||
|
ls /var/log/sysstat 2>/dev/null | head -3
|
||||||
|
echo " (файлы saDD = максимум месяц; saYYYYMMDD = HISTORY>28)"
|
||||||
|
|
||||||
|
h "ОБНОВЛЕНИЯ"
|
||||||
|
pro status --format json 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print("Ubuntu Pro:", d["account"]["name"]); [print(" ",s["name"],s["status"]) for s in d["services"] if s["status"]=="enabled"]' 2>/dev/null
|
||||||
|
grep -hE "Update-Package-Lists|Unattended-Upgrade " /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null
|
||||||
|
snap list 2>/dev/null | tail -n +2 | awk '{print " snap:",$1,$2}'
|
||||||
|
snap refresh --time 2>/dev/null | head -2
|
||||||
|
|
||||||
|
h "ЧЕГО ЗДЕСЬ НЕТ — ДОБРАТЬ ВРУЧНУЮ ПОД ROOT"
|
||||||
|
cat <<'HINT'
|
||||||
|
ssh -t <хост> "sudo bash -c '
|
||||||
|
ufw status verbose
|
||||||
|
certbot certificates | grep -E \"Certificate Name|Expiry\"
|
||||||
|
ls /var/spool/cron/crontabs && for f in /var/spool/cron/crontabs/*; do echo \$f; grep -v ^# \$f; done
|
||||||
|
awg show 2>/dev/null | head -20
|
||||||
|
grep -c suspended /var/log/syslog
|
||||||
|
'"
|
||||||
|
HINT
|
||||||
|
REMOTE
|
||||||
+202
-18
@@ -3,18 +3,23 @@
|
|||||||
Снимок состояния сервера. Секреты (пароли/токены) — только на сервере, не здесь.
|
Снимок состояния сервера. Секреты (пароли/токены) — только на сервере, не здесь.
|
||||||
|
|
||||||
**Провайдер:** Contabo VPS · France / Lauterbourg (Grand Est)
|
**Провайдер:** Contabo VPS · France / Lauterbourg (Grand Est)
|
||||||
**ОС:** Ubuntu 24.04 LTS · kernel 6.8.0-106-generic
|
**ОС:** Ubuntu 24.04.4 LTS · kernel 6.8.0-137-generic (с 2026-08-07; ранее 6.8.0-134 с 2026-07-31, до того 6.8.0-106)
|
||||||
**Ресурсы:** 8 vCPU / 23 GB RAM / 387 GB
|
**Ресурсы:** 8 vCPU / 23 GB RAM / 387 GB
|
||||||
**hostname:** `fr1` (FQDN `fr1.ruzzy.dev` через `/etc/hosts`)
|
**hostname:** `fr1` (FQDN `fr1.ruzzy.dev` через `/etc/hosts`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Пользователи и доступ
|
## Пользователи и доступ
|
||||||
- Пользователь `rus` (uid 1000, группы `rus`, `sudo`, `users`), sudo по паролю.
|
- Пользователь `rus` (uid 1000, группы `rus`, `sudo`, `users`, `docker`, `tgclaude`), sudo по паролю.
|
||||||
|
- Пользователь `tgclaude` (uid 1001, группы `tgclaude`, `users`, `docker`) — аккаунт агента **Astra**, вход по SSH разрешён, sudo нет. `/home/tgclaude` — **21 ГБ**, крупнейший потребитель диска. Подробно — в разделе «Пользователь `tgclaude`».
|
||||||
|
- ⚠️ **Группа `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`.
|
||||||
|
|
||||||
```
|
```
|
||||||
PermitRootLogin no
|
PermitRootLogin no
|
||||||
PasswordAuthentication no
|
PasswordAuthentication no
|
||||||
@@ -22,18 +27,34 @@ PubkeyAuthentication yes
|
|||||||
KbdInteractiveAuthentication no
|
KbdInteractiveAuthentication no
|
||||||
ChallengeResponseAuthentication no
|
ChallengeResponseAuthentication no
|
||||||
MaxAuthTries 3
|
MaxAuthTries 3
|
||||||
AllowUsers rus
|
AllowUsers rus tgclaude
|
||||||
```
|
```
|
||||||
Через socket-activation (`ssh.socket`).
|
Через socket-activation (`ssh.socket`).
|
||||||
|
|
||||||
|
## Сеть
|
||||||
|
- Управляется **netplan → systemd-networkd**, конфиг `/etc/netplan/50-cloud-init.yaml` (наследие cloud-init, сейчас просто статический файл).
|
||||||
|
- Матч интерфейса **по MAC `00:50:56:64:ff:15`** + `set-name: eth0`. Ядро называет карту `ens18`, netplan переименовывает её в **`eth0`** через сгенерированный `/run/systemd/network/10-netplan-eth0.link`.
|
||||||
|
- IPv4 `161.97.93.252/18`, GW `161.97.64.1`. DNS `195.179.224.53` / `209.126.15.53`.
|
||||||
|
- ⚠️ IPv6 `2a02:c207:2338:7709::1/64`, дефолт `via fe80::1 onlink` — **мигает, а не «работает» или «не работает»**. Хронология одного дня, 2026-09-08: утром `fe80::1` в `ip -6 neigh` был `REACHABLE` и `ping6` наружу шёл без потерь (до этого месяцами `FAILED`); к 15:50 снова `FAILED`, `ping6` — 100% потерь. Конфиг на сервере не менялся ни разу, то есть причина целиком на стороне Contabo. Практический вывод: **не завязывать ничего на IPv6 этого хоста** и не считать очередное «заработало» починкой. IPv4 при этом не страдает.
|
||||||
|
- ⚠️ **На имя `eth0` завязано** NAT-правило AmneziaWG (`-o eth0 MASQUERADE` в `wg0.conf`) и правила ufw. Если netplan не отработает, интерфейс останется `ens18` и VPN-выход молча сломается.
|
||||||
|
- **Страховка `net-rescue`** (заведена 2026-08-01): `/usr/local/sbin/net-rescue.sh` + `net-rescue.timer` (`OnBootSec=150`, enabled). Идемпотентно находит карту по MAC — независимо от её имени — и доставляет адрес, дефолтный маршрут и DNS, если их нет. Пишет в journal тегом `net-rescue`. Смысл: пережить повторение аварии 2026-07-31 без похода в VNC.
|
||||||
|
- **Третий механизм правки маршрута — `/etc/cron.d/staticroute`** (из образа Contabo, не наш): `@reboot root ip route replace $(link-scope сеть eth0) via $(дефолтный GW) dev eth0`. Аргументы вычисляет на ходу из текущей таблицы, поэтому если адреса на eth0 нет вообще — тихо ничего не делает. Дублирует задачу `net-rescue`, но вслепую и без привязки к MAC. Знать о нём надо: при разборе «кто поправил маршрут» это третий кандидат после netplan и net-rescue.
|
||||||
|
- cloud-init установлен, но сетевой конфиг ему запрещён: `/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg` (`network: {config: disabled}`) — иначе перезапишет netplan.
|
||||||
|
|
||||||
## DNS
|
## DNS
|
||||||
- Зона `ruzzy.dev` — Namecheap (NS `dns1/dns2.registrar-servers.com`).
|
- Зона `ruzzy.dev` — Namecheap (NS `dns1/dns2.registrar-servers.com`).
|
||||||
- `fr1.ruzzy.dev` A → 161.97.93.252.
|
- `fr1.ruzzy.dev` A → 161.97.93.252. Апекс `ruzzy.dev` A → 161.97.93.252 (переведён с de1).
|
||||||
|
- CNAME → `fr1.ruzzy.dev`: `git`, `mon`, `www`, `share`.
|
||||||
|
- ⚠️ `cam.ruzzy.dev` (CNAME → fr1) остался в Namecheap после удаления ipcam 2026-09-08 — снести руками.
|
||||||
- PTR (reverse DNS): дефолтный `vmi3387709.contaboserver.net` (не менялся).
|
- PTR (reverse DNS): дефолтный `vmi3387709.contaboserver.net` (не менялся).
|
||||||
|
|
||||||
## Firewall (ufw)
|
## Firewall (ufw)
|
||||||
- Default: deny incoming, allow outgoing.
|
- Default: deny incoming, allow outgoing.
|
||||||
- Открыто: `22/tcp`, `80/tcp`, `443/tcp` (IPv4 + IPv6).
|
- Открыто (IPv4 + IPv6): `22/tcp` (OpenSSH), `80/tcp`, `443/tcp`, `2222/tcp` (gitea-ssh), `36360/udp` (awg-de-exit).
|
||||||
|
- `9100/tcp` — только `from 172.16.0.0/12` (node-exporter для Prometheus в docker).
|
||||||
|
- Роутинг туннеля: `allow FWD wg0 ↔ eth0` (в обе стороны) + `allow in on wg0`.
|
||||||
|
- Плюс динамические REJECT-правила от fail2ban.
|
||||||
|
- ⚠️ **ufw НЕ управляет docker-портами.** Публикацию `ports: - "X:Y"` docker вписывает в iptables напрямую, минуя цепочки ufw: порт открыт в мир независимо от `deny incoming` и в `ufw status` не виден. Проверено 2026-09-08 — ipcam-FTPS месяцами держал наружу `2221` и `30000-30010`, правил ufw для них не было вообще. Список выше описывает **только host-сервисы**; чтобы контейнер не светил порт, биндить надо на `127.0.0.1:` (как у grafana/prometheus/alertmanager/gitea-web). Аудит открытого наружу — `ss -tlnH` + `docker ps`, а не `ufw status`.
|
||||||
|
|
||||||
## fail2ban (`/etc/fail2ban/jail.local`)
|
## fail2ban (`/etc/fail2ban/jail.local`)
|
||||||
```
|
```
|
||||||
@@ -49,38 +70,70 @@ backend=systemd banaction=ufw
|
|||||||
|
|
||||||
## Обновления
|
## Обновления
|
||||||
- Ubuntu Pro подключён (аккаунт ruzzyrullezz@gmail.com): **livepatch**, **esm-infra**, **esm-apps** — enabled.
|
- Ubuntu Pro подключён (аккаунт ruzzyrullezz@gmail.com): **livepatch**, **esm-infra**, **esm-apps** — enabled.
|
||||||
|
- livepatch приходит **снапом** `canonical-livepatch` (v11.0.2, rev 414); рядом только `core22` и `snapd` 2.76.3 — больше снапов на хосте нет. Состояние: `canonical-livepatch status` → ядро `6.8.0-137.137-generic`, `patchState: nothing-to-apply`, tier `updates`, поддержка этого ядра до **2027-08-17** (`upgradeRequiredDate` — после этой даты livepatch потребует апгрейда ядра).
|
||||||
|
- ⚠️ snapd обновляет себя и livepatch **сам, 4 раза в сутки, без окна обслуживания**: `snap refresh --time` → `timer: 00:00~24:00/4`, holds не выставлены. Это отдельный от apt канал изменений на хосте, и отчёт в Telegram про него не приходит (`uu-telegram-report.sh` висит только на apt).
|
||||||
- unattended-upgrades: `Update-Package-Lists "1"`, `Unattended-Upgrade "1"` (ежедневно, security). Авто-reboot выключен.
|
- unattended-upgrades: `Update-Package-Lists "1"`, `Unattended-Upgrade "1"` (ежедневно, security). Авто-reboot выключен.
|
||||||
|
|
||||||
## Система
|
## Система
|
||||||
- swap: `/swapfile` 2 GB (в `/etc/fstab`), `vm.swappiness=10` (`/etc/sysctl.d/99-swappiness.conf`).
|
- swap: `/swapfile` 2 GB (в `/etc/fstab`), `vm.swappiness=10` (`/etc/sysctl.d/99-swappiness.conf`).
|
||||||
- journald: `Storage=persistent`, `SystemMaxUse=500M`.
|
|
||||||
- AppArmor включён; время — systemd-timesyncd.
|
- AppArmor включён; время — systemd-timesyncd.
|
||||||
|
|
||||||
|
## Логирование (для разборов) — пересобрано 2026-08-01
|
||||||
|
- journald: `Storage=persistent`; ретенция — дроп-ин `/etc/systemd/journald.conf.d/10-retention.conf`:
|
||||||
|
`SystemMaxUse=8G`, `MaxRetentionSec=1year`. **Было `SystemMaxUse=500M`** — журнал упирался в потолок и хранил всего ~11 дней, из-за чего при разборе аварии логи за 15 июля уже не существовали.
|
||||||
|
- История bash (в `.bashrc` у `rus` и у `root`): `HISTTIMEFORMAT='%F %T '`, `HISTSIZE=100000`, `HISTFILESIZE=200000`,
|
||||||
|
`HISTCONTROL=ignoredups` (было `ignoreboth` — команды с ведущим пробелом исчезали бесследно),
|
||||||
|
`PROMPT_COMMAND="history -a"` — сброс после каждой команды, чтобы жёсткий ресет не съедал сессию.
|
||||||
|
- ⚠️ Записи в `~/.bash_history` с датой **`1970-01-01` — это легаси до 2026-08-01**, реального времени у них нет. Проставлен сентинел `#1` перед каждой такой строкой, иначе bash рисовал бы им текущее время и вводил в заблуждение при разборе.
|
||||||
|
- rsyslog дублирует auth/syslog в `/var/log/*.log` (ротация ~4 недели) — это спасло разбор, когда journald уже вычистил июль.
|
||||||
|
- **sysstat / `sar`** — исторические CPU, память, диски, сеть с шагом 10 минут. Собирает `sysstat-collect.timer` (`OnCalendar=*:00/10` → `/usr/lib/sysstat/sa1`), бинарные срезы в `/var/log/sysstat/saNN` (NN — день месяца), ежедневный текстовый отчёт `sarNN` от `sysstat-summary.timer`. Читать: `sar -u -f /var/log/sysstat/sa07` (CPU), `-r` память, `-d` диски, `-n DEV` сеть. Ценность именно в том, что пишет **системный таймер, а не контейнер**: данные остаются, даже когда лежал сам docker с Prometheus.
|
||||||
|
- **Ретенция sysstat — пересобрана 2026-09-08.** Было `HISTORY=7`: история жила 7–8 дней, файлы затирались по кругу номером дня месяца — ровно та же ловушка, что была с journald на 500M (при разборе аварии месячной давности загрузки уже нет, а sysstat держат именно за этим). Стало **`HISTORY=365`**, `ENABLED="true"` в `/etc/default/sysstat`, мёртвый `/etc/cron.d/sysstat` удалён.
|
||||||
|
- Важная механика: при `HISTORY > 28` скрипт `/usr/lib/sysstat/sa1` сам добавляет `sadc -D`, и имена файлов переключаются с `saDD` на **`saYYYYMMDD`** — без этого поднятие HISTORY было бы бесполезным, срезы всё равно перезаписывались бы каждый месяц. Первый датированный файл появляется на следующем срабатывании таймера. Чистку и сжатие делает `sa2` (`find -mtime +HISTORY` → удаление, `+COMPRESSAFTER=10` → xz); при старом `HISTORY=7` сжатие не срабатывало никогда, теперь работает.
|
||||||
|
- Старые файлы эры 7 дней (`sa31`, `sa01…sa08`) остаются рядом ещё год и читаются как обычно: `sar -u -f /var/log/sysstat/sa07`.
|
||||||
|
- Почему `/etc/cron.d/sysstat` удалён без сожалений: оба его правила искали `debian-sa1`, которого в Ubuntu 24.04 нет (бинарь называется `sa1`) — то есть это был no-op. Сбором всё время занимался systemd-таймер, дублей в данных не было (срезы ровно в :00/:10/:20…). Флаг `ENABLED="false"` там же был легаси и на таймер не влиял — выставлен в `true` для соответствия факту.
|
||||||
|
- **Известные дыры:** команды без sudo (`docker stop`, `mpstat`, …) не фиксируются нигде; `sudo su` — слепая зона. `snoopy`/`auditd` и `log_output` в sudoers сознательно НЕ ставили.
|
||||||
|
- ⚠️ **Открытый баг:** `rsyslogd` периодически валится в `action 'action-0-builtin:omfile' suspended` и в эти окна теряет строки. Наблюдался и 25.07, и перед падением 31.07. Не чинилось.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
- Docker 29.6 + Compose v5.1 (официальный репозиторий). `rus` в группе docker.
|
- Docker 29.6 + Compose v5.1 (официальный репозиторий). `rus` в группе docker.
|
||||||
|
|
||||||
## nginx + TLS
|
## nginx + TLS
|
||||||
- nginx 1.24 (apt) + certbot 2.9 (`python3-certbot-nginx`), автопродление через systemd-таймер certbot.
|
- nginx 1.24 (apt) + certbot 2.9 (`python3-certbot-nginx`), автопродление через systemd-таймер certbot.
|
||||||
- vhost `/etc/nginx/sites-available/mon.ruzzy.dev.conf` → проксирует на Grafana (`127.0.0.1:3000`), HTTPS-редирект, websocket.
|
- vhost `/etc/nginx/sites-available/mon.ruzzy.dev.conf` → проксирует на Grafana (`127.0.0.1:3000`), HTTPS-редирект, websocket.
|
||||||
|
- vhost'ы `share.ruzzy.dev.conf` и `www.ruzzy.dev.conf` — статика с **общим** корнем `/var/www/share.ruzzy.dev` (`autoindex off`, `try_files $uri =404`, `server_tokens off`), у каждого свой сертификат. Используется для раздачи клиентских конфигов OpenVPN — сейчас там `marina-de.ovpn`, `masha-de.ovpn`.
|
||||||
|
- vhost `default` — catch-all `server_name _`, `listen 80 default_server`, отдаёт дефолтную страницу `/var/www/html/index.nginx-debian.html`. На `:443` `default_server` **не задан**, поэтому TLS-запрос с неизвестным SNI попадает на первый по алфавиту vhost (`git.ruzzy.dev`) и получает его сертификат.
|
||||||
|
- Сертификаты 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`. Скрейпит node-exporter + cadvisor (job'ы `node`/`cadvisor`, instance `fr1`).
|
- 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, папка 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` — пустой.
|
||||||
- **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 скрыты). ⚠️ для корректной сети на `de1` его агент тоже надо перевести в host-net (пока bridge).
|
- Дашборд **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.
|
||||||
- Планируется: VPN-экспортёры (wireguard/openvpn/telemt) + подключение `de1` по туннелю.
|
- **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 не трогали.
|
||||||
|
- В `~/bin/pushgw-ingest` есть режим `health` (с 2026-10-11): `ssh … health` отдаёт key=value о здоровье Prometheus/Alertmanager/pushgateway, свежести пушей хоста и счётчиках доставки в Telegram — только чтение. Им пользуется внешний сторож `monwatch` на rpi.
|
||||||
|
- ⚠️ 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.
|
||||||
|
|
||||||
## Gitea (`~/gitea`, stack в репо `stacks/gitea/`)
|
## Gitea (`~/gitea`, stack в репо `stacks/gitea/`)
|
||||||
- Перенесён с `de1` 2026-06-21. **Gitea 1.25.4**, БД SQLite (всё в `/srv/gitea`, 4.3 ГБ, 16 репо).
|
- Перенесён с `de1` 2026-06-21. **Gitea 1.27.3** (обновлён 2026-08-31 с 1.25.4), БД SQLite (всё в `/srv/gitea`, 1.9 ГБ, 20 репо).
|
||||||
|
- Пин образа в `~/gitea/docker-compose.yml` и в `stacks/gitea/` приведён к `1.27.3` **2026-09-08** — до этого оба файла держали `1.25.4`, и любой `docker compose up -d` откатил бы Gitea на две минорные версии. Бэкап прежнего файла — `~/gitea/docker-compose.yml.bak-20260908`.
|
||||||
|
- Запущенный контейнер `gitea` создан вне compose, поэтому `docker compose up -d` его **пересоздаст** — короткий простой, но данные на bind-mount не теряются. Конфликта больше нет: мешавший `gitea_old_1254` (он нёс метки того же compose-проекта и поднимался вторым, забирая порты 2222/3001) удалён 2026-09-08, `--dry-run` теперь показывает ровно одно `Container gitea Creating`.
|
||||||
|
- **Откат на 1.25.4 возможен — предмиграционный снимок есть:** `/home/tgclaude/backups/gitea-backup-20260831-045458.tar.gz`, 4.5 ГБ (`config/app.ini` + `data/`). Проверено 2026-09-08 распаковкой `data/gitea.db`: версия схемы в архиве — **323**, в живой БД — **343**, репозиториев 20, пользователей 4. То есть архив снят до миграции 1.25→1.27 и для откатa годен. Контейнер `gitea_old_1254` и образ `1.25.4` удалены 2026-09-08 (они как раз откатом **не** были — работали бы на уже миграцированных данных); при необходимости образ просто `docker pull gitea/gitea:1.25.4`.
|
||||||
|
- ⚠️ Этот снимок лежит в хоме `tgclaude` и до 2026-09-08 нигде не был описан — при уборке диска его легко снести по незнанию. Правило на будущее: перед апгрейдом Gitea снимать `/srv/gitea`, и складывать снимок туда, где он описан.
|
||||||
- Контейнер `gitea`: SSH `2222` (наружу), web `127.0.0.1:3001` (3000 занят Grafana → перевесил на 3001).
|
- Контейнер `gitea`: SSH `2222` (наружу), web `127.0.0.1:3001` (3000 занят Grafana → перевесил на 3001).
|
||||||
- nginx vhost `git.ruzzy.dev` → `127.0.0.1:3001`, TLS Let's Encrypt, `client_max_body_size 512M`.
|
- nginx vhost `git.ruzzy.dev` → `127.0.0.1:3001`, TLS Let's Encrypt, `client_max_body_size 512M`.
|
||||||
- DNS: `git.ruzzy.dev` (CNAME → fr1) → 161.97.93.252.
|
- DNS: `git.ruzzy.dev` (CNAME → fr1) → 161.97.93.252.
|
||||||
- `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 ГБ), это и есть точка бэкапа.
|
||||||
- Это 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.
|
||||||
@@ -89,13 +142,15 @@ backend=systemd banaction=ufw
|
|||||||
- **ufw:** `36360/udp` открыт + `ufw route allow in on wg0 out on eth0` (и обратно) + `ufw allow in on wg0` — **обязательно**, иначе ufw режет туннельный трафик (на `de1` ufw был выключен).
|
- **ufw:** `36360/udp` открыт + `ufw route allow in on wg0 out on eth0` (и обратно) + `ufw allow in on wg0` — **обязательно**, иначе ufw режет туннельный трафик (на `de1` ufw был выключен).
|
||||||
- Пиры: `ru1` (10.8.1.4) переключён сюда; прямые 10.8.1.1/2/3 пока на `de1`.
|
- Пиры: `ru1` (10.8.1.4) переключён сюда; прямые 10.8.1.1/2/3 пока на `de1`.
|
||||||
- Проверено: зарубежный exit RU-клиентов идёт через `fr1` (exit-IP 161.97.93.252).
|
- Проверено: зарубежный exit RU-клиентов идёт через `fr1` (exit-IP 161.97.93.252).
|
||||||
- ⏳ Остаётся (фаза 2): telemt MTProxy, OpenVPN, прямые AWG-клиенты, перенос DNS `ruzzy.dev`/`www`.
|
- ⏳ Остаётся (фаза 2): telemt MTProxy, OpenVPN, прямые AWG-клиенты. DNS `ruzzy.dev`/`www` — **уже переведены** на fr1.
|
||||||
|
|
||||||
## claude-channels (Telegram-бот @iluha_rabobot)
|
## claude-channels (Telegram-бот @iluha_rabobot)
|
||||||
- Перенесён с `de1` 2026-06-22. `~/claude-channels/` (Dockerfile Node22+Bun+Claude Code, compose, `.env` с токеном). Запущен, авторизован (Sonnet 4.6), подключён к Telegram.
|
- Перенесён с `de1` 2026-06-22. `~/claude-channels/` (Dockerfile Node22+Bun+Claude Code, compose, `.env` с токеном). Запущен, авторизован, подключён к Telegram. Claude Code 2.1.197; в `.claude.json` модели `sonnet-5[1m]`, `opus-4-8[1m]`, `opus-4-7`.
|
||||||
- Volume `claude-channels_claude-home` — только нужное: `.claude.json` + `.claude/.credentials.json` (авторизация) + settings + channels (allowlist). Плагины/кэш/история НЕ копировались (на ext4 раздувались до 6.5G из-за CoW на de1).
|
- Volume `claude-channels_claude-home` — при переносе положили только нужное: `.claude.json` + `.claude/.credentials.json` (авторизация) + settings + channels (allowlist). Плагины/кэш/история НЕ копировались (на de1 раздувались до 6.5 ГБ).
|
||||||
|
- ⚠️ Но том **раздулся заново: 2.8 ГБ** (проверено 2026-09-08) — второй по величине после prometheus_data. Экономия от аккуратного переноса съедена работой самого бота (кэш/история/плагины нарастают внутри контейнера). Если снова упрёмся в диск — смотреть сюда.
|
||||||
|
- ⚠️ В томе, помимо авторизации Claude, лежат **SSH-ключи**: `/root/.ssh/id_ed25519` (в Gitea как deploy-ключ, `Host git.ruzzy.dev`) и `/root/.ssh/astra_fr1` — ключ к самому хосту под `tgclaude` (см. раздел `tgclaude`). Том — чувствительный артефакт, обращаться как с секретом.
|
||||||
- **telegram-плагин переустановлен заново** в контейнере (`claude plugin install telegram@claude-plugins-official`, v0.0.6) — копировать его нельзя.
|
- **telegram-плагин переустановлен заново** в контейнере (`claude plugin install telegram@claude-plugins-official`, v0.0.6) — копировать его нельзя.
|
||||||
- Монтирует `~/MyVault` (`/vault`). ⚠️ Vault-синк в gitea пока НЕ активен (заведём с openclaw) — заметки бота копятся локально.
|
- Монтирует `~/MyVault` (`/vault`, 186 МБ). Vault-синк в gitea **работает**: cron у `rus` `*/5 * * * *` → `~/openclaw/sync.sh` — `git pull --rebase --autostash`, затем при изменениях коммит «vault sync <дата>» и push в `ruzzy/MyVault.git`. Ключ `~/.ssh/openclaw_gitea`, лог `~/.openclaw/logs/vault-sync.log`.
|
||||||
- Gmail MCP — без OAuth-кредов (gmail-инструменты не работают, на бот не влияет).
|
- Gmail MCP — без OAuth-кредов (gmail-инструменты не работают, на бот не влияет).
|
||||||
|
|
||||||
## Telegram-уведомления
|
## Telegram-уведомления
|
||||||
@@ -103,5 +158,134 @@ backend=systemd banaction=ufw
|
|||||||
- `/usr/local/bin/tg-notify.sh "msg"` — универсальная отправка (HTML, префикс hostname).
|
- `/usr/local/bin/tg-notify.sh "msg"` — универсальная отправка (HTML, префикс hostname).
|
||||||
- `/usr/local/bin/uu-telegram-report.sh` — сводка автообновлений + reboot-required; повешен на `apt-daily-upgrade.service` через drop-in `/etc/systemd/system/apt-daily-upgrade.service.d/telegram.conf` (`ExecStartPost`). Молчит, если нечего сообщать.
|
- `/usr/local/bin/uu-telegram-report.sh` — сводка автообновлений + reboot-required; повешен на `apt-daily-upgrade.service` через drop-in `/etc/systemd/system/apt-daily-upgrade.service.d/telegram.conf` (`ExecStartPost`). Молчит, если нечего сообщать.
|
||||||
|
|
||||||
|
## Пользователь `tgclaude` (агенты) и его нагрузки
|
||||||
|
Аккаунт uid 1001 — **общий вход для агентов на этот же хост**, не персональный. До 2026-09-08 был описан только в `~rus/MyVault/TOOLS.md` (шпаргалке агентов), в репо `infra` не значился вовсе. Его нагрузки не видны ни в `docker ps` (они не в контейнерах), ни при обходе `~rus`, поэтому при инвентаризации их легко пропустить. `rus` состоит в группе `tgclaude`, так что большая часть этого хома читается без sudo.
|
||||||
|
|
||||||
|
- Кто заходит: агент **Astra**, работающий **внутри контейнера `claude-channels`** (бот @iluha_rabobot). Ключ и конфиг лежат в самом контейнере: `/root/.ssh/astra_fr1` + `Host fr1-astra → HostName 161.97.93.252, User tgclaude, IdentitiesOnly yes` (созданы 2026-09-07), а публичная часть — в `authorized_keys` у `tgclaude`. То есть бот ходит по SSH на свой же хост.
|
||||||
|
- ⚠️ **Цепочка привилегий, которую стоит осознавать целиком:** Telegram-бот → агент в контейнере → SSH как `tgclaude` → группа `docker` → **root на хосте**. Ни sudo, ни пароля на этом пути не требуется. Всё, что ограничивает агента, — его собственная сдержанность.
|
||||||
|
- Ключ `astra_fr1` живёт в томе `claude-channels_claude-home`: пересоздание контейнера доступ не отберёт, а бэкап тома содержит рабочий ключ к хосту.
|
||||||
|
|
||||||
|
**imgbot** — `/home/tgclaude/imgbot` (209 МБ; python/uv, `.venv`, Playwright + chromium, есть `.git`). Headless-поиск и скачивание картинок по текстовому запросу: источники Bing, Yandex, Google и Reddit (по сохранённым кукам), Pexels API; дедуп SHA-256 + перцептивный dHash; SQLite-манифест; фоновая проверка релевантности vision-моделью через OpenRouter; MCP-сервер с инструментами `request_photos`, `list_saved`, `export_by_query`. Правится активно — README и `.git` от 2026-09-08.
|
||||||
|
- **Оба сервиса живут в контейнерах, стек — `stacks/imgbot/` в репо, на сервере `~/imgbot-daemon/`** (переведено 2026-09-08). Один образ `imgbot-daemon:local` (база `mcr.microsoft.com/playwright/python:v1.62.0-noble`), два сервиса:
|
||||||
|
- `imgbot-daemon` — APScheduler: `refresh` каждые 30 мин (докачка картинок) и `analyze` каждые 5 мин (vision-проверка релевантности, 3 запроса к OpenRouter на картинку). ~15% ядра постоянно, поэтому `cpus: 2.0`, `mem_limit: 3g`.
|
||||||
|
- `imgbot-http` — **объединил прежние `imgbot-mcp-http` (8765) и `imgbot-file-server` (8766)**: MCP отдаётся как под-приложение Starlette (`/mcp`), рядом маршрут `/photo/<id>`. Один процесс, один порт 8765.
|
||||||
|
- Ключевые решения по контейнерам (все с комментариями в compose, ломается молча, если забыть):
|
||||||
|
- **Данные монтируются в тот же путь, что на хосте** — `/home/tgclaude/imgbot-data:/home/tgclaude/imgbot-data`. В SQLite `images.file_path` абсолютный; при монтировании в `/data` analyze падал с `FileNotFoundError` на старых строках, а новые стали бы нечитаемыми для чего угодно вне контейнера.
|
||||||
|
- ⚠️ **Корень хранения может быть не один.** При переезде был пропущен второй — легаси-архив `~tgclaude/images`, куда смотрели 1350 строк манифеста, — и `/photo/<id>` полтора часа отдавал 404 на 19.5% коллекции **молча**: в логах ничего, потому что `analyze` эти строки уже не трогал. Архив с тех пор удалён (см. «Выведено»), сейчас корень один. **Правило: перед контейнеризацией проверять все префиксы `file_path`, а не только `data_dir`** — `select substr(file_path,1,30), count(*) from images group by 1;`
|
||||||
|
- Исходники монтируются **read-only из рабочей копии** `/home/tgclaude/imgbot:/app:ro` — правки применяются `docker restart`, без пересборки. В образе только зависимости и chromium.
|
||||||
|
- `HOME=/tmp` обязателен: `collector.py` безусловно вызывает `Path.home()`, а uid 1001 не имеет записи в `/etc/passwd` образа.
|
||||||
|
- Версия `playwright` в образе (1.62.0) должна совпадать с `uv.lock` и тегом базового образа — иначе разъезжается ревизия chromium.
|
||||||
|
- `/etc/localtime` + `/etc/timezone` внутрь: иначе контейнер писал в общий `daemon.log` в UTC поверх строк в CEST.
|
||||||
|
- `shm_size: 1gb` — chromium падает на дефолтных 64 МБ.
|
||||||
|
- **Сеть и доступ:** `imgbot-http` подключён к `imgbot_default` и к внешней `claude-channels_default`, порт на хост **не публикуется**. Агент внутри `claude-channels` обращается по имени `http://imgbot-http:8765`. Проверено 2026-09-08: `MCP initialize` → 200 с `serverInfo`, `/photo/1` → 200 `image/jpeg`, несуществующий id → 404.
|
||||||
|
- ⚠️ **Авторизации нет сознательно.** Прежде токен `IMGBOT_FILE_SERVER_TOKEN` закрывал только `/photo`, а `/mcp` пускал кого угодно — проверено запросом без заголовков. Держать одну дверь на замке при открытой второй смысла не было, поэтому убрали и токен, и упоминания о нём в docstring и README. Обоснование: снаружи адреса нет, попасть можно лишь с самой машины и из подключённых docker-сетей. **Если сервис когда-нибудь выставят наружу — авторизацию вернуть первым делом:** `request_photos` тратит бюджет API и работает под живыми куками Google/Reddit, `export_by_query` копирует файлы.
|
||||||
|
- ⚠️ У MCP **своя защита от DNS-rebinding**: по умолчанию принимает только `localhost`, на `Host: imgbot-http:8765` отвечал `421 Invalid Host header`. Список задаётся `IMGBOT_ALLOWED_HOSTS` (сейчас `imgbot-http:8765,localhost:8765,127.0.0.1:8765`) — при смене имени сервиса или порта его надо править, иначе получишь загадочный 421.
|
||||||
|
- **Код проекта теперь в Gitea** — `ruzzy/imgbot` (private), создан 2026-09-08. До этого репозиторий существовал единственной копией на fr1: без remote, без бэкапа. Push идёт по deploy-ключу `~tgclaude/.ssh/imgbot_gitea` (с правом записи), remote `ssh://git@git.ruzzy.dev:2222/ruzzy/imgbot.git`. Разовая копия перед переездом — `~rus/imgbot-repo-pre-merge-20260908-133444.tar.gz`.
|
||||||
|
- ⚠️ `_job_analyze` глушит исключения через `log.exception` и возвращается нормально, поэтому APScheduler пишет `executed successfully` **даже после падения**. Мониторить надо `job failed`/`Traceback` в логе, отсутствие ошибок ничего не доказывает.
|
||||||
|
- README описывает те же `refresh`/`analyze` как задачи cron с `flock` — при живом daemon это дубль. Проверить `crontab -l -u tgclaude` (нужен root): если записи есть, работа делается дважды.
|
||||||
|
- Данные: `/home/tgclaude/imgbot-data` (2.6 ГБ) — `images/`, живой `manifest.db`, `exports/` 21 МБ, логи `daemon.log`/`refresh.log`/`bulk_fill.log`, состояние и куки Google/Reddit (mode 600).
|
||||||
|
|
||||||
|
**Прочее у `tgclaude`:**
|
||||||
|
- `/home/tgclaude/.local/lib` — 2.3 ГБ python-пакетов, `chrome-libs` — 3 МБ библиотек для headless-chromium.
|
||||||
|
- **Ещё один Telegram-бот** — `send_galleries.py`, `send_random_photo.py`, `send_retry.py`, `gallery_paths.txt`, токен в `~/.tg_bot_token` (600). Отдельный и от @ultimate_log_bot, и от @iluha_rabobot.
|
||||||
|
- `/home/tgclaude/backups/gitea-backup-20260831-045458.tar.gz` — 4.5 ГБ, предмиграционный снимок Gitea, см. раздел Gitea.
|
||||||
|
- ⚠️ `/home/tgclaude/.openclaw-auth-profile-secrets` — права **777** на каталоге, предназначенном для секретов (сейчас пуст).
|
||||||
|
- ⏳ Не проверено, нужен root: crontab пользователя `tgclaude` (README imgbot упоминает периодические задачи «run via cron» — значит, что-то запланировано), а также `.cache`, `.docker`, `.ssh`.
|
||||||
|
|
||||||
|
## Прочие нагрузки и каталоги (не оформлены в стеки)
|
||||||
|
- **openclaw — две независимые копии, не путать:**
|
||||||
|
- `/home/tgclaude/openclaw-deploy/openclaw` (947 МБ) — **именно отсюда запущены контейнеры** (по метке `com.docker.compose.project.config_files`), состояние в `/home/tgclaude/.openclaw` (135 МБ, владелец при этом `rus`, mode 700). Контейнеры `openclaw-openclaw-{gateway,cli}-1` (образ `openclaw:local`) — exited с 2026-08-31, причём у gateway `restart: unless-stopped`, то есть он остановлен явно, а не упал. Порты gateway `18789`, `18790`, `3978`; cli делит с ним netns. Сеть `openclaw_default` (172.22.0.0/16) и пустой том `openclaw_whisper_models` живы.
|
||||||
|
- `~rus/openclaw` (212 МБ, **не git-репозиторий** — просто распакованное дерево) + `~rus/.openclaw` (2.0 ГБ, из них `agents/` почти всё) + `~rus/openclaw-work` (147 МБ) + `~rus/.openclaw-auth-profile-secrets` — отдельная копия, своих контейнеров не имеет. Единственная живая часть — `sync.sh` из cron `rus` (см. claude-channels), лог пишется.
|
||||||
|
- Образ-снимок `openclaw:backup-20260831` (1.55 ГБ) — путь откатa для openclaw.
|
||||||
|
- **`~/backups` — 5.4 ГБ**, всё с уже выведенного `de1`: `openclaw-de1-2026-06-22.tgz` (3.1 ГБ), `claude-channels-de1-2026-06-22.tgz` (1.1 ГБ), `postgres-de1-2026-06-22/` (1.3 ГБ, 11 дампов — крупнейший `pers_data.dump` 1.2 ГБ), `var-www-envs/` (`.env`-ы 8 проектов), 5 pre-wipe-снимков claude-channels от 2026-07-16/18. Кандидат №1 на выселение с сервера, если понадобится место.
|
||||||
|
- `~/downloads` — пустой. `~/MyVault` (186 МБ) — см. claude-channels.
|
||||||
|
|
||||||
|
## Выведено с fr1
|
||||||
|
- **ipcam / FTPS-приёмник** (был `~/ipcam`, `fauria/vsftpd`, порты `2221:21` + `30000-30010`, домен `cam.ruzzy.dev`, данные `/srv/ipcam/videos`, сертификат `/srv/ftps/certs` + deploy-хук certbot) — **удалён 2026-09-08**: контейнер, образ, том `ipcam_ipcam_logs`, сеть, каталоги `/srv/ipcam` и `/srv/ftps`, хук и LE-сертификат `cam.ruzzy.dev`. Видео так и не начали складываться (в `/srv/ipcam` на момент удаления 0 файлов). Хвосты вне сервера: DNS-запись в Namecheap и настройка аплоада на самой камере.
|
||||||
|
|
||||||
|
- **Уборка 2026-09-08** — удалены неиспользуемые каталоги и их образы: `~/sc2-parser` (+образ `sc2-parser:latest`), `~/booty` (пустой), `~/booty-collector` (+сиротский том `booty_data`, 812 МБ), `~/vacuum-scraper` (2.0 ГБ, из них `output/` от root — дочищен через контейнер, +образ 887 МБ), `~/downloads/vacuum-scraper-images.tar.gz` (121 МБ). Код обоих проектов остался в Gitea (`booty-collector.git`, `vacuum-scraper.git`). Плюс вычищены docker build cache (43.4 ГБ), 6 анонимных томов, базовые образы `node:22`, `node:24-bookworm-slim`, `node:22-alpine`, `busybox` и связка `gitea_old_1254` + `gitea/gitea:1.25.4`. Итог: занято на `/` **104 → 58 ГБ** (27% → 15%).
|
||||||
|
|
||||||
|
- **ollama удалён 2026-09-08** — `/home/tgclaude/ollama` (3.5 ГБ: `ollama.tar.zst` 1.4 ГБ + `lib` 2.1 ГБ), `/home/tgclaude/.ollama` (4.6 ГБ моделей) и `ollama-serve.log`. Не запускался с 2026-08-25, процесса не было, юнитов и бинарей вне хома `tgclaude` не существовало. Итого 8.1 ГБ; хом `tgclaude` 21 → 13 ГБ, занято на `/` **58 → 50 ГБ** (15% → 13%).
|
||||||
|
|
||||||
|
- **Легаси-архив `~tgclaude/images` удалён 2026-09-08** — 1386 файлов, 1.9 ГБ на диске (0.84 ГиБ по сумме `size_bytes`). Это был не мусор, а второй корень хранения imgbot: `migrate_legacy.py` зарегистрировал файлы в манифесте **на месте**, поэтому вместе с каталогом удалены и 1350 строк таблицы `images` (запрос «жопки-архив»; 61 из них была признана релевантной и уже доставлена ботом). Удаление **необратимо**: у этих строк `source_url` пустой — imgbot не знал, откуда файлы взялись, и докачать их не сможет. Метаданные сохранены в `~rus/imgbot-legacy-archive-rows-20260908.json` (808 КБ) — хеши, размеры, вердикты, но не источники. Монтирование каталога убрано из compose (иначе docker пересоздал бы его пустым от root). После удаления: 5578 строк, все файлы на месте, один корень хранения.
|
||||||
|
- **Синк `vacuum-scraper → ~tgclaude/images` признан мёртвым 2026-09-08**, задание из крона root снято. Его роль забрал imgbot.
|
||||||
|
|
||||||
|
## Расписания (cron + systemd-таймеры)
|
||||||
|
Полная инвентаризация на 2026-09-08 — чтобы «кто это сделал в 3 утра» не приходилось выяснять заново.
|
||||||
|
|
||||||
|
**Наше:**
|
||||||
|
- `crontab -u rus`: `*/5 * * * *` → `~/openclaw/sync.sh` — синк MyVault в Gitea (см. claude-channels).
|
||||||
|
- `/etc/cron.d/staticroute` — `@reboot` правка маршрута, из образа Contabo (см. раздел «Сеть»).
|
||||||
|
- `certbot.timer` + `/etc/cron.d/certbot` — продление сертификатов (крон-вариант выходит no-op на systemd).
|
||||||
|
- `sysstat-collect.timer` (`*:00/10`) и `sysstat-summary.timer` (00:07) — сбор и суточный отчёт sar.
|
||||||
|
- `apt-daily.timer` / `apt-daily-upgrade.timer` — unattended-upgrades, на второй повешен `uu-telegram-report.sh`.
|
||||||
|
- `ua-timer.timer` — Ubuntu Pro. `logrotate`, `man-db`, `dpkg-db-backup`, `e2scrub_all`, `systemd-tmpfiles-clean` — штатное.
|
||||||
|
- Внутри контейнера `imgbot-daemon` — свой APScheduler (`refresh` 30 мин, `analyze` 5 мин), в системный крон не входит.
|
||||||
|
|
||||||
|
**Разобрано 2026-09-08 — что тут было и почему убрано:**
|
||||||
|
- **`crontab -u root` пуст.** Там было `*/5 * * * * rsync -a --delete … /home/rus/vacuum-scraper/output/images/ /home/tgclaude/images/` — зеркалило вывод vacuum-scraper в хом `tgclaude` каждые 5 минут. Пайплайн умер вместе с удалением `vacuum-scraper` (роль забрал imgbot), задание снято 2026-09-08. Это же объясняло, почему в `~tgclaude/images` лежали те же UUID-имена, что в удалённом `output/images`: живое зеркало, а не старая копия. Мина, которую заодно убрали: `rsync --delete` мог удалять файлы, на которые ссылался манифест imgbot, — то есть в любой момент осиротить строки в БД.
|
||||||
|
|
||||||
|
- **`/etc/cron.hourly` пуст, оба скрипта удалены 2026-09-08.** Что там было и почему убрано:
|
||||||
|
- `free` — одна строка `echo 1 > /proc/sys/vm/drop_caches`, то есть **сброс страничного кэша каждый час**. Артефакт шаблона Contabo ради «красивого» вывода `free`. Срабатывание доказано по Prometheus: `node_memory_Cached_bytes` проваливался ровно в **:17** каждого часа (именно в это время `/etc/crontab` запускает `run-parts /etc/cron.hourly`) — например 13:17 минус **3.89 ГиБ**, 10:17 минус 2.22 ГиБ. При этом платы на диске **не обнаружено**: чтение после провалов 0.14–0.48 МиБ/с, рабочий набор мал и возвращается в кэш дешёво. Убрано не ради производительности, а потому что это регулярная пила на графиках памяти, не связанная ни с чем реальным, — прямое препятствие для разборов, ради которых на этом сервере задрана ретенция журналов.
|
||||||
|
- `fstrim` — `fstrim /` **каждый час** при том, что штатный `fstrim.timer` (`OnCalendar=weekly`) на этой машине уже `enabled`. То есть trim шёл дважды; часовой убран, недельный таймер оставлен.
|
||||||
|
|
||||||
|
**Проверено и оставлено как есть:**
|
||||||
|
- `/etc/cron.daily/sysstat` — no-op: первой строкой `[ ! -d /run/systemd/system ] || exit 0`, то есть на systemd сразу выходит. Дубля с `sysstat-summary.timer` нет, трогать не надо.
|
||||||
|
- `update-notifier-download.timer` / `update-notifier-motd.timer` — десктопное наследие образа, оставлены.
|
||||||
|
|
||||||
|
**Отключено 2026-09-08 как наследие шаблона:**
|
||||||
|
- Таймеры `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 нет, съёмных носителей тоже.
|
||||||
|
|
||||||
|
## Бэкап (с 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)
|
||||||
|
Мелочи, оставленные сознательно, — чтобы не искать их заново.
|
||||||
|
- ⚠️ **В 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. **Отозвать вручную:** Настройки → Приложения.
|
||||||
|
- В таблице `queries` манифеста imgbot осталась строка `жопки-архив`, хотя все её 1350 картинок удалены. `refresh` может попытаться пополнить её, ища по этой строке в поисковиках, — получится мусор. Снести строку или оставить.
|
||||||
|
- В `~tgclaude/imgbot/.env` осталась строка `IMGBOT_FILE_SERVER_TOKEN` — после объединения HTTP-сервисов её никто не читает.
|
||||||
|
- `~tgclaude/.openclaw-auth-profile-secrets` — права **777** на каталоге для секретов (сейчас пуст), стоит `chmod 700`.
|
||||||
|
- Не решено, нужны ли на fr1: `~rus/backups` (5.4 ГБ архивов с выведенного de1) и спящий openclaw (947 МБ дерева + 2.0 ГБ состояния + образ-снимок 1.55 ГБ).
|
||||||
|
- Страховочные файлы, оставленные после работ: `~rus/imgbot-repo-pre-merge-20260908-133444.tar.gz` (369 КБ), `~rus/imgbot-legacy-archive-rows-20260908.json` (808 КБ), `~rus/gitea/docker-compose.yml.bak-20260908`.
|
||||||
|
- ⏳ Дроп-ин `/etc/ssh/sshd_config.d/50-cloud-init.conf` не читается из-под `rus` — под root проверить `sshd -T | grep -iE "permitrootlogin|passwordauth"`, чтобы знать эффективные значения, а не собранные из файлов вручную.
|
||||||
|
- ⚠️ Описания **других** серверов не сверялись: `servers/de1.md` и `servers/ru1.md` датируются временем до вывода de1, так что могут врать.
|
||||||
|
|
||||||
|
## Как это описание поддерживается
|
||||||
|
Механизм заведён 2026-09-08, после того как сверка нашла десяток расхождений.
|
||||||
|
|
||||||
|
**В репозитории:** `CLAUDE.md` — контракт «тронул сервер, обнови описание в
|
||||||
|
том же прогоне» и правила оформления; `.claude/skills/server-audit/` — метод
|
||||||
|
сверки и накопленные грабли; `scripts/snapshot.sh` — инвентаризация живого
|
||||||
|
состояния за один проход (только чтение, без sudo, секретов не печатает).
|
||||||
|
|
||||||
|
**На самом сервере**, для агентов, у которых репозитория нет:
|
||||||
|
- `~rus/CLAUDE.md` и `~tgclaude/CLAUDE.md` — указатель на репо, правило и
|
||||||
|
пять граблей, которые дороже всего стоили (ufw не видит docker-портов;
|
||||||
|
группа docker равна root; процесс из SSH не переживёт ребут; абсолютные
|
||||||
|
пути в чужих базах; мигающий IPv6). Рядом `AGENTS.md` — симлинк на тот же
|
||||||
|
файл, для агентов, читающих эту конвенцию. Claude Code подхватывает
|
||||||
|
`CLAUDE.md` вверх по дереву каталогов, поэтому файл в хоме виден и при
|
||||||
|
работе в подкаталогах вроде `~tgclaude/imgbot`.
|
||||||
|
- `~rus/MyVault/TOOLS.md`, раздел SSH — та же обязанность для агента Astra.
|
||||||
|
Файл смонтирован в контейнер `claude-channels` как `/vault`, так что
|
||||||
|
изменение видно ему сразу, и синкается в Gitea кроном каждые 5 минут.
|
||||||
|
|
||||||
|
⚠️ **Чего этот механизм не гарантирует.** MOTD и `.bashrc` не помогут:
|
||||||
|
`.bashrc` выходит на строке 6 для неинтерактивных шеллов, а MOTD
|
||||||
|
показывается только при интерактивном входе — то есть агент, дёргающий
|
||||||
|
`ssh fr1 'команда'`, не увидит ничего. Файлы в хомах достанут только тех,
|
||||||
|
кто читает `CLAUDE.md`/`AGENTS.md` и чей рабочий каталог оказался внутри
|
||||||
|
хома. Серверного хука «агент закончил работу» не существует.
|
||||||
|
Детектор дрейфа (таймер, сравнивающий отпечаток состояния с прошлым и
|
||||||
|
пишущий в Telegram) обсуждался и **сознательно не заводился** — если
|
||||||
|
незадокументированные изменения снова начнут накапливаться, начинать
|
||||||
|
стоит с него.
|
||||||
|
|
||||||
## Установленные пакеты (сверх базы)
|
## Установленные пакеты (сверх базы)
|
||||||
htop, mtr-tiny, tmux, vim, curl, wget, git, unzip, net-tools, dnsutils, ncdu, rsync, fail2ban, unattended-upgrades.
|
htop, mtr-tiny, tmux, vim, curl, wget, git, unzip, net-tools, dnsutils, ncdu, rsync, fail2ban, unattended-upgrades, sysstat.
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
# 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; была без резерва).
|
||||||
|
|
||||||
|
## Доступ
|
||||||
|
- С 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.
|
||||||
|
- **monwatch — сторож самого мониторинга** (с 2026-10-11, `stacks/rpi-backup/bin/monwatch`, таймер раз в час): весь мониторинг живёт на fr1, и если упадёт fr1 или цепочка доставки — алертов не будет вообще. Малина спрашивает fr1 `health` (тот же ключ метрик, режим только чтение): живы ли Prometheus/Alertmanager/pushgateway, видит ли Prometheus пуши малины моложе часа, не растут ли `alertmanager_notifications_failed_total{integration="telegram"}`. При проблеме (после повторной проверки через 2 мин) пишет в Telegram **напрямую, минуя fr1** — ботом @rasp_contol_bot (токен из `/etc/astra-bot.env`) в чат 607015; повтор раз в 6 ч, ✅ при восстановлении. Состояние — `/var/lib/monwatch/`. Путь тревоги проверен имитацией 2026-10-11 (`MONWATCH_MAX_PUSH_AGE=1`).
|
||||||
|
- ⚠️ Сообщения сторожа приходят в чат с ботом Astra — это бот сам пишет, Codex их не видит и не обрабатывает.
|
||||||
|
- Алерты — группа `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-bot `11d0a85`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/11d0a85), `codex/skills/home-print/`, установлен в `~astra/.codex/skills/home-print/`. Вместо PNG каждой страницы и повторного подтверждения отправляет **один PDF**, затем по прямой просьбе «распечатай» печатает одну копию A4 с одной стороны без дополнительных вопросов. Текст извлекается из скриншотов, картинки сохраняются в оттенках серого, все страницы проверяются локально. Если PDF не доставлен, печать не запускается; повторная отправка задания при ошибке не автоматическая. Для этого сценария установлены узкие постоянные разрешения `~astra/.codex/rules/home-print.rules` (источник `codex/rules/` в astra-bot): только `/usr/local/bin/home-print` и PDF-режим штатного отправщика. Общая политика подтверждений сохранена. Проверка: 31 тест, валидация скилла и правил; произвольный Python правилами не разрешается. Инструкции установлены в `~/.codex/AGENTS.md`; новые разговоры получают их автоматически.
|
||||||
|
- **Кнопки и промежуточные ответы печати убраны в самом боте 2026-10-11**, [astra-bot `6b1317d`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/6b1317d). Ранее постоянных правил скилла было недостаточно: запросы approval и все agentMessage уходили в Telegram. Бот теперь автоматически отвечает accept только на штатную PDF-отправку разрешённому чату и home-print для print.pdf в каталогах /tmp/astra-print-*; shell-операторы и произвольные команды исключены. В задачах печати сохраняет последний ответ и отправляет его после завершения, PDF доставляется отдельным вложением. Проверено 34 тестами, установлен bot.py; прежний код — /opt/astra-bot/bot.py.bak-20261011-child-print. Перезапуск через restart-check.py после завершения текущего ответа.
|
||||||
|
- ⚠️ 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, очередь пуста; качество на бумаге проверяет пользователь.
|
||||||
|
|
||||||
|
- Для перевёрстки извлечённого текста в PDF установлен `python3-reportlab` 4.3.1 (2026-10-11; ранее средств создания PDF с кириллицей не было), с зависимостями Pillow/Cairo. Шрифт DejaVu Sans внедряется в PDF; PNG-предпросмотр строится из итогового серого PDF. Проверено на комплекте Лермонтова: 3 страницы A4, текст со скриншотов и исходный портрет.
|
||||||
|
|
||||||
|
## Медиацентр (с 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,6310034441`. Тред 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) закреплён. Автопулла нет.
|
||||||
|
|
||||||
|
- Профиль Поли (@Polli4ka303), 10 лет, сохранён в инструкциях 2026-10-11: простые короткие ответы без технических терминов; источник [astra-bot `88b88a5`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/88b88a5). Детская печать уточнена: единый PDF → «Печатай» / «Печатай на двух сторонах» → печать без системных кнопок. Доступ добавлен 2026-10-11: Telegram ID `6310034441` внесён в TG_ALLOWED_IDS (ранее только владелец `607015`), профиль закреплён за ID вместо username; [astra-bot `2d761d3`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/2d761d3). Настройка применяется после перезапуска бота.
|
||||||
|
|
||||||
|
- Постоянная сессия и журнал Поли добавлены 2026-10-11, [astra-bot `8b3400a`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/8b3400a). После авторизации её чат сохраняется в state.child_chats, тред возобновляется после перезапуска; служебные slash-команды не сбрасывают разговор и не показывают справку администратора. Бот передаёт возраст и текущий chat_id в контекст, отправляет только итоговый ответ. Входящие сообщения, метаданные/пути вложений и текстовые ответы пишутся в ~/.local/share/astra-bot/conversations/<chat_id>.jsonl (файл 600, каталог 700); PDF, отправленный отдельным скриптом, в этот журнал не записывается. Имя пользователя выбирает профиль только после проверки TG_ALLOWED_IDS и не предоставляет доступ. Проверка: 37 тестов; резервный код /opt/astra-bot/bot.py.bak-20261011-child-session. ⚠️ При невозможности resume бот создаёт новый тред; локальный журнал остаётся, автоматического импорта журнала в контекст нет.
|
||||||
|
|
||||||
|
## Роутер Huawei HG8245X6 — скилл `router` (с 2026-10-11)
|
||||||
|
- Роутер — **OptiXstar HG8245X6-10**, оптический терминал Cyta (GPON), ПО V5R022C10S110, `192.168.10.254`. Интернет — PPPoE `1_INTERNET_R_VID_42` (публичный IP динамический, на 2026-10-11 — 46.199.208.45); ещё WAN TR069, VOIP, IPTV. За LAN3 — mesh-точки M15/E15 (Wi-Fi-клиенты за ними видны как `lan3`), на LAN4 — малина.
|
||||||
|
- Управление — Codex-скилл Astra `router`, код в `ruzzy/astra-bot` (`codex/skills/router/`), команда `/usr/local/bin/router`. Креды веб-интерфейса — `/etc/router.env` (root:astra 640), в git нет. Умеет: статус/WAN, клиенты (IPv4 из аренд DHCP, сигнал Wi-Fi), трафик по интерфейсам, DHCP static add/del, проброс портов add/del (через WAN INTERNET), Wi-Fi — только чтение. Изменения — только `--yes` и кнопкой (`codex/rules/router.rules` разрешает лишь чтение).
|
||||||
|
- ⚠️ **3 неудачных входа подряд блокируют вход в роутер** (`errloginlockNum = '3'`). `login.cgi` разбирает поля позиционно — порядок как у браузера (UserName, PassWord, Language, x.X_HW_Token), иначе вход «неудачный». login не ретраится. Веб-сервер однопоточный и рвёт соединения при частых запросах — чтение ретраится с паузой; выход (`logout.cgi`) всегда отвечает обрывом, это норма. После каждой операции — выход, чтобы не забивать лимит сессий и не выбивать человека из веб-интерфейса.
|
||||||
|
- ⚠️ Трафик **по устройствам прошивка не считает** (поля `TrafficRecvRate/SendRate` всегда 0) — только по интерфейсам: `wan` (сумма GEM Ethernet), `iptv`, `lan1..4`, `wifi-2.4`, `wifi-5`.
|
||||||
|
- ⚠️ Запись Wi-Fi не сделана сознательно: SSID 2.4/5 ГГц связаны (WifiCover/band steering), запрос — десятки полей; ошибка отключит весь дом. Делать отдельно, в согласованное окно.
|
||||||
|
- `router-watch.timer` (5 мин, root): снимок `/var/lib/router-watch/state.json` (читает astra без сети), `seen.json` (все MAC с первым появлением; база — 22 устройства на 2026-10-11 15:57), `known.json` (имена от пользователя), `events.log`. Новый MAC → Telegram ботом @rasp_contol_bot с флагами (случайный MAC, ночь, без имени, ≥3 новых за час). Метрики `router_*` → pushgateway (job `router`) → алерты группы `home-network`: `RouterWatchStale` (>20 мин), `RouterDownloadSpike`/`RouterUploadSpike` (WAN: 15-мин скорость >4× средней за неделю и >5 МБ/с ↓ / >1 МБ/с ↑, 30 мин). Дашборд https://mon.ruzzy.dev/d/home-net.
|
||||||
|
- Проверено на живом роутере 2026-10-11: чтение всех разделов; `dhcp add/del` (тестовая привязка 02:00:00:00:00:01 → .250 создана и удалена), `portmap add/del` (TCP 59999 → rpi создан и удалён).
|
||||||
|
- Статическая привязка DHCP `192.168.10.25` ↔ `dc:a6:32:db:b0:f9` (малина) на роутере уже есть — единственная. Привязка `192.168.10.101` ↔ `8e:a9:3a:4e:69:ba` удалена 2026-10-11: устройства с таким MAC нет ни в сети, ни в истории роутера (вероятно, старый телефон со сменившимся случайным MAC).
|
||||||
|
|
||||||
|
- Свободная беседа, интернет-поиск, объяснение школьного материала, поиск и генерация картинок для Поли разрешены 2026-10-11; правила установлены в ~/.codex/AGENTS.md, источник [astra-bot `95a2867`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/95a2867). Содержание подбирается для 10 лет, материалы 16+/18+ исключаются; выбранные картинки проверяются перед отправкой. Это инструкции агента, не отдельный сетевой фильтр. Разрешение не распространяется на управление сервером и доступами.
|
||||||
|
|
||||||
|
## Открытые хвосты (на 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 — аккаунты пользователя; решить про автообновление образов.
|
||||||
+46
-5
@@ -1,7 +1,7 @@
|
|||||||
# ru1 — `85.198.109.86` (RU entry node)
|
# ru1 — `85.198.109.86` (RU entry node)
|
||||||
|
|
||||||
VPN entry-нода в РФ. Точка входа в топологии **RU↔DE**: умный роутинг (RU-трафик → напрямую, зарубежный → туннель AWG до de1). Полный runbook — `~/README.md` на сервере.
|
VPN entry-нода в РФ. Точка входа в топологии **RU↔DE**: умный роутинг (RU-трафик → напрямую, зарубежный → туннель AWG до de1). Полный runbook — `~/README.md` на сервере.
|
||||||
Секреты (MTProxy user-secrets, ключи WG) — только на сервере, не здесь.
|
Секреты (WEB-proxy secret в `/etc/tproxy-server/profiles.json` и `/etc/mtproxy/mtproxy.env`, `token.key`, ключи WG) — только на сервере, не здесь.
|
||||||
|
|
||||||
**Провайдер:** VDSina · РФ
|
**Провайдер:** VDSina · РФ
|
||||||
**ОС:** Ubuntu 24.04.3 LTS · kernel 6.8.0-78
|
**ОС:** Ubuntu 24.04.3 LTS · kernel 6.8.0-78
|
||||||
@@ -10,6 +10,8 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D
|
|||||||
|
|
||||||
## Доступ
|
## Доступ
|
||||||
- `ssh mik@85.198.109.86` (пользователь `mik`, вход по ключу), **passwordless sudo**.
|
- `ssh mik@85.198.109.86` (пользователь `mik`, вход по ключу), **passwordless sudo**.
|
||||||
|
- Ключи в `~mik/.ssh/authorized_keys` (по комментарию, сверено 2026-09-23): `mikpokrovskij@gmail.com`, `iluha_proxy`, `iluha_rabobot@ruzzy.dev`, `openclaw@ruzzy`, `claude-bot@ruzzy.dev` (добавлен 2026-09-23, SHA256:mNK/9jiwypqVHj8oOnKUcr1mqt39z4cVrWWsxxbKP+o; бэкап прежнего файла — `authorized_keys.bak-20260923`).
|
||||||
|
⚠️ У `mik` passwordless sudo, поэтому любой из этих ключей даёт root. Отдельного пользователя под ботов нет.
|
||||||
|
|
||||||
## Интерфейсы
|
## Интерфейсы
|
||||||
- `ens3` — публичный `85.198.109.86/24` (RU интернет, default route via 85.198.109.1)
|
- `ens3` — публичный `85.198.109.86/24` (RU интернет, default route via 85.198.109.1)
|
||||||
@@ -25,20 +27,45 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D
|
|||||||
| OpenVPN #2 | 1195/udp (`tun1`) | RU-выход (весь трафик через ens3, DNS Yandex). Cfg `/etc/openvpn/server/ruopenvpn.conf` |
|
| OpenVPN #2 | 1195/udp (`tun1`) | RU-выход (весь трафик через ens3, DNS Yandex). Cfg `/etc/openvpn/server/ruopenvpn.conf` |
|
||||||
| AmneziaWG `wg0` | (awg, `45451/udp`) | туннель до de1 (exit). `awg-quick@wg0`, cfg `/etc/amnezia/amneziawg/wg0.conf`, `Table=off` |
|
| AmneziaWG `wg0` | (awg, `45451/udp`) | туннель до de1 (exit). `awg-quick@wg0`, cfg `/etc/amnezia/amneziawg/wg0.conf`, `Table=off` |
|
||||||
| WireGuard `wg1` | 51820/udp | VPN для клиентов (Docker `linuxserver/wireguard`), подсеть `10.8.2.0/24`, `~/wireguard/` |
|
| WireGuard `wg1` | 51820/udp | VPN для клиентов (Docker `linuxserver/wireguard`), подсеть `10.8.2.0/24`, `~/wireguard/` |
|
||||||
| telemt (MTProxy) | 443 | FakeTLS под `ru.ruzzy.dev`, маска → nginx `127.0.0.1:4443`. API `9091`. Docker, `~/gost/` |
|
| WEB-прокси Telegram | 443 (через nginx) | `tproxy-server` (relay, systemd, `127.0.0.1:8090`, admin `:8091`) → официальный MTProxy (systemd, `2398`, stats `8888`, оба закрыты nftables для не-lo). Подробно — раздел ниже |
|
||||||
| nginx | 80, 4443 | маскировочный сайт `ru.ruzzy.dev` (Docker `nginx:alpine`) |
|
| nginx | 80, 443 | фронт `ru.ruzzy.dev`: весь 443 → relay. Docker `nginx:alpine`, `~/gost/` |
|
||||||
|
| certbot | — | `certbot renew` раз в 12 ч, webroot `~/gost/certbot/www` |
|
||||||
|
|
||||||
Контейнеры: `telemt`, `wireguard`, `nginx` (docker compose в `~/gost` и `~/wireguard`).
|
Контейнеры: `nginx`, `certbot` (`~/gost`), `wireguard` (`~/wireguard`), агент мониторинга (`~/monitoring-agent`). Compose, nginx.conf и скрипт установки — копия в репо `stacks/tproxy/`.
|
||||||
|
|
||||||
|
## WEB-прокси Telegram (с 2026-10-09)
|
||||||
|
Новый тип прокси в клиентах Telegram (Desktop ≥ 7.1.0; мобильные — по сообщениям с 13.0, официально не подтверждено). Клиент держит скрытый WebView к `https://ru.ruzzy.dev/` и гонит по HTTPS/WSS мультиплексированные MTProxy-потоки. Сервер — официальный [`telegramdesktop/tproxy-server`](https://github.com/telegramdesktop/tproxy-server), коммит `c8adb8b` (2026-09-29).
|
||||||
|
- **Клиенту:** server `ru.ruzzy.dev`, secret — 32 hex из `profiles.json`. Ссылка: `https://t.me/webproxy?server=ru.ruzzy.dev&secret=<secret>`; получить на сервере — последней строкой вывода `setup-ru1.sh`.
|
||||||
|
- **Цепочка:** клиент → nginx `:443` (TLS, `ru.ruzzy.dev`) → relay `127.0.0.1:8090` → MTProxy `127.0.0.1:2398` → middle-end Telegram через `wg0` → fr1.
|
||||||
|
- **Mode A, без base path:** relay — единственный шлюз хоста и сам отдаёт публичный сайт из `/srv/tproxy-site` (копия маскировочного `~/gost/nginx/html/index.html`; перечитывается только при рестарте relay). Запросы без валидной capability (`?bridge=` = HMAC от секрета и имени хоста) получают обычный сайт. Base path не включали: по документации его понимает только Desktop.
|
||||||
|
- ⚠️ **NAT для MTProxy задан вручную:** `MTPROXY_NAT_ARGS=--nat-info 10.8.1.4:161.97.93.252` в `/etc/mtproxy/mtproxy.env`. Официальный MTProxy выводит AES-ключи middle-end сессии из своего адреса; трафик к Telegram с ru1 уходит в wg0 и выходит с IP **fr1**. Автоопределение апстримного `install.sh` спросило бы ifconfig-сервис через ens3, получило 85.198.109.86 — и MTProxy принимал бы клиентов, никому не отвечая, без ошибок нигде. Сменится exit-IP fr1 или адрес wg0 — править здесь. Симптом: `journalctl -u mtproxy | grep 'Disconnected from RPC Middle-End'`.
|
||||||
|
- **Юниты:** `tproxy-firewall` (nft-таблица `inet tproxy_backend`: 2398/8888 только с lo), `mtproxy`, `tproxy-server`, `refresh-mtproxy-config.timer` (ежедневно тянет `proxy-multi.conf`, рестарт MTProxy только при изменении). Бинарник relay `/usr/local/bin/tproxy-server` собран локально (`CGO_ENABLED=0 GOOS=linux GOARCH=amd64`), MTProxy — `/opt/MTProxy`, собран `deploy/install-mtproxy.sh` (закреплённый коммит + sha256).
|
||||||
|
- **Carrier: `websocket`** (с 2026-10-09, было `https` по умолчанию) — поле `carrier_mode` профиля в `profiles.json`: один WSS-канал `/api/v1/ws` на все потоки клиента вместо POST + long poll. Клиенту менять нечего, режим выбирает сервер. Relay пингует WSS раз в 25 с простоя (`long_poll`), поэтому `proxy_read_timeout 90s` в nginx канал не рвёт. Смена режима — `systemctl restart tproxy-server`, живые сессии переподключаются. `websocket-lanes` не взят: отдельный WSS на поток, на macOS WebKit сериализует рукопожатия и подключение может не уложиться в таймаут клиента. Бэкап профиля до правки — `profiles.json.bak-20261009`.
|
||||||
|
- ⚠️ Порты relay **8090/8091**, а не дефолтные 8080/8081: `*:8080` занят cadvisor из `~/monitoring-agent`.
|
||||||
|
- ⚠️ **Логи nginx выключены** (`access_log off`): в URI bridge-страницы — capability, в `Sec-WebSocket-Protocol` — токен сессии. Не включать.
|
||||||
|
- **Проверено 2026-10-09:** `readyz` 200; 10 ME-соединений с `10.8.1.4`, `Disconnected` — 0; obfuscated2 + `req_pq_multi` на `127.0.0.1:2398` → `resPQ` за ~60 мс (3/3); capability через публичный фронт → bridge-страница (200, свой CSP). **Живой клиент не проверялся.**
|
||||||
|
- **Диагностика:** `curl -s 127.0.0.1:8091/readyz`, `curl -s 127.0.0.1:8091/metrics | grep tproxy_sessions`, `journalctl -u tproxy-server -u mtproxy`.
|
||||||
|
- **Маршрут:** весь Telegram (`91.108.4/22`, `91.108.8/22`, `91.108.56/22`, `95.161.64/20`, `149.154.160/20`) — в main-таблице `dev wg0`. Упал wg0 — прокси не работает.
|
||||||
|
- **Сертификат** `ru.ruzzy.dev` (LE, webroot, только это имя; перевыпущен 2026-10-09, до 2027-01-07). Продление: `certbot` в контейнере каждые 12 ч; nginx сам делает `reload` раз в 6 ч (цикл в `command` compose).
|
||||||
|
⚠️ До 2026-10-09 контейнера certbot **не было вовсе**, хотя он был описан в compose: сертификат истёк 2026-07-01 и три месяца отдавался просроченным.
|
||||||
|
- ⚠️ `nginx.conf` смонтирован в контейнер **одним файлом**: замена через `mv`/редактор с новым inode контейнер не видит, `nginx -s reload` перечитает старый. После такой замены — `docker compose restart nginx` (2026-10-09 так получили минуту простоя 443).
|
||||||
|
- Открытый прокси для всех, кто знает secret; лимиты — дефолтные апстрима (128 сессий, 4096 потоков).
|
||||||
|
|
||||||
|
### История 2026-10-09
|
||||||
|
1. Под «web-proxy для Telegram» сначала понят Telegram Web в браузере: nginx-прокси на `web.telegram.org` с переписыванием адресов DC (`zws*.ru.ruzzy.dev`) — **убран** в тот же день, не то.
|
||||||
|
2. telemt (MTProxy, FakeTLS под `ru.ruzzy.dev`, был на 443, API `9091` торчал наружу) снят, возвращён, затем **удалён окончательно** по решению владельца: контейнер, образ, `~/gost/telemt-config` (со старым секретом), `telemt-data`, том `gost_mtproxy-config`.
|
||||||
|
3. Поднят WEB-прокси (выше). Бэкапы compose/nginx.conf до изменений — `~/gost/*.bak-20261009*`.
|
||||||
|
|
||||||
## Умный роутинг (tun0 и wg1)
|
## Умный роутинг (tun0 и wg1)
|
||||||
- `ipset ru_ips` (~11400 подсетей, источник ipdeny.com). До routing decision (mangle PREROUTING):
|
- `ipset ru_ips` (~11400 подсетей, источник ipdeny.com). До routing decision (mangle PREROUTING):
|
||||||
- dst ∈ ru_ips → RETURN (без метки) → main table → ens3 (RU напрямую)
|
- dst ∈ ru_ips → RETURN (без метки) → main table → ens3 (RU напрямую)
|
||||||
- иначе → MARK `0x64` → `ip rule fwmark 0x64 → table vpn (100)` → default dev wg0 → de1
|
- иначе → MARK `0x64` → `ip rule fwmark 0x64 → table vpn (100)` → default dev wg0 → de1
|
||||||
|
- ⚠️ **Правило `fwmark 0x64 → vpn` ставит `PostUp` в `wg0.conf`, и его снимает рестарт `systemd-networkd`.** По умолчанию networkd при старте удаляет «чужие» ip rule (`ManageForeignRoutingPolicyRules=yes`). 2026-10-09 в 16:36 `apt-get install` из установки WEB-прокси обновил библиотеки → needrestart перезапустил networkd → правило пропало, и ~45 мин (до 17:21) зарубежный трафик клиентов OpenVPN #1 шёл через ens3 (RU IP) вместо туннеля; жаловались пользователи VPN. Вернули вручную (`ip rule add fwmark 100 lookup vpn priority 10000`). Защита с 2026-10-09: `/etc/systemd/networkd.conf.d/10-keep-foreign-routing.conf` — `ManageForeignRoutingPolicyRules=no`, `ManageForeignRoutes=no` (действует со следующего старта networkd; networkd ради этого не перезапускали). Та же мина раньше висела на unattended-upgrades. Быстрая проверка: `ip rule | grep 0x64`.
|
||||||
- Обновление списка: `~/smart-routing/update-ru-ips.sh`, cron вс 03:00. Восстановление при ребуте: `ipset-restore.service` (до `awg-quick@wg0`).
|
- Обновление списка: `~/smart-routing/update-ru-ips.sh`, cron вс 03:00. Восстановление при ребуте: `ipset-restore.service` (до `awg-quick@wg0`).
|
||||||
- tun1 — без умного роутинга (всегда RU/ens3).
|
- tun1 — без умного роутинга (всегда RU/ens3).
|
||||||
|
|
||||||
## AWG Watchdog
|
## AWG Watchdog
|
||||||
- `~/awg-watchdog.sh` в root-cron **каждые 3 мин**: если handshake `wg0` старше 5 мин → рестарт `awg-quick@wg0` + чистка дублей iptables. Лог `~/awg-watchdog.log`. (telemt зависит от wg0 — Telegram DC маршрутизируются через туннель до de1.)
|
- `~/awg-watchdog.sh` в root-cron **каждые 3 мин**: если handshake `wg0` старше 5 мин → рестарт `awg-quick@wg0` + чистка дублей iptables. Лог `~/awg-watchdog.log`. (WEB-прокси зависит от wg0: адреса Telegram с хоста идут через туннель.)
|
||||||
|
|
||||||
## Клиенты
|
## Клиенты
|
||||||
- OpenVPN: `~/gen_client.sh --profile de|ru|both <имя>`, PKI `~/easy-rsa/pki`, профили `~/clients/`. CRL отключён.
|
- OpenVPN: `~/gen_client.sh --profile de|ru|both <имя>`, PKI `~/easy-rsa/pki`, профили `~/clients/`. CRL отключён.
|
||||||
@@ -58,3 +85,17 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D
|
|||||||
## ⚠️ Грабли (из README)
|
## ⚠️ Грабли (из README)
|
||||||
- `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` один пир, рукопожатий не было ни разу — сервисом, похоже, никто не пользуется. Не правили.
|
||||||
|
- Drop-in `10-keep-foreign-routing.conf` фактически не проверен рестартом networkd (рестарт ens3 по SSH на этой ноде — риск). Проверить при следующей плановой перезагрузке: после неё `ip rule | grep 0x64` должен показать правило.
|
||||||
|
- **Живой клиент через WEB-прокси не проверен** (2026-10-09): серверная цепочка проверена по частям (см. раздел WEB-прокси). Поддержка в мобильных клиентах подтверждена только новостью про Telegram 13.0.
|
||||||
|
- **DNS `*.ru.ruzzy.dev`** (CNAME → `ru.ruzzy.dev`, Namecheap) остался от веб-прокси Telegram Web — больше не нужен, удалить. Имена `zws*.ru.ruzzy.dev` уже попали в CT-логи через сертификат от 2026-10-09 (Let's Encrypt) — это не отменить.
|
||||||
|
- `/tmp/tproxy-server` на сервере — исходники и `setup-ru1.sh`, нужны для `deploy/update-relay.sh`; `/tmp` чистится при ребуте — при обновлении заливать заново.
|
||||||
|
- `~/gost/manage.sh` — от прежнего gost/SOCKS5, не используется.
|
||||||
|
- Замечено при снапшоте 2026-10-09, не разбиралось: `iperf3 --server` (порт 5201 наружу, пользователь `iperf3`, PPID 1), cadvisor `unhealthy`. Остальное описание (OpenVPN, AWG, клиенты) сверялось до переключения на fr1 — см. строку в `AGENTS.md`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: gitea
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
gitea:
|
gitea:
|
||||||
image: gitea/gitea:1.25.4
|
image: gitea/gitea:1.27.3
|
||||||
container_name: gitea
|
container_name: gitea
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Среда запуска для imgbot-daemon. Сам код — не наш: проект живёт у агента
|
||||||
|
# в /home/tgclaude/imgbot и правится им же. Здесь только окружение.
|
||||||
|
#
|
||||||
|
# База — официальный образ playwright с тегом ровно под версию из uv.lock
|
||||||
|
# проекта (playwright 1.62.0): в нём уже есть chromium и его системные
|
||||||
|
# библиотеки. Именно поэтому в контейнере не нужен костыль
|
||||||
|
# ~tgclaude/chrome-libs/extract/usr/lib/... , которым это лечилось на хосте.
|
||||||
|
FROM mcr.microsoft.com/playwright/python:v1.62.0-noble
|
||||||
|
|
||||||
|
# В этом образе лежат только браузеры (/ms-playwright, PLAYWRIGHT_BROWSERS_PATH
|
||||||
|
# уже выставлен), а python-пакета playwright нет — его ставим сами, и версию
|
||||||
|
# фиксируем ровно 1.62.0 = версия из uv.lock проекта = ревизия chromium-1234,
|
||||||
|
# вкомпилированная в этот тег. Разъезд версии пакета и образа даёт
|
||||||
|
# "Executable doesn't exist" при launch(), поэтому теги менять только парой.
|
||||||
|
#
|
||||||
|
# Остальное — по фактическим импортам обоих сервисов из этого образа:
|
||||||
|
# collector/fetcher → playwright
|
||||||
|
# storage/analyze → pillow
|
||||||
|
# daemon → apscheduler (tzlocal подтянётся зависимостью)
|
||||||
|
# app/mcp_server → mcp (с ним приходят starlette, uvicorn, httpx)
|
||||||
|
# Версии — из uv.lock проекта, чтобы образ и хостовый venv не разъезжались.
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
playwright==1.62.0 \
|
||||||
|
pillow==12.3.0 \
|
||||||
|
apscheduler==3.11.3 \
|
||||||
|
mcp==1.29.1
|
||||||
|
|
||||||
|
# Исходники НЕ копируются в образ — они монтируются снаружи (см. compose),
|
||||||
|
# чтобы правки в проекте применялись рестартом контейнера, без пересборки.
|
||||||
|
#
|
||||||
|
# HOME задан не случайно: collector.py безусловно вызывает Path.home(), а uid
|
||||||
|
# 1001 внутри образа не имеет записи в /etc/passwd — без HOME expanduser
|
||||||
|
# упадёт с RuntimeError.
|
||||||
|
ENV PYTHONPATH=/app/src \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
IMGBOT_DATA_DIR=/home/tgclaude/imgbot-data \
|
||||||
|
HOME=/tmp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["python3", "-c", "from imgbot.daemon import main; main()"]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# imgbot-daemon
|
||||||
|
|
||||||
|
Контейнер для фонового демона проекта **imgbot** — headless-поиска и скачивания
|
||||||
|
картинок. Проект принадлежит агенту, живёт в `/home/tgclaude/imgbot` и правится
|
||||||
|
им же; здесь только среда запуска и присмотр.
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
До этого демон работал как голый процесс на хосте: запущен руками из
|
||||||
|
SSH-сессии, `PPID=1`, без юнита и restart-политики, `Linger=no` у пользователя.
|
||||||
|
Держался только потому, что в Ubuntu `KillUserProcesses=no` — **после
|
||||||
|
перезагрузки не поднялся бы**, и сигнала об этом не было бы. Плюс его 15%
|
||||||
|
ядра не были ничем ограничены и не были видны мониторингу.
|
||||||
|
|
||||||
|
Контейнер даёт: автозапуск и `restart: unless-stopped`, потолки CPU/памяти,
|
||||||
|
видимость в cAdvisor (а значит и в Grafana), и снимает костыль с руками
|
||||||
|
распакованными библиотеками chromium.
|
||||||
|
|
||||||
|
## Что тут НЕ запускается и почему
|
||||||
|
|
||||||
|
Два других процесса проекта — `imgbot-mcp-http` (порт 8765) и
|
||||||
|
`imgbot-file-server` (8766) — оставлены вне контейнеров сознательно:
|
||||||
|
|
||||||
|
- оба **жёстко забиндены в коде на `172.22.0.1`** — адрес шлюза docker-сети
|
||||||
|
`openclaw_default`. В контейнере такой адрес не поднять, а сама привязка
|
||||||
|
ломается при пересоздании сети;
|
||||||
|
- до них всё равно никто не дотягивается: единственный живой потребитель —
|
||||||
|
агент в контейнере `claude-channels` — находится в другой docker-сети и
|
||||||
|
ходит через SSH, а не по HTTP (проверено: `curl` из контейнера даёт `000`);
|
||||||
|
- `imgbot-mcp-http` вообще без авторизации, в отличие от file-server с его
|
||||||
|
Bearer-токеном.
|
||||||
|
|
||||||
|
Их имеет смысл контейнеризовать только вместе с правкой бинда на `0.0.0.0`
|
||||||
|
(две строки: `HOST` в `file_server.py`, `host=` в `mcp_server.py`) и уже в
|
||||||
|
общей docker-сети с `claude-channels` — тогда агент получит MCP по токену
|
||||||
|
вместо SSH. Это решение владельца кода, не инфраструктуры.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Собрать и поднять (на сервере каталог стека — `~/imgbot-daemon/`, чтобы не путался с самим проектом в `~tgclaude/imgbot`):
|
||||||
|
|
||||||
|
docker compose build
|
||||||
|
# погасить запущенный руками демон, иначе будет два и двойной analyze:
|
||||||
|
pkill -u tgclaude -f imgbot-daemon
|
||||||
|
docker compose up -d
|
||||||
|
docker logs -f imgbot-daemon
|
||||||
|
|
||||||
|
Проверка, что расписание пошло: в `~tgclaude/imgbot-data/daemon.log` должны
|
||||||
|
появляться строки `_job_analyze ... executed successfully` каждые 5 минут и
|
||||||
|
`_job_refresh` каждые 30.
|
||||||
|
|
||||||
|
## Откат
|
||||||
|
|
||||||
|
docker compose down
|
||||||
|
# и вернуть как было, из-под tgclaude:
|
||||||
|
cd ~/imgbot && .venv/bin/imgbot-daemon >> ~/imgbot-data/daemon.log 2>&1 &
|
||||||
|
|
||||||
|
## Важно для владельца кода
|
||||||
|
|
||||||
|
Перезапуск демона теперь `docker restart imgbot-daemon`, а не `pkill` плюс
|
||||||
|
ручной старт. Если запустить процесс руками при живом контейнере — получатся
|
||||||
|
два демона, которые будут конкурировать за одни и те же строки SQLite
|
||||||
|
(в проекте это уже случалось с cron без `flock` и выглядело как зависание).
|
||||||
|
|
||||||
|
Правки кода пересборки не требуют: исходники монтируются read-only, достаточно
|
||||||
|
`docker restart imgbot-daemon`. Пересобирать нужно только при изменении
|
||||||
|
зависимостей в `pyproject.toml` — и тогда версию playwright в `Dockerfile`
|
||||||
|
надо держать совпадающей с `uv.lock`, иначе разойдётся ревизия браузера.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: imgbot
|
||||||
|
|
||||||
|
services:
|
||||||
|
imgbot-daemon:
|
||||||
|
build: .
|
||||||
|
image: imgbot-daemon:local
|
||||||
|
container_name: imgbot-daemon
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# uid:gid пользователя tgclaude: .env лежит с mode 600, данные принадлежат
|
||||||
|
# ему же. Под другим uid контейнер не прочитает ключи и не запишет картинки.
|
||||||
|
user: "1001:1001"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Исходники read-only: правки агента применяются `docker restart`.
|
||||||
|
- /home/tgclaude/imgbot:/app:ro
|
||||||
|
# Данные: images/, manifest.db, куки google_state/reddit_state, логи.
|
||||||
|
# ВАЖНО: путь внутри контейнера обязан совпадать с путём на хосте.
|
||||||
|
# И проверять надо ВСЕ префиксы file_path в манифесте, а не только этот
|
||||||
|
# (был второй корень — легаси-архив ~tgclaude/images, снесён 2026-09-08):
|
||||||
|
# select substr(file_path,1,28), count(*) from images group by 1;
|
||||||
|
# В SQLite `images.file_path` лежит абсолютным путём хоста, и его читают
|
||||||
|
# как этот контейнер, так и оставшийся на хосте imgbot-file-server.
|
||||||
|
# Любое другое место монтирования ломает обе стороны: analyze падает с
|
||||||
|
# FileNotFoundError на старых строках, а новые строки становятся
|
||||||
|
# нечитаемыми для хостовых процессов.
|
||||||
|
- /home/tgclaude/imgbot-data:/home/tgclaude/imgbot-data
|
||||||
|
# Таймзона хоста: без этого контейнер пишет в общий daemon.log в UTC,
|
||||||
|
# а строки от прежнего хостового запуска — в CEST, и лог мешает два пояса.
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
|
||||||
|
environment:
|
||||||
|
IMGBOT_DATA_DIR: /home/tgclaude/imgbot-data
|
||||||
|
PYTHONPATH: /app/src
|
||||||
|
HOME: /tmp
|
||||||
|
# .env подхватывается самим приложением: config.py ищет его как
|
||||||
|
# parents[2]/.env относительно пакета, то есть /app/.env через монтирование.
|
||||||
|
# Через env_file не пробрасываем, чтобы ключи не размножать.
|
||||||
|
|
||||||
|
# chromium падает на дефолтных 64 МБ /dev/shm
|
||||||
|
shm_size: 1gb
|
||||||
|
|
||||||
|
# Демон ровно жрёт ~15% ядра постоянно (analyze каждые 5 мин, 3 vision-запроса
|
||||||
|
# на картинку). Потолок — чтобы refresh с playwright не разогнался на все 8.
|
||||||
|
cpus: "2.0"
|
||||||
|
mem_limit: 3g
|
||||||
|
|
||||||
|
# Лог остаётся там, где его читает агент, и дублируется в `docker logs`.
|
||||||
|
command: >
|
||||||
|
sh -c 'python3 -c "from imgbot.daemon import main; main()" 2>&1 | tee -a /home/tgclaude/imgbot-data/daemon.log'
|
||||||
|
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# Объединённый HTTP-сервис: MCP (/mcp) и раздача картинок (/photo/<id>) в
|
||||||
|
# одном процессе. Раньше это были два процесса на двух портах, оба
|
||||||
|
# захардкоженные на 172.22.0.1 — адрес шлюза docker-сети openclaw_default.
|
||||||
|
imgbot-http:
|
||||||
|
build: .
|
||||||
|
image: imgbot-daemon:local
|
||||||
|
container_name: imgbot-http
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "1001:1001"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /home/tgclaude/imgbot:/app:ro
|
||||||
|
- /home/tgclaude/imgbot-data:/home/tgclaude/imgbot-data
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
|
||||||
|
environment:
|
||||||
|
IMGBOT_DATA_DIR: /home/tgclaude/imgbot-data
|
||||||
|
PYTHONPATH: /app/src
|
||||||
|
HOME: /tmp
|
||||||
|
# URL, который сервис выдаёт в image_url: имя контейнера в общей
|
||||||
|
# docker-сети с claude-channels, откуда его и качает агент.
|
||||||
|
IMGBOT_PUBLIC_BASE: http://imgbot-http:8765
|
||||||
|
|
||||||
|
# Порт НЕ публикуется на хост: сервис доступен только из docker-сетей,
|
||||||
|
# к которым подключён контейнер (и с самой машины). Авторизации у него
|
||||||
|
# нет сознательно — см. README проекта.
|
||||||
|
expose:
|
||||||
|
- "8765"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- claude-channels
|
||||||
|
|
||||||
|
# request_photos гоняет playwright внутри этого же процесса, поэтому
|
||||||
|
# лимиты и shm такие же, как у демона.
|
||||||
|
shm_size: 1gb
|
||||||
|
cpus: "2.0"
|
||||||
|
mem_limit: 3g
|
||||||
|
|
||||||
|
command: python3 -c "from imgbot.app import main; main()"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Сеть проекта claude-channels: агент внутри того контейнера обращается к
|
||||||
|
# imgbot по имени imgbot-http. Тот же приём, что у gitea с monitoring.
|
||||||
|
claude-channels:
|
||||||
|
external: true
|
||||||
|
name: claude-channels_default
|
||||||
@@ -1 +0,0 @@
|
|||||||
FTP_PASS=changeme
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
name: ipcam
|
|
||||||
|
|
||||||
services:
|
|
||||||
ipcam-ftps:
|
|
||||||
image: fauria/vsftpd
|
|
||||||
container_name: ipcam-ftps
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
FTP_USER: cam
|
|
||||||
FTP_PASS: ${FTP_PASS}
|
|
||||||
PASV_ENABLE: "YES"
|
|
||||||
PASV_ADDRESS: "cam.ruzzy.dev"
|
|
||||||
PASV_ADDR_RESOLVE: "YES"
|
|
||||||
PASV_MIN_PORT: "30000"
|
|
||||||
PASV_MAX_PORT: "30010"
|
|
||||||
PASV_PROMISCUOUS: "NO"
|
|
||||||
PORT_PROMISCUOUS: "NO"
|
|
||||||
TLS_ENABLE: "YES"
|
|
||||||
TLS_FORCE_LOCAL_LOGINS_SSL: "YES"
|
|
||||||
TLS_FORCE_LOCAL_DATA_SSL: "YES"
|
|
||||||
TLS_CERT_FILE: /etc/ssl/private/vsftpd.crt
|
|
||||||
TLS_KEY_FILE: /etc/ssl/private/vsftpd.key
|
|
||||||
LOG_STDOUT: "YES"
|
|
||||||
XFERLOG_STD_FORMAT: "NO"
|
|
||||||
FILE_OPEN_MODE: "0666"
|
|
||||||
LOCAL_UMASK: "077"
|
|
||||||
REVERSE_LOOKUP_ENABLE: "YES"
|
|
||||||
ports:
|
|
||||||
- "2221:21"
|
|
||||||
- "30000-30010:30000-30010"
|
|
||||||
volumes:
|
|
||||||
- /srv/ftps/certs/vsftpd.crt:/etc/ssl/private/vsftpd.crt:ro
|
|
||||||
- /srv/ftps/certs/vsftpd.key:/etc/ssl/private/vsftpd.key:ro
|
|
||||||
- /srv/ipcam/videos:/home/vsftpd
|
|
||||||
- ipcam_logs:/var/log/vsftpd
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
ipcam_logs:
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
{
|
||||||
|
"uid": "home-net",
|
||||||
|
"title": "Домашняя сеть (роутер)",
|
||||||
|
"tags": [
|
||||||
|
"rpi",
|
||||||
|
"router"
|
||||||
|
],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"refresh": "5m",
|
||||||
|
"time": {
|
||||||
|
"from": "now-24h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "iface",
|
||||||
|
"type": "query",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"query": "label_values(router_iface_bytes_total, iface)",
|
||||||
|
"refId": "i"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"includeAll": true,
|
||||||
|
"multi": true,
|
||||||
|
"current": {
|
||||||
|
"text": "wan",
|
||||||
|
"value": "wan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Клиентов онлайн",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "router_clients_online",
|
||||||
|
"instant": true,
|
||||||
|
"range": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "none",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Онлайн без имени (не в known.json)",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 6,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"description": "Устройства, которым ещё не дали имя через `router known add`.",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "router_clients_unknown_online",
|
||||||
|
"instant": true,
|
||||||
|
"range": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "none",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Wi-Fi 2.4 ГГц",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "router_wifi_stations{band=\"wifi-2.4\"}",
|
||||||
|
"instant": true,
|
||||||
|
"range": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "none",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Wi-Fi 5 ГГц",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 18,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "router_wifi_stations{band=\"wifi-5\"}",
|
||||||
|
"instant": true,
|
||||||
|
"range": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "none",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Загрузка (↓ rx) по интерфейсам",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 9
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "rate(router_iface_bytes_total{iface=~\"$iface\",dir=\"rx\"}[5m])",
|
||||||
|
"legendFormat": "{{iface}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "Bps",
|
||||||
|
"custom": {
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"spanNulls": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Отдача (↑ tx) по интерфейсам",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 9
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "rate(router_iface_bytes_total{iface=~\"$iface\",dir=\"tx\"}[5m])",
|
||||||
|
"legendFormat": "{{iface}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "Bps",
|
||||||
|
"custom": {
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"spanNulls": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Клиенты онлайн",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 13,
|
||||||
|
"w": 24,
|
||||||
|
"h": 7
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "router_clients_online",
|
||||||
|
"legendFormat": "всего"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "router_clients_unknown_online",
|
||||||
|
"legendFormat": "без имени"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"custom": {
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"spanNulls": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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,157 @@ 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"
|
||||||
|
|
||||||
|
# Домашняя сеть: роутер Huawei HG8245X6 (ONT Cyta), сторож router-watch на rpi раз в 5 мин.
|
||||||
|
# По отдельным устройствам прошивка трафик не считает — только по интерфейсам (wan, lan*, wifi-*).
|
||||||
|
- name: home-network
|
||||||
|
rules:
|
||||||
|
- alert: RouterWatchStale
|
||||||
|
expr: time() - push_time_seconds{job="router"} > 20 * 60
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: сторож роутера не присылал данных {{ $value | humanizeDuration }} — router-watch или вход на роутер сломан"
|
||||||
|
|
||||||
|
# всплеск = скорость за 15 мин в 4+ раза выше средней за неделю И выше абсолютного порога
|
||||||
|
- alert: RouterDownloadSpike
|
||||||
|
expr: |
|
||||||
|
rate(router_iface_bytes_total{iface="wan",dir="rx"}[15m]) > 5e6
|
||||||
|
and rate(router_iface_bytes_total{iface="wan",dir="rx"}[15m])
|
||||||
|
> 4 * avg_over_time(rate(router_iface_bytes_total{iface="wan",dir="rx"}[1h])[7d:1h])
|
||||||
|
for: 30m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "Дом: необычно большая загрузка из интернета — {{ $value | humanize1024 }}Б/с 30+ мин. Кто качает: `router traffic --live` (по портам/Wi-Fi). Сама малина качает OneDrive/бэкапы — сверь с их расписанием."
|
||||||
|
|
||||||
|
- alert: RouterUploadSpike
|
||||||
|
expr: |
|
||||||
|
rate(router_iface_bytes_total{iface="wan",dir="tx"}[15m]) > 1e6
|
||||||
|
and rate(router_iface_bytes_total{iface="wan",dir="tx"}[15m])
|
||||||
|
> 4 * avg_over_time(rate(router_iface_bytes_total{iface="wan",dir="tx"}[1h])[7d:1h])
|
||||||
|
for: 30m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: "Дом: необычно большая ОТДАЧА в интернет — {{ $value | humanize1024 }}Б/с 30+ мин (облачная синхронизация, торрент или что-то подозрительное). Кто: `router traffic --live`."
|
||||||
|
|||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Forced command для пуш-ключей хостов за NAT (authorized_keys: command="~/bin/pushgw-ingest <instance>").
|
||||||
|
# Клиент передаёт только имя job (ssh ... <job>), метрики — в stdin в text-формате Prometheus.
|
||||||
|
# instance задаётся ключом, а не клиентом: хост не может писать метрики от чужого имени.
|
||||||
|
#
|
||||||
|
# Особый «job» `health` — только чтение: внешний сторож (rpi, monwatch) раз в час проверяет,
|
||||||
|
# что сам мониторинг fr1 жив. Отдаёт строки key=value, ничего не меняет.
|
||||||
|
instance="$1"
|
||||||
|
job="$SSH_ORIGINAL_COMMAND"
|
||||||
|
case "$instance" in ""|*[!a-z0-9_-]*) echo "bad instance" >&2; exit 2;; esac
|
||||||
|
|
||||||
|
if [ "$job" = health ]; then
|
||||||
|
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1"; }
|
||||||
|
echo "prometheus_healthy=$(code http://127.0.0.1:9090/-/healthy)"
|
||||||
|
echo "alertmanager_healthy=$(code http://127.0.0.1:9093/-/healthy)"
|
||||||
|
echo "pushgateway_healthy=$(code http://127.0.0.1:9091/-/healthy)"
|
||||||
|
# видит ли Prometheus свежие пуши этого хоста — сквозная проверка pushgateway → scrape → TSDB
|
||||||
|
age=$(curl -s --max-time 10 http://127.0.0.1:9090/api/v1/query \
|
||||||
|
--data-urlencode "query=time() - max(push_time_seconds{instance=\"$instance\"})" \
|
||||||
|
| python3 -c 'import json,sys
|
||||||
|
try: print(int(float(json.load(sys.stdin)["data"]["result"][0]["value"][1])))
|
||||||
|
except Exception: print(-1)')
|
||||||
|
echo "push_age_seconds=$age"
|
||||||
|
# счётчики доставки в Telegram (монотонные; сторож сравнивает с прошлым замером)
|
||||||
|
curl -s --max-time 10 http://127.0.0.1:9093/metrics | awk '
|
||||||
|
/^alertmanager_notifications_failed_total\{integration="telegram"/ {f+=$2}
|
||||||
|
/^alertmanager_notifications_total\{integration="telegram"/ {t+=$2}
|
||||||
|
END {printf "tg_notifications_total=%d\ntg_notifications_failed_total=%d\n", t, f}'
|
||||||
|
firing=$(curl -s --max-time 10 http://127.0.0.1:9093/api/v2/alerts?active=true | python3 -c 'import json,sys
|
||||||
|
try: print(len(json.load(sys.stdin)))
|
||||||
|
except Exception: print(-1)')
|
||||||
|
echo "alerts_active=$firing"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# monwatch — внешний сторож мониторинга fr1 («кто сторожит сторожа»).
|
||||||
|
# Весь мониторинг (Prometheus, Alertmanager → Telegram) живёт на fr1: если упадёт fr1 или сломается
|
||||||
|
# цепочка доставки, никаких алертов не будет вообще. Раз в час малина спрашивает fr1 `health`
|
||||||
|
# (тот же ограниченный ключ метрик, режим только чтение) и при проблеме пишет в Telegram
|
||||||
|
# НАПРЯМУЮ — через своего бота (@rasp_contol_bot, токен из /etc/astra-bot.env), минуя fr1.
|
||||||
|
#
|
||||||
|
# Тревога — при поломке, повтор раз в 6 ч, ✅ при восстановлении. Разовый сбой сети
|
||||||
|
# не тревожит: перед тревогой одна повторная проверка через 2 минуты.
|
||||||
|
set -uo pipefail
|
||||||
|
STATE=/var/lib/monwatch; mkdir -p "$STATE"
|
||||||
|
CHAT=${MONWATCH_CHAT:-607015}
|
||||||
|
REPEAT=$((6 * 3600))
|
||||||
|
MAX_PUSH_AGE=${MONWATCH_MAX_PUSH_AGE:-3600} # smart-metrics пушит каждые 15 мин
|
||||||
|
|
||||||
|
health() {
|
||||||
|
timeout 60 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 health 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check() { # печатает проблемы по строке; пусто = всё хорошо
|
||||||
|
local out rc
|
||||||
|
out=$(health); rc=$?
|
||||||
|
if [ $rc -ne 0 ] || ! grep -q '^prometheus_healthy=' <<< "$out"; then
|
||||||
|
echo "fr1 не отвечает на проверку (ssh rc=$rc): $(tail -1 <<< "$out")"; return
|
||||||
|
fi
|
||||||
|
declare -A v; while IFS='=' read -r k val; do v[$k]=$val; done <<< "$out"
|
||||||
|
[ "${v[prometheus_healthy]}" = 200 ] || echo "Prometheus не отвечает (HTTP ${v[prometheus_healthy]})"
|
||||||
|
[ "${v[alertmanager_healthy]}" = 200 ] || echo "Alertmanager не отвечает (HTTP ${v[alertmanager_healthy]}) — алерты не доставляются"
|
||||||
|
[ "${v[pushgateway_healthy]}" = 200 ] || echo "Pushgateway не отвечает (HTTP ${v[pushgateway_healthy]})"
|
||||||
|
local age=${v[push_age_seconds]:--1}
|
||||||
|
if [ "$age" -lt 0 ] || [ "$age" -gt $MAX_PUSH_AGE ]; then
|
||||||
|
echo "Prometheus не видит свежих метрик малины ($([ "$age" -lt 0 ] && echo 'нет данных' || echo "$((age / 60)) мин"))"
|
||||||
|
fi
|
||||||
|
local failed=${v[tg_notifications_failed_total]:-0} prev
|
||||||
|
prev=$(cat "$STATE/tg_failed" 2>/dev/null || echo "$failed")
|
||||||
|
[ "$failed" -gt "$prev" ] && echo "Alertmanager не смог отправить в Telegram $((failed - prev)) уведомл. за последний час"
|
||||||
|
echo "$failed" > "$STATE/tg_failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
tg() {
|
||||||
|
local token
|
||||||
|
token=$(sed -n 's/^TG_TOKEN=//p' /etc/astra-bot.env)
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' --max-time 30 "https://api.telegram.org/bot$token/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=$CHAT" --data-urlencode "text=$1" | grep -q 200 || echo "не удалось отправить в Telegram" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
problems=$(check)
|
||||||
|
if [ -n "$problems" ]; then
|
||||||
|
sleep 120; problems=$(check) # повторная проверка: не тревожим из-за разового сбоя сети
|
||||||
|
fi
|
||||||
|
now=$(date +%s); state=$(cat "$STATE/state" 2>/dev/null || echo up); last=$(cat "$STATE/last_alert" 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
if [ -n "$problems" ]; then
|
||||||
|
echo "DOWN: $problems"
|
||||||
|
if [ "$state" != down ] || [ $((now - last)) -ge $REPEAT ]; then
|
||||||
|
tg "🔴 Мониторинг fr1 сломан — алерты от него могут не приходить.
|
||||||
|
$problems
|
||||||
|
|
||||||
|
(сторож на rpi, проверка раз в час; повтор через 6 ч)" && echo "$now" > "$STATE/last_alert"
|
||||||
|
fi
|
||||||
|
echo down > "$STATE/state"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: мониторинг fr1 в порядке"
|
||||||
|
[ "$state" = down ] && tg "✅ Мониторинг fr1 снова в порядке (сторож на rpi)."
|
||||||
|
echo up > "$STATE/state"
|
||||||
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,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Watchdog for fr1 monitoring (alerts to Telegram directly, bypassing fr1)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/monwatch
|
||||||
|
TimeoutStartSec=10min
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Hourly check that fr1 monitoring is alive
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=5min
|
||||||
|
OnCalendar=hourly
|
||||||
|
RandomizedDelaySec=2min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ru1: ~/gost/docker-compose.yml (каталог и имя проекта `gost` — исторические).
|
||||||
|
# Фронт WEB-прокси Telegram. Сам relay и MTProxy — systemd на хосте
|
||||||
|
# (setup-ru1.sh), nginx в host-network проксирует на 127.0.0.1:8090.
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: nginx
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
# certbot живёт в отдельном контейнере и не может дёрнуть nginx —
|
||||||
|
# перечитываем сертификаты раз в 6 часов сами.
|
||||||
|
command: ["/bin/sh", "-c", "while :; do sleep 6h; nginx -s reload; done & exec nginx -g 'daemon off;'"]
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./certbot/conf:/etc/letsencrypt:ro
|
||||||
|
- ./certbot/www:/var/www/certbot:ro
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot
|
||||||
|
container_name: certbot
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
|
- ./certbot/www:/var/www/certbot
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done;'"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
events {}
|
||||||
|
|
||||||
|
http {
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# Ничего не логируем по ru.ruzzy.dev: в URI bridge-страницы — capability,
|
||||||
|
# в Sec-WebSocket-Protocol — токен сессии (требование tproxy-server).
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' "";
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream tproxy_relay {
|
||||||
|
server 127.0.0.1:8090; # tproxy-server (systemd), 8080 занят cadvisor
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ru.ruzzy.dev;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mode A из tproxy-server: relay — единственный шлюз хоста, он же отдаёт
|
||||||
|
# публичный сайт (/srv/tproxy-site). Никаких отдельных location под сайт,
|
||||||
|
# иначе ответы сайта и транспорта станут различимы для зондирования.
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name ru.ruzzy.dev;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/ru.ruzzy.dev/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/ru.ruzzy.dev/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
gzip on;
|
||||||
|
|
||||||
|
# Пачки uplink до 2 MiB; дефолтный 1 MiB ломает транспорт с 413.
|
||||||
|
client_max_body_size 4m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://tproxy_relay;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
# Ровно один адрес: список relay отвергает.
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
# long poll relay — 25 с; не резать и не буферизовать.
|
||||||
|
proxy_read_timeout 90s;
|
||||||
|
proxy_send_timeout 90s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Однократная установка WEB-прокси Telegram (tproxy-server + официальный MTProxy)
|
||||||
|
# на ru1. Запускать от root из каталога с исходниками tproxy-server:
|
||||||
|
# sudo bash /tmp/tproxy-server/setup-ru1.sh
|
||||||
|
# Повторный запуск безопасен: секрет и token.key не перегенерируются.
|
||||||
|
#
|
||||||
|
# Отличия от deploy/install.sh апстрима:
|
||||||
|
# - без Caddy: 80/443 держит nginx в docker (~/gost), он проксирует всё в relay;
|
||||||
|
# - relay на 127.0.0.1:8090/8091 — 8080 занят cadvisor (*:8080);
|
||||||
|
# - NAT задан вручную: трафик к Telegram идёт через wg0 и выходит с IP fr1,
|
||||||
|
# а автоопределение install.sh спросило бы ifconfig-сервис через ens3
|
||||||
|
# и получило 85.198.109.86 — MTProxy молча перестал бы отвечать клиентам;
|
||||||
|
# - без base path: его пока понимает только Desktop.
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
hostname=ru.ruzzy.dev
|
||||||
|
nat_args="--nat-info 10.8.1.4:161.97.93.252"
|
||||||
|
|
||||||
|
[[ "${EUID}" -eq 0 ]] || { echo "run as root" >&2; exit 1; }
|
||||||
|
cd "$repo"
|
||||||
|
|
||||||
|
# 1. Официальный MTProxy (закреплённый коммит, sha256) + конфиг Telegram.
|
||||||
|
bash deploy/install-mtproxy.sh
|
||||||
|
|
||||||
|
# 2. Relay.
|
||||||
|
id tproxy >/dev/null 2>&1 || useradd --system --home /nonexistent --shell /usr/sbin/nologin tproxy
|
||||||
|
install -o root -g root -m 0755 tproxy-server.bin /usr/local/bin/tproxy-server
|
||||||
|
|
||||||
|
install -d -o root -g root -m 0755 /srv/tproxy-site
|
||||||
|
install -o root -g root -m 0644 /home/mik/gost/nginx/html/index.html /srv/tproxy-site/index.html
|
||||||
|
|
||||||
|
install -d -o root -g tproxy -m 0750 /etc/tproxy-server
|
||||||
|
bash deploy/ensure-token-key.sh
|
||||||
|
|
||||||
|
if [[ ! -f /etc/tproxy-server/profiles.json ]]; then
|
||||||
|
secret="$(openssl rand -hex 16)"
|
||||||
|
printf '{"profiles":[{"name":"default","secret":"%s","backend":"127.0.0.1:2398"}]}\n' \
|
||||||
|
"$secret" > /etc/tproxy-server/profiles.json
|
||||||
|
printf 'MTPROXY_SECRET=%s\nMTPROXY_NAT_ARGS=%s\n' "$secret" "$nat_args" > /etc/mtproxy/mtproxy.env
|
||||||
|
unset secret
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > /etc/tproxy-server/config.json <<EOF
|
||||||
|
{
|
||||||
|
"public_hostname": "$hostname",
|
||||||
|
"base_path": "",
|
||||||
|
"listen": "127.0.0.1:8090",
|
||||||
|
"admin_listen": "127.0.0.1:8091",
|
||||||
|
"public_dir": "/srv/tproxy-site",
|
||||||
|
"static_routes": "exact",
|
||||||
|
"profiles_file": "/run/credentials/tproxy-server.service/profiles.json"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chown root:tproxy /etc/tproxy-server/config.json /etc/tproxy-server/profiles.json
|
||||||
|
chmod 0640 /etc/tproxy-server/config.json
|
||||||
|
chmod 0400 /etc/tproxy-server/profiles.json
|
||||||
|
chown root:mtproxy /etc/mtproxy/mtproxy.env
|
||||||
|
chmod 0640 /etc/mtproxy/mtproxy.env
|
||||||
|
|
||||||
|
# 3. Юниты, firewall (2398/8888 только с lo), ежедневное обновление конфига Telegram.
|
||||||
|
for unit in tproxy-server.service mtproxy.service tproxy-firewall.service \
|
||||||
|
refresh-mtproxy-config.service refresh-mtproxy-config.timer; do
|
||||||
|
install -o root -g root -m 0644 "deploy/$unit" "/etc/systemd/system/$unit"
|
||||||
|
done
|
||||||
|
install -o root -g root -m 0644 deploy/firewall.nft /etc/tproxy-server/firewall.nft
|
||||||
|
install -o root -g root -m 0755 deploy/refresh-mtproxy-config.sh /usr/local/sbin/refresh-mtproxy-config
|
||||||
|
|
||||||
|
/usr/local/bin/tproxy-server -config /etc/tproxy-server/config.json \
|
||||||
|
-profiles-file /etc/tproxy-server/profiles.json -check
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now tproxy-firewall.service
|
||||||
|
systemctl enable --now mtproxy.service
|
||||||
|
systemctl restart mtproxy.service
|
||||||
|
systemctl enable --now tproxy-server.service
|
||||||
|
systemctl enable --now refresh-mtproxy-config.timer
|
||||||
|
|
||||||
|
for _ in $(seq 20); do
|
||||||
|
curl -fs -o /dev/null http://127.0.0.1:8091/readyz && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:8091/readyz >/dev/null && echo "relay: ready"
|
||||||
|
|
||||||
|
echo "Ссылка для клиента: sudo sed -n 's/.*\"secret\":\"\\([0-9a-f]*\\)\".*/https:\\/\\/t.me\\/webproxy?server=$hostname\\&secret=\\1/p' /etc/tproxy-server/profiles.json"
|
||||||
Reference in New Issue
Block a user