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
+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)