This commit is contained in:
2026-05-17 20:54:53 +05:00
parent 65a0babeab
commit 27eb4fd606
90 changed files with 12343 additions and 0 deletions
View File
+215
View File
@@ -0,0 +1,215 @@
"""Встроенный FTP-сервер для приёма push-бэкапов от MikroTik.
Идея: вместо того чтобы открывать ssh/ftp на каждом устройстве и тянуть
с него файл, контроллер сам поднимает FTP на отдельном порту и выдаёт
устройству одноразовые креды. Устройство выполняет:
/tool fetch upload=yes mode=ftp address=<ctrl> port=<p> \
user=<u> password=<p> src-path=<file> dst-path=<file>
Файлы складываются во временную директорию сессии. По завершении
загрузки коллбэк `on_file_received` маркирует файл как готовый.
Бэкенд ждёт появления всех ожидаемых файлов и читает их.
Реализация — `pyftpdlib.servers.ThreadedFTPServer`, поднимается
в фоновом потоке и живёт вместе с процессом backend.
"""
from __future__ import annotations
import os
import secrets
import shutil
import socket
import tempfile
import threading
import time
from dataclasses import dataclass, field
from typing import Iterable
from loguru import logger
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import ThreadedFTPServer
@dataclass
class _Session:
session_id: str
username: str
password: str
home_dir: str
expected: set[str]
received: dict[str, str] = field(default_factory=dict) # name -> abs path
created_at: float = field(default_factory=time.time)
class _Server:
def __init__(self, host: str = "0.0.0.0", port: int = 2121) -> None:
self.host = host
self.port = port
self._sessions: dict[str, _Session] = {}
self._sessions_by_user: dict[str, _Session] = {}
self._lock = threading.RLock()
self._authorizer = DummyAuthorizer()
self._server: ThreadedFTPServer | None = None
self._thread: threading.Thread | None = None
self._root_tmp = tempfile.mkdtemp(prefix="mikbak-ftp-")
srv = self # closure для хэндлера
class _Handler(FTPHandler):
def on_file_received(self, file: str) -> None: # type: ignore[override]
try:
user = (self.username or "").strip()
name = os.path.basename(file)
srv._mark_received(user, name, file)
except Exception as exc: # pragma: no cover
logger.warning("FTP on_file_received error: {}", exc)
_Handler.authorizer = self._authorizer
_Handler.banner = "mikrocloud backup ftp ready"
# Пассивный диапазон фиксируем (нужно открыть в compose).
_Handler.passive_ports = range(30000, 30050)
self._handler_cls = _Handler
# ---------- lifecycle ----------
def start(self) -> None:
if self._server is not None:
return
self._server = ThreadedFTPServer((self.host, self.port), self._handler_cls)
self._server.max_cons = 64
self._thread = threading.Thread(
target=self._server.serve_forever,
name="backup-ftp",
daemon=True,
)
self._thread.start()
logger.info("Backup FTP server started on {}:{}", self.host, self.port)
def stop(self) -> None:
if self._server is None:
return
try:
self._server.close_all()
except Exception: # pragma: no cover
pass
self._server = None
self._thread = None
try:
shutil.rmtree(self._root_tmp, ignore_errors=True)
except Exception: # pragma: no cover
pass
logger.info("Backup FTP server stopped")
# ---------- sessions ----------
def open_session(self, expected_files: Iterable[str]) -> _Session:
"""Создаёт уникального пользователя и личный каталог."""
with self._lock:
sid = secrets.token_hex(8)
user = f"mb_{sid}"
password = secrets.token_urlsafe(18)
home = os.path.join(self._root_tmp, sid)
os.makedirs(home, exist_ok=True)
self._authorizer.add_user(user, password, home, perm="elradfmw")
sess = _Session(
session_id=sid,
username=user,
password=password,
home_dir=home,
expected=set(expected_files),
)
self._sessions[sid] = sess
self._sessions_by_user[user] = sess
logger.info("FTP backup session opened: sid={} user={} expected={}",
sid, user, sess.expected)
return sess
def close_session(self, session_id: str) -> None:
with self._lock:
sess = self._sessions.pop(session_id, None)
if sess is None:
return
self._sessions_by_user.pop(sess.username, None)
try:
self._authorizer.remove_user(sess.username)
except Exception: # pragma: no cover
pass
try:
shutil.rmtree(sess.home_dir, ignore_errors=True)
except Exception: # pragma: no cover
pass
logger.info("FTP backup session closed: sid={}", session_id)
def _mark_received(self, username: str, name: str, abs_path: str) -> None:
with self._lock:
sess = self._sessions_by_user.get(username)
if sess is None:
logger.warning("FTP upload from unknown user: {} ({})", username, name)
return
sess.received[name] = abs_path
logger.info("FTP backup file received: sid={} name={} size={}b",
sess.session_id, name, os.path.getsize(abs_path))
def wait_files(self, session_id: str, timeout: float = 60.0) -> dict[str, bytes]:
"""Ожидает поступления всех expected-файлов и возвращает их содержимое."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self._lock:
sess = self._sessions.get(session_id)
if sess is None:
raise RuntimeError(f"session {session_id} not found")
missing = sess.expected - set(sess.received.keys())
if not missing:
out: dict[str, bytes] = {}
for name, path in sess.received.items():
with open(path, "rb") as f:
out[name] = f.read()
return out
time.sleep(0.3)
with self._lock:
sess = self._sessions.get(session_id)
missing = sess.expected - set(sess.received.keys()) if sess else set()
raise TimeoutError(f"backup files not received: missing={sorted(missing)}")
_INSTANCE: _Server | None = None
_INSTANCE_LOCK = threading.Lock()
def get_server() -> _Server | None:
return _INSTANCE
def start_server(host: str = "0.0.0.0", port: int = 2121) -> _Server:
global _INSTANCE
with _INSTANCE_LOCK:
if _INSTANCE is None:
_INSTANCE = _Server(host=host, port=port)
_INSTANCE.start()
return _INSTANCE
def stop_server() -> None:
global _INSTANCE
with _INSTANCE_LOCK:
if _INSTANCE is not None:
_INSTANCE.stop()
_INSTANCE = None
def detect_push_host(default: str | None = None) -> str:
"""Подсказка: IP контроллера, как его видят устройства.
Берётся через udp-сокет к 8.8.8.8 (соединение не открывается).
Используется fallback, если в ENV не задан BACKUP_PUSH_HOST.
"""
if default:
return default
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.3)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "0.0.0.0"
+150
View File
@@ -0,0 +1,150 @@
"""Бэкап самого контроллера: дамп БД и/или конфигурации."""
from __future__ import annotations
import io
import json
import os
import subprocess
import tarfile
from datetime import datetime, timezone
from loguru import logger
from ..core.config import get_settings
def _safe_settings_dump() -> dict:
s = get_settings()
data = s.model_dump()
# маскируем секреты
for k in list(data.keys()):
if any(x in k.lower() for x in ("password", "secret", "key")):
data[k] = "***"
return data
def make_config_only_archive() -> tuple[str, bytes]:
"""Tar.gz с настройками контроллера (без БД)."""
buf = io.BytesIO()
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
name = f"controller-config-{ts}.tar.gz"
settings_json = json.dumps(_safe_settings_dump(), indent=2, default=str).encode()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name="settings.json")
info.size = len(settings_json)
info.mtime = int(datetime.now().timestamp())
tar.addfile(info, io.BytesIO(settings_json))
readme = (
b"ROSzetta - config-only backup\n"
b"Contains masked settings.json (no DB, no secrets).\n"
)
info2 = tarfile.TarInfo(name="README.txt")
info2.size = len(readme)
info2.mtime = int(datetime.now().timestamp())
tar.addfile(info2, io.BytesIO(readme))
return name, buf.getvalue()
def _dump_database() -> bytes:
"""Возвращает pg_dump БД (custom-format) либо raise."""
s = get_settings()
# parse postgresql+psycopg2://user:pass@host:port/db
url = s.database_url.replace("postgresql+psycopg2://", "postgresql://")
cmd = ["pg_dump", "-Fc", url]
logger.info("running pg_dump")
try:
out = subprocess.run(
cmd,
check=True,
capture_output=True,
timeout=300,
env={**os.environ},
)
except FileNotFoundError as exc:
raise RuntimeError("pg_dump not installed in backend image") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"pg_dump failed: {exc.stderr.decode(errors='replace')[:400]}") from exc
return out.stdout
def make_full_archive() -> tuple[str, bytes]:
"""Tar.gz с дампом БД + settings.json."""
buf = io.BytesIO()
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
name = f"controller-full-{ts}.tar.gz"
db_dump = _dump_database()
settings_json = json.dumps(_safe_settings_dump(), indent=2, default=str).encode()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for fname, data in [
("db.dump", db_dump),
("settings.json", settings_json),
(
"README.txt",
b"ROSzetta - full backup\n"
b"Restore: pg_restore -d <db> db.dump\n",
),
]:
info = tarfile.TarInfo(name=fname)
info.size = len(data)
info.mtime = int(datetime.now().timestamp())
tar.addfile(info, io.BytesIO(data))
return name, buf.getvalue()
def restore_full_archive(data: bytes) -> dict:
"""Разворачивает full-бэкап: дроп схемы public + pg_restore из db.dump в архиве.
ВНИМАНИЕ: операция деструктивна. Текущая БД будет полностью заменена.
"""
s = get_settings()
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
try:
member = tar.getmember("db.dump")
except KeyError as exc:
raise RuntimeError("Архив не содержит db.dump (нужен full backup)") from exc
f = tar.extractfile(member)
if f is None:
raise RuntimeError("Не удалось прочитать db.dump из архива")
dump_bytes = f.read()
except tarfile.TarError as exc:
raise RuntimeError(f"Невалидный tar.gz: {exc}") from exc
url = s.database_url.replace("postgresql+psycopg2://", "postgresql://")
logger.warning("controller restore: dropping schema public")
try:
subprocess.run(
["psql", url, "-v", "ON_ERROR_STOP=1", "-c",
"DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;"],
check=True, capture_output=True, timeout=60, env={**os.environ},
)
except FileNotFoundError as exc:
raise RuntimeError("psql not installed in backend image") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"psql DROP SCHEMA failed: {exc.stderr.decode(errors='replace')[:400]}") from exc
logger.warning("controller restore: running pg_restore ({} bytes)", len(dump_bytes))
try:
proc = subprocess.run(
["pg_restore", "--no-owner", "--no-privileges", "-d", url],
input=dump_bytes,
check=True, capture_output=True, timeout=600, env={**os.environ},
)
except FileNotFoundError as exc:
raise RuntimeError("pg_restore not installed in backend image") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"pg_restore failed: {exc.stderr.decode(errors='replace')[:400]}") from exc
return {
"ok": True,
"message": "Бэкап успешно развёрнут. Перезайдите в систему — данные обновлены.",
"stderr": proc.stderr.decode(errors='replace')[:400] if proc.stderr else "",
}
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from ..models.alert import Alert
from .settings import get_settings_dict, severity_meets
from . import telegram as tg
# Соответствие категории алерта ключу notify-toggle.
_NOTIFY_KEY_BY_CATEGORY = {
"device": "device_status",
"internet": "internet",
"abnormal_reboot": "abnormal_reboot",
"firmware": "firmware",
}
def add_alert(
db: Session,
*,
title: str,
severity: str = "info",
category: str = "system",
source: str | None = None,
message: str | None = None,
) -> Alert | None:
"""Создаёт алерт с учётом включенных нотификаций. Возвращает None, если категория отключена."""
cfg = get_settings_dict(db)
notify_cfg = cfg.get("notify", {})
notify_key = _NOTIFY_KEY_BY_CATEGORY.get(category)
if notify_key is not None and notify_cfg.get(notify_key) is False:
return None
a = Alert(
title=title,
severity=severity,
category=category,
source=source,
message=message,
)
db.add(a)
db.commit()
db.refresh(a)
tg_cfg = cfg.get("telegram", {})
if tg_cfg.get("enabled") and severity_meets(severity, tg_cfg.get("min_severity", "warning")):
text = f"<b>[{severity.upper()}] {title}</b>"
if message:
text += f"\n{message}"
if source:
text += f"\n<i>src: {source}</i>"
tg.send_message(tg_cfg.get("bot_token", ""), tg_cfg.get("chat_id", ""), text)
return a
def add_audit(*args, **kwargs) -> None:
"""No-op. Аудит-логи удалены, функция оставлена как заглушка для совместимости."""
return None
+103
View File
@@ -0,0 +1,103 @@
"""Сервис проверки новых версий прошивок MikroTik по нескольким каналам."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
import httpx
from loguru import logger
from sqlalchemy.orm import Session
from ..models.settings import AppSetting
from .events import add_alert
# Каналы и URL-ы для проверки.
CHANNELS: dict[str, str] = {
"stable": "https://download.mikrotik.com/routeros/NEWESTa7.stable",
"long-term": "https://download.mikrotik.com/routeros/NEWESTa7.long-term",
"testing": "https://download.mikrotik.com/routeros/NEWESTa7.testing",
}
STATE_KEY = "firmware_state"
def _fetch_channel(url: str, timeout: float = 10.0) -> tuple[str, datetime] | None:
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as cli:
r = cli.get(url)
r.raise_for_status()
text = r.text.strip()
except httpx.HTTPError as exc:
logger.warning("firmware check: HTTP error for {}: {}", url, exc)
return None
m = re.match(r"(\S+)\s+(\d+)", text)
if not m:
logger.warning("firmware check: unexpected response for {}: {!r}", url, text[:120])
return None
return m.group(1), datetime.fromtimestamp(int(m.group(2)), tz=timezone.utc)
def _load_state(db: Session) -> dict:
row = db.query(AppSetting).filter(AppSetting.key == STATE_KEY).first()
if not row:
return {}
try:
return json.loads(row.value) or {}
except Exception:
return {}
def _save_state(db: Session, state: dict) -> None:
row = db.query(AppSetting).filter(AppSetting.key == STATE_KEY).first()
if not row:
row = AppSetting(key=STATE_KEY, value=json.dumps(state))
db.add(row)
else:
row.value = json.dumps(state)
db.commit()
def get_state(db: Session) -> dict:
"""Состояние проверок по каналам: {channel: {version, released_at, last_check}}."""
return _load_state(db)
def fetch_latest_version(timeout: float = 10.0) -> tuple[str, datetime] | None:
"""Backwards-compat: возвращает только stable."""
return _fetch_channel(CHANNELS["stable"], timeout=timeout)
def check_and_alert(db: Session) -> dict:
"""Проверяет все каналы. При появлении новой версии создаёт alert. Возвращает обновлённый state."""
state = _load_state(db)
now_iso = datetime.now(timezone.utc).isoformat()
for channel, url in CHANNELS.items():
res = _fetch_channel(url)
prev = (state.get(channel) or {}).get("version")
if res is None:
# сохраняем last_check всё равно, чтобы видеть попытку
state.setdefault(channel, {})["last_check"] = now_iso
state[channel]["last_check_ok"] = False
continue
version, released_at = res
state[channel] = {
"version": version,
"released_at": released_at.isoformat(),
"last_check": now_iso,
"last_check_ok": True,
}
if prev and prev != version:
add_alert(
db,
severity="info",
category="firmware",
source=f"mikrotik.com/{channel}",
title=f"RouterOS {channel}: новая версия {version}",
message=f"Предыдущая отслеживаемая: {prev}",
)
logger.info("firmware check {}: new version {} (was {})", channel, version, prev)
elif not prev:
logger.info("firmware check {}: initial = {}", channel, version)
_save_state(db, state)
return state
+177
View File
@@ -0,0 +1,177 @@
"""Создание бэкапа конфигурации MikroTik с PUSH-доставкой на контроллер.
Поток:
1. На устройстве запускается `/system/backup/save name=...` и `/export file=...`.
2. Ждём появления файлов в `/file`.
3. Контроллер открывает в своём встроенном FTP-сервере одноразовую сессию
(уникальный пользователь/пароль, изолированный каталог).
4. На устройстве выполняется `/tool fetch upload=yes mode=ftp ...`,
которое отправляет файлы НА контроллер. На MikroTik не нужно включать
ftp/ssh — нужен только исходящий доступ к контроллеру.
5. Бэкенд читает файлы из каталога сессии, удаляет файлы с устройства,
закрывает FTP-сессию и возвращает байты.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from loguru import logger
from .client import RouterOSCredentials, RouterOSError, routeros_session
from ..backup_ftp_server import detect_push_host, get_server, start_server
@dataclass
class BackupFiles:
binary_name: str
binary_data: bytes
text_name: str
text_data: bytes
# ---------- helpers вокруг librouteros ----------
def _exec_path(api: Any, *path: str, **params: Any) -> list[dict[str, Any]]:
"""Выполнить RouterOS-команду. Последний сегмент — имя cmd для librouteros.
Пример: _exec_path(api, "system", "backup", "save", name="x")
=> api.path("system", "backup")("save", name="x")
"""
if not path:
raise RouterOSError("_exec_path requires at least one path segment")
*base, cmd = path
p = api.path(*base) if base else api.path()
return list(p(cmd, **params))
def _list_files(api: Any) -> list[dict[str, Any]]:
return list(api.path("file"))
def _wait_file(api: Any, name: str, timeout: float = 15.0) -> dict[str, Any] | None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for row in _list_files(api):
if row.get("name") == name and int(row.get("size") or 0) > 0:
return row
time.sleep(0.5)
return None
def _delete_file(api: Any, name: str) -> None:
try:
for row in _list_files(api):
if row.get("name") == name:
api.path("file").remove(row[".id"])
return
except Exception as exc: # pragma: no cover
logger.warning("Could not delete file {} on device: {}", name, exc)
# ---------- главный сценарий ----------
def create_backup_via_push(
creds: RouterOSCredentials,
base_name: str,
push_host: str,
push_port: int = 2121,
timeout: float = 90.0,
) -> BackupFiles:
"""Полный цикл: создать backup+export на устройстве, дождаться upload на контроллер."""
binary_name = f"{base_name}.backup"
text_name = f"{base_name}.rsc"
server = get_server() or start_server(port=push_port)
session = server.open_session(expected_files={binary_name, text_name})
try:
logger.info(
"Backup PUSH: device={} base={} push={}:{} user={}",
creds.host, base_name, push_host, push_port, session.username,
)
with routeros_session(creds) as api:
# 1) бинарный backup
try:
_exec_path(api, "system", "backup", "save", name=base_name)
except Exception as exc:
raise RouterOSError(f"backup save failed: {exc}") from exc
if _wait_file(api, binary_name) is None:
raise RouterOSError(f"backup file {binary_name} not appeared on device")
# 2) текстовый export
try:
_exec_path(api, "export", file=base_name)
except Exception as exc:
raise RouterOSError(f"export failed: {exc}") from exc
if _wait_file(api, text_name) is None:
raise RouterOSError(f"export file {text_name} not appeared on device")
# 3) push обоих файлов
for fname in (binary_name, text_name):
try:
_exec_path(
api, "tool", "fetch",
**{
"upload": "yes",
"mode": "ftp",
"address": push_host,
"port": str(push_port),
"user": session.username,
"password": session.password,
"src-path": fname,
"dst-path": fname,
},
)
except Exception as exc:
raise RouterOSError(f"push {fname} failed: {exc}") from exc
# 4) ждём, пока FTP-сервер контроллера получит оба
try:
files = server.wait_files(session.session_id, timeout=timeout)
except TimeoutError as exc:
raise RouterOSError(str(exc)) from exc
if binary_name not in files or text_name not in files:
raise RouterOSError(f"unexpected push contents: got={sorted(files.keys())}")
# 5) подчищаем флэш на устройстве
try:
with routeros_session(creds) as api:
_delete_file(api, binary_name)
_delete_file(api, text_name)
except Exception as exc: # pragma: no cover
logger.warning("Cleanup failed for {}: {}", base_name, exc)
binary_data = files[binary_name]
text_data = files[text_name]
logger.info(
"Backup PUSH ok: {} binary={}b text={}b",
base_name, len(binary_data), len(text_data),
)
return BackupFiles(
binary_name=binary_name,
binary_data=binary_data,
text_name=text_name,
text_data=text_data,
)
finally:
try:
server.close_session(session.session_id)
except Exception: # pragma: no cover
pass
# Обратно-совместимый алиас — используется существующими роутами.
def create_and_download_backup(
creds: RouterOSCredentials,
base_name: str,
push_host: str | None = None,
push_port: int = 2121,
**_legacy: Any,
) -> BackupFiles:
"""Совместимая обёртка: принимает push_host/port вместо ssh/ftp_port."""
if not push_host:
push_host = detect_push_host()
return create_backup_via_push(creds, base_name, push_host=push_host, push_port=push_port)
+283
View File
@@ -0,0 +1,283 @@
"""Тонкий враппер вокруг librouteros для синхронных вызовов из API/воркеров."""
from __future__ import annotations
import socket
import ssl
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Iterator
from librouteros import connect
from librouteros.exceptions import LibRouterosError
from librouteros.login import plain
from loguru import logger
class RouterOSError(RuntimeError):
pass
@dataclass
class RouterOSCredentials:
host: str
username: str
password: str
# По умолчанию api-ssl: порт 8729 + TLS. plain api (8728) можно использовать
# для legacy-устройств, явно передав port=8728, use_tls=False.
port: int = 8729
use_tls: bool = True
timeout: float = 5.0
@contextmanager
def routeros_session(creds: RouterOSCredentials) -> Iterator[Any]:
kwargs: dict[str, Any] = {
"port": creds.port,
"timeout": creds.timeout,
"login_method": plain,
}
if creds.use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
kwargs["ssl_wrapper"] = ctx.wrap_socket
try:
api = connect(
host=creds.host,
username=creds.username,
password=creds.password,
**kwargs,
)
logger.info("RouterOS connected: {}:{} user={}", creds.host, creds.port, creds.username)
except (LibRouterosError, OSError, socket.timeout) as exc:
logger.warning(
"RouterOS connection failed: {}:{} user={} reason={}",
creds.host, creds.port, creds.username, exc,
)
raise RouterOSError(f"connect {creds.host}:{creds.port} failed: {exc}") from exc
try:
yield api
finally:
try:
api.close()
except Exception: # pragma: no cover
pass
def fetch_resource(creds: RouterOSCredentials) -> dict[str, Any]:
"""Возвращает первую запись `/system/resource`."""
with routeros_session(creds) as api:
rows = list(api.path("system", "resource"))
return rows[0] if rows else {}
def fetch_identity(creds: RouterOSCredentials) -> str | None:
with routeros_session(creds) as api:
rows = list(api.path("system", "identity"))
if not rows:
return None
return rows[0].get("name")
def fetch_interfaces(creds: RouterOSCredentials) -> list[dict[str, Any]]:
with routeros_session(creds) as api:
return list(api.path("interface"))
def cmd_reboot(creds: RouterOSCredentials) -> None:
"""Перезагрузить устройство (/system/reboot)."""
logger.info("Sending reboot to {}:{}", creds.host, creds.port)
with routeros_session(creds) as api:
tuple(api.path("system", "reboot"))
def cmd_safe_mode(creds: RouterOSCredentials) -> None:
"""Войти в safe mode (/system/safe-mode) — отправляет команду, устройство
подтвердит переход (RouterOS 7+). Если устройство уже в safe mode,
команда завершает его."""
logger.info("Toggling safe-mode on {}:{}", creds.host, creds.port)
with routeros_session(creds) as api:
tuple(api.path("system", "safe-mode"))
def check_internet(creds: RouterOSCredentials, target: str = "8.8.8.8") -> bool:
"""Проверка интернет-доступа на устройстве через `/ping count=1`."""
try:
with routeros_session(creds) as api:
rows = list(api(cmd="/ping", address=target, count="2"))
for row in rows:
recv = int(row.get("received") or 0)
if recv > 0:
return True
return False
except (RouterOSError, Exception) as exc:
logger.warning("internet check failed for {}: {}", creds.host, exc)
return False
def parse_uptime(uptime: str | None) -> int | None:
"""Парсит RouterOS uptime '1w2d3h4m5s' → секунды."""
if not uptime:
return None
import re
units = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
total = 0
for value, unit in re.findall(r"(\d+)([wdhms])", uptime):
total += int(value) * units[unit]
return total or None
def execute_cli(creds: RouterOSCredentials, command: str) -> list[dict[str, Any]]:
"""Выполнить произвольную команду RouterOS API.
Команда должна быть в формате RouterOS API path-style, например:
`/system/identity/print`
`/interface/print`
`/ip/address/print where interface=ether1`
Дополнительные параметры через `name=value` после команды.
Возвращает список словарей-результатов.
"""
parts = command.strip().split()
if not parts:
raise RouterOSError("empty command")
cmd = parts[0]
if not cmd.startswith("/"):
raise RouterOSError("command must start with '/'")
kwargs: dict[str, str] = {}
where: dict[str, str] = {}
in_where = False
for token in parts[1:]:
if token == "where":
in_where = True
continue
if "=" in token:
k, v = token.split("=", 1)
(where if in_where else kwargs)[k] = v
logger.info("CLI exec on {}: {} args={} where={}", creds.host, cmd, kwargs, where)
try:
with routeros_session(creds) as api:
res = api(cmd=cmd, **kwargs)
rows = list(res)
if where:
rows = [r for r in rows if all(str(r.get(k)) == v for k, v in where.items())]
return rows
except (LibRouterosError, OSError) as exc:
raise RouterOSError(f"cli failed: {exc}") from exc
# ---------- Sprint 09 helpers ----------
def fetch_interface_stats(creds: RouterOSCredentials) -> list[dict[str, Any]]:
"""Список интерфейсов со счётчиками rx/tx и флагом running.
Возвращает: [{"name", "rx_bytes", "tx_bytes", "running", "type", "comment"}].
"""
out: list[dict[str, Any]] = []
try:
with routeros_session(creds) as api:
for r in api.path("interface"):
def _i(v: Any) -> int:
try:
return int(v)
except (TypeError, ValueError):
return 0
running = str(r.get("running", "")).lower() == "true"
disabled = str(r.get("disabled", "")).lower() == "true"
out.append({
"name": r.get("name"),
"rx_bytes": _i(r.get("rx-byte")),
"tx_bytes": _i(r.get("tx-byte")),
"running": running,
"disabled": disabled,
"type": r.get("type"),
"comment": r.get("comment") or None,
"mac_address": r.get("mac-address") or None,
})
except (LibRouterosError, OSError) as exc:
raise RouterOSError(f"interface stats failed: {exc}") from exc
return out
def fetch_dhcp_leases(creds: RouterOSCredentials) -> list[dict[str, Any]]:
"""Все лизы DHCP-сервера на устройстве."""
out: list[dict[str, Any]] = []
try:
with routeros_session(creds) as api:
for r in api.path("ip", "dhcp-server", "lease"):
out.append({
"address": r.get("address"),
"mac_address": r.get("mac-address"),
"host_name": r.get("host-name") or r.get("comment"),
"comment": r.get("comment") or None,
"server": r.get("server"),
"status": r.get("status"),
"dynamic": str(r.get("dynamic", "")).lower() == "true",
"blocked": str(r.get("blocked", "")).lower() == "true",
"last_seen": r.get("last-seen"),
"expires_after": r.get("expires-after"),
})
except (LibRouterosError, OSError) as exc:
raise RouterOSError(f"dhcp leases failed: {exc}") from exc
return out
def cmd_upgrade_check(creds: RouterOSCredentials, channel: str = "stable") -> dict[str, Any]:
"""Запросить у MikroTik проверку доступного обновления и инициировать
/system/package/update/check-for-updates. Возвращает текущее состояние."""
try:
with routeros_session(creds) as api:
try:
tuple(api.path("system", "package", "update").call("set",
**{"channel": channel}))
except Exception:
pass
try:
tuple(api(cmd="/system/package/update/check-for-updates"))
except Exception:
pass
rows = list(api.path("system", "package", "update"))
return rows[0] if rows else {}
except (LibRouterosError, OSError) as exc:
raise RouterOSError(f"upgrade check failed: {exc}") from exc
def cmd_upgrade_install(creds: RouterOSCredentials) -> None:
"""Запустить установку обновления (устройство ребутнётся)."""
try:
with routeros_session(creds) as api:
tuple(api(cmd="/system/package/update/install"))
except (LibRouterosError, OSError) as exc:
raise RouterOSError(f"upgrade install failed: {exc}") from exc
def push_firmware_via_ftp(
creds: RouterOSCredentials,
server: str,
port: int,
user: str,
password: str,
src_path: str,
dst_filename: str,
) -> None:
"""Загрузить файл с FTP-сервера контроллера на устройство (`/tool/fetch download`).
Используется для установки прошивки из локального репозитория без выгрузки на устройство.
"""
url = f"ftp://{server}:{port}/{src_path}"
try:
with routeros_session(creds) as api:
tuple(api(
cmd="/tool/fetch",
url=url, user=user, password=password,
mode="ftp", **{"dst-path": dst_filename},
))
except (LibRouterosError, OSError) as exc:
raise RouterOSError(f"fetch firmware failed: {exc}") from exc
def cmd_reboot_for_upgrade(creds: RouterOSCredentials) -> None:
"""`/system/reboot` — после загрузки .npk RouterOS установит апдейт при загрузке."""
cmd_reboot(creds)
+89
View File
@@ -0,0 +1,89 @@
"""Глобальные настройки контроллера: хранятся в БД как один JSON-блоб (key='global')."""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy.orm import Session
from ..models.settings import AppSetting
KEY = "global"
# Дефолтные значения. Нельзя менять ключи — только добавлять новые.
DEFAULTS: dict[str, Any] = {
# Брендинг и локализация интерфейса
"ui": {
"instance_name": "ROSzetta", # отображается в шапке
"locale": "ru", # ru | en | uz
"theme": "mk-dark", # см. фронтенд theme.ts
"heartbeat_hours": 6, # окно heartbeat-сетки на дашборде: 6 | 3 | 1 | 0.5
"probe_interval_minutes": 5, # автоопрос устройств: 1 | 2 | 3 | 5 | 10
},
# Видимость пунктов меню
"menu": {
"dashboard": True,
"devices": True,
"switches": True,
"firmware": True,
"notif_center": True,
"cli": True,
"settings": True,
},
# Включение/отключение генерации алертов и учёта в global health
"notify": {
"device_status": True, # переход up<->down
"internet": True, # отсутствие интернета на устройстве
"abnormal_reboot": True, # аномальная перезагрузка
"firmware": True, # вышла новая версия RouterOS
"style": "jokes", # стиль сообщений GlobalHealth: jokes | serious
},
# Telegram-бот (опциональная отправка алертов)
"telegram": {
"enabled": False,
"bot_token": "",
"chat_id": "",
"min_severity": "warning", # info|warning|error|critical
},
}
def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
out = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = _merge(out[k], v)
else:
out[k] = v
return out
def get_settings_dict(db: Session) -> dict[str, Any]:
row = db.query(AppSetting).filter(AppSetting.key == KEY).first()
if not row:
return json.loads(json.dumps(DEFAULTS))
try:
stored = json.loads(row.value)
except Exception:
stored = {}
return _merge(DEFAULTS, stored if isinstance(stored, dict) else {})
def update_settings_dict(db: Session, patch: dict[str, Any]) -> dict[str, Any]:
current = get_settings_dict(db)
merged = _merge(current, patch)
row = db.query(AppSetting).filter(AppSetting.key == KEY).first()
if not row:
row = AppSetting(key=KEY, value=json.dumps(merged))
db.add(row)
else:
row.value = json.dumps(merged)
db.commit()
return merged
_SEVERITY_RANK = {"info": 0, "warning": 1, "error": 2, "critical": 3}
def severity_meets(actual: str, threshold: str) -> bool:
return _SEVERITY_RANK.get(actual, 0) >= _SEVERITY_RANK.get(threshold, 1)
+31
View File
@@ -0,0 +1,31 @@
"""Опциональная отправка сообщений в Telegram-бот."""
from __future__ import annotations
import httpx
from loguru import logger
def send_message(bot_token: str, chat_id: str, text: str) -> bool:
if not bot_token or not chat_id:
return False
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
try:
r = httpx.post(
url,
json={"chat_id": chat_id, "text": text, "parse_mode": "HTML", "disable_web_page_preview": True},
timeout=8.0,
)
if r.status_code != 200:
logger.warning("telegram send failed: {} {}", r.status_code, r.text[:200])
return False
return True
except Exception as exc: # noqa: BLE001
logger.warning("telegram send error: {}", exc)
return False
def test_credentials(bot_token: str, chat_id: str) -> tuple[bool, str]:
if not bot_token or not chat_id:
return False, "Не заданы bot_token или chat_id"
ok = send_message(bot_token, chat_id, "<b>ROSzetta</b>\nТестовое сообщение \u2705")
return (ok, "OK" if ok else "Не удалось отправить (см. логи)")