fix: имя роутера в сообщении об ошибке подключения
This commit is contained in:
+351
-136
@@ -1,21 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MikroTik IP Monitor — читает адреса из /ip firewall address-list,
|
||||
пингует их через MikroTik API и шлёт уведомления в Telegram.
|
||||
пингует параллельно, шлёт уведомления в Telegram и MAX (VK Teams).
|
||||
|
||||
Возможности:
|
||||
- Параллельный пинг всех хостов
|
||||
- Повторные уведомления о даунтайме
|
||||
- Время даунтайма при восстановлении
|
||||
- Ежедневный отчёт
|
||||
- Команды /status, /help через Telegram и MAX
|
||||
- Веб-дашборд на порту 8080
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
import routeros_api
|
||||
import httpx
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Настройки из переменных окружения
|
||||
# Настройки
|
||||
# ──────────────────────────────────────────────
|
||||
MIKROTIK_HOST = os.environ["MIKROTIK_HOST"]
|
||||
MIKROTIK_PORT = int(os.getenv("MIKROTIK_PORT", "8728"))
|
||||
@@ -26,18 +36,18 @@ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org")
|
||||
|
||||
# MAX (VK Teams) — опционально, если не заданы — уведомления не отправляются
|
||||
MAX_BOT_TOKEN = os.getenv("MAX_BOT_TOKEN", "")
|
||||
MAX_CHAT_ID = os.getenv("MAX_CHAT_ID", "")
|
||||
MAX_API_URL = os.getenv("MAX_API_URL", "https://platform-api.max.ru")
|
||||
|
||||
# Имя address-list в MikroTik (/ip firewall address-list)
|
||||
ADDRESS_LIST_NAME = os.environ["ADDRESS_LIST_NAME"]
|
||||
|
||||
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60"))
|
||||
PING_COUNT = int(os.getenv("PING_COUNT", "3"))
|
||||
PING_TIMEOUT_MS = int(os.getenv("PING_TIMEOUT_MS", "1000"))
|
||||
FAILURE_THRESHOLD = int(os.getenv("FAILURE_THRESHOLD", "2"))
|
||||
REPEAT_NOTIFY_MIN = int(os.getenv("REPEAT_NOTIFY_MIN", "30"))
|
||||
DAILY_REPORT_TIME = os.getenv("DAILY_REPORT_TIME", "09:00")
|
||||
WEB_PORT = int(os.getenv("WEB_PORT", "8080"))
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Логирование
|
||||
@@ -49,9 +59,20 @@ logging.basicConfig(
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Глобальное состояние
|
||||
# ──────────────────────────────────────────────
|
||||
hosts_state: dict[str, dict] = {}
|
||||
router_identity: str = MIKROTIK_HOST
|
||||
last_check_time: Optional[datetime] = None
|
||||
daily_report_sent_date: Optional[str] = None
|
||||
_tg_bot_username: str = ""
|
||||
tg_last_update_id: int = 0
|
||||
max_last_marker: int = 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Telegram
|
||||
# Уведомления
|
||||
# ──────────────────────────────────────────────
|
||||
async def send_telegram(message: str) -> None:
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
@@ -61,57 +82,41 @@ async def send_telegram(message: str) -> None:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.post(url, data=payload)
|
||||
log.info("Telegram response %s: %s", resp.status_code, resp.text)
|
||||
resp.raise_for_status()
|
||||
log.info("Telegram: сообщение отправлено")
|
||||
log.info("Telegram: отправлено")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
log.error("Telegram: ошибка %s — %s", exc.response.status_code, exc.response.text)
|
||||
log.error("Telegram: %s — %s", exc.response.status_code, exc.response.text)
|
||||
except Exception as exc:
|
||||
log.error("Telegram: ошибка отправки — %s", exc)
|
||||
log.error("Telegram: %s", exc)
|
||||
|
||||
|
||||
async def send_max(message: str) -> None:
|
||||
"""Отправляет уведомление в MAX (VK Teams)."""
|
||||
if not MAX_BOT_TOKEN or not MAX_CHAT_ID:
|
||||
return
|
||||
import re
|
||||
# chat_id и user_id — query-параметры, не тело запроса
|
||||
params = {"chat_id": MAX_CHAT_ID}
|
||||
headers = {
|
||||
"Authorization": MAX_BOT_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
# MAX поддерживает HTML — оставляем форматирование
|
||||
headers = {"Authorization": MAX_BOT_TOKEN, "Content-Type": "application/json"}
|
||||
payload = {"text": message, "format": "html"}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{MAX_API_URL}/messages",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=payload,
|
||||
headers=headers, params={"chat_id": MAX_CHAT_ID}, json=payload,
|
||||
)
|
||||
log.info("MAX response %s: %s", resp.status_code, resp.text)
|
||||
resp.raise_for_status()
|
||||
log.info("MAX: сообщение отправлено")
|
||||
log.info("MAX: отправлено")
|
||||
except Exception as exc:
|
||||
log.error("MAX: ошибка отправки — %s", exc)
|
||||
log.error("MAX: %s", exc)
|
||||
|
||||
|
||||
def notify(message: str) -> None:
|
||||
async def _send_all():
|
||||
await asyncio.gather(
|
||||
send_telegram(message),
|
||||
send_max(message),
|
||||
)
|
||||
asyncio.run(_send_all())
|
||||
async def _all():
|
||||
await asyncio.gather(send_telegram(message), send_max(message))
|
||||
asyncio.run(_all())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# MikroTik: получить список IP из address-list
|
||||
# MikroTik утилиты
|
||||
# ──────────────────────────────────────────────
|
||||
def decode(val) -> str:
|
||||
"""Декодирует bytes или str. Пробует UTF-8, затем Windows-1251."""
|
||||
if isinstance(val, bytes):
|
||||
try:
|
||||
return val.decode("utf-8")
|
||||
@@ -121,18 +126,9 @@ def decode(val) -> str:
|
||||
|
||||
|
||||
def fetch_address_list(api) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Читает /ip firewall address-list и возвращает список (ip, comment)
|
||||
из записей с именем ADDRESS_LIST_NAME (пропускает отключённые).
|
||||
"""
|
||||
resource = api.get_binary_resource("/ip/firewall/address-list")
|
||||
all_entries = resource.get()
|
||||
|
||||
ip_list = []
|
||||
for entry in all_entries:
|
||||
# Ключи строковые, значения — bytes
|
||||
entry_list = decode(entry.get("list", b""))
|
||||
if entry_list != ADDRESS_LIST_NAME:
|
||||
for entry in api.get_binary_resource("/ip/firewall/address-list").get():
|
||||
if decode(entry.get("list", b"")) != ADDRESS_LIST_NAME:
|
||||
continue
|
||||
if decode(entry.get("disabled", b"false")) == "true":
|
||||
continue
|
||||
@@ -142,31 +138,18 @@ def fetch_address_list(api) -> list[tuple[str, str]]:
|
||||
comment = decode(entry.get("comment", b"")).strip()
|
||||
if address:
|
||||
ip_list.append((address, comment))
|
||||
|
||||
return ip_list
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# MikroTik: пинг через API
|
||||
# ──────────────────────────────────────────────
|
||||
def mikrotik_ping(api, address: str) -> Optional[float]:
|
||||
"""
|
||||
Возвращает avg-rtt (мс) или None если хост недоступен.
|
||||
RouterOS API: /ping — это команда, вызывается через get_binary_resource.
|
||||
"""
|
||||
def fetch_identity(api) -> str:
|
||||
try:
|
||||
binary_resource = api.get_binary_resource("/")
|
||||
results = binary_resource.call(
|
||||
"ping",
|
||||
{
|
||||
"address": address.encode(),
|
||||
"count": str(PING_COUNT).encode(),
|
||||
"interval": b"0.2",
|
||||
},
|
||||
)
|
||||
result = api.get_binary_resource("/system/identity").get()
|
||||
return decode(result[0].get("name", MIKROTIK_HOST.encode())) if result else MIKROTIK_HOST
|
||||
except Exception:
|
||||
return MIKROTIK_HOST
|
||||
|
||||
|
||||
def parse_rtt(val) -> Optional[float]:
|
||||
"""Парсит '3ms674us', '2ms', '500us' → миллисекунды (float)."""
|
||||
if isinstance(val, bytes):
|
||||
val = val.decode()
|
||||
val = val.strip()
|
||||
@@ -192,55 +175,260 @@ def mikrotik_ping(api, address: str) -> Optional[float]:
|
||||
total = ms + us / 1000.0
|
||||
return total if total > 0 else None
|
||||
|
||||
rtts = []
|
||||
for row in results:
|
||||
# Берём avg-rtt из последней строки (она накопительная)
|
||||
rtt = parse_rtt(row.get("avg-rtt", ""))
|
||||
if rtt is not None:
|
||||
rtts.append(rtt)
|
||||
|
||||
if rtts:
|
||||
return rtts[-1] # последняя строка содержит финальный avg
|
||||
return None
|
||||
|
||||
except Exception as exc:
|
||||
log.warning("Ошибка пинга %s: %s", address, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# MikroTik: получить system identity
|
||||
# ──────────────────────────────────────────────
|
||||
def fetch_identity(api) -> str:
|
||||
def mikrotik_ping(api, address: str) -> Optional[float]:
|
||||
try:
|
||||
resource = api.get_binary_resource("/system/identity")
|
||||
result = resource.get()
|
||||
if result:
|
||||
return decode(result[0].get("name", MIKROTIK_HOST.encode()))
|
||||
return MIKROTIK_HOST
|
||||
except Exception:
|
||||
return MIKROTIK_HOST
|
||||
results = api.get_binary_resource("/").call(
|
||||
"ping",
|
||||
{"address": address.encode(), "count": str(PING_COUNT).encode(), "interval": b"0.2"},
|
||||
)
|
||||
rtts = [r for r in (parse_rtt(row.get("avg-rtt", "")) for row in results) if r is not None]
|
||||
return rtts[-1] if rtts else None
|
||||
except Exception as exc:
|
||||
log.warning("Пинг %s: %s", address, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Параллельный пинг
|
||||
# ──────────────────────────────────────────────
|
||||
async def ping_all_async(api, ip_list: list[tuple[str, str]]) -> dict[str, Optional[float]]:
|
||||
loop = asyncio.get_event_loop()
|
||||
tasks = {ip: loop.run_in_executor(None, mikrotik_ping, api, ip) for ip, _ in ip_list}
|
||||
return {ip: await task for ip, task in tasks.items()}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Форматирование
|
||||
# ──────────────────────────────────────────────
|
||||
def fmt_duration(td: timedelta) -> str:
|
||||
total = int(td.total_seconds())
|
||||
if total < 60:
|
||||
return f"{total} сек"
|
||||
elif total < 3600:
|
||||
return f"{total // 60} мин"
|
||||
else:
|
||||
h, m = divmod(total // 60, 60)
|
||||
return f"{h} ч {m} мин" if m else f"{h} ч"
|
||||
|
||||
|
||||
def fmt_label(ip: str) -> str:
|
||||
comment = hosts_state.get(ip, {}).get("comment", "")
|
||||
return f"{ip} ({comment})" if comment else ip
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Статус-отчёт
|
||||
# ──────────────────────────────────────────────
|
||||
def build_status_report(title: str) -> str:
|
||||
now = datetime.now()
|
||||
lines = [f"<b>{title}</b>", f"🔧 Роутер: <b>{router_identity}</b>", ""]
|
||||
down = [ip for ip, s in hosts_state.items() if s["status"] == "down"]
|
||||
up = [ip for ip, s in hosts_state.items() if s["status"] == "up"]
|
||||
|
||||
if down:
|
||||
lines.append(f"🔴 <b>Недоступны ({len(down)}):</b>")
|
||||
for ip in down:
|
||||
ds = hosts_state[ip].get("down_since")
|
||||
dur = fmt_duration(now - ds) if ds else "?"
|
||||
lines.append(f" • <code>{fmt_label(ip)}</code> — {dur}")
|
||||
lines.append("")
|
||||
if up:
|
||||
lines.append(f"✅ <b>Доступны ({len(up)}):</b>")
|
||||
for ip in up:
|
||||
rtt = hosts_state[ip].get("rtt")
|
||||
lines.append(f" • <code>{fmt_label(ip)}</code>" + (f" {rtt:.1f} ms" if rtt else ""))
|
||||
|
||||
lines += ["", f"⏱ {now.strftime('%Y-%m-%d %H:%M:%S')}"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Команды ботов
|
||||
# ──────────────────────────────────────────────
|
||||
async def process_telegram_commands() -> None:
|
||||
global tg_last_update_id, _tg_bot_username
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{TELEGRAM_API_URL}/bot{TELEGRAM_BOT_TOKEN}/getUpdates",
|
||||
params={"timeout": 1, "offset": tg_last_update_id + 1},
|
||||
)
|
||||
data = resp.json()
|
||||
for update in data.get("result", []):
|
||||
tg_last_update_id = update["update_id"]
|
||||
text = update.get("message", {}).get("text", "").strip().lower().split("@")[0]
|
||||
if text == "/status":
|
||||
await send_telegram(build_status_report("📊 Текущий статус"))
|
||||
elif text == "/help":
|
||||
await send_telegram(
|
||||
"🤖 <b>Доступные команды:</b>\n"
|
||||
"/status — статус всех хостов\n"
|
||||
"/help — это сообщение"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.debug("TG polling: %s", exc)
|
||||
|
||||
|
||||
async def process_max_commands() -> None:
|
||||
global max_last_marker
|
||||
if not MAX_BOT_TOKEN or not MAX_CHAT_ID:
|
||||
return
|
||||
params = {"access_token": MAX_BOT_TOKEN}
|
||||
if max_last_marker:
|
||||
params["marker"] = max_last_marker
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
try:
|
||||
resp = await client.get(f"{MAX_API_URL}/updates", params=params)
|
||||
data = resp.json()
|
||||
max_last_marker = data.get("marker", max_last_marker)
|
||||
for update in data.get("updates", []):
|
||||
if update.get("update_type") != "message_created":
|
||||
continue
|
||||
text = update.get("message", {}).get("body", {}).get("text", "").strip().lower()
|
||||
if text == "/status":
|
||||
await send_max(build_status_report("📊 Текущий статус"))
|
||||
elif text == "/help":
|
||||
await send_max(
|
||||
"🤖 <b>Доступные команды:</b>\n"
|
||||
"/status — статус всех хостов\n"
|
||||
"/help — это сообщение"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.debug("MAX polling: %s", exc)
|
||||
|
||||
|
||||
async def process_bot_commands() -> None:
|
||||
await asyncio.gather(process_telegram_commands(), process_max_commands())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Ежедневный отчёт
|
||||
# ──────────────────────────────────────────────
|
||||
def check_daily_report() -> None:
|
||||
global daily_report_sent_date
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
if now.strftime("%H:%M") == DAILY_REPORT_TIME and daily_report_sent_date != today:
|
||||
daily_report_sent_date = today
|
||||
notify(build_status_report(f"📅 Ежедневный отчёт — {today}"))
|
||||
log.info("Ежедневный отчёт отправлен")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Веб-дашборд
|
||||
# ──────────────────────────────────────────────
|
||||
class DashboardHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path not in ("/", "/status"):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
rows = ""
|
||||
for ip, state in hosts_state.items():
|
||||
comment = state.get("comment", "")
|
||||
status = state.get("status", "unknown")
|
||||
rtt = state.get("rtt")
|
||||
ds = state.get("down_since")
|
||||
ls = state.get("last_seen")
|
||||
|
||||
if status == "up":
|
||||
badge = '<span class="badge up">● UP</span>'
|
||||
rtt_cell = f"{rtt:.1f} ms" if rtt else "—"
|
||||
dur_cell = "—"
|
||||
elif status == "down":
|
||||
badge = '<span class="badge down">● DOWN</span>'
|
||||
rtt_cell = "—"
|
||||
dur_cell = fmt_duration(now - ds) if ds else "—"
|
||||
else:
|
||||
badge = '<span class="badge unknown">● ?</span>'
|
||||
rtt_cell = dur_cell = "—"
|
||||
|
||||
rows += f"""<tr class="{status}">
|
||||
<td><code>{ip}</code></td><td>{comment}</td><td>{badge}</td>
|
||||
<td>{rtt_cell}</td><td>{dur_cell}</td>
|
||||
<td>{ls.strftime('%H:%M:%S') if ls else '—'}</td></tr>"""
|
||||
|
||||
total = len(hosts_state)
|
||||
up_count = sum(1 for s in hosts_state.values() if s.get("status") == "up")
|
||||
down_count= sum(1 for s in hosts_state.values() if s.get("status") == "down")
|
||||
last_str = last_check_time.strftime("%Y-%m-%d %H:%M:%S") if last_check_time else "—"
|
||||
|
||||
html = f"""<!DOCTYPE html><html lang="ru"><head>
|
||||
<meta charset="UTF-8"><meta http-equiv="refresh" content="{CHECK_INTERVAL}">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>MikroTik Monitor — {router_identity}</title>
|
||||
<style>
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e0e0e0;padding:24px}}
|
||||
h1{{font-size:1.4rem;color:#fff;margin-bottom:4px}}
|
||||
.sub{{color:#888;font-size:.85rem;margin-bottom:24px}}
|
||||
.cards{{display:flex;gap:16px;margin-bottom:24px;flex-wrap:wrap}}
|
||||
.card{{background:#1a1d27;border-radius:10px;padding:16px 24px;min-width:130px}}
|
||||
.card .num{{font-size:2rem;font-weight:700}}.card .lbl{{font-size:.8rem;color:#888;margin-top:2px}}
|
||||
.green{{color:#4caf50}}.red{{color:#f44336}}.gray{{color:#aaa}}
|
||||
table{{width:100%;border-collapse:collapse;background:#1a1d27;border-radius:10px;overflow:hidden}}
|
||||
th{{background:#22263a;color:#aaa;font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;padding:10px 14px;text-align:left}}
|
||||
td{{padding:10px 14px;font-size:.9rem;border-top:1px solid #22263a}}
|
||||
tr.down td{{background:rgba(244,67,54,.07)}}
|
||||
code{{background:#22263a;padding:2px 6px;border-radius:4px;font-size:.85rem}}
|
||||
.badge{{padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600}}
|
||||
.badge.up{{background:rgba(76,175,80,.15);color:#4caf50}}
|
||||
.badge.down{{background:rgba(244,67,54,.15);color:#f44336}}
|
||||
.badge.unknown{{background:rgba(158,158,158,.15);color:#9e9e9e}}
|
||||
.footer{{margin-top:16px;font-size:.78rem;color:#555}}
|
||||
</style></head><body>
|
||||
<h1>🔍 MikroTik Monitor</h1>
|
||||
<div class="sub">Роутер: <b>{router_identity}</b> ({MIKROTIK_HOST}) |
|
||||
List: <b>{ADDRESS_LIST_NAME}</b> | Проверка: {last_str}</div>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="num gray">{total}</div><div class="lbl">Всего</div></div>
|
||||
<div class="card"><div class="num green">{up_count}</div><div class="lbl">Доступны</div></div>
|
||||
<div class="card"><div class="num red">{down_count}</div><div class="lbl">Недоступны</div></div>
|
||||
</div>
|
||||
<table><thead><tr>
|
||||
<th>IP</th><th>Имя</th><th>Статус</th><th>RTT</th><th>Даунтайм</th><th>Последний ответ</th>
|
||||
</tr></thead><tbody>{rows}</tbody></table>
|
||||
<div class="footer">Автообновление каждые {CHECK_INTERVAL} сек</div>
|
||||
</body></html>"""
|
||||
|
||||
body = html.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def start_web_server() -> None:
|
||||
server = HTTPServer(("0.0.0.0", WEB_PORT), DashboardHandler)
|
||||
log.info("Веб-дашборд: http://0.0.0.0:%d", WEB_PORT)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Основной цикл
|
||||
# ──────────────────────────────────────────────
|
||||
def main() -> None:
|
||||
log.info("=== MikroTik Monitor запущен ===")
|
||||
log.info("Address-list: '%s'", ADDRESS_LIST_NAME)
|
||||
log.info("Интервал: %d сек", CHECK_INTERVAL)
|
||||
global router_identity, last_check_time
|
||||
|
||||
# Состояние хостов: счётчик ошибок и флаг DOWN
|
||||
# Обновляется динамически при каждом чтении address-list
|
||||
failure_count: dict[str, int] = {}
|
||||
host_down: dict[str, bool] = {}
|
||||
log.info("=== MikroTik Monitor запущен ===")
|
||||
log.info("Address-list: '%s' Интервал: %d сек", ADDRESS_LIST_NAME, CHECK_INTERVAL)
|
||||
|
||||
threading.Thread(target=start_web_server, daemon=True).start()
|
||||
|
||||
startup_notified = False
|
||||
|
||||
while True:
|
||||
pool = None
|
||||
try:
|
||||
log.info("Подключение к MikroTik %s:%d …", MIKROTIK_HOST, MIKROTIK_PORT)
|
||||
pool = routeros_api.RouterOsApiPool(
|
||||
MIKROTIK_HOST,
|
||||
username=MIKROTIK_USER,
|
||||
@@ -251,77 +439,104 @@ def main() -> None:
|
||||
api = pool.get_api()
|
||||
log.info("Подключено к MikroTik")
|
||||
|
||||
# Читаем актуальный список адресов с MikroTik
|
||||
ip_list = fetch_address_list(api)
|
||||
|
||||
# Стартовое сообщение — отправляем только после успешного подключения
|
||||
if not startup_notified:
|
||||
identity = fetch_identity(api)
|
||||
router_identity = fetch_identity(api)
|
||||
ip_lines = "\n".join(
|
||||
f" • <code>{ip}</code> ({comment})" if comment else f" • <code>{ip}</code>"
|
||||
for ip, comment in ip_list
|
||||
) or " (список пуст)"
|
||||
notify(
|
||||
f"🟢 <b>MikroTik Monitor запущен</b>\n"
|
||||
f"\n"
|
||||
f"🔧 Роутер: <b>{identity}</b> (<code>{MIKROTIK_HOST}</code>)\n"
|
||||
f"📋 Address-list: <code>{ADDRESS_LIST_NAME}</code>\n"
|
||||
f"\n"
|
||||
f"<b>Мониторинг адресов:</b>\n{ip_lines}\n"
|
||||
f"\n"
|
||||
f"🟢 <b>MikroTik Monitor запущен</b>\n\n"
|
||||
f"🔧 Роутер: <b>{router_identity}</b> (<code>{MIKROTIK_HOST}</code>)\n"
|
||||
f"📋 Address-list: <code>{ADDRESS_LIST_NAME}</code>\n\n"
|
||||
f"<b>Мониторинг адресов:</b>\n{ip_lines}\n\n"
|
||||
f"⏱ Интервал: {CHECK_INTERVAL} сек"
|
||||
)
|
||||
startup_notified = True
|
||||
|
||||
if not ip_list:
|
||||
log.warning("Address-list '%s' пуст или не найден!", ADDRESS_LIST_NAME)
|
||||
log.warning("Address-list '%s' пуст!", ADDRESS_LIST_NAME)
|
||||
else:
|
||||
log.info("Адресов для проверки: %d → %s", len(ip_list), ip_list)
|
||||
log.info("Адресов: %d", len(ip_list))
|
||||
|
||||
# Добавляем новые хосты в state (без сброса существующих)
|
||||
now = datetime.now()
|
||||
for ip, comment in ip_list:
|
||||
if ip not in failure_count:
|
||||
failure_count[ip] = 0
|
||||
host_down[ip] = False
|
||||
if ip not in hosts_state:
|
||||
hosts_state[ip] = {
|
||||
"comment": comment, "status": "unknown",
|
||||
"rtt": None, "down_since": None,
|
||||
"last_seen": None, "last_notify": None,
|
||||
}
|
||||
else:
|
||||
hosts_state[ip]["comment"] = comment
|
||||
|
||||
# Параллельный пинг
|
||||
ping_results = asyncio.run(ping_all_async(api, ip_list))
|
||||
last_check_time = datetime.now()
|
||||
now = last_check_time
|
||||
|
||||
# Пингуем каждый адрес
|
||||
for ip, comment in ip_list:
|
||||
label = f"{ip} ({comment})" if comment else ip
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
rtt = mikrotik_ping(api, ip)
|
||||
rtt = ping_results.get(ip)
|
||||
state = hosts_state[ip]
|
||||
label = fmt_label(ip)
|
||||
ts = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if rtt is not None:
|
||||
log.info("✅ %-28s avg-rtt=%.1f ms", label, rtt)
|
||||
if host_down[ip]:
|
||||
host_down[ip] = False
|
||||
failure_count[ip] = 0
|
||||
log.info("✅ %-34s %.1f ms", label, rtt)
|
||||
state["rtt"] = rtt
|
||||
state["last_seen"] = now
|
||||
|
||||
if state["status"] == "down":
|
||||
ds = state.get("down_since")
|
||||
dur = fmt_duration(now - ds) if ds else "?"
|
||||
state.update({"status": "up", "down_since": None, "last_notify": None})
|
||||
notify(
|
||||
f"✅ <b>Хост восстановился</b>\n"
|
||||
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + f"\n"
|
||||
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + "\n"
|
||||
f"RTT: {rtt:.1f} ms\n"
|
||||
f"Время: {now}"
|
||||
f"Даунтайм: {dur}\n"
|
||||
f"Время: {ts}"
|
||||
)
|
||||
else:
|
||||
failure_count[ip] = 0
|
||||
state["status"] = "up"
|
||||
|
||||
else:
|
||||
failure_count[ip] += 1
|
||||
log.warning(
|
||||
"❌ %-28s не отвечает (%d/%d)",
|
||||
label, failure_count[ip], FAILURE_THRESHOLD,
|
||||
)
|
||||
if failure_count[ip] >= FAILURE_THRESHOLD and not host_down[ip]:
|
||||
host_down[ip] = True
|
||||
log.warning("❌ %-34s нет ответа", label)
|
||||
state["rtt"] = None
|
||||
|
||||
if state["status"] != "down":
|
||||
state.update({"status": "down", "down_since": now, "last_notify": now})
|
||||
notify(
|
||||
f"🔴 <b>Хост недоступен!</b>\n"
|
||||
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + f"\n"
|
||||
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + "\n"
|
||||
f"Список: <code>{ADDRESS_LIST_NAME}</code>\n"
|
||||
f"Не отвечает {failure_count[ip]} проверки подряд\n"
|
||||
f"Время: {now}"
|
||||
f"Время: {ts}"
|
||||
)
|
||||
elif REPEAT_NOTIFY_MIN > 0 and state.get("last_notify"):
|
||||
mins = (now - state["last_notify"]).total_seconds() / 60
|
||||
if mins >= REPEAT_NOTIFY_MIN:
|
||||
ds = state.get("down_since")
|
||||
dur = fmt_duration(now - ds) if ds else "?"
|
||||
state["last_notify"] = now
|
||||
notify(
|
||||
f"🔴 <b>Хост всё ещё недоступен!</b>\n"
|
||||
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + "\n"
|
||||
f"Недоступен уже: {dur}\n"
|
||||
f"Время: {ts}"
|
||||
)
|
||||
|
||||
asyncio.run(process_bot_commands())
|
||||
check_daily_report()
|
||||
|
||||
except Exception as exc:
|
||||
log.error("Ошибка: %s", exc)
|
||||
notify(f"⚠️ <b>Ошибка подключения к MikroTik</b>\n<code>{exc}</code>")
|
||||
notify(
|
||||
f"⚠️ <b>Ошибка подключения к MikroTik</b>\n"
|
||||
f"🔧 Роутер: <b>{router_identity}</b> (<code>{MIKROTIK_HOST}</code>)\n"
|
||||
f"<code>{exc}</code>"
|
||||
)
|
||||
finally:
|
||||
if pool:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user