Initial homelab docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-23 16:47:17 +03:00
commit ce731c28da
53 changed files with 6943 additions and 0 deletions

View 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, H1H4 как выше.

58
homelab/architecture.md Normal file
View 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).

View 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).

View 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.

View File

@@ -0,0 +1,4 @@
# Скопировать в .env и подставить токен после первой настройки Gitea.
# Токен: Администрирование → Actions → Runners → Registration token.
GITEA_RUNNER_REGISTRATION_TOKEN=

138
homelab/gitea/README.md Normal file
View 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).

View 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)."

View 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:

View 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)

View 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 это может не сработать (настраивается на хосте). Если контейнер не видит изменение — не критично.

View 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"

View 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

View 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

View 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
View 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
View 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"

View 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()

View 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())

View 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:

View 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

View 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:

View 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

View 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 соответственно.

View 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()

View 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»**; манифест нужно будет добавить вручную (скачать или создать).

View 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 выводит ожидаемые действия.

View 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 — проверить все игры (~1015 мин)
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)

View 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()

View 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) ==="

View 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) ==="

View 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())

View 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()

View 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 в нужном виде).

View 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
}

File diff suppressed because it is too large Load Diff

View 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()

File diff suppressed because it is too large Load Diff

19
homelab/us-router.conf Normal file
View 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

View 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
View File

@@ -0,0 +1 @@
output/

View 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"]

View 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`).
---

View 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).

View 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()

View 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"

View 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:

View 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

View 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

View 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

View 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()