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
+494
View File
@@ -0,0 +1,494 @@
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)