diff --git a/README.md b/README.md new file mode 100644 index 0000000..098e01e --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Документация инфраструктуры + +Описание домашнего контура: Proxmox, контейнеры, сервисы, VPN, домены и способы подключения. + +**Точка входа:** [Архитектура и подключение](docs/architecture/architecture.md) — схема сети, IP, домены, таблица всех хостов. +**Топология и риски:** [Схема сети и зависимости](docs/network/network-topology.md) — узлы, маршруты NPM, зависимости сервисов, единые точки отказа (SPOF). +**Приоритет №1:** [Бэкапы Proxmox (фаза 1)](docs/backup/proxmox-phase1-backup.md) — стратегия бэкапов LXC/VM и /etc/pve, тестовое восстановление. + +--- + +## Homelab: контейнеры и ВМ + +Подробные инструкции по каждому хосту (сервисы, конфиги, порты, бэкапы, TODO). + +| Документ | Что внутри | +|----------|------------| +| [Контейнер 100](docs/containers/container-100.md) | NPM, Homepage, AdGuard, Wallos, log-dashboard, vpn-route-check | +| [Контейнер 101](docs/containers/container-101.md) | Nextcloud, PostgreSQL, Redis, хранилище «Игры» | +| [Контейнер 103](docs/containers/container-103.md) | Gitea, PostgreSQL, act_runner, CouchDB (Obsidian) | +| [Контейнер 104](docs/containers/container-104.md) | Paperless-ngx, PostgreSQL, Redis | +| [Контейнер 105](docs/containers/container-105.md) | RAG API (mini-lm), модели, векторы | +| [Контейнер 107](docs/containers/container-107.md) | Invidious, Companion, PostgreSQL | +| [Контейнер 108](docs/containers/container-108.md) | Galene (видеозвонки), call.katykhin.ru | +| [ВМ 200](docs/containers/container-200.md) | Immich, PostgreSQL, Redis, ML (CUDA), deduper | + +**Интеграции:** [Paperless + Ollama](docs/containers/paperless-ollama.md) — вопросы к документам Paperless через локальную LLM. + +--- + +## Сеть, VPN и внешние серверы + +| Документ | О чём | +|----------|--------| +| [Роутер Netcraze Speedster](docs/network/router-netcraze-speedster.md) | Доступ к роутеру, два VPN, маршрутизация, DNS | +| [VPN-сервер (VPS, AmneziaWG)](docs/vps/vpn-vps-amneziawg.md) | VPS для обхода блокировок, AmneziaWG, подключение клиента | +| [Перенос конфигурации AmneziaWG](docs/vps/vpn-migrate-config.md) | Миграция конфига между серверами DE/USA | +| [VPS Миран: боты и STUN/TURN](docs/vps/vps-miran-bots.md) | Telegram-боты, prod, coTURN для Galene | + +--- + +## SSL и прокси + +| Документ | О чём | +|----------|--------| +| [Let's Encrypt (DNS-01)](docs/network/ssl-letsencrypt-dns01.md) | Certbot, Beget API, выдача и продление сертификатов, интеграция с NPM | + +Скрипты для NPM и сертификатов лежат в каталоге [scripts/](scripts/) (например `npm-cert-cloud.sh`, `npm-add-proxy.sh`). diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md new file mode 100644 index 0000000..6f3a90e --- /dev/null +++ b/docs/architecture/architecture.md @@ -0,0 +1,99 @@ +# Архитектура и подключение + +Краткое описание домашнего контура: сеть, доступ, гипервизор и ключевые контейнеры. + +--- + +## Сеть и доступ + +- **Внешний IP:** 185.35.193.144 +- **Домашний сервер (Proxmox):** 192.168.1.150 (LAN) + - Подключение: `ssh root@192.168.1.150` +- **DNS домена katykhin.ru:** Beget.com + - Учётная запись: логин `amauri7g`, пароль `QgkaKL3RykeI`, ID аккаунта 2536839. Режим API включён. Домен **katykhin.store** в аккаунте есть, но не используется (поддоменов нет). +- **Reverse proxy и SSL:** Nginx Proxy Manager (NPM) на контейнере 100. + +**Поддомены katykhin.ru:** +В панели Beget отображаются все перечисленные ниже; через API `getSubdomainList` приходят только часть (в т.ч. служебные `_acme-challenge.*`). Полный список ведём вручную. + +| Поддомен | Назначение | +|----------|------------| +| api.katykhin.ru | — | +| call.katykhin.ru | Galene (видеозвонки) | +| cloud.katykhin.ru | — | +| docs.katykhin.ru | — | +| git.katykhin.ru | — | +| home.katykhin.ru | Homepage | +| immich.katykhin.ru | — | +| mini-lm.katykhin.ru | — | +| obsidian.katykhin.ru | — | +| share.katykhin.ru | — | +| video.katykhin.ru | Invidious | +| wallos.katykhin.ru | Wallos | +| _acme-challenge.call.katykhin.ru | Служебный (DNS-01 Let's Encrypt) | + +*Назначение для api, cloud, docs, git, immich, mini-lm, obsidian, share можно заполнить позже при описании контейнеров.* + +--- + +## Гипервизор + +- **Proxmox VE.** Гости — в основном **LXC-контейнеры**, одна **KVM VM** (Immich, ID 200). +- Управление LXC: `pct` (например `pct exec -- bash`). Управление VM: `qm`. +- IP контейнера задаётся вручную (статически в LXC или через DHCP-резерв на роутере). Схема: **ID контейнера = последний октет IP** (например контейнер 105 → 192.168.1.105; исключения вносить в таблицу контейнеров). + +**Создание контейнера через pct (пример):** ВМ с ID 105, 1 ядро, 1 ГБ RAM, IP 192.168.1.105. Шаблон — из local (подставь свой, например `debian-12-standard`). Шлюз ниже — типичный для домашней сети; при необходимости замени на свой. + +```bash +pct create 105 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst \ + --hostname ct105 \ + --memory 1024 \ + --cores 1 \ + --net0 name=eth0,bridge=vmbr0,ip=192.168.1.105/24,gw=192.168.1.1,type=veth +``` + +После создания: `pct start 105`; зайти в консоль: `pct enter 105` или `pct exec 105 -- bash`. + +--- + +## Ключевые контейнеры и ВМ + +| ID | Назначение | IP | Ресурсы | Домены (через NPM) | +|-----|-------------------------------|----------------|------------|---------------------| +| 100 | NPM, Homepage, AdGuard, Wallos | 192.168.1.100 | 1 core, 2 GB | home.katykhin.ru, wallos.katykhin.ru, adguard.local. → [Контейнер 100 (подробно)](../containers/container-100.md) | +| 101 | Nextcloud | 192.168.1.101 | 2 core, 3 GB | cloud.katykhin.ru. → [Контейнер 101 (подробно)](../containers/container-101.md) | +| 103 | Gitea, сервис Obsidian (5984) | 192.168.1.103 | 1 core, 2 GB | obsidian.katykhin.ru → [Контейнер 103 (подробно)](../containers/container-103.md) | +| 104 | Paperless | 192.168.1.104 | 1 core, 2 GB | docs.katykhin.ru → [Контейнер 104 (подробно)](../containers/container-104.md) | +| 105 | RAG-service | 192.168.1.105 | 1 core, 1 GB | mini-lm.katykhin.ru → [Контейнер 105 (подробно)](../containers/container-105.md) | +| 107 | Invidious (misc) | 192.168.1.107 | 1 core, 2 GB | video.katykhin.ru → [Контейнер 107 (подробно)](../containers/container-107.md) | +| 108 | Galene | 192.168.1.108 | 1 core, 256 MB | call.katykhin.ru → [Контейнер 108 (подробно)](../containers/container-108.md) | +| 200 | Immich, immich-pt и др. | 192.168.1.200 | 3 core, 10 GB | immich.katykhin.ru → [ВМ 200 (подробно)](../containers/container-200.md) | + +*100–108 — LXC-контейнеры (pct), 200 — KVM-ВМ (qm).* Домены api, git, share и др. в NPM при необходимости добавить позже. + +--- + +## Поток запросов (упрощённо) + +1. Запрос из интернета: `https://video.katykhin.ru` → роутер (порты 80/443 на 185.35.193.144) → проброс на 192.168.1.100. +2. NPM (контейнер 100) принимает HTTPS, проверяет Host, смотрит proxy_host → upstream (например 192.168.1.107:3000 для video.katykhin.ru, 192.168.1.200:2283 для immich.katykhin.ru). +3. Бэкенд-сервис (например Invidious в контейнере 107) отдаёт страницу; при «без прокси» видео стримится напрямую с YouTube в браузер. + +--- + +## SSL-сертификаты + +- **Let's Encrypt:** certbot на контейнере 100; для доменов без доступа по HTTP-01 используется **DNS-01** (Beget API). После выпуска/продления сертификаты подкладываются в NPM (custom_ssl) и перезагружается nginx. + → **Полная инструкция:** [Выпуск сертификата Let's Encrypt (DNS-01)](../network/ssl-letsencrypt-dns01.md). +- **Самоподписные:** при необходимости добавляются вручную в NPM (БД + файлы в custom_ssl). + +--- + +## Дополнительно + +- **Схема сети и зависимости:** полная топология (роутер, Proxmox, контейнеры, VPS), таблица IP/доменов, маршруты NPM, кто от кого зависит, единые точки отказа (SPOF). → [Схема сети и зависимости](../network/network-topology.md). +- **Homepage:** на контейнере 100, конфиг сервисов в `/opt/docker/homepage/config/services.yaml` (ссылки на NPM, Invidious, AdGuard, Immich, Galene и т.д.). +- **VPN (VPS):** отдельный сервер 185.103.253.99, AmneziaWG для обхода блокировок. → [VPN-сервер (VPS, AmneziaWG)](../vps/vpn-vps-amneziawg.md). +- **Роутер:** Netcraze Speedster, два WireGuard/AmneziaWG (Германия / США), маршрутизация части трафика через VPN. → [Роутер Netcraze Speedster](../network/router-netcraze-speedster.md). +- **VPS Миран (СПБ):** боты (telegram-helper-bot, anonBot), prod-инфраструктура, STUN/TURN для Galene. → [VPS Миран: боты и STUN/TURN](../vps/vps-miran-bots.md). +- Подробнее по контейнерам и сервисам — в отдельных статьях (по мере появления). diff --git a/docs/backup/proxmox-phase1-backup.md b/docs/backup/proxmox-phase1-backup.md new file mode 100644 index 0000000..75202ec --- /dev/null +++ b/docs/backup/proxmox-phase1-backup.md @@ -0,0 +1,262 @@ +# Фаза 1: Стратегия бэкапов Proxmox + +Цель: при смерти SSD с системой или потере `/etc/pve` — развернуть Proxmox, восстановить контейнеры/ВМ, поднять сервисы без многочасового восстановления и угадывания паролей. + +**Приоритет №1.** ИБП уже есть; защищаемся от смерти диска. + +--- + +## Что бэкапить + +| Объект | Зачем | +|--------|--------| +| **LXC и VM целиком** (vzdump) | Восстановление контейнера/ВМ из одного архива: ОС, конфиги, данные на корневом томе. Не только данные внутри — сам образ для restore. | +| **/etc/pve** | Конфиги кластера, VM/LXC (ID, сеть, диски, задачи), пользователи Proxmox, права. Без этого после переустановки Proxmox не восстановить привязку дисков и настройки. | + +--- + +## Пошаговый план + +### Шаг 1. Определить хранилище для бэкапов + +**Выбранная схема:** + +| Место | Описание | Что туда | +|-------|----------|----------| +| **Локально: /dev/sdb1 (1 ТБ под бэкапы)** | Отдельный SSD 2 ТБ; под копии выделен 1 ТБ, смонтирован в `/mnt/backup`. Второй ТБ — в запас. | Proxmox vzdump (через UI), затем те же данные (dump, etc-pve, фотки, VPS) в Yandex через restic. Фотки: оригиналы + метаданные + БД Immich; остальное пересчитать можно. VPS: Amnezia — конфиг; Миран — БД бота + контент (контент можно в S3 Мирана; копию на sdb1 — опционально). Конфигурацию серверов не бэкапим — есть Ansible. | +| **Офсайт: Yandex Object Storage (S3)** | Арендованный бакет, S3-совместимый API. [Yandex Object Storage](https://yandex.cloud/ru/docs/storage/s3/). | **Restic** с хоста (cron на ноде Proxmox): выгрузка содержимого `/mnt/backup` (или выбранных каталогов). Retention: 7 daily, 4 weekly, 6 monthly. | + +- **Вариант A (альтернатива):** Отдельный диск/раздел на том же хосте — у тебя это sdb1. +- **Вариант B:** Сетевое хранилище (NFS/SMB) — не используется в текущей схеме. +- **Вариант C:** Внешний USB — опционально для 3-2-1 (см. ниже). + +**3-2-1:** Три копии: (1) прод — система и данные на основном диске; (2) локальный бэкап — sdb1; (3) офсайт — Yandex. Два типа носителей: локальный SSD и облачное object storage. Один офсайт — да. **Стратегия удовлетворяет 3-2-1.** Опционально: четвёртая копия на внешнем USB/другом ПК для параноидального сценария (пожар/кража) — по желанию. + +**Принято:** Точка монтирования — `/mnt/backup`. На диске 2 ТБ выделено 1 ТБ под бэкапы; второй ТБ пока в запас, назначение не определено. + +**Действие:** Разметить 1 ТБ на /dev/sdb1, создать ФС (ext4/xfs), смонтировать в `/mnt/backup`. Структуру каталогов — см. раздел «Структура локального хранилища» ниже. + +--- + +### Шаг 2. Добавить Backup Storage в Proxmox + +1. В веб-интерфейсе: **Datacenter → Storage → Add**. +2. Тип: **Directory** (если папка на локальном диске) или **NFS/CIFS** (если сетевое). +3. Указать: + - **ID** (например `backup-local` или `backup-nfs`), + - **Directory** — путь, например `/mnt/backup/dump`, + - Включить опции **Content: VZDump backup file** (и при необходимости **ISO**, **Container template** — по желанию). +4. Сохранить. Убедиться, что storage виден и доступен для записи. + +Если используешь NFS: сначала смонтировать NFS в `/mnt/backup` на хосте (fstab или systemd mount), затем добавить Storage с Directory `/mnt/backup/dump`. + +**Для выбранной схемы:** Directory = `/mnt/backup/proxmox/dump` (см. структуру ниже). + +--- + +### Структура локального хранилища (1 ТБ на sdb1) + +«Аналог S3» на одном диске — это просто понятная иерархия каталогов. **MinIO не используем:** лишний сервис; достаточно директорий и restic с бэкендом `local` (если понадобится локальный restic) и `s3` для Yandex. Proxmox пишет в **Directory** — ему достаточно пути. + +Пример структуры под `/mnt/backup`: + +``` +/mnt/backup/ +├── proxmox/ +│ ├── dump/ ← Proxmox Backup Job (VZDump) — сюда добавляем Storage в PVE +│ └── etc-pve/ ← архивы tar.gz из cron (backup-etc-pve.sh) +├── restic/ +│ ├── local/ ← репозитории restic для локальных снапшотов (опционально) +│ └── ... ← или restic только в Yandex, локально только сырые копии +├── photos/ ← Immich: оригиналы фото + метаданные + БД (остальное пересчитать) +├── vps/ ← Amnezia: конфиг. Миран: БД бота (+ контент при необходимости; основной контент можно в S3 Мирана) +└── other/ ← прочие важные данные (конфиги, скрипты, что ещё решите) +``` + +Квоты: при необходимости ограничить размер по каталогам (например `proxmox/dump` — не более 500 ГБ) через отдельные подразделы или скрипты очистки (retention). + +--- + +### Шифрование диска бэкапов (LUKS) + +**LUKS** (Linux Unified Key Setup) — стандартное шифрование раздела в Linux. Если диск с бэкапами украдут или вынесут, без пароля/ключа данные не прочитать. Минусы: нужно вводить пароль при загрузке (или хранить ключ на другом носителе), небольшая нагрузка на CPU. + +**Принято:** LUKS пока не используем. Раздел sdb1 — без шифрования. При необходимости можно добавить позже (потребуется перенос данных). + +--- + +### Restic и Yandex Object Storage + +- **Restic** поддерживает бэкенд **S3**. Yandex Object Storage совместим с S3 API — используешь endpoint бакета и ключи (Access Key / Secret). +- **Retention в Yandex:** 7 daily, 4 weekly, 6 monthly — `restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6` и затем `prune`. +- **Что гнать в Yandex через restic:** Proxmox dump (каталог `proxmox/dump`), `/etc/pve` (архивы из `proxmox/etc-pve`), фотки (оригиналы + метаданные + БД Immich), бэкапы с VPS — см. «Принятые решения: что куда» ниже. + +**Локально отдельной политики retention для restic не нужно:** на sdb1 retention задаётся в самом Proxmox Backup Job (например «keep last 7») и в скрипте бэкапа `/etc/pve` (удалять архивы старше N дней). Restic используется только для отправки в Yandex с политикой 7/4/6. Локальных restic-репозиториев можно не заводить — только каталоги и выгрузка их содержимого в облако. + +Документация Yandex: [Object Storage S3](https://yandex.cloud/ru/docs/storage/s3/). Нужны: bucket name, region, endpoint, Static Key (Access Key ID + Secret Access Key). **Бакет создан; ключи и endpoint зафиксировать при настройке restic.** + +--- + +### Хранилище паролей (варианты) + +Чтобы не терять пароли при восстановлении и держать креды в одном месте: + +| Вариант | Плюсы | Минусы | +|--------|--------|--------| +| **Vaultwarden** (self-hosted, Bitwarden-совместимый) | Один сервер в твоём контуре (например CT 100 или отдельный LXC), клиенты Bitwarden на ПК/телефоне, бесплатно | Нужен HTTPS (NPM уже есть), бэкап базы Vaultwarden — в общий план бэкапов | +| **Bitwarden Cloud** (официальный облачный) | Не нужно поднимать сервер, синхронизация везде | Платно (или ограничения бесплатного), данные у третьей стороны | +| **KeePass / KeePassXC** (файл .kdbx) | Локально, один зашифрованный файл; можно положить в бэкап (restic/local) и открывать с любого ПК | Нет удобной синхронизации на телефон из коробки (нужен общий файл через Nextcloud/флешку) | +| **1Password / др. облачные менеджеры** | Удобно, кросс-девайс | Платно, данные у провайдера | + +**Принято:** Развернуть **Vaultwarden** на контейнере **107 или 103** (не на 100). Домен через NPM, бэкап данных Vaultwarden включить в то, что уходит в restic → Yandex. + +--- + +### Где запускать бэкапы (централизация) + +**С хоста Proxmox (cron на ноде):** + +- **Proxmox Backup Job** уже выполняется на хосте и пишет vzdump всех выбранных LXC/VM в `/mnt/backup/proxmox/dump` — это и есть «бэкапы всех контейнеров и ВМ», централизованно. +- **Restic** (backup/forget/prune) тоже запускаем с хоста по cron: бэкапит каталоги на хосте (например весь `/mnt/backup` или выбранные подкаталоги) в Yandex S3. Данные для бэкапа — локальные пути (dump, etc-pve, а фотки и данные с VPS нужно либо копировать на хост в `/mnt/backup` скриптами, либо монтировать и тогда restic будет их читать с хоста). Контейнеры и ВМ целиком не бэкапим через restic — ими занимается только Proxmox Backup Job. + +--- + +### Шаг 3. Настроить расписание бэкапа LXC и VM + +1. **Datacenter → Backup** (или **Backup** в меню узла). +2. **Add** — создаётся задача (Job). +3. Параметры: + - **Storage** — выбранное хранилище (шаг 2). + - **Schedule** — например `0 2 * * *` (каждую ночь в 02:00). Подстроить под окно, когда нагрузка минимальна. + - **Selection mode:** включить нужные узлы (или **All**), затем отметить **галочками** конкретные **LXC (100–108) и VM (200)**. Либо выбрать "Backup all" для всех VMs/containers. + - **Mode:** + - **Snapshot** — контейнер/ВМ не останавливается, создаётся снимок (рекомендуется для минимизации даунтайма). + - **Suspend** — ВМ приостанавливается на время бэкапа (более консистентно для БД, но даунтайм). + Для LXC обычно достаточно **Snapshot**; для VM 200 (Immich с БД) можно оставить Snapshot, при необходимости позже добавить отдельный консистентный бэкап БД изнутри гостя. + - **Compression:** ZSTD (хороший компромисс скорость/размер). + - **Retention:** например «Keep last 7» или «Keep last 4 weekly» — чтобы не забивать диск. + +4. Сохранить job. Проверить по кнопке **Backup now**, что задача запускается и файлы появляются в Storage. + +Важно: бэкап должен включать **и LXC, и VM 200**. Не только данные внутри них (те уже описаны в документации контейнеров), а именно полный dump для restore. + +--- + +### Шаг 4. Бэкап /etc/pve + +Конфиги кластера и виртуалок лежат в `/etc/pve`. Их нужно копировать регулярно и хранить в безопасном месте (желательно не только на том же диске, что и система). + +**Вариант A: cron на хосте Proxmox** + +1. Создать скрипт, например `/root/scripts/backup-etc-pve.sh`: + +```bash +#!/bin/bash +BACKUP_ROOT="/mnt/backup/proxmox/etc-pve" # по структуре выше +DATE=$(date +%Y%m%d-%H%M) +mkdir -p "$BACKUP_ROOT" +tar -czf "$BACKUP_ROOT/etc-pve-$DATE.tar.gz" -C / etc/pve +# опционально: удалять бэкапы старше N дней +# find "$BACKUP_ROOT" -name 'etc-pve-*.tar.gz' -mtime +30 -delete +``` + +2. Сделать исполняемым: `chmod +x /root/scripts/backup-etc-pve.sh`. +3. Добавить в cron: `crontab -e`, например раз в день после основного backup job: + `0 3 * * * /root/scripts/backup-etc-pve.sh` + +**Вариант B:** Тот же скрипт можно вызывать из задачи в Proxmox (Script/Command в задаче типа Hook script), но проще и надёжнее — отдельный cron на хосте. + +Бэкапы `/etc/pve` хранить **локально** (`/mnt/backup/proxmox/etc-pve`) и **в Yandex** — включить этот каталог в источники restic (мало весит, критично при потере хоста). Файлы с ограниченными правами (chmod 600); `/etc/pve` содержит секреты — не выкладывать в открытый доступ. + +--- + +### Шаг 5. Хранить секреты отдельно (пароли, ключи) + +Чтобы «не вспоминать пароли 3 часа» после восстановления: + +Завести **секретное хранилище** — Vaultwarden на CT 107 или 103 (см. выше). Туда: root Proxmox, пользователи PVE, пароли БД и сервисов (Nextcloud, Gitea, Paperless, Immich, NPM, Galene и т.д.), API-ключи (Beget, certbot, Wallos и др.). Полный список кредов по контейнерам — в статьях container-100 … container-200; свести в один список в Vaultwarden и обновлять при смене паролей. + +Это не «шаг бэкапа», но обязательная часть восстановления: без паролей восстановленные контейнеры не войдут в сервисы. + +--- + +### Шаг 6. Тестовое восстановление одного контейнера + +Без проверки восстановления нельзя считать стратегию рабочей. + +1. Выбрать **некритичный** контейнер (например 105 — RAG, или 107 — Invidious), для которого краткий даунтайм допустим. +2. Убедиться, что есть свежий backup этого контейнера в Storage (после шага 3). +3. **Восстановление:** + - В Proxmox UI: **Datacenter → Backup** → выбрать storage → найти backup нужного CT → **Restore**. Указать **new VMID** (например 999 для теста) и **Storage** для дисков. + - Или с CLI: + `qmrestore` для VM, для LXC — через GUI или `pct restore` (см. справку `pct restore`). + Для LXC типично: Backup → Restore → задать новый ID (например 999), node, storage. +4. Запустить восстановленный контейнер (ID 999), проверить: + - заходит по SSH/консоль; + - сервисы внутри запускаются (docker/ systemd); + - с хоста пинг и при необходимости один сервис по порту. +5. После проверки удалить тестовый контейнер (999), освободить место. + +Если что-то пошло не так (не находится диск, ошибка прав, сеть) — зафиксировать и поправить стратегию (пути storage, режим backup, права). + +--- + +### Шаг 7. Документировать процедуру восстановления «с нуля» + +Кратко зафиксировать в отдельном разделе (здесь или в architecture): + +1. Установка Proxmox на новое железо (или новый диск). +2. Восстановление конфигов: распаковать последний `etc-pve-*.tar.gz` в `/etc/pve` (с учётом того, что нужно корректно подставить ноды и storage; при одномузловой установке обычно достаточно скопировать файлы). +3. Подключение storage с backup (или копирование последних vzdump на новый storage). +4. Восстановление контейнеров и ВМ из backup по одному (Restore с указанием VMID и storage). +5. Запуск контейнеров/ВМ, проверка сети и сервисов. +6. Использование сохранённых паролей/ключей для входа и проверки сервисов. + +После первого успешного тестового восстановления (шаг 6) эту процедуру можно уточнить и дописать по факту. + +--- + +## Чек-лист фазы 1 + +- [ ] Разметка: 1 ТБ на sdb1, ФС, монтирование в `/mnt/backup` (без LUKS). +- [ ] В Proxmox добавлен Storage для VZDump → `/mnt/backup/proxmox/dump`. +- [ ] Настроена регулярная задача Backup: все LXC (100–108) и VM (200), расписание (например ночь), retention. +- [ ] Проверен ручной запуск Backup now — файлы появляются в storage. +- [ ] Настроен бэкап `/etc/pve` (скрипт + cron) → `/mnt/backup/proxmox/etc-pve`. +- [ ] Restic: cron на хосте, выгрузка нужных каталогов из `/mnt/backup` в Yandex S3, retention 7/4/6. +- [ ] Yandex: ключи и endpoint зафиксированы, restic успешно пишет в бакет. +- [ ] Vaultwarden развёрнут (CT 107 или 103), секреты перенесены, бэкап данных Vaultwarden входит в restic. +- [ ] Выполнено тестовое восстановление одного контейнера (другой VMID), проверена работоспособность. +- [ ] В документации зафиксирована процедура полного восстановления Proxmox «с нуля». + +--- + +## Ссылки + +- [Архитектура и подключение](../architecture/architecture.md) — хосты, IP, домены. +- [Схема сети и зависимости](../network/network-topology.md) — SPOF, зависимость от Proxmox и бэкапов. +- Документация контейнеров (100–108, 200) — бэкапы *данных внутри* сервисов (БД, тома); фаза 1 дополняет это бэкапом на уровне PVE. + +--- + +## Принятые решения (сводка) + +| Вопрос | Решение | +|--------|---------| +| Точка монтирования, второй ТБ | `/mnt/backup`; второй ТБ на sdb1 — в запас, назначение позже. | +| Шифрование (LUKS) | Пока не делаем; раздел без шифрования. | +| Proxmox vzdump | Локально в `proxmox/dump` + дублировать в Yandex через restic. | +| Фотки | Оригиналы + метаданные + БД Immich; остальное пересчитать. | +| VPS | Amnezia — конфиг. Миран — БД бота + контент (контент можно в S3 Мирана; копия на sdb1 — по желанию). Конфиг серверов не бэкапим — Ansible. | +| Где запускать бэкапы | Cron на хосте Proxmox: Backup Job (vzdump) + restic в Yandex. | +| Retention локально | Только в Proxmox Job и в скрипте etc-pve; отдельного restic-репозитория локально не делаем. | +| /etc/pve | Локально и в Yandex (через restic). | +| Пароли | Vaultwarden на CT 107 или 103. | +| Yandex | Бакет создан; ключи и endpoint зафиксировать при настройке restic. | +| MinIO | Не используем; директории + restic (s3 для Yandex). | + +--- + +## Осталось сделать + +- **Yandex:** подготовить Static Key (Access Key + Secret), записать endpoint и имя бакета — понадобятся для настройки restic. diff --git a/docs/containers/container-100.md b/docs/containers/container-100.md new file mode 100644 index 0000000..4217773 --- /dev/null +++ b/docs/containers/container-100.md @@ -0,0 +1,258 @@ +# Контейнер 100 (nginx): NPM, Homepage, AdGuard, Wallos и сопутствующие сервисы + +Подробное описание LXC-контейнера **100** на Proxmox (192.168.1.100): reverse proxy, DNS-фильтрация, дашборд, учёт подписок, мониторинг логов и маршрутов VPN. + +--- + +## Общие сведения + +- **Хостнейм:** nginx +- **IP:** 192.168.1.100/24 +- **ОС:** Debian 12 (bookworm) +- **Ресурсы:** 1 core, 2 GB RAM (из [архитектуры](../architecture/architecture.md)) +- **Доступ:** с Proxmox — `pct exec 100 -- bash` или `ssh` на 192.168.1.100, если настроен. + +Диск контейнера: порядка 10 GB, занято ~4.6 GB (логи и данные сервисов). Следить за местом (см. раздел «Логи и ротация» и TODO). + +--- + +## Доступ и логины + +- **Debian (CT 100):** логин `root` (или консольный пользователь Debian), пароль `waccEk-fyqbux-rarja3`. +- **AdGuard Home:** http://192.168.1.100:3000, пользователь `kerrad`, пароль `waccEk-fyqbux-rarja3`. +- **Nginx Proxy Manager:** http://192.168.1.100:81, имя `Kerrad`, email `j3tears100@gmail.com`, пароль `kqEUubVq02DJTS8`. +- **Wallos:** https://wallos.katykhin.ru (через NPM), Web‑логин/пароль сохранены в менеджере паролей; Basic Auth в NPM — логин `admin`, пароль `fy8lNlWvvJryfrUVMZr8`. +- **Homepage:** https://home.katykhin.ru, логин `admin`, пароль `fy8lNlWvvJryfrUVMZr8`. + +--- + +## Сервисы (Docker) + +Все сервисы запущены в Docker. Сети: **proxy_network** (общая для NPM, Homepage, Wallos, dockerproxy, AdGuard), **adguard_proxy_network** (AdGuard дополнительно). Контейнер **vpn-route-check** в режиме `network_mode: host`. + +| Контейнер | Образ | Порты (хост) | Назначение | +|-------------------|------------------------------------|-------------------|------------| +| npm | jc21/nginx-proxy-manager:latest | 80, 81, 443 | Reverse proxy, SSL, админка NPM | +| adguard | adguard/adguardhome:latest | 53/tcp+udp, 67–68, 853, 3000 | DNS, DoT, веб-интерфейс, опционально DHCP | +| homepage | ghcr.io/gethomepage/homepage:latest| 4000→3000 | Дашборд сервисов (home.katykhin.ru) | +| dockerproxy | tecnativa/docker-socket-proxy | 2375 (внутри сети)| Прокси к Docker API для Homepage (только чтение) | +| wallos | bellamy/wallos:latest | 8282→80 | Учёт подписок (wallos.katykhin.ru) | +| log-dashboard | nginx:alpine | 8088→80 | Просмотр ленты обращений NPM (статика из html) | +| vpn-route-check | свой образ (build) | host | Проверка маршрутов VPN по доменам, дашборд на 8765 | + +--- + +## 1. Nginx Proxy Manager (NPM) + +**Каталог:** `/opt/docker/nginx-proxy/` +**Compose:** `docker-compose.yml` (образ `jc21/nginx-proxy-manager:latest`, restart unless-stopped). + +**Порты:** 80 (HTTP), 81 (админка), 443 (HTTPS). +**Тома:** +- `./data` → `/data` (конфиги nginx, БД SQLite, логи, custom_ssl, proxy_host) +- `./letsencrypt` → `/etc/letsencrypt` (в контейнере; с хоста certbot пишет в системный `/etc/letsencrypt`, см. ниже). + +**Сеть:** proxy_network. + +**Основные пути на хосте:** +- `/opt/docker/nginx-proxy/data/` — данные NPM (в т.ч. `nginx/proxy_host/*.conf`, `logs/`, `custom_ssl/`, `database.sqlite`). +- `/opt/docker/nginx-proxy/data/logs/` — логи nginx: `proxy-host-*_access.log`, `proxy-host-*_error.log`, `fallback_http_access.log`, `fallback_http_error.log`, `letsencrypt.log` и др. +- `/opt/docker/nginx-proxy/letsencrypt/` — копия/симлинки сертификатов для контейнера (56 KB); основные сертификаты выпускаются certbot на хосте в `/etc/letsencrypt/live/<домен>/` и при продлении копируются в NPM (custom_ssl) через deploy-hook. + +**Certbot на хосте (внутри CT 100):** +- Установлен в системе, таймер `certbot.timer` (проверка продления дважды в день). +- Учётные данные Beget API: `/root/.secrets/certbot/beget.ini`. +- Deploy-hook’и: `/etc/letsencrypt/renewal-hooks/deploy/` — скрипты `copy-*-to-npm.sh` (video, docs, immich, mini-lm и т.д.) копируют `fullchain.pem` и `privkey.pem` в соответствующий каталог `custom_ssl/npm-/` и делают `docker exec npm nginx -s reload`. + +Подробнее по SSL: [Выпуск сертификата Let's Encrypt (DNS-01)](../network/ssl-letsencrypt-dns01.md). + +**Команды:** +```bash +docker logs npm +docker exec npm nginx -s reload +``` + +--- + +## 2. AdGuard Home + +**Каталог:** `/opt/docker/adguard/` +**Compose:** `docker-compose.yml` (образ `adguard/adguardhome:latest`). + +**Порты:** 53 (DNS TCP/UDP), 67–68 (DHCP при необходимости), 853 (DoT), 3000 (веб-интерфейс). +**Тома:** `./data/work`, `./data/conf` → соответствующие пути в контейнере. +**Сеть:** adguard_proxy_network и proxy_network (доступ с NPM по имени `adguard`). + +**Конфиг:** `/opt/docker/adguard/data/conf/AdGuardHome.yaml` (upstream DNS, кэш, привязки, пользователи и т.д.). Данные и кэш: `data/work/`. + +Доступ в веб: http://192.168.1.100:3000. В NPM настроен proxy на adguard (в т.ч. для adguard.local). Виджет и пароль для Homepage задаются в `services.yaml` (в контейнере). + +**Команды:** +```bash +docker logs adguard +docker restart adguard +``` + +--- + +## 3. Homepage + +**Каталог:** `/opt/docker/homepage/config/` +**Compose:** в `/opt/docker/homepage/docker-compose.yml` (homepage + dockerproxy). + +**Порты:** 4000 (хост) → 3000 (контейнер). +**Тома:** `./config` → `/app/config`, `./config/images` → `/app/public/images`. +**Переменные:** `TZ=Europe/Moscow`, `HOMEPAGE_ALLOWED_HOSTS=home.katykhin.ru`, `DOCKER_HOST=tcp://dockerproxy:2375`. +**Сеть:** proxy_network. + +**Основные файлы конфигурации:** +- `services.yaml` — список сервисов, виджеты (NPM, AdGuard, Proxmox и др.), ссылки, пинги. Пароли и токены виджетов хранятся здесь (не коммитить в открытый репозиторий). +- `docker.yaml` — подключение к Docker через dockerproxy (host/port). +- `settings.yaml`, `widgets.yaml`, `bookmarks.yaml`, `custom.css`, `proxmox.yaml`, `kubernetes.yaml` при необходимости. + +Логи Homepage: `/opt/docker/homepage/config/logs/` (~588 KB). + +**Команды:** +```bash +cd /opt/docker/homepage && docker compose up -d +docker logs homepage +``` + +--- + +## 4. Docker Socket Proxy (dockerproxy) + +Запускается из того же compose, что и Homepage. Даёт Homepage доступ к Docker API только на чтение (CONTAINERS, SERVICES, TASKS, IMAGES, INFO, NETWORKS, VOLUMES; POST=0). Сокет хоста монтируется read-only. Отдельных конфигов нет. + +--- + +## 5. Wallos + +**Каталог:** `/opt/docker/wallos/` +**Compose:** `docker-compose.yml` (образ `bellamy/wallos:latest`). + +**Порты:** 8282 (хост) → 80 (контейнер). +**Тома:** `./db` → `/var/www/html/db`. +**Сеть:** proxy_network. Доступ через NPM: https://wallos.katykhin.ru (с Basic Auth в NPM). + +**Команды:** +```bash +docker logs wallos +docker restart wallos +``` + +--- + +## 6. Log-dashboard (лента обращений NPM) + +Показывает обработанные access-логи NPM в виде ленты с фильтрацией по домену и геолокацией. + +- **Контейнер:** `log-dashboard`, образ `nginx:alpine`, порт **8088** (хост) → 80. +- **Том:** `/opt/docker/log-dashboard/html` → `/usr/share/nginx/html` (только чтение). Контейнер отдаёт статический HTML. +- **Генерация контента:** на **хосте** (внутри CT 100) по крону каждые **15 минут** выполняется: + ```bash + python3 /opt/docker/log-dashboard/gen-dashboard.py /opt/docker/log-dashboard/html/index.html + ``` + Скрипт читает логи из `/opt/docker/nginx-proxy/data/logs/`, парсит access-формат NPM, добавляет геолокацию (ip-api.com, кэш в `ip_cache.json`), пишет результат в `index.html`. +- **Cron:** запись в crontab root: `*/15 * * * * ... gen-dashboard.py ...`. + +Доступ: http://192.168.1.100:8088 (или через NPM, если настроен proxy). Контейнер запускается отдельно (не из общего compose в каталогах выше — при перезагрузке CT нужно проверить, что он поднят). + +--- + +## 7. VPN Route Check + +**Каталог:** `/opt/docker/vpn-route-check/` +**Compose:** `docker-compose.yml` (сборка своего образа, `network_mode: host`). + +Проверяет, идут ли запросы к заданным доменам через VPN или через основное подключение (подключение к роутеру по telnet, разбор маршрутов). Результаты отдаёт на порту **8765** (на хосте). В Homepage добавлена ссылка на http://192.168.1.100:8765. + +**Переменные окружения в compose:** `ROUTER_TELNET_HOST`, `ROUTER_TELNET_USER`, `ROUTER_TELNET_PASSWORD` — **заданы в самом файле** (не в .env). Рекомендация: вынести в `.env` и не коммитить пароль (см. TODO). + +**Том:** volume `vpn-route-check-data` → `/data` (в контейнере). + +**Команды:** +```bash +cd /opt/docker/vpn-route-check && docker compose up -d +docker logs vpn-route-check +``` + +--- + +## Порты (сводка на хосте) + +| Порт | Сервис / примечание | +|-------|----------------------------| +| 80 | NPM (HTTP) | +| 81 | NPM (админка) | +| 443 | NPM (HTTPS) | +| 53 | AdGuard (DNS TCP/UDP) | +| 67–68 | AdGuard (DHCP при необходимости) | +| 853 | AdGuard (DoT) | +| 3000 | AdGuard (веб) | +| 4000 | Homepage | +| 8088 | Log-dashboard | +| 8282 | Wallos | +| 8765 | VPN Route Check (host) | + +--- + +## Логи и ротация + +- **NPM:** логи в `/opt/docker/nginx-proxy/data/logs/`. + - Для файлов **proxy-host-*_access.log** настроен logrotate: `/etc/logrotate.d/npm-access` — ротация при 100 MB, 4 копии, copytruncate, compress. + - Файлы **fallback_http_access.log**, **fallback_http_error.log** и другие **fallback_*** в правиле не указаны** — ротация по размеру/времени для них не настроена, каталог уже ~67 MB. Риск разрастания (см. TODO). +- **Certbot:** `/etc/logrotate.d/certbot` — ротация логов letsencrypt (weekly, 12 копий). +- **AdGuard:** данные и логи в `data/work/` и `data/conf/` (~85 MB). Стоит проверить настройки хранения логов запросов в веб-интерфейсе AdGuard (ограничение по времени/размеру). +- **Homepage:** небольшие логи в `config/logs/`. + +--- + +## Запуск и порядок поднятия + +1. Создать сеть (если ещё нет): `docker network create proxy_network`. +2. NPM: `cd /opt/docker/nginx-proxy && docker compose up -d`. +3. AdGuard: `cd /opt/docker/adguard && docker compose up -d` (создаёт свою сеть и подключается к proxy_network). +4. Homepage (+ dockerproxy): `cd /opt/docker/homepage && docker compose up -d`. +5. Wallos: `cd /opt/docker/wallos && docker compose up -d`. +6. VPN Route Check: `cd /opt/docker/vpn-route-check && docker compose up -d`. +7. Log-dashboard: при необходимости запустить контейнер с монтом html и портом 8088. + +После изменений в NPM (proxy, SSL): перезагрузка nginx внутри контейнера — `docker exec npm nginx -s reload`. Certbot продлевает сертификаты по таймеру; deploy-hook’и копируют их в NPM и перезагружают nginx. + +--- + +## Уязвимости и риски + +1. **Пароли и креды в конфигах:** В `services.yaml` (Homepage) хранятся пароли виджетов (AdGuard, NPM, Proxmox). Файл лежит только на сервере; не помещать в публичный репозиторий. +2. **VPN Route Check:** Логин и пароль роутера прописаны в `docker-compose.yml`. Доступ к compose = доступ к роутеру. Рекомендуется вынести в `.env` и ограничить права на файл. +3. **AdGuard на 3000:** Веб-интерфейс доступен по порту 3000 на хосте. Доступ из LAN; при необходимости закрыть фаерволом снаружи или использовать только через NPM (proxy). +4. **NPM на 81:** Админка NPM по порту 81. Убедиться, что с интернета доступ только через VPN или не пробрасывать 81 наружу. +5. **Логи NPM:** Часть логов (fallback_*) не ротируется — возможен рост и заполнение диска (см. TODO). + +--- + +## TODO по контейнеру 100 + +- [ ] **Логи NPM:** Добавить в logrotate ротацию для `fallback_http_access.log`, `fallback_http_error.log` (и при необходимости других fallback_*) по размеру или по дням, чтобы не забивать диск. +- [ ] **Логи AdGuard:** Проверить в веб-интерфейсе AdGuard настройки хранения логов запросов (срок/размер) и при необходимости ограничить. +- [ ] **VPN Route Check:** Вынести `ROUTER_TELNET_*` в `.env`, подключать в compose через `env_file`, не коммитить .env в репозиторий. +- [ ] **Log-dashboard:** Зафиксировать способ запуска контейнера (отдельный compose или скрипт) и добавить его в документацию/автозапуск при перезагрузке CT. +- [ ] **Мониторинг диска:** Настроить оповещение (например, из Prometheus/Alertmanager или скрипт по крону) при заполнении корня или `/opt/docker` выше порога (например 80%). +- [ ] **Резервное копирование:** Регулярный бэкап критичных папок (оценка размеров на момент документации): + - `/opt/docker/nginx-proxy/data` — ~68 MB (конфиги NPM, БД, логи, custom_ssl). + - `/opt/docker/adguard/data` — ~85 MB (конфиги и данные AdGuard). + - `/opt/docker/homepage/config` — ~0.7 MB (конфиги Homepage). + - `/opt/docker/wallos/db` — ~0.2 MB (БД Wallos). + - `/opt/docker/vpn-route-check` — ~0.1 MB (скрипты и конфиг). + - `/etc/letsencrypt` — ~0.5 MB (структура сертификатов, live/archive). + - `/root/.secrets/certbot` и `/etc/letsencrypt/renewal-hooks/deploy/` — секреты и deploy-hook’и. + Эти размеры небольшие сейчас, но могут расти за счёт логов и числа доменов — учитывать при выборе стратегии бэкапа. + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домены, схема сети. +- [Выпуск сертификата Let's Encrypt (DNS-01)](../network/ssl-letsencrypt-dns01.md) — certbot, Beget API, интеграция с NPM. +- [Роутер Netcraze Speedster](../network/router-netcraze-speedster.md) — VPN и telnet, используемые vpn-route-check. diff --git a/docs/containers/container-101.md b/docs/containers/container-101.md new file mode 100644 index 0000000..c2b974e --- /dev/null +++ b/docs/containers/container-101.md @@ -0,0 +1,164 @@ +# Контейнер 101 (nextcloud): Nextcloud, PostgreSQL, Redis + +Подробное описание LXC-контейнера **101** на Proxmox (192.168.1.101): облачное хранилище Nextcloud за NPM (https://cloud.katykhin.ru), PostgreSQL 16, Redis для кэша и блокировок, внешнее хранилище «Игры». + +--- + +## Общие сведения + +- **Хостнейм:** nextcloud +- **IP:** 192.168.1.101/24 +- **ОС:** Debian 12 (bookworm) +- **Ресурсы:** 2 core, 3 GB RAM (из [архитектуры](../architecture/architecture.md)) +- **Доступ:** с Proxmox — `pct exec 101 -- bash` или по SSH на 192.168.1.101, если настроен. + +Диск контейнера: ~10 GB, занято ~3.8 GB. Основной объём данных — в смонтированных каталогах хоста: `/mnt/nextcloud-data/` (приложение + БД) и `/mnt/nextcloud-extra/` (внешнее хранилище «Игры», порядка нескольких TB). Следить за местом на корне и за логами (см. раздел «Логи» и TODO). + +--- + +## Доступ и логины + +- **Debian (CT 101):** логин `root` (пароль — в менеджере паролей или как настраивал при установке). +- **Nextcloud (веб):** https://cloud.katykhin.ru (через NPM). Логины пользователей — учётные записи Nextcloud (в т.ч. админ); пароли в менеджере паролей или задаются при первом входе. + +--- + +## Сервисы (Docker Compose) + +Один проект: `/opt/nextcloud/docker-compose.yml`. Сеть: **nextcloud_default** (bridge). + +| Сервис | Образ | Порты (хост) | Назначение | +|-----------|--------------------|--------------|------------| +| nextcloud | nextcloud:latest | 8080→80 | Nextcloud (Apache), доступ по https://cloud.katykhin.ru через NPM | +| db | postgres:16 | — | PostgreSQL 16, БД nextcloud | +| redis | redis:7-alpine | — | Redis (кэш, блокировки), appendonly | + +--- + +## 1. Nextcloud + +**Образ:** `nextcloud:latest` (на момент проверки — Nextcloud 32.0.6). +**Порты:** 8080 (хост) → 80 (контейнер). Снаружи доступ только через NPM: https://cloud.katykhin.ru → proxy на 192.168.1.101:8080. + +**Тома:** +- `/mnt/nextcloud-data/html` → `/var/www/html` (код, `config/`, `data/`, приложения). +- `/mnt/nextcloud-extra` → `/mnt/nextcloud-extra` (внутри контейнера; используется для внешнего хранилища «Игры»). +- `/opt/nextcloud/php-uploads.ini` → `/usr/local/etc/php/conf.d/zz-uploads.ini` (лимиты загрузки). + +**Переменные окружения (compose):** +- `NEXTCLOUD_TRUSTED_DOMAINS`: cloud.katykhin.ru 192.168.1.101 +- `OVERWRITEPROTOCOL`, `OVERWRITEHOST`, `OVERWRITECLIURL`: https и cloud.katykhin.ru +- `REDIS_HOST`: redis +- `POSTGRES_*`: хост db, БД nextcloud, пользователь и пароль БД (в compose заданы `nextcloud` / `nextcloud`; в `config.php` приложения может быть другой пользователь БД, заданный при установке). + +**Конфиг приложения:** `/var/www/html/config/config.php` (внутри контейнера; на хосте путь — `/mnt/nextcloud-data/html/config/config.php`). В нём: trusted_domains, overwrite*, redis, datadirectory (`/var/www/html/data`), dbtype pgsql, dbhost/dbname/dbuser/dbpassword, instanceid, passwordsalt, secret и др. Редактировать при необходимости через `occ config:system:set` или правку файла с последующим перезапуском контейнера. + +**PHP (загрузки):** `/opt/nextcloud/php-uploads.ini` на хосте: +- `upload_max_filesize = 64G`, `post_max_size = 64G` +- `memory_limit = 2G`, `max_execution_time = 7200`, `max_input_time = 7200` +- `upload_max_chunk_size` задаётся в config.php (67108864). + +**Внешнее хранилище:** В Nextcloud настроено локальное внешнее хранилище «Игры» (Local): точка монтирования в интерфейсе — «/Игры», каталог на диске — `/mnt/nextcloud-extra/games`. Данные лежат на хосте в `/mnt/nextcloud-extra/` (в т.ч. каталог `games`); объём порядка нескольких TB. + +**Команды:** +```bash +docker exec nextcloud-nextcloud-1 php /var/www/html/occ status +docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:list system +docker exec nextcloud-nextcloud-1 php /var/www/html/occ files_external:list +docker logs nextcloud-nextcloud-1 +docker restart nextcloud-nextcloud-1 +``` + +Обновление приложения (если нужно): через `occ upgrade` или официальную инструкцию Nextcloud; перед этим — бэкап данных и БД. + +--- + +## 2. PostgreSQL (db) + +**Образ:** postgres:16. +**Том:** `/mnt/nextcloud-data/pgdata` → `/var/lib/postgresql/data`. +**Переменные:** `POSTGRES_DB=nextcloud`, `POSTGRES_USER=nextcloud`, `POSTGRES_PASSWORD=nextcloud` (в compose; приложение может подключаться под другим пользователем из config.php). +**Healthcheck:** `pg_isready -U nextcloud`. + +Подключение к БД с хоста CT 101 (для администрирования): +```bash +docker exec -it nextcloud-db-1 psql -U nextcloud -d nextcloud +``` + +--- + +## 3. Redis + +**Образ:** redis:7-alpine. +**Команда:** `redis-server --appendonly yes`. +**Том:** анонимный Docker volume (данные Redis в `/var/lib/docker/volumes/...` на хосте CT 101). Используется Nextcloud для memcache.distributed и memcache.locking; пароль не задан (доступ только внутри сети контейнеров). + +--- + +## Порты + +| Порт | Сервис | Примечание | +|------|----------|------------| +| 8080 | Nextcloud| HTTP внутри LAN; снаружи доступ через NPM (HTTPS cloud.katykhin.ru). | + +PostgreSQL и Redis не проброшены на хост — доступ только из сети nextcloud_default. + +--- + +## Логи и ротация + +- **Nextcloud:** основной лог приложения — `/var/www/html/data/nextcloud.log` (в контейнере; на хосте — `/mnt/nextcloud-data/html/data/nextcloud.log`). На момент проверки файл уже порядка **сотен MB**. Встроенной ротации по размеру/времени в Nextcloud нет; в контейнере и на хосте **logrotate для этого файла не настроен** — риск разрастания и заполнения раздела (см. TODO). +- **PostgreSQL:** логи по умолчанию в stdout (видны через `docker logs nextcloud-db-1`). Отдельного файлового лога и logrotate не проверялось. +- **Docker:** вывод контейнеров — `docker logs`. Ротация логов Docker (json-file) зависит от настроек демона; при необходимости задать max-size/max-file в `/etc/docker/daemon.json`. + +Рекомендуется добавить правило logrotate для `nextcloud.log` (по размеру или по дням) и при необходимости ограничить уровень логирования в Nextcloud. + +--- + +## Запуск и обновление + +Рабочий каталог: `/opt/nextcloud/`. + +```bash +cd /opt/nextcloud +docker compose up -d +docker compose ps +docker compose logs -f nextcloud +``` + +Перед обновлением образов — сделать бэкап БД и каталога данных: +- БД: `docker exec nextcloud-db-1 pg_dump -U nextcloud nextcloud` +- Данные: `/mnt/nextcloud-data/html` (в т.ч. `config/`, `data/`), при необходимости `/mnt/nextcloud-extra`. + +--- + +## Уязвимости и риски + +1. **Пароли в compose:** В `docker-compose.yml` заданы `POSTGRES_PASSWORD: nextcloud` и те же учётные данные в блоке nextcloud. Файл лежит на сервере; не коммитить в публичный репозиторий. При необходимости вынести секреты в `.env`. +2. **Пароль БД в config.php:** В `config/config.php` хранится пароль подключения к PostgreSQL (и другие секреты). Права на файл должны ограничивать чтение (владелец www-data, режим 640 или строже). +3. **Nextcloud доступ по 8080:** Порт 8080 открыт на 0.0.0.0 внутри CT 101. Доступ из интернета только через NPM (HTTPS). Убедиться, что на роутере/фаерволе не пробрасывается 8080 на 192.168.1.101. +4. **Redis без пароля:** Приемлемо, так как Redis не проброшен наружу и доступен только контейнерам в одной сети. При добавлении других стеков в тот же Docker-сеть — учитывать, что Redis доступен без аутентификации. +5. **Логи:** Отсутствие ротации для `nextcloud.log` может привести к заполнению диска (см. TODO). +6. **Квота и внешнее хранилище «Игры»:** после переноса данных с SSD на HDD и смены пути внешнего хранилища (`/mnt/nextcloud-extra/games`) в БД могли остаться старые записи `storage` и кэша (oc_storages/oc_filecache) для прежнего пути. Это приводит к завышенному «Использовано» и постоянному статусу «Ожидается» у «Игры». При будущих переносах хранилища важно очищать устаревшие storages и запускать пересканирование (occ files:scan и files_external:scan), чтобы квота считалась корректно. + +--- + +## TODO по контейнеру 101 + +- [ ] **Ротация nextcloud.log:** Настроить logrotate для `/mnt/nextcloud-data/html/data/nextcloud.log` (или пути на хосте CT 101): ротация по размеру (например 100–200 MB) или по дням, сжатие, ограничение числа копий. Либо включить в Nextcloud логирование в syslog и ротировать его. +- [ ] **Уровень логирования:** В Nextcloud при необходимости снизить уровень лога (loglevel) в config.php или через `occ config:system:set loglevel --value 1` (1 = только ошибки), чтобы уменьшить рост лога. +- [ ] **Резервное копирование:** Регулярный бэкап: + - дамп PostgreSQL (данные в `/mnt/nextcloud-data/pgdata`, сейчас ~2.3 GB); + - каталог `/mnt/nextcloud-data/html` (код, `config/`, `data/` — сейчас ~2.3 GB); + - при необходимости внешнее хранилище `/mnt/nextcloud-extra` (в т.ч. `games` — сейчас ~5.9 TB). + Проверить, что бэкап включает конфиг и что восстановление из дампа и данных проверено. +- [ ] **Мониторинг диска:** Следить за занятостью корня контейнера и раздела, на котором лежат `/mnt/nextcloud-data` и `/mnt/nextcloud-extra`. При необходимости — алерты при достижении порога (например 85%). +- [ ] **Trusted domains:** В config.php на момент проверки указан только `cloud.katykhin.ru`. В compose задан также `192.168.1.101` — при необходимости доступа по IP добавить его в trusted_domains через `occ config:system:set trusted_domains 1 --value 192.168.1.101` (индексы по необходимости скорректировать). +- [ ] **Cron Nextcloud:** Убедиться, что фоновые задачи выполняются (режим cron или AJAX). Проверить: `docker exec nextcloud-nextcloud-1 php /var/www/html/occ background:job:list` или настройки в разделе «Основные» веб-интерфейса. При использовании cron на хосте — добавить задачу вида `*/5 * * * * docker exec nextcloud-nextcloud-1 php /var/www/html/cron.php`. + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домен cloud.katykhin.ru, NPM. +- [Контейнер 100](container-100.md) — NPM, через который открыт доступ к Nextcloud по HTTPS. diff --git a/docs/containers/container-103.md b/docs/containers/container-103.md new file mode 100644 index 0000000..27b5b58 --- /dev/null +++ b/docs/containers/container-103.md @@ -0,0 +1,208 @@ +# Контейнер 103 (Gitea): Gitea, PostgreSQL, act_runner, CouchDB (Obsidian) + +Подробное описание LXC-контейнера **103** на Proxmox (192.168.1.103): Git-сервер Gitea с Actions (runner), база PostgreSQL, CouchDB для синхронизации Obsidian (домен obsidian.katykhin.ru). + +--- + +## Общие сведения + +- **Хостнейм:** gitea +- **IP:** 192.168.1.103/24 +- **ОС:** Debian 12 (bookworm) +- **Ресурсы:** 1 core, 2 GB RAM (из [архитектуры](../architecture/architecture.md)) +- **Доступ:** с Proxmox — `pct exec 103 -- bash` или `ssh` на 192.168.1.103, если настроен. + +Диск контейнера: 15 GB, занято ~2.6 GB. Основной объём — данные Docker (образы, тома Gitea, PostgreSQL, CouchDB). Следить за местом и логами (см. раздел «Логи и ротация» и TODO). + +--- + +## Доступ и логины + +- **Debian (CT 103):** логин `root` (пароль — в менеджере паролей или как настраивал при установке). +- **Gitea (веб):** http://192.168.1.103:3000 (или через NPM по домену git.katykhin.ru, если настроен). Учётные записи — пользователи Gitea. Репозитории могут иметь origin на Gitea; при необходимости пуш в GitHub — отдельный remote (например `github`), команда вида `git push github main`. +- **CouchDB (Obsidian sync):** http://192.168.1.103:5984. Админ (пользователь **obsidian**) и пароль заданы в `/opt/docker/couchdb/local.d/local.ini` (секция `[admins]`); клиент Obsidian подключается по URL и своим учётным данным. + +--- + +## Сервисы (Docker) + +Два независимых набора сервисов: + +1. **Gitea (compose)** — в `/opt/gitea/`: Gitea, PostgreSQL, act_runner. Сеть **gitea_default**. +2. **CouchDB** — запущен отдельным контейнером (без compose в репозитории), данные в `/opt/docker/couchdb/`. + +| Контейнер | Образ | Порты (хост) | Назначение | +|-----------------|--------------------------|------------------|------------| +| gitea | gitea:1.25 | 3000, 2222 | Git-сервер, веб, SSH для Git | +| gitea-db-1 | postgres:16-alpine | — | БД Gitea | +| gitea-runner-1 | gitea/act_runner:latest | — | Gitea Actions (CI) | +| couchdb | couchdb:3 | 5984 | Бэкенд синхронизации Obsidian (obsidian.katykhin.ru) | + +--- + +## 1. Gitea (сервер) + +**Каталог:** `/opt/gitea/` +**Compose:** `docker-compose.yml`. Запуск: `cd /opt/gitea && docker compose up -d`. + +**Порты:** 3000 (HTTP), 2222 (SSH для git clone по SSH). +**Тома:** +- `gitea-data` (volume) → `/data` (внутри: `git/repositories`, `gitea/` — конфиг, логи, сессии, аватары, вложения, indexers, LFS, Actions logs/artifacts). +- `gitea-postgres` → данные PostgreSQL. +- `runner-data` → данные act_runner. + +**Переменные окружения (compose):** +- База: `GITEA__database__*` (postgres, db:5432, user/passwd `gitea`). +- Сервер: `GITEA__server__DOMAIN`, `ROOT_URL` = 192.168.1.103:3000, `SSH_PORT` = 2222. +- Runner: `GITEA_RUNNER_REGISTRATION_TOKEN` из файла `.env` (не коммитить). + +**Конфиг приложения:** внутри тома `gitea-data`, путь в контейнере `/data/gitea/conf/app.ini`. На хосте: `/var/lib/docker/volumes/gitea_gitea-data/_data/gitea/conf/app.ini`. +В нём: репозитории в `/data/git/repositories`, БД postgres (db:5432), LFS, сессии (file), логи в `/data/gitea/log`, `MODE = console` (логи в stdout → Docker). OFFLINE_MODE = true. INTERNAL_TOKEN и LFS_JWT_SECRET заданы в app.ini. + +**Команды:** +```bash +cd /opt/gitea && docker compose up -d +docker logs gitea +docker exec gitea ls -la /data/gitea/conf +``` + +--- + +## 2. PostgreSQL (Gitea) + +**Образ:** postgres:16-alpine. +**Том:** `gitea-postgres` → `/var/lib/postgresql/data`. +**Переменные:** POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB = gitea (заданы в compose). +Healthcheck: pg_isready. Зависимость: Gitea ждёт здоровой БД. + +**Команды:** +```bash +docker exec gitea-db-1 pg_isready -U gitea +docker exec gitea-db-1 psql -U gitea -d gitea -c '\\dt' +``` + +--- + +## 3. Gitea act_runner (Actions) + +**Образ:** gitea/act_runner:latest. +**Тома:** `runner-data` → `/data`; `/var/run/docker.sock` → сокет Docker (для запуска job-контейнеров). +**Переменные:** GITEA_INSTANCE_URL=http://server:3000, GITEA_RUNNER_REGISTRATION_TOKEN из `.env`, GITEA_RUNNER_NAME=gitea-103-runner, GITEA_RUNNER_LABELS=docker:docker://alpine:latest. + +Регистрация runner выполняется при первом запуске по токену из Gitea (Администрирование → Actions → Runners). Токен хранится в `/opt/gitea/.env`. + +**Команды:** +```bash +docker logs gitea-runner-1 +docker restart gitea-runner-1 +``` + +--- + +## 4. CouchDB (Obsidian sync) + +**Образ:** couchdb:3. +**Порты:** 5984 (хост) → 5984 (контейнер). +**Тома (binds):** +- `/opt/docker/couchdb/data` → `/opt/couchdb/data` +- `/opt/docker/couchdb/local.d` → `/opt/couchdb/etc/local.d` + +Контейнер запущен вручную (не из compose в `/opt/gitea`), политика перезапуска `unless-stopped`. После перезагрузки CT Docker поднимает контейнер автоматически. + +**Конфигурация на хосте:** +- `/opt/docker/couchdb/local.d/docker.ini` — базовая секция [couchdb], uuid. +- `/opt/docker/couchdb/local.d/local.ini` — [httpd] enable_cors; [cors] origins=*, credentials, methods, headers; [admins] — пользователь (имя **obsidian**) и хэш пароля (pbkdf2). Пароль админа CouchDB хранится в этом файле; не коммитить и не светить в документации. + +**Смена пароля CouchDB:** через API CouchDB (PUT /_node/_local/_config/admins/obsidian) или пересоздание контейнера с новыми переменными/конфигом. После смены — `docker restart couchdb`. +**CORS:** сейчас origins=* — приемлемо в пределах домашней сети. При выводе доступа через NPM (obsidian.katykhin.ru) можно ограничить origins до конкретного домена. + +**Данные:** БД Obsidian (vault) в `/opt/docker/couchdb/data` (шарды, _dbs.couch, _nodes.couch). Размер порядка десятков MB. + +**Доступ:** Снаружи — http://192.168.1.103:5984. Через NPM: obsidian.katykhin.ru → proxy на 192.168.1.103:5984 (если настроен). Клиент Obsidian Sync подключается к CouchDB по URL и своим учётным данным. + +**Команды:** +```bash +docker logs couchdb +docker restart couchdb +curl -s http://192.168.1.103:5984/ +``` + +**Рекомендация:** Оформить запуск CouchDB в отдельный `docker-compose.yml` в `/opt/docker/couchdb/` для воспроизводимости и документирования (см. TODO). + +--- + +## Порты (сводка на хосте) + +| Порт | Сервис / примечание | +|-------|----------------------------| +| 3000 | Gitea (веб) | +| 2222 | Gitea (SSH для git) | +| 5984 | CouchDB (Obsidian sync) | + +--- + +## Логи и ротация + +- **Gitea:** в app.ini задано `MODE = console`, `ROOT_PATH = /data/gitea/log`. Логи идут в stdout и попадают в драйвер Docker **json-file** без ограничения размера и количества файлов — со временем каталог `/var/lib/docker/containers//*.log` может разрастаться (см. TODO). +- **PostgreSQL:** логи в stdout контейнера, то же хранилище Docker. +- **act_runner:** логи в stdout. +- **CouchDB:** логи в stdout. + +**Системный logrotate** в CT затрагивает только стандартные сервисы (apt, dpkg, btmp, wtmp и т.д.). Отдельных правил для Docker или Gitea нет. Ротация логов контейнеров не настроена — задаётся через `docker-compose` (logging driver options: max-size, max-file) или через `/etc/docker/daemon.json` (default). + +**Риск:** при активном использовании Gitea и Actions логи контейнеров могут заполнить диск. Нужно включить ограничение размера логов Docker (см. TODO). + +--- + +## Запуск и порядок поднятия + +1. **Gitea (compose):** + `cd /opt/gitea && docker compose up -d` + Порядок: db → server (healthcheck) → runner. + +2. **CouchDB:** уже запущен отдельным контейнером. Если после перезагрузки контейнер не поднялся: + ```bash + docker run -d --name couchdb --restart unless-stopped \ + -p 5984:5984 \ + -v /opt/docker/couchdb/data:/opt/couchdb/data \ + -v /opt/docker/couchdb/local.d:/opt/couchdb/etc/local.d \ + couchdb:3 + ``` + (Параметры приведены по текущему inspect; предпочтительно перейти на compose в `/opt/docker/couchdb/`.) + +После смены конфига Gitea (app.ini): перезапуск контейнера — `docker restart gitea`. После смены пароля/пользователя в CouchDB local.ini — `docker restart couchdb`. + +--- + +## Уязвимости и риски + +1. **Пароли в конфигах:** В `docker-compose.yml` (Gitea) заданы пароль БД и креды Gitea (gitea/gitea). Файл лежит только на сервере; не помещать в публичный репозиторий. То же для `/opt/gitea/.env` (GITEA_RUNNER_REGISTRATION_TOKEN). +2. **Пароль админа CouchDB** хранится в `/opt/docker/couchdb/local.d/local.ini` (секция [admins]). Ограничить права на каталог (например chmod 700 local.d, владелец root). +3. **Gitea и CouchDB по IP:** Доступ по 192.168.1.103:3000 и :5984 из LAN. Если нужен доступ снаружи — только через NPM (HTTPS, домены git.katykhin.ru, obsidian.katykhin.ru) и не пробрасывать порты 3000/5984 напрямую в интернет. +4. **Логи Docker:** Ротация не настроена — возможен рост логов и заполнение диска (см. TODO). +5. **CouchDB CORS:** В конфиге включены CORS с origins = * и credentials = true. Достаточно для Obsidian; при расширении использования оценить ограничение origins. + +--- + +## TODO по контейнеру 103 + +- [ ] **Логи Docker:** Включить ограничение размера логов для контейнеров: в `docker-compose.yml` (Gitea) добавить для сервисов `logging: driver: json-file options: max-size: "50m" max-file: "3"` или задать default в `/etc/docker/daemon.json` и перезапустить Docker. После этого перезапустить контейнеры. +- [ ] **CouchDB compose:** Создать `/opt/docker/couchdb/docker-compose.yml` с текущими томами и портами, перейти на `docker compose up -d` вместо ручного `docker run`, зафиксировать в документации. +- [ ] **Домены и NPM:** При необходимости настроить в NPM proxy для git.katykhin.ru → 192.168.1.103:3000 и obsidian.katykhin.ru → 192.168.1.103:5984, выпустить SSL (certbot + deploy в NPM). В Gitea при использовании домена обновить ROOT_URL и DOMAIN в compose/app.ini. +- [ ] **Мониторинг диска:** Следить за местом на корне (df -h). При желании — оповещение при заполнении выше порога (например 80%). +- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации): + - **`/var/lib/docker/volumes/gitea_gitea-data`** — репозитории, конфиг Gitea, логи, сессии, вложения, LFS, Actions (артефакты/логи). Размер: git ~25 MB, gitea (всё остальное) ~сотни MB в зависимости от репо и логов. Бэкапить весь volume или минимум `_data/git`, `_data/gitea/conf`, `_data/gitea/sessions`, при необходимости `_data/gitea/attachments`, `_data/gitea/actions_*`. + - **`/var/lib/docker/volumes/gitea_gitea-postgres`** — БД Gitea. Размер ~71 MB. Для консистентного бэкапа — дамп через `pg_dump` (см. ниже). + - **`/var/lib/docker/volumes/gitea_runner-data`** — данные runner (~8 KB). По желанию. + - **`/opt/gitea`** — docker-compose.yml, .env (секреты). ~12 KB. Обязательно; .env не коммитить. + - **`/opt/docker/couchdb/data`** — данные CouchDB (Obsidian). ~56 MB. + - **`/opt/docker/couchdb/local.d`** — конфиги CouchDB (в т.ч. admins). ~12 KB. Обязательно; не светить в открытом доступе. + + Рекомендуемый способ для PostgreSQL: `docker exec gitea-db-1 pg_dump -U gitea gitea > backup_gitea_$(date +%Y%m%d).sql` и сохранять дамп вне контейнера. Для Gitea data — копирование тома или `tar` по каталогам с остановкой записи (при возможности кратко приостановить Gitea или делать бэкап в момент минимальной активности). + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домены (в т.ч. obsidian.katykhin.ru), схема сети. +- [Контейнер 100 (nginx)](container-100.md) — NPM, через который при необходимости проксируются git.katykhin.ru и obsidian.katykhin.ru. diff --git a/docs/containers/container-104.md b/docs/containers/container-104.md new file mode 100644 index 0000000..5d2facd --- /dev/null +++ b/docs/containers/container-104.md @@ -0,0 +1,158 @@ +# Контейнер 104 (Paperless): Paperless-ngx, PostgreSQL, Redis + +Подробное описание LXC-контейнера **104** на Proxmox (192.168.1.104): архив документов Paperless-ngx (домен docs.katykhin.ru), PostgreSQL 18, Redis 8 для очередей задач. + +--- + +## Общие сведения + +- **Хостнейм:** paperless +- **IP:** 192.168.1.104/24 +- **ОС:** Debian 12 (bookworm) +- **Ресурсы:** 1 core, 2 GB RAM (из [архитектуры](../architecture/architecture.md)) +- **Доступ:** с Proxmox — `pct exec 104 -- bash` или по SSH на 192.168.1.104, если настроен. + +Диск контейнера: ~10 GB, занято ~3.9 GB. Основной объём данных — в каталоге **/mnt/paperless-data/** (ZFS: `tank/subvol-104-disk-0`): данные приложения, медиа (документы), БД PostgreSQL. Плюс образы и тома Docker (~2 GB). Следить за местом и логами (см. раздел «Логи и ротация» и TODO). + +--- + +## Доступ и логины + +- **Debian (CT 104):** логин `root` (пароль — в менеджере паролей или как настраивал при установке). +- **Paperless (веб):** http://192.168.1.104:8000 (или через NPM: https://docs.katykhin.ru, если настроен). Учётные записи — пользователи Paperless (создаются в веб-интерфейсе). + +--- + +## Сервисы (Docker Compose) + +Один проект: **/opt/paperless/docker-compose.yml**. Сеть: **paperless_default** (bridge). + +| Контейнер | Образ | Порты (хост) | Назначение | +|--------------------------|--------------------------|--------------|------------| +| paperless-webserver-1 | paperless-ngx/paperless-ngx:latest | 8000 | Веб-интерфейс, API, OCR, фоновые задачи (Celery) | +| paperless-db-1 | postgres:18 | — | БД Paperless | +| paperless-broker-1 | redis:8 | — | Очередь задач (Celery broker) | + +--- + +## 1. Paperless-ngx (webserver) + +**Каталог:** `/opt/paperless/` +**Compose:** `docker-compose.yml`, переменные из **docker-compose.env** (не коммитить). + +**Порты:** 8000 (хост) → 8000 (контейнер). +**Тома:** +- `/mnt/paperless-data/data` → `/usr/src/paperless/data` (конфиг, индекс, логи, celerybeat-schedule, lock-файлы). +- `/mnt/paperless-data/media` → `/usr/src/paperless/media` (оригиналы и производные документов: `documents/`). +- `./export` → `/usr/src/paperless/export` (экспорт из приложения). +- `./consume` → `/usr/src/paperless/consume` (каталог для автоматического импорта: сюда класть файлы для постановки в очередь). + +**Переменные окружения:** из `docker-compose.env`: PAPERLESS_URL, PAPERLESS_SECRET_KEY, PAPERLESS_TIME_ZONE, PAPERLESS_OCR_LANGUAGE(S). В compose заданы: PAPERLESS_REDIS=redis://broker:6379, PAPERLESS_DBHOST=db. + +**Структура данных на хосте:** +- `/mnt/paperless-data/data/` — `index/` (поисковый индекс), `log/` (celery.log, paperless.log; приложение может создавать rotated файлы celery.log.1, .2 и т.д.), `celerybeat-schedule.db`, `.index_version`, `migration_lock`. +- `/mnt/paperless-data/media/documents/` — подкаталоги с оригинальными и обработанными файлами документов. + +**Команды:** +```bash +cd /opt/paperless && docker compose up -d +docker logs paperless-webserver-1 +docker exec paperless-webserver-1 document_exporter /usr/src/paperless/export --no-input # экспорт +``` + +Импорт: положить файлы (PDF, изображения и т.д.) в `/opt/paperless/consume/` на хосте — Paperless подхватит их и обработает (OCR, теги, корреспонденты настраиваются в веб-интерфейсе). + +--- + +## 2. PostgreSQL (db) + +**Образ:** postgres:18. +**Том:** `/mnt/paperless-data/pgdata` → `/var/lib/postgresql`. +**Переменные:** POSTGRES_DB=paperless, POSTGRES_USER=paperless, POSTGRES_PASSWORD=paperless (в compose). +Зависимость: webserver зависит от db и broker. + +**Команды:** +```bash +docker exec paperless-db-1 pg_isready -U paperless +docker exec paperless-db-1 psql -U paperless -d paperless -c '\dt' +``` + +--- + +## 3. Redis (broker) + +**Образ:** redis:8. +**Том:** volume `redisdata` → `/data` (внутри контейнера). На хосте: `/var/lib/docker/volumes/paperless_redisdata/_data`. +Используется как брокер для Celery (очередь задач OCR и др.). + +**Команды:** +```bash +docker exec paperless-broker-1 redis-cli ping +docker logs paperless-broker-1 +``` + +--- + +## Порты (сводка на хосте) + +| Порт | Сервис / примечание | +|------|----------------------| +| 8000 | Paperless-ngx (веб) | + +--- + +## Логи и ротация + +- **Paperless:** логи приложения в **/mnt/paperless-data/data/log/**: + - `celery.log` — фоновые задачи (OCR, индекс и т.д.); приложение может делать собственную ротацию (celery.log.1, .2, .3). На момент проверки каталог ~568 KB. + - `paperless.log` — основной лог приложения. + Для этих файлов **нет правил logrotate** на хосте — при длительной работе объём может расти (см. TODO). +- **Docker:** драйвер логов контейнеров — **json-file** без ограничения размера и количества файлов. Рост логов контейнеров (stdout/stderr) может заполнять диск. + +**Системный logrotate** в CT — только стандартные правила (apt, dpkg, btmp, wtmp). Отдельных правил для Paperless или Docker нет. + +**Риск:** при большом количестве документов и активной очереди Celery логи в `data/log/` и логи Docker могут разрастаться. Рекомендуется настроить ротацию (см. TODO). + +--- + +## Запуск и порядок поднятия + +1. Убедиться, что смонтирован `/mnt/paperless-data` (ZFS или иной бэкенд). +2. `cd /opt/paperless && docker compose up -d`. + Порядок: broker и db поднимаются первыми, затем webserver (depends_on). + +После смены переменных в `docker-compose.env` или compose: `docker compose up -d` (пересоздание при необходимости). Обновление образа: `docker compose pull && docker compose up -d`. + +--- + +## Уязвимости и риски + +1. **Пароли в конфигах:** В `docker-compose.yml` заданы пароль БД (paperless/paperless). В `docker-compose.env` — PAPERLESS_SECRET_KEY, PAPERLESS_URL и др. Файлы лежат только на сервере; не помещать в публичный репозиторий. Ограничить права на `docker-compose.env` (например chmod 600). +2. **Доступ по порту 8000:** Веб-интерфейс доступен по 192.168.1.104:8000 из LAN. Снаружи доступ только через NPM (https://docs.katykhin.ru). Не пробрасывать 8000 в интернет. +3. **Логи:** Ротация логов Paperless (`data/log/`) и Docker не настроена — возможен рост и заполнение диска (см. TODO). +4. **Каталог consume:** Файлы в `/opt/paperless/consume/` автоматически обрабатываются и удаляются после импорта. Не класть туда единственные копии важных файлов без бэкапа. + +--- + +## TODO по контейнеру 104 + +- [ ] **Логи Paperless:** Настроить logrotate для `/mnt/paperless-data/data/log/*.log`: например еженедельная ротация или по размеру (max 50–100 MB), хранить 3–4 копии, сжатие. Создать `/etc/logrotate.d/paperless` и проверить работу. +- [ ] **Логи Docker:** Включить ограничение размера логов контейнеров: в `docker-compose.yml` добавить для сервисов `logging: driver: json-file options: max-size: "50m" max-file: "3"` или задать default в `/etc/docker/daemon.json` и перезапустить Docker, затем контейнеры. +- [ ] **Домен и NPM:** При необходимости настроить в NPM proxy для docs.katykhin.ru → 192.168.1.104:8000, выпустить SSL. В Paperless в `docker-compose.env` задать PAPERLESS_URL=https://docs.katykhin.ru (если ещё не задан). +- [ ] **Мониторинг диска:** Следить за местом на корне и на `/mnt/paperless-data` (df -h). При желании — оповещение при заполнении выше порога (например 80%). +- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации): + - **`/mnt/paperless-data/data`** — конфиг, индекс, логи, celerybeat-schedule. ~1 MB (логи могут расти). + - **`/mnt/paperless-data/media`** — все документы (оригиналы и производные). ~178 MB (будет расти с новыми документами). + - **`/mnt/paperless-data/pgdata`** — БД PostgreSQL. ~22 MB. Для консистентного бэкапа предпочтительно дамп: `docker exec paperless-db-1 pg_dump -U paperless paperless > backup_paperless_$(date +%Y%m%d).sql`. + - **`/var/lib/docker/volumes/paperless_redisdata`** — данные Redis. ~12 KB. Восстановление не критично (очередь задач), по желанию. + - **`/opt/paperless`** — docker-compose.yml, docker-compose.env (секреты), каталоги consume/export. ~20 KB. Обязательно; docker-compose.env не коммитить. + + Учитывать, что `/mnt/paperless-data` может быть отдельным ZFS-томом (tank/subvol-104-disk-0) — при бэкапе с хоста Proxmox нужно включить этот каталог или снапшот ZFS. + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домен docs.katykhin.ru, схема сети. +- [Контейнер 100 (nginx)](container-100.md) — NPM, через который проксируется docs.katykhin.ru. +- [Paperless + Ollama: поиск по документам](paperless-ollama.md) — интеграция Paperless-ngx с Ollama для вопросов по документам. diff --git a/docs/containers/container-105.md b/docs/containers/container-105.md new file mode 100644 index 0000000..f2b612e --- /dev/null +++ b/docs/containers/container-105.md @@ -0,0 +1,127 @@ +# Контейнер 105 (RAG-service): RAG API (mini-lm) + +Подробное описание LXC-контейнера **105** на Proxmox (192.168.1.105): сервис RAG (Retrieval-Augmented Generation) на базе sentence-transformers, API для семантического поиска и эмбеддингов. Домен: mini-lm.katykhin.ru. + +--- + +## Общие сведения + +- **Хостнейм:** rag-service +- **IP:** 192.168.1.105/24 +- **ОС:** Debian 12 (bookworm) +- **Ресурсы:** 1 core, 1 GB RAM (из [архитектуры](../architecture/architecture.md)) +- **Доступ:** с Proxmox — `pct exec 105 -- bash` или по SSH на 192.168.1.105, если настроен. + +Диск контейнера: ~10 GB, занято **~6.8 GB (74%)**. Основной объём: образ Docker (~4.1 GB), build cache (~2.4 GB, можно освободить `docker builder prune`), данные приложения в `/home/rag-service/data/` (модели ~734 MB, векторы ~2 MB). Следить за местом: при нехватке — почистить build cache и/или ограничить логи Docker (см. TODO). + +--- + +## Доступ и логины + +- **Debian (CT 105):** логин `root` (пароль — в менеджере паролей или как настраивал при установке). +- **RAG API:** http://192.168.1.105:8000 (или через NPM: https://mini-lm.katykhin.ru). Авторизация по заголовку **X-API-Key**; ключ задаётся в `.env` (RAG_API_KEY). При RAG_ALLOW_NO_AUTH=true запросы без ключа допускаются (не рекомендуется снаружи). + +--- + +## Сервисы (Docker Compose) + +Один проект: **/home/rag-service/docker-compose.yml**. Образ собирается локально из **Dockerfile** в том же каталоге. Сеть: **rag-service_default** (bridge). + +| Контейнер | Образ | Порты (хост) | Назначение | +|-------------|--------------------------|--------------|------------| +| rag-service | rag-service-rag-service (build) | 8000 | FastAPI: эмбеддинги, поиск по векторам, health | + +--- + +## 1. RAG-service (приложение) + +**Каталог:** `/home/rag-service/` +**Compose:** `docker-compose.yml`, переменные из **.env** (не коммитить). Образ: `build: context: ., dockerfile: Dockerfile`. + +**Порты:** 8000 (хост) → 8000 (контейнер). +**Тома:** +- `./data/models` → `/app/data/models` (кэш моделей sentence-transformers: all-MiniLM-L12-v2, rubert-base-cased и др.). +- `./data/vectors` → `/app/data/vectors` (файл векторов, например `vectors.npz`; автосохранение по RAG_AUTOSAVE_INTERVAL). + +**Переменные окружения (из .env и compose):** +- Модель: RAG_MODEL (по умолчанию sentence-transformers/all-MiniLM-L12-v2), RAG_CACHE_DIR=/app/data/models. +- Векторы: RAG_VECTORS_PATH=/app/data/vectors/vectors.npz, RAG_MAX_EXAMPLES, RAG_SCORE_MULTIPLIER, RAG_BATCH_SIZE, RAG_MIN_TEXT_LENGTH. +- API: RAG_API_HOST=0.0.0.0, RAG_API_PORT=8000. +- Безопасность: **RAG_API_KEY** (обязателен для продакшена), RAG_ALLOW_NO_AUTH (по умолчанию false). +- Автосохранение: RAG_AUTOSAVE_INTERVAL (секунды). +- Логи: LOG_LEVEL (по умолчанию INFO). + +**Healthcheck:** GET /api/v1/health с заголовком X-API-Key (если RAG_API_KEY задан). interval 30s, start_period 60s. + +**Структура на хосте:** +- `/home/rag-service/data/models/` — подкаталоги вида `models--sentence-transformers--all-MiniLM-L12-v2`, `models--DeepPavlov--rubert-base-cased` (скачанные модели). ~734 MB. +- `/home/rag-service/data/vectors/` — vectors.npz и др. ~2 MB. +- `/home/rag-service/.env` — секреты и настройки. Обязательно бэкапить отдельно, не коммитить. +- Исходный код приложения (app/, Dockerfile, pyproject.toml и т.д.) — в том же homedir; при пересборке образа используется этот контекст. + +**Команды:** +```bash +cd /home/rag-service && docker compose up -d +docker logs rag-service +docker compose build --no-cache # пересборка после изменений кода +curl -s -H "X-API-Key: " http://192.168.1.105:8000/api/v1/health +``` + +--- + +## Порты (сводка на хосте) + +| Порт | Сервис / примечание | +|------|---------------------| +| 8000 | RAG API (веб, API) | + +--- + +## Логи и ротация + +- **RAG-service:** логи приложения идут в **stdout** контейнера (LOG_LEVEL из .env) и попадают в драйвер Docker **json-file** без ограничения размера и количества файлов. +- **Системный logrotate** в CT — только стандартные правила (apt, dpkg, btmp, wtmp). Отдельных правил для RAG или Docker нет. + +**Риск:** при активной работе логи контейнера могут разрастаться и вместе с образом и build cache заполнить диск (уже 74%). Рекомендуется включить ограничение логов Docker и следить за местом (см. TODO). + +--- + +## Запуск и порядок поднятия + +1. Зануться в каталог: `cd /home/rag-service`. +2. При первом запуске или после изменений кода: `docker compose build` (при необходимости `docker compose up -d --build`). +3. Запуск: `docker compose up -d`. + +После смены переменных в `.env`: `docker compose up -d` (пересоздание контейнера при необходимости). После смены кода или Dockerfile: `docker compose build && docker compose up -d`. + +--- + +## Уязвимости и риски + +1. **Секреты в .env:** RAG_API_KEY и прочие переменные хранятся в `/home/rag-service/.env`. Файл не должен попадать в публичный репозиторий. Ограничить права (chmod 600) и владельца. +2. **Доступ по порту 8000:** API доступен по 192.168.1.105:8000 из LAN. Снаружи доступ только через NPM (https://mini-lm.katykhin.ru). Не пробрасывать 8000 в интернет без защиты (API key обязателен при RAG_ALLOW_NO_AUTH=false). +3. **Логи Docker:** Ротация не настроена — возможен рост логов и заполнение диска (см. TODO). +4. **Мало места на диске (74%):** Образ ~4 GB, build cache ~2.4 GB. При нехватке места: `docker builder prune` (освободит кэш сборки), при необходимости увеличить диск контейнера или перенести данные моделей на отдельный том. +5. **Ресурсы:** В compose закомментированы deploy.resources (limits/reservations). При 1 GB RAM контейнера возможны OOM при тяжёлых моделях или батчах; при необходимости увеличить память CT или выставить limits в compose. + +--- + +## TODO по контейнеру 105 + +- [ ] **Логи Docker:** Включить ограничение размера логов: в `docker-compose.yml` добавить для сервиса `logging: driver: json-file options: max-size: "50m" max-file: "3"` или задать default в `/etc/docker/daemon.json`, перезапустить Docker и контейнер. +- [ ] **Мониторинг диска:** Следить за местом (df -h). Уже 74% — при достижении 85%+ выполнить `docker builder prune` и/или оценить увеличение диска. При желании — оповещение при заполнении выше порога. +- [ ] **Домен и NPM:** При необходимости настроить в NPM proxy для mini-lm.katykhin.ru → 192.168.1.105:8000, выпустить SSL. В клиентах использовать https и передавать X-API-Key. +- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации): + - **`/home/rag-service/data/models`** — кэш моделей (sentence-transformers, rubert и др.). ~734 MB. Восстановление: при потере модели скачаются заново при первом запросе, но бэкап ускоряет восстановление. + - **`/home/rag-service/data/vectors`** — векторы (vectors.npz). ~2 MB. Важно бэкапить, если векторы содержат уникальные данные и не воссоздаются автоматически. + - **`/home/rag-service/.env`** — секреты и настройки. Обязательно; не коммитить. + - **`/home/rag-service/docker-compose.yml`**, **Dockerfile**, при необходимости весь каталог **app/** и конфиги (pyproject.toml, env.example и т.д.) — для воспроизведения сборки. Размер кода порядка мегабайт. + + Образ Docker бэкапить не обязательно (собирается из Dockerfile); при восстановлении на новом хосте: скопировать данные и код, задать .env, выполнить `docker compose build && docker compose up -d`. + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домен mini-lm.katykhin.ru, схема сети. +- [Контейнер 100 (nginx)](container-100.md) — NPM, через который проксируется mini-lm.katykhin.ru. diff --git a/docs/containers/container-107.md b/docs/containers/container-107.md new file mode 100644 index 0000000..1a27f53 --- /dev/null +++ b/docs/containers/container-107.md @@ -0,0 +1,183 @@ +# Контейнер 107 (Invidious): Invidious, Companion, PostgreSQL + +Подробное описание LXC-контейнера **107** на Proxmox (192.168.1.107): Invidious (альтернативный фронтенд YouTube), Invidious Companion и PostgreSQL 14. Домен: video.katykhin.ru. + +--- + +## Общие сведения + +- **Хостнейм:** misc +- **IP:** 192.168.1.107/24 +- **ОС:** Debian 12 (bookworm) +- **Ресурсы:** 1 core, 2 GB RAM (из [архитектуры](../architecture/architecture.md)) +- **Доступ:** с Proxmox — `pct exec 107 -- bash` или по SSH на 192.168.1.107, если настроен. + +Диск контейнера: 15 GB, занято ~2.1 GB (16%). Основной объём: образы и тома Docker (~0.8 GB), код Invidious в `/opt/invidious/` (~11 MB), данные PostgreSQL (~51 MB). Запас по месту большой, но при росте логов и БД всё равно стоит следить за заполнением (см. раздел «Логи и ротация» и TODO). + +--- + +## Доступ и логины + +- **Debian (CT 107):** логин `root` (пароль — в менеджере паролей или как настраивал при установке). +- **Invidious (веб):** http://192.168.1.107:3000 (или через NPM: https://video.katykhin.ru). Пользовательские аккаунты и настройки создаются в самом Invidious. + +--- + +## Сервисы (Docker Compose) + +Один проект: **/opt/invidious/docker-compose.yml** (внутри git‑репозитория Invidious: `/opt/invidious/`). Сеть: **invidious_default** (bridge). + +| Контейнер | Образ | Порты (хост) | Назначение | +|--------------------------|------------------------------------------|--------------|------------| +| invidious-invidious-1 | quay.io/invidious/invidious:latest | 3000 | Веб-интерфейс Invidious, API | +| invidious-companion-1 | quay.io/invidious/invidious-companion | — (8282 внутри) | Companion‑сервис для запросов к YouTube | +| invidious-invidious-db-1 | postgres:14 | — | БД Invidious | + +--- + +## 1. Invidious (основной сервис) + +**Каталог:** `/opt/invidious/` +**Compose:** `docker-compose.yml` (лежит в корне репозитория). + +**Порты:** 3000 (хост) → 3000 (контейнер). NPM (контейнер 100) проксирует https://video.katykhin.ru → 192.168.1.107:3000. + +**Тома и конфиги:** +- Invidious не использует отдельные bind‑тома для конфигов/данных — данные хранятся в PostgreSQL (`invidious_postgresdata`), а конфиг задаётся через переменную `INVIDIOUS_CONFIG` в compose (inline YAML). +- Отдельных каталогов с логами Invidious на хосте нет — логи идут в stdout контейнера (см. раздел «Логи и ротация»). + +**Основная конфигурация (в docker-compose.yml, секция `environment / INVIDIOUS_CONFIG`):** +- `db`: dbname=invidious, user=kemal, password=kemal, host=invidious-db, port=5432, check_tables=true. +- `invidious_companion`: URL сервиса companion (`http://companion:8282/companion`). +- `invidious_companion_key` и `SERVER_SECRET_KEY` (в companion) — общий секрет между Invidious и Companion (сейчас заданы прямо в compose; **не выкладывать в публичный репозиторий**). +- `external_port: 443`, `domain: "video.katykhin.ru"`, `https_only: true` — Invidious знает про внешний домен и порт, отдаёт ссылки на https. +- Прочие опции (feeds, captions, hmac_key, default_user_preferences и т.д.). + +**Команды:** +```bash +cd /opt/invidious && docker compose up -d +docker logs invidious-invidious-1 +curl -s http://127.0.0.1:3000/api/v1/stats +``` + +--- + +## 2. Invidious Companion + +**Образ:** `quay.io/invidious/invidious-companion:latest`. +**Порты:** 8282 (внутри docker-сети). НСЛУШАЕТ напрямую на хосте; Invidious обращается к нему по имени `companion` в сети `invidious_default`. + +**Тома:** +- volume `companioncache` → `/var/tmp/youtubei.js` (кэш js‑ресурсов YouTube / youtubei). + +**Безопасность:** +- `SERVER_SECRET_KEY` совпадает с `invidious_companion_key` в конфиге Invidious — это shared secret для обмена. +- Контейнер запущен с `read_only: true`, `cap_drop: [ALL]`, `no-new-privileges:true` — хорошая практика sandboxing. + +**Команды:** +```bash +docker logs invidious-companion-1 +``` + +--- + +## 3. PostgreSQL (invidious-db) + +**Образ:** postgres:14. +**Том:** volume `postgresdata` → `/var/lib/postgresql/data`. На хосте: `/var/lib/docker/volumes/invidious_postgresdata/_data` (~51 MB). +**Дополнительные mounts:** +- `/opt/invidious/config/sql` → `/config/sql` — SQL‑скрипты инициализации/миграций из репозитория Invidious (~40 KB). +- `/opt/invidious/docker/init-invidious-db.sh` → `/docker-entrypoint-initdb.d/init-invidious-db.sh` — скрипт инициализации БД при первом запуске. + +**Переменные окружения:** POSTGRES_DB=invidious, POSTGRES_USER=kemal, POSTGRES_PASSWORD=kemal (заданы в compose; не публиковать). + +**Команды:** +```bash +docker exec invidious-invidious-db-1 pg_isready -U kemal +docker exec invidious-invidious-db-1 psql -U kemal -d invidious -c '\\dt' +``` + +--- + +## Порты (сводка на хосте) + +| Порт | Сервис / примечание | +|------|--------------------------| +| 3000 | Invidious (веб, API) | + +Companion и PostgreSQL доступны только внутри docker-сети `invidious_default`. + +--- + +## Логи и ротация + +- **Invidious и Companion:** логи идут в stdout контейнеров и сохраняются драйвером Docker **json-file**. В compose для `invidious` и `companion` заданы опции: + - `max-size: "1G"`, `max-file: "4"` — максимум ~4 GB логов на контейнер. На момент проверки общий размер `/var/lib/docker` ~774 MB, запас по логам большой. +- **PostgreSQL:** контейнер `invidious-db` использует json-file **без ограничений** (`Config:{}`) — логи БД могут разрастаться (см. TODO). + +**Системный logrotate** в CT — только стандартные правила (apt, dpkg, btmp, wtmp). Отдельных правил для Docker или PostgreSQL нет. + +**Вывод:** для Invidious и Companion уже есть базовая защита от разрастания логов (1G×4), но для БД и общего Docker‑движка ротация не настроена централизованно. + +--- + +## Запуск и порядок поднятия + +1. Зайти в каталог: `cd /opt/invidious`. +2. Проверить/при необходимости подредактировать `docker-compose.yml` (секция `INVIDIOUS_CONFIG`, домен video.katykhin.ru, секреты). +3. Запуск/перезапуск: + ```bash + docker compose up -d + ``` + Порядок: сначала поднимается `invidious-db`, затем `invidious` (depends_on с healthcheck), параллельно Companion. + +После изменения конфигурации (секция `INVIDIOUS_CONFIG` или окружения Companion/DB): +`cd /opt/invidious && docker compose up -d` — конфигурация применяется при перезапуске контейнеров. + +--- + +## Уязвимости и риски + +1. **Секреты и пароли в docker-compose.yml:** + В `INVIDIOUS_CONFIG` заданы: + - пароли БД (user/password kemal), + - `invidious_companion_key`, + - `hmac_key` и др. чувствительные значения. + Файл лежит только на сервере; его нельзя публиковать. Рекомендуется: + - ограничить права на `/opt/invidious/docker-compose.yml` (например chmod 600, владелец root), + - при переносе конфигурации использовать приватные репозитории или отдельные `.env`/секреты. +2. **PostgreSQL логи без лимитов:** + Контейнер `invidious-db` использует json-file без max-size/max-file — логи БД со временем могут занять существенный объём, особенно при ошибках или verbose‑режиме (см. TODO). +3. **Доступ к Invidious:** + - В LAN сервис доступен по http://192.168.1.107:3000. + - Наружу следует пускать только через NPM (https://video.katykhin.ru), без прямого проброса порта 3000 на интернет. +4. **Обновления Invidious:** + В репозитории `/opt/invidious` лежит копия официального кода. Образ используется `latest` — при обновлении нужно внимательно следить за совместимостью БД и параметров, и перед апдейтом делать бэкап БД (см. TODO по бэкапам). + +--- + +## TODO по контейнеру 107 + +- [ ] **Логи PostgreSQL:** Добавить ограничение логов для контейнера `invidious-invidious-db-1`: + либо через `logging` в `docker-compose.yml` (аналогично `invidious`/`companion`, но с меньшим лимитом, например `max-size: "200m"`, `max-file: "5"`), либо через глобальный `/etc/docker/daemon.json`. После изменения — перезапустить контейнер. +- [ ] **Права на конфиги:** Ограничить доступ к `docker-compose.yml` и, при необходимости, к другим файлам с секретами (chmod 600, владелец root), убедиться, что эти файлы не попадают в публичные репозитории. +- [ ] **Мониторинг диска:** Сейчас используется ~2.1 GB из 15 GB, но при росте логов/БД стоит настроить оповещение при заполнении >80% и периодически проверять `du -sh /var/lib/docker /var/lib/docker/volumes/invidious_postgresdata/_data`. +- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации): + - **`/var/lib/docker/volumes/invidious_postgresdata/_data`** — БД Invidious (~51 MB). + Рекомендуемый способ — логический дамп: + ```bash + docker exec invidious-invidious-db-1 pg_dump -U kemal invidious > backup_invidious_$(date +%Y%m%d).sql + ``` + и хранить дампы вне контейнера (на Proxmox‑хосте/внешнем хранилище). + - **`/opt/invidious`** — код Invidious, `docker-compose.yml`, `config/sql`, `docker/init-invidious-db.sh` (~11 MB). + Важно сохранить локальные правки (особенно секцию `INVIDIOUS_CONFIG` и домен/HTTPS‑настройки). + - **`/var/lib/docker/volumes/invidious_companioncache/_data`** — кэш Companion (~4 KB). Бэкап не критичен; при потере восстановится автоматически. +- [ ] **Документация по обновлению:** Зафиксировать отдельной заметкой процедуру апдейта Invidious (pull нового образа, миграции, проверка конфигов и обратимости через бэкап БД). + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домен video.katykhin.ru, схема сети. +- [Контейнер 100 (nginx)](container-100.md) — NPM, через который проксируется video.katykhin.ru. + diff --git a/docs/containers/container-108.md b/docs/containers/container-108.md new file mode 100644 index 0000000..bb7002d --- /dev/null +++ b/docs/containers/container-108.md @@ -0,0 +1,136 @@ +# Контейнер 108 (Galene): Galene видеоконференции + +Подробное описание LXC-контейнера **108** на Proxmox (192.168.1.108): сервер видеоконференций Galene (домен call.katykhin.ru). STUN/TURN вынесены на внешний VPS (coTURN), в контейнере — только Galene как systemd-сервис, без Docker. + +--- + +## Общие сведения + +- **Хостнейм:** galene +- **IP:** 192.168.1.108/24 +- **ОС:** Debian 12 (bookworm) +- **Ресурсы:** 1 core, 256 MB RAM (из [архитектуры](../architecture/architecture.md)) +- **Доступ:** с Proxmox — `pct exec 108 -- bash` или по SSH на 192.168.1.108, если настроен. + +Диск контейнера: ~4 GB, занято ~1.2 GB (33%). Основной объём: система, код Galene в `/opt/galene/` (~6.6 MB), данные в `/opt/galene-data/` (~1.7 MB). Логи — в systemd journal (~8 MB). Следить за местом и ротацией journal (см. раздел «Логи и ротация» и TODO). + +--- + +## Доступ и логины + +- **Debian (CT 108):** логин `root`, пароль `Galene108!`. +- **Galene (веб):** https://call.katykhin.ru (через NPM → 192.168.1.108:8443). Вход в группы — по паролям, заданным в конфигах групп в `/opt/galene-data/groups/` (операторы и участники). + +--- + +## Сервисы (systemd, без Docker) + +| Сервис | Назначение | Порт (хост) | +|------------------|--------------------------------------------------|-------------| +| galene.service | Galene Videoconference Server (galene-server) | 8443 (HTTP) | +| coturn | Не запущен (disabled) — TURN на внешнем VPS | — | +| ssh, cron, postfix@- | Стандартные сервисы CT | 22, 25 | + +Galene слушает на **0.0.0.0:8443** по HTTP (`-insecure`). TLS обеспечивает NPM на контейнере 100 (https://call.katykhin.ru → proxy на 192.168.1.108:8443). + +--- + +## 1. Galene (galene.service) + +**Unit:** `/etc/systemd/system/galene.service` +**Бинарник:** `/opt/galene-server` (собран из исходников в `/opt/galene/`). +**Рабочий каталог:** `/opt/galene-data`. +**Параметры запуска:** `-http 0.0.0.0:8443 -insecure -turn "" -static /opt/galene-data/static` + +- `-insecure` — HTTP без TLS (TLS на NPM). +- `-turn ""` — встроенный TURN не используется; ICE-серверы заданы в `data/ice-servers.json` (внешний coTURN на VPS). +- Статика отдаётся из `/opt/galene-data/static`. + +**Структура данных на хосте:** + +- **`/opt/galene-data/`** — данные и конфиги: + - **`data/`** — глобальные настройки: + - `config.json` — proxyURL (например https://call.katykhin.ru/). + - `ice-servers.json` — список STUN/TURN (URL, username, credential для TURN на VPS). **Содержит креды TURN — не публиковать.** + - `cert.pem`, `key.pem` — локальные сертификаты (для внутренних нужд; внешний HTTPS через NPM). + - **`groups/`** — конфиги групп (по одному JSON на группу): имя файла вида `-.json`. В каждом файле: пользователи и пароли (операторы/участники), `wildcard-user`, `max-clients`, `allow-recording`, `autolock` и т.д. **Пароли пользователей групп хранятся в этих файлах — не публиковать.** + - **`static/`** — копия/кастомизация статики из `/opt/galene/static/` (HTML, CSS, JS для веб-клиента). + +- **`/opt/galene/`** — исходный код Galene (репозиторий), бинарник `galene-server` собирается отсюда. При обновлении: пересборка и перезапуск сервиса. + +**Команды:** +```bash +systemctl status galene +systemctl restart galene +journalctl -u galene.service -f +curl -s -k https://192.168.1.108:8443/ # или через NPM: https://call.katykhin.ru/ +``` + +--- + +## 2. coTURN (не используется в CT) + +Сервис **coturn** в контейнере установлен, но **disabled и inactive**. STUN/TURN для Galene вынесены на внешний сервер (VPS Миран: 185.147.80.190, см. [VPS Миран: боты и STUN/TURN](vps-miran-bots.md)). Клиенты получают список STUN/TURN из `ice-servers.json` в `/opt/galene-data/data/`. + +При необходимости поднять TURN локально — настроить coturn и указать его в `ice-servers.json` или в параметрах galene-server. + +--- + +## Порты (сводка на хосте) + +| Порт | Сервис / примечание | +|------|----------------------------| +| 8443 | Galene (HTTP, TLS на NPM) | +| 22 | SSH | +| 25 | Postfix (localhost) | + +--- + +## Логи и ротация + +- **Galene:** логи пишутся в **stdout/stderr** и попадают в **systemd journal** (journalctl -u galene.service). Отдельных файловых логов в `/opt/galene-data/` нет. На момент проверки журнал занимает ~8 MB. +- **Ротация journal:** управляется настройками journald (например `/etc/systemd/journald.conf`: SystemMaxUse, MaxFileSec). Отдельного правила logrotate для Galene нет — ротация только за счёт journald. При долгой работе и активном трафике журнал может расти (см. TODO). +- **Системный logrotate** в CT — стандартные правила (apt, dpkg, btmp, wtmp). Для Galene отдельного файла логов нет. + +**Риск:** при большом количестве подключений и ошибок объём journal может вырасти. Рекомендуется ограничить размер журнала в journald.conf и при желании настроить выгрузку логов Galene в файл с logrotate (см. TODO). + +--- + +## Запуск и порядок поднятия + +1. Убедиться, что доступны каталоги `/opt/galene` и `/opt/galene-data` и в `data/` есть `config.json`, `ice-servers.json`. +2. Запуск сервиса: `systemctl start galene` (или при загрузке CT — `systemctl enable galene` уже выполнен, т.к. сервис активен). +3. NPM на контейнере 100 должен проксировать https://call.katykhin.ru на 192.168.1.108:8443. + +После смены конфигов в `/opt/galene-data/data/` или в `groups/`: `systemctl restart galene`. После обновления кода в `/opt/galene/` — пересобрать бинарник, заменить `/opt/galene-server` и перезапустить сервис. + +--- + +## Уязвимости и риски + +1. **Пароли и креды в конфигах:** В `/opt/galene-data/data/ice-servers.json` хранится TURN credential. В каждом файле в `/opt/galene-data/groups/*.json` — пароли пользователей (операторы, участники). Эти каталоги не должны попадать в публичный репозиторий. Ограничить права (например chmod 600 на файлы с паролями), хранить бэкапы в защищённом месте. +2. **Доступ по порту 8443:** Galene слушает на всех интерфейсах (0.0.0.0:8443). Из LAN доступ по http://192.168.1.108:8443. Снаружи доступ только через NPM (https://call.katykhin.ru). Не пробрасывать 8443 в интернет напрямую. +3. **Логи в journal:** Ротация только через journald; при необходимости ограничить SystemMaxUse и проверить, что старые логи не заполняют диск (см. TODO). +4. **Мало RAM (256 MB):** Контейнер с небольшим объёмом памяти; при большом числе одновременных участников возможны проблемы. При необходимости увеличить память CT в Proxmox. + +--- + +## TODO по контейнеру 108 + +- [ ] **Ротация journal:** Проверить/задать в `/etc/systemd/journald.conf` параметры `SystemMaxUse=`, `MaxFileSec=` (или аналог), чтобы журнал не разрастался бесконечно. После изменений — `systemctl restart systemd-journald` (учёт потери текущего лога при рестарте). +- [ ] **Права на конфиги:** Ограничить доступ к файлам с паролями и кредами: `chmod 600` на `ice-servers.json`, на файлы в `groups/`. Владелец root. +- [ ] **Мониторинг диска:** Следить за местом (df -h) и размером журнала. При желании — оповещение при заполнении выше порога (например 80%). +- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации): + - **`/opt/galene-data`** — данные и конфиги: `data/` (config.json, ice-servers.json, cert.pem, key.pem), `groups/` (все JSON групп), `static/` при наличии кастомизаций. ~1.7 MB. **Обязательно;** содержит пароли и TURN credential — хранить бэкапы в защищённом месте, не публиковать. + - **`/opt/galene`** — исходный код и бинарник (или только скрипты сборки и версию). ~6.6 MB. Нужен для воспроизведения сборки galene-server; при использовании upstream репозитория можно восстанавливать из git, но локальные патчи стоит бэкапить. + - **`/etc/systemd/system/galene.service`** — unit-файл. Небольшой размер; включить в бэкап конфигов системы. + + Восстановление: скопировать `/opt/galene-data` и при необходимости `/opt/galene`, установить unit, выполнить `systemctl daemon-reload && systemctl start galene`. TURN на VPS должен быть доступен и креды в `ice-servers.json` совпадать с настройками coTURN. + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров, домен call.katykhin.ru, схема сети. +- [Контейнер 100 (nginx)](container-100.md) — NPM, через который проксируется call.katykhin.ru. +- [VPS Миран (СПБ): боты и STUN/TURN](vps-miran-bots.md) — внешний coTURN для Galene (STUN/TURN сервер). diff --git a/docs/containers/container-200.md b/docs/containers/container-200.md new file mode 100644 index 0000000..a78c802 --- /dev/null +++ b/docs/containers/container-200.md @@ -0,0 +1,241 @@ +# ВМ 200 (Immich): Immich, PostgreSQL, Redis, ML, deduper + +Подробное описание **KVM-ВМ 200** на Proxmox (192.168.1.200): хост для Immich (фото/видео), PostgreSQL, Redis (Valkey), Immich Machine Learning (CUDA), upload-optimizer, power-tools, public-proxy и отдельный стек immich-deduper + Qdrant. Домен: immich.katykhin.ru. Данные и Docker размещены на отдельном диске `/mnt/data` (350 GB). + +--- + +## Общие сведения + +- **Тип:** KVM-виртуальная машина (управление: `qm` на Proxmox). +- **Имя ВМ:** immich +- **IP:** 192.168.1.200/24 +- **ОС:** Debian 13 (trixie) +- **Ресурсы:** 3 core, 10 GB RAM, GPU (hostpci0 для VGA; ML-контейнер с NVIDIA). +- **Доступ:** SSH под пользователем **admin** (не root): `ssh admin@192.168.1.200` с хоста 192.168.1.150 или из LAN. Для выполнения команд с правами root: `sudo ...`. + +**Диски:** +- **Корневой диск** (sda1): 35 GB, занято **~29 GB (87%)** — система, образы/кэш в пределах корня. **Критично:** мало свободного места; при росте логов или обновлениях возможны сбои. Следить за местом и логированием (см. TODO). +- **Данные** (sdb1): 344 GB, смонтирован в **/mnt/data**, занято ~177 GB (55%). Здесь: библиотека Immich, БД PostgreSQL, Docker root, containerd, Ollama, данные deduper. + +--- + +## Доступ и логины + +- **ВМ (Debian):** пользователь **admin** (вход по SSH-ключу или паролю; пароль задан при cloud-init, хранить в менеджере паролей). Root через `sudo`. +- **Immich (веб):** учётные записи — пользователи Immich (создаются в веб-интерфейсе). Доступ к сервисам: + +| Сервис | Доступ | Адрес | +|--------------|---------------------|--------| +| Immich | Публичный (NPM) | https://immich.katykhin.ru | +| Power Tools | Публичный (Basic Auth в NPM) | https://immich-pt.katykhin.ru | +| Public Share | Публичный (NPM) | https://share.katykhin.ru | +| Deduper | Только LAN | http://192.168.1.200:8086 | + +Логины и пароли Power Tools / Immich — в менеджере паролей; в открытую документацию не вносить. + +--- + +## Сервисы (Docker Compose) + +Два проекта: + +1. **Immich** — `/opt/immich/docker-compose.yml`. Сеть **immich_default**; дополнительно сеть **immich-deduper** (external) для доступа postgres к deduper. +2. **immich-deduper** — `/opt/immich-deduper/docker-compose.yml`. Сеть **immich-deduper** (external), общая с Immich для доступа к БД. + +| Контейнер | Образ / тег | Порты (хост) | Назначение | +|---------------------------|-------------------------------------------------------|------------------|------------| +| immich_upload_optimizer | miguelangel-nubla/immich-upload-optimizer:latest | 2283 | Прокси перед Immich (оптимизация загрузок); NPM смотрит сюда | +| immich_server | immich-app/immich-server:v2 | 2284→2283 | Основное приложение Immich | +| immich_postgres | immich-app/postgres:14-vectorchord... | — | PostgreSQL с расширениями (pgvector и др.) | +| immich_redis | valkey/valkey:9 | — | Кэш/очереди | +| immich_machine_learning | immich-app/immich-machine-learning:v2-cuda | — | ML (распознавание и т.д.), с GPU | +| immich_power_tools | varun-raj/immich-power-tools:latest | 8001→3000 | Дополнительные утилиты | +| immich_public_proxy | alangrainger/immich-public-proxy:latest | 8501→3000 | Публичный прокси к Immich | +| immich-deduper | razgrizhsu/immich-deduper:latest-cpu | 8086 | Поиск дубликатов | +| immich-deduper-qdrant | qdrant/qdrant:v1.16.3 | — | Векторная БД для deduper | + +--- + +## 1. Immich (основной стек) + +**Каталог:** `/opt/immich/` +**Compose:** `docker-compose.yml`, переменные из **.env** (не коммитить). + +**Порты:** Внешний доступ в NPM настроен на **2283** (upload_optimizer). Дополнительно открыты: 2284 (server), 8001 (power-tools), 8501 (public-proxy). + +**Пути из .env (на хосте):** +- **UPLOAD_LOCATION** — каталог загруженных файлов (фото/видео). На момент проверки: `/mnt/data/library` (~148 GB). +- **DB_DATA_LOCATION** — данные PostgreSQL. На момент проверки: `/mnt/data/postgres` (~657 MB). + +**Тома (по умолчанию из .env):** +- `${UPLOAD_LOCATION}` → `/data` в immich_server (и в upload_optimizer через upstream). +- `${DB_DATA_LOCATION}` → `/var/lib/postgresql/data` в immich_postgres. +- model-cache (volume) → кэш моделей ML в immich_machine_learning. +- /etc/localtime — для времени. + +**Переменные окружения (.env):** DB_PASSWORD, DB_USERNAME, DB_DATABASE_NAME, DB_HOST, DB_PORT, IMMICH_URL, IMMICH_API_KEY, EXTERNAL_IMMICH_URL, GEMINI_API_KEY (и др.). Файл содержит секреты — не публиковать, ограничить права (chmod 600). + +**Команды:** +```bash +cd /opt/immich && docker compose up -d +docker logs immich_server +docker logs immich_upload_optimizer +``` + +--- + +## 2. Immich Machine Learning + +Использует образ с **CUDA** и `deploy.resources.reservations.devices` (nvidia, count: 1). Кэш моделей в volume **model-cache**. Контейнер не публикует порты; общается с immich_server по внутренней сети. + +--- + +## 3. Upload optimizer, power-tools, public-proxy + +- **immich_upload_optimizer:** принимает трафик на 2283, проксирует на immich-server:2283. NPM проксирует https://immich.katykhin.ru на 192.168.1.200:2283. +- **power-tools:** веб-интерфейс на порту 8001 (внутри LAN). +- **immich_public_proxy:** порт 8501, прокси к immich-server:2283 (для публичных ссылок и т.п.). + +--- + +## 4. PostgreSQL и Redis + +- **immich_postgres:** данные в `/mnt/data/postgres`. Healthcheck включён. Подключение из deduper через сеть immich-deduper (database подключён к default и immich-deduper). +- **immich_redis:** Valkey 9, без постоянного тома в стандартной конфигурации (при перезапуске кэш сбрасывается). + +--- + +## 5. immich-deduper + +**Каталог:** `/opt/immich-deduper/` +**Compose:** `docker-compose.yml`, переменные из **.env**. + +**Порты:** 8086 (хост). + +**Тома (из .env):** +- **DEDUP_DATA** — каталог данных deduper (на момент проверки: `/opt/immich-deduper/data`, ~231 MB с учётом всего каталога). Внутри: кэш и данные Qdrant (`DEDUP_DATA/qdrant`). +- **IMMICH_PATH** — путь к библиотеке Immich только для чтения (`/mnt/data/library`). +- Подключение к БД Immich через переменные PSQL_* и сеть immich-deduper. + +**Команды:** +```bash +cd /opt/immich-deduper && docker compose up -d +docker logs immich-deduper +``` + +--- + +## Порты (сводка на хосте) + +| Порт | Сервис / примечание | +|-------|----------------------------------------| +| 2283 | Upload optimizer (основной вход для NPM) | +| 2284 | Immich server (прямой доступ) | +| 8001 | Power-tools | +| 8501 | Public proxy | +| 8086 | immich-deduper | + +--- + +## Расширение диска данных (без даунтайма) + +Диск данных ВМ (sdb1, /mnt/data) можно увеличить на Proxmox без остановки Immich. Пример: с 350 GB до 700 GB. + +**1. На хосте Proxmox (192.168.1.150):** увеличить виртуальный диск: +```bash +qm resize 200 scsi1 +350G +``` + +**2. Внутри ВМ (ssh admin@192.168.1.200):** расширить раздел: +```bash +sudo growpart /dev/sdb 1 +``` + +**3. Внутри ВМ:** расширить файловую систему (онлайн, без размонтирования): +```bash +sudo resize2fs /dev/sdb1 +``` + +Операция занимает порядка 30 секунд; перезагрузка не требуется. + +--- + +## Включение Machine Learning и Smart Search + +После загрузки фото можно включить ML (распознавание, смарт-поиск). Предусловия: GPU passthrough в ВМ настроен и работает (`nvidia-smi` внутри ВМ), в compose для `immich-machine-learning` заданы `runtime: nvidia` и `deploy.resources.reservations.devices` (nvidia). + +1. **Включить ML через API** (подставить свой API-ключ из .env или из веб-интерфейса Immich → Administration → API Keys): + ```bash + curl -s "http://192.168.1.200:2284/api/system-config" -H "x-api-key: YOUR_API_KEY" | \ + python3 -c "import sys,json; c=json.load(sys.stdin); c['machineLearning']['enabled']=True; print(json.dumps(c))" | \ + curl -s -X PUT "http://192.168.1.200:2284/api/system-config" -H "x-api-key: YOUR_API_KEY" -H "Content-Type: application/json" -d @- + ``` + +2. **Сменить CLIP-модель** для поиска на русском: Immich UI → Administration → Settings → Machine Learning → Smart Search → Model Name: `nllb-clip-large-siglip__mrl` → Save. + +3. **Язык пользователя:** в настройках пользователя Immich выставить русский (NLLB-модель ожидает запросы на языке из настроек). + +4. **Запустить переиндексацию:** Administration → Jobs → Smart Search → нажать «All». На большом объёме фото (порядка десятков тысяч) на GPU займёт 20–30 минут. + +5. **По желанию снизить RAM ВМ** до 8 GB после индексации (на хосте Proxmox): `qm set 200 --memory 8192 --cores 3`. + +--- + +## Логи и ротация + +- **Docker:** данные Docker (образы, контейнеры, overlay) хранятся в **/mnt/data/docker** (Docker Root Dir). Логи контейнеров — драйвер **json-file** без ограничения размера и количества файлов (Config:{} у immich_server и immich_postgres). При активной работе логи могут разрастаться и занимать место на **корневом** разделе (если логи пишутся на корень) или в overlay на /mnt/data — уточнить расположение логов контейнеров (часто в /mnt/data/docker/containers). В любом случае ограничение логов не задано (см. TODO). +- **Системный logrotate:** стандартные правила (apt, dpkg, cloud-init, unattended-upgrades, wtmp). Отдельных правил для Immich или Docker нет. + +**Риск:** корневой диск заполнен на 87%. Рост логов, обновления и кэш могут привести к нехватке места. Необходимо ограничить логи Docker и следить за местом на корне (см. TODO). + +--- + +## Запуск и порядок поднятия + +1. Создать внешнюю сеть (если ещё нет): + `docker network create immich-deduper` +2. Immich: + `cd /opt/immich && docker compose up -d` + Порядок: database, redis → immich-server (и ML, upload_optimizer, power_tools, public_proxy). +3. Deduper: + `cd /opt/immich-deduper && docker compose up -d` + +После смены .env или compose: перезапуск соответствующих стеков. Обновление образов: `docker compose pull && docker compose up -d` (для Immich при обновлении проверять совместимость БД и миграции). + +--- + +## Уязвимости и риски + +1. **Секреты в .env:** В `/opt/immich/.env` и `/opt/immich-deduper/.env` хранятся пароли БД, API-ключи (IMMICH_API_KEY, GEMINI_API_KEY), креды для deduper (PSQL_*). Файлы не должны попадать в публичный репозиторий. Ограничить права (chmod 600), хранить бэкапы в защищённом месте. +2. **Корневой диск 87%:** Критично мало свободного места. При 100% возможны сбои обновлений и работы сервисов. Срочно: освободить место и/или перенести часть данных на /mnt/data, ограничить логи Docker (см. TODO). +3. **Логи Docker без лимитов:** Ротация не настроена — возможен рост логов и заполнение диска. +4. **Доступ по портам:** Сервисы доступны по 2283, 2284, 8001, 8501, 8086 из LAN. Снаружи доступ только через NPM (https://immich.katykhin.ru → 2283). Не пробрасывать порты напрямую в интернет. +5. **GPU и PCI-passthrough:** ВМ использует hostpci0 (VGA). Убедиться, что драйверы NVIDIA и доступ к GPU корректны для immich_machine_learning. + +--- + +## TODO по ВМ 200 + +- [ ] **Корневой диск:** Снизить использование корня (87%). Варианты: перенести логи Docker на /mnt/data (если сейчас пишутся на корень), очистить старые образы/кэш (`docker system prune` с осторожностью), увеличить размер корневого диска ВМ в Proxmox. Настроить мониторинг и оповещение при заполнении >90%. +- [ ] **Логи Docker:** Включить ограничение размера логов для всех контейнеров Immich и deduper: в `docker-compose.yml` добавить для каждого сервиса `logging: driver: json-file options: max-size: "100m" max-file: "3"` или задать default в `/etc/docker/daemon.json`. Убедиться, что Docker Root Dir остаётся на /mnt/data и логи не пишутся на корень. После изменений перезапустить контейнеры. +- [ ] **Права на конфиги:** Ограничить доступ к .env (chmod 600), не коммитить в публичные репозитории. +- [ ] **Резервное копирование:** Регулярный бэкап критичных данных (оценка размеров на момент документации): + - **`/mnt/data/library`** — библиотека Immich (фото, видео, превью). ~148 GB. Основной объём; бэкап обязателен (внешний диск, сетевое хранилище). + - **`/mnt/data/postgres`** — данные PostgreSQL Immich. ~657 MB. Для консистентного бэкапа предпочтительно дамп: + `docker exec immich_postgres pg_dump -U > backup_immich_$(date +%Y%m%d).sql` (подставить из .env). Хранить дампы вне ВМ. + - **`/opt/immich`** — docker-compose.yml, .env (секреты), hwaccel.ml.yml. ~20 KB. Обязательно; .env не коммитить. + - **`/opt/immich-deduper`** — docker-compose.yml, .env, каталог data (данные и кэш Qdrant). ~231 MB. Восстановление deduper возможно заново, но данные индексов — в data. + - **`/mnt/data/docker`** — образы и метаданные контейнеров (~16 GB). При восстановлении ВМ образы можно заново скачать; при необходимости бэкапить только метаданные томов или полный каталог. + - **`/mnt/data/ollama`** — данные Ollama (~4.1 GB). Бэкапить при использовании Ollama на этой ВМ. + + Учитывать, что /mnt/data смонтирован с отдельного диска (tank или аналог в Proxmox); при бэкапе с хоста включить этот диск или снапшоты. + +- [ ] **Документация по обновлению Immich:** Зафиксировать процедуру обновления (версия в IMMICH_VERSION, backup БД, docker compose pull, миграции, откат при сбое). + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — таблица контейнеров и ВМ, домен immich.katykhin.ru, схема сети. +- [Контейнер 100 (nginx)](container-100.md) — NPM, через который проксируется immich.katykhin.ru. +- [Paperless + Ollama: поиск по документам](paperless-ollama.md) — использование Ollama (обычно на этой ВМ) для ответов по документам из Paperless. diff --git a/docs/containers/paperless-ollama.md b/docs/containers/paperless-ollama.md new file mode 100644 index 0000000..148ca50 --- /dev/null +++ b/docs/containers/paperless-ollama.md @@ -0,0 +1,82 @@ +# Paperless + Ollama: поиск по документам Paperless-ngx + +Скрипт **paperless-ollama-ask.py** позволяет задавать вопросы по документам из Paperless-ngx (контейнер 104) и получать ответы от модели Ollama (обычно на ВМ 200). Работает так: ищет документы по тексту (OCR), собирает выдержки и подставляет их в промпт для локальной LLM. + +--- + +## Требования + +- Python 3.9+ +- Запущенный **Paperless-ngx** (см. `container-104.md`) с доступным API +- Запущенный **Ollama** (обычно на ВМ 200, см. `container-200.md`) + +Скрипт хранится в репозитории в `homelab/paperless-ollama-ask.py`, документация — в этом файле. + +--- + +## Переменные окружения + +| Переменная | Обязательная | По умолчанию | Пример | +|----------------------|--------------|------------------------|----------------------------------| +| `PAPERLESS_URL` | да | — | `http://192.168.1.104:8000` | +| `PAPERLESS_TOKEN` | да | — | токен API из Paperless | +| `OLLAMA_URL` | нет | `http://localhost:11434` | `http://192.168.1.200:11434` | +| `OLLAMA_MODEL` | нет | `saiga` | имя модели в Ollama | +| `PAPERLESS_MAX_DOCS` | нет | `5` | максимум документов в промпте | + +**PAPERLESS_TOKEN:** берётся в веб-интерфейсе Paperless: профиль пользователя → токен API (см. документацию Paperless-ngx). + +--- + +## Как это работает + +1. Скрипт выполняет поиск в Paperless по строке запроса (full-text по OCR). +2. Берёт до `PAPERLESS_MAX_DOCS` подходящих документов. +3. Собирает выдержки/полный текст и формирует промпт для модели Ollama. +4. Отправляет промпт на `OLLAMA_URL` и выводит ответ в stdout. + +--- + +## Примеры запуска + +```bash +cd homelab + +export PAPERLESS_URL="http://192.168.1.104:8000" # контейнер 104 +export PAPERLESS_TOKEN="твой_токен_из_Paperless" + +# Если Ollama на ВМ 200: +export OLLAMA_URL="http://192.168.1.200:11434" + +# Вопросы по документам +python3 paperless-ollama-ask.py "номер паспорта?" +python3 paperless-ollama-ask.py "когда истекает договор?" +``` + +--- + +## Где запускать + +- **На ВМ с Ollama (обычно ВМ 200):** + - `OLLAMA_URL=http://localhost:11434` + - `PAPERLESS_URL` — адрес контейнера 104 (`http://192.168.1.104:8000` или домен, который проксирует NPM). + +- **На другой машине (например, твой ноут):** + - `PAPERLESS_URL` и `OLLAMA_URL` указывают на IP/домены Paperless и Ollama. + - Учесть доступность портов и фаервол. + +--- + +## Безопасность и ограничение доступа + +- **Токен Paperless** даёт доступ к документам — хранить его только в приватных местах (env, .env вне репозитория). +- **Ollama** обычно слушает на 11434; при пробросе наружу стоит ограничить доступ (файрвол, VPN). +- Запросы и выдержки из документов уходят в локальную модель; при использовании удалённых/облачных моделей учитывать конфиденциальность. + +--- + +## Связь с другими документами + +- [Контейнер 104 (Paperless)](container-104.md) — где крутится Paperless-ngx и какие порты/тома используются. +- [ВМ 200 (Immich)](container-200.md) — может одновременно работать как хост для Ollama (порт 11434). + diff --git a/docs/network/network-topology.md b/docs/network/network-topology.md new file mode 100644 index 0000000..9624c33 --- /dev/null +++ b/docs/network/network-topology.md @@ -0,0 +1,233 @@ +# Схема сети и зависимости инфраструктуры + +Полная топология: домашняя сеть, Proxmox, контейнеры/ВМ, внешние VPS, маршруты и зависимости. Помогает видеть единые точки отказа (single point of failure). + +--- + +## Граф зависимостей (dependency graph) + +Цепочка обслуживания **любого** публичного запроса к доменам katykhin.ru: + +```mermaid +flowchart TB + subgraph External["Внешний мир"] + DNS["DNS (Beget)\n*.katykhin.ru → IP"] + end + + subgraph Edge["Граница сети"] + Router["Роутер\n192.168.1.1\nпроброс 80/443 → .100"] + end + + subgraph Gateway["Единая точка входа"] + NPM["NPM\nCT 100\nтерминация SSL, маршрут по Host"] + end + + subgraph Backends["Сервисы"] + S101["Nextcloud\nCT 101"] + S103["Gitea / CouchDB\nCT 103"] + S104["Paperless\nCT 104"] + S105["RAG API\nCT 105"] + S107["Invidious\nCT 107"] + S108["Galene\nCT 108"] + S200["Immich\nVM 200"] + end + + subgraph Data["Данные"] + D101["PostgreSQL\nRedis\nCT 101"] + D103["PostgreSQL\nCT 103"] + D104["PostgreSQL\nRedis\nCT 104"] + D107["PostgreSQL\nCT 107"] + D200["PostgreSQL\nRedis\nVM 200"] + end + + subgraph External2["Внешние зависимости"] + coTURN["coTURN\nVPS Миран 185.147.80.190"] + end + + DNS --> Router + Router --> NPM + NPM --> S101 + NPM --> S103 + NPM --> S104 + NPM --> S105 + NPM --> S107 + NPM --> S108 + NPM --> S200 + + S101 --> D101 + S103 --> D103 + S104 --> D104 + S107 --> D107 + S200 --> D200 + S108 -.-> coTURN +``` + +**Каноническая цепочка для одного запроса:** + +``` + DNS → Router → NPM → Service → Database +``` + +- **DNS** — без правильных записей запрос не дойдёт до твоего IP. +- **Router** — без проброса 80/443 трафик не попадёт в LAN на NPM. +- **NPM** — без него нет HTTPS и нет маршрутизации по домену на нужный бэкенд (узкое место: все сервисы проходят через один узел). +- **Service** — приложение (Nextcloud, Invidious, Immich и т.д.). +- **Database** — отказ БД/Redis ломает только свой сервис, не остальные. + +**Узкие места, которые видно из графа:** + +| Узел | Почему узкое место | +|------|--------------------| +| **NPM** | Один контейнер на все домены: отказ или перегрузка NPM роняет весь публичный доступ ко всем сервисам. | +| **Router** | Один прибор на вход в сеть: отказ = нет доступа извне. | +| **DNS** | Один регистратор/API: смена IP или ошибка в записях — домены перестают вести к тебе. | +| **Сервис/БД** | Ломается только один бэкенд; остальные цепочки независимы. | + +Исключения из цепочки: +- **Galene (108)** дополнительно зависит от **coTURN (VPS Миран)** для STUN/TURN — на графе это отдельная зависимость от внешнего узла. +- **RAG (105)** без внешней БД в стеке — только NPM → Service. +- **AdGuard, Homepage, Wallos** живут на том же хосте, что и NPM (CT 100), но логически стоят «рядом» с NPM (доступ к ним тоже через роутер и при необходимости через NPM). + +--- + +## Схема сети (упрощённо) + +``` + Интернет (пользователи) + │ + ▼ + ┌───────────────────────────────┐ + │ Роутер 192.168.1.1 │ Внешний IP: 185.35.193.144 + │ Netcraze Speedster │ Проброс 80/443 → .100 + │ VPN: AmneziaWG DE / US │ + └───────────────┬───────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌────────────────┐ ┌─────────────────┐ + │ Proxmox │ │ CT 100 │ │ Остальные CT/VM │ + │ 192.168.1.150│ │ 192.168.1.100 │ │ .101 .103 .104 │ + │ (гипервизор) │ │ NPM, AdGuard, │ │ .105 .107 .108 │ + │ │ │ Homepage, │ │ VM 200 .200 │ + │ pct / qm │ │ Wallos, etc. │ │ (бэкенды) │ + └──────────────┘ └───────┬────────┘ └────────┬────────┘ + │ │ │ + │ │ HTTPS по Host │ + │ └──────────────────────┘ + │ (NPM проксирует на бэкенды) + │ + │ Туннели VPN (роутер ↔ VPS) + │ + ├──────────────────────────────────────────────────┐ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ + │ VPS DE │ │ VPS US │ │ VPS Миран (СПБ) │ + │ 185.103.253.99 │ │ 147.45.124.117 │ │ 185.147.80.190 │ + │ AmneziaWG │ │ AmneziaWG │ │ coTURN (Galene), │ + │ (обход блок.) │ │ (обход блок.) │ │ боты, Prometheus │ + └──────────────────┘ └──────────────────┘ └──────────────────────┘ +``` + +**Поток публичного трафика:** +Запрос из интернета (например `https://video.katykhin.ru`) → роутер (185.35.193.144:443) → проброс на 192.168.1.100:443 → NPM (контейнер 100) → по Host выбирается proxy host → запрос на бэкенд (например 192.168.1.107:3000 для Invidious). Ответ идёт обратно по той же цепочке. + +--- + +## Узлы, IP и домены (сводная таблица) + +| Узел | IP / адрес | Роль | Домены / примечание | +|------|------------|------|----------------------| +| **Роутер** | 192.168.1.1 | Шлюз LAN, проброс 80/443, VPN-клиент (AmneziaWG) | Внешний IP: 185.35.193.144 | +| **Proxmox** | 192.168.1.150 | Гипервизор (LXC + KVM) | Управление: `pct`, `qm` | +| **CT 100** | 192.168.1.100 | NPM, Homepage, AdGuard, Wallos, log-dashboard, vpn-route-check | home.katykhin.ru, wallos.katykhin.ru, adguard.local; приём 80/443 | +| **CT 101** | 192.168.1.101 | Nextcloud, PostgreSQL, Redis | cloud.katykhin.ru | +| **CT 103** | 192.168.1.103 | Gitea, PostgreSQL, act_runner, CouchDB (Obsidian) | git.katykhin.ru, obsidian.katykhin.ru | +| **CT 104** | 192.168.1.104 | Paperless-ngx, PostgreSQL, Redis | docs.katykhin.ru | +| **CT 105** | 192.168.1.105 | RAG API (mini-lm) | mini-lm.katykhin.ru | +| **CT 107** | 192.168.1.107 | Invidious, Companion, PostgreSQL | video.katykhin.ru | +| **CT 108** | 192.168.1.108 | Galene (видеозвонки) | call.katykhin.ru | +| **VM 200** | 192.168.1.200 | Immich, PostgreSQL, Redis, ML, deduper, Power Tools, Public Share | immich.katykhin.ru, immich-pt.katykhin.ru, share.katykhin.ru | +| **VPS DE** | 185.103.253.99 | AmneziaWG (обход блокировок) | Туннель с роутера (10.8.1.x) | +| **VPS US** | 147.45.124.117 | AmneziaWG (второй выход) | Туннель с роутера | +| **VPS Миран** | 185.147.80.190 | coTURN (STUN/TURN для Galene), боты, prod | call.katykhin.ru использует STUN/TURN | +| **DNS** | Beget.com | Домен katykhin.ru, поддомены, API для DNS-01 | Все *.katykhin.ru | + +--- + +## Маршруты NPM (домен → бэкенд) + +Все публичные HTTPS-запросы к доменам katykhin.ru приходят на **192.168.1.100** (NPM). NPM по заголовку Host направляет трафик на соответствующий бэкенд: + +| Домен | Upstream (хост:порт) | Контейнер/ВМ | +|-------|----------------------|---------------| +| home.katykhin.ru | Homepage (внутри 100) | CT 100 | +| wallos.katykhin.ru | wallos:80 | CT 100 | +| adguard.local | adguard:3000 | CT 100 | +| cloud.katykhin.ru | 192.168.1.101:8080 | CT 101 | +| docs.katykhin.ru | 192.168.1.104:8000 | CT 104 | +| video.katykhin.ru | 192.168.1.107:3000 | CT 107 | +| call.katykhin.ru | 192.168.1.108:8443 | CT 108 | +| immich.katykhin.ru | 192.168.1.200:2283 | VM 200 | +| immich-pt.katykhin.ru | 192.168.1.200:8001 | VM 200 | +| share.katykhin.ru | 192.168.1.200:8501 | VM 200 | +| mini-lm.katykhin.ru | 192.168.1.105:8000 | CT 105 | +| git.katykhin.ru | 192.168.1.103:3000 | CT 103 | +| obsidian.katykhin.ru | 192.168.1.103:5984 | CT 103 | + +(Точный список proxy host — в NPM на CT 100; при добавлении доменов таблицу обновлять.) + +--- + +## Зависимости (кто от кого зависит) + +### Публичный доступ из интернета + +- **Все публичные HTTPS-сервисы** зависят от: + 1. **Роутер** — без него нет входа 80/443 на LAN. + 2. **Контейнер 100 (NPM)** — без NPM нет терминации SSL и маршрутизации по Host. + 3. **Certbot + Beget DNS** — без них нет валидных сертификатов (либо только HTTP или самоподписные). + 4. **DNS (Beget)** — без записей *.katykhin.ru запросы не дойдут до твоего IP. + +- **Каждый бэкенд** (101, 103, 104, 105, 107, 108, 200) при доступе снаружи зависит от NPM и роутера; при доступе только из LAN может работать и без NPM (по IP и порту). + +### Внутри хостов + +- **NPM (100):** зависит от certbot (файлы сертификатов) и от конфигурации proxy_host (куда слать трафик). Не зависит от других контейнеров для старта. +- **Homepage (100):** зависит от NPM (ссылки), AdGuard (виджет), dockerproxy (виджеты Docker). Работает и без них, но без данных. +- **AdGuard (100):** самостоятельный; NPM может проксировать к нему по имени. +- **Wallos (100):** самостоятельный; NPM проксирует. +- **Контейнеры 101, 103, 104, 107, 200:** каждый свой стек: приложение зависит от своей БД (PostgreSQL) и при необходимости от Redis. Порядок запуска: сначала БД/Redis, потом приложение (обычно задано в docker-compose через depends_on). +- **Контейнер 105 (RAG):** один контейнер, без внешней БД в стеке. +- **Контейнер 108 (Galene):** не зависит от других контейнеров в LAN; зависит от **внешнего coTURN** (VPS Миран 185.147.80.190) для STUN/TURN — без него видеозвонки могут не устанавливаться за строгим NAT/фаерволом. +- **Immich (200):** зависит от PostgreSQL, Redis, при необходимости от immich-deduper (сеть и БД). ML-контейнер опционально (GPU). + +### Внешние сервисы + +- **Роутер → VPN:** зависит от VPS (185.103.253.99 или 147.45.124.117) и от AmneziaWG на них. При падении VPS соответствующий туннель недоступен. +- **Galene → coTURN:** зависит от VPS Миран (185.147.80.190). При падении coTURN видеозвонки могут не работать в части сценариев. + +--- + +## Единые точки отказа (SPOF) + +| Точка | Что ломается при отказе | Как смягчить | +|-------|-------------------------|--------------| +| **Роутер** | Весь доступ из интернета к домашней сети (в т.ч. все домены). Нет VPN-выхода, если туннели подняты на роутере. | Резервный роутер / запасной канал; доступ к управлению из LAN. | +| **Proxmox (192.168.1.150)** | Все контейнеры и ВМ недоступны (они на нём запущены). | Резервное питание, мониторинг, бэкапы конфигов и данных. | +| **Контейнер 100 (NPM)** | Публичный HTTPS ко всем сервисам: без NPM нет терминации SSL и маршрутизации. Сервисы по IP:порт из LAN могут быть доступны, если порты не закрыты. | Мониторинг NPM и Docker на 100; быстрый перезапуск; бэкап конфигов NPM. | +| **Certbot / Beget API** | Истечение сертификатов → браузеры начнут ругаться. Продление через DNS-01 зависит от Beget. | Следить за продлением (таймер certbot); иметь запасной способ выпуска (другой DNS или ручной сертификат). | +| **DNS (Beget)** | Смена IP или потеря записей — домены перестанут вести на твой хост. | Ведение записей вручную/через API; при смене IP обновить A-записи. | +| **VPS Миран (coTURN)** | Galene: проблемы с установкой видеозвонов за NAT. Остальные сервисы не зависят. | Локальный coTURN на 108 или другом хосте как запасной вариант. | +| **Конкретный бэкенд (101, 103, …)** | Падает только свой сервис (Nextcloud, Gitea, Invidious и т.д.). Остальные работают. | Зависимости внутри стека (БД первым) и мониторинг каждого хоста. | + +--- + +## Связь с другими документами + +- [Архитектура и подключение](../architecture/architecture.md) — общее описание, таблица контейнеров, поток запросов. +- [Контейнер 100](../containers/container-100.md) — NPM, AdGuard, Homepage, порядок запуска. +- [Роутер Netcraze Speedster](router-netcraze-speedster.md) — проброс портов, VPN. +- [VPN-сервер (VPS, AmneziaWG)](../vps/vpn-vps-amneziawg.md) — туннели с роутера. +- [VPS Миран: боты и STUN/TURN](../vps/vps-miran-bots.md) — coTURN для Galene. diff --git a/docs/network/router-netcraze-speedster.md b/docs/network/router-netcraze-speedster.md new file mode 100644 index 0000000..0f1dc2d --- /dev/null +++ b/docs/network/router-netcraze-speedster.md @@ -0,0 +1,102 @@ +# Роутер Netcraze Speedster + +Домашний маршрутизатор с веб-админкой и поддержкой WireGuard/AmneziaWG. Используется для выхода в интернет и выборочной маршрутизации части трафика через VPN (два профиля: Германия и США). + +--- + +## Доступ и логины + +- **Веб-интерфейс:** http://192.168.1.1 — логин `admin`, пароль `eC1cLwZPRoDVEY1`. +- **Веб-CLI (ограниченный):** http://192.168.1.1/a — отправка отдельных команд из браузера. +- **Полноценный CLI:** по **telnet** на 192.168.1.1, порт **2048** (стандартный 23 изменён). Пароль администратора тот же: `eC1cLwZPRoDVEY1`. SSH в настройках включался, но **не работает** — используется telnet. + +Подключение по telnet с компьютера в домашней сети: + +```bash +telnet 192.168.1.1 2048 +``` + +После ввода пароля администратора доступна CLI с автодополнением по Tab (команды и параметры текущего уровня). + +--- + +## VPN (WireGuard / AmneziaWG) + +Настройка выполнялась по инструкции для Keenetic с AmneziaWG: [Installing VPN on a Keenetic Router](https://docs.amnezia.org/documentation/instructions/keenetic-os-awg/) (импорт .conf из AmneziaVPN, при необходимости ручная установка asc-параметров обфускации в CLI). + +**Два подключения WireGuard:** + +| Подключение | Пир (сервер) | Назначение | Статус в скринах | +|--------------------|------------------------|----------------|-------------------| +| netcraze_amnezia | 185.103.253.99:33118 | Германия (AWG) | Включено | +| router_us | 147.45.124.117:37135 | США (AWG) | Выключено | + +- Внутренний адрес клиента для **netcraze_amnezia:** 10.8.1.2/32 (порт 42472). +- Для **router_us:** 10.8.1.2/32 (порт 43116). + +Переключение: в разделе **Интернет → Другие подключения** включается или выключается нужное WireGuard-подключение. В **Приоритеты подключений** оба профиля работают в режиме резервирования. + +--- + +## Приоритеты подключений + +**Интернет → Приоритеты подключений:** + +- **Политика по умолчанию** — основное подключение (Ethernet / PPPoE). +- **netcraze_amnezia** — режим резервирования, WireGuard (Германия). +- **us** — режим резервирования, WireGuard (router_us, США). + +Нужная политика выбирается для устройств/сегментов на вкладке **Применение политик**. Для выхода части трафика через VPN можно создать отдельную политику с одним VPN-подключением и назначить её нужным устройствам или сегментам (например, гостевой Wi‑Fi). + +--- + +## Маршрутизация + +**Сетевые правила → Маршрутизация → IPv4-маршруты:** + +- Пользовательские маршруты с приоритетом над динамическими. +- **Шлюз** в таблице: 10.8.1.2 (внутренний адрес VPN-клиента на роутере). +- **Интерфейсы:** `netcraze_amnezia` и `router_us` — трафик к указанным сетям назначения уходит через соответствующий VPN. +- Записей много (порядка 2000+); для части сетей включено **«Добавлять автоматически»**. Списки конкретных IP/сетей в документацию не выносятся — при необходимости экспорт/импорт через кнопки в веб-интерфейсе. + +--- + +## DNS + +**Сетевые правила → Интернет-фильтры → Настройка DNS:** + +- **Системный профиль:** DNS провайдера (подключение Ethernet), например 178.155.7.18, 217.66.16.35; разрешён транзит запросов. +- **DoT-серверы:** 1.1.1.1 (Cloudflare) и 8.8.8.8 (Google), интерфейс «Любой». + +При необходимости игнорирование DNS провайдера включается в **Интернет → Кабель Ethernet → Порты и VLANы**. + +--- + +## Основные команды CLI (telnet) + +После входа в CLI доступны стандартные команды Keenetic-подобной оболочки. Автодополнение: **Tab** в приглашении выводит список доступных команд на текущем уровне. + +**Полезные команды:** + +- `show interface` — список интерфейсов (в т.ч. WireGuard по имени подключения; по описанию можно найти имя интерфейса, например Wireguard1). +- `system configuration save` — сохранение конфигурации. +- `exit` — выход из CLI. + +**Ручная установка asc-параметров AmneziaWG (если веб не импортировал их):** + +По [инструкции Amnezia](https://docs.amnezia.org/documentation/instructions/keenetic-os-awg/) для версий KeeneticOS 4.3.3 и ниже параметры обфускации (Jc, Jmin, Jmax, S1, S2, H1–H4) задаются в CLI командой вида: + +```text +interface Wireguard1 wireguard asc 6 10 50 90 62 1455064900 852483043 2078090415 1981181588 +``` + +(имя интерфейса и значения — из .conf и `show interface`; для 4.3.4+ импорт из файла может подставлять их автоматически.) + +Полный перечень команд — в [руководстве по CLI](https://support.keenetic.com/hero/kn-1012/en/18480-command-line-interface--cli-.html) и в разделе загрузок на support.keenetic.com для модели Speedster. + +--- + +## Связь с другими статьями + +- Серверы VPN: [VPN-сервер (VPS, AmneziaWG)](../vps/vpn-vps-amneziawg.md), [Перенос конфигурации AmneziaWG](../vps/vpn-migrate-config.md). +- Домашняя сеть и Proxmox: [Архитектура и подключение](../architecture/architecture.md). diff --git a/docs/network/ssl-letsencrypt-dns01.md b/docs/network/ssl-letsencrypt-dns01.md new file mode 100644 index 0000000..daa9e3e --- /dev/null +++ b/docs/network/ssl-letsencrypt-dns01.md @@ -0,0 +1,102 @@ +# Инструкция: выпуск сертификата Let's Encrypt (DNS-01) + +Универсальная инструкция для использования как промпт в следующих проектах. Подходит, когда HTTP-01 недоступен (порт 80 закрыт, блокировки, инстанс за NAT). + +--- + +## Когда использовать DNS-01 + +- **HTTP-01** не срабатывает: таймаут, «Connection refused», порт 80 недоступен с интернета, провайдер или регион блокирует запросы от Let's Encrypt. +- **DNS-01**: проверка владения доменом по TXT-записи `_acme-challenge.<домен>`. До сервера стучаться не нужно — важен только DNS. + +--- + +## Общий алгоритм + +1. Выбрать DNS-провайдера домена и проверить, есть ли у него API или плагин для certbot/acme.sh. +2. Установить certbot и плагин для этого DNS (или использовать acme.sh с DNS API). +3. Создать файл учётных данных (логин/API-токен провайдера), права 600. +4. Запросить сертификат: `certbot certonly --authenticator dns-<провайдер> ... -d example.com`. +5. Подложить полученные `fullchain.pem` и `privkey.pem` в reverse proxy (Nginx Proxy Manager, nginx, Caddy и т.д.). +6. Настроить автообновление (systemd timer certbot + deploy-hook при продлении). + +--- + +## Пример: Beget.com (certbot-dns-beget-api) + +**Условия:** домен на Beget, доступ к API (логин + пароль или отдельный API-пароль). + +1. **Установка (Debian/Ubuntu):** + ```bash + apt install certbot + pip3 install certbot-dns-beget-api # или: python3 -m pip install certbot-dns-beget-api --break-system-packages + ``` + +2. **Файл учётных данных** (например `/root/.secrets/certbot/beget.ini`): + ```ini + dns_beget_api_username = ВАШ_ЛОГИН_BEGET + dns_beget_api_password = ВАШ_ПАРОЛЬ_ИЛИ_API_ПАРОЛЬ + ``` + ```bash + chmod 600 /root/.secrets/certbot/beget.ini + ``` + +3. **Запрос сертификата:** + ```bash + certbot certonly \ + --authenticator dns-beget-api \ + --dns-beget-api-credentials /root/.secrets/certbot/beget.ini \ + --dns-beget-api-propagation-seconds 120 \ + -d example.com \ + --non-interactive \ + --agree-tos \ + --email your@email.com + ``` + +4. **Где лежат файлы после выпуска:** + - Сертификат: `/etc/letsencrypt/live/<домен>/fullchain.pem` + - Ключ: `/etc/letsencrypt/live/<домен>/privkey.pem` + +5. **Интеграция с Nginx Proxy Manager (NPM):** + - Либо вручную скопировать в каталог custom_ssl (например `custom_ssl/npm-/fullchain.pem` и `privkey.pem`) и перезагрузить nginx в контейнере NPM. + - Либо добавить запись в БД NPM (таблица `certificate`, provider `other`) и положить те же файлы в каталог, на который ссылается конфиг nginx (например `custom_ssl/npm-/`). + +6. **Продление и deploy-hook** (чтобы после `certbot renew` сертификат автоматически подхватывался NPM): + ```bash + # /etc/letsencrypt/renewal-hooks/deploy/copy-to-npm.sh + # RENEWED_LINEAGE = путь к обновлённому сертификату, например /etc/letsencrypt/live/example.com + if [ "$RENEWED_LINEAGE" = "/etc/letsencrypt/live/EXAMPLE_DOMAIN" ]; then + cp "$RENEWED_LINEAGE/fullchain.pem" /path/to/npm/custom_ssl/npm-ID/ + cp "$RENEWED_LINEAGE/privkey.pem" /path/to/npm/custom_ssl/npm-ID/ + chmod 644 /path/to/npm/custom_ssl/npm-ID/fullchain.pem + chmod 600 /path/to/npm/custom_ssl/npm-ID/privkey.pem + docker exec CONTAINER_NPM nginx -s reload + fi + ``` + Сделать скрипт исполняемым: `chmod +x ...` + +7. **Проверка автообновления:** таймер certbot обычно уже включён: + ```bash + systemctl list-timers | grep certbot + ``` + +--- + +## Другие DNS-провайдеры (идеи для промпта) + +- **Cloudflare:** `certbot-dns-cloudflare`, переменные `dns_cloudflare_api_token` или `dns_cloudflare_email` + `dns_cloudflare_api_key`. +- **Reg.ru:** плагин `certbot-dns-regru`, свои переменные в credentials-файле. +- **NIC.ru:** `certbot-dns-nicru`. +- **Без API:** `certbot certonly --manual --preferred-challenges dns -d example.com` — выводит TXT-значение, пользователь вручную добавляет запись в DNS, затем продолжает по Enter. + +--- + +## Краткий чеклист для нового домена/проекта + +- [ ] Домен и DNS у одного провайдера; выяснить, есть ли API/плагин для ACME DNS-01. +- [ ] Установить certbot и нужный DNS-плагин. +- [ ] Создать credentials-файл (chmod 600), не коммитить в git. +- [ ] Выпустить сертификат: `certbot certonly --authenticator dns-... -d domain`. +- [ ] Подложить fullchain.pem и privkey.pem в reverse proxy. +- [ ] Добавить deploy-hook для продления и перезагрузки nginx/прокси. +- [ ] Убедиться, что certbot.timer включён для автоматического renew. diff --git a/docs/vps/vpn-migrate-config.md b/docs/vps/vpn-migrate-config.md new file mode 100644 index 0000000..572b6e9 --- /dev/null +++ b/docs/vps/vpn-migrate-config.md @@ -0,0 +1,60 @@ +# Перенос конфигурации AmneziaWG между серверами + +Как развернуть тот же VPN на новом VPS и переключиться с одного сервера на другой без потери параметров обфускации. На роутере создаётся второе VPN-подключение, переключение — выбор нужного (Германия или США / новый сервер). + +--- + +## Два текущих сервера + +| Параметр | Германия (основной) | США (второй) | +|------------|---------------------|---------------------| +| IP | 185.103.253.99 | 147.45.124.117 | +| Порт AWG | 33118/UDP | 37135/UDP | +| Сеть | — | Netmask 255.255.254.0, Gateway 147.45.124.1 | +| Регион | Франкфурт-на-Майне | USA | +| Статус | Основной | Возможен отказ позже | + +Подробнее про сервер в Германии: [VPN-сервер (VPS, AmneziaWG)](vpn-vps-amneziawg.md). + +--- + +## Параметры обфускации совпадают + +На обоих серверах снят вывод `wg show` (AmneziaWG). Параметры обфускации **идентичны**: + +| Параметр | Значение | +|----------|-----------| +| jc | 6 | +| jmin | 10 | +| jmax | 50 | +| s1 | 90 | +| s2 | 62 | +| h1 | 1455064900 | +| h2 | 852483043 | +| h3 | 2078090415 | +| h4 | 1981181588 | + +Отличаются только: IP сервера, порт UDP, имя интерфейса (wg0 / awg0), ключи и список пиров. То есть один и тот же «профиль» обфускации можно воспроизвести на новом VPS — клиенты будут вести себя так же с точки зрения DPI. + +--- + +## Как перенести конфиг на новый сервер + +Если позже арендуешь другой VPS (вместо США или дополнительно), конфигурацию можно перенести полностью. + +1. **Развернуть сервер:** Ubuntu (или другая ОС из [требований Amnezia](https://docs.amnezia.org/ru/documentation/supported-linux-os-for-vps)), Docker, доступ по SSH с твоего ключа. +2. **Установить AmneziaWG через приложение AmneziaVPN:** добавить новый сервер по IP, установить протокол AmneziaWG. В настройках протокола на сервере задать те же параметры обфускации, что и сейчас: **jc, jmin, jmax, s1, s2, h1–h4** (значения из таблицы выше). +3. **Клиенты:** для нового сервера в AmneziaVPN сгенерировать новые гостевые конфиги (или конфиг с полным доступом). В конфиге будет новый Endpoint = IP:порт нового VPS. Старые ключи/пиры с предыдущего сервера на новый не копировать — у нового сервера свой ключ; важны только параметры обфускации на сервере и в клиенте. +4. **Роутер:** создать второе VPN-подключение (второй профиль/интерфейс) с конфигом для нового сервера. Переключение — выбор активного подключения (Германия или США / новый хост). + +Итог: параметры обфускации задаются вручную при настройке AmneziaWG на новом VPS так же, как на текущих; клиентские конфиги получаются из приложения уже с новым Endpoint. + +--- + +## Роутер: два подключения, переключение + +- В настройках роутера создаётся **дополнительное** VPN-подключение (второй профиль AmneziaWG/WireGuard). +- Один профиль — Germany (185.103.253.99:33118), второй — USA (147.45.124.117:37135) или будущий новый сервер. +- Переключение: выбираешь активное подключение — либо одно, либо другое (или отключено). Маршруты/политики при необходимости те же для обоих (например, весь трафик в туннель или только часть по правилам). + +Так можно в любой момент перейти с USA на новый VPS: добавляешь новый профиль с конфигом нового сервера и переключаешься на него. diff --git a/docs/vps/vpn-vps-amneziawg.md b/docs/vps/vpn-vps-amneziawg.md new file mode 100644 index 0000000..84ecb69 --- /dev/null +++ b/docs/vps/vpn-vps-amneziawg.md @@ -0,0 +1,95 @@ +# VPN-сервер (VPS, AmneziaWG) + +Отдельный VPS в другой стране для обхода блокировок. Развёрнут протокол **AmneziaWG** (маскировка VPN-трафика под обычный UDP, устойчивость к DPI). Трафик с домашнего контура при необходимости можно маршрутизировать через этот сервер. + +--- + +## Доступ и логины + +- **SSH:** `ssh root@185.103.253.99` (доступ по SSH-ключу). IP: 185.103.253.99, ОС Ubuntu, ядро 6.8. +- **Веб-интерфейсов на сервере нет.** Управление VPN — через приложение AmneziaVPN на клиенте (добавление сервера по IP, установка AmneziaWG, гостевые конфиги). + +**Хостинг (First Byte):** +- Тариф: KVM-SSD-1-FRA +- Дата открытия: 2026-01-24 +- Доменное имя: vm3839421.firstbyte.club +- Регион: Германия, Франкфурт-на-Майне + +--- + +## Что развёрнуто + +- **Docker** — один контейнер: **amnezia-awg** (образ `amnezia-awg`). +- **AmneziaWG** слушает порт **33118/UDP** на всех интерфейсах. +- Контейнер запущен с `--restart=always`, примонтирован `/lib/modules` (для ядерного модуля WireGuard). Сеть — bridge, проброс порта 33118/udp на хост. +- Дополнительно: **vps-metrics** (systemd-сервис) — API метрик для виджета Homepage. + +Каталог образа/скриптов на хосте: `/opt/amnezia/amnezia-awg/` (там лежит Dockerfile; сам сервер AmneziaWG настраивается через приложение AmneziaVPN при установке протокола на этот VPS). + +--- + +## Протокол AmneziaWG + +- Форк WireGuard с обфускацией трафика: динамические заголовки, рандомизация размеров пакетов, маскировка под другие UDP-протоколы (QUIC, DNS и т.д.). Это затрудняет определение и блокировку VPN системами DPI. +- Криптография и производительность — как у WireGuard; совместимость с обычным WireGuard ограничена (нужен именно клиент AmneziaWG/AmneziaVPN). + +Подробнее: [документация Amnezia](https://docs.amnezia.org/ru/documentation/amnezia-wg), [AmneziaWG 2.0 на self-hosted](https://docs.amnezia.org/ru/documentation/instructions/new-amneziawg-selfhosted). + +--- + +## Как подключиться к VPN + +1. Установи клиент **AmneziaVPN** на устройство (Windows, macOS, Linux, Android, iOS): [amnezia.org/downloads](https://amnezia.org/ru/downloads). +2. Добавь сервер: в приложении создай новое подключение и укажи **185.103.253.99** (логин/пароль или SSH-ключ — как настраивал при первом развёртывании). Либо вставь ключ подключения в формате `vpn://...`, если он был сгенерирован и сохранён. +3. В настройках этого сервера установи/выбери протокол **AmneziaWG** (при первом развёртывании он ставится через приложение на VPS). +4. Для доступа с других устройств без полного доступа к серверу: в AmneziaVPN создай **гостевое** подключение по AmneziaWG и передай сгенерированный ключ или конфиг (.conf) на нужное устройство. + +Конфигурации и ключи хранятся в приложении и (при экспорте) в выданных тебе файлах; на VPS в открытом виде в доку не дублируются. + +--- + +## Полезные команды на VPS + +```bash +# Статус контейнера +docker ps -a | grep amnezia + +# Логи (если включены) +docker logs amnezia-awg + +# Интерфейс WireGuard внутри контейнера (публичные ключи, пиры, handshake) +docker exec amnezia-awg wg show + +# Перезапуск VPN-контейнера +docker restart amnezia-awg +``` + +Порт на хосте: + +```bash +ss -ulnp | grep 33118 +``` + +--- + +## Связь с домашним контуром + +- Домашний сервер (Proxmox): 192.168.1.150, внешний IP 185.35.193.144. +- При необходимости маршрутизировать трафик (например, YouTube/Invidious) через этот VPN настраивается отдельно: маршруты и/или policy-based routing на роутере или на конкретных хостах/контейнерах. + +В документе по [архитектуре](../architecture/architecture.md) этот VPS упоминается как отдельный узел; детали доступа и протокола — в этой статье. + +--- + +## Второй VPS (USA) + +Есть второй сервер в США; от него возможен отказ в пользу одного Germany или нового хостинга. + +- **IP:** 147.45.124.117 +- **Сеть:** Netmask 255.255.254.0, Gateway 147.45.124.1 +- **SSH:** `ssh root@147.45.124.117` +- **AmneziaWG:** контейнер `amnezia-awg2`, порт **37135/UDP**. + +Параметры обфускации на обоих серверах (Германия и США) **одинаковые** — конфиг можно полностью перенести на новый сервер при переезде. На роутере создаётся второе VPN-подключение; переключение между Germany и USA — выбор нужного профиля. + +→ **Подробно:** [Перенос конфигурации AmneziaWG между серверами](vpn-migrate-config.md). diff --git a/docs/vps/vps-miran-bots.md b/docs/vps/vps-miran-bots.md new file mode 100644 index 0000000..4e5a190 --- /dev/null +++ b/docs/vps/vps-miran-bots.md @@ -0,0 +1,105 @@ +# VPS Миран (СПБ): боты и STUN/TURN + +VPS в ЦОД Миран (Санкт-Петербург). Развёрнуты два Telegram-бота (telegram-helper-bot, anonBot), инфраструктура prod (мониторинг, метрики), а также сервер STUN/TURN для Galene (call.katykhin.ru). + +--- + +## Доступ и логины + +- **SSH:** `ssh -p 15722 deploy@185.147.80.190` (пользователь deploy, в группе docker). IP: 185.147.80.190, хостнейм vm220416.vds.miran.ru, ОС Ubuntu. +- **S3 (контент ботов):** URL https://api.s3.miran.ru, порт 443. Access key: `j3tears100@gmail.com`, Secret key: `wQ1-6sZEPs92sbZTSf96` (полная таблица — в разделе «S3» ниже). +- **Админка Миран (панель хостинга VPS):** логин `j3tears100@gmail.com`, пароль `gonPok-xifrys-4nuxde`. +- **Grafana, Uptime Kuma, админки ботов:** логины и пароли — в `.env` проекта prod или в менеджере паролей. + +--- + +## Что развёрнуто + +### 1. Боты и prod (Docker Compose) + +Проект в `/home/prod/`: `docker-compose.yml`, каталоги `bots/`, `infra/`, CI (GitHub Actions в `.github/`). Запуск от пользователя deploy. + +| Сервис | Образ / контейнер | Порт | Назначение | +|------------------|---------------------|-------|--------------------------| +| telegram-bot | prod-telegram-bot | 8080 | telegram-helper-bot | +| anon-bot | prod-anon-bot | 8081 | anonBot | +| prometheus | prom/prometheus | 9090 | Метрики | +| alertmanager | prom/alertmanager | 9093 | Алерты | +| grafana | grafana/grafana | 3000 | Дашборды | +| uptime-kuma | louislam/uptime-kuma| 3001 | Мониторинг доступности | + +Сеть контейнеров: `prod_bots_network` (bridge). Переменные окружения и секреты — в `.env` и настройках сервисов (в доку не дублируются). + +### 2. STUN/TURN (Galene) + +**coTURN** — системный сервис `coturn`, конфиг `/etc/turnserver.conf`. + +- **Порты:** 3478/UDP (STUN/TURN), TLS/DTLS отключены (`no-tls`, `no-dtls`). +- **Realm:** call.katykhin.ru +- **External IP:** 185.147.80.190 +- **Диапазон портов для релея:** 49152–49252 +- Учётные данные для Galene заданы в конфиге (user/realm); на сервере Galene (контейнер 108) в настройках указывается этот TURN-сервер для обхода NAT. + +Трафик с Galene (домашний сервер 192.168.1.108, call.katykhin.ru) уходит на этот VPS для STUN/TURN, чтобы видеозвонки работали при симметричном NAT и ограничениях провайдера. + +### 3. S3 (контент ботов) + +Объектное хранилище Миран (S3-совместимый API) для контента, который отдают боты (медиа, файлы). Доступ по HTTPS. + +| Параметр | Значение | +|-------------|----------| +| URL | https://api.s3.miran.ru | +| Порт | 443 (HTTPS) | +| Access key | j3tears100@gmail.com | +| Secret key | wQ1-6sZEPs92sbZTSf96 | + +В ботаx (переменные окружения prod) заданы `S3_ENDPOINT_URL=https://api.s3.miran.ru`, регион и креды для загрузки/выдачи контента. Для локальной разработки или других клиентов использовать те же endpoint и ключи. + +### 4. Остальное на хосте + +- **nginx** — порты 80 и 443; по умолчанию отдаёт статику из `/var/www/html`. +- **prometheus-node-exporter** — порт 9100, метрики хоста для Prometheus. +- **vps-metrics** — systemd-сервис, API метрик для виджета Homepage (порт 3497). +- **cron** — по расписанию при необходимости. + +--- + +## Порты (сводка) + +| Порт | Служба / контейнер | Протокол | +|--------|--------------------------|----------| +| 15722 | SSH | TCP | +| 80, 443| nginx | TCP | +| 3478 | coturn (STUN/TURN) | UDP | +| 3497 | vps-metrics (Homepage) | TCP | +| 8080 | telegram-bot | TCP | +| 8081 | anon-bot | TCP | +| 9090 | Prometheus | TCP | +| 9093 | Alertmanager | TCP | +| 9100 | node-exporter | TCP | +| 3000 | Grafana | TCP | +| 3001 | Uptime Kuma | TCP | + +--- + +## Управление ботами и prod + +Рабочий каталог Compose: `/home/prod/`. От пользователя deploy: + +```bash +cd /home/prod +docker compose ps +docker compose up -d +docker compose logs -f telegram-bot +``` + +Образы ботов собираются из репозитория (Dockerfile в проекте); деплой через Makefile / CI при необходимости. + +--- + +## Связь с домашним контуром + +- **Galene** (контейнер 108, call.katykhin.ru): в настройках сервера Galene указан TURN-сервер 185.147.80.190:3478 (realm call.katykhin.ru), чтобы клиенты за NAT могли устанавливать медиа-сессии через этот VPS. +- **Homepage** (контейнер 100): виджет метрик может показывать данные с vps-metrics (185.147.80.190:3497). + +Остальные узлы homelab (Proxmox, NPM и т.д.) описаны в [архитектуре](../architecture/architecture.md). diff --git a/homelab/VPN-KEENETIC-CONTEXT-PROMPT.md b/homelab/VPN-KEENETIC-CONTEXT-PROMPT.md deleted file mode 100644 index 61eaac8..0000000 --- a/homelab/VPN-KEENETIC-CONTEXT-PROMPT.md +++ /dev/null @@ -1,71 +0,0 @@ -# Контекст: VPN на Keenetic, два AmneziaWG-сервера (DE и US) - -**Использование:** скопируй этот блок в начало запроса или прикрепи файл (@VPN-KEENETIC-CONTEXT-PROMPT.md), чтобы ассистент сразу имел все вводные. - ---- - -## Цель - -На роутере Keenetic настроены два WireGuard (AmneziaWG)-подключения к разным VPS. Оба используют один и тот же адрес клиента **10.8.1.2** и шлюз **10.8.1.2**. Трафик к определённым сетям (YouTube и др.) идёт через выбранный VPN. Переключение DE ↔ US — только включением/выключением нужного подключения в веб-интерфейсе, без правки маршрутов. - ---- - -## Серверы и доступ - -| Сервер | IP | SSH | AmneziaWG порт | Контейнер | -|--------|-----|-----|----------------|-----------| -| **DE** | 185.103.253.99 | `ssh root@185.103.253.99` | 33118 | amnezia-awg, конфиг `/opt/amnezia/awg/wg0.conf` | -| **US** | 147.45.124.117 | `ssh root@147.45.124.117` | 37135 | amnezia-awg2, конфиг `/opt/amnezia/awg/awg0.conf` | - -- Мой статический IP: **185.35.193.144**. -- На обоих VPS установлен AmneziaVPN (AmneziaWG в Docker). Для управления/создания пользователей используется приложение AmneziaVPN на macOS; при отключённом входе по паролю на сервере нужен доступ по SSH-ключу, иначе ошибка 300 (SshRequestDeniedError). - ---- - -## Общие параметры AmneziaWG (одинаковые на DE и US) - -- Подсеть туннеля: **10.8.1.0/24**, сервер — 10.8.1.0. -- Параметры обфускации (asc), единые для обоих серверов и для конфигов роутера: - - **Jc = 6**, Jmin = 10, Jmax = 50 - - **S1 = 90**, **S2 = 62** - - **H1 = 1455064900**, **H2 = 852483043**, **H3 = 2078090415**, **H4 = 1981181588** - -Роутер (клиент) всегда имеет адрес **10.8.1.2/32**; на каждом сервере в конфиге есть peer с AllowedIPs = 10.8.1.2/32 (свой публичный ключ роутера/клиента). - ---- - -## Роутер Keenetic - -- Адрес веб-интерфейса: **192.168.1.1**. Telnet доступен (для отладки). -- **Два WireGuard-подключения:** - - **netcraze_amnezia** — к DE (185.103.253.99:33118), peer 10.8.1.2 на DE (ключ HSgydyy...). - - **router_us** — к US (147.45.124.117:37135), peer 10.8.1.2 на US (ключ 2a6d52kL...). Конфиг для роутера лежит в репозитории: **homelab/us-router.conf** (Address 10.8.1.2/32, те же asc). -- Включено только одно из двух подключений; маршруты с шлюзом 10.8.1.2 тогда идут через активный туннель. - -**Приоритеты подключений (политики):** -- **Политика по умолчанию:** Ethernet, netcraze_amnezia, router_us (все с галочками; порядок приоритета: Ethernet → netcraze_amnezia → router_us). -- **Политика netcraze_amnezia:** только netcraze_amnezia (для устройств, которые должны идти только через DE). -- **Политика us:** только router_us (для устройств, которые должны идти только через US). - -Чтобы новое WireGuard-подключение появилось в списке при настройке политик, у него должна быть включена галочка **«Использовать для выхода в интернет»** (в настройках подключения в разделе **Интернет → Другие подключения → Wireguard**). - -**Маршрутизация:** -- Пользовательские маршруты (шлюз 10.8.1.2) заданы **по интерфейсу**: часть — **netcraze_amnezia**, часть — **router_us** (одинаковые сети, один шлюз 10.8.1.2, разный интерфейс). Так сделано специально: при включённом только одном VPN активны только маршруты этого интерфейса. -- Импорт маршрутов: формат **.bat** (Windows), строки вида `route add mask 10.8.1.2`. Интерфейс **не в файле** — выбирается при загрузке в выпадающем списке (Импорт → выбор интерфейса: netcraze_amnezia или router_us). Один и тот же файл можно загрузить дважды, указав разный интерфейс, чтобы получить маршруты для обоих VPN. Часть импортированных маршрутов может отображаться в блоке «Действующие маршруты IPv4», а не в «Пользовательские» — это нормально, трафик при этом идёт корректно. -- Инструкция по второму (US) подключению и конфигам: **homelab/vpn-keenetic-us-second-connection.md**. Общая схема homelab: **homelab/architecture.md**. - ---- - -## Добавление третьего VPS - -Конфиг нового сервера можно собрать по образцу DE/US: та же подсеть 10.8.1.0/24, те же asc, свой ListenPort и свой PrivateKey сервера; добавить peer 10.8.1.2/32 с публичным ключом роутера (текущий router_us: 2a6d52kL..., либо новый ключ при создании нового пользователя в Amnezia на третьем сервере). Для этого нужны: IP третьего VPS, SSH-доступ (root или sudo), установленный AmneziaWG в Docker (или возможность его установить). - ---- - -## Краткая сводка для копипаста - -- **DE:** 185.103.253.99, ssh root@185.103.253.99, порт 33118, контейнер amnezia-awg, wg0.conf. -- **US:** 147.45.124.117, ssh root@147.45.124.117, порт 37135, контейнер amnezia-awg2, awg0.conf. -- **Роутер:** 192.168.1.1, два WG: netcraze_amnezia (DE), router_us (US); оба 10.8.1.2/32; переключение — включить один, выключить другой. -- **Маршруты:** шлюз 10.8.1.2, интерфейс задаётся при импорте .bat (netcraze_amnezia или router_us); два набора маршрутов (по одному на интерфейс). -- **Конфиг роутера для US:** homelab/us-router.conf. **ASC везде одинаковые:** Jc=6, Jmin=10, Jmax=50, S1=90, S2=62, H1–H4 как выше. diff --git a/homelab/architecture.md b/homelab/architecture.md deleted file mode 100644 index a298562..0000000 --- a/homelab/architecture.md +++ /dev/null @@ -1,58 +0,0 @@ -# Архитектура домашних сервисов (homelab) - -Краткое описание по состоянию настройки Invidious и сопутствующих сервисов. - ---- - -## Сеть и доступ - -- **Внешний IP:** 185.35.193.144 -- **Домашний сервер (Proxmox):** 192.168.1.150 (LAN), доступ: `ssh root@192.168.1.150` -- **DNS домена katykhin.ru:** Beget.com -- **Reverse proxy и SSL:** Nginx Proxy Manager (NPM) на контейнере 100. Домены: video.katykhin.ru, call.katykhin.ru, home.katykhin.ru, wallos.katykhin.ru, cloud.katykhin.ru, docs.katykhin.ru, immich.katykhin.ru. - ---- - -## Гипервизор - -- **Proxmox VE.** Гости — в основном **LXC-контейнеры**, одна **KVM VM** (Immich, ID 200). -- Управление LXC: `pct` (например `pct exec -- bash`). Управление VM: `qm`. -- IP задаётся статически (в конфиге LXC/VM или резерв на роутере). Схема **ID = последний октет** (100→.100, 101→.101, …, 108→.108, 200→.200). - ---- - -## Ключевые контейнеры и VM - -| ID | Назначение | IP | Заметки | -|-----|-------------------------|----------------|--------| -| 100 | NPM, Homepage, AdGuard, Wallos | 192.168.1.100 | Docker (NPM, Homepage, AdGuard, Wallos). Данные NPM: `/opt/docker/nginx-proxy/data`. Certbot для Let's Encrypt (в т.ч. DNS-01 через Beget). | -| 101 | Nextcloud | 192.168.1.101 | cloud.katykhin.ru. Docker Compose: Nextcloud + PostgreSQL + Redis, порт 8080. | -| 103 | Gitea + Actions | 192.168.1.103 | 1 core, 2 GB RAM, 15 GB (local-lvm). Gitea + PostgreSQL + act_runner, порты 3000 (HTTP), 2222 (SSH). Только LAN. | -| 104 | Paperless-ngx | 192.168.1.104 | docs.katykhin.ru, порт 8000. | -| 107 | Invidious (Misc) | 192.168.1.107 | video.katykhin.ru. 1 core, 2 GB RAM. Docker Compose: Invidious + Companion + PostgreSQL, порт 3000. | -| 108 | Galene (видеозвонки) | 192.168.1.108 | call.katykhin.ru → 192.168.1.108:8443. | -| 200 | Immich (KVM VM) | 192.168.1.200 | immich.katykhin.ru. Фото, ML на GPU (RTX 4060 Ti). Не LXC. | - ---- - -## Поток запросов (упрощённо) - -1. Запрос с интернета: `https://video.katykhin.ru` → роутер (порты 80/443 на 185.35.193.144) → проброс на 192.168.1.100. -2. NPM (контейнер 100) принимает HTTPS, проверяет Host, смотрит proxy_host → upstream: например 192.168.1.107:3000 для video.katykhin.ru, 192.168.1.200:2283 для immich.katykhin.ru и т.д. -3. Invidious (контейнер 107) отдаёт страницу и ссылки на стримы; при «без прокси» видео стримится напрямую с YouTube в браузер. - ---- - -## SSL-сертификаты - -- **Let's Encrypt:** через certbot на контейнере 100. Для доменов, где HTTP-01 недоступен, используется **DNS-01** (плагин certbot-dns-beget-api, учётные данные Beget в `/root/.secrets/certbot/beget.ini`). После выпуска/продления сертификаты копируются в NPM (custom_ssl) и перезагружается nginx в контейнере NPM. -- **Самоподписные:** при необходимости добавляются вручную в NPM (БД + файлы в custom_ssl). - ---- - -## Дополнительно - -- **Homepage:** на контейнере 100, конфиг сервисов в `/opt/docker/homepage/config/services.yaml` (ссылки на NPM, Invidious, AdGuard, Immich, Galene и т.д.). -- **VPS 185.103.253.99 (DE):** AmneziaWG для обхода блокировок; маршрутизация выбранных сетей через роутер (WireGuard netcraze_amnezia, 10.8.1.2). -- **VPS 147.45.124.117 (US):** второй AmneziaWG; на роутере можно добавить второе подключение с тем же адресом 10.8.1.2 и переключать DE/US без смены маршрутов. Подробно: [vpn-keenetic-us-second-connection.md](vpn-keenetic-us-second-connection.md). -- Подробнее по контейнерам и доступу: см. [containers-context.md](containers-context.md). diff --git a/homelab/containers-context.md b/homelab/containers-context.md deleted file mode 100644 index b0302fb..0000000 --- a/homelab/containers-context.md +++ /dev/null @@ -1,134 +0,0 @@ -# Контекст по контейнерам (Proxmox LXC) - -## Общая схема - -| ID | Назначение | IP | Заметки | -|-----|-------------------|----------------|---------| -| 100 | NPM, Homepage, лог-дашборд | 192.168.1.100 | Шлюз/дашборды | -| 101 | **Nextcloud** | 192.168.1.101 | — | -| 103 | **Gitea** | 192.168.1.103 | Gitea + PostgreSQL + act_runner (Gitea Actions). Порт 3000, SSH 2222. | -| 104 | Paperless-ngx | 192.168.1.104 | docs.katykhin.ru | -| 107 | Invidious | 192.168.1.107 | video.katykhin.ru | -| 108 | Galene | 192.168.1.108 | call.katykhin.ru | -| 200 | Immich (VM) | 192.168.1.200 | KVM, immich.katykhin.ru | - -**Хост Proxmox:** `192.168.1.150` -Вход на контейнеры — с хоста через `pct exec -- ...` или `pct enter `. - ---- - -## CT 101 — Nextcloud - -### Как попасть - -1. **С твоей машины на хост Proxmox:** - ```bash - ssh root@192.168.1.150 - ``` - *(пароль хоста — у тебя)* - -2. **С хоста — в контейнер 101:** - ```bash - pct enter 101 - ``` - Либо выполнить одну команду без входа в shell: - ```bash - pct exec 101 -- <команда> - ``` - *(пароль root в CT 101 — если включён и задан; часто тот же, что на хосте, или без пароля при `pct enter` с хоста)* - -3. **По сети (веб):** - - Внутри сети: `http://192.168.1.101:8080` - - Снаружи: `https://cloud.katykhin.ru` (прокси через NPM на 100). - -### Что внутри CT 101 - -- **Docker Compose** с тремя сервисами: - - **nextcloud** — порт `8080:80` - - **db** — PostgreSQL 16 - - **redis** — кэш/очереди - -- **Рабочая директория Compose:** `/opt/nextcloud` - Файл в репо: `homelab/nextcloud/docker-compose-101.yml` — на сервере обычно как `docker-compose.yml` в `/opt/nextcloud`. - -- **Systemd-сервис:** `docker-nextcloud.service` - Запуск/остановка: из контейнера 101: - ```bash - systemctl start docker-nextcloud # или restart/stop - ``` - Либо вручную: - ```bash - cd /opt/nextcloud && docker compose up -d - ``` - -### Креды (из репозитория) - -- **PostgreSQL (внутри CT 101):** - - DB: `nextcloud` - - User: `nextcloud` - - Password: `nextcloud` - *(из `docker-compose-101.yml`)* - -- **Nextcloud (приложение):** - Подключается к БД теми же кредыми. Админ-пользователь Nextcloud (логин/пароль веб-интерфейса) задаётся при первом запуске в UI — в репо не хранятся. - -- **Redis:** без пароля (только внутри Docker-сети). - -### Пути и конфиги внутри CT 101 - -- Данные PostgreSQL: `/mnt/nextcloud-data/pgdata` -- Данные Nextcloud (файлы, конфиг): `/mnt/nextcloud-data/html` -- Доп. том: `/mnt/nextcloud-extra` -- PHP uploads: `/opt/nextcloud/php-uploads.ini` (лимиты 64G, 2G memory и т.д. — см. `homelab/nextcloud/php-uploads.ini`) - -### Полезные команды (на хосте 192.168.1.150) - -```bash -# Войти в CT 101 -pct enter 101 - -# Логи Nextcloud -pct exec 101 -- bash -c 'cd /opt/nextcloud && docker compose logs -f nextcloud' - -# Статус контейнеров -pct exec 101 -- docker ps - -# Перезапуск стека Nextcloud -pct exec 101 -- bash -c 'cd /opt/nextcloud && docker compose restart' -``` - -### Чего нет в репо (нужны тебе локально) - -- Пароль `root` на Proxmox (192.168.1.150) -- Пароль `root` в CT 101 (если включён логин по паролю) -- Логин/пароль админа Nextcloud (веб-интерфейс) - ---- - -## CT 103 — Gitea - -### Как попасть - -1. С хоста Proxmox: `pct enter 103` или `pct exec 103 -- <команда>`. -2. По сети (веб): `http://192.168.1.103:3000` (только LAN, домен не настроен). - -### Что внутри CT 103 - -- **Docker Compose** в `/opt/gitea` (файл в репо: `homelab/gitea/docker-compose-103.yml`): - - **server** — Gitea, порты 3000 (HTTP), 2222 (SSH) - - **db** — PostgreSQL 16 (Alpine) - - **runner** — act_runner (Gitea Actions), использует Docker хоста - -- Токен регистрации раннера задаётся в `.env` (см. `homelab/gitea/.env.example`). Получить: Администрирование → Actions → Runners → Registration token. - -### Полезные команды (на хосте 192.168.1.150) - -```bash -pct enter 103 -pct exec 103 -- bash -c 'cd /opt/gitea && docker compose ps' -pct exec 103 -- bash -c 'cd /opt/gitea && docker compose logs -f runner' -``` - -### Создание контейнера 103 - -Инструкция и параметры (LXC 103, 1 core, 2 GB RAM, 15 GB на local-lvm, IP 192.168.1.103): см. [homelab/gitea/README.md](gitea/README.md). diff --git a/homelab/docs/migrate-nextcloud-to-hdd.md b/homelab/docs/migrate-nextcloud-to-hdd.md deleted file mode 100644 index 2a5e5cd..0000000 --- a/homelab/docs/migrate-nextcloud-to-hdd.md +++ /dev/null @@ -1,86 +0,0 @@ -# Перенос Nextcloud с SSD на HDD (освобождение SSD 1.9 TB) - -## Цель -Перенести все данные Nextcloud (БД, приложение, файлы пользователей, ~73 GB) с SSD (sdb 1.9 TB) на HDD (sdd 6.8 TB) и отмонтировать SSD для использования в других проектах. - -## Текущая схема -- **mp0:** `/mnt/ssd-storage/nextcloud-101` → `/mnt/nextcloud-data` (SSD, ~73 GB) -- **mp1:** `/mnt/nextcloud-hdd` → `/mnt/nextcloud-extra` (HDD, игры в корне) - -После переноса: -- **mp0:** `/mnt/nextcloud-hdd/nextcloud-101` → `/mnt/nextcloud-data` (на HDD) -- **mp1:** `/mnt/nextcloud-hdd` → `/mnt/nextcloud-extra` (как сейчас) -- Внешнее хранилище «Игры» будет указывать на `/mnt/nextcloud-extra/games` (подпапка), чтобы в списке не было каталога `nextcloud-101`. - -## Шаги - -### 1. Остановить Nextcloud и контейнер -На хосте: -```bash -ssh root@192.168.1.150 -pct exec 101 -- bash -c 'cd /opt/nextcloud && docker compose down' -pct stop 101 -``` - -### 2. На хосте: подготовить структуру на HDD -Создать папку `games` и перенести в неё все текущие папки из корня HDD (игры + common): -```bash -# На хосте 192.168.1.150 -mkdir -p /mnt/nextcloud-hdd/games -cd /mnt/nextcloud-hdd -for d in */ ; do - [ "$d" = "games/" ] && continue - mv "$d" games/ -done -``` -Проверка: в корне HDD остаются только `games/`, в нём — все 154 папки. - -### 3. Копировать nextcloud-101 с SSD на HDD -```bash -rsync -av --progress /mnt/ssd-storage/nextcloud-101/ /mnt/nextcloud-hdd/nextcloud-101/ -``` -Проверить объём: `du -sh /mnt/nextcloud-hdd/nextcloud-101` (~73 GB). - -### 4. Изменить конфиг контейнера 101 -```bash -# Заменить mp0 в /etc/pve/lxc/101.conf -# Было: mp0: /mnt/ssd-storage/nextcloud-101,mp=/mnt/nextcloud-data -# Стало: mp0: /mnt/nextcloud-hdd/nextcloud-101,mp=/mnt/nextcloud-data -sed -i 's|mp0: /mnt/ssd-storage/nextcloud-101,mp=/mnt/nextcloud-data|mp0: /mnt/nextcloud-hdd/nextcloud-101,mp=/mnt/nextcloud-data|' /etc/pve/lxc/101.conf -``` - -### 5. Запустить контейнер и Nextcloud -```bash -pct start 101 -# Подождать загрузки, затем: -pct exec 101 -- bash -c 'cd /opt/nextcloud && docker compose up -d' -``` - -### 6. Обновить путь внешнего хранилища «Игры» -Сейчас хранилище указывает на `/mnt/nextcloud-extra`. Нужно изменить на `/mnt/nextcloud-extra/games`, чтобы в «Игры» отображались только игры, без папки `nextcloud-101`: -```bash -pct exec 101 -- docker exec nextcloud-nextcloud-1 php occ files_external:config 1 datadir /mnt/nextcloud-extra/games -pct exec 101 -- docker exec nextcloud-nextcloud-1 php occ files_external:scan 1 -``` - -### 7. Проверить работу -- Открыть Nextcloud в браузере, зайти в «Игры» — должны быть все папки игр. -- Проверить «Мои файлы» и приложение. - -### 8. Отмонтировать SSD и убрать из fstab -Когда всё проверено и SSD больше не нужен: -```bash -umount /mnt/ssd-storage -# Удалить или закомментировать строку с /mnt/ssd-storage в /etc/fstab -sed -i.bak '/\/mnt\/ssd-storage/d' /etc/fstab -``` -После этого SSD можно физически отключить или использовать под другие разделы. - ---- - -## Сократить SSD до 200 GB (альтернатива) -Если SSD нужно оставить в сервере, но выделить под Nextcloud только 200 GB, а остальное под другие проекты: -- Текущее использование на SSD ~73 GB — в 200 GB поместится. -- Нужно: уменьшить раздел sdb1 и файловую систему до 200 GB (опасно, только с резервной копией), затем создать второй раздел на свободном месте. Либо сделать полный бэкап, переразбить диск (например, sdb1=200G, sdb2=остальное), отформатировать, восстановить данные. Это сложнее и рискованнее, чем перенос на HDD. - -Рекомендация: перенос на HDD проще и освобождает весь SSD. diff --git a/homelab/docs/nextcloud-quota-fix.md b/homelab/docs/nextcloud-quota-fix.md deleted file mode 100644 index d3e7819..0000000 --- a/homelab/docs/nextcloud-quota-fix.md +++ /dev/null @@ -1,33 +0,0 @@ -# Исправление отображения квоты и размера «Игры» в Nextcloud - -## Проблемы -1. **«Использовано 928,4 GB»** — устаревшее значение (остаток от папки «Игры» на SSD до переноса). -2. **«Игры» → «Ожидается»** — размер внешней папки ещё не посчитан или считается в фоне. - -## Что сделано на сервере - -### 1. Сброс кэша квоты -- Удалена запись `lastSeenQuotaUsage` для пользователя kerrad в `oc_preferences`. При следующем заходе Nextcloud пересчитает использование заново. - -### 2. Очистка устаревшего кэша хранилища -- В БД оставался старый «storage» для пути `/mnt/nextcloud-extra/` (корень до переноса игр в `games/`). В нём было ~844 тыс. записей и ~29 TB в кэше — это могло влиять на расчёт квоты. -- Удалены все записи этого хранилища из `oc_filecache` и строка из `oc_storages`. Сейчас используется только хранилище `local::/mnt/nextcloud-extra/games/`. - -### 3. Пересканирование файлов -- Выполнен `occ files:scan kerrad --all`: обновлён кэш домашнего хранилища (удалено 161 устаревшая запись). - -## Что сделать тебе - -1. **Жёсткое обновление страницы** - Обнови страницу Nextcloud с очисткой кэша браузера: **Ctrl+Shift+R** (или Cmd+Shift+R на Mac). Либо открой Nextcloud в режиме инкогнито. - После этого «Использовано» должно пересчитаться (ожидаемо порядка **~8–9 GB** для «домашних» файлов без учёта внешнего хранилища, в зависимости от настроек квоты). - -2. **«Игры» → «Ожидается»** - Размер больших внешних каталогов Nextcloud часто считает в фоне. Подожди несколько минут или зайди в «Игры» и открой папку — расчёт может запуститься/обновиться. Если через 10–15 минут по-прежнему «Ожидается», можно запустить на сервере повторное сканирование внешнего хранилища: - ```bash - ssh root@192.168.1.150 'pct exec 101 -- docker exec nextcloud-nextcloud-1 php occ files_external:scan 1' - ``` - -## Если 928 GB снова появится - -Проверь в настройках пользователя (Администрирование → Пользователи → kerrad), включено ли **«Учитывать внешние хранилища в квоте»**. Если да и в кэше внешнего хранилища были старые данные — значение могло быть завышено. После очистки старого storage и сброса `lastSeenQuotaUsage` при следующем пересчёте цифра должна стать адекватной. diff --git a/homelab/docs/storage-mount-layout.md b/homelab/docs/storage-mount-layout.md deleted file mode 100644 index a368c3e..0000000 --- a/homelab/docs/storage-mount-layout.md +++ /dev/null @@ -1,39 +0,0 @@ -# Разметка дисков и монтирование (хост Proxmox 192.168.1.150) - -## Текущая схема - -### Хост - -| Устройство | Размер | Раздел | Точка монтирования | Использовано | -|------------|--------|--------|--------------------|--------------| -| **sdd** (WDC WD80EFPX 8 TB) | 7.28 TiB | sdd1 **6.8 TB** ext4 | `/mnt/nextcloud-hdd` | ~5 TB (Nextcloud + Игры) | - -- **SSD (sdb) отмонтирован** и убран из fstab — используется в других проектах. -- **LVM не используется** для этих дисков — обычные разделы ext4. - -### Контейнер 101 (Nextcloud) - -| Хост (источник) | В контейнере (mp) | -|------------------|-------------------| -| `/mnt/nextcloud-hdd/nextcloud-101` | `/mnt/nextcloud-data` (БД, приложение, файлы пользователей) | -| `/mnt/nextcloud-hdd` | `/mnt/nextcloud-extra` | - -- **«Игры»** в Nextcloud = внешнее хранилище, путь в CT: `/mnt/nextcloud-extra/games` (подпапка на HDD). Все игры в `games/`. -- Nextcloud (pgdata, html, данные пользователей) перенесён с SSD на HDD в `/mnt/nextcloud-hdd/nextcloud-101`. - -### Раздел sdd (8 TB диск) - -Раздел sdd1 расширен до 6.8 TB, используется под Nextcloud (nextcloud-101) и игры (games/). - ---- - -## План: один том «Игры» 7.5 TB + том «Прочее» 200 GB - -1. **Расширить раздел и ФС на sdd до 7.5 TB** (на хосте, при остановленном использовании тома). ✅ Сделано. -2. **Перенести содержимое «Игры»** (928 GB) в `/mnt/nextcloud-hdd` (nextcloud-extra) и перенести игры из common в корень. ✅ Сделано. -3. **Удалить содержимое папки «Игры»** на SSD (освобождение ~928 GB). ✅ В процессе (find -delete). -4. **В Nextcloud:** внешнее хранилище переименовано в «Игры», переиндексация (files_external:scan) запущена. ✅ Сделано. -5. **Смонтировать новый раздел** с SSD диска на 200 гб, назвать его Прочее и подключить как внешнюю папку — в планах. - -Итого: одна сетевая папка Игры = смонтированный расширенный том **7.5 TB** на sdd. -Вторая сетевая папка Прочее = смонтированный как внешняя папка том 200 GB на SSD. \ No newline at end of file diff --git a/homelab/gitea/.env.example b/homelab/gitea/.env.example deleted file mode 100644 index 004708f..0000000 --- a/homelab/gitea/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# Скопировать в .env и подставить токен после первой настройки Gitea. -# Токен: Администрирование → Actions → Runners → Registration token. - -GITEA_RUNNER_REGISTRATION_TOKEN= diff --git a/homelab/gitea/README.md b/homelab/gitea/README.md deleted file mode 100644 index d8112ed..0000000 --- a/homelab/gitea/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# Gitea + Gitea Actions на CT 103 - -LXC 103: 192.168.1.103, 1 core, 2 GB RAM, 15 GB диск (local-lvm). -Доступ: только LAN, без домена. - -## 1. Создание LXC 103 на Proxmox - -Выполнять на хосте **192.168.1.150** (ssh root@192.168.1.150). - -### 1.1. Шаблон (если ещё нет) - -Проверить доступные шаблоны и при необходимости скачать Debian 12: - -```bash -pveam available | grep debian-12 -pveam download local debian-12-standard_12.7-1_amd64.tar.zst -# или актуальный из списка pveam available -pveam list local -``` - -Имя шаблона для `pct create` — как в `pveam list local`, например: `local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst`. - -### 1.2. Создать контейнер - -**Важно:** шлюз `gw=192.168.1.1` — поправь, если у тебя другой (например роутер на .1). - -```bash -pct create 103 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \ - --hostname gitea \ - --cores 1 \ - --memory 2048 \ - --swap 512 \ - --rootfs local-lvm:15 \ - --net0 name=eth0,bridge=vmbr0,ip=192.168.1.103/24,gw=192.168.1.1 \ - --features nesting=1 \ - --onboot 1 \ - --start 1 -``` - -`nesting=1` нужен для Docker внутри LXC. - -### 1.3. Проверить хранилище - -Если твоё хранилище называется иначе (не `local-lvm`): - -```bash -pvesm status -``` - -Если корневой диск на другом storage — замени в команде `--rootfs local-lvm:15` на свой, например `--rootfs SSD:15`. - ---- - -## 2. В контейнере 103: Docker и Gitea - -### 2.1. Войти в контейнер - -```bash -pct enter 103 -``` - -### 2.2. Установить Docker - -```bash -apt update && apt install -y ca-certificates curl -install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc -chmod a+r /etc/apt/keyrings/docker.asc -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list -apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin -``` - -### 2.3. Развернуть Gitea - -Файлы из репозитория (`homelab/gitea/`) нужно положить в контейнер в `/opt/gitea/`. С хоста Proxmox, если репо уже есть на хосте: - -```bash -pct exec 103 -- mkdir -p /opt/gitea -pct push 103 /path/на/хосте/к/homelab/gitea/docker-compose-103.yml /opt/gitea/docker-compose.yml -pct push 103 /path/на/хосте/к/homelab/gitea/.env.example /opt/gitea/.env.example -``` - -В контейнере: - -```bash -mkdir -p /opt/gitea -cd /opt/gitea -# положить docker-compose-103.yml как docker-compose.yml и .env.example как .env -cp .env.example .env -# пока токен пустой — раннер не зарегистрируется, это нормально -docker compose up -d -``` - -### 2.4. Первый запуск Gitea - -1. В браузере: **http://192.168.1.103:3000** -2. Пройти установку (админ, пароль, остальное по желанию). -3. После входа: **Администрирование** → **Actions** → **Runners** → скопировать **Registration token**. -4. В контейнере в `/opt/gitea/.env` прописать: - ```bash - GITEA_RUNNER_REGISTRATION_TOKEN=вставь_токен_сюда - ``` -5. Перезапустить раннер: - ```bash - cd /opt/gitea && docker compose up -d runner - ``` - -Раннер должен появиться в **Actions → Runners** со статусом «Online». - ---- - -## 3. Полезные команды (с хоста 192.168.1.150) - -```bash -pct enter 103 -pct exec 103 -- bash -c 'cd /opt/gitea && docker compose ps' -pct exec 103 -- bash -c 'cd /opt/gitea && docker compose logs -f server' -``` - ---- - -## 4. Homepage (контейнер 100) - -Добавить в `/opt/docker/homepage/config/services.yaml` запись для Gitea (в нужную группу), например: - -```yaml -- Gitea: - icon: gitea.png - href: http://192.168.1.103:3000 - description: Git-сервер (LAN) - widget: - type: iframe - url: http://192.168.1.103:3000 -``` - -Иконку `gitea.png` при необходимости положить в каталог иконок Homepage. - -Готовый фрагмент для вставки: [homepage-services-snippet.yaml](homepage-services-snippet.yaml). diff --git a/homelab/gitea/create-lxc-103.sh b/homelab/gitea/create-lxc-103.sh deleted file mode 100644 index c49e180..0000000 --- a/homelab/gitea/create-lxc-103.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Создание LXC 103 для Gitea на Proxmox. -# Запускать на хосте: ssh root@192.168.1.150 'bash -s' < create-lxc-103.sh -# Или скопировать на хост и выполнить там. -# -# Перед запуском: скачать шаблон (если нет): -# pveam download local debian-12-standard_12.7-1_amd64.tar.zst -# pveam list local -# Подставить актуальное имя шаблона в TEMPLATE ниже. -# Поправить GW= если шлюз не 192.168.1.1. - -set -e -VMID=103 -TEMPLATE="local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst" -GW="${GW:-192.168.1.1}" - -echo "Creating CT ${VMID} (Gitea), template=${TEMPLATE}, gw=${GW}" -pct create ${VMID} "${TEMPLATE}" \ - --hostname gitea \ - --cores 1 \ - --memory 2048 \ - --swap 512 \ - --rootfs local-lvm:15 \ - --net0 name=eth0,bridge=vmbr0,ip=192.168.1.103/24,gw="${GW}" \ - --features nesting=1 \ - --onboot 1 \ - --start 1 - -echo "CT ${VMID} created and started. Next: pct enter ${VMID}, install Docker, deploy compose (see README.md)." diff --git a/homelab/gitea/docker-compose-103.yml b/homelab/gitea/docker-compose-103.yml deleted file mode 100644 index d7e083c..0000000 --- a/homelab/gitea/docker-compose-103.yml +++ /dev/null @@ -1,71 +0,0 @@ -# Gitea + PostgreSQL + act_runner (Gitea Actions) -# Контейнер 103, IP 192.168.1.103 -# Запуск: в /opt/gitea на CT 103 - -services: - db: - image: docker.io/library/postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_USER: gitea - POSTGRES_PASSWORD: gitea - POSTGRES_DB: gitea - volumes: - - gitea-postgres:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U gitea"] - interval: 10s - timeout: 5s - retries: 5 - - server: - image: docker.gitea.com/gitea:1.25 - container_name: gitea - restart: unless-stopped - depends_on: - db: - condition: service_healthy - environment: - USER_UID: 1000 - USER_GID: 1000 - GITEA__database__DB_TYPE: postgres - GITEA__database__HOST: db:5432 - GITEA__database__NAME: gitea - GITEA__database__USER: gitea - GITEA__database__PASSWD: gitea - GITEA__server__DOMAIN: 192.168.1.103 - GITEA__server__ROOT_URL: http://192.168.1.103:3000/ - GITEA__server__SSH_PORT: 2222 - volumes: - - gitea-data:/data - - /etc/timezone:/etc/timezone:ro - - /etc/localtime:/etc/localtime:ro - ports: - - "3000:3000" - - "2222:22" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 30s - - runner: - image: docker.io/gitea/act_runner:latest - restart: unless-stopped - depends_on: - server: - condition: service_healthy - volumes: - - runner-data:/data - - /var/run/docker.sock:/var/run/docker.sock - environment: - GITEA_INSTANCE_URL: http://server:3000 - GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-} - GITEA_RUNNER_NAME: gitea-103-runner - GITEA_RUNNER_LABELS: docker:docker://alpine:latest - -volumes: - gitea-data: - gitea-postgres: - runner-data: diff --git a/homelab/gitea/homepage-services-snippet.yaml b/homelab/gitea/homepage-services-snippet.yaml deleted file mode 100644 index b49d04d..0000000 --- a/homelab/gitea/homepage-services-snippet.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Вставить в /opt/docker/homepage/config/services.yaml на контейнере 100 -# в нужную группу (например «Разработка» или «Сервисы»). - -- Gitea: - icon: gitea.png - href: http://192.168.1.103:3000 - description: Git-сервер и Gitea Actions (LAN) diff --git a/homelab/immich/proxmox-config.md b/homelab/immich/proxmox-config.md deleted file mode 100644 index 18a06fe..0000000 --- a/homelab/immich/proxmox-config.md +++ /dev/null @@ -1,75 +0,0 @@ -# Конфигурация Immich (LXC) - -## Текущее → Целевое - -| Ресурс | Было | Стало | -|--------|------|-------| -| CPU | 8 cores | 3 cores | -| RAM | 10 GB | 8 GB | -| Swap | — | 4 GB (файл) | - ---- - -## 1. Настройка ресурсов Proxmox - -На хосте Proxmox (192.168.1.150): - -```bash -# Замени на реальный ID контейнера Immich -pct set --cores 3 -pct set --memory 8192 -``` - ---- - -## 2. Файл подкачки внутри контейнера - -Войти в контейнер: - -```bash -pct enter -``` - -Создать swap 4 GB: - -```bash -# Создать файл 4 GB -fallocate -l 4G /swapfile - -# Безопасность -chmod 600 /swapfile - -# Инициализировать swap -mkswap /swapfile - -# Включить -swapon /swapfile - -# Сделать постоянным (добавить в fstab) -echo '/swapfile none swap sw 0 0' >> /etc/fstab -``` - -Проверить: - -```bash -free -h -``` - -Должно быть: `Swap: 4.0Gi`. - ---- - -## 3. Опционально: swappiness - -Чтобы swap использовался не слишком агрессивно: - -```bash -# Текущее (обычно 60) -cat /proc/sys/vm/swappiness - -# Уменьшить до 10 (рекомендуется для серверов) -echo 'vm.swappiness=10' >> /etc/sysctl.conf -sysctl -p -``` - -Для LXC это может не сработать (настраивается на хосте). Если контейнер не видит изменение — не критично. diff --git a/homelab/immich/setup-swap.sh b/homelab/immich/setup-swap.sh deleted file mode 100644 index 4319a14..0000000 --- a/homelab/immich/setup-swap.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -# Создание swap-файла 4 GB внутри LXC-контейнера Immich -# Запускать внутри контейнера (pct enter ) - -set -e - -SWAP_SIZE="${1:-4G}" - -echo "Создаю swap-файл ${SWAP_SIZE}..." -fallocate -l "$SWAP_SIZE" /swapfile -chmod 600 /swapfile -mkswap /swapfile -swapon /swapfile - -if ! grep -q '/swapfile' /etc/fstab; then - echo '/swapfile none swap sw 0 0' >> /etc/fstab - echo "Добавлено в /etc/fstab" -fi - -echo "Готово. Swap:" -free -h | grep -E "Mem|Swap" diff --git a/homelab/nextcloud/docker-compose-101.yml b/homelab/nextcloud/docker-compose-101.yml deleted file mode 100644 index 824c721..0000000 --- a/homelab/nextcloud/docker-compose-101.yml +++ /dev/null @@ -1,46 +0,0 @@ -services: - db: - image: docker.io/library/postgres:16 - restart: unless-stopped - volumes: - - /mnt/nextcloud-data/pgdata:/var/lib/postgresql/data - environment: - POSTGRES_DB: nextcloud - POSTGRES_USER: nextcloud - POSTGRES_PASSWORD: nextcloud - healthcheck: - test: ["CMD-SHELL", "pg_isready -U nextcloud"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: docker.io/library/redis:7-alpine - restart: unless-stopped - command: redis-server --appendonly yes - - nextcloud: - image: docker.io/nextcloud:latest - restart: unless-stopped - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - ports: - - "8080:80" - volumes: - - /mnt/nextcloud-data/html:/var/www/html - - /mnt/nextcloud-extra:/mnt/nextcloud-extra - - /opt/nextcloud/php-uploads.ini:/usr/local/etc/php/conf.d/zz-uploads.ini:ro - environment: - APACHE_BODY_LIMIT: "0" - NEXTCLOUD_TRUSTED_DOMAINS: cloud.katykhin.ru 192.168.1.101 - OVERWRITEPROTOCOL: https - OVERWRITEHOST: cloud.katykhin.ru - OVERWRITECLIURL: https://cloud.katykhin.ru - REDIS_HOST: redis - POSTGRES_HOST: db - POSTGRES_DB: nextcloud - POSTGRES_USER: nextcloud - POSTGRES_PASSWORD: nextcloud diff --git a/homelab/nextcloud/docker-nextcloud.service b/homelab/nextcloud/docker-nextcloud.service deleted file mode 100644 index 31bb6e0..0000000 --- a/homelab/nextcloud/docker-nextcloud.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Docker Compose Nextcloud -Requires=docker.service -After=docker.service - -[Service] -Type=oneshot -RemainAfterExit=yes -WorkingDirectory=/opt/nextcloud -ExecStart=/usr/bin/docker compose up -d -ExecStop=/usr/bin/docker compose down -TimeoutStartSec=0 - -[Install] -WantedBy=multi-user.target diff --git a/homelab/nextcloud/php-uploads.ini b/homelab/nextcloud/php-uploads.ini deleted file mode 100644 index e337ea9..0000000 --- a/homelab/nextcloud/php-uploads.ini +++ /dev/null @@ -1,6 +0,0 @@ -; Large file uploads for Nextcloud (games, etc) -upload_max_filesize = 64G -post_max_size = 64G -memory_limit = 2G -max_execution_time = 7200 -max_input_time = 7200 diff --git a/homelab/npm-log-dashboard/__pycache__/gen-dashboard.cpython-313.pyc b/homelab/npm-log-dashboard/__pycache__/gen-dashboard.cpython-313.pyc deleted file mode 100644 index 8702194..0000000 Binary files a/homelab/npm-log-dashboard/__pycache__/gen-dashboard.cpython-313.pyc and /dev/null differ diff --git a/homelab/npm-log-dashboard/gen-dashboard.py b/homelab/npm-log-dashboard/gen-dashboard.py deleted file mode 100644 index cddecba..0000000 --- a/homelab/npm-log-dashboard/gen-dashboard.py +++ /dev/null @@ -1,404 +0,0 @@ -#!/usr/bin/env python3 -""" -Генерирует HTML-дашборд по access-логам Nginx Proxy Manager. -- Сводка по доменам (2 суток) + по IP (топ-5 по каждому домену + остальные) с геолокацией. -- Лента запросов с пагинацией и фильтром по домену (клиентский JS). -Геолокация: ip-api.com с кэшем (локальная сеть для 10.x, 192.168.x, 127.x). -""" -import base64 -import json -import re -import sys -import time -import urllib.request -from collections import defaultdict -from datetime import datetime, timedelta, timezone -from pathlib import Path - -LOG_DIR = Path("/opt/docker/nginx-proxy/data/logs") -CACHE_FILE = Path("/opt/docker/log-dashboard/ip_cache.json") -FEED_MAX = 2_000 # макс. записей в ленте (встроено в HTML, ~300 KB) -GEO_MAX_NEW_PER_RUN = 40 # ip-api.com limit 45/min -API_URL = "http://ip-api.com/json/{ip}?fields=status,country,city,isp" - -LINE_RE = re.compile( - r'\[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+\+\d{4})\]\s+-\s+(\d+)\s+\d+\s+-\s+(\w+)\s+\w+\s+([^\s]+)\s+"([^"]*)"\s+\[Client\s+([^\]]+)\]' -) - - -def parse_date(s: str) -> datetime | None: - try: - return datetime.strptime(s.strip(), "%d/%b/%Y:%H:%M:%S %z") - except Exception: - return None - - -def parse_line(line: str) -> dict | None: - m = LINE_RE.search(line) - if not m: - return None - date_s, status, method, domain, path, client = m.groups() - dt = parse_date(date_s) - if not dt: - return None - return { - "time": dt, - "date_s": date_s, - "status": status, - "method": method, - "domain": domain, - "path": (path or "/")[:80], - "client": client.strip(), - } - - -def is_private_ip(ip: str) -> bool: - if not ip or ip == "-": - return True - parts = ip.split(".") - if len(parts) != 4: - return True - try: - a, b, c, d = (int(x) for x in parts) - if a == 10: - return True - if a == 172 and 16 <= b <= 31: - return True - if a == 192 and b == 168: - return True - if a == 127: - return True - except ValueError: - return True - return False - - -def load_geo_cache() -> dict: - if CACHE_FILE.exists(): - try: - with open(CACHE_FILE, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - pass - return {} - - -def save_geo_cache(cache: dict) -> None: - try: - CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(CACHE_FILE, "w", encoding="utf-8") as f: - json.dump(cache, f, ensure_ascii=False, indent=0) - except Exception: - pass - - -def fetch_geo(ip: str) -> str: - if is_private_ip(ip): - return "Локальная сеть" - try: - req = urllib.request.Request(API_URL.format(ip=ip), headers={"User-Agent": "NPM-Log-Dashboard/1"}) - with urllib.request.urlopen(req, timeout=3) as r: - data = json.loads(r.read().decode()) - if data.get("status") != "success": - return "—" - parts = [data.get("country") or "", data.get("city") or ""] - loc = ", ".join(p for p in parts if p).strip() or "—" - isp = (data.get("isp") or "").strip() - if isp: - loc = f"{loc} ({isp})" if loc != "—" else isp - return loc or "—" - except Exception: - return "—" - - -def ensure_geo_for_ips(cache: dict, ips: list[str], max_new: int) -> None: - to_fetch = [ip for ip in ips if ip and not is_private_ip(ip) and ip not in cache] - to_fetch = to_fetch[:max_new] - for ip in to_fetch: - cache[ip] = fetch_geo(ip) - time.sleep(1.35) # ~44/min to stay under 45 - - -def main(): - out_path = sys.argv[1] if len(sys.argv) > 1 else None - try: - now = datetime.now(timezone.utc) - except Exception: - now = datetime.utcnow() - two_days_ago = now - timedelta(days=2) - try: - t_cut = two_days_ago.timestamp() - except Exception: - t_cut = (two_days_ago - datetime(1970, 1, 1)).total_seconds() - - counts_by_domain: dict[str, int] = defaultdict(int) - counts_by_ip: dict[str, int] = defaultdict(int) - counts_by_domain_ip: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) - all_entries: list[dict] = [] - - log_files = sorted(LOG_DIR.glob("proxy-host-*_access.log")) - for log_path in log_files: - try: - with open(log_path, "r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines() - except Exception: - continue - for line in lines: - entry = parse_line(line) - if not entry: - continue - domain = entry["domain"] - client = entry["client"] - try: - et = entry["time"].timestamp() - except Exception: - try: - et = entry["time"].replace(tzinfo=timezone.utc).timestamp() - except Exception: - et = 0 - if et >= t_cut: - counts_by_domain[domain] += 1 - counts_by_ip[client] += 1 - counts_by_domain_ip[domain][client] += 1 - all_entries.append(entry) - - # Лента = все запросы за последние 2 суток (с лимитом FEED_MAX для размера HTML) - def entry_ts(e: dict) -> float: - try: - return e["time"].timestamp() - except Exception: - try: - return e["time"].replace(tzinfo=timezone.utc).timestamp() - except Exception: - return 0.0 - feed_2d = [e for e in all_entries if entry_ts(e) >= t_cut] - feed_2d.sort(key=lambda x: x["time"], reverse=True) - total_2d = len(feed_2d) - feed = feed_2d[:FEED_MAX] - feed_capped = total_2d > FEED_MAX - feed_serial = [ - {"t": e["date_s"][:20], "d": e["domain"], "m": e["method"], "p": e["path"], "s": e["status"], "c": e["client"]} - for e in feed - ] - - # Порядок доменов как в сводке (по убыванию запросов) - domain_order = sorted(counts_by_domain.keys(), key=lambda d: -counts_by_domain[d]) - # Геокэш: топ-5 IP по каждому домену + часть уникальных из ленты - geo_cache = load_geo_cache() - ips_for_geo = [] - for d in domain_order: - top5_for_d = [ip for ip, _ in sorted(counts_by_domain_ip[d].items(), key=lambda x: -x[1])[:5]] - ips_for_geo.extend(top5_for_d) - ips_for_geo = list(dict.fromkeys(ips_for_geo)) - feed_ips = list(dict.fromkeys(e["client"] for e in feed[:500])) - ensure_geo_for_ips(geo_cache, ips_for_geo + feed_ips, GEO_MAX_NEW_PER_RUN) - save_geo_cache(geo_cache) - - def geo_label(ip: str) -> str: - if is_private_ip(ip): - return "Локальная сеть" - return geo_cache.get(ip, "—") - - # Сводка по доменам (таблица) - summary_domain_rows = [] - for domain in domain_order: - summary_domain_rows.append(f"{domain}{counts_by_domain[domain]}") - summary_domain_table = "\n".join(summary_domain_rows) - - # Сводка по IP: топ-5 по каждому домену + остальные - summary_ip_parts = [] - for domain in domain_order: - sorted_ips_d = sorted(counts_by_domain_ip[domain].items(), key=lambda x: -x[1]) - top5_d = sorted_ips_d[:5] - rest_d = sum(c for _, c in sorted_ips_d[5:]) - rows = [] - for ip, cnt in top5_d: - geo = geo_label(ip) - link_2ip = f'{ip}' - rows.append(f"{link_2ip}{cnt}{geo}") - if rest_d: - rows.append(f'Остальные IP{rest_d}—') - summary_ip_parts.append( - f'{domain}\n' + "\n".join(rows) - ) - summary_ip_table = "\n".join(summary_ip_parts) - - # Гео для всех IP в кэше (для вывода в ленте в JS) - geo_map = {ip: geo_label(ip) for ip in set(e["client"] for e in feed)} - geo_map_json = json.dumps(geo_map, ensure_ascii=False) - - domains_list = domain_order - feed_note = f" (показаны последние {FEED_MAX:,} из {total_2d:,})" if feed_capped else "" - feed_count = len(feed_serial) - - _gen_time = datetime.now().strftime("%d.%m.%Y %H:%M") - # Favicon: мини-терминал с логами (тёмный фон, cyan строки) - favicon_svg = """ - - - - - - -""" - favicon_data_url = "data:image/svg+xml;base64," + base64.b64encode(favicon_svg.encode("utf-8")).decode("ascii") - html_start = f""" - - - - - - Обращения к внешним URL (NPM) - - - - -

Обращения к внешним URL (Nginx Proxy Manager)

-

Сводка за 2 суток; лента — последние {feed_count:,} запросов. Расчёт по крону раз в 15 мин. Сгенерировано: {_gen_time}

- -

Запросы по доменам (2 суток)

- - - {summary_domain_table} -
ДоменЗапросов
- -

Запросы по IP (топ-5 по каждому домену + остальные)

- - - {summary_ip_table} -
IPЗапросовМестоположение
- -

Лента запросов (последние {feed_count:,})

-
- - -
- - - -
ВремяДоменМетодПутьСтатусClientМестоположение
- - - - - - -""" - # JSON для ленты: экранируем чтобы не закрыть тег - payload = {"feed": feed_serial, "geo": geo_map, "domains": domains_list} - feed_json_raw = json.dumps(payload, ensure_ascii=False) - feed_json_safe = feed_json_raw.replace("<", "\\u003c").replace(">", "\\u003e") - - if out_path: - out_dir = Path(out_path).parent - out_dir.mkdir(parents=True, exist_ok=True) - html_full = html_start.replace('', - '') - with open(out_path, "w", encoding="utf-8") as f: - f.write(html_full) - else: - html_full = html_start.replace('', - '') - print(html_full) - - -if __name__ == "__main__": - main() diff --git a/homelab/npm-log-dashboard/verify-dashboard.py b/homelab/npm-log-dashboard/verify-dashboard.py deleted file mode 100644 index dfcac9b..0000000 --- a/homelab/npm-log-dashboard/verify-dashboard.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""Проверка сгенерированного index.html: JSON в feed-data и логика ленты.""" -import json -import re -import sys - -def main(): - path = sys.argv[1] if len(sys.argv) > 1 else "/opt/docker/log-dashboard/html/index.html" - with open(path, "r", encoding="utf-8") as f: - html = f.read() - - m = re.search(r'', html, re.DOTALL) - if not m: - print("FAIL: feed-data block not found") - return 1 - - raw = m.group(1).strip() - try: - data = json.loads(raw) - except Exception as e: - print("FAIL: JSON parse error:", e) - return 1 - - for key in ("feed", "geo", "domains"): - if key not in data: - print("FAIL: missing key", key) - return 1 - - feed, geo, domains = data["feed"], data["geo"], data["domains"] - if not isinstance(feed, list) or not isinstance(geo, dict) or not isinstance(domains, list): - print("FAIL: wrong types") - return 1 - - if len(feed) == 0: - print("WARN: feed is empty") - else: - e = feed[0] - for k in ("t", "d", "m", "p", "s", "c"): - if k not in e: - print("FAIL: feed entry missing", k) - return 1 - - # Симуляция getFiltered() + render() для страницы 1 - domain = domains[0] if domains else "" - filtered = [x for x in feed if x["d"] == domain] if domain else feed - page_size = 100 - current_page = 1 - total_pages = max(1, (len(filtered) + page_size - 1) // page_size) - start = (current_page - 1) * page_size - page = filtered[start : start + page_size] - - rows = [] - for e in page: - geo_val = geo.get(e.get("c"), "—") - rows.append((e.get("t"), e.get("d"), e.get("m"), e.get("p"), e.get("s"), e.get("c"), geo_val)) - - if not page and filtered: - print("FAIL: page is empty but filtered has items") - return 1 - if len(rows) != len(page): - print("FAIL: row count mismatch") - return 1 - - print("OK: feed entries:", len(feed)) - print("OK: geo entries:", len(geo)) - print("OK: domains:", len(domains)) - print("OK: page 1 rows:", len(rows), "| sample:", rows[0][:3] if rows else "n/a") - return 0 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/homelab/paperless-ngx/docker-compose-104.yml b/homelab/paperless-ngx/docker-compose-104.yml deleted file mode 100644 index 8f8f114..0000000 --- a/homelab/paperless-ngx/docker-compose-104.yml +++ /dev/null @@ -1,37 +0,0 @@ -services: - broker: - image: docker.io/library/redis:8 - restart: unless-stopped - volumes: - - redisdata:/data - - db: - image: docker.io/library/postgres:18 - restart: unless-stopped - volumes: - - /mnt/paperless-data/pgdata:/var/lib/postgresql - environment: - POSTGRES_DB: paperless - POSTGRES_USER: paperless - POSTGRES_PASSWORD: paperless - - webserver: - image: ghcr.io/paperless-ngx/paperless-ngx:latest - restart: unless-stopped - depends_on: - - db - - broker - ports: - - "8000:8000" - volumes: - - /mnt/paperless-data/data:/usr/src/paperless/data - - /mnt/paperless-data/media:/usr/src/paperless/media - - ./export:/usr/src/paperless/export - - ./consume:/usr/src/paperless/consume - env_file: docker-compose.env - environment: - PAPERLESS_REDIS: redis://broker:6379 - PAPERLESS_DBHOST: db - -volumes: - redisdata: diff --git a/homelab/paperless-ngx/docker-compose.env b/homelab/paperless-ngx/docker-compose.env deleted file mode 100644 index 13d777c..0000000 --- a/homelab/paperless-ngx/docker-compose.env +++ /dev/null @@ -1,8 +0,0 @@ -# Paperless-ngx settings -# See http://docs.paperless-ngx.com/configuration/ - -PAPERLESS_URL=https://docs.katykhin.ru -PAPERLESS_SECRET_KEY=a8e2c118da39d15fc7f2691f969ddc0e807907bcfdd742b8af943a0cc2a61773 -PAPERLESS_TIME_ZONE=Europe/Moscow -PAPERLESS_OCR_LANGUAGE=rus+eng -PAPERLESS_OCR_LANGUAGES=rus diff --git a/homelab/paperless-ngx/docker-compose.yml b/homelab/paperless-ngx/docker-compose.yml deleted file mode 100644 index 8566d2c..0000000 --- a/homelab/paperless-ngx/docker-compose.yml +++ /dev/null @@ -1,40 +0,0 @@ -services: - broker: - image: docker.io/library/redis:8 - restart: unless-stopped - volumes: - - redisdata:/data - - db: - image: docker.io/library/postgres:18 - restart: unless-stopped - volumes: - - pgdata:/var/lib/postgresql - environment: - POSTGRES_DB: paperless - POSTGRES_USER: paperless - POSTGRES_PASSWORD: paperless - - webserver: - image: ghcr.io/paperless-ngx/paperless-ngx:latest - restart: unless-stopped - depends_on: - - db - - broker - ports: - - "8000:8000" - volumes: - - data:/usr/src/paperless/data - - media:/usr/src/paperless/media - - ./export:/usr/src/paperless/export - - ./consume:/usr/src/paperless/consume - env_file: docker-compose.env - environment: - PAPERLESS_REDIS: redis://broker:6379 - PAPERLESS_DBHOST: db - -volumes: - data: - media: - pgdata: - redisdata: diff --git a/homelab/paperless-ngx/docker-paperless.service b/homelab/paperless-ngx/docker-paperless.service deleted file mode 100644 index b341163..0000000 --- a/homelab/paperless-ngx/docker-paperless.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Docker Compose Paperless-ngx -Requires=docker.service -After=docker.service - -[Service] -Type=oneshot -RemainAfterExit=yes -WorkingDirectory=/opt/paperless -ExecStart=/usr/bin/docker compose up -d -ExecStop=/usr/bin/docker compose down -TimeoutStartSec=0 - -[Install] -WantedBy=multi-user.target diff --git a/homelab/paperless-ollama-README.md b/homelab/paperless-ollama-README.md deleted file mode 100644 index 01de959..0000000 --- a/homelab/paperless-ollama-README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Paperless + Ollama: вопросы по документам - -Скрипт ищет по документам в Paperless-ngx (full-text по OCR) и отвечает на вопрос, подставляя найденный текст в модель Ollama (Saiga). - -## Требования - -- Python 3.9+ -- Доступ к API Paperless-ngx (контейнер 104 → укажи его URL) -- Доступ к Ollama (например на VM 192.168.1.200:11434) - -## Переменные окружения - -| Переменная | Обязательная | По умолчанию | Пример | -|------------|--------------|--------------|--------| -| `PAPERLESS_URL` | да | — | `http://192.168.1.104:8000` | -| `PAPERLESS_TOKEN` | да | — | токен из Paperless (см. ниже) | -| `OLLAMA_URL` | нет | `http://localhost:11434` | `http://192.168.1.200:11434` если скрипт на другой машине | -| `OLLAMA_MODEL` | нет | `saiga` | имя модели в Ollama | -| `PAPERLESS_MAX_DOCS` | нет | `5` | сколько документов подставлять в промпт | - -## Токен Paperless - -В веб-интерфейсе Paperless: **My Profile** (меню пользователя) → кнопка обновления токена (circular arrow) → скопировать токен. Либо создать токен в Django admin. - -## Запуск - -```bash -cd homelab -export PAPERLESS_URL="http://192.168.1.104:8000" # или IP/порт где крутится контейнер 104 -export PAPERLESS_TOKEN="твой_токен" - -# Если Ollama на другой машине: -export OLLAMA_URL="http://192.168.1.200:11434" - -python3 paperless-ollama-ask.py "номер паспорта?" -python3 paperless-ollama-ask.py "когда истекает договор?" -``` - -Скрипт: ищет в Paperless по фразе (по OCR), берёт до 5 документов, подставляет их текст в промпт и отправляет в Ollama. Ответ выводится в stdout. - -## Где запускать - -- **На VM с Ollama (192.168.1.200):** `OLLAMA_URL=http://localhost:11434`, `PAPERLESS_URL` — адрес контейнера 104 (например `http://192.168.1.104:8000` или `http://host-104:8000` если есть DNS). -- **С своей машины:** оба URL с IP (Ollama и Paperless), порт 11434 и 8000 соответственно. diff --git a/homelab/paperless-ollama-ask.py b/homelab/paperless-ollama-ask.py deleted file mode 100644 index 6fe2ca9..0000000 --- a/homelab/paperless-ollama-ask.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -""" -Спрашивает по документам Paperless-ngx через поиск по OCR и отвечает через Ollama. -Использование: PAPERLESS_URL=... PAPERLESS_TOKEN=... python paperless-ollama-ask.py "номер паспорта?" -""" -import json -import os -import sys -import urllib.parse -import urllib.request - - -def env(name: str, default: str = "") -> str: - v = os.environ.get(name, default).strip() - if not v and name.startswith("PAPERLESS_"): - raise SystemExit(f"Set {name} (e.g. PAPERLESS_URL=http://192.168.1.104:8000 PAPERLESS_TOKEN=...)") - return v - - -# Слова, которые редко есть в OCR тексте документов — выкидываем при запасном поиске -_STOP_WORDS = frozenset( - "кем какой какая какие как где когда что кто чей чья чьи откуда куда зачем почему сколько".split() -) - - -def search_query_for_paperless(question: str) -> str: - """Убираем пунктуацию, которая может ломать full-text поиск Paperless (?, ! и т.д.).""" - s = question.strip().rstrip("?!.;") - return s if s else question - - -def fallback_search_query(question: str, max_words: int = 4) -> str: - """Короткий запрос для повторного поиска: ключевые слова без вопросительных слов.""" - q = search_query_for_paperless(question) - words = [w for w in q.split() if len(w) >= 2 and w.lower() not in _STOP_WORDS] - return " ".join(words[:max_words]) if words else q - - -def paperless_search(base_url: str, token: str, query: str, max_docs: int = 5) -> list[dict]: - """Full-text поиск по документам, возвращает список с полем content (OCR).""" - base_url = base_url.rstrip("/") - search_q = search_query_for_paperless(query) - query_encoded = urllib.parse.quote(search_q, encoding="utf-8", safe="") - url = f"{base_url}/api/documents/?query={query_encoded}&page_size={max_docs}" - req = urllib.request.Request(url) - req.add_header("Authorization", f"Token {token}") - req.add_header("Accept", "application/json; version=6") - if os.environ.get("PAPERLESS_DEBUG"): - print(f"[DEBUG] GET {url}", file=sys.stderr) - with urllib.request.urlopen(req, timeout=30) as r: - data = r.read().decode("utf-8") - out = json.loads(data) - results = out.get("results") or [] - if os.environ.get("PAPERLESS_DEBUG"): - print(f"[DEBUG] count={out.get('count', 0)} results={len(results)}", file=sys.stderr) - return results - - -def ollama_generate(ollama_url: str, model: str, prompt: str, timeout: int = 120) -> str: - """Один запрос к Ollama /api/generate, возвращает response.""" - ollama_url = ollama_url.rstrip("/") - url = f"{ollama_url}/api/generate" - body = {"model": model, "prompt": prompt, "stream": False} - data = json.dumps(body).encode() - req = urllib.request.Request(url, data=data, method="POST") - req.add_header("Content-Type", "application/json") - with urllib.request.urlopen(req, timeout=timeout) as r: - out = json.loads(r.read().decode("utf-8")) - return (out.get("response") or "").strip() - - -def main(): - question = " ".join(sys.argv[1:]).strip() - if not question: - print("Usage: PAPERLESS_URL=... PAPERLESS_TOKEN=... python paperless-ollama-ask.py 'ваш вопрос'", file=sys.stderr) - sys.exit(1) - - base_url = env("PAPERLESS_URL", "http://localhost:8000") - token = env("PAPERLESS_TOKEN") - ollama_url = env("OLLAMA_URL", "http://localhost:11434") - model = env("OLLAMA_MODEL", "saiga") - max_docs = int(env("PAPERLESS_MAX_DOCS", "5")) - - # Поиск по OCR: сначала полный вопрос, при пустом ответе — по ключевым словам - try: - docs = paperless_search(base_url, token, question, max_docs=max_docs) - if not docs: - fallback = fallback_search_query(question) - if fallback and fallback != search_query_for_paperless(question): - docs = paperless_search(base_url, token, fallback, max_docs=max_docs) - if os.environ.get("PAPERLESS_DEBUG") and docs: - print(f"[DEBUG] fallback query '{fallback}' found {len(docs)} docs", file=sys.stderr) - except urllib.error.HTTPError as e: - print(f"Paperless API error: {e.code} {e.reason}", file=sys.stderr) - if e.code == 401: - print("Check PAPERLESS_TOKEN (My Profile → API token in Paperless UI).", file=sys.stderr) - sys.exit(2) - except Exception as e: - print(f"Paperless request failed: {e}", file=sys.stderr) - sys.exit(2) - - if not docs: - print("По документам ничего не найдено.") - return - - # Собираем текст из документов (OCR content) - parts = [] - for i, d in enumerate(docs, 1): - title = d.get("title") or f"Документ {d.get('id')}" - content = (d.get("content") or "").strip() - if not content: - content = "(текст пустой)" - parts.append(f"[Документ {i}: {title}]\n{content[:8000]}") # ограничиваем длину - - docs_text = "\n\n---\n\n".join(parts) - - prompt = f"""Ниже текст из документов (OCR). Ответь на вопрос пользователя, опираясь только на этот текст. -Если в тексте нет ответа — напиши "В документах не найдено". -Отвечай кратко, по делу. - -Документы: - -{docs_text} - -Вопрос: {question} - -Ответ:""" - - try: - answer = ollama_generate(ollama_url, model, prompt) - except urllib.error.URLError as e: - print(f"Ollama недоступна ({ollama_url}): {e}", file=sys.stderr) - sys.exit(3) - except Exception as e: - print(f"Ollama error: {e}", file=sys.stderr) - sys.exit(3) - - print(answer) - - -if __name__ == "__main__": - main() diff --git a/homelab/scripts/README-reorganize-games-common.md b/homelab/scripts/README-reorganize-games-common.md deleted file mode 100644 index 3ae1f35..0000000 --- a/homelab/scripts/README-reorganize-games-common.md +++ /dev/null @@ -1,47 +0,0 @@ -# Реорганизация common/ (HDD 4 TB) - -Скрипт приводит папки игр в каталоге **common/** к виду: `GameName/GameName/` (файлы внутри) и кладёт `appmanifest_.acf` в корень папки игры. AppId берётся из **steam_library_with_sizes.json** по имени папки (сопоставление по нормализованному имени, не 100% точное). - -## Что нужно - -- В CT 101 должны быть: `reorganize-games-common.sh`, `resolve_steam_appid.py`, `steam_library_with_sizes.json` (например в `/opt/scripts/`). -- Корневая директория: `/mnt/nextcloud-extra/common/` (или путь 1-м аргументом). - -## Подготовка (один раз) - -С хоста Proxmox (из репозитория plantUML): - -```bash -# Создать каталог в контейнере и залить файлы -pct exec 101 -- mkdir -p /opt/scripts -pct push 101 homelab/scripts/reorganize-games-common.sh /opt/scripts/ -pct push 101 homelab/scripts/resolve_steam_appid.py /opt/scripts/ -pct push 101 homelab/scripts/steam_library_with_sizes.json /opt/scripts/ -``` - -## Dry-run (ничего не меняет) - -Показывает, что будет сделано, и в конце выводит **папки без совпадений** (по ним appId не найден в JSON — их можно проставить вручную и перезапустить): - -```bash -ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 1 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' -``` - -## Реальное выполнение - -Второй аргумент `0`: - -```bash -ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 0 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' -``` - -## Папки без совпадений - -Если в конце dry-run в блоке **«Папки, для которых не найден appId»** что-то есть — имена папок не совпали ни с одной записью в `steam_library_with_sizes.json`. Варианты: - -1. Переименовать папку в common/ так, чтобы оно было ближе к названию в JSON, и снова запустить dry-run. -2. Добавить вручную маппинг: можно расширить `resolve_steam_appid.py` (словарь папка → appid) или положить рядом с папкой файл с appid и доработать скрипт — при необходимости можно добавить такой режим. - -## Манифесты - -Скрипт ищет `appmanifest_.acf` в корне common/ или на уровень выше (steamapps/). Если манифеста нет, игра попадёт в блок **«Нет файла appmanifest»**; манифест нужно будет добавить вручную (скачать или создать). diff --git a/homelab/scripts/README-reorganize-games.md b/homelab/scripts/README-reorganize-games.md deleted file mode 100644 index 10153c2..0000000 --- a/homelab/scripts/README-reorganize-games.md +++ /dev/null @@ -1,35 +0,0 @@ -# Скрипт reorganize-games.sh - -Упорядочивает папки игр в двух корневых директориях в CT 101 (Nextcloud): - -- **Игры:** `/mnt/nextcloud-data/html/data/kerrad/files/Игры/` -- **nextcloud-extra:** `/mnt/nextcloud-extra/` - -Целевая структура: `GameName/GameName/` (внутри — файлы игры), в корне папки игры — `appmanifest_.acf` по таблице из `readme.md` / `Readme.md`. - -## Запуск dry-run (ничего не меняет) - -С хоста (из репозитория plantUML): - -```bash -# Игры (nextcloud-data) -ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 1' < homelab/scripts/reorganize-games.sh - -# nextcloud-extra (4 TB) -ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 1' < homelab/scripts/reorganize-games.sh -``` - -В конце вывода будут блоки: -- **Папки без соответствия в readme** — директории на диске, для которых нет строки в таблице readme. -- **Не найдены appmanifest** — игры из readme, для которых в корне нет нужного `appmanifest_XXXX.acf` (нужно добавить файл или поправить readme). - -## Реальное выполнение - -Второй аргумент `0`: - -```bash -ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 0' < homelab/scripts/reorganize-games.sh -ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 0' < homelab/scripts/reorganize-games.sh -``` - -Рекомендуется перед этим сделать резервную копию или убедиться, что dry-run выводит ожидаемые действия. diff --git a/homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc b/homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc deleted file mode 100644 index ff9b88c..0000000 Binary files a/homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc and /dev/null differ diff --git a/homelab/scripts/check_missing_steam_games.py b/homelab/scripts/check_missing_steam_games.py deleted file mode 100644 index ab3ccd3..0000000 --- a/homelab/scripts/check_missing_steam_games.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -import sys -import unicodedata -from typing import Dict, List, Tuple, Any - - -DEFAULT_GAMES_JSON = os.path.join( - os.path.dirname(__file__), "steam_library_with_sizes.json" -) -DEFAULT_GAMES_ROOT = "/mnt/nextcloud-hdd/games" -IGNORE_DIRS = {".", "..", "common", "lost+found"} - - -def normalize(text: str) -> str: - """Normalize game / directory names for comparison.""" - text = unicodedata.normalize("NFKD", text) - text = "".join(ch for ch in text if not unicodedata.combining(ch)) - text = text.lower() - return "".join(ch for ch in text if ch.isalnum()) - - -def load_games(json_path: str) -> List[Dict[str, Any]]: - with open(json_path, "r", encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, list): - raise ValueError(f"Unexpected JSON structure in {json_path}, expected list") - return data - - -def list_game_dirs(root: str) -> List[str]: - entries: List[str] = [] - for name in os.listdir(root): - if name in IGNORE_DIRS or name.startswith("."): - continue - full = os.path.join(root, name) - if os.path.isdir(full): - entries.append(name) - return sorted(entries) - - -def build_dir_index(dirs: List[str]) -> Dict[str, List[str]]: - index: Dict[str, List[str]] = {} - for d in dirs: - nd = normalize(d) - index.setdefault(nd, []).append(d) - return index - - -def similar_candidates( - norm_name: str, dirs: List[str] -) -> Tuple[float, List[str]]: - from difflib import SequenceMatcher - - best_ratio = 0.0 - best: List[str] = [] - for d in dirs: - nd = normalize(d) - ratio = SequenceMatcher(None, norm_name, nd).ratio() - if ratio > best_ratio: - best_ratio = ratio - best = [d] - elif ratio == best_ratio and ratio > 0: - best.append(d) - return best_ratio, best - - -def classify_games( - games: List[Dict[str, Any]], server_dirs: List[str] -) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, int]]: - norm_dir_map = build_dir_index(server_dirs) - - missing: List[Dict[str, Any]] = [] - doubtful: List[Dict[str, Any]] = [] - - for g in games: - appid = str(g.get("appid", "")) - name = g.get("name", "") - norm_name = normalize(name) - - candidates = norm_dir_map.get(norm_name, []) - if len(candidates) == 1: - # Confident уникальное соответствие: игра считается присутствующей - continue - - record = { - "appid": appid, - "name": name, - "attempted_match": False, - "reason": "", - } - - if len(candidates) > 1: - record["attempted_match"] = True - record[ - "reason" - ] = f"Найдено несколько директорий с одинаковым нормализованным именем: {candidates}" - missing.append(record) - doubtful.append( - { - "appid": appid, - "name": name, - "candidates": candidates, - "confidence": "низкая уверенность (несколько совпадений по нормализованному имени)", - } - ) - continue - - # Нет точного совпадения по нормализованному имени: ищем похожие директории, - # но не считаем их найденными. - best_ratio, best_matches = similar_candidates(norm_name, server_dirs) - - if best_ratio >= 0.85: - record["attempted_match"] = True - record["reason"] = ( - "Найден(ы) похожие директории, но совпадение не однозначное " - f"(коэффициент схожести {best_ratio:.2f}, кандидаты: {best_matches})" - ) - if best_ratio >= 0.92: - conf = "вероятно совпадение" - else: - conf = "низкая уверенность" - doubtful.append( - { - "appid": appid, - "name": name, - "candidates": best_matches, - "confidence": f"{conf} (similarity={best_ratio:.2f})", - } - ) - else: - record[ - "reason" - ] = "Похожих директорий не найдено (по нормализованному имени)" - - missing.append(record) - - stats = { - "total_games_in_json": len(games), - "total_server_dirs": len(server_dirs), - "missing_count": len(missing), - "doubtful_count": len(doubtful), - } - return missing, doubtful, stats - - -def print_markdown( - missing: List[Dict[str, Any]], doubtful: List[Dict[str, Any]], stats: Dict[str, int] -) -> None: - print("### Статистика") - print() - print(f"- **Всего игр в JSON**: {stats['total_games_in_json']}") - print(f"- **Всего директорий на сервере**: {stats['total_server_dirs']}") - print(f"- **Отсутствующие игры**: {stats['missing_count']}") - print(f"- **Сомнительные сопоставления**: {stats['doubtful_count']}") - print() - - print("### Игры, отсутствующие на сервере") - print() - if not missing: - print("Все игры из JSON найдены на сервере по строгому сопоставлению.") - else: - print("| appid | name | попытка сопоставления | комментарий |") - print("| --- | --- | --- | --- |") - for r in missing: - attempted = "да" if r["attempted_match"] else "нет" - reason = r.get("reason", "").replace("\n", " ") - print(f"| {r['appid']} | {r['name']} | {attempted} | {reason} |") - print() - - print("### Сомнительные/неоднозначные сопоставления") - print() - if not doubtful: - print("Сомнительных сопоставлений не обнаружено.") - return - - print("| appid | name | директории-кандидаты | степень уверенности |") - print("| --- | --- | --- | --- |") - for d in doubtful: - candidates = ", ".join(d.get("candidates") or []) - conf = d.get("confidence", "") - print(f"| {d['appid']} | {d['name']} | {candidates} | {conf} |") - - -def main(argv: List[str]) -> int: - games_json = DEFAULT_GAMES_JSON - games_root = DEFAULT_GAMES_ROOT - - if len(argv) > 1: - games_json = argv[1] - if len(argv) > 2: - games_root = argv[2] - - if not os.path.isfile(games_json): - print(f"ERROR: JSON file not found: {games_json}", file=sys.stderr) - return 1 - if not os.path.isdir(games_root): - print(f"ERROR: Games root not found: {games_root}", file=sys.stderr) - return 1 - - games = load_games(games_json) - server_dirs = list_game_dirs(games_root) - missing, doubtful, stats = classify_games(games, server_dirs) - print_markdown(missing, doubtful, stats) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) - diff --git a/homelab/scripts/check_size_sources.py b/homelab/scripts/check_size_sources.py deleted file mode 100644 index 89aefb6..0000000 --- a/homelab/scripts/check_size_sources.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -""" -Сверяет кэш размеров (steam_app_sizes.json) с api.steamcmd.net. -Находит игры, у которых размер в кэше сильно меньше, чем по steamcmd — -скорее всего размер был взят из fallback (магазин Steam), а не из steamcmd. -Запуск: из homelab/scripts/ - python3 check_size_sources.py — проверить все игры (~10–15 мин) - python3 check_size_sources.py --sample 50 — только 50 для быстрой оценки -""" - -import argparse -import json -import random -import time -from pathlib import Path - -# используем ту же логику, что и steam_library_size -from steam_library_size import get_app_size_from_steamcmd - -SCRIPT_DIR = Path(__file__).parent -CACHE_FILE = SCRIPT_DIR / "steam_app_sizes.json" -LIBRARY_FILE = SCRIPT_DIR / "steam_library.json" -REQUEST_DELAY = 0.5 - -# если в кэше меньше чем (steamcmd * MIN_RATIO), считаем источник сомнительным -MIN_RATIO = 0.6 -# или если steamcmd даёт больше MIN_GB, а разница больше DIFF_GB -MIN_GB_STEAMCMD = 15.0 -DIFF_GB = 10.0 - - -def main(sample: int | None = None) -> None: - with open(CACHE_FILE, encoding="utf-8") as f: - cache = json.load(f) - cache_sizes = {int(k): v for k, v in cache.items()} - - if LIBRARY_FILE.exists(): - with open(LIBRARY_FILE, encoding="utf-8") as f: - library = json.load(f) - name_by_appid = {g["appid"]: g.get("name", "?") for g in library} - else: - name_by_appid = {} - - items = list(cache_sizes.items()) - if sample is not None and len(items) > sample: - random.seed(42) - items = random.sample(items, sample) - print(f"Режим выборки: проверяем {sample} из {len(cache_sizes)} игр") - total = len(items) - suspicious = [] - - for i, (appid, cached_gb) in enumerate(items): - if cached_gb is None: - continue - if not isinstance(cached_gb, (int, float)): - continue - cached_gb = float(cached_gb) - steamcmd_gb = get_app_size_from_steamcmd(appid) - time.sleep(REQUEST_DELAY) - name = name_by_appid.get(appid, "?") - if i % 20 == 0 or i == total - 1: - print(f"\rПроверено {i + 1}/{total} {name[:40]}", end="", flush=True) - - if steamcmd_gb is None: - continue - if cached_gb >= steamcmd_gb * MIN_RATIO: - continue - if steamcmd_gb >= MIN_GB_STEAMCMD and (steamcmd_gb - cached_gb) >= DIFF_GB: - suspicious.append({ - "appid": appid, - "name": name, - "cached_gb": round(cached_gb, 2), - "steamcmd_gb": round(steamcmd_gb, 2), - }) - - print() - print(f"Проверено: {total}") - print(f"Подозрительных (кэш заметно меньше steamcmd): {len(suspicious)}") - if sample and total < len(cache_sizes): - print(f"(оценка на всю библиотеку: ~{len(suspicious) * len(cache_sizes) // max(1, total)} таких игр)") - print() - if suspicious: - print("Список (кэш ГБ → steamcmd ГБ):") - for s in sorted(suspicious, key=lambda x: -x["steamcmd_gb"]): - print(f" {s['appid']:>8} {s['cached_gb']:>7.1f} → {s['steamcmd_gb']:>7.1f} {s['name']}") - return suspicious - - -def heuristic_round_numbers() -> None: - """Без API: считает записи с «круглым» размером в ГБ (часто из требований магазина).""" - with open(CACHE_FILE, encoding="utf-8") as f: - cache = json.load(f) - if LIBRARY_FILE.exists(): - with open(LIBRARY_FILE, encoding="utf-8") as f: - library = json.load(f) - name_by_appid = {g["appid"]: g.get("name", "?") for g in library} - else: - name_by_appid = {} - # круглые числа: целые или X.0, и типичные для магазина (1, 2, 4, 6, 8, 12, 16, 20, 70...) - round_entries = [] - for k, v in cache.items(): - if v is None: - continue - gb = float(v) - if gb <= 0: - continue - if gb == int(gb) or abs(gb - round(gb)) < 0.01: - round_entries.append((int(k), round(gb, 1), name_by_appid.get(int(k), "?"))) - round_entries.sort(key=lambda x: -x[1]) - print(f"Записей с «круглым» размером (целое число ГБ): {len(round_entries)} из {len(cache)}") - print("Часто так указывают в системных требованиях магазина, а не реальный размер депо.") - print() - print("Топ по размеру (могут быть занижены):") - for appid, gb, name in round_entries[:30]: - print(f" {gb:>6.1f} ГБ {appid:>8} {name}") - if len(round_entries) > 30: - print(f" ... и ещё {len(round_entries) - 30}") - - -if __name__ == "__main__": - p = argparse.ArgumentParser(description="Сверка кэша размеров с steamcmd API") - p.add_argument("--sample", type=int, default=None, help="Проверить только N игр (быстрая оценка)") - p.add_argument("--heuristic", action="store_true", help="Только эвристика по круглым числам (без API)") - args = p.parse_args() - if args.heuristic: - heuristic_round_numbers() - else: - main(sample=args.sample) diff --git a/homelab/scripts/merge_names_into_sizes.py b/homelab/scripts/merge_names_into_sizes.py deleted file mode 100644 index f77b7f8..0000000 --- a/homelab/scripts/merge_names_into_sizes.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -""" -Однократно подтягивает названия игр из steam_library.json в steam_library_with_sizes.json. -Размеры берутся из кэша steam_app_sizes.json. Запускать из homelab/scripts/. -""" - -import json -from pathlib import Path - -SCRIPT_DIR = Path(__file__).parent -LIBRARY_FILE = SCRIPT_DIR / "steam_library.json" -CACHE_FILE = SCRIPT_DIR / "steam_app_sizes.json" -OUTPUT_FILE = SCRIPT_DIR / "steam_library_with_sizes.json" - - -def main() -> None: - if not LIBRARY_FILE.exists(): - print(f"Нужен файл {LIBRARY_FILE}") - return - if not CACHE_FILE.exists(): - print(f"Нужен файл {CACHE_FILE}") - return - - with open(LIBRARY_FILE, encoding="utf-8") as f: - library = json.load(f) - with open(CACHE_FILE, encoding="utf-8") as f: - cache = json.load(f) - - # кэш: ключи строковые "appid", значения — число или null - cache_sizes = {int(k): v for k, v in cache.items()} - - results = [] - for game in library: - appid = game["appid"] - name = game.get("name", "Unknown") - raw = cache_sizes.get(appid) - if raw is not None and isinstance(raw, (int, float)): - size_gb = round(float(raw), 2) - else: - size_gb = None - results.append({"appid": appid, "name": name, "size_gb": size_gb}) - - with open(OUTPUT_FILE, "w", encoding="utf-8") as f: - json.dump(results, f, ensure_ascii=False, indent=2) - - print(f"Записано {len(results)} записей в {OUTPUT_FILE}") - - -if __name__ == "__main__": - main() diff --git a/homelab/scripts/reorganize-games-common.sh b/homelab/scripts/reorganize-games-common.sh deleted file mode 100644 index 94bdc85..0000000 --- a/homelab/scripts/reorganize-games-common.sh +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env bash -# -# Упорядочивание папок игр в common/ (HDD 4 TB): GameName/GameName/ (файлы) + appmanifest в GameName/, -# затем перенос всех папок GameName из common/ на уровень выше (HDD 4 TB). Итог: HDD 4 TB/GameName/GameName/, HDD 4 TB/GameName/appmanifest_*.acf, common/ пустая. -# AppId берётся по имени папки из steam_library_with_sizes.json (резолвер resolve_steam_appid.py). -# Ручные сопоставления (CM3, Darksiders 3, DOOMEternal и др.) — в resolve_steam_appid.py MANUAL_MAP. -# -# Запуск в CT 101. Корневая директория: /mnt/nextcloud-extra/common/ (PARENT = /mnt/nextcloud-extra = HDD 4 TB). -# -# Подготовка: скопировать в контейнер скрипты и JSON (в одну папку, например /opt/scripts/): -# pct push 101 homelab/scripts/reorganize-games-common.sh /opt/scripts/ -# pct push 101 homelab/scripts/resolve_steam_appid.py /opt/scripts/ -# pct push 101 homelab/scripts/steam_library_with_sizes.json /opt/scripts/ -# -# Dry-run (ничего не меняет, только вывод; в конце — папки без совпадений): -# ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 1 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' -# -# Реальное выполнение (второй аргумент 0): -# ssh root@192.168.1.150 'pct exec 101 -- bash /opt/scripts/reorganize-games-common.sh /mnt/nextcloud-extra/common 0 /opt/scripts/resolve_steam_appid.py /opt/scripts/steam_library_with_sizes.json' -# -set -euo pipefail - -ROOT="${1:?Usage: $0 [DRY_RUN=1] [RESOLVER_SCRIPT] [LIBRARY_JSON]}" -DRY_RUN="${2:-1}" -RESOLVER_SCRIPT="${3:-}" -LIBRARY_JSON="${4:-}" -[[ -z "$RESOLVER_SCRIPT" ]] && [[ -f ./resolve_steam_appid.py ]] && RESOLVER_SCRIPT="./resolve_steam_appid.py" -[[ -n "$RESOLVER_SCRIPT" ]] && [[ -z "$LIBRARY_JSON" ]] && LIBRARY_JSON="$(dirname "$RESOLVER_SCRIPT")/steam_library_with_sizes.json" -[[ -z "$RESOLVER_SCRIPT" ]] && { - echo "ERROR: resolve_steam_appid.py not found. Pass path as 3rd arg or run from script dir." - exit 1 -} -[[ ! -f "$RESOLVER_SCRIPT" ]] && { - echo "ERROR: resolver script not found: $RESOLVER_SCRIPT" - exit 1 -} -[[ -z "$LIBRARY_JSON" ]] || [[ ! -f "$LIBRARY_JSON" ]] && { - echo "ERROR: steam_library_with_sizes.json not found. Pass path as 4th arg or put next to resolver." - exit 1 -} - -echo "=== ROOT: $ROOT | DRY_RUN: $DRY_RUN | Resolver: $RESOLVER_SCRIPT | Library: $LIBRARY_JSON ===" -echo "" - -if [[ ! -d "$ROOT" ]]; then - echo "ERROR: not a directory: $ROOT" - exit 1 -fi - -PARENT_DIR="$(dirname "$ROOT")" -echo "Папки после обработки будут перенесены: $ROOT -> $PARENT_DIR (уровень выше)" -echo "" - -MISSING_APPID=() -MISSING_MANIFEST=() -RESOLVED=() -PROCESSED_DIRS=() - -while IFS= read -r -d '' dir; do - base=$(basename "$dir") - [[ "$base" == "lost+found" ]] && continue - - appid="" - appid=$(python3 "$RESOLVER_SCRIPT" "$LIBRARY_JSON" "$base" 2>/dev/null || true) - if [[ -z "$appid" ]]; then - MISSING_APPID+=("$base") - echo "[$base] appId не найден по имени — пропуск" - echo "" - continue - fi - - manifest="$ROOT/appmanifest_${appid}.acf" - # Манифест может лежать в корне common/ или на уровень выше (steamapps/) - if [[ ! -f "$manifest" ]]; then - manifest_parent="$(dirname "$ROOT")/appmanifest_${appid}.acf" - if [[ -f "$manifest_parent" ]]; then - manifest="$manifest_parent" - else - MISSING_MANIFEST+=("$base -> appmanifest_${appid}.acf") - fi - fi - - inner="$dir/$base" - if [[ -d "$inner" ]]; then - echo "[$base] (уже есть вложенная $base/) appId=$appid" - for item in "$dir"/*; do - [[ -e "$item" ]] || continue - iname=$(basename "$item") - [[ "$iname" == "$base" ]] && continue - [[ "$iname" == appmanifest_*.acf ]] && continue - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mv '$item' -> '$inner/'" - else - mv "$item" "$inner/" - echo " mv $iname -> $base/" - fi - done - if [[ -f "$manifest" ]]; then - dest_manifest="$dir/appmanifest_${appid}.acf" - if [[ "$manifest" != "$dest_manifest" ]]; then - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] cp/mv '$manifest' -> '$dest_manifest'" - else - cp "$manifest" "$dest_manifest" 2>/dev/null || mv "$manifest" "$dest_manifest" - echo " appmanifest_${appid}.acf -> $dir/" - fi - fi - fi - else - echo "[$base] (создать $base/$base/ и перенести файлы) appId=$appid" - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mkdir -p '$inner'" - else - mkdir -p "$inner" - fi - for item in "$dir"/*; do - [[ -e "$item" ]] || continue - iname=$(basename "$item") - [[ "$iname" == "$base" ]] && continue - [[ "$iname" == appmanifest_*.acf ]] && continue - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mv '$item' -> '$inner/'" - else - mv "$item" "$inner/" - echo " mv $iname -> $base/" - fi - done - if [[ -f "$manifest" ]]; then - dest_manifest="$dir/appmanifest_${appid}.acf" - if [[ "$manifest" != "$dest_manifest" ]]; then - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] cp/mv '$manifest' -> '$dest_manifest'" - else - cp "$manifest" "$dest_manifest" 2>/dev/null || mv "$manifest" "$dest_manifest" - echo " appmanifest_${appid}.acf -> $dir/" - fi - fi - fi - fi - RESOLVED+=("$base -> $appid") - PROCESSED_DIRS+=("$base") - echo "" -done < <(find "$ROOT" -maxdepth 1 -type d ! -path "$ROOT" -print0 | sort -z) - -echo "=== Перенос папок из common/ на уровень выше (HDD 4 TB) ===" -for base in "${PROCESSED_DIRS[@]}"; do - src="$ROOT/$base" - dst="$PARENT_DIR/$base" - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mv '$src' -> '$PARENT_DIR/'" - else - if mv "$src" "$PARENT_DIR/" 2>/dev/null; then - echo " mv $base -> $PARENT_DIR/" - else - # Цель уже существует (например, дубликат игры) — сливаем содержимое и удаляем источник - if [[ -d "$dst" ]]; then - inner="$src/$base" - if [[ -d "$inner" ]]; then - mkdir -p "$dst/$base" - for item in "$inner"/*; do [[ -e "$item" ]] && mv "$item" "$dst/$base/"; done - rmdir "$inner" 2>/dev/null || true - fi - for f in "$src"/appmanifest_*.acf; do [[ -f "$f" ]] && cp -n "$f" "$dst/" 2>/dev/null || true; done - rm -rf "$src" - echo " merge $base -> $PARENT_DIR/ (target existed)" - else - echo " ERROR: could not move $base" - fi - fi - fi -done -echo "" - -echo "=== Папки, для которых не найден appId ===" -for x in "${MISSING_APPID[@]}"; do echo " $x"; done -echo "" -echo "=== Итог: игры в $PARENT_DIR/GameName/GameName/, манифесты в $PARENT_DIR/GameName/, common/ пустая ===" -echo "" -echo "=== Нет файла appmanifest (игра найдена по имени) ===" -for x in "${MISSING_MANIFEST[@]}"; do echo " $x"; done -echo "" -echo "=== Сопоставления имя -> appId ===" -for x in "${RESOLVED[@]}"; do echo " $x"; done -echo "" -echo "=== Конец (DRY_RUN=$DRY_RUN) ===" diff --git a/homelab/scripts/reorganize-games.sh b/homelab/scripts/reorganize-games.sh deleted file mode 100644 index 072608a..0000000 --- a/homelab/scripts/reorganize-games.sh +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env bash -# -# Упорядочивание папок игр: GameName/GameName/ (файлы) + appmanifest в GameName/ -# Запуск в CT 101. Поддерживает две корневые директории: Игры и nextcloud-extra. -# -# Использование (с хоста Proxmox 192.168.1.150): -# -# Dry-run (только вывод действий, ничего не меняет): -# ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 1' < homelab/scripts/reorganize-games.sh -# ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 1' < homelab/scripts/reorganize-games.sh -# -# Реальное выполнение (второй аргумент 0): -# ssh root@192.168.1.150 'pct exec 101 -- bash -s "/mnt/nextcloud-data/html/data/kerrad/files/Игры" 0' < homelab/scripts/reorganize-games.sh -# ssh root@192.168.1.150 'pct exec 101 -- bash -s /mnt/nextcloud-extra 0' < homelab/scripts/reorganize-games.sh -# -# Либо скопировать скрипт в контейнер и запустить там: -# pct exec 101 -- bash /path/to/reorganize-games.sh "/mnt/.../Игры" 1 -# -set -euo pipefail - -ROOT="${1:?Usage: $0 [DRY_RUN=1]}" -DRY_RUN="${2:-1}" -README="" -[ -f "$ROOT/readme.md" ] && README="$ROOT/readme.md" -[ -f "$ROOT/Readme.md" ] && README="${README:-$ROOT/Readme.md}" -[ -z "$README" ] && { echo "ERROR: no readme.md or Readme.md in $ROOT"; exit 1; } - -# Маппинг: "имя в readme" -> "имя папки на диске" (если отличается) -declare -A NAME_TO_FOLDER -# Игры (kerrad/files/Игры) -NAME_TO_FOLDER["Mortal Kombat X"]="MK10" -NAME_TO_FOLDER["Ведьмак 3"]="The Witcher 3" -NAME_TO_FOLDER["Crusader Kings 3"]="Crusader Kings III" -NAME_TO_FOLDER["Cities: Skylines"]="Cities_Skylines" -NAME_TO_FOLDER["Baldur's gate 3"]="Baldurs Gate 3" -NAME_TO_FOLDER["Anno 1800 (full dlc)"]="Anno 1800" -NAME_TO_FOLDER["Titan Quest (full dlc)"]="Titan Quest Anniversary Edition" -NAME_TO_FOLDER["KCD 2"]="KingdomComeDeliverance2" -NAME_TO_FOLDER["FC 24"]="EA Sports FC 24" -NAME_TO_FOLDER["Katana Zero"]="Katana ZERO" -NAME_TO_FOLDER["L4D2"]="Left 4 Dead 2" -NAME_TO_FOLDER["XCOM2"]="XCOM 2" -NAME_TO_FOLDER["L.A. Noire"]="L.A.Noire" -NAME_TO_FOLDER["ETS 2"]="Euro Truck Simulator 2" -NAME_TO_FOLDER["Phantome Doctrine"]="PhantomDoctrine" -NAME_TO_FOLDER["Черепашки ниндзя: В поисках Сплинтера"]="Teenage Mutant Ninja Turtles Splintered Fate" -NAME_TO_FOLDER["Казаки 3"]="Cossacks 3" -NAME_TO_FOLDER["A Way Out"]="AWayOut" -NAME_TO_FOLDER["Counter Strike 2"]="Counter-Strike Global Offensive" -NAME_TO_FOLDER["Sid Meier's Civilization IV"]="Sid Meier's Civilization VI" -# nextcloud-extra (readme: "Assasins" с одной s, на диске часто "Assassin's") -NAME_TO_FOLDER["Assasins Creed"]="Assassins Creed" -NAME_TO_FOLDER["Bully"]="Bully Scholarship Edition" -NAME_TO_FOLDER["Assasins Creed 2"]="Assassin's Creed 2" -NAME_TO_FOLDER["Assasins Creed III Remastered"]="Assassin's Creed III Remastered" -NAME_TO_FOLDER["Assasins Creed IV Black Flag"]="Assassin's Creed IV Black Flag" -NAME_TO_FOLDER["Assasins Creed Revelations"]="Assassin's Creed Revelations" -NAME_TO_FOLDER["Assasins Creed Syndicate"]="Assassin's Creed Syndicate" -NAME_TO_FOLDER["Assasins Creed Unity"]="Assassin's Creed Unity" -NAME_TO_FOLDER["Assasins Creed Valhalla"]="Assassin's Creed Valhalla" -NAME_TO_FOLDER["Assasins Creed Mirage"]="Assassin's Creed Mirage" -NAME_TO_FOLDER["Assassins Creed Brotherhood"]="Assassins Creed Brotherhood" -NAME_TO_FOLDER["Assassins Creed Odyssey"]="Assassins Creed Odyssey" -NAME_TO_FOLDER["Assassins Creed Origins"]="Assassins Creed Origins" -NAME_TO_FOLDER["Bioshock Remastered"]="BioShock Remastered" -NAME_TO_FOLDER["Bioshock Infinite"]="BioShock Infinite" -NAME_TO_FOLDER["Bioshock 2 Remastered"]="BioShock 2 Remastered" - -normalize() { - echo "$1" | tr '[:upper:]' '[:lower:]' | tr -s ' \t' ' ' | sed "s/[[:punct:]]//g" | sed 's/^ *//;s/ *$//' -} - -# Парсим readme: строки таблицы | ... | название | appid | -declare -A README_NAME_TO_APPID -while IFS= read -r line; do - [[ "$line" != "|"* ]] && continue - # Убираем первый и последний |, разбиваем по | - rest="${line#|}" - rest="${rest%|}" - rest="${rest# }" - rest="${rest% }" - # Колонки: жанр | название | appmanifest - name="" - appid="" - col=0 - while [[ -n "$rest" ]]; do - if [[ "$rest" == *"|"* ]]; then - part="${rest%%|*}" - rest="${rest#*|}" - else - part="$rest" - rest="" - fi - part=$(echo "$part" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - if [[ $col -eq 1 ]]; then - name="$part" - elif [[ $col -eq 2 ]]; then - appid="${part// /}" - fi - ((col++)) || true - done - [[ -z "$name" || -z "$appid" ]] && continue - [[ "$name" == "название игры" ]] && continue - [[ "$name" == "appmanifest" ]] && continue - README_NAME_TO_APPID["$name"]="$appid" -done < "$README" - -# По имени из readme находим папку на диске -resolve_folder() { - local readme_name="$1" - if [[ -n "${NAME_TO_FOLDER[$readme_name]:-}" ]]; then - echo "${NAME_TO_FOLDER[$readme_name]}" - return - fi - local norm=$(normalize "$readme_name") - for dir in "$ROOT"/*/; do - [[ -d "$dir" ]] || continue - base=$(basename "$dir") - [[ "$base" == "моды" || "$base" == "Моды" || "$base" == "lost+found" ]] && continue - if [[ $(normalize "$base") == "$norm" ]]; then - echo "$base" - return - fi - done - echo "" -} - -# По имени папки находим appid из readme (перебор по всем именам в readme) -resolve_appid() { - local folder_name="$1" - local norm_folder=$(normalize "$folder_name") - for readme_name in "${!README_NAME_TO_APPID[@]}"; do - local f=$(resolve_folder "$readme_name") - if [[ "$f" == "$folder_name" ]]; then - echo "${README_NAME_TO_APPID[$readme_name]}" - return - fi - done - # Прямое совпадение по нормализованному имени папки - for readme_name in "${!README_NAME_TO_APPID[@]}"; do - if [[ $(normalize "$readme_name") == "$norm_folder" ]]; then - echo "${README_NAME_TO_APPID[$readme_name]}" - return - fi - done - echo "" -} - -echo "=== ROOT: $ROOT | DRY_RUN: $DRY_RUN ===" -echo "" - -# Собираем папки игр (директории, не файлы) -MISSING_APPID=() -MISSING_MANIFEST=() -while IFS= read -r -d '' dir; do - base=$(basename "$dir") - [[ "$base" == "моды" || "$base" == "Моды" || "$base" == "lost+found" ]] && continue - appid=$(resolve_appid "$base") - if [[ -z "$appid" ]]; then - MISSING_APPID+=("$base (нет в readme)") - continue - fi - manifest="$ROOT/appmanifest_${appid}.acf" - if [[ ! -f "$manifest" ]]; then - MISSING_MANIFEST+=("$base -> appmanifest_${appid}.acf") - fi - - inner="$dir/$base" - if [[ -d "$inner" ]]; then - # Уже есть вложенная папка с тем же именем - echo "[$base] (есть вложенная $base/)" - for item in "$dir"/*; do - [[ -e "$item" ]] || continue - iname=$(basename "$item") - [[ "$iname" == "$base" ]] && continue - [[ "$iname" == "readme.md" || "$iname" == "Readme.md" ]] && continue - [[ "$iname" == appmanifest_*.acf ]] && continue - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mv '$item' -> '$inner/'" - else - echo " mv '$item' -> '$inner/'" - mv "$item" "$inner/" - fi - done - if [[ -f "$manifest" ]]; then - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mv '$manifest' -> '$dir/'" - else - mv "$manifest" "$dir/" - echo " mv appmanifest_${appid}.acf -> $dir/" - fi - fi - else - # Нет вложенной папки — создаём GameName/GameName/ и переносим всё - echo "[$base] (создать $base/$base/ и перенести файлы)" - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mkdir -p '$inner'" - else - mkdir -p "$inner" - fi - for item in "$dir"/*; do - [[ -e "$item" ]] || continue - iname=$(basename "$item") - [[ "$iname" == "$base" ]] && continue - [[ "$iname" == "readme.md" || "$iname" == "Readme.md" ]] && continue - [[ "$iname" == appmanifest_*.acf ]] && continue - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mv '$item' -> '$inner/'" - else - mv "$item" "$inner/" - echo " mv $iname -> $base/" - fi - done - if [[ -f "$manifest" ]]; then - if [[ $DRY_RUN -eq 1 ]]; then - echo " [DRY-RUN] mv '$manifest' -> '$dir/'" - else - mv "$manifest" "$dir/" - echo " mv appmanifest_${appid}.acf -> $dir/" - fi - fi - fi - echo "" -done < <(find "$ROOT" -maxdepth 1 -type d ! -path "$ROOT" -print0 | sort -z) - -echo "=== Папки без соответствия в readme ===" -for x in "${MISSING_APPID[@]}"; do echo " $x"; done -echo "" -echo "=== Не найдены appmanifest (игра есть в readme) ===" -for x in "${MISSING_MANIFEST[@]}"; do echo " $x"; done -echo "" -echo "=== Конец (DRY_RUN=$DRY_RUN) ===" diff --git a/homelab/scripts/resolve_steam_appid.py b/homelab/scripts/resolve_steam_appid.py deleted file mode 100644 index 5832907..0000000 --- a/homelab/scripts/resolve_steam_appid.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -""" -По имени папки (игры) возвращает Steam App ID из локального steam_library_with_sizes.json. -Совпадение по нормализованному имени (регистр, пунктуация не учитываются). -Использование: resolve_steam_appid.py [PATH_TO_JSON] "Имя папки" -Выход: один appid или пусто при ненайденном. -""" - -import json -import re -import sys -from pathlib import Path - -SCRIPT_DIR = Path(__file__).resolve().parent -DEFAULT_JSON = SCRIPT_DIR / "steam_library_with_sizes.json" - -# Ручное сопоставление: имя папки на диске -> appId (если нет в JSON или совпадение неверное) -MANUAL_MAP = { - "CM3": "351920", # Crazy Machines 3 - "Darksiders 3": "606280", # Darksiders III - "DOOMEternal": "782330", # DOOM Eternal (папка без пробела) - "FarCry5": "552520", # Far Cry 5 - "Gears5": "1097840", # Gears 5 - "GodOfWar": "1593500", # God of War - "HITMAN 3": "1659040", # HITMAN World of Assassination - "ItTakesTwo": "1426210", # It Takes Two - "hotline_miami": "219150", # Hotline Miami - "Jaded": "1932570", # Jaded -} - - -def normalize(name: str) -> str: - s = (name or "").lower().strip() - s = re.sub(r"[^\w\s]", " ", s) - s = re.sub(r"\s+", " ", s) - return s.strip() - - -def load_library(path: Path) -> list[dict]: - with open(path, encoding="utf-8") as f: - return json.load(f) - - -def find_best_appid(query: str, library: list[dict]) -> str: - if not query: - return "" - qnorm = normalize(query) - if not qnorm: - return "" - qwords = set(qnorm.split()) - best_appid = "" - best_score = -1 - for entry in library: - name = entry.get("name") or "" - appid = entry.get("appid") - if appid is None: - continue - nnorm = normalize(name) - if not nnorm: - continue - # Точное совпадение - if nnorm == qnorm: - return str(appid) - # Оба содержат друг друга - if qnorm in nnorm or nnorm in qnorm: - score = len(qwords & set(nnorm.split())) - if score > best_score: - best_score = score - best_appid = str(appid) - # Пересечение по словам - nwords = set(nnorm.split()) - overlap = len(qwords & nwords) - if overlap > 0 and overlap >= min(2, len(qwords), len(nwords)): - if overlap > best_score: - best_score = overlap - best_appid = str(appid) - return best_appid - - -def main() -> int: - args = [a for a in sys.argv[1:] if a.strip()] - if not args: - print("", end="") - return 0 - # Путь к JSON: первый аргумент, если это путь к существующему файлу - json_path = Path(args[0]) - if json_path.exists() and json_path.is_file(): - path = json_path - name = " ".join(args[1:]).strip() - else: - path = DEFAULT_JSON - name = " ".join(args).strip() - if not name: - print("", end="") - return 0 - if not path.exists(): - sys.stderr.write(f"resolve_steam_appid: file not found: {path}\n") - print("", end="") - return 1 - if name in MANUAL_MAP: - print(MANUAL_MAP[name], end="") - return 0 - try: - library = load_library(path) - except Exception as e: - sys.stderr.write(f"resolve_steam_appid: {e}\n") - print("", end="") - return 1 - appid = find_best_appid(name, library) - print(appid, end="") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/homelab/scripts/steam-library-to-json.py b/homelab/scripts/steam-library-to-json.py deleted file mode 100644 index 6458e82..0000000 --- a/homelab/scripts/steam-library-to-json.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -""" -Выгрузка списка игр из Steam-библиотеки в JSON через Steam Web API. -Нужны: API key (https://steamcommunity.com/dev/apikey) и 64-битный Steam ID. -""" - -import json -import urllib.request -import urllib.parse - -# Подставь свои данные -STEAM_API_KEY = "CB3A546C47CBADB5CAB3FA6D60B6DE4C" # https://steamcommunity.com/dev/apikey -STEAM_ID_64 = "76561198113489815" # 64-битный ID, например из https://steamid.io/ - -OUTPUT_FILE = "steam_library.json" - - -def get_owned_games(api_key: str, steam_id: str) -> list[dict]: - url = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/" - params = { - "key": api_key, - "steamid": steam_id, - "include_appinfo": 1, - "include_played_free_games": 1, - } - req = urllib.request.Request( - f"{url}?{urllib.parse.urlencode(params)}", - headers={"Accept": "application/json"}, - ) - with urllib.request.urlopen(req) as resp: - data = json.loads(resp.read().decode()) - if "response" not in data or "games" not in data["response"]: - raise RuntimeError(data.get("response", data)) - return data["response"]["games"] - - -def main() -> None: - if not STEAM_API_KEY or not STEAM_ID_64: - print("Заполни STEAM_API_KEY и STEAM_ID_64 в начале скрипта.") - return - games = get_owned_games(STEAM_API_KEY, STEAM_ID_64) - out = [ - { - "appid": g["appid"], - "name": g.get("name", ""), - "playtime_forever_min": g.get("playtime_forever", 0), - "img_icon_url": g.get("img_icon_url", ""), - "img_logo_url": g.get("img_logo_url", ""), - } - for g in games - ] - with open(OUTPUT_FILE, "w", encoding="utf-8") as f: - json.dump(out, f, ensure_ascii=False, indent=2) - print(f"Записано {len(out)} игр в {OUTPUT_FILE}") - print("Дальше: python3 steam_library_size.py — посчитает размеры и суммарный объём.") - - -if __name__ == "__main__": - main() diff --git a/homelab/scripts/steam-to-cloud-options.md b/homelab/scripts/steam-to-cloud-options.md deleted file mode 100644 index 96d54a0..0000000 --- a/homelab/scripts/steam-to-cloud-options.md +++ /dev/null @@ -1,37 +0,0 @@ -# Варианты: Steam → облако (Nextcloud) - -## Как сейчас -Скачиваешь игру в Steam на ПК, потом вручную заливаешь папку игры в облако и отдельно — манифест. Долго и неудобно. - ---- - -## Вариант 1: Библиотека Steam в папке Nextcloud - -Создаёшь вторую библиотеку Steam и указываешь папку, которую синкает Nextcloud (например папка «Игры» на компе или отдельная «Steam в облако»). При установке игры в Steam выбираешь эту библиотеку — игра качается сразу в эту папку, а Nextcloud в фоне поднимает её в облако. - -Плюсы: один раз настроил, дальше только выбираешь библиотеку при установке. -Минусы: структура в облаке будет не «Игра/Игра/файлы», а как у Steam — `steamapps/common/Игра/` и манифесты в `steamapps/`. То есть не совсем как у тебя сейчас. Плюс синк больших объёмов может долго идти и грузить диск и сеть. - -Как сделать: Steam → Настройки → Загрузки → Папки библиотеки Steam → Добавить → указать папку внутри Nextcloud (или отдельную папку, которую потом добавишь в синк). При установке игры выбирать эту библиотеку. - ---- - -## Вариант 2: Качаешь как обычно, потом один раз копируешь в Nextcloud - -Игру качаешь в обычную библиотеку Steam. Когда установилась — один раз копируешь в папку Nextcloud на компе: саму папку игры (из steamapps/common) и соответствующий appmanifest (из steamapps). В облаке заранее создаёшь папку с именем игры, внутрь — папку с тем же именем, туда кладёшь файлы игры, а appmanifest — в корень папки игры. То есть делаешь ту же структуру, что уже есть в облаке, но вручную за один «копируй и вставь» в синк-папку Nextcloud, без отдельной загрузки манифеста через веб. - -Плюсы: схема в облаке остаётся твоя (Игра/Игра/ + манифест), процесс быстрее, чем «игра отдельно, манифест отдельно» через интерфейс. -Минусы: после каждой установки нужно один раз скопировать папку и манифест в папку Nextcloud. - ---- - -## Вариант 3: Сетевое хранилище как диск - -Поднимаешь на сервере общий доступ по сети (SMB) к тому месту, где лежат «Игры» или HDD 4 TB (или к отдельной папке под Steam). На компе монтируешь этот диск и в Steam добавляешь вторую библиотеку на этот сетевой диск. Тогда установка игры идёт сразу «в облако» по сети. - -Плюсы: не нужно потом ничего копировать — всё пишется сразу на сервер. -Минусы: нужна настройка SMB (или другого доступа к диску), скорость и стабильность зависят от сети; структура в облаке снова будет Steam-стандартная (steamapps/common и т.д.), не «Игра/Игра/». - ---- - -Итого: если хочешь минимум возни — вариант 1 (библиотека в папке Nextcloud), но структура в облаке будет другая. Если важно сохранить текущую структуру и чуть ускорить процесс — вариант 2 (качаешь как обычно, один раз копируешь игру и манифест в папку Nextcloud в нужном виде). diff --git a/homelab/scripts/steam_app_sizes.json b/homelab/scripts/steam_app_sizes.json deleted file mode 100644 index 2a274aa..0000000 --- a/homelab/scripts/steam_app_sizes.json +++ /dev/null @@ -1,271 +0,0 @@ -{ - "10": 0.09375, - "80": 0.09375, - "100": 0.09375, - "220": 0.31052582152187824, - "240": 0.29873945750296116, - "320": 0.30254453141242266, - "360": 0.29670846182852983, - "400": 0.15206828899681568, - "440": 0.5697660846635699, - "550": 2.0, - "570": 6.1378975212574005, - "620": 0.7513375505805016, - "730": 8.0, - "1250": 1.0, - "2600": null, - "4500": 6.0, - "6370": 1.0, - "7670": 1.0, - "8850": 13.66907548904419, - "8870": 2.0, - "9480": 12.131029523909092, - "10150": null, - "12200": 1.0, - "12210": 1.5, - "13500": 1.5, - "13530": 1.5, - "15100": 1.0, - "19980": 1.0, - "20500": 1.0, - "20510": 6.0, - "20920": 0.13999772910028696, - "24240": 1.0, - "24880": 2.0, - "28000": 1.0, - "29800": 0.04376364313066006, - "33230": 6.003417863510549, - "33320": 1.0, - "35140": 7.889322315342724, - "35420": null, - "35700": 4.141127409413457, - "35720": 4.501905304379761, - "40800": 0.4876335524022579, - "41700": 6.0, - "47810": 1.0, - "48190": 18.191587133333087, - "50130": 9.246913000009954, - "55230": 11.12900713365525, - "96100": 0.5, - "102600": 2.0, - "104200": null, - "105600": 0.746307723224163, - "108600": 6.804165206849575, - "110800": 2.0, - "113200": 0.12740739434957504, - "200260": 18.57858782913536, - "201870": 1.5, - "204100": 36.21077110711485, - "204180": 0.012577004730701447, - "204360": 8.0, - "204450": 2.0, - "206420": 3.034766612574458, - "208090": null, - "208650": 70.37538086436689, - "209000": 2.0, - "218620": 82.8623315691948, - "219150": 0.5458543803542852, - "219990": 2.0, - "220240": 11.551703695207834, - "222900": 2.0, - "224260": 2.792985877022147, - "225540": 55.86762906052172, - "226320": 0.4693394098430872, - "227300": 8.0, - "230230": 8.314474378712475, - "232090": 3.0, - "233270": 4.994622882455587, - "233450": 0.5001069400459528, - "233860": 11.471694991923869, - "241930": 3.0, - "242050": 29.128467716276646, - "242760": 5.5415748516097665, - "243470": 11.854517194442451, - "250400": 4.0, - "250900": 1.905337835662067, - "255710": 8.0, - "261550": 61.63875979743898, - "263980": 0.3049567611888051, - "268500": 4.0, - "270550": 1.0, - "270880": 8.0, - "271590": 4.0, - "274170": 0.5740419281646609, - "286690": 7.812409473583102, - "287450": 1.0, - "287700": 4.0, - "289070": 4.0, - "289130": 10.362007912248373, - "289650": 6.0, - "291010": 0.18821301590651274, - "292030": 6.0, - "292620": 0.02205665223300457, - "294100": 4.0, - "298110": 4.0, - "301910": 0.018734455108642578, - "305620": 17.259958535432816, - "307690": 16.70861548744142, - "307780": 3.0, - "314020": 1.1223331121727824, - "315430": 3.094804703257978, - "316030": 1.0, - "318430": 0.0009765625, - "319910": 4.7006360068917274, - "322330": 4.019438261166215, - "330020": 1.7270579617470503, - "330350": 0.7871295092627406, - "331470": 0.5, - "333420": 0.021825484931468964, - "339610": 10.02976268529892, - "345520": 2.0, - "346900": 0.4643522584810853, - "351920": 5.7309086956083775, - "356190": 6.0, - "360430": 52.395995314233005, - "361420": 2.9809251623228192, - "362890": 0.20420024264603853, - "363970": 0.5682053109630942, - "368500": 6.0, - "371660": 4.0, - "373420": 9.955548072233796, - "374320": 4.0, - "377160": 8.0, - "379430": 86.40584150701761, - "379720": 0.16897685825824738, - "386360": 38.648146538995206, - "391220": 6.0, - "393380": 64.49421414826065, - "394360": 4.0, - "409710": 20.658832599408925, - "409720": 19.94643583614379, - "412020": 70.07764175347984, - "414340": 8.0, - "435150": 65.71966441441327, - "447040": 31.45290844514966, - "460950": 0.21165237482637167, - "474960": 8.0, - "475150": 20.15837282501161, - "489830": 8.0, - "492720": 20.065139889717102, - "526870": 25.532881601713598, - "529340": 8.0, - "548430": 3.6065317867323756, - "551770": 19.047910733148456, - "552520": 47.025207565166056, - "557340": 3.561660211533308, - "559100": 23.429331749677658, - "578080": 8.0, - "582160": 6.0, - "582660": 87.07785203587264, - "597180": 7.040180410258472, - "601150": 40.011952906847, - "606280": 8.0, - "613100": 26.301719304174185, - "632470": 9.572589739225805, - "667720": 30.291241589933634, - "690640": 15.723209703341126, - "728880": 7.922727338038385, - "747350": 23.047448326833546, - "774941": 63.950939419679344, - "782330": 0.42261453717947006, - "812140": 8.0, - "829660": 0.2579149715602398, - "870780": 83.82497963216156, - "892970": 1.478191097266972, - "911400": 47.15562159009278, - "916440": 8.0, - "939960": 39.56889073736966, - "945360": 0.9332891013473272, - "960910": 36.49493746738881, - "973580": 8.0, - "976310": 8.0, - "978300": 46.344152592122555, - "990080": 2.2234949553385377, - "1029690": 8.0, - "1030830": 7.450580596923828e-08, - "1030840": 36.23636360000819, - "1044720": 8.0, - "1070330": 0.5611261501908302, - "1086940": 145.0, - "1088850": 8.0, - "1091500": 62.0, - "1092790": 4.0, - "1097150": 8.0, - "1097840": 120.63846635539085, - "1123770": 4.0, - "1124300": 34.544752170331776, - "1139900": 34.11205630656332, - "1145360": 13.034619254991412, - "1151640": 88.1803381787613, - "1158310": 8.0, - "1172380": 8.0, - "1174180": 8.0, - "1210320": 8.0, - "1222140": 58.74557256232947, - "1222670": 4.0, - "1222680": 8.0, - "1222700": 8.0, - "1232130": 2.0, - "1233570": 6.0, - "1237950": 8.0, - "1238810": 8.0, - "1239690": 4.0, - "1259420": 110.01045930571854, - "1272080": 16.0, - "1316680": 4.0, - "1328670": 8.0, - "1332010": 6.137137608602643, - "1336490": 6.509317799471319, - "1338770": 8.0, - "1363080": 13.207469248212874, - "1426210": 8.0, - "1436700": 8.0, - "1449560": 72.23142071720213, - "1466860": 8.0, - "1510580": 4.0, - "1546970": 8.0, - "1546990": 8.0, - "1547000": 8.0, - "1568590": 0.9997884016484022, - "1593500": 8.0, - "1659040": 78.71203076094389, - "1665460": 46.43916262499988, - "1715130": 19.86594975180924, - "1771300": 102.12097447179258, - "1774580": 8.0, - "1794680": 0.7062101969495416, - "1797610": 2.0, - "1817070": 70.1352766584605, - "1850570": 8.0, - "1888930": 4.507225760258734, - "1903340": 8.0, - "1971870": 201.90859704837203, - "2096600": 8.0, - "2096610": 8.0, - "2138710": 30.304323970340192, - "2140020": 0.00941288098692894, - "2144740": 69.84031751286238, - "2195250": 8.0, - "2208920": 158.53002528753132, - "2239550": 8.0, - "2290000": 1.6174326296895742, - "2369390": 8.0, - "2406770": 8.0, - "2417610": 16.0, - "2427410": 8.0, - "2427420": 8.0, - "2427430": 8.0, - "2456740": 33.04833281133324, - "2461850": 16.0, - "2531310": 139.8620684761554, - "2532550": 3.0764142777770758, - "2784470": 0.1604305850341916, - "2796010": 5.448299317620695, - "2807960": 16.0, - "2996040": 4.0, - "3035570": 65.0052012577653, - "3107800": 8.0, - "3240220": 8.0, - "3472040": 26.530300861224532, - "3551340": 7.121387985534966 -} \ No newline at end of file diff --git a/homelab/scripts/steam_library.json b/homelab/scripts/steam_library.json deleted file mode 100644 index c23b683..0000000 --- a/homelab/scripts/steam_library.json +++ /dev/null @@ -1,1885 +0,0 @@ -[ - { - "appid": 10, - "name": "Counter-Strike", - "playtime_forever_min": 0, - "img_icon_url": "6b0312cda02f5f777efa2f3318c307ff9acafbb5", - "img_logo_url": "" - }, - { - "appid": 80, - "name": "Counter-Strike: Condition Zero", - "playtime_forever_min": 77, - "img_icon_url": "077b050ef3e89cd84e2c5a575d78d53b54058236", - "img_logo_url": "" - }, - { - "appid": 100, - "name": "Counter-Strike: Condition Zero Deleted Scenes", - "playtime_forever_min": 0, - "img_icon_url": "077b050ef3e89cd84e2c5a575d78d53b54058236", - "img_logo_url": "" - }, - { - "appid": 220, - "name": "Half-Life 2", - "playtime_forever_min": 0, - "img_icon_url": "fcfb366051782b8ebf2aa297f3b746395858cb62", - "img_logo_url": "" - }, - { - "appid": 240, - "name": "Counter-Strike: Source", - "playtime_forever_min": 0, - "img_icon_url": "9052fa60c496a1c03383b27687ec50f4bf0f0e10", - "img_logo_url": "" - }, - { - "appid": 320, - "name": "Half-Life 2: Deathmatch", - "playtime_forever_min": 0, - "img_icon_url": "795e85364189511f4990861b578084deef086cb1", - "img_logo_url": "" - }, - { - "appid": 360, - "name": "Half-Life Deathmatch: Source", - "playtime_forever_min": 0, - "img_icon_url": "40b8a62efff5a9ab356e5c56f5c8b0532c8e1aa3", - "img_logo_url": "" - }, - { - "appid": 400, - "name": "Portal", - "playtime_forever_min": 275, - "img_icon_url": "cfa928ab4119dd137e50d728e8fe703e4e970aff", - "img_logo_url": "" - }, - { - "appid": 440, - "name": "Team Fortress 2", - "playtime_forever_min": 0, - "img_icon_url": "f568912870a4684f9ec76277a1a404dda6bab213", - "img_logo_url": "" - }, - { - "appid": 550, - "name": "Left 4 Dead 2", - "playtime_forever_min": 0, - "img_icon_url": "7d5a243f9500d2f8467312822f8af2a2928777ed", - "img_logo_url": "" - }, - { - "appid": 570, - "name": "Dota 2", - "playtime_forever_min": 35696, - "img_icon_url": "0bbb630d63262dd66d2fdd0f7d37e8661a410075", - "img_logo_url": "" - }, - { - "appid": 620, - "name": "Portal 2", - "playtime_forever_min": 185, - "img_icon_url": "25a5a16b2423bf7487ac5340b5b0948cef48c5f8", - "img_logo_url": "" - }, - { - "appid": 730, - "name": "Counter-Strike 2", - "playtime_forever_min": 23810, - "img_icon_url": "8dbc71957312bbd3baea65848b545be9eae2a355", - "img_logo_url": "" - }, - { - "appid": 1250, - "name": "Killing Floor", - "playtime_forever_min": 904, - "img_icon_url": "d8a2d777cb4c59cf06aa244166db232336520547", - "img_logo_url": "" - }, - { - "appid": 2600, - "name": "Vampire: The Masquerade - Bloodlines", - "playtime_forever_min": 54, - "img_icon_url": "9fd08d0034ba09d371e1f1a179a0a3af6c36d1f0", - "img_logo_url": "" - }, - { - "appid": 4500, - "name": "S.T.A.L.K.E.R.: Shadow of Chernobyl", - "playtime_forever_min": 646, - "img_icon_url": "c57f5fdde74464aed0a09c2e5dd41f8973cbee8d", - "img_logo_url": "" - }, - { - "appid": 6370, - "name": "Bloodline Champions", - "playtime_forever_min": 494, - "img_icon_url": "f277e8f95b03f1565e3c5b3d45ff1b625fc01354", - "img_logo_url": "" - }, - { - "appid": 7670, - "name": "BioShock", - "playtime_forever_min": 0, - "img_icon_url": "9a7c9f640a76e6a32592277dbbc36a0f6da05372", - "img_logo_url": "" - }, - { - "appid": 8850, - "name": "BioShock 2", - "playtime_forever_min": 0, - "img_icon_url": "f5eda925c0e57373aaea4cae17b6f175115a8d54", - "img_logo_url": "" - }, - { - "appid": 8870, - "name": "BioShock Infinite", - "playtime_forever_min": 0, - "img_icon_url": "4ebaf5f9ee74f50152f7ff361debef7553fa0e4e", - "img_logo_url": "" - }, - { - "appid": 9480, - "name": "Saints Row 2", - "playtime_forever_min": 0, - "img_icon_url": "1a0aef912300d396a67f9eda9d8b4e66c41c9891", - "img_logo_url": "" - }, - { - "appid": 10150, - "name": "Prototype", - "playtime_forever_min": 0, - "img_icon_url": "c09d5f7b718a963b1f24162184c28d3293e8fd8c", - "img_logo_url": "" - }, - { - "appid": 12200, - "name": "Bully: Scholarship Edition", - "playtime_forever_min": 0, - "img_icon_url": "791f13dd4ea6c4cdf171670cc576682171c1eae5", - "img_logo_url": "" - }, - { - "appid": 12210, - "name": "Grand Theft Auto IV: The Complete Edition", - "playtime_forever_min": 1684, - "img_icon_url": "a3cf6a64c73f991898a9e34681d0db8226eaa191", - "img_logo_url": "" - }, - { - "appid": 13500, - "name": "Prince of Persia: Warrior Within", - "playtime_forever_min": 32, - "img_icon_url": "7ccfe3fae3c5fc44dad734dbd0af10f041fe94e7", - "img_logo_url": "" - }, - { - "appid": 13530, - "name": "Prince of Persia: The Two Thrones", - "playtime_forever_min": 0, - "img_icon_url": "8ceeaa9e2d1b0eb804b1826e95e5c268703f6c37", - "img_logo_url": "" - }, - { - "appid": 15100, - "name": "Assassin's Creed", - "playtime_forever_min": 0, - "img_icon_url": "cd8f7a795e34e16449f7ad8d8190dce521967917", - "img_logo_url": "" - }, - { - "appid": 19980, - "name": "Prince of Persia", - "playtime_forever_min": 0, - "img_icon_url": "2b404bd60fb97a9c1eabb9d4a573909c014a23fd", - "img_logo_url": "" - }, - { - "appid": 20500, - "name": "Red Faction: Guerrilla Steam Edition", - "playtime_forever_min": 211, - "img_icon_url": "e7f356bc74c871f6955c9caec5d38a8d56f49490", - "img_logo_url": "" - }, - { - "appid": 20510, - "name": "S.T.A.L.K.E.R.: Clear Sky", - "playtime_forever_min": 918, - "img_icon_url": "82713e11a93dcbdbb0e1158b2e289720000bed3c", - "img_logo_url": "" - }, - { - "appid": 20920, - "name": "The Witcher 2: Assassins of Kings Enhanced Edition", - "playtime_forever_min": 0, - "img_icon_url": "62dd5c627664df1bcabc47727c7dcd7ccab353e9", - "img_logo_url": "" - }, - { - "appid": 24240, - "name": "PAYDAY: The Heist", - "playtime_forever_min": 147, - "img_icon_url": "a07ceea8808f1a2104ed1c864756f263ec67df49", - "img_logo_url": "" - }, - { - "appid": 24880, - "name": "The Saboteur™", - "playtime_forever_min": 0, - "img_icon_url": "fd29235b1565127a71fd1065a4347d4ea780f1fc", - "img_logo_url": "" - }, - { - "appid": 28000, - "name": "Kane & Lynch 2: Dog Days", - "playtime_forever_min": 10, - "img_icon_url": "e82911b452dd182875dc83cd03767de44f68856c", - "img_logo_url": "" - }, - { - "appid": 29800, - "name": "Caster", - "playtime_forever_min": 88, - "img_icon_url": "31528f4c11a59d2c904109a6ad5e3764f782108c", - "img_logo_url": "" - }, - { - "appid": 33230, - "name": "Assassin's Creed II", - "playtime_forever_min": 0, - "img_icon_url": "0492d8ee860ac99168e46efeb003029c3224cb38", - "img_logo_url": "" - }, - { - "appid": 33320, - "name": "Prince of Persia: The Forgotten Sands", - "playtime_forever_min": 0, - "img_icon_url": "ae71d05f6a30478dc896f58bff7b353c0edf9959", - "img_logo_url": "" - }, - { - "appid": 35140, - "name": "Batman: Arkham Asylum GOTY Edition", - "playtime_forever_min": 0, - "img_icon_url": "e52f91ecb0d3f20263e96fe188de1bcc8c91643e", - "img_logo_url": "" - }, - { - "appid": 35420, - "name": "Killing Floor Mod: Defence Alliance 2", - "playtime_forever_min": 6, - "img_icon_url": "ae7580a60cf77b754c723c72d5e31d530fbe7804", - "img_logo_url": "" - }, - { - "appid": 35700, - "name": "Trine", - "playtime_forever_min": 0, - "img_icon_url": "298185d8ef9d688fe96a5fa15ee8174892f1c000", - "img_logo_url": "" - }, - { - "appid": 35720, - "name": "Trine 2", - "playtime_forever_min": 0, - "img_icon_url": "061ecbbd7c70ae1c052377bad136c7759cbb708d", - "img_logo_url": "" - }, - { - "appid": 40800, - "name": "Super Meat Boy", - "playtime_forever_min": 66, - "img_icon_url": "64eec20c9375e7473b964f0d0bc41d19f03add3b", - "img_logo_url": "" - }, - { - "appid": 41700, - "name": "S.T.A.L.K.E.R.: Call of Pripyat", - "playtime_forever_min": 768, - "img_icon_url": "60b32d190ebcaac8d2b2aaf16db2b07015616eb2", - "img_logo_url": "" - }, - { - "appid": 47810, - "name": "Dragon Age: Origins - Ultimate Edition", - "playtime_forever_min": 0, - "img_icon_url": "e5e0b9629b314f7338f493d26227eb2bd1172e77", - "img_logo_url": "" - }, - { - "appid": 48190, - "name": "Assassin's Creed Brotherhood", - "playtime_forever_min": 0, - "img_icon_url": "6a87d0b61e45e8075decd1f8ace549300b036a52", - "img_logo_url": "" - }, - { - "appid": 50130, - "name": "Mafia II (Classic)", - "playtime_forever_min": 0, - "img_icon_url": "62f1f7324e520be067aec1ab06d1ec6fa56f25bd", - "img_logo_url": "" - }, - { - "appid": 55230, - "name": "Saints Row: The Third", - "playtime_forever_min": 0, - "img_icon_url": "ec83645f13643999e7c91da75d418053d6b56529", - "img_logo_url": "" - }, - { - "appid": 96100, - "name": "Defy Gravity", - "playtime_forever_min": 128, - "img_icon_url": "fa26f1090915f4248bdabe9c99e3d51113fb6e3f", - "img_logo_url": "" - }, - { - "appid": 102600, - "name": "Orcs Must Die!", - "playtime_forever_min": 144, - "img_icon_url": "0d14a70abb580146741de26bbad46d0ad81a67c7", - "img_logo_url": "" - }, - { - "appid": 104200, - "name": "BEEP", - "playtime_forever_min": 0, - "img_icon_url": "a08809b6b78b2adcc347ad406d4deb2946761e4b", - "img_logo_url": "" - }, - { - "appid": 105600, - "name": "Terraria", - "playtime_forever_min": 33, - "img_icon_url": "858961e95fbf869f136e1770d586e0caefd4cfac", - "img_logo_url": "" - }, - { - "appid": 108600, - "name": "Project Zomboid", - "playtime_forever_min": 0, - "img_icon_url": "2bd4642ae337e378e7b04a19d19683425c5f81a4", - "img_logo_url": "" - }, - { - "appid": 110800, - "name": "L.A. Noire", - "playtime_forever_min": 0, - "img_icon_url": "ee09d4d2552e52c78d139a3b6dbe60173079d9f8", - "img_logo_url": "" - }, - { - "appid": 113200, - "name": "The Binding of Isaac", - "playtime_forever_min": 0, - "img_icon_url": "383cf045ca20625db18f68ef5e95169012118b9e", - "img_logo_url": "" - }, - { - "appid": 200260, - "name": "Batman: Arkham City GOTY", - "playtime_forever_min": 76, - "img_icon_url": "746ecf3ce44b2525eb7ad643e76a3b60913d2662", - "img_logo_url": "" - }, - { - "appid": 201870, - "name": "Assassin's Creed Revelations", - "playtime_forever_min": 0, - "img_icon_url": "08f7266496718dbd5d2f93e4776d3f5dc368ad54", - "img_logo_url": "" - }, - { - "appid": 204100, - "name": "Max Payne 3", - "playtime_forever_min": 0, - "img_icon_url": "96af86331719b56cefc55298b4fcb99c99f1cfee", - "img_logo_url": "" - }, - { - "appid": 204180, - "name": "Waveform", - "playtime_forever_min": 30, - "img_icon_url": "a4637179003554caab19db1e35ca684f6137a01d", - "img_logo_url": "" - }, - { - "appid": 204360, - "name": "Castle Crashers", - "playtime_forever_min": 32, - "img_icon_url": "9b7625f9b70f103397fd0416fd92abb583db8659", - "img_logo_url": "" - }, - { - "appid": 204450, - "name": "Call of Juarez Gunslinger", - "playtime_forever_min": 0, - "img_icon_url": "b9a00d132852719cd6e6c2be3f3d0fdf669ed7ce", - "img_logo_url": "" - }, - { - "appid": 206420, - "name": "Saints Row IV", - "playtime_forever_min": 0, - "img_icon_url": "211c502e385781a17b4d02cad7a3ef53f09f1e91", - "img_logo_url": "" - }, - { - "appid": 208090, - "name": "Loadout", - "playtime_forever_min": 102, - "img_icon_url": "d4dc7a4f71d4ba7c44cd05b5c235e0a387ca212b", - "img_logo_url": "" - }, - { - "appid": 208650, - "name": "Batman™: Arkham Knight", - "playtime_forever_min": 0, - "img_icon_url": "f6c2ce13796844750dfbd01685fb009eeac4bf70", - "img_logo_url": "" - }, - { - "appid": 209000, - "name": "Batman™: Arkham Origins", - "playtime_forever_min": 0, - "img_icon_url": "76dac70a2206de1a80da4950da43e1b05ea302a8", - "img_logo_url": "" - }, - { - "appid": 218620, - "name": "PAYDAY 2", - "playtime_forever_min": 4724, - "img_icon_url": "a6abc0d0c1e79c0b5b0f5c8ab81ce9076a542414", - "img_logo_url": "" - }, - { - "appid": 219150, - "name": "Hotline Miami", - "playtime_forever_min": 1249, - "img_icon_url": "493c0a3e823c60c14f91204526f26cc064ed7c84", - "img_logo_url": "" - }, - { - "appid": 219990, - "name": "Grim Dawn", - "playtime_forever_min": 0, - "img_icon_url": "762057f2b14463ae1cbf0701a4cdb25cf94e8a0c", - "img_logo_url": "" - }, - { - "appid": 220240, - "name": "Far Cry® 3", - "playtime_forever_min": 880, - "img_icon_url": "8231f43d3dd47fb4b4111fd6bd4a849e73eb0520", - "img_logo_url": "" - }, - { - "appid": 222900, - "name": "Dead Island: Epidemic", - "playtime_forever_min": 129, - "img_icon_url": "eeca961216745840443d87373f907d1e6e4ca764", - "img_logo_url": "" - }, - { - "appid": 224260, - "name": "No More Room in Hell", - "playtime_forever_min": 37, - "img_icon_url": "684de0d9c5749b5ddd52f120894fd97efd620b1d", - "img_logo_url": "" - }, - { - "appid": 225540, - "name": "Just Cause 3", - "playtime_forever_min": 0, - "img_icon_url": "c82c69c8d616d9d273d749e3dd3bd7a0f9da594a", - "img_logo_url": "" - }, - { - "appid": 226320, - "name": "Marvel Heroes Omega", - "playtime_forever_min": 1601, - "img_icon_url": "f9e8431600ea2ac6b15d81bd6c38e63c13ba2bbd", - "img_logo_url": "" - }, - { - "appid": 227300, - "name": "Euro Truck Simulator 2", - "playtime_forever_min": 3617, - "img_icon_url": "adc18a4fc9adc0330144b76d61cbda68bb2394a0", - "img_logo_url": "" - }, - { - "appid": 230230, - "name": "Divinity: Original Sin (Classic)", - "playtime_forever_min": 0, - "img_icon_url": "ca9cdafd4257253dba68babeed300874190fbbff", - "img_logo_url": "" - }, - { - "appid": 232090, - "name": "Killing Floor 2", - "playtime_forever_min": 81, - "img_icon_url": "15837cefb378766e9916548f8591b6eb490b9e52", - "img_logo_url": "" - }, - { - "appid": 233270, - "name": "Far Cry® 3 Blood Dragon", - "playtime_forever_min": 14, - "img_icon_url": "37ea239abeef5c5b2a90a2f821aaab745633972a", - "img_logo_url": "" - }, - { - "appid": 233450, - "name": "Prison Architect", - "playtime_forever_min": 0, - "img_icon_url": "c166c7911beec4d63a74cdddf25f26b73c84556b", - "img_logo_url": "" - }, - { - "appid": 233860, - "name": "Kenshi", - "playtime_forever_min": 0, - "img_icon_url": "9e8fa3ef841ba1861b2b8aa1254532bb4eee954b", - "img_logo_url": "" - }, - { - "appid": 241930, - "name": "Middle-earth™: Shadow of Mordor™", - "playtime_forever_min": 49, - "img_icon_url": "161afab3c9f725f593635723cc64a7fbeb324ead", - "img_logo_url": "" - }, - { - "appid": 242050, - "name": "Assassin's Creed IV Black Flag", - "playtime_forever_min": 0, - "img_icon_url": "a03c06290af00eed66814815663b68f5fd063882", - "img_logo_url": "" - }, - { - "appid": 242760, - "name": "The Forest", - "playtime_forever_min": 0, - "img_icon_url": "3a6847f6ac5879e48531db52261771d5e22904ac", - "img_logo_url": "" - }, - { - "appid": 243470, - "name": "Watch_Dogs", - "playtime_forever_min": 1323, - "img_icon_url": "53368e59a196dfa9af66ecd32135939da97fa72e", - "img_logo_url": "" - }, - { - "appid": 250400, - "name": "How to Survive", - "playtime_forever_min": 538, - "img_icon_url": "2c493ce76970c4d70183c17b2e44b0099f0c56d5", - "img_logo_url": "" - }, - { - "appid": 250900, - "name": "The Binding of Isaac: Rebirth", - "playtime_forever_min": 0, - "img_icon_url": "16d46c8630499bfc54d20745ac90786a302cd643", - "img_logo_url": "" - }, - { - "appid": 255710, - "name": "Cities: Skylines", - "playtime_forever_min": 890, - "img_icon_url": "6cf7b10dd29db28448ef79698ed2118a03617d63", - "img_logo_url": "" - }, - { - "appid": 261550, - "name": "Mount & Blade II: Bannerlord", - "playtime_forever_min": 1446, - "img_icon_url": "896d5f4214e4e185a20af51b46d9dea2e6f4aaad", - "img_logo_url": "" - }, - { - "appid": 263980, - "name": "Out There Somewhere", - "playtime_forever_min": 0, - "img_icon_url": "9ef5b78baca02bd93df4050525de055b4b5c0e3a", - "img_logo_url": "" - }, - { - "appid": 268500, - "name": "XCOM 2", - "playtime_forever_min": 2343, - "img_icon_url": "f275aeb0b1b947262810569356a199848c643754", - "img_logo_url": "" - }, - { - "appid": 270550, - "name": "Yet Another Zombie Defense", - "playtime_forever_min": 72, - "img_icon_url": "d9414db164fa12a1e2cb83a60056dccc77ea3f38", - "img_logo_url": "" - }, - { - "appid": 270880, - "name": "American Truck Simulator", - "playtime_forever_min": 0, - "img_icon_url": "2aec8af4b4b23cb6054d9aea06194621cdd66945", - "img_logo_url": "" - }, - { - "appid": 271590, - "name": "Grand Theft Auto V Legacy", - "playtime_forever_min": 2473, - "img_icon_url": "1e72f87eb927fa1485e68aefaff23c7fd7178251", - "img_logo_url": "" - }, - { - "appid": 274170, - "name": "Hotline Miami 2: Wrong Number", - "playtime_forever_min": 570, - "img_icon_url": "3fc78b615529f78cc24cea247c7b2ee50c551bbd", - "img_logo_url": "" - }, - { - "appid": 286690, - "name": "Metro 2033 Redux", - "playtime_forever_min": 0, - "img_icon_url": "353ea01a045c084215bca95519808eaa7319ce0c", - "img_logo_url": "" - }, - { - "appid": 287450, - "name": "Rise of Nations: Extended Edition", - "playtime_forever_min": 696, - "img_icon_url": "0e142a02add319e32447f957a30fd1d360e62d3e", - "img_logo_url": "" - }, - { - "appid": 287700, - "name": "METAL GEAR SOLID V: THE PHANTOM PAIN", - "playtime_forever_min": 0, - "img_icon_url": "7a1737163c96ea641143db45709a4ac444ba8f7b", - "img_logo_url": "" - }, - { - "appid": 289070, - "name": "Sid Meier's Civilization VI", - "playtime_forever_min": 3752, - "img_icon_url": "9dc914132fec244adcede62fb8e7524a72a7398c", - "img_logo_url": "" - }, - { - "appid": 289130, - "name": "ENDLESS™ Legend", - "playtime_forever_min": 4, - "img_icon_url": "02277be6b9b09671b857948006316057974d7036", - "img_logo_url": "" - }, - { - "appid": 289650, - "name": "Assassin's Creed Unity", - "playtime_forever_min": 0, - "img_icon_url": "0629ffe7fc7d88011c5b9705625bfe3791fe0afe", - "img_logo_url": "" - }, - { - "appid": 291010, - "name": "The Hat Man: Shadow Ward", - "playtime_forever_min": 13, - "img_icon_url": "8a9fd7a24e99762464daa999cde440cca5373d2c", - "img_logo_url": "" - }, - { - "appid": 292030, - "name": "The Witcher 3: Wild Hunt", - "playtime_forever_min": 0, - "img_icon_url": "78d0ff98b67851f24539cdf2402cf147679134f4", - "img_logo_url": "" - }, - { - "appid": 292620, - "name": "Pressured", - "playtime_forever_min": 31, - "img_icon_url": "53d99d13ef00dcd83fd9666379076aa290923f63", - "img_logo_url": "" - }, - { - "appid": 294100, - "name": "RimWorld", - "playtime_forever_min": 0, - "img_icon_url": "addbe3b704b267060b4d5d7649cfb292de61bd70", - "img_logo_url": "" - }, - { - "appid": 298110, - "name": "Far Cry 4", - "playtime_forever_min": 686, - "img_icon_url": "be1366a047a515ed2275aa500a36a50de587b7a7", - "img_logo_url": "" - }, - { - "appid": 301910, - "name": "Saints Row: Gat out of Hell", - "playtime_forever_min": 0, - "img_icon_url": "7e817d02a9e0a52cdc171a006a8f0a48225456ea", - "img_logo_url": "" - }, - { - "appid": 305620, - "name": "The Long Dark", - "playtime_forever_min": 0, - "img_icon_url": "e26b78087b75d5e002e92f2bdd73ce6fc4861e56", - "img_logo_url": "" - }, - { - "appid": 307690, - "name": "Sleeping Dogs: Definitive Edition", - "playtime_forever_min": 0, - "img_icon_url": "8ef5e84ffbd9a4b3fb28d308073bccb415fc25ab", - "img_logo_url": "" - }, - { - "appid": 307780, - "name": "Mortal Kombat X", - "playtime_forever_min": 333, - "img_icon_url": "f3137c23413b98a9091a217e16ccc5e477a2a88e", - "img_logo_url": "" - }, - { - "appid": 314020, - "name": "Morphopolis", - "playtime_forever_min": 82, - "img_icon_url": "e425a6c4512f5c35a6cb08ff855630889ef32b29", - "img_logo_url": "" - }, - { - "appid": 315430, - "name": "Polarity", - "playtime_forever_min": 27, - "img_icon_url": "65a5e1336816df79435ac5164644c62380c3f133", - "img_logo_url": "" - }, - { - "appid": 316030, - "name": "Alice in Wonderland", - "playtime_forever_min": 19, - "img_icon_url": "53860ea4348022e17e92801257f50007fc3a736d", - "img_logo_url": "" - }, - { - "appid": 318430, - "name": "Squishy the Suicidal Pig", - "playtime_forever_min": 260, - "img_icon_url": "32f01eb14852af74a09807dc7154f6e9ef459d77", - "img_logo_url": "" - }, - { - "appid": 319910, - "name": "Trine 3: The Artifacts of Power", - "playtime_forever_min": 0, - "img_icon_url": "15853f24d7576a83cd4d54532a2a93c2d8d6024d", - "img_logo_url": "" - }, - { - "appid": 322330, - "name": "Don't Starve Together", - "playtime_forever_min": 0, - "img_icon_url": "a80aa6cff8eebc1cbc18c367d9ab063e1553b0ee", - "img_logo_url": "" - }, - { - "appid": 330020, - "name": "Children of Morta", - "playtime_forever_min": 200, - "img_icon_url": "29e179c5c7ac3aa6d3fb15ca5b19fd1124af4e7b", - "img_logo_url": "" - }, - { - "appid": 330350, - "name": "Robotex", - "playtime_forever_min": 4, - "img_icon_url": "367cda832cbe1383afd3eb00bff109fff0db8e40", - "img_logo_url": "" - }, - { - "appid": 331470, - "name": "Everlasting Summer", - "playtime_forever_min": 1391, - "img_icon_url": "c238887d29f28382920884d89bb3fc0718070a19", - "img_logo_url": "" - }, - { - "appid": 333420, - "name": "Cossacks 3", - "playtime_forever_min": 731, - "img_icon_url": "7613db27bfc85fdd767782eb6e53ba19d8bd028d", - "img_logo_url": "" - }, - { - "appid": 339610, - "name": "FreeStyle 2: Street Basketball", - "playtime_forever_min": 3428, - "img_icon_url": "368f89b5ecc999f1956a98a9bdb86f4d6acb243d", - "img_logo_url": "" - }, - { - "appid": 345520, - "name": "Infinite Crisis™", - "playtime_forever_min": 21, - "img_icon_url": "afc10c6aa4476db6d0d7fc3b6ccbeba33f5c0b3a", - "img_logo_url": "" - }, - { - "appid": 346900, - "name": "AdVenture Capitalist", - "playtime_forever_min": 313, - "img_icon_url": "b4dd5ca1582ed52335b31960e05766fd22fa7cc4", - "img_logo_url": "" - }, - { - "appid": 351920, - "name": "Crazy Machines 3", - "playtime_forever_min": 0, - "img_icon_url": "6cf6da3763360bbc86b1b55b10ddbb7e8f363d81", - "img_logo_url": "" - }, - { - "appid": 356190, - "name": "Middle-earth™: Shadow of War™", - "playtime_forever_min": 0, - "img_icon_url": "316c479e5d51d21e15eb8f82338fce82c42c4115", - "img_logo_url": "" - }, - { - "appid": 360430, - "name": "Mafia III: Definitive Edition", - "playtime_forever_min": 0, - "img_icon_url": "3df3ee0dfcb254bedbf15822c93a6983f67b0822", - "img_logo_url": "" - }, - { - "appid": 361420, - "name": "ASTRONEER", - "playtime_forever_min": 0, - "img_icon_url": "18ef632a152a69a8fb4f62c074cd50aa15d3bf33", - "img_logo_url": "" - }, - { - "appid": 362890, - "name": "Black Mesa", - "playtime_forever_min": 0, - "img_icon_url": "51182b6a74035eb913b353d5d60e596c3804daf6", - "img_logo_url": "" - }, - { - "appid": 363970, - "name": "Clicker Heroes", - "playtime_forever_min": 87, - "img_icon_url": "155ed2de95eacc3353b91c2a057bdbf727c11c20", - "img_logo_url": "" - }, - { - "appid": 368500, - "name": "Assassin's Creed Syndicate", - "playtime_forever_min": 0, - "img_icon_url": "4f67f650e621319029fa884755dbd89a2d7076f6", - "img_logo_url": "" - }, - { - "appid": 371660, - "name": "Far Cry Primal", - "playtime_forever_min": 1031, - "img_icon_url": "5b8109f8e71bc8b5c295d6972b841ffb92702ef0", - "img_logo_url": "" - }, - { - "appid": 373420, - "name": "Divinity: Original Sin Enhanced Edition", - "playtime_forever_min": 0, - "img_icon_url": "822ff912b2e464fbebbedf7a75ae1eb24921f16b", - "img_logo_url": "" - }, - { - "appid": 374320, - "name": "DARK SOULS™ III", - "playtime_forever_min": 271, - "img_icon_url": "7abe1a33129c20cf10d2c74128bbd657a2a2c806", - "img_logo_url": "" - }, - { - "appid": 377160, - "name": "Fallout 4", - "playtime_forever_min": 0, - "img_icon_url": "779c4356ebe32af2af7c9f0bbba595dfe872cd7f", - "img_logo_url": "" - }, - { - "appid": 379430, - "name": "Kingdom Come: Deliverance", - "playtime_forever_min": 2164, - "img_icon_url": "915ec515c36645855abed0476b5b0891d8a4e47a", - "img_logo_url": "" - }, - { - "appid": 379720, - "name": "DOOM", - "playtime_forever_min": 655, - "img_icon_url": "b6e72ff47d1990cb644700751eeeff14e0aba6dc", - "img_logo_url": "" - }, - { - "appid": 386360, - "name": "SMITE", - "playtime_forever_min": 24274, - "img_icon_url": "7ed9de7bbfab9accb81e47b84943e7478baf2f3a", - "img_logo_url": "" - }, - { - "appid": 391220, - "name": "Rise of the Tomb Raider", - "playtime_forever_min": 0, - "img_icon_url": "0b8a37f32ed2b7c934be8aa94d53f71e274c6497", - "img_logo_url": "" - }, - { - "appid": 393380, - "name": "Squad", - "playtime_forever_min": 0, - "img_icon_url": "eb66a8cb52165274c84cae6526205065d13119d5", - "img_logo_url": "" - }, - { - "appid": 394360, - "name": "Hearts of Iron IV", - "playtime_forever_min": 0, - "img_icon_url": "67b15e2336311b4b4f1eb5c802af0c0dbe644195", - "img_logo_url": "" - }, - { - "appid": 409710, - "name": "BioShock Remastered", - "playtime_forever_min": 0, - "img_icon_url": "eb72262cd3ccc3219dd76392be3b60a4b6cbfd38", - "img_logo_url": "" - }, - { - "appid": 409720, - "name": "BioShock 2 Remastered", - "playtime_forever_min": 0, - "img_icon_url": "97527a02b36f8ac4aba21005c2d953cc908a08e1", - "img_logo_url": "" - }, - { - "appid": 412020, - "name": "Metro Exodus", - "playtime_forever_min": 0, - "img_icon_url": "f9863ed0ffc717b130744488f088ae95fe6e9bd8", - "img_logo_url": "" - }, - { - "appid": 414340, - "name": "Hellblade: Senua's Sacrifice", - "playtime_forever_min": 565, - "img_icon_url": "8a5d8f2d1cb52b21eaf4f6609592c6634731c962", - "img_logo_url": "" - }, - { - "appid": 435150, - "name": "Divinity: Original Sin 2", - "playtime_forever_min": 0, - "img_icon_url": "519a99caef7c5e2b4625c8c2fa0620fb66a752f3", - "img_logo_url": "" - }, - { - "appid": 447040, - "name": "Watch_Dogs 2", - "playtime_forever_min": 191, - "img_icon_url": "49dc5c3e4053e40159ffa73d2121daadbc467fe2", - "img_logo_url": "" - }, - { - "appid": 460950, - "name": "Katana ZERO", - "playtime_forever_min": 0, - "img_icon_url": "874ee87e03abd66ff158727c051ceb173e3cba3c", - "img_logo_url": "" - }, - { - "appid": 474960, - "name": "Quantum Break", - "playtime_forever_min": 0, - "img_icon_url": "68c50addc599af219e4e135a81733a867c2986c2", - "img_logo_url": "" - }, - { - "appid": 475150, - "name": "Titan Quest Anniversary Edition", - "playtime_forever_min": 1618, - "img_icon_url": "be3384e7936f548303c77ee2abc4c9026285f554", - "img_logo_url": "" - }, - { - "appid": 489830, - "name": "The Elder Scrolls V: Skyrim Special Edition", - "playtime_forever_min": 153, - "img_icon_url": "0dfe3eed5658f9fbd8b62f8021038c0a4190f21d", - "img_logo_url": "" - }, - { - "appid": 492720, - "name": "Tropico 6", - "playtime_forever_min": 643, - "img_icon_url": "11f6e0cc7514f7ef8f1687276718c6f95d3c3e50", - "img_logo_url": "" - }, - { - "appid": 526870, - "name": "Satisfactory", - "playtime_forever_min": 0, - "img_icon_url": "ee3406fe5ec813b1987ad67e37e5cd6fb4f620e6", - "img_logo_url": "" - }, - { - "appid": 529340, - "name": "Victoria 3", - "playtime_forever_min": 0, - "img_icon_url": "da7eddaef804905207eaf0ba9580b5026385f041", - "img_logo_url": "" - }, - { - "appid": 548430, - "name": "Deep Rock Galactic", - "playtime_forever_min": 0, - "img_icon_url": "e033e23c29a192a17c16a7645a2b423ac64ff447", - "img_logo_url": "" - }, - { - "appid": 551770, - "name": "ECHO", - "playtime_forever_min": 0, - "img_icon_url": "1a1502bdb54955388979ee39fb2821e79c0046c7", - "img_logo_url": "" - }, - { - "appid": 552520, - "name": "Far Cry 5", - "playtime_forever_min": 948, - "img_icon_url": "928df2d32021f480499a97458068f62c45298e7a", - "img_logo_url": "" - }, - { - "appid": 557340, - "name": "My Friend Pedro", - "playtime_forever_min": 239, - "img_icon_url": "734deb65d5561961595a190fa2d2adf61d607efe", - "img_logo_url": "" - }, - { - "appid": 559100, - "name": "Phantom Doctrine", - "playtime_forever_min": 86, - "img_icon_url": "042d9ec1fed7cfb3581d6e0a24c4c3738573130a", - "img_logo_url": "" - }, - { - "appid": 578080, - "name": "PUBG: BATTLEGROUNDS", - "playtime_forever_min": 138, - "img_icon_url": "609f27278aa70697c13bf99f32c5a0248c381f9d", - "img_logo_url": "" - }, - { - "appid": 582160, - "name": "Assassin's Creed Origins", - "playtime_forever_min": 0, - "img_icon_url": "dff3a53f93d39f1e0432ec4f22d31c12aeefa36f", - "img_logo_url": "" - }, - { - "appid": 582660, - "name": "Black Desert", - "playtime_forever_min": 0, - "img_icon_url": "bf5ccace0a692720984827bf042143d0d4b28a42", - "img_logo_url": "" - }, - { - "appid": 597180, - "name": "Old World", - "playtime_forever_min": 139, - "img_icon_url": "015feb0aa056dd763c0ead2b98a774719ca62ac0", - "img_logo_url": "" - }, - { - "appid": 601150, - "name": "Devil May Cry 5", - "playtime_forever_min": 0, - "img_icon_url": "7ec2f6fb9069c3ae03af7e83610862090cd757bb", - "img_logo_url": "" - }, - { - "appid": 606280, - "name": "Darksiders III", - "playtime_forever_min": 0, - "img_icon_url": "1f2b21f809d9a409460f23d12061c1b18f6d5a74", - "img_logo_url": "" - }, - { - "appid": 613100, - "name": "House Flipper", - "playtime_forever_min": 143, - "img_icon_url": "4ab6e7c49f3f0c063dfe08ab85038c979301f6dd", - "img_logo_url": "" - }, - { - "appid": 632470, - "name": "Disco Elysium", - "playtime_forever_min": 84, - "img_icon_url": "b681544caa931c7c1a6788e6e3e33cb42892d17c", - "img_logo_url": "" - }, - { - "appid": 667720, - "name": "Red Faction Guerrilla Re-Mars-tered", - "playtime_forever_min": 0, - "img_icon_url": "a98e571b7225e3acc06042ed97444e37e99cc4ad", - "img_logo_url": "" - }, - { - "appid": 690640, - "name": "Trine 4: The Nightmare Prince", - "playtime_forever_min": 0, - "img_icon_url": "afe71b7d8dd5ceb78d971d1996266c6307ec5fd0", - "img_logo_url": "" - }, - { - "appid": 728880, - "name": "Overcooked! 2", - "playtime_forever_min": 0, - "img_icon_url": "bac36f471109038ae0b40619de4109d475f86a54", - "img_logo_url": "" - }, - { - "appid": 747350, - "name": "Hellblade: Senua's Sacrifice VR Edition", - "playtime_forever_min": 0, - "img_icon_url": "8a5d8f2d1cb52b21eaf4f6609592c6634731c962", - "img_logo_url": "" - }, - { - "appid": 774941, - "name": "Squad - Public Testing", - "playtime_forever_min": 0, - "img_icon_url": "6a9ee44c92c5ca7ddacf0d56bb7d5720961da0f8", - "img_logo_url": "" - }, - { - "appid": 782330, - "name": "DOOM Eternal", - "playtime_forever_min": 0, - "img_icon_url": "14235f425d27ab3b83661ba14c17d22dc90e566b", - "img_logo_url": "" - }, - { - "appid": 812140, - "name": "Assassin's Creed Odyssey", - "playtime_forever_min": 0, - "img_icon_url": "4b6cf0715b30669411bf204bce7ed99a9c84671b", - "img_logo_url": "" - }, - { - "appid": 829660, - "name": "No Time to Relax", - "playtime_forever_min": 0, - "img_icon_url": "0ec88035a2161eb56476db70fe7593d4aed94425", - "img_logo_url": "" - }, - { - "appid": 870780, - "name": "Control Ultimate Edition", - "playtime_forever_min": 0, - "img_icon_url": "3438e3a03c82194349d66f99ee642ba59cbcc3dc", - "img_logo_url": "" - }, - { - "appid": 892970, - "name": "Valheim", - "playtime_forever_min": 0, - "img_icon_url": "2f64c9a826e2c6cf3253fea4834c2e612db09143", - "img_logo_url": "" - }, - { - "appid": 911400, - "name": "Assassin's Creed III Remastered", - "playtime_forever_min": 0, - "img_icon_url": "68e5640754e5ddf29aadff49062fa68cc8c8f4a5", - "img_logo_url": "" - }, - { - "appid": 916440, - "name": "Anno 1800", - "playtime_forever_min": 6387, - "img_icon_url": "cf7c754267856d5a1b36614855c52867daf72186", - "img_logo_url": "" - }, - { - "appid": 939960, - "name": "Far Cry New Dawn", - "playtime_forever_min": 643, - "img_icon_url": "4123e8e644fd7c9d576e405f76f4296869293deb", - "img_logo_url": "" - }, - { - "appid": 945360, - "name": "Among Us", - "playtime_forever_min": 81, - "img_icon_url": "b82c3f46da8f3c918e1c9e0d18bd6fa8fcef6801", - "img_logo_url": "" - }, - { - "appid": 960910, - "name": "Heavy Rain", - "playtime_forever_min": 0, - "img_icon_url": "ddc1b819a9c00d9642d290fc521e48d708af7329", - "img_logo_url": "" - }, - { - "appid": 973580, - "name": "Sniper Ghost Warrior Contracts", - "playtime_forever_min": 1102, - "img_icon_url": "3519b24cf69525f64e262d8791dff1cb650069e5", - "img_logo_url": "" - }, - { - "appid": 976310, - "name": "Mortal Kombat 11", - "playtime_forever_min": 0, - "img_icon_url": "315c46ce25bcb6afa47473588b5fd8edeb3afdfa", - "img_logo_url": "" - }, - { - "appid": 978300, - "name": "Saints Row The Third Remastered", - "playtime_forever_min": 0, - "img_icon_url": "115fd158b792354987fb64f7cbcceadfa2f43b8b", - "img_logo_url": "" - }, - { - "appid": 990080, - "name": "Hogwarts Legacy", - "playtime_forever_min": 2130, - "img_icon_url": "a9ecb94f249768d0ee5ccecbffe8d8c06d9bed59", - "img_logo_url": "" - }, - { - "appid": 1029690, - "name": "Sniper Elite 5", - "playtime_forever_min": 0, - "img_icon_url": "00bce3b25aed4dfd96e864e03dd315448dcb834b", - "img_logo_url": "" - }, - { - "appid": 1030830, - "name": "Mafia II: Definitive Edition", - "playtime_forever_min": 2, - "img_icon_url": "f80e173ad5c242ca0d41e30af1f6358d1e64d9b4", - "img_logo_url": "" - }, - { - "appid": 1030840, - "name": "Mafia: Definitive Edition", - "playtime_forever_min": 480, - "img_icon_url": "883ceebf7d8ebce37c9244a62777ef4a800393ce", - "img_logo_url": "" - }, - { - "appid": 1044720, - "name": "Farthest Frontier", - "playtime_forever_min": 1059, - "img_icon_url": "8cc098a18d3e8b12529e5653fc075af66a7f7155", - "img_logo_url": "" - }, - { - "appid": 1070330, - "name": "Russian Life Simulator", - "playtime_forever_min": 164, - "img_icon_url": "41e1a7721ee76c3beab84b01f8fe8c83c9802a93", - "img_logo_url": "" - }, - { - "appid": 1086940, - "name": "Baldur's Gate 3", - "playtime_forever_min": 5742, - "img_icon_url": "d866cae7ea1e471fdbc206287111f1b642373bd9", - "img_logo_url": "" - }, - { - "appid": 1088850, - "name": "Marvel's Guardians of the Galaxy", - "playtime_forever_min": 0, - "img_icon_url": "8aec558b5e9c3aa185869d438f78200a2d4bf885", - "img_logo_url": "" - }, - { - "appid": 1091500, - "name": "Cyberpunk 2077", - "playtime_forever_min": 5057, - "img_icon_url": "42b9b33fa0f0d997beb299c6157592a8fe7d8f68", - "img_logo_url": "" - }, - { - "appid": 1092790, - "name": "Inscryption", - "playtime_forever_min": 0, - "img_icon_url": "25c02dcbabe97febe787797195099325a4d47935", - "img_logo_url": "" - }, - { - "appid": 1097150, - "name": "Fall Guys", - "playtime_forever_min": 112, - "img_icon_url": "6c027709b9eb1417c993bc7d513b97b232002368", - "img_logo_url": "" - }, - { - "appid": 1097840, - "name": "Gears 5", - "playtime_forever_min": 0, - "img_icon_url": "df954557a7cbb1ae3ef632871025991082ed362f", - "img_logo_url": "" - }, - { - "appid": 1123770, - "name": "Curse of the Dead Gods", - "playtime_forever_min": 0, - "img_icon_url": "74439a263f77dc8e5d6dff12da3458b46ce2726b", - "img_logo_url": "" - }, - { - "appid": 1124300, - "name": "HUMANKIND™", - "playtime_forever_min": 877, - "img_icon_url": "c2d90169b56bc798213b8bcf949d3d5924a2d0a0", - "img_logo_url": "" - }, - { - "appid": 1139900, - "name": "Ghostrunner", - "playtime_forever_min": 1335, - "img_icon_url": "ed987f3c8a114549fca26e0c2e9bbc62a9691d57", - "img_logo_url": "" - }, - { - "appid": 1145360, - "name": "Hades", - "playtime_forever_min": 1014, - "img_icon_url": "8a3fca36a00883e8066263ad35dd15d77a1f9abc", - "img_logo_url": "" - }, - { - "appid": 1151640, - "name": "Horizon Zero Dawn™ Complete Edition", - "playtime_forever_min": 0, - "img_icon_url": "08a1b6df68bdc8c28c974b79325ed8c89417af48", - "img_logo_url": "" - }, - { - "appid": 1158310, - "name": "Crusader Kings III", - "playtime_forever_min": 4503, - "img_icon_url": "8a0d88dfaff790ea1aa2b9fcf50d4e3b4f49cf56", - "img_logo_url": "" - }, - { - "appid": 1172380, - "name": "STAR WARS Jedi: Fallen Order™ ", - "playtime_forever_min": 1186, - "img_icon_url": "0ea1d285a8ee6fbeef0e8f7f3b2d7fa4cbcb423b", - "img_logo_url": "" - }, - { - "appid": 1174180, - "name": "Red Dead Redemption 2", - "playtime_forever_min": 2811, - "img_icon_url": "5106abd9c1187a97f23295a0ba9470c94804ec6c", - "img_logo_url": "" - }, - { - "appid": 1210320, - "name": "Potion Craft: Alchemist Simulator", - "playtime_forever_min": 1178, - "img_icon_url": "e0a089daf3bfb668d42375e2e3d1f9c1184bb80f", - "img_logo_url": "" - }, - { - "appid": 1222140, - "name": "Detroit: Become Human", - "playtime_forever_min": 0, - "img_icon_url": "3d9f5bc971d44398aef740c0d8e79a358be2e800", - "img_logo_url": "" - }, - { - "appid": 1222670, - "name": "The Sims™ 4", - "playtime_forever_min": 19, - "img_icon_url": "ca6bc8b2411bce4a2cd325ab75f0204bc3a4ad98", - "img_logo_url": "" - }, - { - "appid": 1222680, - "name": "Need for Speed™ Heat ", - "playtime_forever_min": 1084, - "img_icon_url": "98a96955403db8eeed026bc5ff04e51e3329f1b0", - "img_logo_url": "" - }, - { - "appid": 1222700, - "name": "A Way Out", - "playtime_forever_min": 0, - "img_icon_url": "ac36b7d06c117d34c204166c3120e15910ef2b8b", - "img_logo_url": "" - }, - { - "appid": 1232130, - "name": "BEAR, VODKA, STALINGRAD! 🐻", - "playtime_forever_min": 10, - "img_icon_url": "945316799a912f0b53214c956a76b8ca38755541", - "img_logo_url": "" - }, - { - "appid": 1233570, - "name": "Mirror's Edge™ Catalyst", - "playtime_forever_min": 0, - "img_icon_url": "a5583fa5e07defaeb3a3ade0b4936767265dd660", - "img_logo_url": "" - }, - { - "appid": 1237950, - "name": "STAR WARS™ Battlefront™ II", - "playtime_forever_min": 0, - "img_icon_url": "dbdd20f60579c3695427801a7b8d9844cf3e428b", - "img_logo_url": "" - }, - { - "appid": 1238810, - "name": "Battlefield™ V", - "playtime_forever_min": 0, - "img_icon_url": "23ded6a957b5ec7525679c2e7bdac90c0653dbc8", - "img_logo_url": "" - }, - { - "appid": 1239690, - "name": "Retrowave", - "playtime_forever_min": 87, - "img_icon_url": "43f7a3f64c81e229b53fd3fdb7ee151faea0eff1", - "img_logo_url": "" - }, - { - "appid": 1259420, - "name": "Days Gone", - "playtime_forever_min": 0, - "img_icon_url": "839338fcedd1033ab7f0d2a31c194fb880ebf7b5", - "img_logo_url": "" - }, - { - "appid": 1272080, - "name": "PAYDAY 3", - "playtime_forever_min": 0, - "img_icon_url": "d4ad48893646cae61934ea10749527346f8b7736", - "img_logo_url": "" - }, - { - "appid": 1316680, - "name": "VLADiK BRUTAL", - "playtime_forever_min": 271, - "img_icon_url": "0bb8db4a473947acbdbe1c8814ff338efd053a31", - "img_logo_url": "" - }, - { - "appid": 1328670, - "name": "Mass Effect™ Legendary Edition", - "playtime_forever_min": 0, - "img_icon_url": "443ed7dba43882ef4298f8ed15bf67f323461e14", - "img_logo_url": "" - }, - { - "appid": 1332010, - "name": "Stray", - "playtime_forever_min": 366, - "img_icon_url": "7794cc0b4e70bd3e4b9a0d07e2570ab8668c5f0b", - "img_logo_url": "" - }, - { - "appid": 1336490, - "name": "Against the Storm", - "playtime_forever_min": 0, - "img_icon_url": "f26712271f7b0554cc1c6fb0267f122c47cf6e85", - "img_logo_url": "" - }, - { - "appid": 1338770, - "name": "Sniper Ghost Warrior Contracts 2", - "playtime_forever_min": 0, - "img_icon_url": "6bdcfa89cd9d556ead596849b17683ee047a926c", - "img_logo_url": "" - }, - { - "appid": 1363080, - "name": "Manor Lords", - "playtime_forever_min": 263, - "img_icon_url": "f0b869c004ddb564f961668130894f335fe04175", - "img_logo_url": "" - }, - { - "appid": 1426210, - "name": "It Takes Two", - "playtime_forever_min": 1890, - "img_icon_url": "6b15f6d81c4fc52056f4928b363b7fac591a945b", - "img_logo_url": "" - }, - { - "appid": 1436700, - "name": "Trine 5: A Clockwork Conspiracy", - "playtime_forever_min": 0, - "img_icon_url": "3ef869db216460baed5dcd9caedc3d86510db597", - "img_logo_url": "" - }, - { - "appid": 1449560, - "name": "Metro Exodus Enhanced Edition", - "playtime_forever_min": 0, - "img_icon_url": "f9863ed0ffc717b130744488f088ae95fe6e9bd8", - "img_logo_url": "" - }, - { - "appid": 1466860, - "name": "Age of Empires IV: Anniversary Edition", - "playtime_forever_min": 306, - "img_icon_url": "503acf3ef0503e24af6b5336012a1f585a2f955e", - "img_logo_url": "" - }, - { - "appid": 1510580, - "name": "Toy Tinker Simulator", - "playtime_forever_min": 0, - "img_icon_url": "7a7c69259b7d1f622d8f7c0d34f93a85683c8fa4", - "img_logo_url": "" - }, - { - "appid": 1546970, - "name": "Grand Theft Auto III - The Definitive Edition", - "playtime_forever_min": 2214, - "img_icon_url": "34da0ad18d5dd3507e137195038ecd4b45ec08b3", - "img_logo_url": "" - }, - { - "appid": 1546990, - "name": "Grand Theft Auto: Vice City - The Definitive Edition", - "playtime_forever_min": 2176, - "img_icon_url": "cdbe042499edb7c1ca10ae17077a01ef2d4ea116", - "img_logo_url": "" - }, - { - "appid": 1547000, - "name": "Grand Theft Auto: San Andreas - The Definitive Edition", - "playtime_forever_min": 4008, - "img_icon_url": "44e14013432397d8c710ffcafcb77c259f3b4f27", - "img_logo_url": "" - }, - { - "appid": 1568590, - "name": "Goose Goose Duck", - "playtime_forever_min": 599, - "img_icon_url": "3b802d5010d6d5d72c93a8565b260c6042cc9390", - "img_logo_url": "" - }, - { - "appid": 1593500, - "name": "God of War", - "playtime_forever_min": 0, - "img_icon_url": "8aa5e7e0e55cad682c7f2e65e075c3bc04dfc1cb", - "img_logo_url": "" - }, - { - "appid": 1659040, - "name": "HITMAN World of Assassination", - "playtime_forever_min": 0, - "img_icon_url": "552be1d38866afd1c33f682323d325130e7d0ce6", - "img_logo_url": "" - }, - { - "appid": 1665460, - "name": "eFootball™", - "playtime_forever_min": 13, - "img_icon_url": "bde1bd66adae3f4e72e67e0687321f0038153ce3", - "img_logo_url": "" - }, - { - "appid": 1715130, - "name": "Crysis Remastered", - "playtime_forever_min": 647, - "img_icon_url": "c94e91cdf9ec7a73fde8090d96a8674386a1a0e3", - "img_logo_url": "" - }, - { - "appid": 1771300, - "name": "Kingdom Come: Deliverance II", - "playtime_forever_min": 0, - "img_icon_url": "80e8d75b87433627cafd2a1bcbb5b9f5741e2277", - "img_logo_url": "" - }, - { - "appid": 1774580, - "name": "STAR WARS Jedi: Survivor™", - "playtime_forever_min": 1666, - "img_icon_url": "25578ffb2c6070c5d4e6569624efaadb8a0f5538", - "img_logo_url": "" - }, - { - "appid": 1794680, - "name": "Vampire Survivors", - "playtime_forever_min": 0, - "img_icon_url": "3677cf1be3be1f4ea42261c62ce10519715ade58", - "img_logo_url": "" - }, - { - "appid": 1797610, - "name": "Barro 22", - "playtime_forever_min": 0, - "img_icon_url": "ab6f762cb4a3a63c26e0f9f03393cd2aab5a827e", - "img_logo_url": "" - }, - { - "appid": 1817070, - "name": "Marvel’s Spider-Man Remastered", - "playtime_forever_min": 0, - "img_icon_url": "346333cb340139ad8b697005e5c79a3162c387b0", - "img_logo_url": "" - }, - { - "appid": 1850570, - "name": "DEATH STRANDING DIRECTOR'S CUT", - "playtime_forever_min": 0, - "img_icon_url": "dd1abd2566c95d1e89408570719657a663591f8f", - "img_logo_url": "" - }, - { - "appid": 1888930, - "name": "The Last of Us™ Part I", - "playtime_forever_min": 1167, - "img_icon_url": "6991d13038a61e35bade3aabea400c74bbbf966d", - "img_logo_url": "" - }, - { - "appid": 1903340, - "name": "Clair Obscur: Expedition 33", - "playtime_forever_min": 1817, - "img_icon_url": "d48fb3ed033a08bad9bf0a2e1ceb145e58ffe0aa", - "img_logo_url": "" - }, - { - "appid": 1971870, - "name": "Mortal Kombat 1", - "playtime_forever_min": 0, - "img_icon_url": "8b1c5aa33466802fc2a5df95505be71fae0b8d47", - "img_logo_url": "" - }, - { - "appid": 2096600, - "name": "Crysis 2 Remastered", - "playtime_forever_min": 729, - "img_icon_url": "cb25994d3d2d4ea9c605e785c1f5b3db265e2529", - "img_logo_url": "" - }, - { - "appid": 2096610, - "name": "Crysis 3 Remastered", - "playtime_forever_min": 294, - "img_icon_url": "0c3330f540a6fbce8123110357608e6a36fff1fb", - "img_logo_url": "" - }, - { - "appid": 2138710, - "name": "Sifu", - "playtime_forever_min": 0, - "img_icon_url": "3930eb06cc31a6c91ab243afd4c45f49c9d0c5c1", - "img_logo_url": "" - }, - { - "appid": 2140020, - "name": "Stronghold: Definitive Edition", - "playtime_forever_min": 186, - "img_icon_url": "2079705e18e488f1aae992fb72f16c6853da710c", - "img_logo_url": "" - }, - { - "appid": 2144740, - "name": "Ghostrunner 2", - "playtime_forever_min": 178, - "img_icon_url": "39fc0a6b7051f744c725f1fc518a8996b859e86d", - "img_logo_url": "" - }, - { - "appid": 2195250, - "name": "EA SPORTS FC™ 24", - "playtime_forever_min": 4492, - "img_icon_url": "d01e05e0fb8e77c790c699b4fa0a9559cf80d16d", - "img_logo_url": "" - }, - { - "appid": 2208920, - "name": "Assassin's Creed Valhalla", - "playtime_forever_min": 0, - "img_icon_url": "3946f91cf6beb0d384a85fc32d71616313dc362e", - "img_logo_url": "" - }, - { - "appid": 2239550, - "name": "Watch Dogs: Legion", - "playtime_forever_min": 0, - "img_icon_url": "66909b160660967933f4ffed89d10d1b1cc5df32", - "img_logo_url": "" - }, - { - "appid": 2290000, - "name": "TerraScape", - "playtime_forever_min": 303, - "img_icon_url": "261012036371fb8015bab5f0728c0518a0e7a50d", - "img_logo_url": "" - }, - { - "appid": 2369390, - "name": "Far Cry 6", - "playtime_forever_min": 1570, - "img_icon_url": "178c21f6ed3ea43c9e5b3c9ea2919e7bca63c7b0", - "img_logo_url": "" - }, - { - "appid": 2406770, - "name": "Bodycam", - "playtime_forever_min": 0, - "img_icon_url": "793bf9dd10a0c8838879adca10459e77e1f02c51", - "img_logo_url": "" - }, - { - "appid": 2417610, - "name": "METAL GEAR SOLID Δ: SNAKE EATER", - "playtime_forever_min": 0, - "img_icon_url": "23e791770df64f422ed9e3620085f44c1c27ed50", - "img_logo_url": "" - }, - { - "appid": 2427410, - "name": "S.T.A.L.K.E.R.: Shadow of Chornobyl - Enhanced Edition", - "playtime_forever_min": 0, - "img_icon_url": "2bd3b525b674984e9e89f33fd47c5ce6020beeda", - "img_logo_url": "" - }, - { - "appid": 2427420, - "name": "S.T.A.L.K.E.R.: Clear Sky - Enhanced Edition", - "playtime_forever_min": 0, - "img_icon_url": "e04a49e45a8389c008cd46b54653889a8982eca9", - "img_logo_url": "" - }, - { - "appid": 2427430, - "name": "S.T.A.L.K.E.R.: Call of Prypiat - Enhanced Edition", - "playtime_forever_min": 0, - "img_icon_url": "bcf208587bb2cca67a8994f2e8599e6cfe0769e2", - "img_logo_url": "" - }, - { - "appid": 2456740, - "name": "inZOI", - "playtime_forever_min": 273, - "img_icon_url": "91593017a96a23af849781d6a68e9599cf250b4d", - "img_logo_url": "" - }, - { - "appid": 2461850, - "name": "Senua’s Saga: Hellblade II", - "playtime_forever_min": 0, - "img_icon_url": "f70eeb6c1dbafe03301c6ddaa3da5907755e4834", - "img_logo_url": "" - }, - { - "appid": 2531310, - "name": "The Last of Us™ Part II Remastered", - "playtime_forever_min": 1458, - "img_icon_url": "e04b9ac7c860fb6c60d9ad26e6779f324a7bcb70", - "img_logo_url": "" - }, - { - "appid": 2532550, - "name": "LIZARDS MUST DIE", - "playtime_forever_min": 328, - "img_icon_url": "1f0e5d8402f5f70e2c575f5548e9f1196251f869", - "img_logo_url": "" - }, - { - "appid": 2784470, - "name": "9 Kings", - "playtime_forever_min": 323, - "img_icon_url": "d1d0c590e82d977b24e1070a9ec1aeea9e4cd9f4", - "img_logo_url": "" - }, - { - "appid": 2796010, - "name": "Party Club", - "playtime_forever_min": 0, - "img_icon_url": "a80c96dde162ef5ed25704384b6e312b6b6cddf9", - "img_logo_url": "" - }, - { - "appid": 2807960, - "name": "Battlefield™ 6", - "playtime_forever_min": 72, - "img_icon_url": "f07be1624106283c0d85e1bc9211d431caa9b4b8", - "img_logo_url": "" - }, - { - "appid": 2996040, - "name": "Teenage Mutant Ninja Turtles: Splintered Fate", - "playtime_forever_min": 77, - "img_icon_url": "52873169f648846aadf836fefa8b6a4305c8dab9", - "img_logo_url": "" - }, - { - "appid": 3035570, - "name": "Assassin's Creed Mirage", - "playtime_forever_min": 0, - "img_icon_url": "e5d6098ec84423be04e5d8a4fac2784b392dae86", - "img_logo_url": "" - }, - { - "appid": 3107800, - "name": "LIZARDS MUST DIE 2", - "playtime_forever_min": 256, - "img_icon_url": "d6dd1764f0f95a2cfc6279e1f90340e21e26232b", - "img_logo_url": "" - }, - { - "appid": 3240220, - "name": "Grand Theft Auto V Enhanced", - "playtime_forever_min": 97, - "img_icon_url": "8355a7bbdb704f727bfba80ec56bc7228991338e", - "img_logo_url": "" - }, - { - "appid": 3472040, - "name": "NBA 2K26", - "playtime_forever_min": 918, - "img_icon_url": "78919a2d089bd18bd0dc9ee2525256f9723b047c", - "img_logo_url": "" - }, - { - "appid": 3551340, - "name": "Football Manager 26", - "playtime_forever_min": 797, - "img_icon_url": "fa56d151ecdd69c841687cbe65b4beba767bafb4", - "img_logo_url": "" - } -] \ No newline at end of file diff --git a/homelab/scripts/steam_library_size.py b/homelab/scripts/steam_library_size.py deleted file mode 100644 index 72e6322..0000000 --- a/homelab/scripts/steam_library_size.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -""" -Расчёт суммарного размера библиотеки Steam по данным из steam_library.json. -Приоритет: api.steamcmd.net (реальные размеры депо). Запас: системные требования -в магазине Steam (часто занижены). Результаты кэшируются в steam_app_sizes.json. -""" - -import json -import re -import time -import urllib.request -from pathlib import Path -from typing import Optional - -LIBRARY_FILE = "steam_library.json" -SIZES_CACHE_FILE = "steam_app_sizes.json" -OUTPUT_FILE = "steam_library_with_sizes.json" - -REQUEST_DELAY = 0.4 - - -def get_app_size_from_steamcmd(appid: int) -> Optional[float]: - """ - Размер в ГБ из api.steamcmd.net (депо, реальный объём установки). - Учитываются только депо для Windows, для языков — только english, чтобы не суммировать все локали. - """ - url = f"https://api.steamcmd.net/v1/info/{appid}" - try: - req = urllib.request.Request(url, headers={"Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=20) as resp: - data = json.loads(resp.read().decode()) - except Exception: - return None - if data.get("status") != "success" or "data" not in data: - return None - app_data = data["data"].get(str(appid)) - if not app_data or "depots" not in app_data: - return None - depots = app_data["depots"] - total_bytes = 0 - for depot_id, depot in depots.items(): - if depot_id in ("appmanagesdlc", "baselanguages", "branches", "privatebranches"): - continue - if not isinstance(depot, dict): - continue - config = depot.get("config") or {} - oslist = config.get("oslist", "") - if "windows" not in oslist.lower(): - continue - manifests = depot.get("manifests") - if not manifests or "public" not in manifests: - continue - size_str = manifests["public"].get("size") - if not size_str: - continue - try: - size_bytes = int(size_str) - except (TypeError, ValueError): - continue - language = config.get("language", "") - if language and language != "english": - continue - total_bytes += size_bytes - if total_bytes == 0: - return None - return total_bytes / (1024 ** 3) - - -def parse_size_from_text(text: str) -> Optional[float]: - """Извлекает размер в ГБ из строки системных требований.""" - if not text: - return None - text = text.replace(",", ".") - m = re.search(r"(\d+(?:\.\d+)?)\s*GB", text, re.IGNORECASE) - if m: - return float(m.group(1)) - m = re.search(r"(\d+(?:\.\d+)?)\s*MB", text, re.IGNORECASE) - if m: - return float(m.group(1)) / 1024 - return None - - -def get_app_size_from_store(appid: int) -> Optional[float]: - """Размер в ГБ из системных требований store.steampowered.com (часто занижен).""" - url = f"https://store.steampowered.com/api/appdetails?appids={appid}&l=english" - try: - req = urllib.request.Request(url, headers={"Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.loads(resp.read().decode()) - except Exception: - return None - block = data.get(str(appid)) - if not block or not block.get("success") or "data" not in block: - return None - reqs = block["data"].get("pc_requirements") - if not reqs: - return None - if isinstance(reqs, dict): - for part in (reqs.get("minimum"), reqs.get("recommended")): - size = parse_size_from_text(part) - if size is not None: - return size - elif isinstance(reqs, str): - return parse_size_from_text(reqs) - return None - - -def get_app_size(appid: int) -> Optional[float]: - """Сначала steamcmd (реальный размер), при отсутствии — магазин.""" - size = get_app_size_from_steamcmd(appid) - if size is not None: - return size - return get_app_size_from_store(appid) - - -def load_library(path: Path) -> list[dict]: - with open(path, encoding="utf-8") as f: - return json.load(f) - - -def load_sizes_cache(path: Path) -> dict: - if not path.exists(): - return {} - with open(path, encoding="utf-8") as f: - return {int(k): v for k, v in json.load(f).items()} - - -def save_sizes_cache(path: Path, cache: dict) -> None: - with open(path, "w", encoding="utf-8") as f: - json.dump(cache, f, indent=2) - - -def progress_bar(current: int, total: int, width: int = 40) -> str: - """Строка прогресс-бара: [=======> ] 45/289 15%""" - if total <= 0: - return "" - pct = current / total - filled = int(width * pct) - bar = "=" * filled + ">" * (1 if filled < width else 0) + " " * (width - filled - 1) - return f"[{bar}] {current}/{total} {pct*100:.0f}%" - - -def main() -> None: - base = Path(__file__).parent - lib_path = base / LIBRARY_FILE - cache_path = base / SIZES_CACHE_FILE - out_path = base / OUTPUT_FILE - - if not lib_path.exists(): - print(f"Сначала запусти steam-library-to-json.py — нужен файл {LIBRARY_FILE}") - return - - library = load_library(lib_path) - cache = load_sizes_cache(cache_path) - total_gb = 0.0 - unknown_count = 0 - results = [] - - total_games = len(library) - for i, game in enumerate(library): - appid = game["appid"] - name = game.get("name", "") - if appid in cache: - size_gb = cache[appid] - else: - size_gb = get_app_size(appid) - cache[appid] = size_gb - time.sleep(REQUEST_DELAY) - if size_gb is not None: - total_gb += size_gb - else: - unknown_count += 1 - results.append({ - "appid": appid, - "name": name, - "size_gb": round(size_gb, 2) if size_gb is not None else None, - }) - print(f"\r{progress_bar(i + 1, total_games)} {name[:35]:<35}", end="", flush=True) - print() - - save_sizes_cache(cache_path, cache) - with open(out_path, "w", encoding="utf-8") as f: - json.dump(results, f, ensure_ascii=False, indent=2) - - print() - print(f"Игр в библиотеке: {len(library)}") - print(f"С известным размером: {len(library) - unknown_count}") - print(f"Без размера: {unknown_count}") - print(f"Суммарный размер: {total_gb:.1f} ГБ") - print(f"Результат: {out_path}") - print(f"Кэш: {cache_path}") - - -if __name__ == "__main__": - main() diff --git a/homelab/scripts/steam_library_with_sizes.json b/homelab/scripts/steam_library_with_sizes.json deleted file mode 100644 index 54cb369..0000000 --- a/homelab/scripts/steam_library_with_sizes.json +++ /dev/null @@ -1,1347 +0,0 @@ -[ - { - "appid": 10, - "name": "Counter-Strike", - "size_gb": 0.09 - }, - { - "appid": 80, - "name": "Counter-Strike: Condition Zero", - "size_gb": 0.09 - }, - { - "appid": 100, - "name": "Counter-Strike: Condition Zero Deleted Scenes", - "size_gb": 0.09 - }, - { - "appid": 220, - "name": "Half-Life 2", - "size_gb": 0.31 - }, - { - "appid": 240, - "name": "Counter-Strike: Source", - "size_gb": 0.3 - }, - { - "appid": 320, - "name": "Half-Life 2: Deathmatch", - "size_gb": 0.3 - }, - { - "appid": 360, - "name": "Half-Life Deathmatch: Source", - "size_gb": 0.3 - }, - { - "appid": 400, - "name": "Portal", - "size_gb": 0.15 - }, - { - "appid": 440, - "name": "Team Fortress 2", - "size_gb": 0.57 - }, - { - "appid": 550, - "name": "Left 4 Dead 2", - "size_gb": 2.0 - }, - { - "appid": 570, - "name": "Dota 2", - "size_gb": 6.14 - }, - { - "appid": 620, - "name": "Portal 2", - "size_gb": 0.75 - }, - { - "appid": 730, - "name": "Counter-Strike 2", - "size_gb": 8.0 - }, - { - "appid": 1250, - "name": "Killing Floor", - "size_gb": 1.0 - }, - { - "appid": 2600, - "name": "Vampire: The Masquerade - Bloodlines", - "size_gb": null - }, - { - "appid": 4500, - "name": "S.T.A.L.K.E.R.: Shadow of Chernobyl", - "size_gb": 6.0 - }, - { - "appid": 6370, - "name": "Bloodline Champions", - "size_gb": 1.0 - }, - { - "appid": 7670, - "name": "BioShock", - "size_gb": 1.0 - }, - { - "appid": 8850, - "name": "BioShock 2", - "size_gb": 13.67 - }, - { - "appid": 8870, - "name": "BioShock Infinite", - "size_gb": 2.0 - }, - { - "appid": 9480, - "name": "Saints Row 2", - "size_gb": 12.13 - }, - { - "appid": 10150, - "name": "Prototype", - "size_gb": null - }, - { - "appid": 12200, - "name": "Bully: Scholarship Edition", - "size_gb": 1.0 - }, - { - "appid": 12210, - "name": "Grand Theft Auto IV: The Complete Edition", - "size_gb": 1.5 - }, - { - "appid": 13500, - "name": "Prince of Persia: Warrior Within", - "size_gb": 1.5 - }, - { - "appid": 13530, - "name": "Prince of Persia: The Two Thrones", - "size_gb": 1.5 - }, - { - "appid": 15100, - "name": "Assassin's Creed", - "size_gb": 1.0 - }, - { - "appid": 19980, - "name": "Prince of Persia", - "size_gb": 1.0 - }, - { - "appid": 20500, - "name": "Red Faction: Guerrilla Steam Edition", - "size_gb": 1.0 - }, - { - "appid": 20510, - "name": "S.T.A.L.K.E.R.: Clear Sky", - "size_gb": 6.0 - }, - { - "appid": 20920, - "name": "The Witcher 2: Assassins of Kings Enhanced Edition", - "size_gb": 0.14 - }, - { - "appid": 24240, - "name": "PAYDAY: The Heist", - "size_gb": 1.0 - }, - { - "appid": 24880, - "name": "The Saboteur™", - "size_gb": 2.0 - }, - { - "appid": 28000, - "name": "Kane & Lynch 2: Dog Days", - "size_gb": 1.0 - }, - { - "appid": 29800, - "name": "Caster", - "size_gb": 0.04 - }, - { - "appid": 33230, - "name": "Assassin's Creed II", - "size_gb": 6.0 - }, - { - "appid": 33320, - "name": "Prince of Persia: The Forgotten Sands", - "size_gb": 1.0 - }, - { - "appid": 35140, - "name": "Batman: Arkham Asylum GOTY Edition", - "size_gb": 7.89 - }, - { - "appid": 35420, - "name": "Killing Floor Mod: Defence Alliance 2", - "size_gb": null - }, - { - "appid": 35700, - "name": "Trine", - "size_gb": 4.14 - }, - { - "appid": 35720, - "name": "Trine 2", - "size_gb": 4.5 - }, - { - "appid": 40800, - "name": "Super Meat Boy", - "size_gb": 0.49 - }, - { - "appid": 41700, - "name": "S.T.A.L.K.E.R.: Call of Pripyat", - "size_gb": 6.0 - }, - { - "appid": 47810, - "name": "Dragon Age: Origins - Ultimate Edition", - "size_gb": 1.0 - }, - { - "appid": 48190, - "name": "Assassin's Creed Brotherhood", - "size_gb": 18.19 - }, - { - "appid": 50130, - "name": "Mafia II (Classic)", - "size_gb": 9.25 - }, - { - "appid": 55230, - "name": "Saints Row: The Third", - "size_gb": 11.13 - }, - { - "appid": 96100, - "name": "Defy Gravity", - "size_gb": 0.5 - }, - { - "appid": 102600, - "name": "Orcs Must Die!", - "size_gb": 2.0 - }, - { - "appid": 104200, - "name": "BEEP", - "size_gb": null - }, - { - "appid": 105600, - "name": "Terraria", - "size_gb": 0.75 - }, - { - "appid": 108600, - "name": "Project Zomboid", - "size_gb": 6.8 - }, - { - "appid": 110800, - "name": "L.A. Noire", - "size_gb": 2.0 - }, - { - "appid": 113200, - "name": "The Binding of Isaac", - "size_gb": 0.13 - }, - { - "appid": 200260, - "name": "Batman: Arkham City GOTY", - "size_gb": 18.58 - }, - { - "appid": 201870, - "name": "Assassin's Creed Revelations", - "size_gb": 1.5 - }, - { - "appid": 204100, - "name": "Max Payne 3", - "size_gb": 36.21 - }, - { - "appid": 204180, - "name": "Waveform", - "size_gb": 0.01 - }, - { - "appid": 204360, - "name": "Castle Crashers", - "size_gb": 8.0 - }, - { - "appid": 204450, - "name": "Call of Juarez Gunslinger", - "size_gb": 2.0 - }, - { - "appid": 206420, - "name": "Saints Row IV", - "size_gb": 3.03 - }, - { - "appid": 208090, - "name": "Loadout", - "size_gb": null - }, - { - "appid": 208650, - "name": "Batman™: Arkham Knight", - "size_gb": 70.38 - }, - { - "appid": 209000, - "name": "Batman™: Arkham Origins", - "size_gb": 2.0 - }, - { - "appid": 218620, - "name": "PAYDAY 2", - "size_gb": 82.86 - }, - { - "appid": 219150, - "name": "Hotline Miami", - "size_gb": 0.55 - }, - { - "appid": 219990, - "name": "Grim Dawn", - "size_gb": 2.0 - }, - { - "appid": 220240, - "name": "Far Cry® 3", - "size_gb": 11.55 - }, - { - "appid": 222900, - "name": "Dead Island: Epidemic", - "size_gb": 2.0 - }, - { - "appid": 224260, - "name": "No More Room in Hell", - "size_gb": 2.79 - }, - { - "appid": 225540, - "name": "Just Cause 3", - "size_gb": 55.87 - }, - { - "appid": 226320, - "name": "Marvel Heroes Omega", - "size_gb": 0.47 - }, - { - "appid": 227300, - "name": "Euro Truck Simulator 2", - "size_gb": 8.0 - }, - { - "appid": 230230, - "name": "Divinity: Original Sin (Classic)", - "size_gb": 8.31 - }, - { - "appid": 232090, - "name": "Killing Floor 2", - "size_gb": 3.0 - }, - { - "appid": 233270, - "name": "Far Cry® 3 Blood Dragon", - "size_gb": 4.99 - }, - { - "appid": 233450, - "name": "Prison Architect", - "size_gb": 0.5 - }, - { - "appid": 233860, - "name": "Kenshi", - "size_gb": 11.47 - }, - { - "appid": 241930, - "name": "Middle-earth™: Shadow of Mordor™", - "size_gb": 3.0 - }, - { - "appid": 242050, - "name": "Assassin's Creed IV Black Flag", - "size_gb": 29.13 - }, - { - "appid": 242760, - "name": "The Forest", - "size_gb": 5.54 - }, - { - "appid": 243470, - "name": "Watch_Dogs", - "size_gb": 11.85 - }, - { - "appid": 250400, - "name": "How to Survive", - "size_gb": 4.0 - }, - { - "appid": 250900, - "name": "The Binding of Isaac: Rebirth", - "size_gb": 1.91 - }, - { - "appid": 255710, - "name": "Cities: Skylines", - "size_gb": 8.0 - }, - { - "appid": 261550, - "name": "Mount & Blade II: Bannerlord", - "size_gb": 61.64 - }, - { - "appid": 263980, - "name": "Out There Somewhere", - "size_gb": 0.3 - }, - { - "appid": 268500, - "name": "XCOM 2", - "size_gb": 4.0 - }, - { - "appid": 270550, - "name": "Yet Another Zombie Defense", - "size_gb": 1.0 - }, - { - "appid": 270880, - "name": "American Truck Simulator", - "size_gb": 8.0 - }, - { - "appid": 271590, - "name": "Grand Theft Auto V Legacy", - "size_gb": 4.0 - }, - { - "appid": 274170, - "name": "Hotline Miami 2: Wrong Number", - "size_gb": 0.57 - }, - { - "appid": 286690, - "name": "Metro 2033 Redux", - "size_gb": 7.81 - }, - { - "appid": 287450, - "name": "Rise of Nations: Extended Edition", - "size_gb": 1.0 - }, - { - "appid": 287700, - "name": "METAL GEAR SOLID V: THE PHANTOM PAIN", - "size_gb": 4.0 - }, - { - "appid": 289070, - "name": "Sid Meier's Civilization VI", - "size_gb": 4.0 - }, - { - "appid": 289130, - "name": "ENDLESS™ Legend", - "size_gb": 10.36 - }, - { - "appid": 289650, - "name": "Assassin's Creed Unity", - "size_gb": 6.0 - }, - { - "appid": 291010, - "name": "The Hat Man: Shadow Ward", - "size_gb": 0.19 - }, - { - "appid": 292030, - "name": "The Witcher 3: Wild Hunt", - "size_gb": 6.0 - }, - { - "appid": 292620, - "name": "Pressured", - "size_gb": 0.02 - }, - { - "appid": 294100, - "name": "RimWorld", - "size_gb": 4.0 - }, - { - "appid": 298110, - "name": "Far Cry 4", - "size_gb": 4.0 - }, - { - "appid": 301910, - "name": "Saints Row: Gat out of Hell", - "size_gb": 0.02 - }, - { - "appid": 305620, - "name": "The Long Dark", - "size_gb": 17.26 - }, - { - "appid": 307690, - "name": "Sleeping Dogs: Definitive Edition", - "size_gb": 16.71 - }, - { - "appid": 307780, - "name": "Mortal Kombat X", - "size_gb": 3.0 - }, - { - "appid": 314020, - "name": "Morphopolis", - "size_gb": 1.12 - }, - { - "appid": 315430, - "name": "Polarity", - "size_gb": 3.09 - }, - { - "appid": 316030, - "name": "Alice in Wonderland", - "size_gb": 1.0 - }, - { - "appid": 318430, - "name": "Squishy the Suicidal Pig", - "size_gb": 0.0 - }, - { - "appid": 319910, - "name": "Trine 3: The Artifacts of Power", - "size_gb": 4.7 - }, - { - "appid": 322330, - "name": "Don't Starve Together", - "size_gb": 4.02 - }, - { - "appid": 330020, - "name": "Children of Morta", - "size_gb": 1.73 - }, - { - "appid": 330350, - "name": "Robotex", - "size_gb": 0.79 - }, - { - "appid": 331470, - "name": "Everlasting Summer", - "size_gb": 0.5 - }, - { - "appid": 333420, - "name": "Cossacks 3", - "size_gb": 0.02 - }, - { - "appid": 339610, - "name": "FreeStyle 2: Street Basketball", - "size_gb": 10.03 - }, - { - "appid": 345520, - "name": "Infinite Crisis™", - "size_gb": 2.0 - }, - { - "appid": 346900, - "name": "AdVenture Capitalist", - "size_gb": 0.46 - }, - { - "appid": 351920, - "name": "Crazy Machines 3", - "size_gb": 5.73 - }, - { - "appid": 356190, - "name": "Middle-earth™: Shadow of War™", - "size_gb": 6.0 - }, - { - "appid": 360430, - "name": "Mafia III: Definitive Edition", - "size_gb": 52.4 - }, - { - "appid": 361420, - "name": "ASTRONEER", - "size_gb": 2.98 - }, - { - "appid": 362890, - "name": "Black Mesa", - "size_gb": 0.2 - }, - { - "appid": 363970, - "name": "Clicker Heroes", - "size_gb": 0.57 - }, - { - "appid": 368500, - "name": "Assassin's Creed Syndicate", - "size_gb": 6.0 - }, - { - "appid": 371660, - "name": "Far Cry Primal", - "size_gb": 4.0 - }, - { - "appid": 373420, - "name": "Divinity: Original Sin Enhanced Edition", - "size_gb": 9.96 - }, - { - "appid": 374320, - "name": "DARK SOULS™ III", - "size_gb": 4.0 - }, - { - "appid": 377160, - "name": "Fallout 4", - "size_gb": 8.0 - }, - { - "appid": 379430, - "name": "Kingdom Come: Deliverance", - "size_gb": 86.41 - }, - { - "appid": 379720, - "name": "DOOM", - "size_gb": 0.17 - }, - { - "appid": 386360, - "name": "SMITE", - "size_gb": 38.65 - }, - { - "appid": 391220, - "name": "Rise of the Tomb Raider", - "size_gb": 6.0 - }, - { - "appid": 393380, - "name": "Squad", - "size_gb": 64.49 - }, - { - "appid": 394360, - "name": "Hearts of Iron IV", - "size_gb": 4.0 - }, - { - "appid": 409710, - "name": "BioShock Remastered", - "size_gb": 20.66 - }, - { - "appid": 409720, - "name": "BioShock 2 Remastered", - "size_gb": 19.95 - }, - { - "appid": 412020, - "name": "Metro Exodus", - "size_gb": 70.08 - }, - { - "appid": 414340, - "name": "Hellblade: Senua's Sacrifice", - "size_gb": 8.0 - }, - { - "appid": 435150, - "name": "Divinity: Original Sin 2", - "size_gb": 65.72 - }, - { - "appid": 447040, - "name": "Watch_Dogs 2", - "size_gb": 31.45 - }, - { - "appid": 460950, - "name": "Katana ZERO", - "size_gb": 0.21 - }, - { - "appid": 474960, - "name": "Quantum Break", - "size_gb": 8.0 - }, - { - "appid": 475150, - "name": "Titan Quest Anniversary Edition", - "size_gb": 20.16 - }, - { - "appid": 489830, - "name": "The Elder Scrolls V: Skyrim Special Edition", - "size_gb": 8.0 - }, - { - "appid": 492720, - "name": "Tropico 6", - "size_gb": 20.07 - }, - { - "appid": 526870, - "name": "Satisfactory", - "size_gb": 25.53 - }, - { - "appid": 529340, - "name": "Victoria 3", - "size_gb": 8.0 - }, - { - "appid": 548430, - "name": "Deep Rock Galactic", - "size_gb": 3.61 - }, - { - "appid": 551770, - "name": "ECHO", - "size_gb": 19.05 - }, - { - "appid": 552520, - "name": "Far Cry 5", - "size_gb": 47.03 - }, - { - "appid": 557340, - "name": "My Friend Pedro", - "size_gb": 3.56 - }, - { - "appid": 559100, - "name": "Phantom Doctrine", - "size_gb": 23.43 - }, - { - "appid": 578080, - "name": "PUBG: BATTLEGROUNDS", - "size_gb": 8.0 - }, - { - "appid": 582160, - "name": "Assassin's Creed Origins", - "size_gb": 6.0 - }, - { - "appid": 582660, - "name": "Black Desert", - "size_gb": 87.08 - }, - { - "appid": 597180, - "name": "Old World", - "size_gb": 7.04 - }, - { - "appid": 601150, - "name": "Devil May Cry 5", - "size_gb": 40.01 - }, - { - "appid": 606280, - "name": "Darksiders III", - "size_gb": 8.0 - }, - { - "appid": 613100, - "name": "House Flipper", - "size_gb": 26.3 - }, - { - "appid": 632470, - "name": "Disco Elysium", - "size_gb": 9.57 - }, - { - "appid": 667720, - "name": "Red Faction Guerrilla Re-Mars-tered", - "size_gb": 30.29 - }, - { - "appid": 690640, - "name": "Trine 4: The Nightmare Prince", - "size_gb": 15.72 - }, - { - "appid": 728880, - "name": "Overcooked! 2", - "size_gb": 7.92 - }, - { - "appid": 747350, - "name": "Hellblade: Senua's Sacrifice VR Edition", - "size_gb": 23.05 - }, - { - "appid": 774941, - "name": "Squad - Public Testing", - "size_gb": 63.95 - }, - { - "appid": 782330, - "name": "DOOM Eternal", - "size_gb": 0.42 - }, - { - "appid": 812140, - "name": "Assassin's Creed Odyssey", - "size_gb": 8.0 - }, - { - "appid": 829660, - "name": "No Time to Relax", - "size_gb": 0.26 - }, - { - "appid": 870780, - "name": "Control Ultimate Edition", - "size_gb": 83.82 - }, - { - "appid": 892970, - "name": "Valheim", - "size_gb": 1.48 - }, - { - "appid": 911400, - "name": "Assassin's Creed III Remastered", - "size_gb": 47.16 - }, - { - "appid": 916440, - "name": "Anno 1800", - "size_gb": 8.0 - }, - { - "appid": 939960, - "name": "Far Cry New Dawn", - "size_gb": 39.57 - }, - { - "appid": 945360, - "name": "Among Us", - "size_gb": 0.93 - }, - { - "appid": 960910, - "name": "Heavy Rain", - "size_gb": 36.49 - }, - { - "appid": 973580, - "name": "Sniper Ghost Warrior Contracts", - "size_gb": 8.0 - }, - { - "appid": 976310, - "name": "Mortal Kombat 11", - "size_gb": 8.0 - }, - { - "appid": 978300, - "name": "Saints Row The Third Remastered", - "size_gb": 46.34 - }, - { - "appid": 990080, - "name": "Hogwarts Legacy", - "size_gb": 2.22 - }, - { - "appid": 1029690, - "name": "Sniper Elite 5", - "size_gb": 8.0 - }, - { - "appid": 1030830, - "name": "Mafia II: Definitive Edition", - "size_gb": 0.0 - }, - { - "appid": 1030840, - "name": "Mafia: Definitive Edition", - "size_gb": 36.24 - }, - { - "appid": 1044720, - "name": "Farthest Frontier", - "size_gb": 8.0 - }, - { - "appid": 1070330, - "name": "Russian Life Simulator", - "size_gb": 0.56 - }, - { - "appid": 1086940, - "name": "Baldur's Gate 3", - "size_gb": 145.0 - }, - { - "appid": 1088850, - "name": "Marvel's Guardians of the Galaxy", - "size_gb": 8.0 - }, - { - "appid": 1091500, - "name": "Cyberpunk 2077", - "size_gb": 62.0 - }, - { - "appid": 1092790, - "name": "Inscryption", - "size_gb": 4.0 - }, - { - "appid": 1097150, - "name": "Fall Guys", - "size_gb": 8.0 - }, - { - "appid": 1097840, - "name": "Gears 5", - "size_gb": 120.64 - }, - { - "appid": 1123770, - "name": "Curse of the Dead Gods", - "size_gb": 4.0 - }, - { - "appid": 1124300, - "name": "HUMANKIND™", - "size_gb": 34.54 - }, - { - "appid": 1139900, - "name": "Ghostrunner", - "size_gb": 34.11 - }, - { - "appid": 1145360, - "name": "Hades", - "size_gb": 13.03 - }, - { - "appid": 1151640, - "name": "Horizon Zero Dawn™ Complete Edition", - "size_gb": 88.18 - }, - { - "appid": 1158310, - "name": "Crusader Kings III", - "size_gb": 8.0 - }, - { - "appid": 1172380, - "name": "STAR WARS Jedi: Fallen Order™ ", - "size_gb": 8.0 - }, - { - "appid": 1174180, - "name": "Red Dead Redemption 2", - "size_gb": 8.0 - }, - { - "appid": 1210320, - "name": "Potion Craft: Alchemist Simulator", - "size_gb": 8.0 - }, - { - "appid": 1222140, - "name": "Detroit: Become Human", - "size_gb": 58.75 - }, - { - "appid": 1222670, - "name": "The Sims™ 4", - "size_gb": 4.0 - }, - { - "appid": 1222680, - "name": "Need for Speed™ Heat ", - "size_gb": 8.0 - }, - { - "appid": 1222700, - "name": "A Way Out", - "size_gb": 8.0 - }, - { - "appid": 1232130, - "name": "BEAR, VODKA, STALINGRAD! 🐻", - "size_gb": 2.0 - }, - { - "appid": 1233570, - "name": "Mirror's Edge™ Catalyst", - "size_gb": 6.0 - }, - { - "appid": 1237950, - "name": "STAR WARS™ Battlefront™ II", - "size_gb": 8.0 - }, - { - "appid": 1238810, - "name": "Battlefield™ V", - "size_gb": 8.0 - }, - { - "appid": 1239690, - "name": "Retrowave", - "size_gb": 4.0 - }, - { - "appid": 1259420, - "name": "Days Gone", - "size_gb": 110.01 - }, - { - "appid": 1272080, - "name": "PAYDAY 3", - "size_gb": 16.0 - }, - { - "appid": 1316680, - "name": "VLADiK BRUTAL", - "size_gb": 4.0 - }, - { - "appid": 1328670, - "name": "Mass Effect™ Legendary Edition", - "size_gb": 8.0 - }, - { - "appid": 1332010, - "name": "Stray", - "size_gb": 6.14 - }, - { - "appid": 1336490, - "name": "Against the Storm", - "size_gb": 6.51 - }, - { - "appid": 1338770, - "name": "Sniper Ghost Warrior Contracts 2", - "size_gb": 8.0 - }, - { - "appid": 1363080, - "name": "Manor Lords", - "size_gb": 13.21 - }, - { - "appid": 1426210, - "name": "It Takes Two", - "size_gb": 8.0 - }, - { - "appid": 1436700, - "name": "Trine 5: A Clockwork Conspiracy", - "size_gb": 8.0 - }, - { - "appid": 1449560, - "name": "Metro Exodus Enhanced Edition", - "size_gb": 72.23 - }, - { - "appid": 1466860, - "name": "Age of Empires IV: Anniversary Edition", - "size_gb": 8.0 - }, - { - "appid": 1510580, - "name": "Toy Tinker Simulator", - "size_gb": 4.0 - }, - { - "appid": 1546970, - "name": "Grand Theft Auto III - The Definitive Edition", - "size_gb": 8.0 - }, - { - "appid": 1546990, - "name": "Grand Theft Auto: Vice City - The Definitive Edition", - "size_gb": 8.0 - }, - { - "appid": 1547000, - "name": "Grand Theft Auto: San Andreas - The Definitive Edition", - "size_gb": 8.0 - }, - { - "appid": 1568590, - "name": "Goose Goose Duck", - "size_gb": 1.0 - }, - { - "appid": 1593500, - "name": "God of War", - "size_gb": 8.0 - }, - { - "appid": 1659040, - "name": "HITMAN World of Assassination", - "size_gb": 78.71 - }, - { - "appid": 1665460, - "name": "eFootball™", - "size_gb": 46.44 - }, - { - "appid": 1715130, - "name": "Crysis Remastered", - "size_gb": 19.87 - }, - { - "appid": 1771300, - "name": "Kingdom Come: Deliverance II", - "size_gb": 102.12 - }, - { - "appid": 1774580, - "name": "STAR WARS Jedi: Survivor™", - "size_gb": 8.0 - }, - { - "appid": 1794680, - "name": "Vampire Survivors", - "size_gb": 0.71 - }, - { - "appid": 1797610, - "name": "Barro 22", - "size_gb": 2.0 - }, - { - "appid": 1817070, - "name": "Marvel’s Spider-Man Remastered", - "size_gb": 70.14 - }, - { - "appid": 1850570, - "name": "DEATH STRANDING DIRECTOR'S CUT", - "size_gb": 8.0 - }, - { - "appid": 1888930, - "name": "The Last of Us™ Part I", - "size_gb": 4.51 - }, - { - "appid": 1903340, - "name": "Clair Obscur: Expedition 33", - "size_gb": 8.0 - }, - { - "appid": 1971870, - "name": "Mortal Kombat 1", - "size_gb": 201.91 - }, - { - "appid": 2096600, - "name": "Crysis 2 Remastered", - "size_gb": 8.0 - }, - { - "appid": 2096610, - "name": "Crysis 3 Remastered", - "size_gb": 8.0 - }, - { - "appid": 2138710, - "name": "Sifu", - "size_gb": 30.3 - }, - { - "appid": 2140020, - "name": "Stronghold: Definitive Edition", - "size_gb": 0.01 - }, - { - "appid": 2144740, - "name": "Ghostrunner 2", - "size_gb": 69.84 - }, - { - "appid": 2195250, - "name": "EA SPORTS FC™ 24", - "size_gb": 8.0 - }, - { - "appid": 2208920, - "name": "Assassin's Creed Valhalla", - "size_gb": 158.53 - }, - { - "appid": 2239550, - "name": "Watch Dogs: Legion", - "size_gb": 8.0 - }, - { - "appid": 2290000, - "name": "TerraScape", - "size_gb": 1.62 - }, - { - "appid": 2369390, - "name": "Far Cry 6", - "size_gb": 8.0 - }, - { - "appid": 2406770, - "name": "Bodycam", - "size_gb": 8.0 - }, - { - "appid": 2417610, - "name": "METAL GEAR SOLID Δ: SNAKE EATER", - "size_gb": 16.0 - }, - { - "appid": 2427410, - "name": "S.T.A.L.K.E.R.: Shadow of Chornobyl - Enhanced Edition", - "size_gb": 8.0 - }, - { - "appid": 2427420, - "name": "S.T.A.L.K.E.R.: Clear Sky - Enhanced Edition", - "size_gb": 8.0 - }, - { - "appid": 2427430, - "name": "S.T.A.L.K.E.R.: Call of Prypiat - Enhanced Edition", - "size_gb": 8.0 - }, - { - "appid": 2456740, - "name": "inZOI", - "size_gb": 33.05 - }, - { - "appid": 2461850, - "name": "Senua’s Saga: Hellblade II", - "size_gb": 16.0 - }, - { - "appid": 2531310, - "name": "The Last of Us™ Part II Remastered", - "size_gb": 139.86 - }, - { - "appid": 2532550, - "name": "LIZARDS MUST DIE", - "size_gb": 3.08 - }, - { - "appid": 2784470, - "name": "9 Kings", - "size_gb": 0.16 - }, - { - "appid": 2796010, - "name": "Party Club", - "size_gb": 5.45 - }, - { - "appid": 2807960, - "name": "Battlefield™ 6", - "size_gb": 16.0 - }, - { - "appid": 2996040, - "name": "Teenage Mutant Ninja Turtles: Splintered Fate", - "size_gb": 4.0 - }, - { - "appid": 3035570, - "name": "Assassin's Creed Mirage", - "size_gb": 65.01 - }, - { - "appid": 3107800, - "name": "LIZARDS MUST DIE 2", - "size_gb": 8.0 - }, - { - "appid": 3240220, - "name": "Grand Theft Auto V Enhanced", - "size_gb": 8.0 - }, - { - "appid": 3472040, - "name": "NBA 2K26", - "size_gb": 26.53 - }, - { - "appid": 3551340, - "name": "Football Manager 26", - "size_gb": 7.12 - } -] \ No newline at end of file diff --git a/homelab/us-router.conf b/homelab/us-router.conf deleted file mode 100644 index a4a9dce..0000000 --- a/homelab/us-router.conf +++ /dev/null @@ -1,19 +0,0 @@ -[Interface] -Address = 10.8.1.2/32 -PrivateKey = htF4B+QY72eKaJnnKmrKqtowN76c4IV1qYxlEaIx2Z4= -Jc = 6 -Jmin = 10 -Jmax = 50 -S1 = 90 -S2 = 62 -H1 = 1455064900 -H2 = 852483043 -H3 = 2078090415 -H4 = 1981181588 - -[Peer] -PublicKey = SDjcBREvIXfuCOCISj4hk3gvYaM188IZcZNnWAuOclc= -PresharedKey = 8C4rE3PVqyi3FQD+zDjGzQ4W5Dju8T2uGhGF9yor1zc= -AllowedIPs = 0.0.0.0/0, ::/0 -Endpoint = 147.45.124.117:37135 -PersistentKeepalive = 25 diff --git a/homelab/vpn-keenetic-us-second-connection.md b/homelab/vpn-keenetic-us-second-connection.md deleted file mode 100644 index da9b43a..0000000 --- a/homelab/vpn-keenetic-us-second-connection.md +++ /dev/null @@ -1,99 +0,0 @@ -# Второе VPN-подключение (US) на Keenetic без смены маршрутов - -Оба туннеля используют один и тот же адрес клиента **10.8.1.2**. Маршруты на роутере указывают на шлюз 10.8.1.2 — переключение DE/US делается включением/выключением нужного подключения в веб-интерфейсе. - ---- - -## Что уже сделано - -- На **US VPS** (147.45.124.117) в AmneziaWG добавлен peer с **AllowedIPs = 10.8.1.2/32** и публичным ключом роутера (тот же, что для DE). Контейнер перезапущен, peer активен. - ---- - -## Параметры US-сервера (для конфига роутера) - -| Параметр | Значение | -|----------|----------| -| Endpoint | 147.45.124.117:37135 | -| PublicKey сервера | SDjcBREvIXfuCOCISj4hk3gvYaM188IZcZNnWAuOclc= | -| PresharedKey | 8C4rE3PVqyi3FQD+zDjGzQ4W5Dju8T2uGhGF9yor1zc= | -| Адрес клиента | 10.8.1.2/32 (как у DE) | - -**Параметры обфускации (asc)** на US-сервере сделаны **такими же, как на DE** — один набор для обоих подключений, вручную вводить один раз: - -- Jc = 6, Jmin = 10, Jmax = 50 -- S1 = 90, S2 = 62 -- H1 = 1455064900, H2 = 852483043, H3 = 2078090415, H4 = 1981181588 - ---- - -## Конфиг клиента для роутера (шаблон) - -Нужно подставить **PrivateKey** из текущего подключения **netcraze_amnezia** на роутере (тот же ключ, что и для DE — тогда публичный ключ совпадает и peer 10.8.1.2 на US уже привязан к нему). - -Сохраните как `amnezia-us-router.conf` и загрузите в роутер через **Интернет → Другие подключения → Wireguard → Загрузить из файла**. - -```ini -[Interface] -PrivateKey = ВСТАВЬТЕ_ПРИВАТНЫЙ_КЛЮЧ_ИЗ_netcraze_amnezia -Address = 10.8.1.2/32 -Jc = 6 -Jmin = 10 -Jmax = 50 -S1 = 90 -S2 = 62 -H1 = 1455064900 -H2 = 852483043 -H3 = 2078090415 -H4 = 1981181588 - -[Peer] -PublicKey = SDjcBREvIXfuCOCISj4hk3gvYaM188IZcZNnWAuOclc= -PresharedKey = 8C4rE3PVqyi3FQD+zDjGzQ4W5Dju8T2uGhGF9yor1zc= -Endpoint = 147.45.124.117:37135 -AllowedIPs = 0.0.0.0/0, ::/0 -``` - -**Откуда взять PrivateKey:** в приложении AmneziaVPN он не показывается (только «Частный ключ установлен»). Варианты: (1) В веб-интерфейсе Keenetic откройте **Интернет → Другие подключения → Wireguard**, нажмите на строку **netcraze_amnezia** и проверьте, есть ли там поле приватного ключа или экспорт конфига. (2) После настройки доступа по SSH-ключу в Amnezia создайте на US-сервере нового пользователя, скачайте конфиг — в нём будет новая пара ключей; тогда на US-сервере нужно заменить peer 10.8.1.2 на новый публичный ключ из этого конфига и в конфиге прописать Address 10.8.1.2/32 — этот конфиг и загружать в роутер. - ---- - -## Если при загрузке конфига ошибка про обфускацию - -1. **Версия KeeneticOS** смотрится в **Управление → Обзор системы** («Об системе»), а не в версии компонента MDNS. Нужна именно строка вида **KeeneticOS 4.3.x** (или 4.2.x). -2. В **KeeneticOS 4.3.4+** параметры asc должны подхватываться из файла. Если ошибка всё равно есть — возможно, формат S3/S4 или H не поддерживается; тогда добавьте подключение **без** asc в файле и задайте asc вручную через CLI (см. ниже). -3. Для **KeeneticOS 4.3.3 и ниже** по [инструкции Amnezia](https://docs.amnezia.org/documentation/instructions/keenetic-os-awg/) после импорта конфига нужно вручную прописать asc в веб-CLI: - - **Управление → шестерёнка → Командная строка** - - `show interface` — найти имя интерфейса нового подключения (например Wireguard1). - - Выполнить (подставив имя интерфейса и значения asc из блока выше): - ```text - interface Wireguard1 wireguard asc 6 10 50 90 62 1455064900 852483043 2078090415 1981181588 - ``` - (Один набор asc для обоих подключений: Jc Jmin Jmax S1 S2 H1 H2 H3 H4.) - - `system configuration save` - ---- - -## После добавления второго подключения - -- В **Приоритеты подключений** можно добавить вторую политику с новым подключением (US) или оставить одну политику и переключать только активное подключение в ней. -- **Маршруты не менять**: все пользовательские маршруты по-прежнему с шлюзом **10.8.1.2**. -- **Включать только одно подключение**: либо **netcraze_amnezia** (DE), либо новое (US). Включённое подключение даёт интерфейс с адресом 10.8.1.2 — трафик по маршрутам пойдёт через него. -- Переключение: в **Другие подключения → Wireguard** выключить одно подключение и включить другое. - ---- - -## Сводка по серверам - -| Сервер | IP | Порт | Роутер (peer) | -|--------|-----|------|----------------| -| DE (текущий) | 185.103.253.99 | 33118 | 10.8.1.2, ключ HSgydyy... | -| US (новый) | 147.45.124.117 | 37135 | 10.8.1.2, тот же ключ | - -При обновлении конфига Amnezia через приложение на US-сервере вручную добавленный peer 10.8.1.2 может пропасть; тогда его нужно снова добавить в `awg0.conf` в контейнере и перезапустить контейнер (или восстановить конфиг из бэкапа). - ---- - -## Ошибка 300 (SshRequestDeniedError) в AmneziaVPN - -Если на сервере отключён вход по паролю, приложение не сможет подключаться к серверу (создавать пользователей, менять настройки) и выдаст ошибку 300. **Решение:** в настройках сервера в AmneziaVPN указать аутентификацию по **SSH-ключу**: выбрать «Подключение по ключу» и указать ваш приватный SSH-ключ (тот же, которым заходите с Mac в `ssh root@...`). После сохранения создание пользователей и выдача конфигов снова работают. [Подробнее в документации Amnezia](https://m-docs-3w5hsuiikq-ez.a.run.app/troubleshooting/error-codes/#error-300-sshrequestdeniederror). diff --git a/homelab/vpn-route-check/.gitignore b/homelab/vpn-route-check/.gitignore deleted file mode 100644 index ea1472e..0000000 --- a/homelab/vpn-route-check/.gitignore +++ /dev/null @@ -1 +0,0 @@ -output/ diff --git a/homelab/vpn-route-check/Dockerfile b/homelab/vpn-route-check/Dockerfile deleted file mode 100644 index f0d3c0f..0000000 --- a/homelab/vpn-route-check/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM python:3.11-slim -RUN apt-get update && apt-get install -y --no-install-recommends traceroute dnsutils iproute2 openssh-client && rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY check_routes.py domains.txt serve_no_cache.py ./ -RUN mkdir -p /data -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -EXPOSE 8765 -ENTRYPOINT ["/entrypoint.sh"] diff --git a/homelab/vpn-route-check/PROMPT-deploy-to-server.md b/homelab/vpn-route-check/PROMPT-deploy-to-server.md deleted file mode 100644 index 33f8400..0000000 --- a/homelab/vpn-route-check/PROMPT-deploy-to-server.md +++ /dev/null @@ -1,28 +0,0 @@ -# Промпт: обновить vpn-route-check на сервере - -Скопируй этот блок и вставь в чат с ассистентом, когда нужно выкатить обновления на сервер. - ---- - -**Задача:** развернуть/обновить сервис vpn-route-check на сервере. - -**Контекст:** -- Проект в репозитории: `homelab/vpn-route-check/` (скрипт проверки маршрутов VPN/Ethernet, выдача HTML по HTTP). -- Сервер: Proxmox по адресу **192.168.1.150**, логин **root** (SSH без пароля уже настроен). -- Сервис крутится **внутри LXC-контейнера с ID 100** (IP контейнера **192.168.1.100**). В контейнере установлены Docker и Docker Compose. -- Путь в контейнере: `/opt/docker/vpn-route-check`. После деплоя страница доступна по **http://192.168.1.100:8765**. - -**Что сделать:** -1. Из корня репозитория (или из `homelab/vpn-route-check`) упаковать содержимое папки `vpn-route-check` в tar (без самой папки, только файлы внутри: `check_routes.py`, `domains.txt`, `Dockerfile`, `entrypoint.sh`, `docker-compose.yml` и т.д.). -2. Скопировать tar на Proxmox: `scp ... root@192.168.1.150:/tmp/vpn-route-check.tar`. -3. На Proxmox: загрузить tar в контейнер 100: - `pct push 100 /tmp/vpn-route-check.tar /tmp/vpn-route-check.tar` -4. В контейнере: распаковать в `/opt/docker/vpn-route-check`: - `pct exec 100 -- tar -xf /tmp/vpn-route-check.tar -C /opt/docker/vpn-route-check` -5. В контейнере: пересобрать и запустить: - `pct exec 100 -- bash -c 'cd /opt/docker/vpn-route-check && docker compose up -d --build'` -6. Проверить: контейнер `vpn-route-check` в статусе Up, в логах есть строка вида `OK: N доменов`, по адресу http://192.168.1.100:8765 отдаётся страница (curl или браузер). - -Все команды выполни сам (у меня есть доступ по SSH к 192.168.1.150). Если репозиторий открыт в Cursor, используй путь к проекту из workspace (например `homelab/vpn-route-check`). - ---- diff --git a/homelab/vpn-route-check/README.md b/homelab/vpn-route-check/README.md deleted file mode 100644 index 3d23586..0000000 --- a/homelab/vpn-route-check/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Проверка маршрута к доменам (VPN / Ethernet) - -Скрипт проверяет, идёт ли трафик к заданным доменам через VPN (10.8.1.0) или через Ethernet. Результаты отдаются по HTTP и обновляются **каждые 15 минут** без ручного запуска. - -## Быстрый старт (один раз настроил — дальше всё само) - -**Шаг 1.** С хоста Proxmox скопировать папку в контейнер 100 и запустить контейнер: - -```bash -# С Mac скопировать папку на Proxmox (если репо только на Mac): -scp -r /Users/andrejkatyhin/Work/plantUML/homelab/vpn-route-check root@192.168.1.150:/root/ - -# Зайти на Proxmox и запустить развёртывание: -ssh root@192.168.1.150 -bash /root/vpn-route-check/deploy-on-proxmox.sh -``` - -Если репо уже есть на Proxmox (например в `/root/plantUML`): - -```bash -ssh root@192.168.1.150 -cd /root/plantUML/homelab/vpn-route-check -bash deploy-on-proxmox.sh -``` - -Контейнер поднимется, проверка будет запускаться при старте и **каждые 15 минут**, страница отдаётся на порту **8765** на 192.168.1.100. - -**Шаг 2 (опционально).** Когда сервис доступен по http://192.168.1.100:8765 — можно добавить виджет в Homepage (фрагмент в `homepage-widget.yaml`). Сейчас виджет из дашборда убран: страница 8765 недоступна. - -**Итоговая страница (в локальной сети):** **http://192.168.1.100:8765** - ---- - -## Список доменов - -По умолчанию проверяются домены из `domains.txt` в образе. Чтобы менять список без пересборки, раскомментируй в `docker-compose.yml` volume для `domains.txt` и перезапусти контейнер. - ---- - -## Локальный запуск (без Docker) - -Для разовой проверки с Mac: - -```bash -python3 check_routes.py -# Результаты в output/index.html и output/results.json -``` - -Требуется: `traceroute`, `dig` (dnsutils). diff --git a/homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc b/homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc deleted file mode 100644 index ffd1781..0000000 Binary files a/homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc and /dev/null differ diff --git a/homelab/vpn-route-check/check_routes.py b/homelab/vpn-route-check/check_routes.py deleted file mode 100644 index 2d69743..0000000 --- a/homelab/vpn-route-check/check_routes.py +++ /dev/null @@ -1,379 +0,0 @@ -#!/usr/bin/env python3 -""" -Проверяет, идёт ли трафик к заданным доменам через VPN (10.8.1.0) или через Ethernet. -Запускать с хоста в той же LAN, что и роутер (например контейнер 100 или рабочий Mac). -Требуется: traceroute, dig (dnsutils) или резолв через Python. -Выход: JSON + HTML в указанную директорию для просмотра в Homepage (iframe). -""" -import json -import os -import re -import subprocess -import sys -try: - import telnetlib -except ImportError: - telnetlib = None # удалён в Python 3.13+ -from datetime import datetime, timezone -from ipaddress import IPv4Address, ip_network -from pathlib import Path - -# Второй прыжок traceroute = 10.8.1.0 означает туннель AmneziaWG (VPN) -VPN_HOP_IP = "10.8.1.0" -VPN_IN_FIRST_N_HOPS = 10 # в первых N прыжках ищем VPN-шлюз -TRACEROUTE_MAX_HOPS = 15 # больше прыжков — видим VPN до того, как хост перестаёт отвечать (Яндекс, Mail и др.) -TRACEROUTE_TIMEOUT = 25 # секунд на один домен - -# Роутер: откуда брать реальный маршрут (VPN vs провайдер). Либо SSH, либо Telnet (NDMS/Keenetic). -ROUTER_HOST = os.environ.get("ROUTER_HOST", "").strip() # 192.168.1.1 -ROUTER_SSH_USER = os.environ.get("ROUTER_SSH_USER", "root") -ROUTER_SSH_TIMEOUT = 8 -# Telnet (если на роутере нет SSH, только telnet) -ROUTER_TELNET_HOST = os.environ.get("ROUTER_TELNET_HOST", "").strip() -ROUTER_TELNET_USER = os.environ.get("ROUTER_TELNET_USER", "admin") -ROUTER_TELNET_PASSWORD = os.environ.get("ROUTER_TELNET_PASSWORD", "") -ROUTER_TELNET_TIMEOUT = 15 -# По какой настройке опрашивать роутер (если задан TELNET — используем telnet) -ROUTER_USE_TELNET = bool(ROUTER_TELNET_HOST and ROUTER_TELNET_PASSWORD) - - -def load_domains_grouped(path: Path) -> list[tuple[str, str]]: - """ - Читает domains.txt с секциями [Россия] и [Зарубеж]. - Возвращает список пар (название_группы, домен). - """ - out: list[tuple[str, str]] = [] - current_group = "Прочее" - if not path.exists(): - return out - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if line.startswith("[") and line.endswith("]"): - current_group = line[1:-1].strip() - continue - out.append((current_group, line)) - return out - - -def resolve_domain(domain: str) -> str | None: - """Возвращает один IPv4-адрес для домена или None.""" - try: - r = subprocess.run( - ["dig", "+short", "-4", domain], - capture_output=True, - text=True, - timeout=10, - ) - if r.returncode != 0: - return None - lines = [s.strip() for s in r.stdout.splitlines() if s.strip()] - for line in lines: - # первый похожий на IPv4 - if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", line): - return line - return None - except (FileNotFoundError, subprocess.TimeoutExpired): - return None - - -def traceroute_hop2(ip: str) -> tuple[str, list[str]]: - """ - Запускает traceroute до ip с max TRACEROUTE_MAX_HOPS прыжками. - Возвращает ("VPN" | "Ethernet" | "unknown", список IP прыжков). - """ - hops: list[str] = [] - try: - r = subprocess.run( - ["traceroute", "-m", str(TRACEROUTE_MAX_HOPS), "-n", ip], - capture_output=True, - text=True, - timeout=TRACEROUTE_TIMEOUT, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return "unknown", [] - - # Парсим строки вида " 2 10.8.1.0 20.5 ms" или " 2 * * *" - for line in r.stdout.splitlines(): - # номер_прыжка IP время... - m = re.match(r"\s*\d+\s+([*\d.]+)", line) - if m: - hop = m.group(1).strip() - if hop != "*": - hops.append(hop) - - first_hops = hops[:VPN_IN_FIRST_N_HOPS] - if VPN_HOP_IP in first_hops: - return "VPN", hops - if len(hops) >= 2: - return "Ethernet", hops - # Один прыжок: хост (часто Google/CDN) не отвечает на traceroute по пути — виден только последний узел - if len(hops) == 1: - return "few_hops", hops - return "unknown", hops - - -def route_get_path(ip: str) -> str | None: - """ - Определяет маршрут до IP через «ip route get» на этом хосте. - Возвращает "VPN", "Ethernet" или None при ошибке. - На LAN-клиенте всегда виден только шлюз 192.168.1.1 — для реального маршрута настрой ROUTER_HOST. - """ - for ip_bin in ("/usr/sbin/ip", "/sbin/ip", "ip"): - try: - r = subprocess.run( - [ip_bin, "route", "get", ip], - capture_output=True, - text=True, - timeout=5, - ) - out = (r.stdout or "") + (r.stderr or "") - if not out.strip(): - continue - if "via " + VPN_HOP_IP in out or "via " + VPN_HOP_IP + " " in out: - return "VPN" - return "Ethernet" - except (FileNotFoundError, subprocess.TimeoutExpired): - continue - return None - - -def route_get_path_via_router_ssh(ip: str) -> str | None: - """Выполняет на роутере «ip route get list[tuple[str, str, str]] | None: - """ - Подключается к роутеру по Telnet (NDMS/Keenetic), логин, выполняет «show ip route», - парсит таблицу. Возвращает список (network_cidr, gateway, interface) или None. - """ - if not ROUTER_USE_TELNET or telnetlib is None: - return None - try: - tn = telnetlib.Telnet(ROUTER_TELNET_HOST, 23, timeout=ROUTER_TELNET_TIMEOUT) - tn.read_until(b"Login:", timeout=8) - tn.write(ROUTER_TELNET_USER.encode() + b"\n") - tn.read_until(b"Password:", timeout=5) - tn.write(ROUTER_TELNET_PASSWORD.encode() + b"\n") - tn.read_until(b">", timeout=8) - tn.write(b"show ip route\n") - raw = tn.read_until(b"(config)>", timeout=15) - tn.close() - except (OSError, EOFError) as e: - return None - text = raw.decode("utf-8", errors="replace") - routes: list[tuple[str, str, str]] = [] - for line in text.splitlines(): - line = line.strip() - # Строка вида "216.58.192.0/20 10.8.1.2 Wireguard0 ..." - if "/" not in line or line.startswith("("): - continue - parts = line.split() - if len(parts) >= 3: - net, gw, iface = parts[0], parts[1], parts[2] - if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$", net): - routes.append((net, gw, iface)) - return routes if routes else None - - -def find_route_for_ip(router_routes: list[tuple[str, str, str]], ip: str) -> str | None: - """ - По таблице маршрутов роутера (network, gateway, interface) находит маршрут для IP - (longest prefix match). Возвращает "VPN" если шлюз 10.8.1.x или интерфейс Wireguard0, иначе "Ethernet". - """ - try: - addr = IPv4Address(ip) - except ValueError: - return None - best: tuple[int, str, str] | None = None # (prefix_len, gateway, interface) - for net_cidr, gw, iface in router_routes: - try: - net = ip_network(net_cidr, strict=False) - if addr in net: - plen = net.prefixlen - if best is None or plen > best[0]: - best = (plen, gw, iface) - except ValueError: - continue - if best is None: - return None - _plen, gateway, interface = best - if gateway.startswith("10.8.1.") or "Wireguard" in interface: - return "VPN" - return "Ethernet" - - -def check_domain(domain: str, router_routes: list[tuple[str, str, str]] | None = None) -> dict: - ip = resolve_domain(domain) - if not ip: - return { - "domain": domain, - "ip": None, - "path": "error", - "path_label": "Ошибка резолва", - "hops": [], - } - path, hops = traceroute_hop2(ip) - # Если traceroute дал мало данных или неизвестно — смотрим маршрут: таблица с роутера (telnet) или SSH/локальный ip route get. - if path in ("few_hops", "unknown"): - if router_routes is not None: - route_path = find_route_for_ip(router_routes, ip) - hop_note = "(router)" - elif ROUTER_HOST: - route_path = route_get_path_via_router_ssh(ip) - hop_note = "(router)" - else: - route_path = route_get_path(ip) - hop_note = "(ip route get)" - if route_path is not None: - path = route_path - hops = hops + [hop_note] if hops else [hop_note] - path_labels = { - "VPN": "VPN", - "Ethernet": "Ethernet", - "few_hops": "Мало данных (1 прыжок)", - "unknown": "Неизвестно", - } - return { - "domain": domain, - "ip": ip, - "path": path, - "path_label": path_labels.get(path, path), - "hops": hops, - } - - -def _row_html(r: dict) -> str: - path = r.get("path") or "unknown" - if path == "VPN": - badge = 'VPN' - elif path == "Ethernet": - badge = 'Ethernet' - elif path == "error": - badge = 'Ошибка' - elif path == "few_hops": - badge = 'Мало данных' - else: - badge = '?' - ip = r.get("ip") or "—" - hops_s = ", ".join(r.get("hops") or []) or "—" - return f"{r['domain']}{ip}{badge}{hops_s}" - - -def build_html(grouped_results: dict[str, list[dict]], gen_time: str, out_path: Path | None) -> str: - sections_html = [] - for group_name, results in grouped_results.items(): - if not results: - continue - rows = [_row_html(r) for r in results] - table_body = "\n".join(rows) - sections_html.append( - f"

{group_name}

\n" - f" \n" - f" \n" - f" \n{table_body}\n \n
ДоменIPМаршрутПрыжки
" - ) - blocks = "\n\n".join(sections_html) - - html = f""" - - - - - - Маршрут к доменам (VPN / Ethernet) - - - -

Маршрут к доменам

-

Трафик через VPN (10.8.1.0) или напрямую (Ethernet). Обновлено: {gen_time}

- -{blocks} - - -""" - if out_path: - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_text(html, encoding="utf-8") - return html - - -def main(): - script_dir = Path(__file__).resolve().parent - domains_path = script_dir / "domains.txt" - out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else script_dir / "output" - out_dir = Path(out_dir) - - grouped_domains = load_domains_grouped(domains_path) - if not grouped_domains: - grouped_domains = [("Зарубеж", "youtube.com"), ("Зарубеж", "instagram.com"), ("Зарубеж", "google.com")] - - router_routes: list[tuple[str, str, str]] | None = None - if ROUTER_USE_TELNET: - router_routes = fetch_router_routes_telnet() - - grouped_results: dict[str, list[dict]] = {} - all_results = [] - for group_name, domain in grouped_domains: - r = check_domain(domain, router_routes) - r["group"] = group_name - all_results.append(r) - grouped_results.setdefault(group_name, []).append(r) - - gen_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - payload = {"updated": gen_time, "results": all_results} - - json_path = out_dir / "results.json" - out_dir.mkdir(parents=True, exist_ok=True) - json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - html_path = out_dir / "index.html" - build_html(grouped_results, gen_time, html_path) - - total = len(all_results) - print(f"OK: {total} доменов → {html_path} / {json_path}") - - -if __name__ == "__main__": - main() diff --git a/homelab/vpn-route-check/deploy-on-proxmox.sh b/homelab/vpn-route-check/deploy-on-proxmox.sh deleted file mode 100755 index 5bdf9a4..0000000 --- a/homelab/vpn-route-check/deploy-on-proxmox.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Запускать на хосте Proxmox (ssh root@192.168.1.150). -# Копирует папку vpn-route-check в контейнер 100 и поднимает Docker. -set -e -CT=100 -SRC="$(cd "$(dirname "$0")" && pwd)" -echo "Источник: $SRC" -echo "Копирую в контейнер $CT..." -TAR="/tmp/vpn-route-check.tar" -tar -C "$SRC" -cf "$TAR" . -pct exec "$CT" -- mkdir -p /opt/docker/vpn-route-check -pct push "$CT" "$TAR" /tmp/vpn-route-check.tar -pct exec "$CT" -- tar -xf /tmp/vpn-route-check.tar -C /opt/docker/vpn-route-check -pct exec "$CT" -- rm -f /tmp/vpn-route-check.tar -rm -f "$TAR" -echo "Запуск Docker Compose в контейнере $CT..." -pct exec "$CT" -- bash -c 'cd /opt/docker/vpn-route-check && docker compose up -d --build' -echo "Готово. Сервис: http://192.168.1.100:8765" diff --git a/homelab/vpn-route-check/docker-compose.yml b/homelab/vpn-route-check/docker-compose.yml deleted file mode 100644 index 083af83..0000000 --- a/homelab/vpn-route-check/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - vpn-route-check: - build: . - container_name: vpn-route-check - network_mode: host - environment: - ROUTER_TELNET_HOST: "192.168.1.1" - ROUTER_TELNET_USER: "admin" - ROUTER_TELNET_PASSWORD: "eC1cLwZPRoDVEY1" - volumes: - - vpn-route-check-data:/data - # опционально: свой список доменов с хоста - # - ./domains.txt:/app/domains.txt:ro - restart: unless-stopped - -volumes: - vpn-route-check-data: diff --git a/homelab/vpn-route-check/domains.txt b/homelab/vpn-route-check/domains.txt deleted file mode 100644 index 43b0e93..0000000 --- a/homelab/vpn-route-check/domains.txt +++ /dev/null @@ -1,56 +0,0 @@ -# Секции: [Россия] и [Зарубеж]. Домены по одному на строку; # — комментарий. - -[Россия] -ozon.ru -wildberries.ru -aliexpress.ru -kinopoisk.ru -ivi.ru -smotrim.ru -ria.ru -tass.ru -lenta.ru -gazeta.ru -vk.com -ok.ru -my.mail.ru -yandex.ru -ya.ru -mail.ru -sberbank.ru -tinkoff.ru -alfabank.ru -vtb.ru -gosuslugi.ru -www.gosuslugi.ru -hh.ru -katykhin.ru - -[Зарубеж] -facebook.com -instagram.com -twitter.com -x.com -threads.net -reddit.com -tiktok.com -youtube.com -youtu.be -openai.com -chat.openai.com -claude.ai -anthropic.com -gemini.google.com -huggingface.co -midjourney.com -leonardo.ai -aistudio.google.com -ai.google.dev -telegram.org -discord.com -signal.org -whatsapp.com -github.com -gitlab.com -stackoverflow.com -npmjs.com diff --git a/homelab/vpn-route-check/entrypoint.sh b/homelab/vpn-route-check/entrypoint.sh deleted file mode 100755 index b0d99d9..0000000 --- a/homelab/vpn-route-check/entrypoint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -e -# Сначала поднимаем HTTP, чтобы страница была доступна даже при сбое проверки -python3 /app/serve_no_cache.py & -# Первый запуск проверки (при ошибке не выходим — сервер уже слушает) -python3 /app/check_routes.py /data || true -# Каждые 15 минут обновляем данные -while true; do - sleep 900 - python3 /app/check_routes.py /data || true -done diff --git a/homelab/vpn-route-check/homepage-widget.yaml b/homelab/vpn-route-check/homepage-widget.yaml deleted file mode 100644 index 7cdd178..0000000 --- a/homelab/vpn-route-check/homepage-widget.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Фрагмент для /opt/docker/homepage/config/services.yaml (карточка как «Обращения (логи NPM)»: ссылка + пинг, без iframe) - -- Маршруты VPN: - icon: shield-check.png - href: http://192.168.1.100:8765 - description: "Проверка: трафик к доменам через VPN или Ethernet (обновление каждые 15 мин)" - ping: http://192.168.1.100:8765 diff --git a/homelab/vpn-route-check/serve_no_cache.py b/homelab/vpn-route-check/serve_no_cache.py deleted file mode 100644 index b3d86aa..0000000 --- a/homelab/vpn-route-check/serve_no_cache.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -"""Минимальный HTTP-сервер для /data с заголовками no-cache, чтобы браузер не кэшировал страницу.""" -import http.server -import os - -DIR = os.environ.get("SERVE_DIR", "/data") -PORT = int(os.environ.get("SERVE_PORT", "8765")) - - -class NoCacheHandler(http.server.SimpleHTTPRequestHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=DIR, **kwargs) - - def end_headers(self): - self.send_header("Cache-Control", "no-store, no-cache, must-revalidate") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") - super().end_headers() - - -if __name__ == "__main__": - http.server.HTTPServer(("", PORT), NoCacheHandler).serve_forever() diff --git a/homelab/npm-add-proxy.sh b/scripts/npm-add-proxy.sh old mode 100755 new mode 100644 similarity index 100% rename from homelab/npm-add-proxy.sh rename to scripts/npm-add-proxy.sh diff --git a/homelab/npm-cert-cloud.sh b/scripts/npm-cert-cloud.sh similarity index 100% rename from homelab/npm-cert-cloud.sh rename to scripts/npm-cert-cloud.sh