Control Server (FastAPI) с WEB UI (Jinja2 + HTMX) и JSON API для группового администрирования устройств RouterOS 7 через REST. - Устройства: список, статус (модель, канал, версии ROS/FW, uptime, доступные обновления), примечания, пароли шифруются (Fernet); фоновый опрос каждые 30 с и быстрое обнаружение недоступности (таймаут соединения 4 с). - Группы устройств и фильтры (устройства, резервные копии). - Резервные копии: .backup и .rsc (show-sensitive) создаются через REST, скачиваются сервером и загружаются в S3 (Yandex Object Storage). - Обновление ROS/FW и выбор канала, групповые операции задачами. - Интерфейс по утверждённому макету: светлая/тёмная темы, окна, шрифты IBM Plex локально. Docker Compose, SQLite в томе, минимальный набор тестов. - Планы и итоги каждого изменения — в docs/changes/. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
186 lines
9.4 KiB
Python
186 lines
9.4 KiB
Python
"""Операции над устройством через RouterOS REST. Не знают про БД и S3."""
|
|
import asyncio
|
|
import base64
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from app.ros.client import RosClient, RosError
|
|
|
|
CHANNELS = ("stable", "long-term", "testing", "development")
|
|
|
|
|
|
async def get_status(c: RosClient, check_updates: bool = True) -> dict:
|
|
"""Собирает статус: модель, канал, версии ROS/FW, uptime и доступные обновления.
|
|
|
|
latest-version в system/package/update появляется только после check-for-updates
|
|
(устройство обращается к серверам MikroTik). Сбой проверки не считается сбоем опроса.
|
|
"""
|
|
resource, board, update = await asyncio.gather(
|
|
c.get("system/resource"),
|
|
c.get("system/routerboard"),
|
|
c.get("system/package/update"),
|
|
)
|
|
check_error = None
|
|
if check_updates:
|
|
try:
|
|
await c.post("system/package/update/check-for-updates", timeout=30)
|
|
update = await c.get("system/package/update")
|
|
except RosError as e:
|
|
check_error = str(e)
|
|
is_board = str(board.get("routerboard", "false")) == "true"
|
|
return {
|
|
"model": (board.get("model") if is_board else None) or resource.get("board-name"),
|
|
"channel": update.get("channel"),
|
|
"ros_version": resource.get("version"),
|
|
"ros_installed": update.get("installed-version"), # без суффикса «(stable)» — для сравнения
|
|
"ros_latest": update.get("latest-version"),
|
|
"ros_update_status": update.get("status"),
|
|
"ros_check_error": check_error,
|
|
"fw_current": board.get("current-firmware") if is_board else None,
|
|
"fw_upgrade": board.get("upgrade-firmware") if is_board else None,
|
|
"uptime": resource.get("uptime"),
|
|
}
|
|
|
|
|
|
async def wait_file(c: RosClient, name: str, attempts: int = 15) -> str:
|
|
"""Ждёт появления файла на устройстве и возвращает его .id."""
|
|
for _ in range(attempts):
|
|
files = await c.get("file", name=name)
|
|
if files:
|
|
return files[0][".id"]
|
|
await asyncio.sleep(1)
|
|
raise RosError(f"Файл {name} не появился на устройстве")
|
|
|
|
|
|
async def create_backup_files(c: RosClient, base: str) -> tuple[str, str]:
|
|
"""Создаёт бинарный бэкап (без шифрования) и .rsc-экспорт (с секретами). Возвращает имена файлов."""
|
|
binary, rsc = f"{base}.backup", f"{base}.rsc"
|
|
await c.post("system/backup/save", {"name": base, "dont-encrypt": "yes"})
|
|
# show-sensitive: в .rsc попадают пароли, ключи и секреты — из файла можно восстановить всё, что их использует
|
|
await c.post("export", {"file": base, "show-sensitive": ""})
|
|
await wait_file(c, binary)
|
|
await wait_file(c, rsc)
|
|
return binary, rsc
|
|
|
|
|
|
async def download_file(c: RosClient, name: str, dest: Path, chunk_size: int = 32768) -> int:
|
|
"""Скачивает файл блоками через REST (/file/read). REST не отдаёт содержимое больших файлов
|
|
через GET /file, а бинарные данные в JSON ненадёжны, поэтому блок читается скриптом и
|
|
передаётся в base64. Возвращает размер файла; сверяет его с размером на устройстве."""
|
|
files = await c.get("file", name=name)
|
|
if not files:
|
|
raise RosError(f"Файл {name} не найден на устройстве")
|
|
size = int(files[0]["size"])
|
|
received = 0
|
|
with dest.open("wb") as f:
|
|
while received < size:
|
|
script = (f':put [:convert to=base64 ([/file/read file="{name}" offset={received} '
|
|
f'chunk-size={chunk_size} as-value]->"data")]')
|
|
res = await c.post("execute", {"script": script, "as-string": ""})
|
|
chunk = base64.b64decode((res or {}).get("ret", ""))
|
|
if not chunk:
|
|
raise RosError(f"Пустой блок при чтении {name} со смещения {received}")
|
|
f.write(chunk)
|
|
received += len(chunk)
|
|
if received != size:
|
|
raise RosError(f"Размер {name}: получено {received} из {size} байт")
|
|
return size
|
|
|
|
|
|
async def remove_file(c: RosClient, filename: str) -> None:
|
|
files = await c.get("file", name=filename)
|
|
for f in files or []:
|
|
await c.delete(f"file/{f['.id']}")
|
|
|
|
|
|
async def set_channel(c: RosClient, channel: str) -> None:
|
|
if channel not in CHANNELS:
|
|
raise ValueError(f"Неизвестный канал: {channel}")
|
|
await c.post("system/package/update/set", {"channel": channel})
|
|
|
|
|
|
async def install_ros_update(c: RosClient) -> str:
|
|
"""Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания."""
|
|
await c.post("system/package/update/check-for-updates", timeout=120)
|
|
u = await c.get("system/package/update")
|
|
installed, latest = u.get("installed-version"), u.get("latest-version")
|
|
if not latest or installed == latest:
|
|
return f"Обновление не требуется (версия {installed})"
|
|
try:
|
|
await c.post("system/package/update/install", timeout=15)
|
|
except RosError as e:
|
|
# после скачивания устройство уходит в перезагрузку и рвёт соединение — это штатно
|
|
if e.status is not None:
|
|
raise
|
|
return f"Обновление {installed} → {latest} запущено, устройство перезагрузится"
|
|
|
|
|
|
FW_DONE_MSG = "Firmware upgraded successfully, please reboot for changes to take effect!"
|
|
_UPTIME_RE = re.compile(r"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
|
|
|
|
|
|
def parse_uptime(value: str | None) -> int | None:
|
|
"""'1w2d3h4m5s' -> секунды; None, если формат не распознан."""
|
|
m = _UPTIME_RE.match(value or "")
|
|
if not m or not any(m.groups()):
|
|
return None
|
|
w, d, h, mi, sec = (int(x or 0) for x in m.groups())
|
|
return (((w * 7 + d) * 24 + h) * 60 + mi) * 60 + sec
|
|
|
|
|
|
def _ts(value: str | None) -> datetime | None:
|
|
try:
|
|
return datetime.strptime(value or "", "%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
async def _fw_done_entries(c: RosClient) -> list[dict]:
|
|
# фильтр REST ?message=… по точному тексту ничего не возвращает — фильтруем сами
|
|
log = await c.get("log", **{".proplist": ".id,time,message"})
|
|
return [e for e in log or [] if e.get("message") == FW_DONE_MSG]
|
|
|
|
|
|
async def _booted_at(c: RosClient) -> datetime | None:
|
|
"""Время последней загрузки устройства по его часам (в той же зоне, что и время журнала)."""
|
|
clock, res = await asyncio.gather(c.get("system/clock"), c.get("system/resource"))
|
|
now = _ts(f"{clock.get('date')} {clock.get('time')}")
|
|
up = parse_uptime(res.get("uptime"))
|
|
return now - timedelta(seconds=up) if now and up is not None else None
|
|
|
|
|
|
async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str:
|
|
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
|
|
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
|
|
поэтому по нему завершение определить нельзя."""
|
|
board = await c.get("system/routerboard")
|
|
if str(board.get("routerboard", "false")) != "true":
|
|
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
|
|
if board.get("current-firmware") == board.get("upgrade-firmware"):
|
|
return f"Прошивка актуальна ({board.get('current-firmware')})"
|
|
|
|
before = await _fw_done_entries(c)
|
|
booted = await _booted_at(c)
|
|
if booted and any((t := _ts(e.get("time"))) and t >= booted for e in before):
|
|
# прошивка уже записана после последней загрузки, ждём только перезагрузки
|
|
await _reboot(c)
|
|
return "Прошивка уже записана (запись в журнале), перезагрузка отправлена"
|
|
|
|
known = {e[".id"] for e in before}
|
|
await c.post("system/routerboard/upgrade")
|
|
for _ in range(int(wait_seconds / poll) if poll else 1000):
|
|
if any(e[".id"] not in known for e in await _fw_done_entries(c)):
|
|
await _reboot(c)
|
|
return f"Прошивка обновлена до {board.get('upgrade-firmware')}, перезагрузка отправлена"
|
|
await asyncio.sleep(poll)
|
|
raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время")
|
|
|
|
|
|
async def _reboot(c: RosClient) -> None:
|
|
try:
|
|
await c.post("system/reboot", timeout=10)
|
|
except RosError as e:
|
|
if e.status is not None: # обрыв соединения при перезагрузке — штатно
|
|
raise
|