292 lines
12 KiB
Python
292 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MikroTik IP Monitor — читает адреса из /ip firewall address-list,
|
|
пингует их через MikroTik API и шлёт уведомления в Telegram.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import logging
|
|
import asyncio
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
import routeros_api
|
|
import httpx
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Настройки из переменных окружения
|
|
# ──────────────────────────────────────────────
|
|
MIKROTIK_HOST = os.environ["MIKROTIK_HOST"]
|
|
MIKROTIK_PORT = int(os.getenv("MIKROTIK_PORT", "8728"))
|
|
MIKROTIK_USER = os.environ["MIKROTIK_USER"]
|
|
MIKROTIK_PASSWORD = os.environ["MIKROTIK_PASSWORD"]
|
|
|
|
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
|
|
TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"]
|
|
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org")
|
|
|
|
# Имя 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"))
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Логирование
|
|
# ──────────────────────────────────────────────
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Telegram
|
|
# ──────────────────────────────────────────────
|
|
async def send_telegram(message: str) -> None:
|
|
url = f"{TELEGRAM_API_URL}/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
|
payload = {"chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "HTML"}
|
|
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: сообщение отправлено")
|
|
except httpx.HTTPStatusError as exc:
|
|
log.error("Telegram: ошибка %s — %s", exc.response.status_code, exc.response.text)
|
|
except Exception as exc:
|
|
log.error("Telegram: ошибка отправки — %s", exc)
|
|
|
|
|
|
def notify(message: str) -> None:
|
|
asyncio.run(send_telegram(message))
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# MikroTik: получить список IP из address-list
|
|
# ──────────────────────────────────────────────
|
|
def decode(val) -> str:
|
|
"""Декодирует bytes или str, игнорируя ошибки кодировки."""
|
|
if isinstance(val, bytes):
|
|
return val.decode("utf-8", errors="replace")
|
|
return str(val) if val else ""
|
|
|
|
|
|
def fetch_address_list(api) -> list[str]:
|
|
"""
|
|
Читает /ip firewall address-list и возвращает список IP
|
|
из записей с именем ADDRESS_LIST_NAME (пропускает отключённые).
|
|
"""
|
|
resource = api.get_binary_resource("/ip/firewall/address-list")
|
|
all_entries = resource.get()
|
|
|
|
log.info("DEBUG address-list raw: %s", all_entries)
|
|
|
|
ip_list = []
|
|
for entry in all_entries:
|
|
entry_list = decode(entry.get(b"list", b""))
|
|
log.info("DEBUG entry list='%s' address='%s'", entry_list, decode(entry.get(b"address", b"")))
|
|
if entry_list != ADDRESS_LIST_NAME:
|
|
continue
|
|
if decode(entry.get(b"disabled", b"false")) == "true":
|
|
continue
|
|
address = decode(entry.get(b"address", b"")).strip()
|
|
if "/" in address:
|
|
address = address.split("/")[0]
|
|
if address:
|
|
ip_list.append(address)
|
|
|
|
return ip_list
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# MikroTik: пинг через API
|
|
# ──────────────────────────────────────────────
|
|
def mikrotik_ping(api, address: str) -> Optional[float]:
|
|
"""
|
|
Возвращает avg-rtt (мс) или None если хост недоступен.
|
|
RouterOS API: /ping — это команда, вызывается через get_binary_resource.
|
|
"""
|
|
try:
|
|
binary_resource = api.get_binary_resource("/")
|
|
results = binary_resource.call(
|
|
"ping",
|
|
{
|
|
"address": address.encode(),
|
|
"count": str(PING_COUNT).encode(),
|
|
"interval": b"0.2",
|
|
},
|
|
)
|
|
|
|
def parse_rtt(val) -> Optional[float]:
|
|
"""Парсит '3ms674us', '2ms', '500us' → миллисекунды (float)."""
|
|
if isinstance(val, bytes):
|
|
val = val.decode()
|
|
val = val.strip()
|
|
if not val:
|
|
return None
|
|
ms, us = 0.0, 0.0
|
|
if "ms" in val:
|
|
parts = val.split("ms")
|
|
try:
|
|
ms = float(parts[0])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 1 and "us" in parts[1]:
|
|
try:
|
|
us = float(parts[1].replace("us", ""))
|
|
except ValueError:
|
|
pass
|
|
elif "us" in val:
|
|
try:
|
|
us = float(val.replace("us", ""))
|
|
except ValueError:
|
|
pass
|
|
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:
|
|
try:
|
|
resource = api.get_binary_resource("/system/identity")
|
|
result = resource.get()
|
|
if result:
|
|
return decode(result[0].get(b"name", MIKROTIK_HOST.encode()))
|
|
return MIKROTIK_HOST
|
|
except Exception:
|
|
return MIKROTIK_HOST
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Основной цикл
|
|
# ──────────────────────────────────────────────
|
|
def main() -> None:
|
|
log.info("=== MikroTik Monitor запущен ===")
|
|
log.info("Address-list: '%s'", ADDRESS_LIST_NAME)
|
|
log.info("Интервал: %d сек", CHECK_INTERVAL)
|
|
|
|
# Состояние хостов: счётчик ошибок и флаг DOWN
|
|
# Обновляется динамически при каждом чтении address-list
|
|
failure_count: dict[str, int] = {}
|
|
host_down: dict[str, bool] = {}
|
|
|
|
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,
|
|
password=MIKROTIK_PASSWORD,
|
|
port=MIKROTIK_PORT,
|
|
plaintext_login=True,
|
|
)
|
|
api = pool.get_api()
|
|
log.info("Подключено к MikroTik")
|
|
|
|
# Читаем актуальный список адресов с MikroTik
|
|
ip_list = fetch_address_list(api)
|
|
|
|
# Стартовое сообщение — отправляем только после успешного подключения
|
|
if not startup_notified:
|
|
identity = fetch_identity(api)
|
|
ip_lines = "\n".join(f" • <code>{ip}</code>" for ip 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"⏱ Интервал: {CHECK_INTERVAL} сек"
|
|
)
|
|
startup_notified = True
|
|
|
|
if not ip_list:
|
|
log.warning("Address-list '%s' пуст или не найден!", ADDRESS_LIST_NAME)
|
|
else:
|
|
log.info("Адресов для проверки: %d → %s", len(ip_list), ip_list)
|
|
|
|
# Добавляем новые хосты в state (без сброса существующих)
|
|
for ip in ip_list:
|
|
if ip not in failure_count:
|
|
failure_count[ip] = 0
|
|
host_down[ip] = False
|
|
|
|
# Пингуем каждый адрес
|
|
for ip in ip_list:
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
rtt = mikrotik_ping(api, ip)
|
|
|
|
if rtt is not None:
|
|
log.info("✅ %-18s avg-rtt=%.1f ms", ip, rtt)
|
|
if host_down[ip]:
|
|
host_down[ip] = False
|
|
failure_count[ip] = 0
|
|
notify(
|
|
f"✅ <b>Хост восстановился</b>\n"
|
|
f"IP: <code>{ip}</code>\n"
|
|
f"RTT: {rtt:.1f} ms\n"
|
|
f"Время: {now}"
|
|
)
|
|
else:
|
|
failure_count[ip] = 0
|
|
else:
|
|
failure_count[ip] += 1
|
|
log.warning(
|
|
"❌ %-18s не отвечает (%d/%d)",
|
|
ip, failure_count[ip], FAILURE_THRESHOLD,
|
|
)
|
|
if failure_count[ip] >= FAILURE_THRESHOLD and not host_down[ip]:
|
|
host_down[ip] = True
|
|
notify(
|
|
f"🔴 <b>Хост недоступен!</b>\n"
|
|
f"IP: <code>{ip}</code>\n"
|
|
f"Список: <code>{ADDRESS_LIST_NAME}</code>\n"
|
|
f"Не отвечает {failure_count[ip]} проверки подряд\n"
|
|
f"Время: {now}"
|
|
)
|
|
|
|
except Exception as exc:
|
|
log.error("Ошибка: %s", exc)
|
|
notify(f"⚠️ <b>Ошибка подключения к MikroTik</b>\n<code>{exc}</code>")
|
|
finally:
|
|
if pool:
|
|
try:
|
|
pool.disconnect()
|
|
except Exception:
|
|
pass
|
|
|
|
log.info("Следующая проверка через %d сек …\n", CHECK_INTERVAL)
|
|
time.sleep(CHECK_INTERVAL)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|