From 6e3c7afab8d8e39c409e620ef0dcc60bbd780890 Mon Sep 17 00:00:00 2001 From: Sergey
{', '.join(IP_LIST)}\n"
+ f"Интервал: {CHECK_INTERVAL} сек"
+ )
+
+ while True:
+ connection = None
+ try:
+ log.info("Подключение к MikroTik %s:%d …", MIKROTIK_HOST, MIKROTIK_PORT)
+ connection = routeros_api.RouterOsApiPool(
+ MIKROTIK_HOST,
+ username=MIKROTIK_USER,
+ password=MIKROTIK_PASSWORD,
+ port=MIKROTIK_PORT,
+ plaintext_login=True,
+ )
+ connection.get_api()
+ log.info("Подключено к MikroTik")
+
+ for ip in IP_LIST:
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ rtt = mikrotik_ping(connection, ip)
+
+ if rtt is not None:
+ log.info("✅ %s 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("❌ %s не отвечает (%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"Не отвечает {failure_count[ip]} проверки подряд\n"
+ f"Время: {now}"
+ )
+
+ except Exception as exc:
+ log.error("Ошибка подключения к MikroTik: %s", exc)
+ notify(f"⚠️ Ошибка подключения к MikroTik\n{exc}")
+ finally:
+ if connection:
+ try:
+ connection.disconnect()
+ except Exception:
+ pass
+
+ log.info("Следующая проверка через %d сек …\n", CHECK_INTERVAL)
+ time.sleep(CHECK_INTERVAL)
+
+
+if __name__ == "__main__":
+ main()
+PYEOF
+
+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{', '.join(IP_LIST)}\n"
+ f"Интервал: {CHECK_INTERVAL} сек"
+ )
+
+ while True:
+ connection = None
+ try:
+ log.info("Подключение к MikroTik %s:%d …", MIKROTIK_HOST, MIKROTIK_PORT)
+ connection = routeros_api.RouterOsApiPool(
+ MIKROTIK_HOST,
+ username=MIKROTIK_USER,
+ password=MIKROTIK_PASSWORD,
+ port=MIKROTIK_PORT,
+ plaintext_login=True,
+ )
+ connection.get_api() # проверяем соединение
+ log.info("Подключено к MikroTik")
+
+ for ip in IP_LIST:
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ rtt = mikrotik_ping(connection, ip)
+
+ if rtt is not None:
+ log.info("✅ %s 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(
+ "❌ %s не отвечает (попытка %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"Не отвечает {failure_count[ip]} проверки подряд\n"
+ f"Время: {now}"
+ )
+
+ except Exception as exc:
+ log.error("Ошибка подключения к MikroTik: %s", exc)
+ notify(
+ f"⚠️ Ошибка подключения к MikroTik\n"
+ f"{exc}"
+ )
+ finally:
+ if connection:
+ try:
+ connection.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..6f86c0b
--- /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 чата/группы
+
+ # Список IP через запятую
+ - IP_LIST=192.168.1.1,192.168.1.10,192.168.1.20
+
+ # Параметры мониторинга
+ - 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"
From 994b95f3e9aa67ddf108ce11904cd69f3d736ab8 Mon Sep 17 00:00:00 2001
From: Sergey {', '.join(IP_LIST)}\n"
- f"Интервал: {CHECK_INTERVAL} сек"
- )
-
- while True:
- connection = None
- try:
- log.info("Подключение к MikroTik %s:%d …", MIKROTIK_HOST, MIKROTIK_PORT)
- connection = routeros_api.RouterOsApiPool(
- MIKROTIK_HOST,
- username=MIKROTIK_USER,
- password=MIKROTIK_PASSWORD,
- port=MIKROTIK_PORT,
- plaintext_login=True,
- )
- connection.get_api()
- log.info("Подключено к MikroTik")
-
- for ip in IP_LIST:
- now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- rtt = mikrotik_ping(connection, ip)
-
- if rtt is not None:
- log.info("✅ %s 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("❌ %s не отвечает (%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"Не отвечает {failure_count[ip]} проверки подряд\n"
- f"Время: {now}"
- )
-
- except Exception as exc:
- log.error("Ошибка подключения к MikroTik: %s", exc)
- notify(f"⚠️ Ошибка подключения к MikroTik\n{exc}")
- finally:
- if connection:
- try:
- connection.disconnect()
- except Exception:
- pass
-
- log.info("Следующая проверка через %d сек …\n", CHECK_INTERVAL)
- time.sleep(CHECK_INTERVAL)
-
-
-if __name__ == "__main__":
- main()
-PYEOF
+# Копируем скрипт из репозитория
+COPY monitor.py .
ENV TZ=Europe/Moscow
CMD ["python", "-u", "/app/monitor.py"]
diff --git a/monitor.py b/monitor.py
index a691d3d..c3eb7cd 100644
--- a/monitor.py
+++ b/monitor.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""
-MikroTik IP Monitor — пингует адреса через MikroTik API и шлёт уведомления в Telegram.
+MikroTik IP Monitor — читает адреса из /ip firewall address-list,
+пингует их через MikroTik API и шлёт уведомления в Telegram.
"""
import os
@@ -12,9 +13,6 @@ from typing import Optional
import routeros_api
import httpx
-from dotenv import load_dotenv
-
-load_dotenv()
# ──────────────────────────────────────────────
# Настройки из переменных окружения
@@ -27,14 +25,12 @@ MIKROTIK_PASSWORD = os.environ["MIKROTIK_PASSWORD"]
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"]
-# Список IP-адресов для мониторинга (через запятую в .env или задать напрямую)
-IP_LIST = [ip.strip() for ip in os.environ["IP_LIST"].split(",") if ip.strip()]
+# Имя 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")) # таймаут пинга (мс)
-
-# Сколько раз подряд хост должен не ответить, чтобы считаться DOWN
+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"))
# ──────────────────────────────────────────────
@@ -53,11 +49,7 @@ log = logging.getLogger(__name__)
# ──────────────────────────────────────────────
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",
- }
+ 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)
@@ -71,15 +63,41 @@ 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(connection, address: str) -> Optional[float]:
+def mikrotik_ping(api, address: str) -> Optional[float]:
"""
- Запускает ping на MikroTik. Возвращает средний avg-rtt (мс) или None если недоступен.
+ Возвращает avg-rtt (мс) или None если хост недоступен.
"""
try:
- api = connection.get_api()
ping_resource = api.get_resource("/ping")
results = ping_resource.call(
"",
@@ -89,18 +107,14 @@ def mikrotik_ping(connection, address: str) -> Optional[float]:
"timeout": str(PING_TIMEOUT_MS),
},
)
-
- # Ищем строку с итогами (packet-loss и avg-rtt)
for row in results:
if "avg-rtt" in row and row.get("avg-rtt"):
- avg_rtt_str = row["avg-rtt"].replace("ms", "").strip()
try:
- return float(avg_rtt_str)
+ return float(row["avg-rtt"].replace("ms", "").strip())
except ValueError:
pass
if row.get("packet-loss") == "100":
return None
-
return None
except Exception as exc:
log.warning("Ошибка пинга %s: %s", address, exc)
@@ -112,41 +126,56 @@ def mikrotik_ping(connection, address: str) -> Optional[float]:
# ──────────────────────────────────────────────
def main() -> None:
log.info("=== MikroTik Monitor запущен ===")
- log.info("Хосты для проверки: %s", IP_LIST)
- log.info("Интервал проверки: %d сек", CHECK_INTERVAL)
+ log.info("Address-list: '%s'", ADDRESS_LIST_NAME)
+ log.info("Интервал: %d сек", CHECK_INTERVAL)
- # Состояние: счётчик последовательных ошибок и флаг «уже DOWN»
- failure_count: dict[str, int] = {ip: 0 for ip in IP_LIST}
- host_down: dict[str, bool] = {ip: False for ip in IP_LIST}
+ # Состояние хостов: счётчик ошибок и флаг DOWN
+ # Обновляется динамически при каждом чтении address-list
+ failure_count: dict[str, int] = {}
+ host_down: dict[str, bool] = {}
notify(
f"🟢 MikroTik Monitor запущен\n"
- f"Хосты: {', '.join(IP_LIST)}\n"
+ f"Address-list: {ADDRESS_LIST_NAME}\n"
f"Интервал: {CHECK_INTERVAL} сек"
)
while True:
- connection = None
+ pool = None
try:
log.info("Подключение к MikroTik %s:%d …", MIKROTIK_HOST, MIKROTIK_PORT)
- connection = routeros_api.RouterOsApiPool(
+ pool = routeros_api.RouterOsApiPool(
MIKROTIK_HOST,
username=MIKROTIK_USER,
password=MIKROTIK_PASSWORD,
port=MIKROTIK_PORT,
plaintext_login=True,
)
- connection.get_api() # проверяем соединение
+ api = pool.get_api()
log.info("Подключено к MikroTik")
- for ip in IP_LIST:
+ # Читаем актуальный список адресов с 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(connection, ip)
+ rtt = mikrotik_ping(api, ip)
if rtt is not None:
- log.info("✅ %s avg-rtt=%.1f ms", ip, rtt)
+ log.info("✅ %-18s avg-rtt=%.1f ms", ip, rtt)
if host_down[ip]:
- # Хост восстановился
host_down[ip] = False
failure_count[ip] = 0
notify(
@@ -160,29 +189,26 @@ def main() -> None:
else:
failure_count[ip] += 1
log.warning(
- "❌ %s не отвечает (попытка %d/%d)",
+ "❌ %-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("Ошибка подключения к MikroTik: %s", exc)
- notify(
- f"⚠️ Ошибка подключения к MikroTik\n"
- f"{exc}"
- )
+ log.error("Ошибка: %s", exc)
+ notify(f"⚠️ Ошибка подключения к MikroTik\n{exc}")
finally:
- if connection:
+ if pool:
try:
- connection.disconnect()
+ pool.disconnect()
except Exception:
pass
diff --git a/stack.yml b/stack.yml
index 6f86c0b..d2d6b60 100644
--- a/stack.yml
+++ b/stack.yml
@@ -33,8 +33,8 @@ services:
- TELEGRAM_BOT_TOKEN=123456:AABBcc # ← токен от @BotFather
- TELEGRAM_CHAT_ID=-1001234567890 # ← ID чата/группы
- # Список IP через запятую
- - IP_LIST=192.168.1.1,192.168.1.10,192.168.1.20
+ # Имя address-list из /ip firewall address-list на MikroTik
+ - ADDRESS_LIST_NAME=monitor
# Параметры мониторинга
- CHECK_INTERVAL=60 # интервал между проверками (сек)