start
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user