Files
ROSzetta/backend/app/api/v1/devices.py
T
2026-05-17 20:54:53 +05:00

495 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...core.security import decrypt_secret, encrypt_secret
from ...models.device import Device
from ...models.metric import DeviceMetric
from ...models.user import User
from ...schemas.device import (
DeviceCreate,
DeviceOut,
DeviceResource,
DeviceUpdate,
)
from ...services.events import add_alert, add_audit
from ...services.routeros.client import (
RouterOSCredentials,
RouterOSError,
check_internet,
cmd_reboot,
cmd_safe_mode,
cmd_upgrade_check,
cmd_upgrade_install,
fetch_dhcp_leases,
fetch_identity,
fetch_interface_stats,
fetch_resource,
parse_uptime,
push_firmware_via_ftp,
)
from ..deps import get_current_user, require_role
router = APIRouter()
def _creds(d: Device) -> RouterOSCredentials:
return RouterOSCredentials(
host=d.host,
username=d.username,
password=decrypt_secret(d.password_enc),
port=d.port,
use_tls=d.use_tls,
)
@router.get("", response_model=list[DeviceOut])
def list_devices(
kind: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Device]:
q = db.query(Device)
if kind:
q = q.filter(Device.kind == kind)
return q.order_by(Device.id.desc()).all()
@router.post("", response_model=DeviceOut, status_code=status.HTTP_201_CREATED)
def create_device(
payload: DeviceCreate,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> Device:
d = Device(
name=payload.name,
host=payload.host,
port=payload.port,
use_tls=payload.use_tls,
username=payload.username,
password_enc=encrypt_secret(payload.password),
kind=payload.kind or "router",
)
db.add(d)
db.commit()
db.refresh(d)
return d
@router.get("/{device_id}", response_model=DeviceOut)
def get_device(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Device:
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
return d
@router.patch("/{device_id}", response_model=DeviceOut)
def update_device(
device_id: int,
payload: DeviceUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> Device:
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
data = payload.model_dump(exclude_unset=True)
if "password" in data:
d.password_enc = encrypt_secret(data.pop("password"))
for k, v in data.items():
setattr(d, k, v)
db.commit()
db.refresh(d)
return d
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_device(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> Response:
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
db.delete(d)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{device_id}/probe", response_model=DeviceResource)
def probe_device(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> DeviceResource:
"""Подключиться к устройству, прочитать `/system/resource` и обновить
метаданные (identity, model, serial, version, status)."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
res = fetch_resource(_creds(d))
identity = fetch_identity(_creds(d))
except RouterOSError as exc:
d.status = "down"
d.last_error = str(exc)
db.commit()
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
d.identity = identity or d.identity
d.model = res.get("board-name") or d.model
d.ros_version = res.get("version") or d.ros_version
d.architecture = res.get("architecture-name") or d.architecture
prev_status = d.status
d.status = "up"
d.last_error = None
d.last_seen = datetime.now(timezone.utc)
def _to_int(v):
try:
return int(v) if v is not None else None
except (TypeError, ValueError):
return None
cpu = _to_int(res.get("cpu-load"))
free_mem = _to_int(res.get("free-memory"))
total_mem = _to_int(res.get("total-memory"))
uptime_s = parse_uptime(res.get("uptime"))
# abnormal reboot detection: новый uptime < предыдущего и отличие > 60s
abnormal = False
if uptime_s is not None and d.last_uptime_seconds is not None:
if uptime_s < d.last_uptime_seconds - 60:
abnormal = True
d.abnormal_reboot = True
add_alert(
db,
severity="warning",
category="abnormal_reboot",
source=f"device:{d.id}",
title=f"Возможен аварийный перезапуск: {d.identity or d.name}",
message=(
f"Uptime упал с {d.last_uptime_seconds}s до {uptime_s}s "
f"без штатной команды reboot."
),
)
if not abnormal:
d.abnormal_reboot = False
d.last_uptime_seconds = uptime_s
# internet check
try:
ok = check_internet(_creds(d))
d.internet_ok = ok
if not ok:
add_alert(
db,
severity="warning",
category="internet",
source=f"device:{d.id}",
title=f"Нет интернета на {d.identity or d.name}",
message="Ping 8.8.8.8 не прошёл.",
)
except Exception:
d.internet_ok = None
# уведомление о возврате в строй
if prev_status == "down" and d.status == "up":
add_alert(
db,
severity="info",
category="device",
source=f"device:{d.id}",
title=f"Устройство снова онлайн: {d.identity or d.name}",
)
mem_used_pct = None
if free_mem is not None and total_mem and total_mem > 0:
mem_used_pct = round(100 - (free_mem / total_mem) * 100, 1)
metric = DeviceMetric(
device_id=d.id,
cpu_load=float(cpu) if cpu is not None else None,
mem_used_pct=mem_used_pct,
free_memory=free_mem,
total_memory=total_mem,
uptime_seconds=uptime_s,
internet_ok=d.internet_ok,
)
db.add(metric)
db.commit()
return DeviceResource(
cpu_load=cpu,
free_memory=free_mem,
total_memory=total_mem,
uptime=res.get("uptime"),
version=res.get("version"),
board_name=res.get("board-name"),
architecture_name=res.get("architecture-name"),
)
@router.post("/{device_id}/reboot", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def reboot_device(
device_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> Response:
"""Отправить команду перезагрузки устройству."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
cmd_reboot(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
add_audit(db, actor=user.email, action="device.reboot", target=f"device:{device_id}")
add_alert(db, severity="info", category="device", source=f"device:{device_id}",
title=f"Reboot отправлен: {d.identity or d.name}", message=f"by {user.email}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{device_id}/safe-mode", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def toggle_safe_mode(
device_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> Response:
"""Переключить safe mode на устройстве."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
cmd_safe_mode(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
add_audit(db, actor=user.email, action="device.safe_mode", target=f"device:{device_id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ---------- Sprint 09: интерфейсы / DHCP / upgrade ----------
@router.get("/{device_id}/interfaces")
def list_interfaces(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Список интерфейсов устройства со счётчиками rx/tx и running."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
return fetch_interface_stats(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
@router.get("/{device_id}/interface-traffic")
def interface_traffic(
device_id: int,
names: str | None = None,
hours: float = 24.0,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict:
"""Серии bps по выбранным интерфейсам за окно `hours`.
`names` — CSV. Если пусто — берётся из `device.monitored_interfaces`.
Возвращает {"series": {name: [{ts, rx_bps, tx_bps, running}]}}.
"""
from ...models.interface_stat import InterfaceStat
from datetime import datetime, timedelta, timezone
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
if not names:
names = d.monitored_interfaces or ""
name_list = [x.strip() for x in names.split(",") if x.strip()]
if not name_list:
return {"series": {}, "hours": hours}
since = datetime.now(timezone.utc) - timedelta(hours=hours)
rows = (
db.query(InterfaceStat)
.filter(
InterfaceStat.device_id == device_id,
InterfaceStat.name.in_(name_list),
InterfaceStat.ts >= since,
)
.order_by(InterfaceStat.name.asc(), InterfaceStat.ts.asc())
.all()
)
by_name: dict[str, list] = {n: [] for n in name_list}
last: dict[str, tuple] = {}
for r in rows:
prev = last.get(r.name)
rx_bps = tx_bps = None
if prev is not None:
dt = (r.ts - prev[0]).total_seconds()
if dt > 0:
# счётчики могут сброситься после reboot — игнорируем отрицательные дельты
drx = r.rx_bytes - prev[1]
dtx = r.tx_bytes - prev[2]
if drx >= 0 and dtx >= 0:
rx_bps = round(drx * 8 / dt)
tx_bps = round(dtx * 8 / dt)
by_name[r.name].append({
"ts": r.ts.isoformat(),
"rx_bps": rx_bps,
"tx_bps": tx_bps,
"running": r.running,
})
last[r.name] = (r.ts, r.rx_bytes, r.tx_bytes)
return {"series": by_name, "hours": hours}
@router.get("/{device_id}/uplink-status")
def uplink_status(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Текущий статус выбранных аплинков (running) — по последней записи."""
from ...models.interface_stat import InterfaceStat
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
name_list = [x.strip() for x in (d.uplink_interfaces or "").split(",") if x.strip()]
out = []
for n in name_list:
last = (
db.query(InterfaceStat)
.filter(InterfaceStat.device_id == device_id, InterfaceStat.name == n)
.order_by(InterfaceStat.ts.desc()).first()
)
out.append({
"name": n,
"running": bool(last.running) if last else None,
"ts": last.ts.isoformat() if last else None,
})
return out
@router.get("/{device_id}/dhcp-leases")
def dhcp_leases(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Список выданных DHCP-лизов по всем DHCP-серверам устройства."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
return fetch_dhcp_leases(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
@router.post("/{device_id}/upgrade/internet")
def upgrade_from_internet(
device_id: int,
channel: str = "stable",
install: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> dict:
"""Запросить у MikroTik проверку обновления и при `install=true` — установить.
Идёт через штатный `/system/package/update` (репозиторий MikroTik).
Установка перезагрузит устройство.
"""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
info = cmd_upgrade_check(_creds(d), channel=channel)
if install:
cmd_upgrade_install(_creds(d))
add_audit(db, actor=user.email, action="device.upgrade.internet",
target=f"device:{device_id}", detail=f"channel={channel}")
add_alert(db, severity="info", category="firmware",
source=f"device:{device_id}",
title=f"Обновление из интернета запущено: {d.identity or d.name}",
message=f"by {user.email}, channel={channel}")
db.commit()
return {"ok": True, "info": info, "installed": bool(install)}
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
@router.post("/{device_id}/upgrade/local")
def upgrade_from_local(
device_id: int,
firmware_id: int,
reboot: bool = True,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> dict:
"""Установить прошивку из локального репозитория контроллера.
Файл прошивки временно публикуется во встроенный FTP, устройство сам
скачивает его командой `/tool/fetch`, затем (опц.) перезагружается —
RouterOS установит .npk при загрузке.
"""
from ...models.firmware import Firmware
from ...services.backup_ftp_server import get_server, detect_push_host
from ...core.config import get_settings as _cfg
import os
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
fw = db.get(Firmware, firmware_id)
if not fw:
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
if not fw.content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "firmware has no payload")
srv = get_server()
if srv is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "backup ftp server not running")
cfg = _cfg()
push_host = cfg.backup_push_host or detect_push_host()
if not push_host:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "BACKUP_PUSH_HOST not configured")
sess = srv.open_session([fw.name])
try:
path = os.path.join(sess.home_dir, fw.name)
with open(path, "wb") as f:
f.write(fw.content)
try:
push_firmware_via_ftp(
_creds(d),
server=push_host, port=int(cfg.backup_ftp_port),
user=sess.username, password=sess.password,
src_path=fw.name, dst_filename=fw.name,
)
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
if reboot:
try:
cmd_reboot(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
add_audit(db, actor=user.email, action="device.upgrade.local",
target=f"device:{device_id}", detail=f"firmware={fw.name}")
add_alert(db, severity="info", category="firmware",
source=f"device:{device_id}",
title=f"Установлена локальная прошивка: {d.identity or d.name}",
message=f"{fw.name} by {user.email}")
db.commit()
return {"ok": True, "file": fw.name, "reboot": reboot}
finally:
srv.close_session(sess.session_id)