From b0d2746490f609dbd95822c6fa4f3ac830f1295f Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 25 Feb 2026 17:03:10 +0300 Subject: [PATCH] Remove deprecated files related to homelab architecture, container context, and various scripts. This cleanup includes the removal of configuration files for Nextcloud, Gitea, and VPN setups, as well as documentation files that are no longer relevant. This helps streamline the project and eliminate outdated references. --- README.md | 47 + docs/architecture/architecture.md | 99 + docs/backup/proxmox-phase1-backup.md | 262 +++ docs/containers/container-100.md | 258 +++ docs/containers/container-101.md | 164 ++ docs/containers/container-103.md | 208 ++ docs/containers/container-104.md | 158 ++ docs/containers/container-105.md | 127 ++ docs/containers/container-107.md | 183 ++ docs/containers/container-108.md | 136 ++ docs/containers/container-200.md | 241 +++ docs/containers/paperless-ollama.md | 82 + docs/network/network-topology.md | 233 ++ docs/network/router-netcraze-speedster.md | 102 + docs/network/ssl-letsencrypt-dns01.md | 102 + docs/vps/vpn-migrate-config.md | 60 + docs/vps/vpn-vps-amneziawg.md | 95 + docs/vps/vps-miran-bots.md | 105 + homelab/VPN-KEENETIC-CONTEXT-PROMPT.md | 71 - homelab/architecture.md | 58 - homelab/containers-context.md | 134 -- homelab/docs/migrate-nextcloud-to-hdd.md | 86 - homelab/docs/nextcloud-quota-fix.md | 33 - homelab/docs/storage-mount-layout.md | 39 - homelab/gitea/.env.example | 4 - homelab/gitea/README.md | 138 -- homelab/gitea/create-lxc-103.sh | 29 - homelab/gitea/docker-compose-103.yml | 71 - homelab/gitea/homepage-services-snippet.yaml | 7 - homelab/immich/proxmox-config.md | 75 - homelab/immich/setup-swap.sh | 21 - homelab/nextcloud/docker-compose-101.yml | 46 - homelab/nextcloud/docker-nextcloud.service | 15 - homelab/nextcloud/php-uploads.ini | 6 - .../__pycache__/gen-dashboard.cpython-313.pyc | Bin 22027 -> 0 bytes homelab/npm-log-dashboard/gen-dashboard.py | 404 ---- homelab/npm-log-dashboard/verify-dashboard.py | 71 - homelab/paperless-ngx/docker-compose-104.yml | 37 - homelab/paperless-ngx/docker-compose.env | 8 - homelab/paperless-ngx/docker-compose.yml | 40 - .../paperless-ngx/docker-paperless.service | 15 - homelab/paperless-ollama-README.md | 44 - homelab/paperless-ollama-ask.py | 142 -- .../scripts/README-reorganize-games-common.md | 47 - homelab/scripts/README-reorganize-games.md | 35 - .../steam_library_size.cpython-313.pyc | Bin 10528 -> 0 bytes homelab/scripts/check_missing_steam_games.py | 212 -- homelab/scripts/check_size_sources.py | 128 -- homelab/scripts/merge_names_into_sizes.py | 50 - homelab/scripts/reorganize-games-common.sh | 185 -- homelab/scripts/reorganize-games.sh | 232 -- homelab/scripts/resolve_steam_appid.py | 115 - homelab/scripts/steam-library-to-json.py | 59 - homelab/scripts/steam-to-cloud-options.md | 37 - homelab/scripts/steam_app_sizes.json | 271 --- homelab/scripts/steam_library.json | 1885 ----------------- homelab/scripts/steam_library_size.py | 195 -- homelab/scripts/steam_library_with_sizes.json | 1347 ------------ homelab/us-router.conf | 19 - homelab/vpn-keenetic-us-second-connection.md | 99 - homelab/vpn-route-check/.gitignore | 1 - homelab/vpn-route-check/Dockerfile | 9 - .../PROMPT-deploy-to-server.md | 28 - homelab/vpn-route-check/README.md | 49 - .../__pycache__/check_routes.cpython-313.pyc | Bin 17555 -> 0 bytes homelab/vpn-route-check/check_routes.py | 379 ---- homelab/vpn-route-check/deploy-on-proxmox.sh | 18 - homelab/vpn-route-check/docker-compose.yml | 17 - homelab/vpn-route-check/domains.txt | 56 - homelab/vpn-route-check/entrypoint.sh | 11 - homelab/vpn-route-check/homepage-widget.yaml | 7 - homelab/vpn-route-check/serve_no_cache.py | 22 - {homelab => scripts}/npm-add-proxy.sh | 0 {homelab => scripts}/npm-cert-cloud.sh | 0 74 files changed, 2662 insertions(+), 7107 deletions(-) create mode 100644 README.md create mode 100644 docs/architecture/architecture.md create mode 100644 docs/backup/proxmox-phase1-backup.md create mode 100644 docs/containers/container-100.md create mode 100644 docs/containers/container-101.md create mode 100644 docs/containers/container-103.md create mode 100644 docs/containers/container-104.md create mode 100644 docs/containers/container-105.md create mode 100644 docs/containers/container-107.md create mode 100644 docs/containers/container-108.md create mode 100644 docs/containers/container-200.md create mode 100644 docs/containers/paperless-ollama.md create mode 100644 docs/network/network-topology.md create mode 100644 docs/network/router-netcraze-speedster.md create mode 100644 docs/network/ssl-letsencrypt-dns01.md create mode 100644 docs/vps/vpn-migrate-config.md create mode 100644 docs/vps/vpn-vps-amneziawg.md create mode 100644 docs/vps/vps-miran-bots.md delete mode 100644 homelab/VPN-KEENETIC-CONTEXT-PROMPT.md delete mode 100644 homelab/architecture.md delete mode 100644 homelab/containers-context.md delete mode 100644 homelab/docs/migrate-nextcloud-to-hdd.md delete mode 100644 homelab/docs/nextcloud-quota-fix.md delete mode 100644 homelab/docs/storage-mount-layout.md delete mode 100644 homelab/gitea/.env.example delete mode 100644 homelab/gitea/README.md delete mode 100644 homelab/gitea/create-lxc-103.sh delete mode 100644 homelab/gitea/docker-compose-103.yml delete mode 100644 homelab/gitea/homepage-services-snippet.yaml delete mode 100644 homelab/immich/proxmox-config.md delete mode 100644 homelab/immich/setup-swap.sh delete mode 100644 homelab/nextcloud/docker-compose-101.yml delete mode 100644 homelab/nextcloud/docker-nextcloud.service delete mode 100644 homelab/nextcloud/php-uploads.ini delete mode 100644 homelab/npm-log-dashboard/__pycache__/gen-dashboard.cpython-313.pyc delete mode 100644 homelab/npm-log-dashboard/gen-dashboard.py delete mode 100644 homelab/npm-log-dashboard/verify-dashboard.py delete mode 100644 homelab/paperless-ngx/docker-compose-104.yml delete mode 100644 homelab/paperless-ngx/docker-compose.env delete mode 100644 homelab/paperless-ngx/docker-compose.yml delete mode 100644 homelab/paperless-ngx/docker-paperless.service delete mode 100644 homelab/paperless-ollama-README.md delete mode 100644 homelab/paperless-ollama-ask.py delete mode 100644 homelab/scripts/README-reorganize-games-common.md delete mode 100644 homelab/scripts/README-reorganize-games.md delete mode 100644 homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc delete mode 100644 homelab/scripts/check_missing_steam_games.py delete mode 100644 homelab/scripts/check_size_sources.py delete mode 100644 homelab/scripts/merge_names_into_sizes.py delete mode 100644 homelab/scripts/reorganize-games-common.sh delete mode 100644 homelab/scripts/reorganize-games.sh delete mode 100644 homelab/scripts/resolve_steam_appid.py delete mode 100644 homelab/scripts/steam-library-to-json.py delete mode 100644 homelab/scripts/steam-to-cloud-options.md delete mode 100644 homelab/scripts/steam_app_sizes.json delete mode 100644 homelab/scripts/steam_library.json delete mode 100644 homelab/scripts/steam_library_size.py delete mode 100644 homelab/scripts/steam_library_with_sizes.json delete mode 100644 homelab/us-router.conf delete mode 100644 homelab/vpn-keenetic-us-second-connection.md delete mode 100644 homelab/vpn-route-check/.gitignore delete mode 100644 homelab/vpn-route-check/Dockerfile delete mode 100644 homelab/vpn-route-check/PROMPT-deploy-to-server.md delete mode 100644 homelab/vpn-route-check/README.md delete mode 100644 homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc delete mode 100644 homelab/vpn-route-check/check_routes.py delete mode 100755 homelab/vpn-route-check/deploy-on-proxmox.sh delete mode 100644 homelab/vpn-route-check/docker-compose.yml delete mode 100644 homelab/vpn-route-check/domains.txt delete mode 100755 homelab/vpn-route-check/entrypoint.sh delete mode 100644 homelab/vpn-route-check/homepage-widget.yaml delete mode 100644 homelab/vpn-route-check/serve_no_cache.py rename {homelab => scripts}/npm-add-proxy.sh (100%) mode change 100755 => 100644 rename {homelab => scripts}/npm-cert-cloud.sh (100%) 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 870219439631e17ac17631fad69ea45f8e3d8023..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22027 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 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 ff9b88cc018ce2fb55b54262d0c834c6edfe758e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10528 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 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 ffd17813fab1de9debf784b423f93dd4a98f570a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17555 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 Ab^rhX 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