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

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