#!/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)
resp.raise_for_status()
log.info("Telegram: сообщение отправлено")
except Exception as exc:
log.error("Telegram: ошибка отправки — %s", exc)
def notify(message: str) -> None:
asyncio.run(send_telegram(message))
# ──────────────────────────────────────────────
# MikroTik: получить список IP из address-list
# ──────────────────────────────────────────────
def fetch_address_list(api) -> list[str]:
"""
Читает /ip firewall address-list и возвращает список IP
из записей с именем ADDRESS_LIST_NAME (пропускает отключённые).
"""
resource = api.get_resource("/ip/firewall/address-list")
entries = resource.get(list=ADDRESS_LIST_NAME)
ip_list = []
for entry in entries:
# Пропускаем отключённые записи
if entry.get("disabled", "false") == "true":
log.debug("Пропущена (disabled): %s", entry.get("address"))
continue
address = entry.get("address", "").strip()
# Убираем префикс /32 если есть — пингуем чистый IP
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
# ──────────────────────────────────────────────
# Основной цикл
# ──────────────────────────────────────────────
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] = {}
notify(
f"🟢 MikroTik Monitor запущен\n"
f"Address-list: {ADDRESS_LIST_NAME}\n"
f"Интервал: {CHECK_INTERVAL} сек"
)
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 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"✅ Хост восстановился\n"
f"IP: {ip}\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"🔴 Хост недоступен!\n"
f"IP: {ip}\n"
f"Список: {ADDRESS_LIST_NAME}\n"
f"Не отвечает {failure_count[ip]} проверки подряд\n"
f"Время: {now}"
)
except Exception as exc:
log.error("Ошибка: %s", exc)
notify(f"⚠️ Ошибка подключения к MikroTik\n{exc}")
finally:
if pool:
try:
pool.disconnect()
except Exception:
pass
log.info("Следующая проверка через %d сек …\n", CHECK_INTERVAL)
time.sleep(CHECK_INTERVAL)
if __name__ == "__main__":
main()