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