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