71
homelab/VPN-KEENETIC-CONTEXT-PROMPT.md
Normal file
71
homelab/VPN-KEENETIC-CONTEXT-PROMPT.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Контекст: 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 <network> mask <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 как выше.
|
||||||
58
homelab/architecture.md
Normal file
58
homelab/architecture.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Архитектура домашних сервисов (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 <ID> -- 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).
|
||||||
134
homelab/containers-context.md
Normal file
134
homelab/containers-context.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Контекст по контейнерам (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 <ID> -- ...` или `pct enter <ID>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
49
homelab/docs/storage-mount-layout.md
Normal file
49
homelab/docs/storage-mount-layout.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Разметка дисков и монтирование (хост Proxmox 192.168.1.150)
|
||||||
|
|
||||||
|
## Текущая схема
|
||||||
|
|
||||||
|
### Хост
|
||||||
|
|
||||||
|
| Устройство | Размер | Раздел | Точка монтирования | Использовано |
|
||||||
|
|------------|--------|--------|--------------------|--------------|
|
||||||
|
| **sdd** (WDC WD80EFPX 8 TB) | 7.28 TiB | sdd1 **4 TB** ext4 | `/mnt/nextcloud-hdd` | 3.4 TB |
|
||||||
|
| **sdb** (SATA SSD) | 1.9 TB | sdb1 ext4 | `/mnt/ssd-storage` | 932 GB |
|
||||||
|
|
||||||
|
- На диске **sdd** занята только часть: раздел sdd1 = 4 TB. Свободно ~3.3 TB неразмеченного места.
|
||||||
|
- **LVM не используется** для этих дисков — обычные разделы ext4.
|
||||||
|
|
||||||
|
### Контейнер 101 (Nextcloud)
|
||||||
|
|
||||||
|
| Хост (источник) | В контейнере (mp) |
|
||||||
|
|------------------|-------------------|
|
||||||
|
| `/mnt/ssd-storage/nextcloud-101` | `/mnt/nextcloud-data` |
|
||||||
|
| `/mnt/nextcloud-hdd` | `/mnt/nextcloud-extra` |
|
||||||
|
|
||||||
|
- **«HDD 4 TB»** в Nextcloud = `/mnt/nextcloud-extra` в CT = хост `/mnt/nextcloud-hdd` (диск sdd, 4 TB).
|
||||||
|
- **«Игры»** = папка внутри данных Nextcloud на SSD:
|
||||||
|
- В CT: `/mnt/nextcloud-data/html/data/kerrad/files/Игры` (≈928 GB)
|
||||||
|
- На хосте: `/mnt/ssd-storage/nextcloud-101/html/data/kerrad/files/Игры`
|
||||||
|
|
||||||
|
### Раздел sdd (8 TB диск)
|
||||||
|
|
||||||
|
```
|
||||||
|
Disk /dev/sdd: 7.28 TiB
|
||||||
|
Disklabel type: gpt
|
||||||
|
/dev/sdd1 Start 2048 End 8589936639 Size 4T (Linux filesystem)
|
||||||
|
↑ свободно до конца диска ~3.3 TB
|
||||||
|
```
|
||||||
|
|
||||||
|
Расширение до 6.5 TB: увеличить конец раздела sdd1, затем `resize2fs`. Это не LVM, а рост обычного раздела + файловой системы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## План: один том «Игры» 7.5 TB + том «Прочее» 200 GB
|
||||||
|
|
||||||
|
1. **Расширить раздел и ФС на sdd до 7.5 TB** (на хосте, при остановленном использовании тома). ✅ Сделано.
|
||||||
|
2. **Перенести содержимое «Игры»** (928 GB) в `/mnt/nextcloud-hdd` (то есть в текущий nextcloud-extra) и удалить том Игры.
|
||||||
|
3. **Размонтировать/удалить** только папку «Игры» из файлов (данные Игры убираем с SSD; место освобождается).
|
||||||
|
4. **В Nextcloud:** убрать отображение папки «Игры», оставить одно хранилище и переименовать его в Игры.
|
||||||
|
5. **Смонтировать новый раздел** с SSD диска на 200 гб, назвать его Прочее и подключить как внешнюю папку
|
||||||
|
|
||||||
|
Итого: одна сетевая папка Игры = смонтированный расширенный том **7.5 TB** на sdd.
|
||||||
|
Вторая сетевая папка Прочее = смонтированный как внешняя папка том 200 GB на SSD.
|
||||||
4
homelab/gitea/.env.example
Normal file
4
homelab/gitea/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Скопировать в .env и подставить токен после первой настройки Gitea.
|
||||||
|
# Токен: Администрирование → Actions → Runners → Registration token.
|
||||||
|
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||||
138
homelab/gitea/README.md
Normal file
138
homelab/gitea/README.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 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).
|
||||||
29
homelab/gitea/create-lxc-103.sh
Normal file
29
homelab/gitea/create-lxc-103.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/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)."
|
||||||
71
homelab/gitea/docker-compose-103.yml
Normal file
71
homelab/gitea/docker-compose-103.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 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:
|
||||||
7
homelab/gitea/homepage-services-snippet.yaml
Normal file
7
homelab/gitea/homepage-services-snippet.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Вставить в /opt/docker/homepage/config/services.yaml на контейнере 100
|
||||||
|
# в нужную группу (например «Разработка» или «Сервисы»).
|
||||||
|
|
||||||
|
- Gitea:
|
||||||
|
icon: gitea.png
|
||||||
|
href: http://192.168.1.103:3000
|
||||||
|
description: Git-сервер и Gitea Actions (LAN)
|
||||||
75
homelab/immich/proxmox-config.md
Normal file
75
homelab/immich/proxmox-config.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Конфигурация Immich (LXC)
|
||||||
|
|
||||||
|
## Текущее → Целевое
|
||||||
|
|
||||||
|
| Ресурс | Было | Стало |
|
||||||
|
|--------|------|-------|
|
||||||
|
| CPU | 8 cores | 3 cores |
|
||||||
|
| RAM | 10 GB | 8 GB |
|
||||||
|
| Swap | — | 4 GB (файл) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Настройка ресурсов Proxmox
|
||||||
|
|
||||||
|
На хосте Proxmox (192.168.1.150):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Замени <IMMICH_ID> на реальный ID контейнера Immich
|
||||||
|
pct set <IMMICH_ID> --cores 3
|
||||||
|
pct set <IMMICH_ID> --memory 8192
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Файл подкачки внутри контейнера
|
||||||
|
|
||||||
|
Войти в контейнер:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct enter <IMMICH_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
Создать 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 это может не сработать (настраивается на хосте). Если контейнер не видит изменение — не критично.
|
||||||
21
homelab/immich/setup-swap.sh
Normal file
21
homelab/immich/setup-swap.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Создание swap-файла 4 GB внутри LXC-контейнера Immich
|
||||||
|
# Запускать внутри контейнера (pct enter <ID>)
|
||||||
|
|
||||||
|
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"
|
||||||
46
homelab/nextcloud/docker-compose-101.yml
Normal file
46
homelab/nextcloud/docker-compose-101.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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
|
||||||
15
homelab/nextcloud/docker-nextcloud.service
Normal file
15
homelab/nextcloud/docker-nextcloud.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[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
|
||||||
6
homelab/nextcloud/php-uploads.ini
Normal file
6
homelab/nextcloud/php-uploads.ini
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
; 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
|
||||||
59
homelab/npm-add-proxy.sh
Executable file
59
homelab/npm-add-proxy.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Add docs.katykhin.ru → 192.168.1.104:8000 via NPM API
|
||||||
|
# Usage: NPM_EMAIL=admin@example.com NPM_PASSWORD=xxx ./npm-add-proxy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
NPM_URL="${NPM_URL:-http://192.168.1.100:81}"
|
||||||
|
API="$NPM_URL/api"
|
||||||
|
|
||||||
|
if [ -z "$NPM_EMAIL" ] || [ -z "$NPM_PASSWORD" ]; then
|
||||||
|
echo "Set NPM_EMAIL and NPM_PASSWORD"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Getting token..."
|
||||||
|
TOKEN=$(curl -s -X POST "$API/tokens" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identity\":\"$NPM_EMAIL\",\"secret\":\"$NPM_PASSWORD\"}" \
|
||||||
|
| jq -r '.token // empty')
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Failed to get token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Finding certificate for docs.katykhin.ru..."
|
||||||
|
CERT_ID=$(curl -s -H "Authorization: Bearer $TOKEN" "$API/nginx/certificates" \
|
||||||
|
| jq -r '.[] | select(.domain_names[]? == "docs.katykhin.ru") | .id' | head -1)
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg cert "$CERT_ID" \
|
||||||
|
'{
|
||||||
|
domain_names: ["docs.katykhin.ru"],
|
||||||
|
forward_host: "192.168.1.104",
|
||||||
|
forward_port: "8000",
|
||||||
|
forward_scheme: "http",
|
||||||
|
enabled: true,
|
||||||
|
allow_websocket_upgrade: true,
|
||||||
|
http2_support: true,
|
||||||
|
block_exploits: true,
|
||||||
|
certificate_id: (if $cert != "" and $cert != "null" then ($cert | tonumber) else null end),
|
||||||
|
ssl_forced: ($cert != "" and $cert != "null")
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "Creating proxy host..."
|
||||||
|
RESP=$(curl -s -w "\n%{http_code}" -X POST "$API/nginx/proxy-hosts" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD")
|
||||||
|
HTTP_CODE=$(echo "$RESP" | tail -1)
|
||||||
|
BODY=$(echo "$RESP" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "201" ]; then
|
||||||
|
echo "Proxy host created: docs.katykhin.ru -> 192.168.1.104:8000"
|
||||||
|
echo "$BODY" | jq .
|
||||||
|
else
|
||||||
|
echo "Failed (HTTP $HTTP_CODE):"
|
||||||
|
echo "$BODY" | jq . 2>/dev/null || echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
98
homelab/npm-cert-cloud.sh
Normal file
98
homelab/npm-cert-cloud.sh
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Выпуск сертификата cloud.katykhin.ru через certbot DNS-01 Beget и подключение к NPM
|
||||||
|
# Usage: BEGET_USER=логин BEGET_PASS=пароль ./npm-cert-cloud.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
DOMAIN="cloud.katykhin.ru"
|
||||||
|
EMAIL="j3tears100@gmail.com"
|
||||||
|
# Запуск: ssh root@PROXMOX "pct exec 100 -- bash -s" < npm-cert-cloud.sh
|
||||||
|
# Или: BEGET_USER=xxx BEGET_PASS=xxx pct exec 100 -- bash -c 'eval "$(cat)"' < npm-cert-cloud.sh
|
||||||
|
NPM_URL="${NPM_URL:-http://127.0.0.1:81}"
|
||||||
|
API="$NPM_URL/api"
|
||||||
|
NPM_EMAIL="j3tears100@gmail.com"
|
||||||
|
NPM_PASSWORD="kqEUubVq02DJTS8"
|
||||||
|
|
||||||
|
if [ -z "$BEGET_USER" ] || [ -z "$BEGET_PASS" ]; then
|
||||||
|
echo "Укажите BEGET_USER и BEGET_PASS (логин и пароль Beget API)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "1. Создание credentials для certbot..."
|
||||||
|
CRED_DIR="/root/.secrets/certbot"
|
||||||
|
mkdir -p "$CRED_DIR"
|
||||||
|
cat > "$CRED_DIR/beget.ini" << EOF
|
||||||
|
dns_beget_api_username = $BEGET_USER
|
||||||
|
dns_beget_api_password = $BEGET_PASS
|
||||||
|
EOF
|
||||||
|
chmod 600 "$CRED_DIR/beget.ini"
|
||||||
|
|
||||||
|
echo "2. Запрос сертификата Let's Encrypt (DNS-01)..."
|
||||||
|
certbot certonly \
|
||||||
|
--authenticator dns-beget-api \
|
||||||
|
--dns-beget-api-credentials "$CRED_DIR/beget.ini" \
|
||||||
|
--dns-beget-api-propagation-seconds 120 \
|
||||||
|
-d "$DOMAIN" \
|
||||||
|
--non-interactive \
|
||||||
|
--agree-tos \
|
||||||
|
--email "$EMAIL"
|
||||||
|
|
||||||
|
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
|
||||||
|
CERT=$(cat "$CERT_DIR/fullchain.pem")
|
||||||
|
KEY=$(cat "$CERT_DIR/privkey.pem")
|
||||||
|
|
||||||
|
echo "3. Добавление сертификата в NPM..."
|
||||||
|
TOKEN=$(curl -s -X POST "$API/tokens" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identity\":\"$NPM_EMAIL\",\"secret\":\"$NPM_PASSWORD\"}" \
|
||||||
|
| jq -r '.token // empty')
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Ошибка: не удалось получить токен NPM"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Экранируем для JSON
|
||||||
|
CERT_ESC=$(echo "$CERT" | jq -Rs .)
|
||||||
|
KEY_ESC=$(echo "$KEY" | jq -Rs .)
|
||||||
|
|
||||||
|
RESP=$(curl -s -w "\n%{http_code}" -X POST "$API/nginx/certificates" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"provider\":\"other\",\"domain_names\":[\"$DOMAIN\"],\"nice_name\":\"$DOMAIN\",\"meta\":{\"certificate\":$CERT_ESC,\"certificate_key\":$KEY_ESC}}")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESP" | tail -1)
|
||||||
|
BODY=$(echo "$RESP" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Ошибка добавления сертификата (HTTP $HTTP_CODE):"
|
||||||
|
echo "$BODY" | jq . 2>/dev/null || echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CERT_ID=$(echo "$BODY" | jq -r '.id')
|
||||||
|
echo "Сертификат добавлен, ID: $CERT_ID"
|
||||||
|
|
||||||
|
echo "4. Подключение сертификата к proxy host cloud.katykhin.ru..."
|
||||||
|
PROXY_ID=$(curl -s -H "Authorization: Bearer $TOKEN" "$API/nginx/proxy-hosts" \
|
||||||
|
| jq -r '.[] | select(.domain_names[]? == "cloud.katykhin.ru") | .id')
|
||||||
|
|
||||||
|
if [ -z "$PROXY_ID" ]; then
|
||||||
|
echo "Proxy host для $DOMAIN не найден"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY=$(curl -s -H "Authorization: Bearer $TOKEN" "$API/nginx/proxy-hosts/$PROXY_ID")
|
||||||
|
UPD=$(echo "$PROXY" | jq --argjson cid "$CERT_ID" '
|
||||||
|
.certificate_id = $cid |
|
||||||
|
.ssl_forced = true |
|
||||||
|
del(.owner, .certificate, .access_list)
|
||||||
|
')
|
||||||
|
# domain_names должен быть массив
|
||||||
|
UPD=$(echo "$UPD" | jq '.domain_names = ["cloud.katykhin.ru"]')
|
||||||
|
|
||||||
|
curl -s -X PUT "$API/nginx/proxy-hosts/$PROXY_ID" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$UPD" | jq .
|
||||||
|
|
||||||
|
echo "Готово. Сертификат подключён к https://$DOMAIN"
|
||||||
Binary file not shown.
404
homelab/npm-log-dashboard/gen-dashboard.py
Normal file
404
homelab/npm-log-dashboard/gen-dashboard.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
#!/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"<tr><td>{domain}</td><td>{counts_by_domain[domain]}</td></tr>")
|
||||||
|
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'<a href="https://2ip.ru/whois/?ip={ip}" target="_blank" rel="noopener">{ip}</a>'
|
||||||
|
rows.append(f"<tr><td>{link_2ip}</td><td>{cnt}</td><td>{geo}</td></tr>")
|
||||||
|
if rest_d:
|
||||||
|
rows.append(f'<tr><td><em>Остальные IP</em></td><td>{rest_d}</td><td>—</td></tr>')
|
||||||
|
summary_ip_parts.append(
|
||||||
|
f'<tr class="domain-header"><td colspan="3"><strong>{domain}</strong></td></tr>\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 = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="5" fill="#1a1a2e"/>
|
||||||
|
<rect x="4" y="6" width="24" height="20" rx="2" fill="#0f0f1a" stroke="#7fdbff" stroke-width="1"/>
|
||||||
|
<line x1="8" y1="11" x2="22" y2="11" stroke="#7fdbff" stroke-width="1"/>
|
||||||
|
<line x1="8" y1="16" x2="18" y2="16" stroke="#7fdbff" stroke-width="1"/>
|
||||||
|
<line x1="8" y1="21" x2="24" y2="21" stroke="#7fdbff" stroke-width="1"/>
|
||||||
|
<circle cx="10" cy="11" r="1" fill="#7fdbff"/>
|
||||||
|
</svg>"""
|
||||||
|
favicon_data_url = "data:image/svg+xml;base64," + base64.b64encode(favicon_svg.encode("utf-8")).decode("ascii")
|
||||||
|
html_start = f"""<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Обращения к внешним URL (NPM)</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{favicon_data_url}">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: system-ui, sans-serif; margin: 1rem; background: #1a1a2e; color: #eee; }}
|
||||||
|
h1 {{ font-size: 1.2rem; }}
|
||||||
|
h2 {{ font-size: 1rem; margin-top: 1.5rem; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; font-size: 0.85rem; }}
|
||||||
|
th, td {{ border: 1px solid #444; padding: 0.35rem 0.5rem; text-align: left; }}
|
||||||
|
th {{ background: #16213e; }}
|
||||||
|
tr:nth-child(even) {{ background: #0f0f1a; }}
|
||||||
|
tr.domain-header td {{ background: #16213e; padding: 0.4rem 0.5rem; }}
|
||||||
|
.meta {{ color: #888; font-size: 0.8rem; margin-bottom: 1rem; }}
|
||||||
|
.controls {{ display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.5rem; }}
|
||||||
|
.controls label {{ display: flex; align-items: center; gap: 0.35rem; }}
|
||||||
|
.controls select {{ background: #0f0f1a; color: #eee; border: 1px solid #444; padding: 0.25rem 0.5rem; }}
|
||||||
|
.pagination {{ margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }}
|
||||||
|
.pagination button {{ background: #16213e; color: #eee; border: 1px solid #444; padding: 0.35rem 0.75rem; cursor: pointer; }}
|
||||||
|
.pagination button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
|
||||||
|
.geo {{ font-size: 0.8rem; color: #7fdbff; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Обращения к внешним URL (Nginx Proxy Manager)</h1>
|
||||||
|
<p class="meta">Сводка за 2 суток; лента — последние {feed_count:,} запросов. Расчёт по крону раз в 15 мин. <em>Сгенерировано: {_gen_time}</em></p>
|
||||||
|
|
||||||
|
<h2>Запросы по доменам (2 суток)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Домен</th><th>Запросов</th></tr></thead>
|
||||||
|
<tbody>{summary_domain_table}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Запросы по IP (топ-5 по каждому домену + остальные)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>IP</th><th>Запросов</th><th>Местоположение</th></tr></thead>
|
||||||
|
<tbody>{summary_ip_table}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Лента запросов (последние {feed_count:,})</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<label>Домен: <select id="filterDomain">
|
||||||
|
<option value="">Все</option>
|
||||||
|
</select></label>
|
||||||
|
<label>На странице: <select id="pageSize">
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100" selected>100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
<table id="feedTable">
|
||||||
|
<thead><tr><th>Время</th><th>Домен</th><th>Метод</th><th>Путь</th><th>Статус</th><th>Client</th><th>Местоположение</th></tr></thead>
|
||||||
|
<tbody id="feedBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination">
|
||||||
|
<button type="button" id="prevPage">← Назад</button>
|
||||||
|
<span id="pageInfo">—</span>
|
||||||
|
<button type="button" id="nextPage">Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="feed-data"></script>
|
||||||
|
<script>
|
||||||
|
(function() {{
|
||||||
|
var el = document.getElementById("feed-data");
|
||||||
|
var data = JSON.parse(el.textContent);
|
||||||
|
var feedEntries = data.feed;
|
||||||
|
var geoMap = data.geo;
|
||||||
|
var domainsList = data.domains;
|
||||||
|
|
||||||
|
var currentPage = 1;
|
||||||
|
var pageSize = parseInt(document.getElementById("pageSize").value, 10);
|
||||||
|
var filterDomain = "";
|
||||||
|
|
||||||
|
var filterSelect = document.getElementById("filterDomain");
|
||||||
|
for (var i = 0; i < domainsList.length; i++) {{
|
||||||
|
var opt = document.createElement("option");
|
||||||
|
opt.value = domainsList[i];
|
||||||
|
opt.textContent = domainsList[i];
|
||||||
|
filterSelect.appendChild(opt);
|
||||||
|
}}
|
||||||
|
|
||||||
|
document.getElementById("pageSize").onchange = function() {{
|
||||||
|
pageSize = parseInt(this.value, 10);
|
||||||
|
currentPage = 1;
|
||||||
|
render();
|
||||||
|
}};
|
||||||
|
filterSelect.onchange = function() {{
|
||||||
|
filterDomain = this.value;
|
||||||
|
currentPage = 1;
|
||||||
|
render();
|
||||||
|
}};
|
||||||
|
document.getElementById("prevPage").onclick = function() {{
|
||||||
|
currentPage = Math.max(1, currentPage - 1);
|
||||||
|
render();
|
||||||
|
}};
|
||||||
|
document.getElementById("nextPage").onclick = function() {{
|
||||||
|
currentPage++;
|
||||||
|
render();
|
||||||
|
}};
|
||||||
|
|
||||||
|
function getFiltered() {{
|
||||||
|
if (!filterDomain) return feedEntries;
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < feedEntries.length; i++)
|
||||||
|
if (feedEntries[i].d === filterDomain) out.push(feedEntries[i]);
|
||||||
|
return out;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function render() {{
|
||||||
|
var list = getFiltered();
|
||||||
|
var totalPages = Math.max(1, Math.ceil(list.length / pageSize));
|
||||||
|
if (currentPage > totalPages) currentPage = totalPages;
|
||||||
|
var start = (currentPage - 1) * pageSize;
|
||||||
|
var page = list.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
var tbody = document.getElementById("feedBody");
|
||||||
|
var html = "";
|
||||||
|
for (var i = 0; i < page.length; i++) {{
|
||||||
|
var e = page[i];
|
||||||
|
var geo = geoMap[e.c] || "—";
|
||||||
|
var ipLink = e.c ? "<a href=\\"https://2ip.ru/whois/?ip=" + e.c + "\\" target=\\"_blank\\" rel=\\"noopener\\">" + e.c + "</a>" : e.c;
|
||||||
|
html += "<tr><td>" + e.t + "</td><td>" + e.d + "</td><td>" + e.m + "</td><td>" + e.p + "</td><td>" + e.s + "</td><td>" + ipLink + "</td><td class=\\"geo\\">" + geo + "</td></tr>";
|
||||||
|
}}
|
||||||
|
tbody.innerHTML = html || "<tr><td colspan=\\"7\\">Нет записей</td></tr>";
|
||||||
|
|
||||||
|
document.getElementById("pageInfo").textContent = "Страница " + currentPage + " из " + totalPages + " (всего " + list.length + ")";
|
||||||
|
document.getElementById("prevPage").disabled = currentPage <= 1;
|
||||||
|
document.getElementById("nextPage").disabled = currentPage >= totalPages;
|
||||||
|
}}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
# JSON для ленты: экранируем </script> чтобы не закрыть тег
|
||||||
|
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('<script type="application/json" id="feed-data"></script>',
|
||||||
|
'<script type="application/json" id="feed-data">' + feed_json_safe + '</script>')
|
||||||
|
with open(out_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html_full)
|
||||||
|
else:
|
||||||
|
html_full = html_start.replace('<script type="application/json" id="feed-data"></script>',
|
||||||
|
'<script type="application/json" id="feed-data">' + feed_json_safe + '</script>')
|
||||||
|
print(html_full)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
71
homelab/npm-log-dashboard/verify-dashboard.py
Normal file
71
homelab/npm-log-dashboard/verify-dashboard.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/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'<script type="application/json" id="feed-data">(.*?)</script>', 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())
|
||||||
37
homelab/paperless-ngx/docker-compose-104.yml
Normal file
37
homelab/paperless-ngx/docker-compose-104.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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:
|
||||||
8
homelab/paperless-ngx/docker-compose.env
Normal file
8
homelab/paperless-ngx/docker-compose.env
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 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
|
||||||
40
homelab/paperless-ngx/docker-compose.yml
Normal file
40
homelab/paperless-ngx/docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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:
|
||||||
15
homelab/paperless-ngx/docker-paperless.service
Normal file
15
homelab/paperless-ngx/docker-paperless.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[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
|
||||||
44
homelab/paperless-ollama-README.md
Normal file
44
homelab/paperless-ollama-README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 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 соответственно.
|
||||||
142
homelab/paperless-ollama-ask.py
Normal file
142
homelab/paperless-ollama-ask.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/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()
|
||||||
47
homelab/scripts/README-reorganize-games-common.md
Normal file
47
homelab/scripts/README-reorganize-games-common.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Реорганизация common/ (HDD 4 TB)
|
||||||
|
|
||||||
|
Скрипт приводит папки игр в каталоге **common/** к виду: `GameName/GameName/` (файлы внутри) и кладёт `appmanifest_<AppID>.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_<AppID>.acf` в корне common/ или на уровень выше (steamapps/). Если манифеста нет, игра попадёт в блок **«Нет файла appmanifest»**; манифест нужно будет добавить вручную (скачать или создать).
|
||||||
35
homelab/scripts/README-reorganize-games.md
Normal file
35
homelab/scripts/README-reorganize-games.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Скрипт reorganize-games.sh
|
||||||
|
|
||||||
|
Упорядочивает папки игр в двух корневых директориях в CT 101 (Nextcloud):
|
||||||
|
|
||||||
|
- **Игры:** `/mnt/nextcloud-data/html/data/kerrad/files/Игры/`
|
||||||
|
- **nextcloud-extra:** `/mnt/nextcloud-extra/`
|
||||||
|
|
||||||
|
Целевая структура: `GameName/GameName/` (внутри — файлы игры), в корне папки игры — `appmanifest_<AppID>.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 выводит ожидаемые действия.
|
||||||
BIN
homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc
Normal file
BIN
homelab/scripts/__pycache__/steam_library_size.cpython-313.pyc
Normal file
Binary file not shown.
128
homelab/scripts/check_size_sources.py
Normal file
128
homelab/scripts/check_size_sources.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/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)
|
||||||
50
homelab/scripts/merge_names_into_sizes.py
Normal file
50
homelab/scripts/merge_names_into_sizes.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/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()
|
||||||
185
homelab/scripts/reorganize-games-common.sh
Normal file
185
homelab/scripts/reorganize-games-common.sh
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/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 <ROOT_DIR> [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) ==="
|
||||||
232
homelab/scripts/reorganize-games.sh
Normal file
232
homelab/scripts/reorganize-games.sh
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
#!/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 <ROOT_DIR> [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) ==="
|
||||||
115
homelab/scripts/resolve_steam_appid.py
Normal file
115
homelab/scripts/resolve_steam_appid.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/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())
|
||||||
59
homelab/scripts/steam-library-to-json.py
Normal file
59
homelab/scripts/steam-library-to-json.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/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()
|
||||||
37
homelab/scripts/steam-to-cloud-options.md
Normal file
37
homelab/scripts/steam-to-cloud-options.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Варианты: 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 в нужном виде).
|
||||||
271
homelab/scripts/steam_app_sizes.json
Normal file
271
homelab/scripts/steam_app_sizes.json
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
1885
homelab/scripts/steam_library.json
Normal file
1885
homelab/scripts/steam_library.json
Normal file
File diff suppressed because it is too large
Load Diff
195
homelab/scripts/steam_library_size.py
Normal file
195
homelab/scripts/steam_library_size.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/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()
|
||||||
1347
homelab/scripts/steam_library_with_sizes.json
Normal file
1347
homelab/scripts/steam_library_with_sizes.json
Normal file
File diff suppressed because it is too large
Load Diff
19
homelab/us-router.conf
Normal file
19
homelab/us-router.conf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[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
|
||||||
99
homelab/vpn-keenetic-us-second-connection.md
Normal file
99
homelab/vpn-keenetic-us-second-connection.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Второе 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).
|
||||||
1
homelab/vpn-route-check/.gitignore
vendored
Normal file
1
homelab/vpn-route-check/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
output/
|
||||||
9
homelab/vpn-route-check/Dockerfile
Normal file
9
homelab/vpn-route-check/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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"]
|
||||||
28
homelab/vpn-route-check/PROMPT-deploy-to-server.md
Normal file
28
homelab/vpn-route-check/PROMPT-deploy-to-server.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Промпт: обновить 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`).
|
||||||
|
|
||||||
|
---
|
||||||
49
homelab/vpn-route-check/README.md
Normal file
49
homelab/vpn-route-check/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Проверка маршрута к доменам (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).
|
||||||
BIN
homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc
Normal file
BIN
homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc
Normal file
Binary file not shown.
379
homelab/vpn-route-check/check_routes.py
Normal file
379
homelab/vpn-route-check/check_routes.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
#!/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 <ip» по SSH. Возвращает "VPN", "Ethernet" или None."""
|
||||||
|
if not ROUTER_HOST:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[
|
||||||
|
"ssh",
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=" + str(ROUTER_SSH_TIMEOUT),
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
f"{ROUTER_SSH_USER}@{ROUTER_HOST}",
|
||||||
|
f"ip route get {ip}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=ROUTER_SSH_TIMEOUT + 2,
|
||||||
|
)
|
||||||
|
out = (r.stdout or "") + (r.stderr or "")
|
||||||
|
if not out.strip():
|
||||||
|
return None
|
||||||
|
if "via " + VPN_HOP_IP in out or "via " + VPN_HOP_IP + " " in out:
|
||||||
|
return "VPN"
|
||||||
|
return "Ethernet"
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_router_routes_telnet() -> 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 = '<span class="badge vpn">VPN</span>'
|
||||||
|
elif path == "Ethernet":
|
||||||
|
badge = '<span class="badge ethernet">Ethernet</span>'
|
||||||
|
elif path == "error":
|
||||||
|
badge = '<span class="badge error">Ошибка</span>'
|
||||||
|
elif path == "few_hops":
|
||||||
|
badge = '<span class="badge unknown" title="Хост не отвечает на traceroute по пути, виден только 1 прыжок">Мало данных</span>'
|
||||||
|
else:
|
||||||
|
badge = '<span class="badge unknown">?</span>'
|
||||||
|
ip = r.get("ip") or "—"
|
||||||
|
hops_s = ", ".join(r.get("hops") or []) or "—"
|
||||||
|
return f"<tr><td>{r['domain']}</td><td>{ip}</td><td>{badge}</td><td class=\"hops\">{hops_s}</td></tr>"
|
||||||
|
|
||||||
|
|
||||||
|
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" <h2 class=\"group-title\">{group_name}</h2>\n"
|
||||||
|
f" <table>\n"
|
||||||
|
f" <thead><tr><th>Домен</th><th>IP</th><th>Маршрут</th><th>Прыжки</th></tr></thead>\n"
|
||||||
|
f" <tbody>\n{table_body}\n </tbody>\n </table>"
|
||||||
|
)
|
||||||
|
blocks = "\n\n".join(sections_html)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="refresh" content="900">
|
||||||
|
<title>Маршрут к доменам (VPN / Ethernet)</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: system-ui, sans-serif; margin: 1rem; background: #1a1a2e; color: #eee; }}
|
||||||
|
h1 {{ font-size: 1.2rem; }}
|
||||||
|
.meta {{ color: #888; font-size: 0.85rem; margin-bottom: 1rem; }}
|
||||||
|
.group-title {{ font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #b8d4e3; }}
|
||||||
|
.group-title:first-of-type {{ margin-top: 0; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; font-size: 0.9rem; max-width: 720px; margin-bottom: 1rem; }}
|
||||||
|
th, td {{ border: 1px solid #444; padding: 0.4rem 0.6rem; text-align: left; }}
|
||||||
|
th {{ background: #16213e; }}
|
||||||
|
tr:nth-child(even) {{ background: #0f0f1a; }}
|
||||||
|
.badge {{ display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; font-weight: 600; font-size: 0.8rem; }}
|
||||||
|
.badge.vpn {{ background: #0d7377; color: #fff; }}
|
||||||
|
.badge.ethernet {{ background: #3d5a80; color: #fff; }}
|
||||||
|
.badge.unknown {{ background: #555; color: #ccc; }}
|
||||||
|
.badge.error {{ background: #9e2b2b; color: #fff; }}
|
||||||
|
.hops {{ font-size: 0.8rem; color: #aaa; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Маршрут к доменам</h1>
|
||||||
|
<p class="meta">Трафик через VPN (10.8.1.0) или напрямую (Ethernet). Обновлено: {gen_time}</p>
|
||||||
|
|
||||||
|
{blocks}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
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()
|
||||||
18
homelab/vpn-route-check/deploy-on-proxmox.sh
Executable file
18
homelab/vpn-route-check/deploy-on-proxmox.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/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"
|
||||||
17
homelab/vpn-route-check/docker-compose.yml
Normal file
17
homelab/vpn-route-check/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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:
|
||||||
56
homelab/vpn-route-check/domains.txt
Normal file
56
homelab/vpn-route-check/domains.txt
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Секции: [Россия] и [Зарубеж]. Домены по одному на строку; # — комментарий.
|
||||||
|
|
||||||
|
[Россия]
|
||||||
|
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
|
||||||
11
homelab/vpn-route-check/entrypoint.sh
Executable file
11
homelab/vpn-route-check/entrypoint.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/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
|
||||||
7
homelab/vpn-route-check/homepage-widget.yaml
Normal file
7
homelab/vpn-route-check/homepage-widget.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Фрагмент для /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
|
||||||
22
homelab/vpn-route-check/serve_no_cache.py
Normal file
22
homelab/vpn-route-check/serve_no_cache.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user