1
homelab/vpn-route-check/.gitignore
vendored
Normal file
1
homelab/vpn-route-check/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
output/
|
||||
9
homelab/vpn-route-check/Dockerfile
Normal file
9
homelab/vpn-route-check/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends traceroute dnsutils iproute2 openssh-client && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY check_routes.py domains.txt serve_no_cache.py ./
|
||||
RUN mkdir -p /data
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
EXPOSE 8765
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
28
homelab/vpn-route-check/PROMPT-deploy-to-server.md
Normal file
28
homelab/vpn-route-check/PROMPT-deploy-to-server.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Промпт: обновить vpn-route-check на сервере
|
||||
|
||||
Скопируй этот блок и вставь в чат с ассистентом, когда нужно выкатить обновления на сервер.
|
||||
|
||||
---
|
||||
|
||||
**Задача:** развернуть/обновить сервис vpn-route-check на сервере.
|
||||
|
||||
**Контекст:**
|
||||
- Проект в репозитории: `homelab/vpn-route-check/` (скрипт проверки маршрутов VPN/Ethernet, выдача HTML по HTTP).
|
||||
- Сервер: Proxmox по адресу **192.168.1.150**, логин **root** (SSH без пароля уже настроен).
|
||||
- Сервис крутится **внутри LXC-контейнера с ID 100** (IP контейнера **192.168.1.100**). В контейнере установлены Docker и Docker Compose.
|
||||
- Путь в контейнере: `/opt/docker/vpn-route-check`. После деплоя страница доступна по **http://192.168.1.100:8765**.
|
||||
|
||||
**Что сделать:**
|
||||
1. Из корня репозитория (или из `homelab/vpn-route-check`) упаковать содержимое папки `vpn-route-check` в tar (без самой папки, только файлы внутри: `check_routes.py`, `domains.txt`, `Dockerfile`, `entrypoint.sh`, `docker-compose.yml` и т.д.).
|
||||
2. Скопировать tar на Proxmox: `scp ... root@192.168.1.150:/tmp/vpn-route-check.tar`.
|
||||
3. На Proxmox: загрузить tar в контейнер 100:
|
||||
`pct push 100 /tmp/vpn-route-check.tar /tmp/vpn-route-check.tar`
|
||||
4. В контейнере: распаковать в `/opt/docker/vpn-route-check`:
|
||||
`pct exec 100 -- tar -xf /tmp/vpn-route-check.tar -C /opt/docker/vpn-route-check`
|
||||
5. В контейнере: пересобрать и запустить:
|
||||
`pct exec 100 -- bash -c 'cd /opt/docker/vpn-route-check && docker compose up -d --build'`
|
||||
6. Проверить: контейнер `vpn-route-check` в статусе Up, в логах есть строка вида `OK: N доменов`, по адресу http://192.168.1.100:8765 отдаётся страница (curl или браузер).
|
||||
|
||||
Все команды выполни сам (у меня есть доступ по SSH к 192.168.1.150). Если репозиторий открыт в Cursor, используй путь к проекту из workspace (например `homelab/vpn-route-check`).
|
||||
|
||||
---
|
||||
49
homelab/vpn-route-check/README.md
Normal file
49
homelab/vpn-route-check/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Проверка маршрута к доменам (VPN / Ethernet)
|
||||
|
||||
Скрипт проверяет, идёт ли трафик к заданным доменам через VPN (10.8.1.0) или через Ethernet. Результаты отдаются по HTTP и обновляются **каждые 15 минут** без ручного запуска.
|
||||
|
||||
## Быстрый старт (один раз настроил — дальше всё само)
|
||||
|
||||
**Шаг 1.** С хоста Proxmox скопировать папку в контейнер 100 и запустить контейнер:
|
||||
|
||||
```bash
|
||||
# С Mac скопировать папку на Proxmox (если репо только на Mac):
|
||||
scp -r /Users/andrejkatyhin/Work/plantUML/homelab/vpn-route-check root@192.168.1.150:/root/
|
||||
|
||||
# Зайти на Proxmox и запустить развёртывание:
|
||||
ssh root@192.168.1.150
|
||||
bash /root/vpn-route-check/deploy-on-proxmox.sh
|
||||
```
|
||||
|
||||
Если репо уже есть на Proxmox (например в `/root/plantUML`):
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.150
|
||||
cd /root/plantUML/homelab/vpn-route-check
|
||||
bash deploy-on-proxmox.sh
|
||||
```
|
||||
|
||||
Контейнер поднимется, проверка будет запускаться при старте и **каждые 15 минут**, страница отдаётся на порту **8765** на 192.168.1.100.
|
||||
|
||||
**Шаг 2 (опционально).** Когда сервис доступен по http://192.168.1.100:8765 — можно добавить виджет в Homepage (фрагмент в `homepage-widget.yaml`). Сейчас виджет из дашборда убран: страница 8765 недоступна.
|
||||
|
||||
**Итоговая страница (в локальной сети):** **http://192.168.1.100:8765**
|
||||
|
||||
---
|
||||
|
||||
## Список доменов
|
||||
|
||||
По умолчанию проверяются домены из `domains.txt` в образе. Чтобы менять список без пересборки, раскомментируй в `docker-compose.yml` volume для `domains.txt` и перезапусти контейнер.
|
||||
|
||||
---
|
||||
|
||||
## Локальный запуск (без Docker)
|
||||
|
||||
Для разовой проверки с Mac:
|
||||
|
||||
```bash
|
||||
python3 check_routes.py
|
||||
# Результаты в output/index.html и output/results.json
|
||||
```
|
||||
|
||||
Требуется: `traceroute`, `dig` (dnsutils).
|
||||
BIN
homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc
Normal file
BIN
homelab/vpn-route-check/__pycache__/check_routes.cpython-313.pyc
Normal file
Binary file not shown.
379
homelab/vpn-route-check/check_routes.py
Normal file
379
homelab/vpn-route-check/check_routes.py
Normal file
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Проверяет, идёт ли трафик к заданным доменам через VPN (10.8.1.0) или через Ethernet.
|
||||
Запускать с хоста в той же LAN, что и роутер (например контейнер 100 или рабочий Mac).
|
||||
Требуется: traceroute, dig (dnsutils) или резолв через Python.
|
||||
Выход: JSON + HTML в указанную директорию для просмотра в Homepage (iframe).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
try:
|
||||
import telnetlib
|
||||
except ImportError:
|
||||
telnetlib = None # удалён в Python 3.13+
|
||||
from datetime import datetime, timezone
|
||||
from ipaddress import IPv4Address, ip_network
|
||||
from pathlib import Path
|
||||
|
||||
# Второй прыжок traceroute = 10.8.1.0 означает туннель AmneziaWG (VPN)
|
||||
VPN_HOP_IP = "10.8.1.0"
|
||||
VPN_IN_FIRST_N_HOPS = 10 # в первых N прыжках ищем VPN-шлюз
|
||||
TRACEROUTE_MAX_HOPS = 15 # больше прыжков — видим VPN до того, как хост перестаёт отвечать (Яндекс, Mail и др.)
|
||||
TRACEROUTE_TIMEOUT = 25 # секунд на один домен
|
||||
|
||||
# Роутер: откуда брать реальный маршрут (VPN vs провайдер). Либо SSH, либо Telnet (NDMS/Keenetic).
|
||||
ROUTER_HOST = os.environ.get("ROUTER_HOST", "").strip() # 192.168.1.1
|
||||
ROUTER_SSH_USER = os.environ.get("ROUTER_SSH_USER", "root")
|
||||
ROUTER_SSH_TIMEOUT = 8
|
||||
# Telnet (если на роутере нет SSH, только telnet)
|
||||
ROUTER_TELNET_HOST = os.environ.get("ROUTER_TELNET_HOST", "").strip()
|
||||
ROUTER_TELNET_USER = os.environ.get("ROUTER_TELNET_USER", "admin")
|
||||
ROUTER_TELNET_PASSWORD = os.environ.get("ROUTER_TELNET_PASSWORD", "")
|
||||
ROUTER_TELNET_TIMEOUT = 15
|
||||
# По какой настройке опрашивать роутер (если задан TELNET — используем telnet)
|
||||
ROUTER_USE_TELNET = bool(ROUTER_TELNET_HOST and ROUTER_TELNET_PASSWORD)
|
||||
|
||||
|
||||
def load_domains_grouped(path: Path) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Читает domains.txt с секциями [Россия] и [Зарубеж].
|
||||
Возвращает список пар (название_группы, домен).
|
||||
"""
|
||||
out: list[tuple[str, str]] = []
|
||||
current_group = "Прочее"
|
||||
if not path.exists():
|
||||
return out
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
current_group = line[1:-1].strip()
|
||||
continue
|
||||
out.append((current_group, line))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_domain(domain: str) -> str | None:
|
||||
"""Возвращает один IPv4-адрес для домена или None."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dig", "+short", "-4", domain],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
lines = [s.strip() for s in r.stdout.splitlines() if s.strip()]
|
||||
for line in lines:
|
||||
# первый похожий на IPv4
|
||||
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", line):
|
||||
return line
|
||||
return None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def traceroute_hop2(ip: str) -> tuple[str, list[str]]:
|
||||
"""
|
||||
Запускает traceroute до ip с max TRACEROUTE_MAX_HOPS прыжками.
|
||||
Возвращает ("VPN" | "Ethernet" | "unknown", список IP прыжков).
|
||||
"""
|
||||
hops: list[str] = []
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["traceroute", "-m", str(TRACEROUTE_MAX_HOPS), "-n", ip],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=TRACEROUTE_TIMEOUT,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return "unknown", []
|
||||
|
||||
# Парсим строки вида " 2 10.8.1.0 20.5 ms" или " 2 * * *"
|
||||
for line in r.stdout.splitlines():
|
||||
# номер_прыжка IP время...
|
||||
m = re.match(r"\s*\d+\s+([*\d.]+)", line)
|
||||
if m:
|
||||
hop = m.group(1).strip()
|
||||
if hop != "*":
|
||||
hops.append(hop)
|
||||
|
||||
first_hops = hops[:VPN_IN_FIRST_N_HOPS]
|
||||
if VPN_HOP_IP in first_hops:
|
||||
return "VPN", hops
|
||||
if len(hops) >= 2:
|
||||
return "Ethernet", hops
|
||||
# Один прыжок: хост (часто Google/CDN) не отвечает на traceroute по пути — виден только последний узел
|
||||
if len(hops) == 1:
|
||||
return "few_hops", hops
|
||||
return "unknown", hops
|
||||
|
||||
|
||||
def route_get_path(ip: str) -> str | None:
|
||||
"""
|
||||
Определяет маршрут до IP через «ip route get» на этом хосте.
|
||||
Возвращает "VPN", "Ethernet" или None при ошибке.
|
||||
На LAN-клиенте всегда виден только шлюз 192.168.1.1 — для реального маршрута настрой ROUTER_HOST.
|
||||
"""
|
||||
for ip_bin in ("/usr/sbin/ip", "/sbin/ip", "ip"):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[ip_bin, "route", "get", ip],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
out = (r.stdout or "") + (r.stderr or "")
|
||||
if not out.strip():
|
||||
continue
|
||||
if "via " + VPN_HOP_IP in out or "via " + VPN_HOP_IP + " " in out:
|
||||
return "VPN"
|
||||
return "Ethernet"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def route_get_path_via_router_ssh(ip: str) -> str | None:
|
||||
"""Выполняет на роутере «ip route get <ip» по SSH. Возвращает "VPN", "Ethernet" или None."""
|
||||
if not ROUTER_HOST:
|
||||
return None
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=" + str(ROUTER_SSH_TIMEOUT),
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
f"{ROUTER_SSH_USER}@{ROUTER_HOST}",
|
||||
f"ip route get {ip}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=ROUTER_SSH_TIMEOUT + 2,
|
||||
)
|
||||
out = (r.stdout or "") + (r.stderr or "")
|
||||
if not out.strip():
|
||||
return None
|
||||
if "via " + VPN_HOP_IP in out or "via " + VPN_HOP_IP + " " in out:
|
||||
return "VPN"
|
||||
return "Ethernet"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_router_routes_telnet() -> list[tuple[str, str, str]] | None:
|
||||
"""
|
||||
Подключается к роутеру по Telnet (NDMS/Keenetic), логин, выполняет «show ip route»,
|
||||
парсит таблицу. Возвращает список (network_cidr, gateway, interface) или None.
|
||||
"""
|
||||
if not ROUTER_USE_TELNET or telnetlib is None:
|
||||
return None
|
||||
try:
|
||||
tn = telnetlib.Telnet(ROUTER_TELNET_HOST, 23, timeout=ROUTER_TELNET_TIMEOUT)
|
||||
tn.read_until(b"Login:", timeout=8)
|
||||
tn.write(ROUTER_TELNET_USER.encode() + b"\n")
|
||||
tn.read_until(b"Password:", timeout=5)
|
||||
tn.write(ROUTER_TELNET_PASSWORD.encode() + b"\n")
|
||||
tn.read_until(b">", timeout=8)
|
||||
tn.write(b"show ip route\n")
|
||||
raw = tn.read_until(b"(config)>", timeout=15)
|
||||
tn.close()
|
||||
except (OSError, EOFError) as e:
|
||||
return None
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
routes: list[tuple[str, str, str]] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
# Строка вида "216.58.192.0/20 10.8.1.2 Wireguard0 ..."
|
||||
if "/" not in line or line.startswith("("):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
net, gw, iface = parts[0], parts[1], parts[2]
|
||||
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$", net):
|
||||
routes.append((net, gw, iface))
|
||||
return routes if routes else None
|
||||
|
||||
|
||||
def find_route_for_ip(router_routes: list[tuple[str, str, str]], ip: str) -> str | None:
|
||||
"""
|
||||
По таблице маршрутов роутера (network, gateway, interface) находит маршрут для IP
|
||||
(longest prefix match). Возвращает "VPN" если шлюз 10.8.1.x или интерфейс Wireguard0, иначе "Ethernet".
|
||||
"""
|
||||
try:
|
||||
addr = IPv4Address(ip)
|
||||
except ValueError:
|
||||
return None
|
||||
best: tuple[int, str, str] | None = None # (prefix_len, gateway, interface)
|
||||
for net_cidr, gw, iface in router_routes:
|
||||
try:
|
||||
net = ip_network(net_cidr, strict=False)
|
||||
if addr in net:
|
||||
plen = net.prefixlen
|
||||
if best is None or plen > best[0]:
|
||||
best = (plen, gw, iface)
|
||||
except ValueError:
|
||||
continue
|
||||
if best is None:
|
||||
return None
|
||||
_plen, gateway, interface = best
|
||||
if gateway.startswith("10.8.1.") or "Wireguard" in interface:
|
||||
return "VPN"
|
||||
return "Ethernet"
|
||||
|
||||
|
||||
def check_domain(domain: str, router_routes: list[tuple[str, str, str]] | None = None) -> dict:
|
||||
ip = resolve_domain(domain)
|
||||
if not ip:
|
||||
return {
|
||||
"domain": domain,
|
||||
"ip": None,
|
||||
"path": "error",
|
||||
"path_label": "Ошибка резолва",
|
||||
"hops": [],
|
||||
}
|
||||
path, hops = traceroute_hop2(ip)
|
||||
# Если traceroute дал мало данных или неизвестно — смотрим маршрут: таблица с роутера (telnet) или SSH/локальный ip route get.
|
||||
if path in ("few_hops", "unknown"):
|
||||
if router_routes is not None:
|
||||
route_path = find_route_for_ip(router_routes, ip)
|
||||
hop_note = "(router)"
|
||||
elif ROUTER_HOST:
|
||||
route_path = route_get_path_via_router_ssh(ip)
|
||||
hop_note = "(router)"
|
||||
else:
|
||||
route_path = route_get_path(ip)
|
||||
hop_note = "(ip route get)"
|
||||
if route_path is not None:
|
||||
path = route_path
|
||||
hops = hops + [hop_note] if hops else [hop_note]
|
||||
path_labels = {
|
||||
"VPN": "VPN",
|
||||
"Ethernet": "Ethernet",
|
||||
"few_hops": "Мало данных (1 прыжок)",
|
||||
"unknown": "Неизвестно",
|
||||
}
|
||||
return {
|
||||
"domain": domain,
|
||||
"ip": ip,
|
||||
"path": path,
|
||||
"path_label": path_labels.get(path, path),
|
||||
"hops": hops,
|
||||
}
|
||||
|
||||
|
||||
def _row_html(r: dict) -> str:
|
||||
path = r.get("path") or "unknown"
|
||||
if path == "VPN":
|
||||
badge = '<span class="badge vpn">VPN</span>'
|
||||
elif path == "Ethernet":
|
||||
badge = '<span class="badge ethernet">Ethernet</span>'
|
||||
elif path == "error":
|
||||
badge = '<span class="badge error">Ошибка</span>'
|
||||
elif path == "few_hops":
|
||||
badge = '<span class="badge unknown" title="Хост не отвечает на traceroute по пути, виден только 1 прыжок">Мало данных</span>'
|
||||
else:
|
||||
badge = '<span class="badge unknown">?</span>'
|
||||
ip = r.get("ip") or "—"
|
||||
hops_s = ", ".join(r.get("hops") or []) or "—"
|
||||
return f"<tr><td>{r['domain']}</td><td>{ip}</td><td>{badge}</td><td class=\"hops\">{hops_s}</td></tr>"
|
||||
|
||||
|
||||
def build_html(grouped_results: dict[str, list[dict]], gen_time: str, out_path: Path | None) -> str:
|
||||
sections_html = []
|
||||
for group_name, results in grouped_results.items():
|
||||
if not results:
|
||||
continue
|
||||
rows = [_row_html(r) for r in results]
|
||||
table_body = "\n".join(rows)
|
||||
sections_html.append(
|
||||
f" <h2 class=\"group-title\">{group_name}</h2>\n"
|
||||
f" <table>\n"
|
||||
f" <thead><tr><th>Домен</th><th>IP</th><th>Маршрут</th><th>Прыжки</th></tr></thead>\n"
|
||||
f" <tbody>\n{table_body}\n </tbody>\n </table>"
|
||||
)
|
||||
blocks = "\n\n".join(sections_html)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="900">
|
||||
<title>Маршрут к доменам (VPN / Ethernet)</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, sans-serif; margin: 1rem; background: #1a1a2e; color: #eee; }}
|
||||
h1 {{ font-size: 1.2rem; }}
|
||||
.meta {{ color: #888; font-size: 0.85rem; margin-bottom: 1rem; }}
|
||||
.group-title {{ font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #b8d4e3; }}
|
||||
.group-title:first-of-type {{ margin-top: 0; }}
|
||||
table {{ border-collapse: collapse; width: 100%; font-size: 0.9rem; max-width: 720px; margin-bottom: 1rem; }}
|
||||
th, td {{ border: 1px solid #444; padding: 0.4rem 0.6rem; text-align: left; }}
|
||||
th {{ background: #16213e; }}
|
||||
tr:nth-child(even) {{ background: #0f0f1a; }}
|
||||
.badge {{ display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; font-weight: 600; font-size: 0.8rem; }}
|
||||
.badge.vpn {{ background: #0d7377; color: #fff; }}
|
||||
.badge.ethernet {{ background: #3d5a80; color: #fff; }}
|
||||
.badge.unknown {{ background: #555; color: #ccc; }}
|
||||
.badge.error {{ background: #9e2b2b; color: #fff; }}
|
||||
.hops {{ font-size: 0.8rem; color: #aaa; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Маршрут к доменам</h1>
|
||||
<p class="meta">Трафик через VPN (10.8.1.0) или напрямую (Ethernet). Обновлено: {gen_time}</p>
|
||||
|
||||
{blocks}
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
if out_path:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(html, encoding="utf-8")
|
||||
return html
|
||||
|
||||
|
||||
def main():
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
domains_path = script_dir / "domains.txt"
|
||||
out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else script_dir / "output"
|
||||
out_dir = Path(out_dir)
|
||||
|
||||
grouped_domains = load_domains_grouped(domains_path)
|
||||
if not grouped_domains:
|
||||
grouped_domains = [("Зарубеж", "youtube.com"), ("Зарубеж", "instagram.com"), ("Зарубеж", "google.com")]
|
||||
|
||||
router_routes: list[tuple[str, str, str]] | None = None
|
||||
if ROUTER_USE_TELNET:
|
||||
router_routes = fetch_router_routes_telnet()
|
||||
|
||||
grouped_results: dict[str, list[dict]] = {}
|
||||
all_results = []
|
||||
for group_name, domain in grouped_domains:
|
||||
r = check_domain(domain, router_routes)
|
||||
r["group"] = group_name
|
||||
all_results.append(r)
|
||||
grouped_results.setdefault(group_name, []).append(r)
|
||||
|
||||
gen_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
payload = {"updated": gen_time, "results": all_results}
|
||||
|
||||
json_path = out_dir / "results.json"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
html_path = out_dir / "index.html"
|
||||
build_html(grouped_results, gen_time, html_path)
|
||||
|
||||
total = len(all_results)
|
||||
print(f"OK: {total} доменов → {html_path} / {json_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
homelab/vpn-route-check/deploy-on-proxmox.sh
Executable file
18
homelab/vpn-route-check/deploy-on-proxmox.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Запускать на хосте Proxmox (ssh root@192.168.1.150).
|
||||
# Копирует папку vpn-route-check в контейнер 100 и поднимает Docker.
|
||||
set -e
|
||||
CT=100
|
||||
SRC="$(cd "$(dirname "$0")" && pwd)"
|
||||
echo "Источник: $SRC"
|
||||
echo "Копирую в контейнер $CT..."
|
||||
TAR="/tmp/vpn-route-check.tar"
|
||||
tar -C "$SRC" -cf "$TAR" .
|
||||
pct exec "$CT" -- mkdir -p /opt/docker/vpn-route-check
|
||||
pct push "$CT" "$TAR" /tmp/vpn-route-check.tar
|
||||
pct exec "$CT" -- tar -xf /tmp/vpn-route-check.tar -C /opt/docker/vpn-route-check
|
||||
pct exec "$CT" -- rm -f /tmp/vpn-route-check.tar
|
||||
rm -f "$TAR"
|
||||
echo "Запуск Docker Compose в контейнере $CT..."
|
||||
pct exec "$CT" -- bash -c 'cd /opt/docker/vpn-route-check && docker compose up -d --build'
|
||||
echo "Готово. Сервис: http://192.168.1.100:8765"
|
||||
17
homelab/vpn-route-check/docker-compose.yml
Normal file
17
homelab/vpn-route-check/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
vpn-route-check:
|
||||
build: .
|
||||
container_name: vpn-route-check
|
||||
network_mode: host
|
||||
environment:
|
||||
ROUTER_TELNET_HOST: "192.168.1.1"
|
||||
ROUTER_TELNET_USER: "admin"
|
||||
ROUTER_TELNET_PASSWORD: "eC1cLwZPRoDVEY1"
|
||||
volumes:
|
||||
- vpn-route-check-data:/data
|
||||
# опционально: свой список доменов с хоста
|
||||
# - ./domains.txt:/app/domains.txt:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
vpn-route-check-data:
|
||||
56
homelab/vpn-route-check/domains.txt
Normal file
56
homelab/vpn-route-check/domains.txt
Normal file
@@ -0,0 +1,56 @@
|
||||
# Секции: [Россия] и [Зарубеж]. Домены по одному на строку; # — комментарий.
|
||||
|
||||
[Россия]
|
||||
ozon.ru
|
||||
wildberries.ru
|
||||
aliexpress.ru
|
||||
kinopoisk.ru
|
||||
ivi.ru
|
||||
smotrim.ru
|
||||
ria.ru
|
||||
tass.ru
|
||||
lenta.ru
|
||||
gazeta.ru
|
||||
vk.com
|
||||
ok.ru
|
||||
my.mail.ru
|
||||
yandex.ru
|
||||
ya.ru
|
||||
mail.ru
|
||||
sberbank.ru
|
||||
tinkoff.ru
|
||||
alfabank.ru
|
||||
vtb.ru
|
||||
gosuslugi.ru
|
||||
www.gosuslugi.ru
|
||||
hh.ru
|
||||
katykhin.ru
|
||||
|
||||
[Зарубеж]
|
||||
facebook.com
|
||||
instagram.com
|
||||
twitter.com
|
||||
x.com
|
||||
threads.net
|
||||
reddit.com
|
||||
tiktok.com
|
||||
youtube.com
|
||||
youtu.be
|
||||
openai.com
|
||||
chat.openai.com
|
||||
claude.ai
|
||||
anthropic.com
|
||||
gemini.google.com
|
||||
huggingface.co
|
||||
midjourney.com
|
||||
leonardo.ai
|
||||
aistudio.google.com
|
||||
ai.google.dev
|
||||
telegram.org
|
||||
discord.com
|
||||
signal.org
|
||||
whatsapp.com
|
||||
github.com
|
||||
gitlab.com
|
||||
stackoverflow.com
|
||||
npmjs.com
|
||||
11
homelab/vpn-route-check/entrypoint.sh
Executable file
11
homelab/vpn-route-check/entrypoint.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Сначала поднимаем HTTP, чтобы страница была доступна даже при сбое проверки
|
||||
python3 /app/serve_no_cache.py &
|
||||
# Первый запуск проверки (при ошибке не выходим — сервер уже слушает)
|
||||
python3 /app/check_routes.py /data || true
|
||||
# Каждые 15 минут обновляем данные
|
||||
while true; do
|
||||
sleep 900
|
||||
python3 /app/check_routes.py /data || true
|
||||
done
|
||||
7
homelab/vpn-route-check/homepage-widget.yaml
Normal file
7
homelab/vpn-route-check/homepage-widget.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# Фрагмент для /opt/docker/homepage/config/services.yaml (карточка как «Обращения (логи NPM)»: ссылка + пинг, без iframe)
|
||||
|
||||
- Маршруты VPN:
|
||||
icon: shield-check.png
|
||||
href: http://192.168.1.100:8765
|
||||
description: "Проверка: трафик к доменам через VPN или Ethernet (обновление каждые 15 мин)"
|
||||
ping: http://192.168.1.100:8765
|
||||
22
homelab/vpn-route-check/serve_no_cache.py
Normal file
22
homelab/vpn-route-check/serve_no_cache.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Минимальный HTTP-сервер для /data с заголовками no-cache, чтобы браузер не кэшировал страницу."""
|
||||
import http.server
|
||||
import os
|
||||
|
||||
DIR = os.environ.get("SERVE_DIR", "/data")
|
||||
PORT = int(os.environ.get("SERVE_PORT", "8765"))
|
||||
|
||||
|
||||
class NoCacheHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=DIR, **kwargs)
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("Expires", "0")
|
||||
super().end_headers()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
http.server.HTTPServer(("", PORT), NoCacheHandler).serve_forever()
|
||||
Reference in New Issue
Block a user