commit 6be7fad541b179a7e07a46cb277d49cf3849523e Author: Sergey Date: Sat May 16 15:08:53 2026 +0700 feat: initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fe39057 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Скопируйте этот файл в .env и заполните значения +# cp .env.example .env +# +# ⚠️ Файл .env добавлен в .gitignore — не коммитьте его! + +# ─── MikroTik ───────────────────────────────────────────────────────────────── +MIKROTIK_HOST=192.168.88.1 +MIKROTIK_PORT=8728 +MIKROTIK_USER=monitor +MIKROTIK_PASSWORD=your_password + +# ─── Telegram ───────────────────────────────────────────────────────────────── +TELEGRAM_BOT_TOKEN=123456789:AABBccDDeeFFggHH +TELEGRAM_CHAT_ID=-1001234567890 + +# Имя address-list из /ip firewall address-list на MikroTik +ADDRESS_LIST_NAME=monitor + +# ─── Параметры мониторинга ──────────────────────────────────────────────────── +CHECK_INTERVAL=60 +PING_COUNT=3 +PING_TIMEOUT_MS=1000 +FAILURE_THRESHOLD=2 + +# ─── Временная зона ─────────────────────────────────────────────────────────── +TZ=Europe/Moscow diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d57104e --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Секреты — никогда не коммитить! +.env + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f3c8c00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +LABEL description="MikroTik IP Monitor with Telegram alerts" + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir \ + routeros-api==0.17.0 \ + httpx==0.27.0 + +# Копируем скрипт из репозитория +COPY monitor.py . + +ENV TZ=Europe/Moscow +CMD ["python", "-u", "/app/monitor.py"] diff --git a/PUSH.md b/PUSH.md new file mode 100644 index 0000000..bfcd29f --- /dev/null +++ b/PUSH.md @@ -0,0 +1,77 @@ +# Публикация в Gitea — пошаговая инструкция + +## 1. Создайте репозиторий в Gitea + +1. Откройте Gitea → **+** → **New Repository** +2. Имя: `mikrotik-monitor` +3. Видимость: Private (рекомендуется) +4. **Не инициализировать** репозиторий (без README, .gitignore) +5. Нажмите **Create Repository** + +--- + +## 2. Инициализируйте и запушьте + +Скопируйте папку репозитория на свой компьютер, затем: + +```bash +cd mikrotik-monitor-repo + +# Инициализация +git init +git checkout -b main + +# Первый коммит +git add . +git commit -m "feat: initial MikroTik monitor setup" + +# Подключение к Gitea (замените URL на свой) +git remote add origin https://gitea.example.com/yourname/mikrotik-monitor.git + +# Пуш +git push -u origin main +``` + +--- + +## 3. Настройте SSH (опционально, но удобнее) + +```bash +# Сгенерируйте ключ (если ещё нет) +ssh-keygen -t ed25519 -C "your@email.com" + +# Скопируйте публичный ключ +cat ~/.ssh/id_ed25519.pub +``` + +Добавьте ключ в Gitea: **Settings → SSH / GPG Keys → Add Key** + +Затем используйте SSH-URL: +```bash +git remote set-url origin git@gitea.example.com:yourname/mikrotik-monitor.git +``` + +--- + +## 4. Дальнейшая работа + +```bash +# Внести изменения и запушить +git add . +git commit -m "fix: описание изменения" +git push + +# Посмотреть статус +git status +git log --oneline +``` + +--- + +## ⚠️ Важно: секреты в git + +Файл `.env` добавлен в `.gitignore` и **никогда не попадёт в репозиторий**. +Для хранения секретов (пароли, токены) используйте: + +- Переменные окружения в Portainer (раздел **Environment variables** при создании стека) +- [Gitea Secrets](https://docs.gitea.com/usage/secrets) — если настроите CI/CD diff --git a/README.md b/README.md new file mode 100644 index 0000000..9760e8b --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# 🔍 MikroTik Monitor + +Мониторинг IP-адресов через MikroTik API с уведомлениями в Telegram. +Деплой через Portainer Stack — без лишних зависимостей. + +--- + +## Как это работает + +``` +[Docker Container] + │ + ├─→ Подключается к MikroTik по API (порт 8728) + │ └─→ Выполняет /ping для каждого IP из списка + │ + └─→ Если хост не отвечает N раз подряд → Telegram уведомление +``` + +**Логика уведомлений:** +- 🔴 `DOWN` — хост не ответил `FAILURE_THRESHOLD` раз подряд +- ✅ `UP` — хост восстановился после даунтайма +- ⚠️ `ERROR` — не удалось подключиться к MikroTik + +--- + +## Структура репозитория + +``` +. +├── Dockerfile # Образ с встроенным скриптом +├── monitor.py # Основной скрипт мониторинга +├── stack.yml # Portainer Stack +├── .env.example # Шаблон переменных окружения +└── README.md +``` + +--- + +## Быстрый старт + +### 1. Настройте MikroTik + +```routeros +/ip service enable api + +/user group add name=monitor policy=api,read,test +/user add name=monitor password=StrongPass group=monitor +``` + +### 2. Создайте Telegram-бота + +1. [@BotFather](https://t.me/BotFather) → `/newbot` → скопируйте токен +2. Добавьте бота в чат, напишите любое сообщение +3. Получите `chat_id`: + ``` + https://api.telegram.org/bot/getUpdates + ``` + +### 3. Соберите Docker-образ на сервере + +```bash +git clone https://gitea.example.com/yourname/mikrotik-monitor.git +cd mikrotik-monitor +docker build -t mikrotik-monitor:latest . +``` + +### 4. Задеплойте через Portainer + +1. **Stacks** → **Add Stack** +2. Имя: `mikrotik-monitor` +3. **Web editor** → вставьте содержимое `stack.yml` +4. Заполните переменные окружения +5. **Deploy the stack** + +--- + +## Переменные окружения + +| Переменная | Обязательная | По умолчанию | Описание | +|---|:---:|---|---| +| `MIKROTIK_HOST` | ✅ | — | IP или hostname MikroTik | +| `MIKROTIK_PORT` | | `8728` | API-порт (8729 для SSL) | +| `MIKROTIK_USER` | ✅ | — | Логин | +| `MIKROTIK_PASSWORD` | ✅ | — | Пароль | +| `TELEGRAM_BOT_TOKEN` | ✅ | — | Токен бота от @BotFather | +| `TELEGRAM_CHAT_ID` | ✅ | — | ID чата/группы/канала | +| `IP_LIST` | ✅ | — | Адреса через запятую | +| `CHECK_INTERVAL` | | `60` | Интервал проверок (сек) | +| `PING_COUNT` | | `3` | Кол-во пинг-пакетов | +| `PING_TIMEOUT_MS` | | `1000` | Таймаут пинга (мс) | +| `FAILURE_THRESHOLD` | | `2` | Провалов подряд → DOWN | +| `TZ` | | `Europe/Moscow` | Временная зона | + +### Рекомендуемый способ: переменные Portainer + +В `stack.yml` используйте синтаксис `${VAR}`: +```yaml +environment: + - MIKROTIK_PASSWORD=${MIKROTIK_PASSWORD} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} +``` +Значения добавляйте в раздел **Environment variables** в UI Portainer — они хранятся зашифрованно. + +--- + +## Обновление + +```bash +# Пересобрать образ +cd mikrotik-monitor +git pull +docker build -t mikrotik-monitor:latest . +``` + +Затем в Portainer: **Stacks → mikrotik-monitor → Update the stack**. + +--- + +## Просмотр логов + +```bash +docker logs -f mikrotik-monitor +``` + +Пример вывода: +``` +2024-01-15 14:30:00 [INFO] === MikroTik Monitor запущен === +2024-01-15 14:30:00 [INFO] Хосты: ['192.168.1.1', '192.168.1.10'] +2024-01-15 14:30:01 [INFO] Подключено к MikroTik +2024-01-15 14:30:02 [INFO] ✅ 192.168.1.1 avg-rtt=0.8 ms +2024-01-15 14:30:03 [WARNING] ❌ 192.168.1.10 не отвечает (1/2) +``` diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..53d6982 --- /dev/null +++ b/monitor.py @@ -0,0 +1,228 @@ +#!/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"] + +# Имя 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"https://api.telegram.org/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, json=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", + }, + ) + + rtts = [] + for row in results: + # Каждая строка — один пинг-ответ + # "time" присутствует только если ответ получен + time_val = row.get(b"time", b"").decode().replace("ms", "").strip() + if time_val: + try: + rtts.append(float(time_val)) + except ValueError: + pass + + if rtts: + return sum(rtts) / len(rtts) + 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() diff --git a/stack.yml b/stack.yml new file mode 100644 index 0000000..d2d6b60 --- /dev/null +++ b/stack.yml @@ -0,0 +1,71 @@ +version: "3.9" + +# ═══════════════════════════════════════════════════════════════════ +# MikroTik Monitor — Portainer Stack +# +# Перед деплоем: +# 1. Соберите образ на сервере (см. README) и запомните его имя +# 2. Заполните все переменные окружения ниже +# 3. В Portainer: Stacks → Add Stack → вставьте этот файл → Deploy +# ═══════════════════════════════════════════════════════════════════ + +services: + mikrotik-monitor: + # ── Образ ────────────────────────────────────────────────────── + # Соберите локально: docker build -t mikrotik-monitor:latest . + # Или укажите адрес своего реестра: + # registry.example.com/mikrotik-monitor:latest + image: mikrotik-monitor:latest + + container_name: mikrotik-monitor + restart: unless-stopped + hostname: mikrotik-monitor + + # ── Переменные окружения ──────────────────────────────────────── + environment: + # MikroTik + - MIKROTIK_HOST=192.168.88.1 # ← IP вашего роутера + - MIKROTIK_PORT=8728 # API-порт (8728 plain / 8729 SSL) + - MIKROTIK_USER=monitor # ← логин + - MIKROTIK_PASSWORD=your_password # ← пароль + + # Telegram + - TELEGRAM_BOT_TOKEN=123456:AABBcc # ← токен от @BotFather + - TELEGRAM_CHAT_ID=-1001234567890 # ← ID чата/группы + + # Имя address-list из /ip firewall address-list на MikroTik + - ADDRESS_LIST_NAME=monitor + + # Параметры мониторинга + - CHECK_INTERVAL=60 # интервал между проверками (сек) + - PING_COUNT=3 # кол-во пинг-пакетов + - PING_TIMEOUT_MS=1000 # таймаут пинга (мс) + - FAILURE_THRESHOLD=2 # провалов подряд → уведомление DOWN + + # Временная зона + - TZ=Europe/Moscow + + # ── Тома ─────────────────────────────────────────────────────── + volumes: + - /etc/localtime:/etc/localtime:ro + + # ── Логирование ───────────────────────────────────────────────── + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # ── Healthcheck ───────────────────────────────────────────────── + # Контейнер помечается healthy, если процесс monitor.py жив + healthcheck: + test: ["CMD-SHELL", "pgrep -f monitor.py || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # ── Метки для Portainer ───────────────────────────────────────── + labels: + - "com.docker.compose.project=mikrotik-monitor" + - "description=MikroTik IP monitoring with Telegram alerts"