start
This commit is contained in:
@@ -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"
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 "Не удалось отправить (см. логи)")
|
||||
Reference in New Issue
Block a user