Compare commits

171 Commits
Author SHA1 Message Date
astra ce23ac2824 rpi: отметить проверки принтера без подтверждения 2026-10-19 15:47:45 +03:00
gilfanovrandClaude Opus 5.5 5e6df382c8 fr1: DulyNotes — продолжение в комментарии через закреплённую копию поста, ретрай 429
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:28:33 +03:00
gilfanovrandClaude Opus 5.5 69ad9cf123 rpi: tg_bios.py — личные каналы участников; FloodWait ResolveUsername ~22 ч — размеры с t.me
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:22:09 +03:00
gilfanovrandClaude Opus 5.5 1a525e9073 rpi: tg_members.py — участники группы с аватарками (CY Fast Track Talks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:18:07 +03:00
gilfanovrandClaude Opus 5.5 15a9e1d2de rpi: tg digest — вечерний рекап группы целиком (23:50 Кипр, Codex), CY Fast Track Talks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:14:31 +03:00
gilfanovrandClaude Opus 5.5 dfe0fad728 rpi: tg-export — + @cyfasttrack (CY Fast Track Talks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:08:36 +03:00
gilfanovrandClaude Opus 5.5 3bdb75d465 rpi: tg — кэш векторов (npz) и служба tg-embed: поиск ~6 с вместо ~40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:01:45 +03:00
gilfanovrandClaude Opus 5.5 48c129615f rpi: открытый хвост — место на диске роутера (прогноз, алерт проверен)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:34:39 +03:00
gilfanovrandClaude Opus 5.5 e327b53496 TODO: пункты 8, 13, 15 — готово (владелец)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:31:11 +03:00
gilfanovrandClaude Opus 5.5 4da6260433 rpi: tg-export — все кипрские группы и каналы (кипр/cyprus в названии + явные @username), без личных и ботов
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:27:33 +03:00
gilfanovrandClaude Opus 5.5 f7adfb15c3 rpi: tg-export.service — вернуть комментарий
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:24:24 +03:00
gilfanovrandClaude Opus 5.5 a09106648b rpi: tg-export — 13 выбранных чатов (вступили в @cyvisaorg, @cylang, @cyprusit, @cyprusinet; @cyschools — заявка)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:24:10 +03:00
gilfanovrandClaude Opus 5.5 d13a05510c rpi: tg-export — + @cyprussport (Cyprus Sport)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:03:53 +03:00
gilfanovrandClaude Opus 5.5 9a82a6e639 rpi: README tg-export — период выгрузки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:58:01 +03:00
gilfanovrandClaude Opus 5.5 b2bb921890 rpi: tg-export — история за последние 2 года (--since 2024-10-18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:58:00 +03:00
gilfanovrandClaude Opus 5.5 1dc4705309 rpi: описание выбранных чатов tg-export и эпизода с лишней выгрузкой
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:52:44 +03:00
gilfanovrandClaude Opus 5.5 ba6cc4054a rpi: tg-export — только 6 выбранных чатов (--only); таймер выключен до вступления в чаты
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:52:32 +03:00
gilfanovrandClaude Opus 5.5 6a4c199e47 rpi: описание tg-поиска и сводки (README стека, rpi.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:40:17 +03:00
gilfanovrandClaude Opus 5.5 1bf2760973 rpi: tg — RAG-поиск по группам и утренняя сводка по темам (Codex), докачка каждый час
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:39:55 +03:00
gilfanovrandClaude Opus 5.5 bb5b2c46cd fr1: DulyNotes — очередь книг (pubctl.mjs), настройки на книгу
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:18:34 +03:00
gilfanovrandClaude Opus 5.5 3f5f9664e7 fr1: DulyNotes — больше примеров кода, подсветка Go
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:26:57 +03:00
gilfanovrandClaude Opus 5.5 9bb9b1debf fr1: DulyNotes — продолжение статей в комментариях, группа обсуждений подключена
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:15:49 +03:00
gilfanovrandClaude Opus 5.5 3007b43788 fr1: DulyNotes — заметка на главу отдельным сообщением, без упоминания книги
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:37:14 +03:00
gilfanovrandClaude Opus 5.5 ebe17db0ba fr1: DulyNotes — посты связным рассказом вместо списка
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:24:23 +03:00
gilfanovrandClaude Opus 5.5 7fc58175dd fr1: DulyNotes — публикация глав по расписанию, ограниченный режим комментариев, astra-draw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:16:33 +03:00
astra 6b88f9856f rpi: определять озвучку по описанию раздачи до загрузки 2026-10-18 12:59:11 +03:00
astra 5132ef8c63 rpi: скачать Бёрта Уандерстоуна и уведомить о готовности 2026-10-18 12:58:46 +03:00
gilfanovrandClaude Opus 5.5 19f957f1c6 fr1: claude-channels — подключён канал DulyNotes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:58:11 +03:00
gilfanovrandClaude Opus 5.5 5e0b13db00 fr1: claude-channels — канал бот подключает сам по команде владельца
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:39:24 +03:00
gilfanovrandClaude Opus 5.5 6a564df167 fr1: books — логи планировщика, bot:-токены MCP; claude-channels — MCP books
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:35:36 +03:00
gilfanovrandClaude Opus 5.5 e38ca91cff rpi: books-sync — без обложек, предисловий, указателя и прочей обвязки книги
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:48:06 +03:00
gilfanovrandClaude Opus 5.5 4eacb09f8e rpi: books-sync — диалоги с ИИ не выгружать, только заметки и рекапы
Нужное из диалога владелец сохраняет кнопкой «В заметки». Файл книги, где заметок
не осталось, переписывается шапкой («Мои мысли» сохраняются).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:43:08 +03:00
gilfanovrandClaude Opus 5.5 71486960c5 rpi: books-sync — заметки читалки в Obsidian «Книги/» (pull по export-токену)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:39:22 +03:00
gilfanovrandClaude Opus 5.5 8a06244ef4 rpi: выгрузка Telegram второго аккаунта (tg_export с Mac) — stacks/rpi-tg-export
Пользователь tgexport, сессия /var/lib/tg-export (600), тексты в /mnt/backup/tg (группа astra),
докачка раз в сутки без медиа, итог в backup-metrics. Вход делает владелец.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:26:35 +03:00
gilfanovrandClaude Opus 5.5 8373a87826 TODO: пункт 14 закрыт — других ящиков не подключаем
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:19:22 +03:00
gilfanovrandClaude Opus 5.5 169978d2d4 rpi: второй ящик ruzzyrullezz@gmail.com в почте Astra (astra-bot 86c7f4d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:18:42 +03:00
astra 70b2be3f2a rpi: исправить пропущенные вложения из-за пробелов 2026-10-15 19:38:19 +03:00
astra be5aa94c18 rpi: опознать Onyx Boox владельца 2026-10-14 08:39:33 +03:00
astra 435c1d1a13 rpi: установить доставку вложений в ответах бота 2026-10-13 20:33:08 +03:00
astra 176913cb15 rpi: установить разрешение конкретного материала для Поли 2026-10-13 18:49:41 +03:00
claudeandClaude Opus 5.5 bdc7e5ecc9 rpi: материал не по возрасту — с разрешения владельца, печать Поли обычная — код готов (astra-bot d916407), не установлен
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:47:03 +03:00
astra 5c3f94bed8 rpi: установить подтверждение детской печати владельцем 2026-10-13 18:32:42 +03:00
claudeandClaude Opus 5.5 3bdda1068d rpi: печать для Поли только после кнопки владельца — код готов (astra-bot 83aed7a), не установлен
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:25:28 +03:00
gilfanovrandClaude Opus 5.5 e7499cab3e TODO: книги — сделано (books.ruzzy.dev)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:56:35 +03:00
gilfanovrandClaude Opus 5.5 643744a575 TODO: MCP ко всему (книги — внешний доступ: токены/OAuth; дальше заметки, почта, календарь)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:55:16 +03:00
gilfanovrandClaude Opus 5.5 fdc52ba982 fr1: gVisor (runsc) для books-runner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:45:48 +03:00
gilfanovrandClaude Opus 5.5 d08516d2ab fr1: books-runner — песочница запуска примеров кода
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:50:36 +03:00
gilfanovrandClaude Opus 5.5 85fc5c0bce fr1: books — thinking выключен, прогретые процессы Claude
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:38:25 +03:00
gilfanovrandClaude Opus 5.5 316e1e4af4 fr1: books — Go-инструменты и фоновая подготовка глав
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:14:00 +03:00
astra 189b85d3f7 rpi: запланировать безопасный рабочий контур 2026-10-13 11:31:48 +03:00
astra 8d56e3fa97 rpi: добавить оповещатель Linear в план 2026-10-13 11:30:30 +03:00
astra fc9fe0ccfd rpi: добавить идею рабочего скилла GitLab 2026-10-13 11:28:23 +03:00
gilfanovrandClaude Opus 5.5 352d114023 fr1: books — ИИ подключён и проверен
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:04:15 +03:00
gilfanovrandClaude Opus 5.5 9c5c9de412 fr1: books.ruzzy.dev — vhost, сертификат, DNS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:58:28 +03:00
gilfanovrandClaude Opus 5.5 7408e350f5 fr1: books — вход по логину и паролю
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:51:37 +03:00
gilfanovrandClaude Opus 5.5 dfb8dffc6f fr1: books — деплой без .git
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:45:14 +03:00
gilfanovrandClaude Opus 5.5 0f2a403b99 books: код переехал в отдельный репо ruzzy/books
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:45:05 +03:00
gilfanovrandClaude Opus 5.5 9aab5b3f40 fr1: books.ruzzy.dev — читалка с ИИ (PDF/EPUB → главы, рекапы, перевод/объяснение выделенного, вопросы с RAG; Claude Code по подписке без инструментов; вход через Telegram)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:39:51 +03:00
gilfanovrandClaude Opus 5.5 8fd84239be rpi: личные дела — скилл life
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:06:11 +03:00
gilfanovrandClaude Opus 5.5 4b8405040e TODO: бэкапы OneDrive и роутера проходят успешно
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:02:43 +03:00
gilfanovrandClaude Opus 5.5 5a16e62745 TODO: подключить остальные почты (ruzzyrullezz@gmail.com в первую очередь)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:01:28 +03:00
gilfanovrandClaude Opus 5.5 d23be78c0a rpi: mail — метки без префикса Astra/
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:55:08 +03:00
gilfanovrandClaude Opus 5.5 f1bef9d2aa rpi: mail — метки классов в Gmail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:40:54 +03:00
gilfanovrandClaude Opus 5.5 a65a19d5b2 rpi: mail — оповещения об ожидаемых письмах (nellka)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:25:17 +03:00
gilfanovrandClaude Opus 5.5 5dcf669200 rpi: Astra — критерии подтверждений, меньше кнопок
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:11:53 +03:00
astra a2ec7229e5 rpi: описать вторую песню в профиле Поли 2026-10-13 08:15:17 +03:00
gilfanovrandClaude Opus 5.5 fb76b2116b rpi: mail — индекс «Отправленных», поиск по адресатам
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:10:56 +03:00
gilfanovrandClaude Opus 5.5 3512f4dbef rpi: mail — ссылка на коммит astra-bot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:50:57 +03:00
gilfanovrandClaude Opus 5.5 2a166b70cb rpi: mail — индексация завершена, сверка «прочитано», уточнены классы
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:50:43 +03:00
astra 498f729620 rpi: описать исключение общих заданий из домашки 2026-10-12 14:24:21 +03:00
astra 1d4ffff98c rpi: описать пропуск пустых дат домашки 2026-10-12 14:22:19 +03:00
astra 1de8c11fb8 rpi: описать период домашки в скилле ЭлЖура 2026-10-12 14:15:38 +03:00
astra 44aaf00227 rpi: описать сохранение песни в профиле Поли 2026-10-12 14:04:50 +03:00
gilfanovrandClaude Opus 5.5 dff068b9e5 rpi: work — проекты с планами, проблемами и журналом
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:12:00 +03:00
gilfanovrandClaude Opus 5.5 d832be2ccf rpi: рабочий помощник — скилл work (задачи/входящие/дневник/встречи в vault Работа/, сводка будни 09:00, напоминания ⏰)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:02:54 +03:00
astra 2703917be4 rpi: описать напоминания рабочих событий 2026-10-12 12:30:45 +03:00
gilfanovrandClaude Opus 5.5 d101f7093a rpi: почта — индекс/классы (mail), выгрузка писем Нели в Nelka/, gmail.modify; квота Gmail ~100 писем/мин
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:34:20 +03:00
gilfanovrandClaude Opus 5.5 b69afd89a1 rpi: календарь — правила записи, оповещения об изменениях Оли, напоминания; TODO: календарь Полины
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:04:19 +03:00
gilfanovrandClaude Opus 5.5 7d31435262 rpi: Google — праздники Кипра вместо США
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:55:57 +03:00
gilfanovrandClaude Opus 5.5 f87fcd1f5a rpi: Google — календарь и Gmail (только чтение) для Astra; fr1: страницы OAuth на www.ruzzy.dev/astra
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:54:55 +03:00
gilfanovrandClaude Opus 5.5 d28fe9f5be rpi: vault закрыт для 129703290, отдельный vault по просьбе; починен .profile astra
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:47:37 +03:00
gilfanovrandClaude Opus 5.5 1c636c1f0f rpi: уведомления малины — также 129703290 (по его просьбе)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:46:09 +03:00
gilfanovrandClaude Opus 5.5 5b2640ee10 rpi: получатели уведомлений — TG_NOTIFY_IDS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:36:02 +03:00
gilfanovrandClaude Opus 5.5 d96dcf1f64 rpi: astra-bot — доступ Telegram ID 129703290 (как у владельца)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:34:26 +03:00
gilfanovrandClaude Opus 5.5 a4aee69170 rpi: eljur — триместры/промежутки, итоговые, доступ Поли; форматирование бота
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:54:27 +03:00
gilfanovrandClaude Opus 5.5 9c589936e6 rpi: eljur table (триместр/промежуток), рендер таблиц в боте; грабли с границами триместров
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:37:53 +03:00
gilfanovrandClaude Opus 5.5 ed1ea6d207 rpi: eljur — оценки по отдельному предмету
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:30:36 +03:00
gilfanovrandClaude Opus 5.5 1415aa37d4 rpi: электронный дневник ЭлЖур — скилл eljur, уведомления о новых оценках
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:27:34 +03:00
astra b2df510a96 rpi: записать ручной запуск OneDrive с фильтром 2026-10-12 08:16:55 +03:00
astra 6dfb1394f9 rpi: исключать помеченные OneDrive заражённые файлы 2026-10-12 07:01:06 +03:00
gilfanovrandClaude Opus 5.5 6ef28a7621 rpi: Samba — сетевая папка бэкапа OneDrive для Mac (только чтение, только LAN)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:21:07 +03:00
gilfanovrandClaude Opus 5.5 2ea676eee8 rpi: субагент Claude Code — пользователь claude (sudo), Gitea-пользователь, мост ask-claude; TODO п.9 закрыт
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:41:43 +03:00
gilfanovrandClaude Opus 5.5 8548ea0bb4 TODO: убраны снятые пункты (банки, камера); сведения о камере — в servers/rpi.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:23:59 +03:00
gilfanovrandClaude Opus 5.5 72ad013505 TODO: убран выполненный пункт Obsidian (факт — в servers/rpi.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:23:21 +03:00
gilfanovrandClaude Opus 5.5 b44a9e6fb2 rpi: архив vault в gitea — каждый час при изменениях (было раз в сутки); алерт свежести 3 ч
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:22:03 +03:00
gilfanovrandClaude Opus 5.5 7ef4d0b3e5 rpi: Obsidian-vault через Obsidian Sync, суточный архив в ruzzy/MyVault, скилл obsidian у Astra; алерты backup_vault
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:20:06 +03:00
gilfanovrandClaude Opus 5.5 89c9563453 Удалить stacks/astra-marusia: интеграция с Марусей отменена (остаётся в истории git)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:04:30 +03:00
gilfanovrandClaude Opus 5.5 4775d59a4e rpi: интеграция с Марусей удалена (юниты, код, секрет, CODEX_HOME, ключ туннеля)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:01:53 +03:00
gilfanovrandClaude Opus 5.5 5c17e4f3d6 fr1: интеграция с Марусей демонтирована (nginx, LE, sshd, пользователь, audit); осталась DNS-запись
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:58:55 +03:00
gilfanovrandClaude Opus 5.5 3ecc432ab4 Маруся: интеграция отменена владельцем — службы и туннель на rpi выключены; остатки на fr1 — к демонтажу
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:42 +03:00
gilfanovrandClaude Opus 5.5 cc363aa714 fr1: у бота claude-channels убран доступ к Obsidian-vault, ~/MyVault удалён, ask-astra → /root/astra-work
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:41:10 +03:00
gilfanovrandClaude Opus 5.5 5b60da731b TODO: Obsidian — git-синк убран везде, дальше ob на rpi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:19:44 +03:00
gilfanovrandClaude Opus 5.5 61b38b6886 fr1: синк MyVault через git выключен (Obsidian-vault теперь на Obsidian Sync), MyVault — архив на 2026-09-23
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:10:23 +03:00
astra d5bff58e2a rpi: зафиксировать обновление каталога Чернобыля 2026-10-11 19:18:27 +03:00
gilfanovrandClaude Opus 5.5 3a87905764 TODO: банки сняты (решение владельца)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:09:04 +03:00
gilfanovrandClaude Opus 5.5 b682f4db3c TODO: .46 — пункт 12 (11 занят снятой камерой)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:08:20 +03:00
astra 61e191fabf rpi: уведомлять владельца о скачивании Чернобыля 2026-10-11 19:04:36 +03:00
gilfanovrandClaude Opus 5.5 d2283f0d77 TODO: сняты бэкап малины и Wi‑Fi роутера (решение владельца), .46 — в общий список
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:04:04 +03:00
gilfanovrandClaude Opus 5.5 5cc0d468ef fr1: туннель Маруси проверен; fail2ban — домашний IP в ignoreip
- 18788 только на loopback, 8788/18788 снаружи закрыты, коды вебхука
  совпали с ожидаемыми, секрета в логах nginx нет
- грабли: access log выключен только в vhost astra — запрос к пути
  вебхука на другом имени пишется в общий лог (было превью TelegramBot
  с пустым секретом)
- fail2ban: 46.199.208.45 в ignoreip (IP динамический — ⚠️)
- интеграция не завершена: навык VK и проверка на колонке за владельцем

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:59:35 +03:00
gilfanovrandClaude Opus 5.5 587ae5ce7a TODO: п. 11 (камера) снят — хватает Mi Home
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:52:04 +03:00
astra a879988cb0 rpi: исправить ложную просрочку метрик дневных бэкапов 2026-10-11 18:44:28 +03:00
astra 587a57a60c rpi: усилить изоляцию Маруси и записать повторную проверку 2026-10-11 18:41:06 +03:00
astra aece60da74 rpi: запустить и проверить туннель вебхука Маруси 2026-10-11 18:26:59 +03:00
astra 258367d22d rpi: усилить ограничения разговорного сервиса Маруси 2026-10-11 18:24:26 +03:00
gilfanovrandClaude Opus 5.5 e2bd908198 fr1: вебхук Маруси — vhost astra.ruzzy.dev и пользователь туннеля marusia-tunnel
- nginx: vhost astra.ruzzy.dev (LE), только POST /marusia/webhook/ → 127.0.0.1:18788,
  limit_req 1r/s, 64k, без access log, error_log crit (секрет в пути); ссылка zz-,
  чтобы не перехватить ответ на неизвестный SNI у git
- sshd: Match User marusia-tunnel — только remote forward 127.0.0.1:18788;
  добавлен в AllowUsers
- живые конфиги fr1 в stacks/astra-marusia/ вместо nginx-location.example
- fr1.md: эффективные настройки sshd проверены под root, отпечатки host-ключей
- туннель ещё не поднят, интеграция не завершена (хвост в fr1.md)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:22:56 +03:00
astra 3159921a4d rpi: создать отдельный ключ туннеля Маруси 2026-10-11 18:17:12 +03:00
astra 2f9f222fac rpi: отметить планшет подруги Полины в реестре 2026-10-11 18:16:01 +03:00
astra 436e855f33 rpi: установить локальный разговорный сервис Маруси 2026-10-11 17:55:56 +03:00
gilfanovrandClaude Opus 5.5 0de0132eef TODO: подключить камеру
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:50:12 +03:00
gilfanovrandClaude Opus 5.5 2cac56073e rpi-media: пароли сняты для домашней сети (*arr, qBittorrent, Bazarr), Jellyfin — пользователь «Дом» без пароля
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:47:43 +03:00
astra d2a7b8efbb rpi: описать машинку ожидания в Telegram 2026-10-11 17:45:42 +03:00
gilfanovrandClaude Opus 5.5 fc59f7ac39 TODO: п. 10 — банки (Bank of Cyprus, Revolut), анализ трат и подписок
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:43:36 +03:00
astra e1e8c45a93 rpi: описать переход голосовых на многоязычную Whisper 2026-10-11 17:13:22 +03:00
gilfanovrandClaude Opus 5.5 78f34c97eb TODO: п. 9 — Claude Code как субагент Astra на rpi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:00:44 +03:00
gilfanovrandClaude Opus 5.5 b982f63b24 TODO: п. 8 — промежуточные точки доступа и скиллы для других устройств
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:58:46 +03:00
gilfanovrandClaude Opus 5.5 4ed2a16499 TODO: новые возможности Astra — просто список, детали позже
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:55:07 +03:00
gilfanovrandClaude Opus 5.5 1287a857cf TODO: новые возможности Astra — Obsidian, два календаря, книги PDF, планы обучения, проекты, личные дела, чаты
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:54:16 +03:00
gilfanovrandClaude Opus 5.5 b34e831fef rpi: DLNA и FTP на диске роутера выключены; TODO — срочный раздел снят (сделано/снято владельцем)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:48:48 +03:00
astra a360743dc7 rpi: описать локальное распознавание голосовых 2026-10-11 16:46:47 +03:00
gilfanovrandClaude Opus 5.5 618f789ec0 TODO.md: открытые задачи по инфре после настройки rpi, бэкапов, мониторинга и роутера
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:44:16 +03:00
gilfanovrandClaude Opus 5.5 b8a31c7e54 rpi-media: приоритет русской дорожки и автозамена, ручные субтитры из Sub-раздачи
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:35 +03:00
gilfanovrandClaude Opus 5.5 716539def3 rpi: Astra управляет медиацентром (скилл media)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:16:38 +03:00
gilfanovrandClaude Opus 5.5 705a7dce99 rpi: убрать номер записи DHCP (сдвинулся после удаления .101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:48 +03:00
gilfanovrandClaude Opus 5.5 94bc983401 rpi-media: стартовая страница Homepage, Radarr/Sonarr → Jellyfin connect
- homepage/: сервисы с описанием и живыми виджетами; ключи — в homepage.env на малине
- Jellyfin-виджет через customapi (встроенный не совместим с Jellyfin 12)
- provision.py: шаги homepage и connect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:39 +03:00
gilfanovrandClaude Opus 5.5 f70d5c848f rpi-media: RuTracker через FlareSolverr, таймаут прокси 180 с
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:39 +03:00
gilfanovrandClaude Opus 5.5 08aa787b95 rpi: медиацентр (Jellyfin, Seerr, qBittorrent, *arr) в Docker; диск роутера и резерв DHCP
- stacks/rpi-media: docker-compose и идемпотентный provision.py
- servers/rpi.md: раздел «Медиацентр», почему NTFS и SFTP на роутере,
  закрыт хвост про резерв адреса
- AGENTS.md: роль малины

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:31 +03:00
gilfanovrandClaude Opus 5.5 880ad61253 rpi: удалена неиспользуемая DHCP-привязка 192.168.10.101
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:05:05 +03:00
astra 7843c6e958 rpi: описать разрешённые занятия и возрастные правила Поли 2026-10-11 16:03:55 +03:00
gilfanovrandClaude Opus 5.5 60558dc4b3 rpi: роутер HG8245X6 — скилл router, сторож устройств и трафика, алерты home-network, дашборд home-net
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:59:58 +03:00
astra f9d027ba86 rpi: открыть доступ Поли по Telegram ID 2026-10-11 15:59:17 +03:00
astra 69c02162db rpi: описать постоянную сессию и журнал Поли 2026-10-11 15:54:44 +03:00
astra 6d7dc4c8f1 rpi: описать профиль Поли и ожидание её Telegram ID 2026-10-11 15:51:55 +03:00
astra 0ffcb63b3c rpi: описать исправление кнопок и ответов детской печати 2026-10-11 15:44:13 +03:00
astra 89ca3cea03 rpi: описать детскую печать единым PDF без подтверждений 2026-10-11 15:38:01 +03:00
gilfanovrandClaude Opus 5.5 facc6c1e65 monwatch: сторож мониторинга fr1 на rpi — раз в час health-проверка, тревога в Telegram напрямую
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:36:56 +03:00
astra b881d861f0 rpi: добавить создание PDF с кириллицей для домашней печати 2026-10-11 15:33:38 +03:00
astra bc0537a2d9 rpi: описать навык печати с обязательным предпросмотром 2026-10-11 15:28:38 +03:00
astra 931219ad47 rpi: подтвердить успешное завершение тестовой печати 2026-10-11 15:22:35 +03:00
gilfanovrandClaude Opus 5.5 d780c91bd5 rpi: ежемесячная выборочная перечитка файлов OneDrive с диска роутера (sha256) + алерты
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:21:32 +03:00
astra 629b11f03f rpi: настроить домашнюю печать на Brother DCP-L2620DW 2026-10-11 15:18:56 +03:00
gilfanovrandClaude Opus 5.5 4965f70299 alerts: BackupNeverReported (absent) — ловит задачи бэкапа, ни разу не отчитавшиеся
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:16:17 +03:00
gilfanovrandClaude Opus 5.5 464e12abdb rpi: вторая копия /mnt/backup на 1 ТБ диск в роутере (rclone SFTP) + мониторинг монтирования и заполнения
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:53 +03:00
astra 3811483577 rpi: описать удаление повторных уведомлений очереди Astra 2026-10-11 15:04:09 +03:00
gilfanovrandClaude Opus 5.5 550c359e4a Gitea: пользователь astra вместо двух deploy-ключей — один ключ, запись в infra/astra-bot, свои репо
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:57:13 +03:00
astra c1f16ebc38 rpi: описать обработку альбомов одной пачкой в Astra 2026-10-11 14:56:44 +03:00
gilfanovrandClaude Opus 5.5 0a6d728851 rpi: код бота — только в ruzzy/astra-bot; доступ astra к нему, временная копия из infra убрана
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:48:36 +03:00
astra bba3858894 rpi: исправить приём картинок и пересланных сообщений в Astra 2026-10-11 14:46:48 +03:00
gilfanovrandClaude Opus 5.5 7d0a891793 Бэкап серверов fr1/ru1 на rpi: сервер шифрует age открытым ключом, малина только хранит
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:44:05 +03:00
gilfanovrandClaude Opus 5.5 e645377916 rpi: агенту Astra разрешён прямой push в main infra
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:12:49 +03:00
gilfanovrandClaude Opus 5.5 ef2891323c rpi: агент Astra — Codex + Telegram-бот (ruzzy/astra-bot), грабли с Codex Remote на Linux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:07:30 +03:00
gilfanovrandClaude Opus 5.5 65a01740e8 grafana: дашборд RPi — диск и бэкап OneDrive; заполнение /mnt/backup в метриках
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:37:14 +03:00
gilfanovrandClaude Opus 5.5 2bb74f7430 rpi: пользователь astra (sudo) и deploy-ключ с записью в ruzzy/infra для агента
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:33:28 +03:00
gilfanovrandClaude Opus 5.5 d1269162e7 rpi: бэкап OneDrive на HDD + SMART/бэкап-метрики через pushgateway на fr1
- servers/rpi.md: новая домашняя Raspberry Pi 4 (Trixie, cloud-init), HDD 4 ТБ,
  грабли с питанием двух HDD от USB
- stacks/rpi-backup: rclone sync с вечным архивом версий, еженедельная сверка
  хэшей, smartd, пуш SMART и статуса бэкапов
- fr1: pushgateway (127.0.0.1:9091), приём по SSH через forced command
  ~/bin/pushgw-ingest, алерты группы rpi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:28:20 +03:00
gilfanovrandClaude Opus 5.5 3604e843c8 ru1: рестарт networkd снёс fwmark-правило умного роутинга — починено и защищено
После apt-get из установки WEB-прокси needrestart перезапустил
systemd-networkd, тот удалил ip rule fwmark 0x64 -> vpn: ~45 мин
зарубежный трафик клиентов OpenVPN #1 шёл через RU IP. Правило
возвращено, networkd запрещено трогать чужие правила и маршруты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:42 +03:00
gilfanovrandClaude Opus 5.5 2ab38bc1f8 ru1: WEB-прокси Telegram вместо telemt
На ru.ruzzy.dev:443 поднят официальный tproxy-server (relay, carrier
websocket) перед официальным MTProxy на loopback. Фронт — nginx в
~/gost, сертификат только на ru.ruzzy.dev.

- MTProxy: --nat-info 10.8.1.4:161.97.93.252 вручную — трафик к
  Telegram выходит через wg0 с IP fr1, автоопределение install.sh
  ошиблось бы и прокси молча не отвечал бы.
- relay на 8090/8091: 8080 занят cadvisor.
- telemt удалён окончательно (контейнер, образ, конфиг, том).
- certbot до этого не работал вовсе: сертификат был просрочен с
  2026-07-01, продлён и запущен.

stacks/tproxy/: compose и nginx.conf стека ~/gost, setup-ru1.sh —
однократная root-установка relay и MTProxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:16:12 +03:00
gilfanovrandClaude Opus 5.5 123cdaa19b ru1: ключ claude-bot@ruzzy.dev в authorized_keys mik
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:28:06 +03:00
gilfanovrandClaude Opus 5 11531ceb16 Указатели на контракт — на самом сервере
Контракт в репозитории достаёт только агентов, у которых этот репозиторий
есть. Astra работает на fr1 как tgclaude и infra у неё не склонирован, так
что до неё не дошло бы ничего.

Заведено на сервере:
  ~rus/CLAUDE.md и ~tgclaude/CLAUDE.md — указатель на репо, правило и пять
  граблей, стоивших больше всего; 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 host 'команда'` и не увидят ни того, ни другого.

Ограничения механизма и причина, по которой детектор дрейфа не заведён,
описаны в servers/fr1.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:04:08 +03:00
gilfanovrandClaude Opus 5 b56c137b5c Механизм поддержания описаний в актуальном состоянии
Сверка fr1 показала, что дока расходится с фактом по десятку пунктов —
значит нужен не разовый проход, а контракт и инструмент.

  CLAUDE.md — правило «тронул сервер, обнови servers/<хост>.md в том же
  прогоне» плюс стиль: факт + причина + что сломается молча. Лежит именно
  в CLAUDE.md, потому что AGENTS.md автоматически агенту не подгружается.

  .claude/skills/server-audit/ — метод сверки и накопленные грабли: где
  дока врёт чаще всего, почему ufw не отвечает на вопрос об открытых
  портах, почему нужен up -d --dry-run, почему «executed successfully»
  не доказывает работу задания.

  scripts/snapshot.sh — инвентаризация живого состояния за один проход:
  только чтение, без sudo, секретов не печатает, недоступное под root
  помечает и подсказывает команду.

Первый же прогон скрипта нашёл две вещи, обе описаны:

  - IPv6 не «починился», а мигает: за один день FAILED -> REACHABLE ->
    FAILED без единой правки конфига. Описан как мигающий, с выводом
    ничего на него не завязывать.
  - В основном sshd_config стоит PermitRootLogin yes (правка шаблона
    Contabo). Root-вход закрыт только потому, что Include дроп-инов
    стоит выше, а sshd берёт первое значение. Уберёшь дроп-ин —
    включится.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:52:22 +03:00
gilfanovrandClaude Opus 5 4308c5c7e1 fr1: сверка описания с живым сервером
Дока расходилась с фактом по десятку пунктов. Что исправлено по
результатам обхода сервера:

  - ядро 6.8.0-137 (было 134); IPv6 заработал — шлюз отвечает на NDP,
    хотя конфиг не менялся, так что может отвалиться обратно
  - Gitea 1.27.3, 1.9 ГБ, 20 репо (было 1.25.4 / 4.3 ГБ / 16)
  - апекс ruzzy.dev уже на fr1; появились vhost'ы share и www
  - мониторинг шире описанного: таргеты ru1 и job gitea, дашборды
    OpenVPN и Gitea; убраны устаревшие упоминания выведенного de1
  - vault-синк claude-channels работает (дока говорила обратное), том
    claude-home раздулся заново до 2.8 ГБ

Добавлены разделы, которых не было вовсе:
  - tgclaude — общий вход для агентов; цепочка Telegram-бот → агент в
    контейнере → SSH → группа docker → root на хосте
  - imgbot и его переезд в контейнеры
  - расписания: полная инвентаризация cron и таймеров
  - открытые хвосты, включая непогашенные токены в Gitea

Разобрано и описано попутно: ufw не управляет docker-портами (ipcam
месяцами держал 2221 и 30000-30010 наружу без единого правила);
sysstat хранил историю 7 дней при HISTORY=7 — поднято до 365 (при >28
sa1 сам переключает имена на saYYYYMMDD, иначе смысла нет); /etc/cron.
hourly/free час за часом выбрасывал страничный кэш (до -3.89 ГиБ,
подтверждено по Prometheus) — удалён.

Уборка диска: 104 → 52 ГБ занято. Ушли ipcam, sc2-parser, booty,
booty-collector, vacuum-scraper, ollama (8.1 ГБ), легаси-архив
картинок, docker build cache (43.4 ГБ).

В AGENTS.md поправлены две неверные строки: de1 значился действующей
прод-нодой, хотя выведен, а туннель ru1 — идущим до de1. Сами
servers/de1.md и servers/ru1.md не сверялись.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:26:40 +03:00
gilfanovrandClaude Opus 5 a65d6a249e imgbot: стек из двух сервисов вместо процессов на хосте
Проект агента (~tgclaude/imgbot) работал тремя голыми python-процессами,
запущенными руками из SSH-сессии: без юнита, без restart-политики, при
Linger=no. Держались только на KillUserProcesses=no и не пережили бы
перезагрузку, причём молча.

Один образ на базе playwright/python:v1.62.0-noble (версия пакета
playwright обязана совпадать с тегом, иначе разъезжается ревизия
chromium), два сервиса:
  - imgbot-daemon — APScheduler: refresh 30 мин, analyze 5 мин
  - imgbot-http   — объединил прежние mcp-http (8765) и file-server
    (8766): MCP как под-приложение Starlette плюс маршрут /photo/<id>

Неочевидное, из-за чего это ломается молча (в compose есть комментарии):
  - данные монтируются в тот же путь, что на хосте: images.file_path в
    SQLite абсолютный, и его читают обе стороны
  - корней хранения может быть больше одного — проверять все префиксы
    file_path, а не только data_dir
  - HOME обязателен: collector.py вызывает Path.home(), а uid 1001 не
    имеет записи в /etc/passwd образа
  - у MCP своя защита от DNS-rebinding: без IMGBOT_ALLOWED_HOSTS отдаёт
    421 на любой Host кроме localhost

Порт не публикуется на хост, сервис доступен из общей docker-сети с
claude-channels — агент наконец ходит по MCP, а не по SSH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:26:16 +03:00
gilfanovrandClaude Opus 5 bf4708b775 ipcam выведен; пин Gitea приведён к рабочей 1.27.3
ipcam/FTPS-приёмник удалён с fr1 целиком: контейнер, образ, том, сеть,
каталоги /srv/ipcam и /srv/ftps, deploy-хук certbot и сертификат
cam.ruzzy.dev. Видео так и не начали складываться — на момент удаления
в /srv/ipcam было 0 файлов.

Пин образа Gitea в стеке держал 1.25.4, тогда как работает 1.27.3
(обновлён 2026-08-31). Любой `docker compose up -d` откатил бы сервис
на две минорные версии назад.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:26:00 +03:00
99 changed files with 7310 additions and 68 deletions
+74
View File
@@ -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`?
+1
View File
@@ -5,3 +5,4 @@ secrets/
alertmanager/telegram_token
**/telegram_token
stacks/ipcam/.env
__pycache__/
+18 -3
View File
@@ -2,9 +2,24 @@
Описание состояния серверов.
## Задачи
Открытые задачи по всей инфре — [`TODO.md`](TODO.md) (кто делает: владелец или Astra).
## Серверы
- [`servers/de1.md`](servers/de1.md) — `ruzzy.dev` (213.136.74.247). Действующая прод-нода: прокси/VPN (telemt, AmneziaWG, OpenVPN), gitea, openclaw, боты. Источник для переноса.
- [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Новая чистая нода (Ubuntu 24.04). Мониторинг + gitea.
- [`servers/ru1.md`](servers/ru1.md) — `85.198.109.86` (VDSina, РФ). VPN entry-нода (OpenVPN ×2, AmneziaWG до de1, WireGuard клиентов, telemt), умный роутинг RU/зарубеж.
- [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан.
- [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Основная нода (Ubuntu 24.04): мониторинг, gitea, AmneziaWG exit для ru1, боты (claude-channels, openclaw), статика `www`/`share`. Держит апекс `ruzzy.dev`.
- [`servers/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, он помечает и подсказывает команду.
Секреты (пароли, токены) в репозитории не хранятся — только на серверах.
+49
View File
@@ -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` напрямую, если пользователь не просил.
+83
View File
@@ -0,0 +1,83 @@
# TODO
Открытые задачи по инфре. Подробности и контекст — в `servers/<хост>.md` («Открытые хвосты»).
Сделал — вычеркни (`[x]`) с датой или удали пункт, а факт перенеси в паспорт хоста.
Исполнитель: **владелец** — нужны руки/пароли владельца; **Astra** — может сделать агент на rpi.
_Собрано 2026-10-11._
## 🟠 Важно
- [x] (2026-10-13: `onedrive-backup` и `router-backup` — success; OneDrive после фильтра malware-detected прошёл 13.10 08:37) **Astra** — Проверить, что закончились первая выгрузка OneDrive (~368 ГБ) и первичная копия на диск роутера.
⚠️ Копия на роутер идёт медленно (3–8 МБ/с) — может сработать ложный `BackupNeverReported{job="backup_router"}` (30 ч).
## 🟡 По желанию
- [ ] **владелец** — Пароль администратора D‑Link (логин `Admin`) — тогда скилл `router` покажет, какой клиент
к какой точке mesh (M15 №1/№2, E15) подключён, и карта сети станет точной.
- [ ] **Astra** — `STATUS.txt` в `router:rpi-backup/` после каждой копии: даты последних успешных sync OneDrive,
бэкапов серверов и самой копии — чтобы при восстановлении с роутера было видно, насколько данные свежие.
- [ ] **Astra** — Разово забрать на rpi `~rus/backups` (5.4 ГБ, архивы de1) и `~tgclaude/backups` (4.2 ГБ, снимок Gitea
перед апгрейдом) с fr1; после проверки — удалить их с fr1 (решение владельца).
- [ ] **владелец** — `codex-remote.service` на rpi: QR‑сопряжение ChatGPT с Linux не работает — убрать службу или ждать
поддержки от OpenAI.
- [ ] **владелец** — Перевыпустить токен бота @rasp_contol_bot (`/revoke` у @BotFather) — он проходил через чат с
ассистентом; новый вписать в `/etc/astra-bot.env`.
- [ ] **владелец** — Диск WD Elements 1 ТБ: роль второй копии уже у диска в роутере — либо третья копия через USB‑хаб
с питанием, либо забыть.
- [ ] **Astra** — Решить про node-exporter на rpi (CPU/RAM/SD малины сейчас не видны в Prometheus).
- [ ] **Astra** — Политика хранения входящих фото бота (`~astra/.local/share/astra-bot/incoming/` не чистится).
- [ ] **владелец** — Защита ветки `main` в `ruzzy/infra` и `ruzzy/astra-bot` от force-push (обычный push Astra останется).
- [ ] **fr1** — Отозвать старые Gitea-токены `imgbot-setup`/`imgbot-setup2` (рецепт удаления через sqlite — в `servers/fr1.md`).
- [ ] **fr1** — Пароль Grafana вписан литералом в живой `~/monitoring/docker-compose.yml` — вынести в `.env`, как в репо.
## 🧩 Новые возможности Astra (разберём позже)
- [x] 2. Интеграция с двумя календарями — личным и рабочим (2026-10-12: скилл `google`; рабочий — только занятость, см. `servers/rpi.md`)
- [x] 3. Скилл для работы с PDF с книгами (2026-10-13: читалка books.ruzzy.dev на fr1, репо `ruzzy/books`, см. `servers/fr1.md`)
- [ ] 4. Скилл составления и слежения за планами обучения
- [ ] 5. Скилл для собственных проектов
- [x] 6. Скилл для ведения собственных дел (2026-10-13: скилл `life`, `Дела насущные/`, см. `servers/rpi.md`)
- [ ] 7. Скрипты для работы с чатами — выжимки и слежение за интересующей информацией
- [x] 8. Собрать промежуточные точки доступа (mesh D-Link M15 ×2, E15) и настроить скиллы для других устройств — ✅ готово (владелец, 2026-10-18)
- [ ] 12. Опознать `.46` (`ea:08:6d:8c:59:56`, iOS без имени) и подтвердить, что `.13` — iPhone жены
- [x] 13. Подключить календарь для Полины — ✅ готово (владелец, 2026-10-18)
- [x] 14. Подключить к почте Astra (индекс, классы, метки, ожидаемые письма) остальные ящики владельца —
**ruzzyrullezz@gmail.com** подключён 2026-10-16 (см. `servers/rpi.md`, «Второй ящик»); другие ящики не нужны (владелец 2026-10-17).
Для Gmail — тот же OAuth-клиент проекта `astra`, второй токен; ⚠️ квота ~100 писем/мин на пользователя — первичная индексация долгая.
- [x] 15. **MCP ко всему этому** — подключать Claude (claude.ai web/Desktop/телефон, Claude Code, Astra) к книгам и прочему. — ✅ готово (владелец, 2026-10-18)
Читалка books.ruzzy.dev: MCP уже есть (`POST /mcp`, инструменты search_books/read_chapter/get_toc/list_books/get_notes/
get_recaps, только чтение; сейчас им пользуется только ИИ самой читалки по внутреннему токену) — нужен внешний доступ:
Bearer-токены (`books_mcp.py add-token`) для Claude Code/Astra и OAuth 2.1 для custom connector в claude.ai.
Дальше — по аналогии для заметок work/life, почты, календаря, ЭлЖура (решить, что из этого стоит открывать).
## 💼 По работе — новые возможности
- [ ] **Astra** — Настроить безопасный рабочий контур для взаимодействия с рабочими сервисами
(запрос владельца 2026-10-13). Скилл/мониторинг GitLab и оповещатель Linear должны работать из этого контура;
подготовка контура — зависимость перед их подключением. При проектировании определить изоляцию,
минимальные права, хранение секретов, сетевые доступы и журнал действий; сейчас только запись в плане.
- [ ] **Astra** — Скилл GitLab и мониторинг активности (запрос владельца 2026-10-13):
Работает из безопасного рабочего контура (см. пункт выше).
- собирать историю по коммитам;
- находить проекты, помогать с их настройкой и анализом, искать реализацию функционала;
- уведомлять об успешном/неуспешном завершении пайплайна и зависших пайплайнах;
- уведомлять о назначенной владельцу работе (в т.ч. упомянутых «назначенных коммитах»),
оставленных комментариях, упоминаниях и других действиях, где от него что-то требуется.
Перед реализацией уточнить, что означает «назначенные коммиты» (MR на ревью, задачи или другое),
критерий зависшего пайплайна и допустимые изменения проектов. Это запись идеи, GitLab пока не подключать.
- [ ] **Astra** — Оповещатель Linear (запрос владельца 2026-10-13): уведомлять,
когда на владельца назначают задачу, пишут комментарии, меняют статус его задач,
упоминают его и происходит другая относящаяся к нему активность.
Работает из безопасного рабочего контура (см. пункт выше).
Перед реализацией уточнить охват событий и проектов; пока только пункт в плане, без подключения Linear.
## 📅 По расписанию
- [ ] **~2026-10-18, Astra** — Снять SMART диска 4 ТБ (`sudo smartctl -A -d sat /dev/sda`), посчитать прирост за неделю
Start_Stop (id 4, было 21 946) и Load_Cycle (id 193, было 42 530) → реальный прогноз ресурса, записать в паспорт.
- [ ] **2027-01, владелец** — Пробное восстановление бэкапов серверов на Mac (`stacks/server-backup/README.md`):
расшифровать свежий архив fr1 и ru1 — проверка и архивов, и ключа.
- [ ] **2026-11-01** — Первая ежемесячная перечитка файлов OneDrive с диска роутера (`router-sample`) — убедиться, что прошла.
+114
View File
@@ -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
+235 -20
View File
@@ -3,18 +3,24 @@
Снимок состояния сервера. Секреты (пароли/токены) — только на сервере, не здесь.
**Провайдер:** 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
**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).
- root-доступ по SSH закрыт; для root изнутри — `sudo -i`.
- ~~Пользователь `marusia-tunnel`~~ — удалён 2026-10-11 вместе со всей интеграцией с Марусей (отменена владельцем): пользователь и `/home/marusia-tunnel`, `sshd_config.d/70-marusia-tunnel.conf`, запись в `AllowUsers`, vhost `astra.ruzzy.dev` (`sites-available/astra.ruzzy.dev.conf`, ссылка `zz-…`, `conf.d/marusia-webhook.conf`), сертификат LE `astra.ruzzy.dev`, `~rus/marusia-audit/`. Скрипт демонтажа — `~rus/marusia-removal.sh`, копии удалённого — `/root/marusia-removed-20261011/` (root 700). nginx/sshd перезагружались после успешных `-t`; проверено: git/mon открываются, SSH-вход работает.
- **Пуш-ключ `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`)
⚠️ В основном `/etc/ssh/sshd_config` строкой 42 стоит `PermitRootLogin yes` (правка шаблона Contabo). Побеждает всё равно дроп-ин: `Include /etc/ssh/sshd_config.d/*.conf` стоит строкой 12, а sshd берёт **первое** встреченное значение. То есть root-вход закрыт, но держится на порядке строк — уберёшь дроп-ин, и root-вход по ключу включится. Рядом лежит `50-cloud-init.conf` (root 600) — эффективные значения проверены под root 2026-10-11, см. ниже.
```
PermitRootLogin no
PasswordAuthentication no
@@ -22,65 +28,135 @@ PubkeyAuthentication yes
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 3
AllowUsers rus
AllowUsers rus tgclaude
```
Через socket-activation (`ssh.socket`).
Через socket-activation (`ssh.socket`), слушает `0.0.0.0:22` и `[::]:22`.
- `marusia-tunnel` был в `AllowUsers` 2026-10-11 (для туннеля Маруси), убран в тот же день вместе с интеграцией; бэкап исходного — `00-hardening.conf.bak-20261011-171345`.
- Проверено под root 2026-10-11: `50-cloud-init.conf` содержит `PasswordAuthentication yes`, но эффективное значение — `no` (`00-hardening` читается первым); `sshd -T` → `permitrootlogin no`, `passwordauthentication no`. ⚠️ То же «держится на порядке файлов»: переименуй `00-hardening` во что-то после `50-`, и вход по паролю включится.
- Отпечатки host-ключей: ED25519 `SHA256:M5J9nH+svyA3+beD7ZllUn1tayMMO2cxw/t3Z/kj6KU`, ECDSA `SHA256:fMZEytu7XI3W+Mzo4WQ6WZHDn0nYiagtbKb4JyUIX5w`, RSA `SHA256:53olW/dhoPclFgp8X6OWh0euoOfSmwXWZb6c026OGAw`.
## Сеть
- Управляется **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
- Зона `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`, `books` (с 2026-10-13).
- ⚠️ `cam.ruzzy.dev` (CNAME → fr1) остался в Namecheap после удаления ipcam 2026-09-08 — снести руками.
- PTR (reverse DNS): дефолтный `vmi3387709.contaboserver.net` (не менялся).
## Firewall (ufw)
- 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`)
```
[DEFAULT]
bantime=1h findtime=10m maxretry=5
ignoreip = 127.0.0.1/8 ::1 31.153.37.106
ignoreip = 127.0.0.1/8 ::1 31.153.37.106 46.199.208.45
backend=systemd banaction=ufw
[sshd] enabled=true
[recidive] enabled=true bantime=1w findtime=1d maxretry=5
backend=auto logpath=/var/log/fail2ban.log
```
- `46.199.208.45` — текущий домашний IP (Cyta, за ним rpi и Mac), добавлен 2026-10-11 (было только `31.153.37.106`), бэкап `jail.local.bak-20261011-175800`. Зачем: туннель Маруси с rpi переподключается сам, и серия ошибок входа забанила бы весь дом — вместе с `ssh rus-161` — на час, а `recidive` на неделю.
- ⚠️ **Домашний IP динамический** (PPPoE Cyta, см. `servers/rpi.md`). После смены адреса защита молча пропадёт, а в `ignoreip` останется чужой адрес — сверять с `router status` на rpi.
- ⏳ Происхождение `31.153.37.106` нигде не записано (стоит с 2026-06-21) — выяснить и убрать, если не нужен.
## Обновления
- 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 выключен.
## Система
- swap: `/swapfile` 2 GB (в `/etc/fstab`), `vm.swappiness=10` (`/etc/sysctl.d/99-swappiness.conf`).
- journald: `Storage=persistent`, `SystemMaxUse=500M`.
- 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 29.6 + Compose v5.1 (официальный репозиторий). `rus` в группе docker.
## nginx + TLS
- 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'ы `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`; с 2026-10-12 ещё `astra/index.html` и `astra/privacy.html` — страницы OAuth-приложения Astra для Google (`stacks/www-ruzzy/`, см. `servers/rpi.md`).
- 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`) и получает его сертификат. Так отвечают и `https://ruzzy.dev`, и `https://fr1.ruzzy.dev` — своих vhost'ов и сертификатов у них нет, браузер видит несовпадение имени (проверено 2026-10-11).
- vhost `astra.ruzzy.dev` (вебхук Маруси) — **удалён 2026-10-11** вместе с сертификатом. ⚠️ DNS-запись `astra.ruzzy.dev` (CNAME → fr1.ruzzy.dev) у регистратора — удалить вручную; пока она есть, запросы к этому имени отвечает vhost `git` с чужим сертификатом.
- Сертификаты LE: `mon`, `git`, `share`, `www`, `books` (с 2026-10-13), `astra` (с 2026-10-11, `certbot certonly --nginx`, продление тем же таймером). `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam.
## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`)
- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager** (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).
- Prometheus: retention 90d, слушает `127.0.0.1:9090`. Скрейпит node-exporter + cadvisor (job'ы `node`/`cadvisor`, instance `fr1`).
- 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}`).
- Стек: **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; группа `rpi` (с 2026-10-11) — SMART и бэкапы малины: PushMetricsStale, SmartHealthFailed, SmartBadSectors(+Growing), SmartUdmaCrcErrors, SmartSelftestFailed, SmartTemperatureHigh, BackupFailed, BackupStale. Токен — в `~/monitoring/alertmanager/telegram_token` (chown 65534, не в git).
- Prometheus: retention 90d, слушает `127.0.0.1:9090`. Job'ы: `node` и `cadvisor` (file_sd, instance'ы **fr1 и ru1**), `openvpn` (file_sd, только ru1 — `85.198.109.86:9176`), `gitea` (static, `gitea:3000` через внешнюю docker-сеть `monitoring_monitoring`), `prometheus` (self). На 2026-09-08 все таргеты up, активных алертов нет.
- 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`.
- **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.
- Планируется: 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/`)
- Перенесён с `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).
- 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.
- `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.
- **Пользователь `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.
## books.ruzzy.dev — читалка с ИИ (с 2026-10-13, `~/books`, код — репо `ruzzy/books`)
- Деплой: из клона `ruzzy/books` (`~/pets/books` на Mac) — `rsync -a --exclude data --exclude .env --exclude .git ./ rus-161:books/ && ssh rus-161 'cd ~/books && docker compose up -d --build'` (подробно — README репо). `~/books` на fr1 — копия, не git-клон.
- 2026-10-13: vhost и сертификат LE `books.ruzzy.dev` (до 2027-01-11, продление — таймер certbot) поставил владелец `sudo sh ~/books/nginx/install.sh` (у `rus` sudo по паролю, root-вход закрыт); DNS — CNAME `books` → `fr1.ruzzy.dev` в Namecheap (добавлен в тот же день; ⚠️ wildcard-записи у зоны нет — новый поддомен = новая CNAME, иначе certbot падает с NXDOMAIN). Проверено: https 200, http→https 301, `/` без входа → `/login`, `X-Robots-Tag: noindex`.
- 2026-10-13 `CLAUDE_CODE_OAUTH_TOKEN` вписан, проверено на «SRE» (592 с.): индексация 75 с, рекап главы ~8 с, перевод выделенного — первые слова ~5 с (целиком 10–17 с), вопрос с WebSearch ~10 с; у агента «нет инструментов». ⚠️ Токен владелец передал через чат с ассистентом — перевыпустить (`claude setup-token`) и вписать напрямую.
- Контейнер `books` (образ `books:local`, compose `~/books/docker-compose.yml`), `127.0.0.1:3010` → nginx vhost `books.ruzzy.dev` (`sites-available/books.ruzzy.dev.conf`, `client_max_body_size 210M`, `proxy_buffering off`, таймаут 900 с — ответы ИИ потоком). Данные — `~/books/data` (uid 1000 = rus): `books.db`, `files/`, `models/`, `claude/`. Лимиты: 4 ГБ RAM, 4 CPU, `cap_drop ALL`, `no-new-privileges`.
- ИИ — Claude Code 2.1.197 внутри контейнера в фоновом режиме по **подписке владельца** (`CLAUDE_CODE_OAUTH_TOKEN` в `~/books/.env`, 600), без инструментов; для проверки фактов — только WebSearch. ⚠️ Лимиты подписки общие с claude-channels и работой владельца.
- Вход — логин и пароль (решение владельца 2026-10-13, вместо Telegram Login): в `.env` `BOOKS_LOGIN` и scrypt-хеш `BOOKS_PASSWORD_HASH` (`scrypt:соль:хеш`; ⚠️ без `$` — compose подставляет переменные в `.env`, с `$` хеш молча портился и пароль «не подходил»). Не больше 5 неудачных попыток за 15 мин с IP (IP — последний в `X-Forwarded-For`, его ставит nginx) и 30 — всего. Секреты `.env`: `BOOKS_PASSWORD_HASH`, `SESSION_SECRET`, `CLAUDE_CODE_OAUTH_TOKEN`.
- 2026-10-13: в образе ещё Go 1.23.4 + goimports + staticcheck (проверка листингов книг; кэш модулей — `~/books/data/go`, качает с proxy.golang.org) и pymupdf4llm. Фоновая подготовка «книжных» глав — Claude по подписке, ~5–10 мин/глава, параллельно ≤2 запроса (`LLM_PARALLEL_BG`), интерактив — отдельно ≤3. Картинки глав — `~/books/data/books/<id>/img/`.
- ⚠️ 2026-10-13: Claude Code по умолчанию «размышляет» (thinking) — перевод шёл до минуты; в контейнере `MAX_THINKING_TOKENS=0` (через код, `LLM_THINKING_TOKENS`). Для интерактива держатся 2 прогретых процесса `claude -p --input-format stream-json` (haiku и sonnet, ~200 МБ каждый) — первые слова ответа за ~0,7 с.
- **gVisor** (с 2026-10-13): `runsc` release-20261005.0 из apt-репозитория gvisor.dev (`/etc/apt/sources.list.d/gvisor.list`), `/etc/docker/daemon.json` — runtime `runsc` с `--host-uds=create` (файла раньше не было). Подключён `systemctl reload docker` без перезапуска контейнеров (поставил владелец `sudo sh ~/books/runner/gvisor-install.sh`). Используется только `books-runner`. Откат — убрать runtime из daemon.json, reload, `apt-get remove runsc`.
- **Песочница `books-runner`** (с 2026-10-13, тот же compose `~/books`): запуск примеров кода из книг. **runtime runsc (gVisor)**, без сети (`network_mode: none`), ФС только для чтения, `cap_drop ALL` + SETUID/SETGID/KILL/CHOWN, 1 CPU / 768 МБ / 128 pid, tmpfs `/work`; код — от nobody, ≤40 процессов, ≤384 МБ (сторож RSS); бинарник собирает контейнер books. ⚠️ Бенчмарки под gVisor в 1,5–2,5 раза медленнее нативных. Связь — unix-сокет `~/books/run/runner.sock`; ⚠️ каталог `~/books/run` — root:rus 0770 (ставился через контейнер — у rus нет sudo без пароля): если сделать 0777, код примеров сможет писать в каталог хоста и стучаться в сокет песочницы.
- ⚠️ Агенту книг сознательно не дан доступ к хосту (в отличие от claude-channels с ключами к fr1/gitea): книги и выделения — чужой текст, возможны внедрённые инструкции.
- 2026-10-18: в `app/prep.py` логи планировщика (`docker compose logs books | grep prep:` — взял слот, пропустил слот и почему, пауза после лимита, глава готова). Повод: в ночь на 18.10 не подготовилась ни одна глава, причину (вероятно, пауза после лимита в памяти процесса) без логов было не установить; контейнер пересоздан.
- 2026-10-18: MCP-токены `bot:…` — для ботов в общих чатах: без `get_notes`, поиск без заметок/стикеров владельца. Выдан `bot:iluha_rabobot` (claude-channels); список — `docker compose exec books python /app/books_mcp.py list`, отзыв — `… revoke bot:iluha_rabobot`.
## 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.
@@ -89,19 +165,158 @@ backend=systemd banaction=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`.
- Проверено: зарубежный 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)
- Перенесён с `de1` 2026-06-22. `~/claude-channels/` (Dockerfile Node22+Bun+Claude Code, compose, `.env` с токеном). Запущен, авторизован (Sonnet 4.6), подключён к Telegram.
- Volume `claude-channels_claude-home` — только нужное: `.claude.json` + `.claude/.credentials.json` (авторизация) + settings + channels (allowlist). Плагины/кэш/история НЕ копировались (на ext4 раздувались до 6.5G из-за CoW на de1).
- Перенесён с `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). Плагины/кэш/история НЕ копировались (на 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) — копировать его нельзя.
- Монтирует `~/MyVault` (`/vault`). ⚠️ Vault-синк в gitea пока НЕ активен (заведём с openclaw) — заметки бота копятся локально.
- **Доступа к Obsidian-vault у бота нет с 2026-10-11** (решение владельца: vault — его личная база, а не рабочая папка агентов; заметки — к Astra на rpi). Убрано: монтирование `/home/rus/MyVault:/vault` из `~/claude-channels/docker-compose.yml` (бэкап `docker-compose.yml.bak-20261011`), разделы «Obsidian Vault»/«Git и Obsidian Vault» в `/root/CLAUDE.md` бота заменены пометкой «доступа нет» (бэкап `/root/CLAUDE.md.bak-20261011`), синк-cron снят, **`~/MyVault` удалён** (227 МБ; перед удалением всё было в gitea, незапушенного не было). История — `ruzzy/MyVault`, архив на 2026-09-23.
- Мост `ask-astra` в контейнере (`/root/agent-bridge/bin/ask-astra`) по умолчанию работал в `/vault` — переключён на `/root/astra-work` (пустой git-репозиторий; `ask-astra` требует git). Бэкапы — `ask-astra.bak-20261011`, `astra-section.md.bak-20261011`.
- Gmail MCP — без OAuth-кредов (gmail-инструменты не работают, на бот не влияет).
- **MCP `books` с 2026-10-18** (книги читалки, только чтение): `claude mcp add --scope user --transport http books https://books.ruzzy.dev/mcp` с Bearer-токеном `bot:iluha_rabobot` (токен — только в `/root/.claude.json` тома и хешем в `books.db`). В `/root/CLAUDE.md` бота дописан раздел «Книги владельца» (без бэкапа: `cp` в `/root` не прошёл — нет прав; раздел — в конце файла, удаляется целиком): ответы по книгам, публикация в Telegram-канал только по команде владельца, без дословных глав. Контейнер перезапущен (ушёл в новую сессию: прежняя была >5 МБ и заархивирована entrypoint-ом). ⚠️ Текст книг — чужие PDF, теперь он попадает к агенту с SSH-ключами к fr1/gitea и `--dangerously-skip-permissions`: защита от внедрённых инструкций — только правило в CLAUDE.md. Канал для публикаций бот подключает сам по команде владельца «подключи канал @имя» (раздел «Подключение канала» в его CLAUDE.md): `getChat`/`getChatMember` по Bot API → id в `groups` в `/root/.claude/channels/telegram/access.json` (без этого `reply` в канал отклоняется) → строка в «Подключённые каналы» в конце CLAUDE.md. Подключён канал **DulyNotes** (приватный, без @username, id `-1004305290483`, бот — админ с правом публикации) 2026-10-18; бэкап access.json до правки — `/tmp/access.json.bak` в контейнере. ⚠️ У приватного канала `getChat` по имени не работает — id узнавали `getUpdates` при приостановленном (`kill -STOP`) плагине, без `offset`, потом вернули `allowed_updates=[]` (параметр `getUpdates` запоминается Telegram и действует на плагин).
- **DulyNotes: разбор книг по расписанию** (с 2026-10-18; с того же дня — **очередь книг**: `books/<slug>.json` — главы, прогресс, `posted`, настройки per_slot/slots/topic/lang; `queue.json` — `{active, queue}`; ставит бот по разговору с владельцем через `pubctl.mjs` (status | add <book_id> [--dry] [--skip] | set | remove | activate); закончилась книга — со следующего слота следующая; неподготовленная читалкой глава — слот пропускается, владельцу уведомление; тексты заметок — `log/<slug>/ch<idx>.html`; прежний `learning-go.json` перенесён, бэкап `learning-go.json.bak-queue`). `/root/publisher/` в томе (создан от root: `/root` в контейнере — root:claude 755, бот сам туда не пишет): `publish.mjs` — в 9:00 и 21:00 по Никосии по 2 главы «Learning Go» (book_id=2, 16 глав → 4 дня) в канал `-1004305290483`, с 2026-10-18 каждая отдельным сообщением как самостоятельная техническая заметка о Go: заголовок по теме, без упоминания книги, автора и глав, связный рассказ с переходом от прошлой заметки (её текст — `log/ch<idx>.html`), 2800–3500 символов; длиннее 3800 — второй проход «сократи», с 2026-10-18 вечера статьи 6000–8500 символов с 5–7 примерами кода (`<pre>` → `<pre><code class="language-go">` — подсветка в Telegram; режется по абзацам, не разрывая код): начало — постом, продолжение — **в комментариях** (группа обсуждений DulyNotesChat `-1004373354142`, бот — админ). Bot API не отдаёт id копии поста в группе, а апдейты держит плагин, поэтому копию ищем перебором: проба-сообщение даёт верхнюю границу id, затем ответ на кандидатов с проверкой `reply_to_message.is_automatic_forward` и `forward_origin.message_id` == id поста, промахи сразу удаляются; копия появляется с задержкой — до 5 попыток с паузой. Не вышло — продолжение ответом в канале. ⚠️ Если в группе удалить копию поста, комментарии под ним пропадают. Ручные режимы: `--fix-comments` (перенести продолжение из канала в комментарии), `--edit-last`, `--repost`; id сообщений — `posted` в `learning-go.json`; `claude -p` запускается из пустого `/tmp/publisher-run`, иначе подхватывает `/root/CLAUDE.md` бота; очередь и сделанные слоты — `learning-go.json`, лог и тексты глав — `log/`. Пост пишет `claude -p` (sonnet, по той же подписке) в **изолированном конфиге** `.claude-config` (только `.credentials.json` — симлинк на основной, пустые settings): без плагинов/хуков/встроенных инструментов, только MCP `books` (`books-mcp.json`, 600, токен `bot:iluha_rabobot`); ⚠️ `MCP_CONNECTION_NONBLOCKING=false` обязателен — иначе MCP «pending» и модель пишет вызов инструмента текстом. ⚠️ Основной конфиг для `claude -p` не годится: поднимется второй telegram-плагин, а он при старте убивает чужой опрос (`server.ts`, «Kill any stale holder»). Отправка — Bot API напрямую; HTML чистится от «<» не по делу, при отказе Telegram — текстом; ошибки — владельцу в личку. Ручные режимы: `--now`, `--preview`.
- **Продолжение в комментарии — поиск копии поста** (исправлено 2026-10-18 вечером, бэкап `/root/publisher/publish.mjs.bak-20261018-comments`): слот 21:00 ушёл ответами в канал — перебор пробами (до 20 ответов-проб в группу на попытку) упёрся в лимит Bot API ~20 сообщений/мин в группу (`429 retry after 22`). Теперь копия берётся из `getChat(группа).pinned_message`: Telegram сам закрепляет каждую автоматическую копию поста в группе обсуждений (проверено: закреплена копия последнего поста) — один запрос, без проб; перебор — запасной (6 сообщений от пробы, либо до 40 вниз от закреплённой копии более позднего поста — для `--fix-comments`), с паузой 1,5 с; `tg()` на 429 ждёт `retry_after` и повторяет. Слот 21:00 перенесён `--fix-comments` (пост 22, глава 5). ⚠️ Код публикатора — только в томе контейнера, в git его нет.
- **Комментарии DulyNotes — ограниченный режим** (с 2026-10-18). Хук `PreToolUse` `/root/.claude/hooks/restricted_guard.js` (в `/root/.claude/settings.json`, бэкап `settings.json.bak-20261018`): если последнее входящее из чата из `/root/.claude/hooks/restricted_chats.json` (`chats`) не от `trusted_users` (607015), разрешены только WebSearch/WebFetch, `mcp__books__*`, reply/react/edit в тот же чат (файлы — только `draw/out` и inbox), Read/Write своей истории, Write `draw/*.txt` и Bash ровно `astra-draw <draw/*.txt>`. Проверено: 24 сценария на поддельных транскриптах + живой `claude -p --dangerously-skip-permissions` (deny соблюдается в bypass). ⚠️ Без транскрипта на диске хук пропускает (у бота он есть всегда; в тестах с `--no-session-persistence` — нет). Группа комментариев DulyNotesChat (`-1004373354142`) подключена 2026-10-18: в `restricted_chats.json` и в `access.json` (`requireMention:false`); бэкап access.json до правки — `/tmp/access.json.bak2` в контейнере. Новые группы подключает бот по команде «подключи комментарии» (раздел в CLAUDE.md; `requireMention:false`, id → `restricted_chats.json`). Privacy mode у бота выключен (`can_read_all_group_messages`), админство в группе не нужно.
- **`/root/agent-bridge/bin/astra-draw`** (с 2026-10-18) — Astra только для картинок: `codex exec --sandbox read-only` в пустом temp-каталоге с выключенными shell_tool, unified_exec, apps, plugins, browser_use*, computer_use, multi_agent, goals и `mcp_servers={}`; `code_mode_host` оставлен — через него идёт image_gen. Проверено: прочитать `/etc/hostname` не может (JS без ФС, apply_patch запрещён режимом), дочерний агент тоже без shell, внедрённая в описание просьба прочитать `~/.ssh` проигнорирована. Остаётся `web__run`. Картинки → `/root/agent-bridge/draw/out/`. Бэкап CLAUDE.md бота до этих правок — `/root/agent-bridge/CLAUDE.md.bak-20261018`.
## Telegram-уведомления
- Бот @ultimate_log_bot. Конфиг `/etc/tg-notify.conf` (chmod 600, токен+chat_id — секрет на сервере).
- `/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`). Молчит, если нечего сообщать.
## Пользователь `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` удалён 2026-10-11 — см. 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` — с 2026-10-18 одно задание: `0 * * * * docker exec claude-channels node /root/publisher/publish.mjs` (разбор книги в DulyNotes; час 9/21 по Никосии проверяет сам скрипт, поэтому смена DST и TZ хоста не сдвигают слоты). Синк MyVault снят 2026-10-11 (см. 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`.
- ⏳ DNS-запись `astra.ruzzy.dev` (остаток отменённой интеграции с Марусей) — удалить у регистратора. Всё остальное на fr1 демонтировано 2026-10-11.
- ⚠️ Описания **других** серверов не сверялись: `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.
- Исправление ложного PushMetricsStale подготовлено в infra 2026-10-11: правило `stacks/monitoring/prometheus/rules/alerts.yml` ограничено job=smart (было smart|backup_.* с порогом 3 ч для всех). backup_servers успешно завершился на rpi в 15:14, итог отправляется раз в сутки после прогона, поэтому алерт в 18:24 был ложным. Свежесть backup_* остаётся под BackupStale/BackupNeverReported с отдельными интервалами. ⚠️ Изменение пока только в репозитории: агенту fr1 требуется проверить правила promtool, установить alerts.yml и reload Prometheus; с rpi административного доступа к fr1 нет. Не перепушивать старые результаты ради исчезновения алерта.
+310
View File
File diff suppressed because one or more lines are too long
+46 -5
View File
@@ -1,7 +1,7 @@
# ru1 — `85.198.109.86` (RU entry node)
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 · РФ
**ОС:** 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**.
- Ключи в `~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)
@@ -25,20 +27,45 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D
| 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` |
| 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/` |
| nginx | 80, 4443 | маскировочный сайт `ru.ruzzy.dev` (Docker `nginx:alpine`) |
| WEB-прокси Telegram | 443 (через nginx) | `tproxy-server` (relay, systemd, `127.0.0.1:8090`, admin `:8091`) → официальный MTProxy (systemd, `2398`, stats `8888`, оба закрыты nftables для не-lo). Подробно — раздел ниже |
| 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)
- `ipset ru_ips` (~11400 подсетей, источник ipdeny.com). До routing decision (mangle PREROUTING):
- dst ∈ ru_ips → RETURN (без метки) → main table → ens3 (RU напрямую)
- иначе → 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`).
- tun1 — без умного роутинга (всегда RU/ens3).
## 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 отключён.
@@ -58,3 +85,17 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D
## ⚠️ Грабли (из README)
- `wg1.conf` обязан иметь `Table = off` — иначе wg-quick при рестарте сносит default route и сервер теряет SSH (восстановление: `ip route add default via 85.198.109.1 dev ens3`).
- Таблица маршрутизации — `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`.
+5
View File
@@ -0,0 +1,5 @@
# books.ruzzy.dev
Код и стек переехали в отдельный репо **`ruzzy/books`** (gitea, приватный; локально — `~/pets/books`) 2026-10-13.
Здесь не дублируем: два источника кода уже однажды разъехались (см. astra-bot).
Состояние на сервере — `servers/fr1.md`, раздел «books.ruzzy.dev».
+1 -1
View File
@@ -2,7 +2,7 @@ name: gitea
services:
gitea:
image: gitea/gitea:1.25.4
image: gitea/gitea:1.27.3
container_name: gitea
restart: unless-stopped
environment:
+40
View File
@@ -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()"]
+68
View File
@@ -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`, иначе разойдётся ревизия браузера.
+112
View File
@@ -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
View File
@@ -1 +0,0 @@
FTP_PASS=changeme
-38
View File
@@ -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:
+17
View File
@@ -33,6 +33,22 @@ services:
- '127.0.0.1:9093:9093'
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:
image: grafana/grafana:latest
container_name: grafana
@@ -93,6 +109,7 @@ services:
networks: [monitoring]
volumes:
pushgateway_data:
prometheus_data:
grafana_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
file_sd_configs:
- 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,166 @@ groups:
labels: {severity: critical}
annotations:
summary: "КРИТИЧНО: диск почти полон на {{ $labels.instance }} ({{ $labels.mountpoint }}): свободно {{ printf \"%.0f\" $value }}%"
# Метрики от rpi приходят через pushgateway (SSH-пуш, см. servers/rpi.md).
# push_time_seconds — когда группа последний раз обновлялась.
# SMART отправляется каждые 15 минут. Итог backup_* — только после прогона;
# свежесть бэкапов проверяется BackupStale/BackupNeverReported по расписанию.
- name: rpi
rules:
- alert: PushMetricsStale
expr: time() - push_time_seconds{job="smart"} > 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_vault"} > 3 * 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`."
- alert: BackupNeverReported
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_vault"})
for: 3h
labels: {severity: warning, instance: rpi, job: backup_vault}
annotations:
summary: "rpi: архив Obsidian-vault в gitea не отчитывался 3+ ч — проверь vault-archive / obsidian-sync"
+38
View File
@@ -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"
+16
View File
@@ -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
+7
View File
@@ -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"
+67
View File
@@ -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"
+31
View File
@@ -0,0 +1,31 @@
#!/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/**" \
--metadata-exclude "malware-detected=true" --onedrive-av-override=false \
--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; }
+25
View File
@@ -0,0 +1,25 @@
#!/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/**" \
--metadata-exclude "malware-detected=true" --onedrive-av-override=false \
--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
+81
View File
@@ -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
+53
View File
@@ -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"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Ежечасный (коммит только при изменениях) ОДНОСТОРОННИЙ архив Obsidian-vault в gitea (ruzzy/MyVault).
# Vault синхронизирует Obsidian Sync (obsidian-sync.service, ~astra/vault); git — только история:
# commit + push, никогда не pull — в vault отсюда ничего не попадает.
# Git-каталог отдельно (~astra/vault.git, core.worktree=~astra/vault), чтобы в vault не было .git.
# Скрытые файлы (.obsidian, данные плагинов, .gitignore) Sync на rpi не приносит — их не трогаем,
# иначе git записал бы их удаление.
set -euo pipefail
cd "$HOME"
G=(git --git-dir="$HOME/vault.git" --work-tree="$HOME/vault")
systemctl is-active -q obsidian-sync || { echo "obsidian-sync не работает — архив устарел бы, пропуск"; exit 1; }
"${G[@]}" add -A -- . ":(exclude).*" ":(exclude)**/.*"
if "${G[@]}" diff --cached --quiet; then
echo "изменений нет"
else
stat=$("${G[@]}" diff --cached --shortstat)
"${G[@]}" commit -q -m "Архив vault $(date "+%F %H:%M")" -m "$stat"
echo "коммит: $stat"
fi
"${G[@]}" push -q origin HEAD:main
echo "OK: $("${G[@]}" log -1 --format='%h %s')"
+1
View File
@@ -0,0 +1 @@
UUID=6eea3421-da3f-4a0f-b66d-26000b9a1b01 /mnt/backup ext4 defaults,noatime,nofail,x-systemd.device-timeout=30s 0 2
+7
View File
@@ -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
+11
View File
@@ -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,15 @@
[Unit]
Description=Obsidian Sync (headless) for vault /home/astra/vault
Wants=network-online.target
After=network-online.target
[Service]
User=astra
Environment=HOME=/home/astra
WorkingDirectory=/home/astra/vault
ExecStart=/usr/bin/ob sync --path /home/astra/vault --continuous
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.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,12 @@
[Unit]
Description=Hourly one-way archive of Obsidian vault to gitea (ruzzy/MyVault)
Wants=network-online.target
After=network-online.target obsidian-sync.service
[Service]
Type=oneshot
User=astra
Environment=HOME=/home/astra
ExecStart=/usr/local/bin/vault-archive
ExecStopPost=+/usr/local/bin/backup-metrics backup_vault
TimeoutStartSec=15min
@@ -0,0 +1,10 @@
[Unit]
Description=Hourly Obsidian vault archive (commit only if changed)
[Timer]
OnCalendar=hourly
RandomizedDelaySec=5m
Persistent=true
[Install]
WantedBy=timers.target
+25
View File
@@ -0,0 +1,25 @@
# Читалка books → Obsidian (rpi)
С 2026-10-18: заметки и рекапы из читалки books.ruzzy.dev (fr1) раз в 10 минут попадают в vault
владельца, папка **`Книги/`** — файл на книгу, главы разделами. Дальше их разносит Obsidian Sync (Mac, iPhone, Boox).
- **Доставка — pull с малины по HTTPS** (fr1 до малины за NAT не достучится): `GET https://books.ruzzy.dev/api/export?v=<версия>`
с Bearer-токеном **`export:obsidian-rpi`**. Токены с префиксом `export:` читалка пускает **только** на `/api/export`
(MCP и остальной API — 401). Ответ без изменений — `{"changed": false}`, данные не передаются.
- Токен — `/etc/books-sync/token` (root:astra 640), в читалке — только хэш (`api_token`). Отозвать:
`docker compose exec books python3 books_mcp.py revoke export:obsidian-rpi` на fr1; выдать новый — `add-token` (stdin).
- `/usr/local/bin/books-sync` (stdlib), `books-sync.timer` (каждые 10 мин после окончания прогона, юзер astra),
состояние `/var/lib/books-sync/state.json` (версия, book_id → файл).
- **Односторонняя:** всё выше `## Мои мысли` перезаписывается из читалки; сам раздел и всё ниже — не трогается
(свои мысли писать туда). Правки заметок — в читалке. Книгу переименовали — файл переименовывается вместе с «Моими мыслями».
- Диалоги с ИИ целиком не выгружаются (владелец 2026-10-18): нужное — кнопкой «В заметки» в окне объяснения, приходит обычной заметкой.
- Обвязка книги (обложка, копирайт, предисловия, благодарности, оглавление, указатель, «об авторе», колофон…) в файлы не попадает (владелец 2026-10-18), приложения — попадают.
- Файлы — только для книг, где есть заметки/рекапы. Длинные цитаты обрезаются до 1500 знаков.
Удалённая из читалки книга — файл остаётся.
```sh
sudo install -m 755 books-sync /usr/local/bin/ && sudo install -d -o astra -g astra -m 750 /var/lib/books-sync
sudo install -m 644 systemd/books-sync.* /etc/systemd/system/ && sudo systemctl daemon-reload
sudo systemctl enable --now books-sync.timer
sudo -u astra books-sync --force # переписать файлы принудительно
```
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""books-sync — заметки и рекапы читалки books.ruzzy.dev → Obsidian-vault владельца (папка «Книги/»).
Раз в 10 мин (books-sync.timer, юзер astra): GET /api/export с токеном «export:obsidian-rpi» (/etc/books-sync/token);
если версия не изменилась — ничего не делаем. Файл на книгу: «Книги/<название>.md», главы — разделами (рекап, заметки).
Диалоги с ИИ целиком не выгружаются (владелец 2026-10-18): нужное сохраняется кнопкой «В заметки» в окне объяснения —
ИИ сжимает разговор в обычную заметку, она и приходит сюда. Всё выше заголовка «## Мои мысли» перезаписывается из читалки, сам раздел и всё ниже — не трогаем.
Синхронизация односторонняя: правки заметок — в читалке. Только stdlib.
books-sync синхронизировать (таймер)
books-sync --force переписать файлы, даже если версия та же
"""
import datetime as dt
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
from zoneinfo import ZoneInfo
URL = os.environ.get("BOOKS_URL", "https://books.ruzzy.dev")
TOKEN = Path(os.environ.get("BOOKS_SYNC_TOKEN", "/etc/books-sync/token"))
STATE = Path(os.environ.get("BOOKS_SYNC_STATE", "/var/lib/books-sync/state.json"))
VAULT = Path(os.environ.get("ASTRA_VAULT", "/home/astra/vault"))
FOLDER = "Книги"
MINE = "## Мои мысли"
QUOTE_MAX = 1500
TZ = ZoneInfo("Asia/Nicosia")
KIND = {"note": "📝 Заметка", "mine": "✎ Стикер", "later": "⏳ Разобрать позже"}
# обвязка книги — в Obsidian не нужна (владелец 2026-10-18): обложка, выходные данные, предисловия, указатель…
FRONT_BACK = re.compile(r"(?i)^\s*(cover|copyright|table of contents|contents|preface|foreword|acknowledg\w*|index|"
r"about the authors?|about this book|colophon|dedication|начало книги|обложка|титул|предисловие|"
r"благодарност\w*|об этой книге|об автор\w*|иллюстрация на обложке|от издательства|оглавление|"
r"содержание|предметный указатель|указатель|выходные данные|посвящение)\b")
def fetch(version):
req = urllib.request.Request(f"{URL}/api/export?v={version}",
headers={"Authorization": "Bearer " + TOKEN.read_text().strip()})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
def safe(name, n=90):
name = re.split(r"\s+--\s+", name or "")[0] # «Название -- Автор -- 2024 -- …» из имени файла
name = re.sub(r'[\\/:*?"<>|#^\[\]\x00-\x1f]', " ", name).strip(" .")
return re.sub(r"\s+", " ", name)[:n].rstrip(" .") or "Без названия"
def quote(text):
text = (text or "").strip()
if len(text) > QUOTE_MAX:
text = text[:QUOTE_MAX].rstrip() + " …"
return "\n".join("> " + l if l.strip() else ">" for l in text.splitlines())
def demote(md):
"""Заголовки внутри рекапа/ответа ИИ — ниже уровня главы, чтобы не ломать структуру файла."""
return re.sub(r"(?m)^(#{1,4}) ", lambda m: "#" * min(6, len(m.group(1)) + 3) + " ", (md or "").strip())
def day(ts):
try:
return dt.datetime.fromisoformat(ts).strftime("%d.%m.%Y")
except (TypeError, ValueError):
return ""
def render(site, b, chapters, notes, recaps, dialogs):
url = f"{site}/book/{b['id']}"
title = re.split(r"\s+--\s+", b["title"])[0]
out = ["---", f"книга: {json.dumps(title, ensure_ascii=False)}"]
if b.get("author"):
out.append(f"автор: {json.dumps(b['author'], ensure_ascii=False)}")
out += [f"читалка: {url}", "источник: books-sync — всё выше «Мои мысли» перезаписывается из читалки",
"tags: [книга]", "---", "", f"# {title}", ""]
out.append(" · ".join(x for x in (b.get("author") or "", f"[Открыть в читалке]({url})") if x))
idxs = sorted({x["chapter_idx"] for x in notes + recaps + dialogs})
for idx in idxs:
ch = chapters.get(idx, {"title": f"Глава {idx}"})
out += ["", f"## {ch['title']}", f"[в читалке]({url}/ch/{idx})"]
r = [x for x in recaps if x["chapter_idx"] == idx]
if r:
out += ["", "### Рекап", "", demote(r[0]["md"])]
ns = [x for x in notes if x["chapter_idx"] == idx]
if ns:
out += ["", "### Заметки"]
for n in ns:
meta = " · ".join(x for x in (KIND.get(n["kind"], n["kind"]), f"с. {n['page']}" if n.get("page") else "",
day(n.get("updated") or n.get("created")),
"✓ разобрано" if n.get("status") == "done" else "") if x)
out += ["", f"**{meta}**"]
if n["kind"] != "note" and n.get("quote"): # у «заметки» цитата уже в тексте (как в читалке)
out.append(quote(n["quote"]))
if (n.get("body") or "").strip():
out += ["", n["body"].strip()]
ds = [x for x in dialogs if x["chapter_idx"] == idx]
if ds:
out += ["", "### Диалоги с ИИ"]
for d in ds:
head = re.sub(r"\s+", " ", d.get("quote") or "").strip()
head = head[:70].rstrip() + ("…" if len(head) > 70 else "")
out += ["", f"#### «{head}» · {day(d.get('created'))}", quote(d.get("quote"))]
for m in d["msgs"]:
who = "**Я:**" if m["role"] == "user" else "**ИИ:**"
out += ["", who, demote(m["text"])]
return "\n".join(out).rstrip() + "\n"
def write_book(path, generated):
"""Сгенерированная часть + раздел «Мои мысли» из прежнего файла (или пустой)."""
mine = f"\n{MINE}\n\n"
if path.exists():
old = path.read_text(encoding="utf-8")
i = old.find("\n" + MINE)
if i >= 0:
mine = old[i:]
text = generated + mine
if path.exists() and path.read_text(encoding="utf-8") == text:
return False
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name("." + path.name + ".tmp")
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, path)
return True
def main():
force = "--force" in sys.argv
state = json.loads(STATE.read_text()) if STATE.exists() else {}
data = fetch("" if force else state.get("version", ""))
if not data.get("changed"):
return
chapters = {}
for ch in data["chapters"]:
chapters.setdefault(ch["book_id"], {})[ch["idx"]] = ch
files = state.get("files", {})
changed = []
for b in data["books"]:
chs = chapters.get(b["id"], {})
sel = lambda xs: [x for x in xs if x["book_id"] == b["id"]
and not FRONT_BACK.match(chs.get(x["chapter_idx"], {}).get("title", ""))]
notes, recaps, dialogs = sel(data["notes"]), sel(data["recaps"]), [] # диалоги — только через «В заметки»
if not (notes or recaps or dialogs) and str(b["id"]) not in files:
continue # уже был файл, а заметок не осталось — перепишем шапкой, «Мои мысли» сохранятся
rel = f"{FOLDER}/{safe(b['title'])}.md"
old = files.get(str(b["id"]))
if old and old != rel and (VAULT / old).exists() and not (VAULT / rel).exists():
os.replace(VAULT / old, VAULT / rel) # книгу переименовали — файл вместе с «Моими мыслями»
if write_book(VAULT / rel, render(data["site"], b, chapters.get(b["id"], {}), notes, recaps, dialogs)):
changed.append(rel)
files[str(b["id"])] = rel
STATE.parent.mkdir(parents=True, exist_ok=True)
STATE.write_text(json.dumps({"version": data["version"], "files": files,
"synced": dt.datetime.now(TZ).isoformat(timespec="seconds")}, ensure_ascii=False))
for rel in changed:
print("обновлено:", rel)
if __name__ == "__main__":
main()
@@ -0,0 +1,11 @@
[Unit]
Description=Читалка books → Obsidian: заметки, рекапы, диалоги в «Книги/»
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=astra
Group=astra
ExecStart=/usr/local/bin/books-sync
TimeoutStartSec=5min
@@ -0,0 +1,9 @@
[Unit]
Description=Читалка books → Obsidian каждые 10 минут
[Timer]
OnBootSec=5min
OnUnitInactiveSec=10min
[Install]
WantedBy=timers.target
+130
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
{}
+99
View File
@@ -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
+23
View File
@@ -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
+18
View File
@@ -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,14 @@
[Unit]
Description=Notify owner when Burt Wonderstone is ready in Jellyfin
After=network-online.target docker.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /usr/local/lib/media-ready/notify-movie-ready.py
StateDirectory=media-ready
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
TimeoutStartSec=120
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Check requested movie readiness every two minutes
[Timer]
OnBootSec=2min
OnUnitInactiveSec=2min
Unit=notify-movie-ready.service
[Install]
WantedBy=timers.target
+435
View File
@@ -0,0 +1,435 @@
#!/usr/bin/env python3
"""Первичная связка медиастека на rpi через API. Идемпотентно: повторный запуск
ничего не ломает, уже сделанное пропускает.
Запуск на малине: python3 /opt/media/provision.py [шаг ...]
Шаги: qbit jellyfin arr langs 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")
# ---------------------------------------------------------------- языки в *arr
def langs():
"""Приоритет дорожек: русская+оригинал (150) > русская (100) > оригинал (50). Ничего не отклоняется,
а cutoffFormatScore=150 + upgradeAllowed — *arr сами перекачают, когда появится раздача с русской дорожкой
(у свежих фильмов дубляж выходит через недели после оригинала)."""
for name, a in ARR.items():
h = {"X-Api-Key": apikey(name)}
u = f"{a['url']}/api/{a['api']}"
st, ll = http("GET", u + "/language", headers=h)
lid = {l["name"]: l["id"] for l in ll}
spec = lambda n, impl, v: {"name": n, "implementation": impl, "negate": False, "required": False,
"fields": [{"name": "value", "value": v}]}
orig = ("Оригинальная дорожка", "Original") if name == "radarr" else ("Английская дорожка", "English")
defs = {
# RuTracker с опцией «добавить RUS»: язык может не распознаться, а тег в названии есть
"Русская дорожка": [spec("Russian", "LanguageSpecification", lid["Russian"]),
spec("RUS в названии", "ReleaseTitleSpecification", r"\b(RUS|Rus|MVO|DVO|Dub|Дубляж)\b")],
orig[0]: [spec(orig[1], "LanguageSpecification", lid[orig[1]])],
}
scores = {"Русская дорожка": 100, orig[0]: 50}
st, cfs = http("GET", u + "/customformat", headers=h)
ids = {c["name"]: c["id"] for c in cfs}
for cname, specs in defs.items():
if cname not in ids:
st, r = http("POST", u + "/customformat",
{"name": cname, "includeCustomFormatWhenRenaming": False, "specifications": specs}, h)
ids[cname] = r["id"]
st, profs = http("GET", u + "/qualityprofile", headers=h)
for p in profs:
items = {fi["format"]: fi for fi in p["formatItems"]}
for cname, sc in scores.items():
if ids[cname] in items:
items[ids[cname]]["score"] = sc
else:
p["formatItems"].append({"format": ids[cname], "name": cname, "score": sc})
p.update({"minFormatScore": 0, "cutoffFormatScore": 150, "upgradeAllowed": True})
if name == "radarr": # было Original — отклоняло раздачи только с дубляжом
p["language"] = {"id": lid["Any"], "name": "Any"}
http("PUT", u + "/qualityprofile/%d" % p["id"], p, h)
log(name, "langs 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, "langs": langs, "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,44 @@
#!/usr/bin/env python3
"""Enable owner Telegram notifications for Chernobyl imports; run as root."""
import json
import urllib.request
def env(path):
return dict(line.strip().split('=', 1) for line in open(path)
if '=' in line and not line.startswith('#'))
key = env('/etc/media.env')['SONARR_KEY']
def api(path, data=None, method=None):
req = urllib.request.Request('http://127.0.0.1:8989/api/v3/' + path,
data=None if data is None else json.dumps(data).encode(),
headers={'X-Api-Key': key, 'Content-Type': 'application/json'},
method=method or ('GET' if data is None else 'POST'))
with urllib.request.urlopen(req, timeout=30) as response:
return json.load(response)
series = next(s for s in api('series') if s['tvdbId'] == 360893)
tags = api('tag')
tag = next((t for t in tags if t['label'] == 'notify-owner-chernobyl'), None)
if tag is None:
tag = api('tag', {'label': 'notify-owner-chernobyl'})
if tag['id'] not in series['tags']:
series['tags'].append(tag['id'])
api('series/' + str(series['id']), series, 'PUT')
name = 'Telegram: Chernobyl owner'
existing = next((n for n in api('notification') if n['name'] == name), None)
notification = existing or next(n for n in api('notification/schema') if n['implementation'] == 'Telegram')
for field in notification['fields']:
values = {'botToken': env('/etc/astra-bot.env')['TG_TOKEN'], 'chatId': '607015',
'sendSilently': False, 'includeAppNameInTitle': True}
if field['name'] in values:
field['value'] = values[field['name']]
for event in list(notification):
if event.startswith('on') and isinstance(notification[event], bool):
notification[event] = False
notification.update(name=name, tags=[tag['id']], onDownload=True)
api('notification/' + str(existing['id']) if existing else 'notification', notification,
'PUT' if existing else 'POST')
check = next(n for n in api('notification') if n['name'] == name)
assert check['onDownload'] and check['tags'] == [tag['id']]
print('Configured: Telegram notifications for Chernobyl episode imports, owner chat 607015.')
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Send one owner notification after Radarr import and Jellyfin indexing."""
import json
from pathlib import Path
import urllib.request
TMDB = 124459
STATE = Path('/var/lib/media-ready/burt-wonderstone.sent')
def env(path):
return dict(line.strip().split('=', 1) for line in open(path)
if '=' in line and not line.startswith('#'))
def api(base, path, headers=None, data=None):
request = urllib.request.Request(base + path, headers=headers or {},
data=None if data is None else json.dumps(data).encode())
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read()
return json.loads(body) if body else None
def main():
if STATE.exists():
return
keys = env('/etc/media.env')
movies = api('http://127.0.0.1:7878', '/api/v3/movie',
{'X-Api-Key': keys['RADARR_KEY']})
movie = next((m for m in movies if m['tmdbId'] == TMDB), None)
if not movie or not movie.get('hasFile'):
print('Waiting for Radarr import')
return
base = 'http://127.0.0.1:8096'
headers = {'Authorization': 'MediaBrowser Token="' + keys['JELLYFIN_KEY'] + '"'}
items = api(base, '/Items?Recursive=true&IncludeItemTypes=Movie&Fields=ProviderIds', headers)
item = next((i for i in items['Items']
if i.get('ProviderIds', {}).get('Tmdb') == str(TMDB)), None)
if not item:
api(base, '/Library/Refresh', headers, {})
print('Waiting for Jellyfin index; refresh requested')
return
server = api(base, '/System/Info/Public')['Id']
link = ('http://192.168.10.25:8096/web/#/details?id='
+ item['Id'] + '&serverId=' + server)
token = env('/etc/astra-bot.env')['TG_TOKEN']
result = api('https://api.telegram.org', '/bot' + token + '/sendMessage',
{'Content-Type': 'application/json'},
{'chat_id': 607015, 'text': '«Невероятный Бёрт Уандерстоун» скачался. '
'Русская озвучка, 1080p. Смотреть дома:\n' + link,
'disable_web_page_preview': True})
if not result.get('ok'):
raise RuntimeError('Telegram did not confirm delivery')
STATE.parent.mkdir(parents=True, exist_ok=True)
STATE.write_text(str(result['result']['message_id']) + '\n')
print('Ready notification delivered')
if __name__ == '__main__':
# urllib exceptions can contain the Telegram token in their URL.
try:
main()
except Exception as error:
print('Movie notification failed: ' + type(error).__name__)
raise SystemExit(1)
+30
View File
@@ -0,0 +1,30 @@
# Домашняя печать с 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`.
Печать по просьбе ребёнка (уточнение владельца 2026-10-13): обычная — PDF в её чат, печать по её
«Печатай», этот же `home-print`. Владелец кнопкой в Telegram разрешает не печать, а работу с конкретным
материалом не по возрасту (`material-approval-request`, до подготовки текста). Код — `ruzzy/astra-bot`
(`material_approvals.py`, `codex/skills/home-print/`); `home-print-request` (PDF владельцу перед печатью,
`print_requests.py`) остался необязательным режимом. ⚠️ astra-bot `d916407` ещё не установлен: на хосте
действует прежнее «вся печать Поли — после кнопки владельца». Подробности — `servers/rpi.md`, «Домашняя печать».
+25
View File
@@ -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"
+10
View 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
+44
View File
@@ -0,0 +1,44 @@
# Samba на rpi — просмотр бэкапа OneDrive с Mac (Finder → smb://rpi.local). Только чтение.
# Доступ: только домашняя сеть, только пользователь rus (пароль Samba — отдельный, smbpasswd -a rus).
[global]
workgroup = WORKGROUP
server string = rpi
server role = standalone server
interfaces = lo eth0
bind interfaces only = yes
hosts allow = 192.168.10.0/24 127.0.0.1
hosts deny = 0.0.0.0/0
server min protocol = SMB2_10
map to guest = never
restrict anonymous = 2
load printers = no
printing = bsd
printcap name = /dev/null
disable spoolss = yes
log file = /var/log/samba/log.%m
max log size = 1000
logging = file
# macOS: Finder, Quick Look, превью, корректные имена с «:» и др.
vfs objects = catia fruit streams_xattr
fruit:metadata = stream
fruit:model = MacSamba
fruit:posix_rename = yes
fruit:veto_appledouble = no
fruit:nfs_aces = no
fruit:wipe_intentionally_left_blank_rfork = yes
fruit:delete_empty_adfiles = yes
multicast dns register = yes
[onedrive]
comment = Зеркало OneDrive (бэкап rpi), только чтение
path = /mnt/backup/onedrive/current
read only = yes
valid users = rus
browseable = yes
[onedrive-archive]
comment = Всё, что удалили или перезаписали в OneDrive (вечный архив), только чтение
path = /mnt/backup/onedrive/archive
read only = yes
valid users = rus
browseable = yes
+75
View File
@@ -0,0 +1,75 @@
# Выгрузка Telegram — второй аккаунт владельца (rpi)
Проект `tg_export` (Telethon, MTProto) перенесён с Mac 2026-10-17: на малине — **второй аккаунт владельца**,
регулярная докачка **только текста** (медиа — позже, если понадобится), поиск (RAG) и утренняя сводка по темам. Старая выгрузка первого аккаунта
(~14 ГБ) осталась на Mac в `~/pets/tg_export/out`, сюда не переносилась; анализ (`stats`, `trips`, `presence`…) — там же.
| Что | Где |
|---|---|
| Код | `/opt/tg-export/` (`export.py`, `fetch_public.py`, `channel_comments.py`), venv `/opt/tg-export/venv` |
| Обёртка | `/usr/local/bin/tg-export` — подставляет `--session` и `--out`, ключи из env |
| Ключи приложения (`TG_API_ID`/`TG_API_HASH`, my.telegram.org) | `/etc/tg-export/env` — root:tgexport 640, в git нет |
| Сессия (= **полный доступ к аккаунту**) | `/var/lib/tg-export/tg.session` — tgexport 600 |
| Выгрузка | `/mnt/backup/tg/` — `dialogs.json`, `chats/<id>_<название>/{messages.jsonl,messages.txt,meta.json,_state.json}`; tgexport:astra, каталоги 2750, файлы 640 |
| Чаты | **все кипрские группы и каналы** (владелец 2026-10-18): `--kinds group supergroup channel --only кипр cyprus @…` — всё с «кипр»/«cyprus» в названии (подстрока, без регистра; новые кипрские группы — сами) плюс явные @username без этих слов (@cylaw, @cymedic, @cyprusinet, @cyschools — заявка ждёт админа, @cyfasttrack, …), Cyprus Car – Chat (`-1001315261949`). Личные переписки и боты — нет. История — **с 2024-10-18**. ⚠️ Выгружаются только чаты, где аккаунт состоит; в группах со скрытой историей — только с даты вступления (CypRus Helpers — с 2026-06-20) |
| Таймер | `tg-export.timer` — **каждый час** ±5 мин (было 04:30 раз в сутки), `tg-export.service` (oneshot, `run --no-media --archived`, затем `tgrag.py index`) |
| Мониторинг | `ExecStopPost=+backup-metrics backup_tg_export` → алерты `BackupFailed` / `BackupStale` (>30 ч) |
Пользователь `tgexport` — системный, без шелла: Astra (группа astra) читает тексты, но **не видит сессию и ключи**.
## Установка и вход
```sh
sudo sh install.sh # код, venv, пользователь, каталоги, юниты
sudo install -o root -g tgexport -m 640 env /etc/tg-export/env # ключи приложения (как .env.example)
ssh -t rpi sudo -u tgexport tg-export login # номер, код из Telegram, 2FA — делает владелец
sudo systemctl enable --now tg-export.timer
```
Вручную: `sudo -u tgexport tg-export list` (диалоги), `sudo -u tgexport tg-export run --no-media --only @чат`.
Повторный запуск догружает только новое (`_state.json` в каждом чате), `--full` — перезалить чат.
⚠️ Первый прогон долгий: Telegram троттлит массовое чтение (FloodWait, скрипт ждёт до 24 ч) — `BackupStale`
может сработать, пока он идёт; следующий запуск таймера не стартует поверх идущего.
⚠️ Медиа выключены (`--no-media`); включить — убрать флаг в юните (лимит по умолчанию 50 МБ на файл).
## Поиск (RAG) и утренняя сводка — `tg` (с 2026-10-18)
`/opt/tg-export/tgrag.py`, обёртка `/usr/local/bin/tg` (запускает astra):
- **Индекс** `/mnt/backup/tg/index.db` (SQLite, журнал DELETE — не WAL, чтобы astra читала без записи в `-shm`): кусок =
подряд идущие сообщения одного чата с паузами < 20 мин, до ~800 знаков, строки «Автор: текст»; FTS5 + эмбеддинги.
Пишет `tgexport` (`ExecStartPost` после каждой докачки), инкрементально по id; последний кусок пересобирается,
если разговор продолжился.
- **Модель** — `minishlab/potion-multilingual-128M` (fastembed, статические эмбеддинги, `/opt/tg-export/models`).
Сравнение на 8 тыс. сообщений групп 2026-10-18: potion 57 с, `paraphrase-multilingual-MiniLM-L12-v2` 915 с при
сопоставимом качестве поиска (MiniLM к тому же видит только ~128 токенов куска). e5-large/jina-v3 на Pi 4 —
дни на историю. Сменить — `TG_EMB_MODEL` + переиндексация (удалить `index.db`).
- **Скорость** (2026-10-18, 85 тыс. кусков): векторы всех кусков — `index.emb.npz` рядом с индексом (пересобирается после
индексации; из SQLite на HDD было ~40 с на запрос); модель держит служба **`tg-embed`** (astra, сокет
`/run/tg-embed/embed.sock` 0600, ~140 МБ RSS) — загрузка модели в процесс ~11 с (fastembed/ONNX; model2vec ещё
медленнее, 28 с — проверено, векторы те же). Итого `tg search` ~6 с. Нет службы — `tg` грузит модель сам.
- `tg search ЗАПРОС [--chat] [--since 90d] [-n]` — гибрид BM25 + косинус (RRF), куски со ссылками t.me
(`t.me/<username>/<id>` или `t.me/c/<id>/<msg>`). ⚠️ Одна формулировка часто промахивается
(«посоветуйте спортзал» не нашло «фитнес в отелях») — Astra по навыку `tg` переформулирует 2–4 раза.
- **Сводка**: темы `tg topic add "Тема" --query "ключевые слова"` → `/var/lib/tg-report/topics.json`;
`tg-report.timer` 08:00 **Asia/Nicosia** (не съедет при переходе Кипра на зимнее время) → `tg report`: по теме —
найденное за 24 ч (словами — всегда, по смыслу — близость ≥ 0.4), сжимает **Codex** (`codex exec`, подписка Astra —
не лимит Claude читалки) → очередь `/var/lib/astra-google/outbox/` → `gcal remind` шлёт владельцу.
⚠️ Очередь шлёт одним `sendMessage` без разбиения: сводка режется на части ≤ 3800 знаков (иначе файл > 4096
застрял бы и заблокировал очередь). Тем нет или индекса нет — ничего не шлёт. `tg report --dry` — посмотреть.
- **Вечерний рекап группы целиком** (с 2026-10-18): `tg digest add @чат` (список — `/var/lib/tg-report/digests.json`),
`tg-digest.timer` 23:50 Asia/Nicosia → `tg digest run`: все куски чата **с прошлого рекапа** (не календарный день —
сообщения после последней ежечасной докачки уйдут в следующий рекап, без дыр), до 60 тыс. знаков (больше — последние),
Codex пишет темы дня и «Полезное» со ссылками → очередь outbox. Сейчас: CY Fast Track Talks (@cyfasttrack).
`tg digest run --dry --hours 24` — посмотреть.
- **Участники группы с аватарками** — `app/tg_members.py` (`/opt/tg-export/tg_members.py @чат`, от tgexport, ⚠️ не параллельно
с `tg-export.service` — одна сессия): `/mnt/backup/tg/members/<id>_<название>/members.{json,csv}` + `avatars/<id>.jpg`
(маленькие). Персональные данные участников — в поиск/индекс не идут. 2026-10-18: CY Fast Track Talks — 2774 из 2774,
аватарок 1619. Разовая выгрузка, не по таймеру.
- **Личные каналы участников** — `app/tg_bios.py`: bio и «личный канал» каждого участника (GetFullUser, ~1 запрос/с —
2765 профилей за ~55 мин без ограничений) → `bios.json`. 2026-10-18 CY Fast Track Talks: личный канал у 145 из 2765,
топ — `top-personal-channels.txt` в папке участников. ⚠️ Второй шаг скрипта (размер каждой ссылки через
`get_entity` = ResolveUsername) после ~190 запросов получил **FloodWait 77 545 с (~22 ч)** — остановлен; размеры
публичных каналов взяты со страниц `t.me/<username>` (без сессии). Массово резолвить username через API нельзя —
брать `t.me`-страницы или `full.chats` из GetFullUser.
+3
View File
@@ -0,0 +1,3 @@
# https://my.telegram.org → API development tools → создать приложение
TG_API_ID=1234567
TG_API_HASH=0123456789abcdef0123456789abcdef
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Выгрузка канала вместе с комментариями из привязанной группы обсуждений.
Обычный `export.py run` берёт только посты канала: комментарии физически
лежат в отдельном linked chat и через iter_dialogs не приходят. Здесь для
каждого поста дополнительно вытягивается его ветка (iter_messages(reply_to=...)),
плюс, при --with-linked, вся история группы обсуждений целиком — там бывают
сообщения, не привязанные ни к одному посту.
uv run channel_comments.py @cpython_notes
uv run channel_comments.py @cpython_notes --with-linked --session copy.session
Формат вывода совпадает с export.py: сырой to_dict() в *.jsonl, плоское
представление в *_pretty.jsonl, читаемая расшифровка в *.txt.
"""
from __future__ import annotations
import argparse
import asyncio
import datetime as dt
import json
import sys
from pathlib import Path
from telethon import TelegramClient, errors, functions
import export as ex
ROOT = Path(__file__).resolve().parent
async def dump_stream(client, entity, it, jsonl, pretty, txt, cache, label, tag=None):
"""Прогоняет итератор сообщений в три файла. Возвращает счётчик."""
n = 0
async for msg in it:
if ex.STOP:
break
record = msg.to_dict()
if tag:
record.update(tag)
sender = await ex.sender_label(client, msg, cache)
jsonl.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
if pretty:
flat = ex.simplify_message(msg, sender, record)
if tag:
flat.update(tag)
pretty.write(json.dumps(flat, ensure_ascii=False, cls=ex.Encoder) + "\n")
if txt:
txt.write(ex.render_line(msg, sender))
n += 1
if n % 500 == 0:
print(f" … {label}: {n}", flush=True)
return n
async def main_async(args) -> None:
api_id, api_hash = ex.require_credentials()
client = TelegramClient(args.session, api_id, api_hash)
client.flood_sleep_threshold = 24 * 60 * 60
await client.start()
try:
chan = await client.get_entity(args.channel)
full = await client(functions.channels.GetFullChannelRequest(channel=chan))
linked_id = getattr(full.full_chat, "linked_chat_id", None)
linked = None
if linked_id:
linked = next((c for c in full.chats if c.id == linked_id), None)
dialog_id = int(f"-100{chan.id}")
folder = Path(args.out) / "chats" / f"{dialog_id}_{ex.slugify(getattr(chan, 'title', args.channel))}"
folder.mkdir(parents=True, exist_ok=True)
header = {
"id": dialog_id,
"name": getattr(chan, "title", None),
"kind": ex.dialog_kind(chan),
"username": getattr(chan, "username", None),
"participants": getattr(full.full_chat, "participants_count", None),
"linked_chat_id": linked_id,
"linked_chat_title": getattr(linked, "title", None) if linked else None,
"exported_at": dt.datetime.now(dt.timezone.utc).isoformat(),
}
(folder / "meta.json").write_text(
json.dumps({**header, "entity": chan.to_dict()},
ensure_ascii=False, indent=2, cls=ex.Encoder),
encoding="utf-8")
print(f"Канал: {header['name']} (@{header['username']}) · "
f"подписчиков {header['participants']}")
print(f"Группа обсуждений: {header['linked_chat_title']} (id={linked_id})\n"
if linked_id else "Группа обсуждений не привязана — комментариев нет\n")
cache: dict = {}
mode = "w" if args.full else "a"
# ---- посты канала ----
pj = (folder / "messages.jsonl").open(mode, encoding="utf-8")
pp = (folder / "_pretty.jsonl").open(mode, encoding="utf-8")
pt = (folder / "messages.txt").open(mode, encoding="utf-8")
posts = []
async for m in client.iter_messages(chan, reverse=True):
posts.append(m)
for m in posts:
record = m.to_dict()
sender = await ex.sender_label(client, m, cache)
pj.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
pp.write(json.dumps(ex.simplify_message(m, sender, record),
ensure_ascii=False, cls=ex.Encoder) + "\n")
pt.write(ex.render_line(m, sender))
for f in (pj, pp, pt):
f.close()
print(f"постов: {len(posts)}")
if not linked_id:
return
# ---- комментарии: по ветке на каждый пост ----
cj = (folder / "comments.jsonl").open(mode, encoding="utf-8")
cp = (folder / "comments_pretty.jsonl").open(mode, encoding="utf-8")
thread = (folder / "threads.txt").open(mode, encoding="utf-8")
total_c = 0
with_c = 0
for i, post in enumerate(posts, 1):
if ex.STOP:
break
try:
it = client.iter_messages(chan, reply_to=post.id, reverse=True)
n = 0
head_written = False
async for cm in it:
if not head_written:
head = (post.message or "").split("\n")[0][:90]
thread.write(f"\n{'='*78}\nПОСТ #{post.id} "
f"[{post.date:%Y-%m-%d %H:%M}] {head}\n{'-'*78}\n")
head_written = True
record = cm.to_dict()
record["_post_id"] = post.id
sender = await ex.sender_label(client, cm, cache)
cj.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
flat = ex.simplify_message(cm, sender, record)
flat["_post_id"] = post.id
cp.write(json.dumps(flat, ensure_ascii=False, cls=ex.Encoder) + "\n")
thread.write(" " + ex.render_line(cm, sender).replace("\n", "\n ").rstrip() + "\n")
n += 1
except (errors.MsgIdInvalidError, errors.ChannelPrivateError) as exc:
# у поста отключены комментарии либо ветка недоступна
print(f" пост #{post.id}: {type(exc).__name__}", file=sys.stderr)
continue
except errors.FloodWaitError as exc:
print(f" FloodWait {exc.seconds}s на посте #{post.id} — жду",
file=sys.stderr)
await asyncio.sleep(exc.seconds + 1)
continue
total_c += n
if n:
with_c += 1
print(f" [{i}/{len(posts)}] пост #{post.id}: {n} комментариев", flush=True)
for f in (cj, cp, thread):
f.close()
print(f"\nкомментариев: {total_c} в {with_c} ветках "
f"(из {len(posts)} постов)")
# ---- вся группа обсуждений целиком ----
if args.with_linked:
lj = (folder / "linked_chat.jsonl").open(mode, encoding="utf-8")
lp = (folder / "linked_chat_pretty.jsonl").open(mode, encoding="utf-8")
lt = (folder / "linked_chat.txt").open(mode, encoding="utf-8")
n = await dump_stream(
client, linked, client.iter_messages(linked, reverse=True),
lj, lp, lt, cache, "linked")
for f in (lj, lp, lt):
f.close()
print(f"вся группа обсуждений: {n} сообщений")
print(f"\nКаталог: {folder}")
finally:
await client.disconnect()
def main() -> None:
ex.load_dotenv(ROOT / ".env")
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("channel", help="@username, id или часть названия канала")
p.add_argument("--session", default=str(ROOT / "tg_export.session"))
p.add_argument("--out", default=str(ROOT / "out"))
p.add_argument("--with-linked", action="store_true",
help="дополнительно выгрузить всю группу обсуждений целиком")
p.add_argument("--full", action="store_true", help="перезаписать файлы с нуля")
args = p.parse_args()
ex.install_signal_handlers()
try:
asyncio.run(main_async(args))
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()
+901
View File
@@ -0,0 +1,901 @@
#!/usr/bin/env python3
"""Полная выгрузка истории личного Telegram-аккаунта через Telethon.
Возможности:
* логин по номеру телефона (MTProto, свой api_id/api_hash);
* индекс всех диалогов (личные, боты, группы, каналы);
* выгрузка всех сообщений в JSONL (сырой `to_dict()` — ничего не теряется)
плюс опциональный человекочитаемый .txt;
* скачивание медиа с лимитом на размер;
* возобновление: повторный запуск догружает только новое;
* аккуратная обработка FloodWait и приватных/недоступных чатов.
Команды:
python export.py login — только авторизация (создаёт .session)
python export.py list — вывести список диалогов
python export.py run — выгрузить историю
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import datetime as dt
import json
import os
import re
import signal
import sys
import unicodedata
from pathlib import Path
from telethon import TelegramClient, errors, utils
from telethon.tl import types
# --------------------------------------------------------------------------- #
# конфиг
# --------------------------------------------------------------------------- #
ROOT = Path(__file__).resolve().parent
def load_dotenv(path: Path) -> None:
"""Минимальный .env-лоадер, чтобы не тащить python-dotenv."""
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key, val = key.strip(), val.strip().strip("'\"")
os.environ.setdefault(key, val)
def require_credentials() -> tuple[int, str]:
api_id = os.environ.get("TG_API_ID")
api_hash = os.environ.get("TG_API_HASH")
if not api_id or not api_hash:
sys.exit(
"Нет TG_API_ID / TG_API_HASH.\n"
"Получи их на https://my.telegram.org → API development tools,\n"
f"затем скопируй {ROOT / '.env.example'} в {ROOT / '.env'} и заполни."
)
try:
return int(api_id), api_hash
except ValueError:
sys.exit(f"TG_API_ID должен быть числом, получено: {api_id!r}")
# --------------------------------------------------------------------------- #
# утилиты
# --------------------------------------------------------------------------- #
class Encoder(json.JSONEncoder):
"""Сериализует всё, что встречается в Telethon-объектах."""
def default(self, o): # noqa: D102
if isinstance(o, (dt.datetime, dt.date, dt.time)):
return o.isoformat()
if isinstance(o, dt.timedelta):
return o.total_seconds()
if isinstance(o, (bytes, bytearray, memoryview)):
return base64.b64encode(bytes(o)).decode("ascii")
if isinstance(o, set):
return sorted(o, key=str)
return str(o)
_SLUG_BAD = re.compile(r"[^\w\-. ]+", re.UNICODE)
def slugify(name: str, limit: int = 48) -> str:
name = unicodedata.normalize("NFKC", name or "").strip()
name = _SLUG_BAD.sub("", name)
name = re.sub(r"\s+", "_", name).strip("._-")
return (name[:limit] or "unnamed")
def dialog_kind(entity) -> str:
if isinstance(entity, types.User):
return "bot" if getattr(entity, "bot", False) else "user"
if isinstance(entity, types.Chat) or isinstance(entity, types.ChatForbidden):
return "group"
if isinstance(entity, types.Channel):
return "supergroup" if getattr(entity, "megagroup", False) else "channel"
return "unknown"
def human_size(n: int | None) -> str:
if not n:
return "0B"
for unit in ("B", "K", "M", "G", "T"):
if n < 1024 or unit == "T":
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}T"
def parse_date(value: str | None) -> dt.datetime | None:
if not value:
return None
try:
parsed = dt.datetime.fromisoformat(value)
except ValueError:
sys.exit(f"Не смог разобрать дату {value!r}. Формат: YYYY-MM-DD или YYYY-MM-DDTHH:MM")
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt.timezone.utc)
return parsed
STOP = False
def install_signal_handlers() -> None:
def handler(signum, frame): # noqa: ARG001
global STOP
if STOP:
sys.exit(130)
STOP = True
print("\n[!] Останавливаюсь после текущего сообщения; прогресс сохранён. Ctrl-C ещё раз — выход.",
file=sys.stderr)
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGTERM, handler)
# --------------------------------------------------------------------------- #
# состояние (для возобновления)
# --------------------------------------------------------------------------- #
class State:
def __init__(self, path: Path):
self.path = path
self.data = {"last_id": 0, "count": 0, "media": 0, "updated_at": None}
if path.exists():
try:
self.data.update(json.loads(path.read_text(encoding="utf-8")))
except (ValueError, OSError):
pass
@property
def last_id(self) -> int:
return int(self.data.get("last_id") or 0)
def bump(self, msg_id: int, media: int = 0) -> None:
self.data["last_id"] = max(self.last_id, int(msg_id))
self.data["count"] = int(self.data.get("count") or 0) + 1
self.data["media"] = int(self.data.get("media") or 0) + media
def save(self) -> None:
self.data["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.path)
# --------------------------------------------------------------------------- #
# текстовое представление
# --------------------------------------------------------------------------- #
def render_line(msg, sender_name: str) -> str:
when = msg.date.astimezone().strftime("%Y-%m-%d %H:%M:%S") if msg.date else "?"
body = (msg.message or "").replace("\n", "\n ")
tags = []
if msg.media is not None and not isinstance(msg.media, types.MessageMediaWebPage):
tags.append(type(msg.media).__name__.replace("MessageMedia", "").lower())
if getattr(msg, "reply_to_msg_id", None):
tags.append(f"reply→{msg.reply_to_msg_id}")
if getattr(msg, "fwd_from", None):
tags.append("forwarded")
if getattr(msg, "action", None):
tags.append(type(msg.action).__name__.replace("MessageAction", "action:"))
suffix = f" [{', '.join(tags)}]" if tags else ""
return f"[{when}] #{msg.id} {sender_name}:{suffix}\n {body}\n" if body else \
f"[{when}] #{msg.id} {sender_name}:{suffix}\n"
async def sender_label(client, msg, cache: dict) -> str:
sid = msg.sender_id
if sid is None:
return "—"
if sid in cache:
return cache[sid]
label = str(sid)
try:
sender = await msg.get_sender()
if sender is not None:
if isinstance(sender, types.User):
parts = [sender.first_name or "", sender.last_name or ""]
label = " ".join(p for p in parts if p) or (sender.username or str(sid))
else:
label = getattr(sender, "title", None) or str(sid)
except (errors.RPCError, ValueError, TypeError):
pass
cache[sid] = label
return label
# --------------------------------------------------------------------------- #
# читаемое представление (messages.json)
# --------------------------------------------------------------------------- #
def media_info(msg, record: dict) -> dict | None:
media = getattr(msg, "media", None)
if media is None:
return None
if isinstance(media, types.MessageMediaWebPage):
url = getattr(getattr(media, "webpage", None), "url", None)
return {"type": "link", "url": url} if url else None
info: dict = {"type": type(media).__name__.replace("MessageMedia", "").lower()}
handle = getattr(msg, "file", None)
for key in ("name", "mime_type", "size", "duration"):
val = getattr(handle, key, None)
if val:
info[key] = round(val, 1) if key == "duration" else val
if info.get("size"):
info["size_human"] = human_size(info["size"])
for attr in getattr(getattr(media, "document", None), "attributes", None) or []:
if isinstance(attr, types.DocumentAttributeSticker):
info["type"] = "sticker"
info["emoji"] = attr.alt
elif isinstance(attr, types.DocumentAttributeAudio) and attr.voice:
info["type"] = "voice"
if isinstance(media, types.MessageMediaPoll):
poll = media.poll
info["question"] = getattr(poll.question, "text", poll.question)
info["answers"] = [getattr(a.text, "text", a.text) for a in poll.answers]
elif isinstance(media, (types.MessageMediaGeo, types.MessageMediaGeoLive)):
geo = media.geo
info["lat"], info["long"] = getattr(geo, "lat", None), getattr(geo, "long", None)
elif isinstance(media, types.MessageMediaContact):
info["contact"] = " ".join(
p for p in (media.first_name, media.last_name, media.phone_number) if p
)
for key in ("_media_file", "_media_skipped", "_media_error"):
if key in record:
info[key.lstrip("_").removeprefix("media_")] = record[key]
return info
def forward_info(msg) -> dict | None:
fwd = getattr(msg, "fwd_from", None)
if fwd is None:
return None
info: dict = {}
if fwd.from_name:
info["from_name"] = fwd.from_name
if fwd.from_id is not None:
try:
info["from_id"] = utils.get_peer_id(fwd.from_id)
except (TypeError, ValueError):
info["from_id"] = str(fwd.from_id)
if fwd.date:
info["original_date"] = fwd.date.astimezone().isoformat()
if fwd.post_author:
info["post_author"] = fwd.post_author
return info or None
def reactions_info(msg) -> list | None:
results = getattr(getattr(msg, "reactions", None), "results", None) or []
out = []
for res in results:
emoji = getattr(res.reaction, "emoticon", None) \
or getattr(res.reaction, "document_id", None)
out.append({"reaction": str(emoji), "count": res.count})
return out or None
def simplify_message(msg, sender: str, record: dict) -> dict:
"""Плоское, читаемое представление сообщения для messages.json."""
out: dict = {
"id": msg.id,
"date": msg.date.astimezone().isoformat() if msg.date else None,
"from": sender,
"from_id": msg.sender_id,
"outgoing": bool(getattr(msg, "out", False)),
"text": getattr(msg, "message", "") or "",
}
if getattr(msg, "reply_to_msg_id", None):
out["reply_to"] = msg.reply_to_msg_id
if getattr(msg, "edit_date", None):
out["edited"] = msg.edit_date.astimezone().isoformat()
if fwd := forward_info(msg):
out["forwarded"] = fwd
if media := media_info(msg, record):
out["media"] = media
if reactions := reactions_info(msg):
out["reactions"] = reactions
action = getattr(msg, "action", None)
if action is not None:
out["service_action"] = type(action).__name__.replace("MessageAction", "")
if duration := getattr(action, "duration", None):
out["service_action_duration_sec"] = duration
if getattr(msg, "views", None):
out["views"] = msg.views
if getattr(msg, "via_bot_id", None):
out["via_bot_id"] = msg.via_bot_id
if getattr(msg, "pinned", False):
out["pinned"] = True
if getattr(msg, "grouped_id", None):
out["album_id"] = str(msg.grouped_id)
return out
# медиа без файла: качать нечего, для них download_media вернёт None.
# MessageMediaContact сюда НЕ входит — Telethon сохраняет контакт как .vcard.
NO_FILE_MEDIA = frozenset({
"MessageMediaWebPage", "MessageMediaGeo", "MessageMediaGeoLive",
"MessageMediaPoll", "MessageMediaDice", "MessageMediaVenue",
"MessageMediaGame", "MessageMediaUnsupported", "MessageMediaStory",
"MessageMediaInvoice", "MessageMediaGiveaway",
"MessageMediaGiveawayResults", "MessageMediaEmpty",
})
def raw_media_tags(media: dict) -> set[str]:
"""Метки медиа из сырого to_dict(): тип, mime, расширение, вид документа.
Позволяет отсеивать по `--skip-media webp` / `sticker` / `video` ещё до
запроса к API — по данным, которые уже лежат в messages.jsonl.
"""
if not media:
return set()
kind = media.get("_", "")
tags = {kind.replace("MessageMedia", "").lower()}
if kind == "MessageMediaPhoto":
tags |= {"photo", "jpg", "jpeg", "image/jpeg"}
doc = media.get("document") or {}
mime = (doc.get("mime_type") or "").lower()
if mime:
tags.add(mime)
tags.add(mime.rsplit("/", 1)[-1])
for attr in doc.get("attributes") or []:
name = attr.get("_", "")
if name == "DocumentAttributeSticker":
tags.add("sticker")
elif name == "DocumentAttributeAudio":
tags.add("voice" if attr.get("voice") else "audio")
elif name == "DocumentAttributeVideo":
tags.add("round" if attr.get("round_message") else "video")
elif name == "DocumentAttributeAnimated":
tags.add("gif")
elif name == "DocumentAttributeFilename":
filename = attr.get("file_name") or ""
if "." in filename:
tags.add(filename.rsplit(".", 1)[-1].lower())
return tags
def has_downloadable_media(msg) -> bool:
media = getattr(msg, "media", None)
return media is not None and type(media).__name__ not in NO_FILE_MEDIA
def load_media_index(folder: Path) -> dict[str, dict]:
path = folder / "media_index.json"
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return {}
def assemble_pretty(folder: Path, header: dict) -> int:
"""Собирает messages.json из накопленного _pretty.jsonl (устойчиво к докачке)."""
src = folder / "_pretty.jsonl"
if not src.exists():
return 0
by_id: dict[int, dict] = {}
with src.open(encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
by_id[rec["id"]] = rec
# подмешиваем результаты догрузки медиа, если она уже прошла
for msg_id, entry in load_media_index(folder).items():
rec = by_id.get(int(msg_id))
if rec is None or "media" not in rec:
continue
# не ссылаемся на файлы, которых уже нет на диске (удалили вручную и т.п.)
if "file" in entry and not (folder / entry["file"]).exists():
continue
rec["media"].update({k: v for k, v in entry.items() if v is not None})
messages = [by_id[key] for key in sorted(by_id)]
payload = {
"chat": header,
"messages_count": len(messages),
"first_message_date": messages[0]["date"] if messages else None,
"last_message_date": messages[-1]["date"] if messages else None,
"messages": messages,
}
(folder / "messages.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
return len(messages)
# --------------------------------------------------------------------------- #
# выгрузка одного диалога
# --------------------------------------------------------------------------- #
async def export_dialog(client, dialog, out_root: Path, args) -> dict:
entity = dialog.entity
kind = dialog_kind(entity)
folder = out_root / "chats" / f"{dialog.id}_{slugify(dialog.name)}"
media_dir = folder / "media"
folder.mkdir(parents=True, exist_ok=True)
state = State(folder / "_state.json")
start_from = 0 if args.full else state.last_id
header = {
"id": dialog.id,
"name": dialog.name,
"kind": kind,
"username": getattr(entity, "username", None),
"phone": getattr(entity, "phone", None),
"is_archived": bool(getattr(dialog, "archived", False)),
"exported_at": dt.datetime.now(dt.timezone.utc).isoformat(),
}
(folder / "meta.json").write_text(
json.dumps({**header, "entity": entity.to_dict()},
ensure_ascii=False, indent=2, cls=Encoder),
encoding="utf-8",
)
excluded_types = {t.lower().lstrip(".") for t in args.skip_media}
mode = "w" if args.full else "a"
jsonl = (folder / "messages.jsonl").open(mode, encoding="utf-8")
txt = (folder / "messages.txt").open(mode, encoding="utf-8") if args.txt else None
pretty = (folder / "_pretty.jsonl").open(mode, encoding="utf-8") if args.pretty else None
cache: dict = {}
new_msgs = 0
new_media = 0
media_bytes = 0
errors_count = 0
try:
async for msg in client.iter_messages(
entity,
reverse=True, # от старых к новым — так работает докачка
offset_id=start_from,
offset_date=args._since,
limit=args.limit,
wait_time=args.wait or None,
):
if STOP:
break
if args._until and msg.date and msg.date > args._until:
break
record = msg.to_dict()
if args.media and has_downloadable_media(msg):
size = getattr(getattr(msg, "file", None), "size", None) or 0
hit = raw_media_tags(record.get("media") or {}) & excluded_types
if hit:
record["_media_skipped"] = f"type_excluded:{','.join(sorted(hit))}"
elif args.max_media_mb and size > args.max_media_mb * 1024 * 1024:
record["_media_skipped"] = f"too_large:{human_size(size)}"
else:
media_dir.mkdir(parents=True, exist_ok=True)
try:
path = await client.download_media(msg, file=str(media_dir / f"{msg.id}"))
if path:
record["_media_file"] = str(Path(path).relative_to(folder))
new_media += 1
media_bytes += size or Path(path).stat().st_size
except errors.FloodWaitError as exc:
print(f" FloodWait {exc.seconds}s на медиа #{msg.id} — жду", file=sys.stderr)
await asyncio.sleep(exc.seconds + 1)
except (errors.RPCError, OSError, ValueError) as exc:
record["_media_error"] = f"{type(exc).__name__}: {exc}"
errors_count += 1
jsonl.write(json.dumps(record, ensure_ascii=False, cls=Encoder) + "\n")
label = await sender_label(client, msg, cache) if (txt or pretty) else ""
if txt is not None:
txt.write(render_line(msg, label))
if pretty is not None:
pretty.write(json.dumps(simplify_message(msg, label, record),
ensure_ascii=False, cls=Encoder) + "\n")
state.bump(msg.id, media=1 if "_media_file" in record else 0)
new_msgs += 1
if new_msgs % 500 == 0:
jsonl.flush()
for stream in (txt, pretty):
if stream is not None:
stream.flush()
state.save()
print(f" … {new_msgs} новых (всего {state.data['count']})", flush=True)
finally:
jsonl.close()
for stream in (txt, pretty):
if stream is not None:
stream.close()
state.save()
if args.pretty:
assemble_pretty(folder, header)
return {
"id": dialog.id,
"name": dialog.name,
"kind": kind,
"new_messages": new_msgs,
"total_messages": state.data["count"],
"new_media": new_media,
"media_bytes": media_bytes,
"media_errors": errors_count,
"folder": str(folder.relative_to(out_root)),
}
# --------------------------------------------------------------------------- #
# отбор диалогов
# --------------------------------------------------------------------------- #
def dialog_matches(dialog, args) -> bool:
kind = dialog_kind(dialog.entity)
if args.kinds and kind not in args.kinds:
return False
if not args.archived and getattr(dialog, "archived", False):
return False
if args.skip:
for needle in args.skip:
if _needle_hits(dialog, needle):
return False
if args.only:
return any(_needle_hits(dialog, needle) for needle in args.only)
return True
def _needle_hits(dialog, needle: str) -> bool:
needle = needle.strip()
if not needle:
return False
if needle.lstrip("-").isdigit() and int(needle) == dialog.id:
return True
username = getattr(dialog.entity, "username", None) or ""
if needle.lstrip("@").lower() == username.lower() and username:
return True
return needle.lower() in (dialog.name or "").lower()
# --------------------------------------------------------------------------- #
# команды
# --------------------------------------------------------------------------- #
async def cmd_login(client, args) -> None: # noqa: ARG001
me = await client.get_me()
print(f"OK: {me.first_name or ''} {me.last_name or ''} "
f"(@{me.username or '—'}, id={me.id}). Сессия: {args.session}")
async def cmd_list(client, args) -> None:
rows = []
async for dialog in client.iter_dialogs():
if not dialog_matches(dialog, args):
continue
rows.append((dialog.id, dialog_kind(dialog.entity), dialog.name or "",
getattr(dialog.entity, "username", None) or "",
getattr(dialog, "unread_count", 0)))
width = max((len(r[2]) for r in rows), default=10)
print(f"{'ID':>15} {'ТИП':<10} {'НАЗВАНИЕ':<{min(width, 50)}} USERNAME")
for did, kind, name, username, _ in rows:
print(f"{did:>15} {kind:<10} {name[:50]:<{min(width, 50)}} "
f"{'@' + username if username else ''}")
print(f"\nвсего: {len(rows)}")
async def cmd_media(client, args) -> None:
"""Догружает медиа к уже выгруженным сообщениям.
Идёт не по API-истории, а по локальному messages.jsonl, поэтому:
* не зависит от прогресса `run` и не мешает ему;
* инкрементальна — уже скачанное фиксируется в media_index.json;
* может гоняться повторно, пока `run` дописывает историю.
"""
out_root = Path(args.out).expanduser().resolve()
folders = sorted(p for p in (out_root / "chats").glob("*") if p.is_dir())
if not folders:
sys.exit(f"В {out_root / 'chats'} нет выгруженных чатов — сначала запусти `run`.")
limit_bytes = int(args.max_media_mb * 1024 * 1024) if args.max_media_mb else 0
grand_new = grand_bytes = grand_skip = grand_err = 0
for folder in folders:
try:
header = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
except (ValueError, OSError):
print(f"[!] {folder.name}: нет читаемого meta.json — пропуск", file=sys.stderr)
continue
needles = args.only or []
if needles and not any(
n.lstrip("@").lower() in ((header.get("username") or "").lower(),
str(header.get("id")))
or n.lower() in (header.get("name") or "").lower()
for n in needles
):
continue
raw = folder / "messages.jsonl"
if not raw.exists():
continue
index = load_media_index(folder)
excluded = {t.lower().lstrip(".") for t in args.skip_media}
todo: list[int] = []
by_type_excluded = 0
with raw.open(encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue # хвост может быть недописан, если `run` ещё идёт
media = rec.get("media")
if not media or media.get("_") in NO_FILE_MEDIA:
index.pop(str(rec["id"]), None) # чистим ложные nothing_to_download
continue
if excluded and (raw_media_tags(media) & excluded):
index.pop(str(rec["id"]), None) # забываем то, что решили не хранить
by_type_excluded += 1
continue
if rec.get("_media_file"):
continue
# готово только то, у чего реально есть файл: записи skipped/error
# переспрашиваются — иначе поднятый --max-media-mb ничего не даст
if "file" in index.get(str(rec["id"]), {}):
continue
todo.append(rec["id"])
print(f"\n{folder.name}: к догрузке {len(todo)} медиа "
f"(уже есть {len(index)}"
+ (f", исключено по типу {by_type_excluded}" if by_type_excluded else "")
+ ")", flush=True)
if not todo:
continue
entity = await client.get_entity(int(header["id"]))
media_dir = folder / "media"
media_dir.mkdir(parents=True, exist_ok=True)
sem = asyncio.Semaphore(max(1, args.jobs))
done = new = skipped = failed = 0
got_bytes = 0
async def fetch(msg) -> None:
nonlocal done, new, skipped, failed, got_bytes
async with sem:
if msg is None or STOP:
return
size = getattr(getattr(msg, "file", None), "size", None) or 0
if limit_bytes and size > limit_bytes:
index[str(msg.id)] = {"skipped": f"too_large:{human_size(size)}"}
skipped += 1
else:
try:
path = await client.download_media(
msg, file=str(media_dir / f"{msg.id}"))
if path:
index[str(msg.id)] = {
"file": str(Path(path).relative_to(folder))}
new += 1
got_bytes += size or Path(path).stat().st_size
else:
index[str(msg.id)] = {"error": "nothing_to_download"}
failed += 1
except errors.FloodWaitError as exc:
print(f" FloodWait {exc.seconds}s — жду", file=sys.stderr, flush=True)
await asyncio.sleep(exc.seconds + 1)
return
except (errors.RPCError, OSError, ValueError) as exc:
index[str(msg.id)] = {"error": f"{type(exc).__name__}: {exc}"}
failed += 1
done += 1
if done % 200 == 0:
(folder / "media_index.json").write_text(
json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8")
print(f" … {done}/{len(todo)} +{new} файлов "
f"({human_size(got_bytes)}), крупных пропущено {skipped}, "
f"ошибок {failed}", flush=True)
for start in range(0, len(todo), 100):
if STOP:
break
chunk = todo[start:start + 100]
try:
msgs = await client.get_messages(entity, ids=chunk)
except errors.FloodWaitError as exc:
print(f" FloodWait {exc.seconds}s на выборке — жду", file=sys.stderr, flush=True)
await asyncio.sleep(exc.seconds + 1)
msgs = await client.get_messages(entity, ids=chunk)
await asyncio.gather(*(fetch(m) for m in msgs))
(folder / "media_index.json").write_text(
json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8")
if args.pretty:
assemble_pretty(folder, header)
print(f" итого: +{new} файлов ({human_size(got_bytes)}), "
f"крупных пропущено {skipped}, ошибок {failed}", flush=True)
grand_new += new
grand_bytes += got_bytes
grand_skip += skipped
grand_err += failed
print(f"\nМедиа{' (прервано)' if STOP else ''}: +{grand_new} файлов "
f"({human_size(grand_bytes)}), пропущено по размеру {grand_skip}, "
f"ошибок {grand_err}.")
async def cmd_run(client, args) -> None:
out_root = Path(args.out).expanduser().resolve()
(out_root / "chats").mkdir(parents=True, exist_ok=True)
dialogs = []
async for dialog in client.iter_dialogs():
if dialog_matches(dialog, args):
dialogs.append(dialog)
me = await client.get_me()
print(f"Аккаунт: @{me.username or me.id} · диалогов к выгрузке: {len(dialogs)} · вывод: {out_root}\n")
index, summary = [], []
for n, dialog in enumerate(dialogs, 1):
if STOP:
break
kind = dialog_kind(dialog.entity)
print(f"[{n}/{len(dialogs)}] {kind:<10} {dialog.name} (id={dialog.id})", flush=True)
try:
result = await export_dialog(client, dialog, out_root, args)
except errors.FloodWaitError as exc:
print(f" FloodWait {exc.seconds}s — жду и повторяю", file=sys.stderr)
await asyncio.sleep(exc.seconds + 1)
result = await export_dialog(client, dialog, out_root, args)
except (errors.ChannelPrivateError, errors.ChatAdminRequiredError,
errors.UserBannedInChannelError) as exc:
print(f" пропуск: {type(exc).__name__}", file=sys.stderr)
result = {"id": dialog.id, "name": dialog.name, "kind": kind,
"error": type(exc).__name__}
except errors.RPCError as exc:
print(f" ошибка: {type(exc).__name__}: {exc}", file=sys.stderr)
result = {"id": dialog.id, "name": dialog.name, "kind": kind,
"error": f"{type(exc).__name__}: {exc}"}
index.append(result)
summary.append(result)
if "error" not in result:
print(f" +{result['new_messages']} сообщений, +{result['new_media']} медиа "
f"({human_size(result['media_bytes'])}), всего {result['total_messages']}",
flush=True)
(out_root / "dialogs.json").write_text(
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
total_msgs = sum(r.get("new_messages", 0) for r in summary)
total_media = sum(r.get("new_media", 0) for r in summary)
total_bytes = sum(r.get("media_bytes", 0) for r in summary)
failed = [r for r in summary if "error" in r]
print(f"\nГотово{' (прервано)' if STOP else ''}: "
f"{len(summary)} диалогов, +{total_msgs} сообщений, "
f"+{total_media} медиа ({human_size(total_bytes)}).")
if failed:
print(f"Недоступны ({len(failed)}): " +
", ".join(f"{r['name']} [{r['error']}]" for r in failed[:10]) +
(" …" if len(failed) > 10 else ""))
print(f"Каталог: {out_root}")
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
KINDS = ("user", "bot", "group", "supergroup", "channel")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Выгрузка истории личного Telegram через Telethon",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("command", choices=("login", "list", "run", "media"))
p.add_argument("--session", default=str(ROOT / "tg_export.session"),
help="файл сессии Telethon")
p.add_argument("--out", default=str(ROOT / "out"), help="каталог для выгрузки")
sel = p.add_argument_group("отбор диалогов")
sel.add_argument("--kinds", nargs="*", choices=KINDS, default=list(KINDS),
help="какие типы чатов выгружать")
sel.add_argument("--only", nargs="*", default=[], metavar="NEEDLE",
help="только эти чаты: id, @username или часть названия")
sel.add_argument("--skip", nargs="*", default=[], metavar="NEEDLE",
help="исключить чаты (id / @username / часть названия)")
sel.add_argument("--archived", action="store_true", help="включить архив")
rng = p.add_argument_group("границы истории")
rng.add_argument("--since", metavar="YYYY-MM-DD", help="только сообщения не раньше даты")
rng.add_argument("--until", metavar="YYYY-MM-DD", help="только сообщения не позже даты")
rng.add_argument("--limit", type=int, default=None,
help="максимум сообщений на чат (для пробного прогона)")
rng.add_argument("--full", action="store_true",
help="игнорировать сохранённый прогресс и перезаписать всё с нуля")
med = p.add_argument_group("медиа и вывод")
med.add_argument("--media", action=argparse.BooleanOptionalAction, default=True,
help="скачивать медиафайлы")
med.add_argument("--max-media-mb", type=float, default=50,
help="не качать файлы крупнее (0 — без лимита)")
med.add_argument("--skip-media", nargs="*", default=[], metavar="TAG",
help="не качать медиа с такими метками: расширение (webp, mp4), "
"mime (image/webp) или вид (sticker, video, voice, round, gif)")
med.add_argument("--pretty", action=argparse.BooleanOptionalAction, default=True,
help="писать читаемый messages.json (плоские поля, отступы)")
med.add_argument("--txt", action=argparse.BooleanOptionalAction, default=True,
help="дополнительно писать читаемый messages.txt")
med.add_argument("--wait", type=float, default=0.0,
help="пауза между запросами истории, сек (мягче к лимитам)")
med.add_argument("--jobs", type=int, default=4,
help="параллельных загрузок в команде media")
return p
async def main_async(args) -> None:
api_id, api_hash = require_credentials()
client = TelegramClient(args.session, api_id, api_hash)
client.flood_sleep_threshold = 24 * 60 * 60 # сам ждёт длинные FloodWait
await client.start(
phone=lambda: input("Номер телефона (в формате +7...): ").strip(),
password=lambda: input("Пароль двухфакторной аутентификации: ").strip(),
)
try:
handler = {"login": cmd_login, "list": cmd_list,
"run": cmd_run, "media": cmd_media}[args.command]
await handler(client, args)
finally:
await client.disconnect()
def main() -> None:
load_dotenv(ROOT / ".env")
args = build_parser().parse_args()
args._since = parse_date(args.since)
args._until = parse_date(args.until)
if args.max_media_mb <= 0:
args.max_media_mb = 0
install_signal_handlers()
try:
asyncio.run(main_async(args))
except (errors.PhoneNumberInvalidError, errors.SessionPasswordNeededError) as exc:
sys.exit(f"Ошибка авторизации: {type(exc).__name__}: {exc}")
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Выгрузка публичной группы/канала, в которой мы не состоим.
`export.py run` ходит по iter_dialogs и потому видит только свои чаты.
Здесь сущность резолвится по @username и история читается напрямую.
uv run fetch_public.py @cyschools --since 2026-03-04
"""
from __future__ import annotations
import argparse
import asyncio
import datetime as dt
import json
from pathlib import Path
from telethon import TelegramClient
import export as ex
ex.load_dotenv(ex.ROOT / ".env")
async def main_async(args) -> None:
api_id, api_hash = ex.require_credentials()
client = TelegramClient(args.session, api_id, api_hash)
client.flood_sleep_threshold = 24 * 60 * 60
await client.start()
try:
entity = await client.get_entity(args.chat)
dialog_id = int(f"-100{entity.id}")
folder = Path(args.out) / "chats" / f"{dialog_id}_{ex.slugify(getattr(entity, 'title', args.chat))}"
folder.mkdir(parents=True, exist_ok=True)
since = dt.datetime.fromisoformat(args.since).replace(tzinfo=dt.timezone.utc)
(folder / "meta.json").write_text(json.dumps({
"id": dialog_id,
"name": getattr(entity, "title", None),
"kind": ex.dialog_kind(entity),
"username": getattr(entity, "username", None),
"fetched_since": args.since,
}, ensure_ascii=False, indent=2), encoding="utf-8")
cache: dict = {}
n = 0
with (folder / "messages.jsonl").open("w", encoding="utf-8") as jsonl, \
(folder / "messages_pretty.jsonl").open("w", encoding="utf-8") as pretty:
async for msg in client.iter_messages(entity, offset_date=since, reverse=True):
record = msg.to_dict()
sender = await ex.sender_label(client, msg, cache)
jsonl.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
pretty.write(json.dumps(ex.simplify_message(msg, sender, record),
ensure_ascii=False, cls=ex.Encoder) + "\n")
n += 1
if n % 1000 == 0:
print(f" … {n} ({msg.date:%Y-%m-%d})", flush=True)
print(f"{folder.name}: {n} сообщений")
finally:
await client.disconnect()
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("chat")
p.add_argument("--since", required=True)
p.add_argument("--out", default=str(ex.ROOT / "out"))
p.add_argument("--session", default=str(ex.ROOT / "tg_export.session"))
asyncio.run(main_async(p.parse_args()))
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "tg-export"
version = "0.1.0"
requires-python = ">=3.13"
description = "Полная выгрузка истории личного Telegram через Telethon"
dependencies = [
"cryptg>=0.6.0",
"telethon>=1.44.0",
]
[tool.uv]
package = false
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Ссылки на группы/каналы в профилях участников (владелец 2026-10-18): по members.json группы — bio и «личный канал»
каждого участника (GetFullUser, ~1 запрос/с), затем размер каждой найденной группы/канала → топ по участникам.
sudo -u tgexport sh -c '. /etc/tg-export/env; /opt/tg-export/venv/bin/python /opt/tg-export/tg_bios.py <папка members>'
Результат в той же папке: bios.json (id → bio, личный канал; докачка с места остановки), links.json (группа/канал →
кто ссылается, размер), top.txt. ⚠️ Не параллельно с tg-export.service (одна сессия).
"""
import asyncio
import json
import os
import re
import sys
from pathlib import Path
from telethon import TelegramClient, errors
from telethon.tl.functions.channels import GetFullChannelRequest
from telethon.tl.functions.users import GetFullUserRequest
LINK = re.compile(r"(?:https?://)?(?:t\.me|telegram\.me)/(?:s/)?([A-Za-z][\w]{3,31})(?![\w/])|(?<![\w@])@([A-Za-z][\w]{3,31})")
SKIP = {"joinchat", "addstickers", "share", "proxy", "socks", "iv", "c", "addlist", "boost"}
async def main(folder):
folder = Path(folder)
members = json.loads((folder / "members.json").read_text())["members"]
bios_f = folder / "bios.json"
bios = json.loads(bios_f.read_text()) if bios_f.exists() else {}
c = TelegramClient("/var/lib/tg-export/tg.session", int(os.environ["TG_API_ID"]), os.environ["TG_API_HASH"])
c.flood_sleep_threshold = 0 # FloodWait ловим сами, с логом
await c.start()
try:
todo = [m for m in members if not m["bot"] and not m["deleted"] and str(m["id"]) not in bios]
print(f"профилей: {len(members)}, уже есть {len(bios)}, осталось {len(todo)}", flush=True)
for i, m in enumerate(todo, 1):
while True:
try:
full = await c(GetFullUserRequest(m["id"]))
break
except errors.FloodWaitError as e:
print(f" FloodWait {e.seconds} с на {i}/{len(todo)}", flush=True)
await asyncio.sleep(e.seconds + 2)
except Exception as e:
full = None
print(f" {m['id']}: {type(e).__name__}", flush=True)
break
rec = {"username": m["username"], "bio": "", "channel": None}
if full:
rec["bio"] = full.full_user.about or ""
pc = getattr(full.full_user, "personal_channel_id", None)
if pc:
ch = next((x for x in full.chats if x.id == pc), None)
rec["channel"] = {"id": pc, "username": getattr(ch, "username", None), "title": getattr(ch, "title", None)}
bios[str(m["id"])] = rec
if i % 50 == 0:
bios_f.write_text(json.dumps(bios, ensure_ascii=False))
print(f" {i}/{len(todo)}", flush=True)
await asyncio.sleep(1.0)
bios_f.write_text(json.dumps(bios, ensure_ascii=False))
# ссылки: username → кто ссылается
refs = {}
for uid, r in bios.items():
found = set()
for a, b in LINK.findall(r["bio"] or ""):
u = (a or b).lower()
if u not in SKIP:
found.add(u)
if r["channel"] and r["channel"].get("username"):
found.add(r["channel"]["username"].lower())
for u in found:
refs.setdefault(u, set()).add(r["username"] or uid)
print(f"уникальных ссылок: {len(refs)}", flush=True)
links = {}
for i, (u, who) in enumerate(sorted(refs.items()), 1):
info = {"refs": sorted(who), "kind": None, "title": None, "members": None}
while True:
try:
e = await c.get_entity(u)
if getattr(e, "broadcast", None) is not None or getattr(e, "megagroup", None) is not None:
full = await c(GetFullChannelRequest(e))
info.update(kind="channel" if e.broadcast else "group", title=e.title,
members=full.full_chat.participants_count)
else:
info["kind"] = "user" if hasattr(e, "first_name") else type(e).__name__
break
except errors.FloodWaitError as ex:
print(f" FloodWait {ex.seconds} с на ссылке {i}", flush=True)
await asyncio.sleep(ex.seconds + 2)
except Exception as ex:
info["kind"] = f"error:{type(ex).__name__}"
break
links[u] = info
await asyncio.sleep(1.5)
(folder / "links.json").write_text(json.dumps(links, ensure_ascii=False, indent=1))
top = sorted(((u, x) for u, x in links.items() if x["members"]), key=lambda t: -t[1]["members"])
lines = [f"{n}. {x['title']} (@{u}) — {x['kind']}, {x['members']} участников; в профилях: {', '.join('@' + w if not w.isdigit() else w for w in x['refs'][:5])}"
for n, (u, x) in enumerate(top[:30], 1)]
(folder / "top.txt").write_text("\n".join(lines) + "\n")
print("\n".join(lines[:10]))
finally:
await c.disconnect()
asyncio.run(main(sys.argv[1]))
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Участники группы с аватарками (владелец 2026-10-18): /mnt/backup/tg/members/<id>_<название>/
members.json и members.csv (id, username, имя, бот/премиум, статус, есть ли фото), avatars/<id>.jpg (маленькие).
sudo -u tgexport sh -c '. /etc/tg-export/env; /opt/tg-export/venv/bin/python /opt/tg-export/tg_members.py @cyfasttrack'
⚠️ Не запускать одновременно с tg-export.service (одна сессия). Если админы скрыли список участников — Telegram
отдаст только админов. Это персональные данные участников: в поиск/индекс не идут.
"""
import asyncio
import csv
import json
import os
import sys
from pathlib import Path
from telethon import TelegramClient, errors
import export as ex
OUT = Path(os.environ.get("TG_OUT", "/mnt/backup/tg")) / "members"
def status_of(u):
s = getattr(u, "status", None)
if s is None:
return ""
name = type(s).__name__.replace("UserStatus", "")
when = getattr(s, "was_online", None)
return f"{name} {when.isoformat()}" if when else name
async def main(chat):
client = TelegramClient("/var/lib/tg-export/tg.session", int(os.environ["TG_API_ID"]), os.environ["TG_API_HASH"])
client.flood_sleep_threshold = 3600
await client.start()
try:
ent = await client.get_entity(chat)
folder = OUT / f"-100{ent.id}_{ex.slugify(getattr(ent, 'title', chat))}"
av = folder / "avatars"
av.mkdir(parents=True, exist_ok=True)
people = await client.get_participants(ent, aggressive=True)
rows = []
for i, u in enumerate(people, 1):
photo = ""
if getattr(u, "photo", None):
dest = av / f"{u.id}.jpg"
if not dest.exists():
try:
await client.download_profile_photo(u, file=str(dest), download_big=False)
except errors.FloodWaitError as e:
await asyncio.sleep(e.seconds + 1)
except Exception as e: # фото недоступно — пропускаем
print(f" {u.id}: фото не скачалось ({type(e).__name__})", flush=True)
photo = dest.name if dest.exists() else ""
p = getattr(u, "participant", None)
rows.append({"id": u.id, "username": u.username or "", "first_name": u.first_name or "",
"last_name": u.last_name or "", "bot": bool(u.bot), "premium": bool(getattr(u, "premium", False)),
"deleted": bool(u.deleted), "status": status_of(u),
"role": type(p).__name__.replace("ChannelParticipant", "") if p else "",
"joined": p.date.isoformat() if getattr(p, "date", None) else "", "avatar": photo})
if i % 100 == 0:
print(f" {i}/{len(people)}", flush=True)
(folder / "members.json").write_text(json.dumps({"chat": getattr(ent, "title", chat), "id": f"-100{ent.id}",
"count": len(rows), "members": rows}, ensure_ascii=False, indent=1))
with (folder / "members.csv").open("w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["id"])
w.writeheader()
w.writerows(rows)
total = getattr(await client.get_participants(ent, limit=0), "total", None)
print(f"{folder.name}: участников получено {len(rows)} (в группе всего {total}), аватарок {sum(1 for r in rows if r['avatar'])}")
finally:
await client.disconnect()
asyncio.run(main(sys.argv[1]))
+567
View File
@@ -0,0 +1,567 @@
#!/usr/bin/env python3
"""tg — поиск по выгрузке Telegram (группы второго аккаунта владельца) и ежедневная сводка по темам.
tg search ЗАПРОС [--chat ЧАСТЬ_НАЗВАНИЯ] [--since 30d|2026-01-01] [-n 12]
гибридный поиск (слова + смысл) по кускам переписки, со ссылками t.me на сообщения
tg topic add "ТЕМА" [--query "что искать"] | list | del НОМЕР
темы ежедневной сводки (query — поисковая формулировка, по умолчанию — сама тема)
tg report [--hours 24] [--dry] сводка по темам за период → владельцу в Telegram (--dry — только напечатать)
tg digest add @ЧАТ | list | del @ЧАТ чаты для ежедневного рекапа целиком (всё за день, а не по темам)
tg digest run [--hours N] [--dry] рекап этих чатов с прошлого рекапа → владельцу в Telegram (таймер 23:50)
tg stats сколько чатов/сообщений/кусков, когда обновлялось
tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки
tg serve (служба tg-embed, юзер astra) — держать модель в памяти, векторы запросов по unix-сокету
Индекс — /mnt/backup/tg/index.db (SQLite: FTS5 + эмбеддинги potion-multilingual-128M), пишет tgexport,
читают все из группы astra. Кусок = подряд идущие сообщения одного чата с паузами < 20 мин (до ~800 знаков).
Темы и состояние сводки — /var/lib/tg-report/ (astra). Сводку пишет Codex (подписка Astra), не Claude.
"""
import argparse
import datetime as dt
import json
import os
import re
import sqlite3
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from zoneinfo import ZoneInfo
OUT = Path(os.environ.get("TG_OUT", "/mnt/backup/tg"))
DB = Path(os.environ.get("TG_INDEX", OUT / "index.db"))
MODELS = os.environ.get("TG_MODELS", "/opt/tg-export/models")
# potion — статические эмбеддинги (model2vec): на Pi 4 в ~16 раз быстрее MiniLM при близком качестве поиска
# (сравнение 2026-10-18 на 8 тыс. сообщений: 57 с против 915 с), без ограничения длины текста
EMB_MODEL = os.environ.get("TG_EMB_MODEL", "minishlab/potion-multilingual-128M")
REPORT = Path(os.environ.get("TG_REPORT_DIR", "/var/lib/tg-report"))
OUTBOX = Path(os.environ.get("ASTRA_OUTBOX", "/var/lib/astra-google/outbox"))
TZ = ZoneInfo("Asia/Nicosia")
GAP = 20 * 60 # пауза, после которой начинается новый кусок
CHUNK_CHARS = int(os.environ.get("TG_CHUNK_CHARS", "800"))
TG_MAX = 3800 # одно сообщение Telegram (лимит 4096) — сводку режем на части
# векторы всех кусков одним файлом рядом с индексом: чтение 85 тыс. BLOB из SQLite на HDD — ~40 с на запрос,
# а np.load(mmap) — доли секунды; пересобирается после каждой индексации
CACHE = DB.with_suffix(".emb.npz")
_emb = None
SOCK = os.environ.get("TG_EMBED_SOCK", "/run/tg-embed/embed.sock")
def _embed_remote(texts):
"""Векторы от службы tg-embed (модель уже в памяти) — иначе None. Загрузка модели в процесс — ~11 с на Pi 4."""
import socket
import numpy as np
if not os.path.exists(SOCK):
return None
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(30)
s.connect(SOCK)
s.sendall(json.dumps({"texts": texts}).encode() + b"\n")
buf = b""
while not buf.endswith(b"\n"):
part = s.recv(1 << 20)
if not part:
break
buf += part
r = json.loads(buf)
return np.frombuffer(bytes.fromhex(r["vecs"]), dtype=np.float32).reshape(r["n"], r["dim"])
except (OSError, ValueError, KeyError):
return None
def embed(texts, remote=True):
global _emb
import numpy as np
if remote and len(texts) <= 64:
v = _embed_remote(list(texts))
if v is not None:
return v
if _emb is None:
from fastembed import TextEmbedding
_emb = TextEmbedding(EMB_MODEL, cache_dir=MODELS, threads=4)
v = np.array(list(_emb.embed(texts, batch_size=32)), dtype=np.float32)
v /= np.linalg.norm(v, axis=1, keepdims=True) + 1e-9
return v
def db(write=False):
if write:
DB.parent.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(DB, timeout=120)
# rollback-журнал, не WAL: читателям (astra) не нужна запись в -shm/-wal
c.execute("PRAGMA journal_mode=DELETE")
c.executescript("""
CREATE TABLE IF NOT EXISTS chat(id INTEGER PRIMARY KEY, title TEXT, username TEXT, kind TEXT, folder TEXT, last_id INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS chunk(id INTEGER PRIMARY KEY, chat_id INTEGER, first_id INTEGER, last_id INTEGER,
ts0 INTEGER, ts1 INTEGER, n INTEGER, text TEXT, emb BLOB);
CREATE INDEX IF NOT EXISTS chunk_chat ON chunk(chat_id, last_id);
CREATE INDEX IF NOT EXISTS chunk_ts ON chunk(ts1);
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(text, content='chunk', content_rowid='id',
tokenize='unicode61 remove_diacritics 2');
CREATE TRIGGER IF NOT EXISTS chunk_ai AFTER INSERT ON chunk BEGIN
INSERT INTO chunk_fts(rowid, text) VALUES (new.id, new.text); END;
CREATE TRIGGER IF NOT EXISTS chunk_ad AFTER DELETE ON chunk BEGIN
INSERT INTO chunk_fts(chunk_fts, rowid, text) VALUES ('delete', old.id, old.text); END;
CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT);
""")
else:
if not DB.exists():
raise SystemExit("индекса ещё нет — выгрузка Telegram не запускалась (tg-export)")
c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True, timeout=120)
c.row_factory = sqlite3.Row
return c
# ---------------------------------------------------------------- индекс
def _ts(s):
return int(dt.datetime.fromisoformat(s).timestamp())
def _line(m):
text = (m.get("text") or "").strip()
if not text:
return None
who = m.get("from") or "?"
return f"{who}: {re.sub(r'\s+', ' ', text)}"
def _windows(msgs):
"""Сообщения (по возрастанию id) → куски разговора."""
cur, out = [], []
for m in msgs:
line = _line(m)
if not line:
continue
ts = _ts(m["date"])
if cur and (ts - cur[-1][1] > GAP or sum(len(x[2]) for x in cur) + len(line) > CHUNK_CHARS):
out.append(cur)
cur = []
cur.append((m["id"], ts, line))
if cur:
out.append(cur)
return out
def cmd_index(a):
c = db(write=True)
chats = sorted(p for p in (OUT / "chats").glob("*") if (p / "_pretty.jsonl").exists())
total = 0
for folder in chats:
meta = json.loads((folder / "meta.json").read_text()) if (folder / "meta.json").exists() else {}
cid = int(meta.get("id") or folder.name.split("_")[0])
row = c.execute("SELECT last_id FROM chat WHERE id=?", (cid,)).fetchone()
last = row["last_id"] if row else 0
c.execute("INSERT INTO chat(id, title, username, kind, folder, last_id) VALUES (?,?,?,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, username=excluded.username, kind=excluded.kind, "
"folder=excluded.folder", (cid, meta.get("name") or folder.name, meta.get("username"), meta.get("kind"),
folder.name, last))
# последний кусок мог быть недописан (разговор продолжился) — пересобираем его вместе с новыми сообщениями
tail = c.execute("SELECT id, first_id, ts1 FROM chunk WHERE chat_id=? ORDER BY last_id DESC LIMIT 1", (cid,)).fetchone()
msgs = []
with (folder / "_pretty.jsonl").open(encoding="utf-8") as f:
floor = tail["first_id"] - 1 if tail else last
for raw in f:
mid = re.match(r'\{"id": (\d+)', raw) # дёшево отсеять уже проиндексированное без разбора JSON
if not mid or int(mid.group(1)) <= floor:
continue
m = json.loads(raw)
if m.get("date"):
msgs.append(m)
msgs.sort(key=lambda m: m["id"])
new = [m for m in msgs if m["id"] > last]
if not new:
continue
if tail and _ts(new[0]["date"]) - tail["ts1"] <= GAP:
c.execute("DELETE FROM chunk WHERE id=?", (tail["id"],))
else:
msgs = new
wins = _windows(msgs)
title = meta.get("name") or folder.name
for i in range(0, len(wins), 256):
part = wins[i:i + 256]
texts = [f"Чат: {title}\n" + "\n".join(x[2] for x in w) for w in part]
vecs = embed(texts)
c.executemany("INSERT INTO chunk(chat_id, first_id, last_id, ts0, ts1, n, text, emb) VALUES (?,?,?,?,?,?,?,?)",
[(cid, w[0][0], w[-1][0], w[0][1], w[-1][1], len(w), "\n".join(x[2] for x in w), v.tobytes())
for w, v in zip(part, vecs)])
c.commit()
if not a.quiet:
print(f" {title}: {min(i + 256, len(wins))}/{len(wins)} кусков", flush=True)
c.execute("UPDATE chat SET last_id=? WHERE id=?", (max(m["id"] for m in new), cid))
c.commit()
total += len(new)
c.execute("INSERT OR REPLACE INTO meta VALUES ('indexed', ?)", (dt.datetime.now(TZ).isoformat(timespec="seconds"),))
c.commit()
if total or not CACHE.exists():
build_cache(c)
print(f"проиндексировано новых сообщений: {total}")
def build_cache(c):
import numpy as np
rows = c.execute("SELECT id, chat_id, ts1, emb FROM chunk ORDER BY id").fetchall()
if not rows:
return
tmp = CACHE.with_name(CACHE.name + ".tmp.npz")
np.savez(tmp, ids=np.array([r["id"] for r in rows], dtype=np.int64),
chat=np.array([r["chat_id"] for r in rows], dtype=np.int64),
ts=np.array([r["ts1"] for r in rows], dtype=np.int64),
emb=np.frombuffer(b"".join(r["emb"] for r in rows), dtype=np.float32).reshape(len(rows), -1))
os.replace(tmp, CACHE)
# ---------------------------------------------------------------- поиск
def link(chat, msg_id):
if chat["username"]:
return f"https://t.me/{chat['username']}/{msg_id}"
cid = str(chat["id"])
return f"https://t.me/c/{cid[4:] if cid.startswith('-100') else cid.lstrip('-')}/{msg_id}"
def fts_query(q):
words = [re.sub(r"[^\w]", "", w) for w in q.split()]
out = []
for w in words:
if len(w) < 3:
continue
stem = re.sub(r"(ами|ями|ого|его|ому|ему|ой|ей|ий|ый|ая|яя|ое|ее|ые|ие|ам|ям|ах|ях|ом|ем|ов|ев|а|я|ы|и|у|ю|е|о)$", "", w) \
if re.fullmatch(r"[а-яё]{5,}", w.lower()) else w
out.append(f'"{stem}"*')
return " OR ".join(out)
def parse_since(s):
if not s:
return None
m = re.fullmatch(r"(\d+)\s*(d|д|w|н|m|м)", s.strip().lower())
if m:
days = int(m.group(1)) * {"d": 1, "д": 1, "w": 7, "н": 7, "m": 30, "м": 30}[m.group(2)]
return int(time.time()) - days * 86400
return int(dt.datetime.combine(dt.date.fromisoformat(s), dt.time(0), TZ).timestamp())
def search(q, chat=None, since=None, n=12, min_sim=None):
"""Гибрид: BM25 (FTS5) + косинус эмбеддингов, слияние по рангам (RRF). → [(row, sim)]"""
import numpy as np
c = db()
where, args = ["1=1"], []
if chat:
where.append("c.chat_id IN (SELECT id FROM chat WHERE title LIKE ? OR username LIKE ?)")
args += [f"%{chat}%", f"%{chat}%"]
if since:
where.append("c.ts1 >= ?")
args.append(since)
w = " AND ".join(where)
ranks, sims, by_words = {}, {}, set()
fq = fts_query(q)
if fq:
for r, row in enumerate(c.execute(f"SELECT c.id FROM chunk_fts f JOIN chunk c ON c.id=f.rowid WHERE chunk_fts MATCH ? "
f"AND {w} ORDER BY bm25(chunk_fts) LIMIT 60", [fq] + args)):
ranks[row["id"]] = ranks.get(row["id"], 0) + 1 / (60 + r)
by_words.add(row["id"])
qv = embed([q])[0]
if CACHE.exists():
z = np.load(CACHE)
ids, emb, mask = z["ids"], z["emb"], np.ones(len(z["ids"]), dtype=bool)
if since:
mask &= z["ts"] >= since
if chat:
cids = [r[0] for r in c.execute("SELECT id FROM chat WHERE title LIKE ? OR username LIKE ?", (f"%{chat}%", f"%{chat}%"))]
mask &= np.isin(z["chat"], cids)
ids, emb = ids[mask], emb[mask]
else: # кэша нет (старый индекс) — медленно, из базы
rows = c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE {w}", args).fetchall()
ids = np.array([r["id"] for r in rows], dtype=np.int64)
emb = np.frombuffer(b"".join(r["emb"] for r in rows), dtype=np.float32).reshape(len(rows), -1) if rows else None
if len(ids):
s = emb @ qv
top_i = np.argpartition(-s, min(60, len(s) - 1))[:60]
for r, i in enumerate(top_i[np.argsort(-s[top_i])]):
sims[int(ids[i])] = float(s[i])
ranks[int(ids[i])] = ranks.get(int(ids[i]), 0) + 1 / (60 + r)
top = sorted(ranks, key=ranks.get, reverse=True)
if min_sim is not None: # для сводки: найденное словами — всегда, по смыслу — только достаточно близкое
top = [i for i in top if i in by_words or sims.get(i, 0) >= min_sim]
top = top[:n]
if not top:
return []
rows = {r["id"]: r for r in c.execute(
f"SELECT c.*, ch.title, ch.username, ch.id AS cid FROM chunk c JOIN chat ch ON ch.id=c.chat_id "
f"WHERE c.id IN ({','.join('?' * len(top))})", top)}
return [(rows[i], sims.get(i)) for i in top if i in rows]
def fmt_hit(r, sim=None, cap=900):
chat = {"id": r["cid"], "username": r["username"]}
t0 = dt.datetime.fromtimestamp(r["ts0"], TZ)
text = r["text"] if len(r["text"]) <= cap else r["text"][:cap] + " …"
return (f"── {r['title']} · {t0:%d.%m.%Y %H:%M} · {link(chat, r['first_id'])}"
+ (f" · близость {sim:.2f}" if sim is not None else "") + f"\n{text}\n")
def cmd_search(a):
hits = search(" ".join(a.query), a.chat, parse_since(a.since), a.n)
if not hits:
print("Ничего не нашлось.")
for r, sim in hits:
print(fmt_hit(r, sim))
def cmd_stats(a):
c = db()
chats = c.execute("SELECT count(*) FROM chat").fetchone()[0]
n, msgs, last = c.execute("SELECT count(*), coalesce(sum(n),0), max(ts1) FROM chunk").fetchone()
upd = c.execute("SELECT v FROM meta WHERE k='indexed'").fetchone()
print(f"чатов {chats}, кусков {n}, сообщений с текстом {msgs}; последнее сообщение "
f"{dt.datetime.fromtimestamp(last, TZ):%d.%m.%Y %H:%M}" if last else "пусто",
f"; индекс обновлён {upd[0] if upd else '—'}")
for r in c.execute("SELECT ch.title, count(k.id) n, max(k.ts1) last FROM chat ch LEFT JOIN chunk k ON k.chat_id=ch.id "
"GROUP BY ch.id ORDER BY n DESC LIMIT 30"):
print(f" {r['title'][:50]:<50} кусков {r['n']:>6}"
+ (f", последнее {dt.datetime.fromtimestamp(r['last'], TZ):%d.%m %H:%M}" if r["last"] else ""))
# ---------------------------------------------------------------- темы и сводка
def topics():
f = REPORT / "topics.json"
return json.loads(f.read_text()) if f.exists() else []
def save_topics(t):
REPORT.mkdir(parents=True, exist_ok=True)
(REPORT / "topics.json").write_text(json.dumps(t, ensure_ascii=False, indent=1))
def cmd_topic(a):
t = topics()
if a.op == "add":
t.append({"title": a.title.strip(), "query": (a.query or a.title).strip(), "added": dt.date.today().isoformat()})
save_topics(t)
print(f"Тема {len(t)}: {a.title}")
elif a.op == "del":
i = int(a.num) - 1
if not 0 <= i < len(t):
raise SystemExit("нет такой темы")
print("Удалена:", t.pop(i)["title"])
save_topics(t)
else:
for i, x in enumerate(t, 1):
print(f"{i}. {x['title']}" + (f" (ищу: {x['query']})" if x["query"] != x["title"] else ""))
if not t:
print("Тем нет. Добавить: tg topic add \"тема\"")
def codex(prompt):
with tempfile.TemporaryDirectory() as d:
out = Path(d) / "out.txt"
subprocess.run(["codex", "exec", "--skip-git-repo-check", "--sandbox", "read-only", "--ephemeral",
"--color", "never", "-o", str(out), "-"], input=prompt, text=True, cwd=d,
capture_output=True, timeout=900, check=True)
return out.read_text().strip()
REPORT_PROMPT = """Ты готовишь владельцу утреннюю сводку по Telegram-группам за последние {hours} ч по его темам.
Ниже по каждой теме — найденные поиском куски переписки (группа, время, ссылка, текст). Поиск грубый: часть кусков
к теме не относится — их пропусти.
Для каждой темы, где есть действительно относящееся: 2–6 пунктов по сути (что предлагают/советуют/спрашивают,
конкретика — места, цены, контакты, даты), у каждого пункта — ссылка на сообщение из данных. Ничего не выдумывай
и не добавляй от себя. Тему без относящегося — одной строкой «ничего нового». Без вступлений и выводов.
Формат — простой текст для Telegram без Markdown: заголовок темы строкой «▸ Тема», пункты — «• …».
Пиши по-русски.
{data}"""
DIGEST_PROMPT = """Ты готовишь владельцу вечерний рекап Telegram-группы «{title}» за период {period}.
Ниже — вся переписка группы за этот период по кускам (время, ссылка на первое сообщение куска, текст «Автор: сообщение»).
Сделай рекап по-русски, простым текстом для Telegram без Markdown:
- 3–8 главных тем дня, по каждой — 1–3 строки сути: о чём спорили, что выяснили, какие советы и ответы дали;
- отдельно «Полезное»: конкретика — места, контакты, цены, сроки, ссылки, документы, лайфхаки — с ссылкой на сообщение;
- если был важный вопрос без ответа — упомяни;
- флуд, приветствия, рекламу и оффтоп пропусти. Ничего не выдумывай, только по переписке.
Темы — строкой «▸ Тема», пункты — «• …». Без вступлений и выводов. Если переписки почти не было — одной строкой.
{data}"""
DIGEST_MAX = 60_000 # знаков переписки в один запрос к Codex (больше — берём последние)
def digests():
f = REPORT / "digests.json"
return json.loads(f.read_text()) if f.exists() else {"chats": [], "last": {}}
def save_digests(d):
REPORT.mkdir(parents=True, exist_ok=True)
(REPORT / "digests.json").write_text(json.dumps(d, ensure_ascii=False, indent=1))
def find_chat(c, needle):
n = needle.lstrip("@").lower()
for r in c.execute("SELECT id, title, username FROM chat"):
if (r["username"] or "").lower() == n or str(r["id"]) == needle or n in (r["title"] or "").lower():
return r
return None
def queue_text(text, tag):
OUTBOX.mkdir(parents=True, exist_ok=True)
parts, cur = [], ""
for para in text.split("\n"):
if len(cur) + len(para) + 1 > TG_MAX and cur:
parts.append(cur)
cur = ""
cur += para[:TG_MAX] + "\n"
if cur.strip():
parts.append(cur)
old = os.umask(0o007)
try:
stamp = dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
for i, p in enumerate(parts):
(OUTBOX / f"{stamp}-{tag}{i:02d}.txt").write_text(p.strip())
finally:
os.umask(old)
return len(parts)
def cmd_digest(a):
d = digests()
if a.op == "list":
for x in d["chats"]:
last = d["last"].get(str(x["id"]))
print(f"{x['title']} (@{x.get('username') or x['id']})" + (f" — последний рекап {dt.datetime.fromtimestamp(last, TZ):%d.%m %H:%M}" if last else ""))
if not d["chats"]:
print("Чатов для рекапа нет. Добавить: tg digest add @чат")
return
if a.op in ("add", "del"):
c = db()
r = find_chat(c, a.chat)
if a.op == "add":
if not r:
raise SystemExit(f"{a.chat} нет в индексе — чат выгружается? (tg stats)")
if all(x["id"] != r["id"] for x in d["chats"]):
d["chats"].append({"id": r["id"], "title": r["title"], "username": r["username"]})
print(f"Ежедневный рекап: {r['title']}")
else:
before = len(d["chats"])
d["chats"] = [x for x in d["chats"] if not (r and x["id"] == r["id"]) and x.get("username") != a.chat.lstrip("@")]
print("Убран." if len(d["chats"]) < before else "Такого чата в рекапе нет.")
save_digests(d)
return
# run
if not d["chats"]:
print("Чатов для рекапа нет.")
return
if not DB.exists():
print("Индекса ещё нет — рекап не делаю.")
return
c = db()
now = int(time.time())
for x in d["chats"]:
since = now - a.hours * 3600 if a.hours else d["last"].get(str(x["id"]), now - 86400)
rows = c.execute("SELECT * FROM chunk WHERE chat_id=? AND ts1 > ? ORDER BY ts0", (x["id"], since)).fetchall()
chat = {"id": x["id"], "username": x.get("username")}
blocks, total = [], 0
for r in reversed(rows): # не влезает — берём последние
b = f"── {dt.datetime.fromtimestamp(r['ts0'], TZ):%d.%m %H:%M} · {link(chat, r['first_id'])}\n{r['text']}"
if total + len(b) > DIGEST_MAX:
break
blocks.append(b)
total += len(b)
blocks.reverse()
period = f"{dt.datetime.fromtimestamp(since, TZ):%d.%m %H:%M} — {dt.datetime.fromtimestamp(now, TZ):%d.%m %H:%M}"
n_msgs = sum(r["n"] for r in rows)
if not rows:
text = f"🗒 {x['title']} за {period}: сообщений не было."
else:
body = codex(DIGEST_PROMPT.format(title=x["title"], period=period, data="\n\n".join(blocks)))
cut = f" (в рекапе последние {len(blocks)} из {len(rows)} кусков)" if len(blocks) < len(rows) else ""
text = f"🗒 {x['title']} — рекап за {period}, сообщений {n_msgs}{cut}\n\n{body}"
if a.dry:
print(text)
continue
print(f"{x['title']}: в очереди {queue_text(text, 'dg')} сообщ.")
d["last"][str(x["id"])] = now
save_digests(d)
def cmd_report(a):
t = topics()
if not t:
print("Тем нет — сводку не делаю.")
return
if not DB.exists():
print("Индекса ещё нет (выгрузка не запускалась) — сводку не делаю.")
return
since = int(time.time()) - a.hours * 3600
blocks = []
for x in t:
hits = search(x["query"], since=since, n=15, min_sim=0.4)
body = "\n".join(fmt_hit(r, cap=700) for r, _ in hits) or "(ничего не найдено)"
blocks.append(f"### Тема: {x['title']}\n{body}")
text = codex(REPORT_PROMPT.format(hours=a.hours, data="\n\n".join(blocks)))
text = f"🗞 Telegram за {a.hours} ч — {dt.datetime.now(TZ):%d.%m}\n\n{text}"
if a.dry:
print(text)
return
n = queue_text(text, "tg")
REPORT.mkdir(parents=True, exist_ok=True)
(REPORT / "last-report.txt").write_text(text)
print(f"сводка поставлена в очередь: {n} сообщ.")
def cmd_serve(a):
"""Служба tg-embed: модель в памяти, векторы запросов по unix-сокету (одна строка JSON → одна строка JSON)."""
import socketserver
embed(["прогрев"], remote=False)
class H(socketserver.StreamRequestHandler):
def handle(self):
try:
req = json.loads(self.rfile.readline())
v = embed(req["texts"][:64], remote=False)
out = {"n": v.shape[0], "dim": v.shape[1], "vecs": v.astype("float32").tobytes().hex()}
except Exception as e: # плохой запрос не роняет службу
out = {"error": str(e)}
self.wfile.write(json.dumps(out).encode() + b"\n")
if os.path.exists(SOCK):
os.unlink(SOCK)
old = os.umask(0o077) # сокет — только своему пользователю (astra)
srv = socketserver.UnixStreamServer(SOCK, H)
os.umask(old)
print(f"tg-embed: {EMB_MODEL} в памяти, сокет {SOCK}", flush=True)
srv.serve_forever()
def main():
p = argparse.ArgumentParser(prog="tg", description=__doc__.split("\n")[0])
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("search"); s.add_argument("query", nargs="+"); s.add_argument("--chat"); s.add_argument("--since")
s.add_argument("-n", type=int, default=12)
s = sub.add_parser("topic"); ts = s.add_subparsers(dest="op", required=True)
r = ts.add_parser("add"); r.add_argument("title"); r.add_argument("--query")
r = ts.add_parser("del"); r.add_argument("num")
ts.add_parser("list")
s = sub.add_parser("report"); s.add_argument("--hours", type=int, default=24); s.add_argument("--dry", action="store_true")
s = sub.add_parser("digest"); ds = s.add_subparsers(dest="op", required=True)
r = ds.add_parser("add"); r.add_argument("chat")
r = ds.add_parser("del"); r.add_argument("chat")
ds.add_parser("list")
r = ds.add_parser("run"); r.add_argument("--hours", type=int, default=0); r.add_argument("--dry", action="store_true")
sub.add_parser("stats")
s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true")
sub.add_parser("serve")
a = p.parse_args()
globals()["cmd_" + a.cmd](a)
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "cryptg"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/cc/3cc53619c07ab1def2f0ce7a958c10528c947e973821234714cfc61d7dd7/cryptg-0.6.0.tar.gz", hash = "sha256:f8fd59fea56398ab812f218c044b42fbbaeee8a9ae32771d3108a3cf210781aa", size = 15019, upload-time = "2026-04-12T13:31:09.978Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/77/b56510a48a5c8f3cd38a0a2f3b93a6eee9e1e27ca3b1b8a3ad4a323f8311/cryptg-0.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ad5ccdd23d95f857c90e4109a7a8bd21cd998b342d8447d69881125f72b337e", size = 208770, upload-time = "2026-04-12T18:33:51.402Z" },
{ url = "https://files.pythonhosted.org/packages/80/92/2780c8f178396c64e1a8bf350d68e66a08d801cd0ba355e81f8d10e58bee/cryptg-0.6.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2d5a99c8c3897930974f47bbfe75ab3304606a1d98f66b0d35d9dafdd98e0512", size = 238481, upload-time = "2026-04-12T18:35:15.194Z" },
{ url = "https://files.pythonhosted.org/packages/aa/db/84a3375d757c55f8c0e6d471828560a0f5bd6e8b2ecdf94cee44c1657c3d/cryptg-0.6.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:28497a18178b09f39b3d5faeaa34235ac1762b74017fbeac7bc34724c8efe0a0", size = 250130, upload-time = "2026-04-12T18:35:34.83Z" },
{ url = "https://files.pythonhosted.org/packages/52/1a/28f81903ac4c0a1db0f17b1774e67bcc64733d89188f21c10d3b19aef396/cryptg-0.6.0-cp313-cp313-win32.whl", hash = "sha256:c1bc4edee45eda7dd5963420d578bbf86f0a7e2cbbf0d25d5a812b4293a223f0", size = 110091, upload-time = "2026-04-12T18:36:01.976Z" },
{ url = "https://files.pythonhosted.org/packages/24/c3/742794894911bac8d83971b4a9ce742a0ca3b64d13ee2a64d2bb3cf5f01e/cryptg-0.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:2b11d6ac297a63ba413cb79c97030cfb93763f041fd85755a488e0db13699336", size = 109113, upload-time = "2026-04-12T18:36:03.613Z" },
{ url = "https://files.pythonhosted.org/packages/a9/88/51f40b7991d77fab8d307fbe2b7e28dbef6d499141f953b0ade73faa2ab7/cryptg-0.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e726a6a40967d91ad1eaa0f6245cc79035b528c059a22de34aca18bfb61efd2e", size = 208094, upload-time = "2026-04-12T18:33:52.905Z" },
{ url = "https://files.pythonhosted.org/packages/17/71/16f4b747d9be7c363be16717d4c2094d710f09acce15efea39e21aaff9a7/cryptg-0.6.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:3ccaa95f9ed6e6457b80ca003c99ee035c072dc421636b2482d069fc575ab5ba", size = 236844, upload-time = "2026-04-12T18:35:17.103Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/8af9ebfa5aaca1da3d2ab8520b886b41194be1019e9cb414c5c4c492afaa/cryptg-0.6.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1931178451aa59da98294973bf6916115572a412514960d8fd8afb7919fb7fd", size = 250346, upload-time = "2026-04-12T18:35:36.593Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d9/0214b6bc2b153abf91359de1a6035e59832e7377152de8749b378e6eebb5/cryptg-0.6.0-cp313-cp313t-win32.whl", hash = "sha256:0c312d2c0cc592cbf7bb08c1374f3ab27bcb01f46739b3ba23a4108f4f48661d", size = 109723, upload-time = "2026-04-12T18:35:58.77Z" },
{ url = "https://files.pythonhosted.org/packages/b0/89/133592fdb0859c639b5e62f5dfa24de4474b2e4e223668210e063592ddc8/cryptg-0.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8748fe0ba8cce4268b2f3570f9fc8cd5e7d1730598174e271f525a93d5f64c90", size = 108149, upload-time = "2026-04-12T18:36:00.377Z" },
{ url = "https://files.pythonhosted.org/packages/68/ca/740ba5201f47d50cd24809719ddff38b8a5d950f9921e426a0d2990de084/cryptg-0.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7eb4e335ac3df00333c202cf98e65edbecd8983c9a639c11d93f1f369ae64e05", size = 208586, upload-time = "2026-04-12T18:33:54.23Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d0/cfc3664d4b8c57f2058278472de7052719d2e37aece5591512d07e14777e/cryptg-0.6.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:28f6ca127a907d00ad075252e8d970bbac55e7f3df0f66a277f52b986fab0a57", size = 238396, upload-time = "2026-04-12T18:35:19.201Z" },
{ url = "https://files.pythonhosted.org/packages/16/59/abc03cb099cec7b5e28866a8401040b40e99c719f3bef49fa7a0b302e09a/cryptg-0.6.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4127534527ff741026b981359083da660010ddd556eb5e0f22523b9ce0fe80a2", size = 250040, upload-time = "2026-04-12T18:35:38.802Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6e/707de2c52842f093ab05811d002214e284c50f18057160352d7baa4ef1fa/cryptg-0.6.0-cp314-cp314-win32.whl", hash = "sha256:9c823319f6ff00c3c04be00cad78bd6555ccfc832fffc6b709eb983bbe0182b0", size = 112774, upload-time = "2026-04-12T18:36:08.654Z" },
{ url = "https://files.pythonhosted.org/packages/d2/49/6d8c6313789c3649f089a5f277369013072a5f8d96ba93d8c83def9c4a75/cryptg-0.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:0776b0256cd79ff202dd4c187872eb309bbee7ea63b9be722f9063b232b42ecd", size = 112208, upload-time = "2026-04-12T18:36:10.289Z" },
{ url = "https://files.pythonhosted.org/packages/de/31/3df7c7bfa022b8e49fba5bd511226eb8ab02b335832441dc9d5c07a154e7/cryptg-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:877e872b0967f03d008e2cb16a1aa44c8011b2f200bdf14771e6f783228e8bce", size = 207943, upload-time = "2026-04-12T18:33:55.483Z" },
{ url = "https://files.pythonhosted.org/packages/06/81/dea9beefd7317c353484a5736ce3de69ef9c1e84a93ea46cbe47f04a7f18/cryptg-0.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8ba3ed799086af279c1efb7bfa72b226dc9427f85034ab0b05ff23bccb97b45", size = 236856, upload-time = "2026-04-12T18:35:21.006Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f1/fcc52170486f35698229b573c0b6bc844f0504745f43fc47fde0cb61888e/cryptg-0.6.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ae96b26dfb53968fe4986f70222f2419c82651936f2ef3b2c2dc47bc74e77d96", size = 250297, upload-time = "2026-04-12T18:35:40.577Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d5/5041385f3a55d6eaa8f86c79e9fbb799d09680977619d506e35c09a9a3f9/cryptg-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:e4988fd1657aa86d91bc98d8262b78b141dec8ef51a843f1ef78d69e8a81fdf0", size = 111989, upload-time = "2026-04-12T18:36:05.193Z" },
{ url = "https://files.pythonhosted.org/packages/88/08/86db1d558e836c75724ef893c024d3f087886d1f8b173f2f64164505e256/cryptg-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:43f195810c642c6f6c552d291a216a5b5cb36b06aabe2832873db9dde3e65bb1", size = 111602, upload-time = "2026-04-12T18:36:06.81Z" },
]
[[package]]
name = "pyaes"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" }
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "telethon"
version = "1.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyaes" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/e2/d4137e598db0ba50e4cf9bf32f2e26d016b2c4bb0e9763e3d5a07007d780/telethon-1.44.0.tar.gz", hash = "sha256:7de1473e9e412e20bd57b102d8f50d48372712462be9ff6c003bb81c50ad7e98", size = 701463, upload-time = "2026-06-15T15:47:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/fd/4ad621d7a4b8655dfc964ee1c1267407496ec6fd10c91901a64ea29c16c8/telethon-1.44.0-py3-none-any.whl", hash = "sha256:52fc49efb67a4916c2aedcb295ad286f4afa2aba9bf15d83ed2acdc64af0c718", size = 789786, upload-time = "2026-06-15T15:47:41.534Z" },
]
[[package]]
name = "tg-export"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptg" },
{ name = "telethon" },
]
[package.metadata]
requires-dist = [
{ name = "cryptg", specifier = ">=0.6.0" },
{ name = "telethon", specifier = ">=1.44.0" },
]
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# tg — поиск по выгрузке Telegram и сводка по темам (infra stacks/rpi-tg-export, app/tgrag.py). Запускает astra.
exec /opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py "$@"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# tg-export КОМАНДА [опции export.py] — выгрузка второго Telegram-аккаунта владельца (infra stacks/rpi-tg-export).
# Запускать от tgexport: sudo -u tgexport tg-export list | login | run …
# Ключи приложения — /etc/tg-export/env (root:tgexport 640), сессия — /var/lib/tg-export/tg.session (tgexport 600),
# выгрузка — /mnt/backup/tg (группа astra, только чтение).
set -eu
set -a; . /etc/tg-export/env; set +a
exec /opt/tg-export/venv/bin/python /opt/tg-export/export.py "$@" \
--session /var/lib/tg-export/tg.session --out /mnt/backup/tg
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Установка/обновление выгрузки Telegram на rpi: sudo sh install.sh (из копии каталога stacks/rpi-tg-export)
# Ключи (/etc/tg-export/env) и вход (sudo -u tgexport tg-export login) — отдельно, см. README.
set -eu
cd "$(dirname "$0")"
id tgexport >/dev/null 2>&1 || useradd --system --home-dir /var/lib/tg-export --shell /usr/sbin/nologin tgexport
install -d -m 755 /opt/tg-export
install -m 644 app/export.py app/fetch_public.py app/channel_comments.py app/pyproject.toml /opt/tg-export/
[ -x /opt/tg-export/venv/bin/python ] || python3 -m venv /opt/tg-export/venv
/opt/tg-export/venv/bin/pip install -q --disable-pip-version-check 'telethon>=1.44.0' 'cryptg>=0.6.0'
install -m 755 bin/tg-export /usr/local/bin/tg-export
install -d -o root -g tgexport -m 750 /etc/tg-export
install -d -o tgexport -g tgexport -m 700 /var/lib/tg-export
install -d -o tgexport -g astra -m 2750 /mnt/backup/tg
install -m 644 systemd/tg-export.service systemd/tg-export.timer /etc/systemd/system/
systemctl daemon-reload
echo "ok: код в /opt/tg-export; таймер включается после входа: systemctl enable --now tg-export.timer"
@@ -0,0 +1,11 @@
[Unit]
Description=Telegram: вечерний рекап выбранных групп целиком (Codex) → Telegram
After=network-online.target tg-export.service
Wants=network-online.target
[Service]
Type=oneshot
User=astra
Group=astra
ExecStart=/usr/local/bin/tg digest run
TimeoutStartSec=30min
@@ -0,0 +1,9 @@
[Unit]
Description=Telegram: рекап групп каждый день в 23:50 (Кипр)
[Timer]
OnCalendar=*-*-* 23:50 Asia/Nicosia
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,17 @@
[Unit]
Description=Telegram-поиск: модель эмбеддингов в памяти (векторы запросов tg search/report за доли секунды)
[Service]
User=astra
Group=astra
# сокет /run/tg-embed/embed.sock, только astra (0700 каталог, 0600 сокет)
RuntimeDirectory=tg-embed
RuntimeDirectoryMode=0700
ExecStart=/opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py serve
Restart=on-failure
RestartSec=30
Nice=5
MemoryMax=1500M
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,23 @@
[Unit]
Description=Telegram: докачка истории второго аккаунта владельца (только текст) и индекс для поиска
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/mnt/backup
[Service]
Type=oneshot
User=tgexport
Group=tgexport
# файлы выгрузки 640, каталог setgid astra — Astra читает, остальные пользователи малины нет
UMask=0027
Nice=10
# первый прогон долгий: Telegram троттлит чтение истории (FloodWait), скрипт ждёт сам
TimeoutStartSec=infinity
# все кипрские группы и каналы (владелец 2026-10-18): в названии «кипр»/«cyprus» (подстрока, без регистра — новые кипрские
# группы подхватываются сами) + явные @username без этих слов; только группы/каналы — не личные и не боты (@CyprusCarBot).
# Не вступивший чат --only просто не находит — подтянется после вступления.
# -1001315261949 — Cyprus Car – Chat (без username). История — с 2024-10-18 (последние 2 года, владелец 2026-10-18).
ExecStart=/usr/local/bin/tg-export run --no-media --archived --since 2024-10-18 --kinds group supergroup channel --only кипр cyprus @cylaw @cymedic @cyprusinet @cyschools @cyvisaorg @cylang @cyprusdiy @CypRusSale @CupRusHelpers @badcyprus @cyprusit @cyprussport @cyfasttrack -1001315261949
# индекс для поиска и сводки: новые сообщения → куски + эмбеддинги (/mnt/backup/tg/index.db)
ExecStartPost=/opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py index --quiet
ExecStopPost=+/usr/local/bin/backup-metrics backup_tg_export
@@ -0,0 +1,10 @@
[Unit]
Description=Telegram: докачка истории второго аккаунта каждый час (свежий поиск и утренняя сводка)
[Timer]
OnCalendar=hourly
RandomizedDelaySec=5m
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,11 @@
[Unit]
Description=Telegram: утренняя сводка по темам владельца (Codex) → Telegram
After=network-online.target tg-export.service
Wants=network-online.target
[Service]
Type=oneshot
User=astra
Group=astra
ExecStart=/usr/local/bin/tg report
TimeoutStartSec=30min
@@ -0,0 +1,9 @@
[Unit]
Description=Telegram: сводка по темам каждый день в 08:00
[Timer]
OnCalendar=*-*-* 08:00 Asia/Nicosia
Persistent=true
[Install]
WantedBy=timers.target
+68
View File
@@ -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 исключён — он был бы рваной копией)
```
⚠️ **Потеря закрытого ключа = все бэкапы бесполезны.** Минимум две копии в разных местах,
и пробное восстановление раз в квартал.
+48
View File
@@ -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
+82
View File
@@ -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
+19
View File
@@ -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
+25
View File
@@ -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;'"
+67
View File
@@ -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;
}
}
}
+86
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
# www.ruzzy.dev/astra — страницы для Google OAuth (Branding)
Домашняя страница и privacy policy OAuth-приложения «Astra» (Google Cloud, проект `astra`,
личный аккаунт владельца) — см. `servers/rpi.md`, раздел «Google». Отдаёт vhost `www.ruzzy.dev`
на fr1 (корень `/var/www/share.ruzzy.dev`, `try_files $uri =404` — ссылки только с `.html`).
Установка на fr1: скопировать каталог в `~/www-ruzzy` и `sudo sh ~/www-ruzzy/install.sh`.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Astra</title>
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:640px;margin:40px auto;padding:0 16px;color:#222}</style>
</head><body>
<h1>Astra</h1>
<p>Astra is a private home assistant run by Ruslan Gilfanov for personal and family use only.
It is not offered to the public.</p>
<p>It uses the Google Calendar API to show the owner's agenda and to create or change events in the owner's
own Google Calendar on request.</p>
<p><a href="privacy.html">Privacy policy</a> · Contact: ruslan.gilfanov@gmail.com</p>
</body></html>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Astra — Privacy policy</title>
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:640px;margin:40px auto;padding:0 16px;color:#222}</style>
</head><body>
<h1>Astra — Privacy policy</h1>
<p>Last updated: 2026-10-12.</p>
<p>Astra is a private assistant used only by its owner (Ruslan Gilfanov) and his family. It is not a public service.</p>
<h2>Data accessed</h2>
<p>With the owner's consent Astra accesses the owner's Google Calendar (events and calendars visible to the owner's
Google account) to show the agenda, find free time and create, change or delete events at the owner's request.</p>
<h2>Storage and sharing</h2>
<p>The OAuth token and calendar data are processed on the owner's own home server only. Data is not sold,
shared with third parties or used for advertising. Calendar data is not used to train AI models.</p>
<p>The use of information received from Google APIs adheres to the
<a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,
including the Limited Use requirements.</p>
<h2>Revoking access</h2>
<p>Access can be revoked at any time at <a href="https://myaccount.google.com/permissions">myaccount.google.com/permissions</a>.</p>
<p>Contact: ruslan.gilfanov@gmail.com</p>
</body></html>
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Страницы Astra для Google OAuth Branding (homepage + privacy policy) → https://www.ruzzy.dev/astra/
# Запуск на fr1: sudo sh ~/www-ruzzy/install.sh
set -e
src=$(dirname "$0")/astra
install -d -o www-data -g www-data -m 755 /var/www/share.ruzzy.dev/astra
install -o www-data -g www-data -m 644 "$src/index.html" "$src/privacy.html" /var/www/share.ruzzy.dev/astra/
# /astra/ → index.html (vhost: try_files $uri =404, без index-каталога)
ls -l /var/www/share.ruzzy.dev/astra