start
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.db import get_db
|
||||
from ..core.security import decode_token
|
||||
from ..models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "wrong token type")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user = db.query(User).filter(User.id == int(user_id)).first() if user_id else None
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found or inactive")
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles: str):
|
||||
def _checker(user: User = Depends(get_current_user)) -> User:
|
||||
if roles and user.role not in roles:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "insufficient permissions")
|
||||
return user
|
||||
|
||||
return _checker
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .v1 import alerts as alerts_router
|
||||
from .v1 import auth as auth_router
|
||||
from .v1 import backups as backups_router
|
||||
from .v1 import cli as cli_router
|
||||
from .v1 import controller_backup as controller_backup_router
|
||||
from .v1 import devices as devices_router
|
||||
from .v1 import firmware as firmware_router
|
||||
from .v1 import health as health_router
|
||||
from .v1 import metrics as metrics_router
|
||||
from .v1 import settings as settings_router
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health_router.router, tags=["health"])
|
||||
api_router.include_router(auth_router.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(devices_router.router, prefix="/devices", tags=["devices"])
|
||||
api_router.include_router(backups_router.router, tags=["backups"])
|
||||
api_router.include_router(firmware_router.router, prefix="/firmware", tags=["firmware"])
|
||||
api_router.include_router(alerts_router.router, prefix="/alerts", tags=["alerts"])
|
||||
api_router.include_router(metrics_router.router, tags=["metrics"])
|
||||
api_router.include_router(cli_router.router, prefix="/cli", tags=["cli"])
|
||||
api_router.include_router(controller_backup_router.router, prefix="/controller/backup", tags=["controller"])
|
||||
api_router.include_router(settings_router.router, prefix="/settings", tags=["settings"])
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.db import get_db
|
||||
from ...models.alert import Alert
|
||||
from ...models.user import User
|
||||
from ...schemas.alert import AlertOut
|
||||
from ..deps import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[AlertOut])
|
||||
def list_alerts(
|
||||
only_unack: bool = False,
|
||||
limit: int = 200,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[Alert]:
|
||||
q = db.query(Alert)
|
||||
if only_unack:
|
||||
q = q.filter(Alert.acknowledged.is_(False))
|
||||
return q.order_by(Alert.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
def unread_count(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict[str, int]:
|
||||
n = db.query(Alert).filter(Alert.acknowledged.is_(False)).count()
|
||||
return {"count": n}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/ack", response_model=AlertOut)
|
||||
def acknowledge(
|
||||
alert_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Alert:
|
||||
a = db.get(Alert, alert_id)
|
||||
if not a:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "alert not found")
|
||||
a.acknowledged = True
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return a
|
||||
|
||||
|
||||
@router.post("/ack-all")
|
||||
def acknowledge_all(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict[str, int]:
|
||||
n = db.query(Alert).filter(Alert.acknowledged.is_(False)).update({"acknowledged": True})
|
||||
db.commit()
|
||||
return {"updated": n}
|
||||
|
||||
|
||||
@router.delete("/{alert_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_alert(
|
||||
alert_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> Response:
|
||||
a = db.get(Alert, alert_id)
|
||||
if not a:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "alert not found")
|
||||
db.delete(a)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def purge_alerts(
|
||||
only_acked: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
"""Очистить лог алертов. По умолчанию удаляет всё; only_acked=true — только прочитанные."""
|
||||
q = db.query(Alert)
|
||||
if only_acked:
|
||||
q = q.filter(Alert.acknowledged == True) # noqa: E712
|
||||
n = q.delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return {"deleted": int(n or 0)}
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.db import get_db
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from ...models.user import User
|
||||
from ...schemas.auth import LoginIn, RefreshIn, TokenPair, UserOut
|
||||
from ...services.events import add_audit
|
||||
from ..deps import get_current_user
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
current: str
|
||||
new: str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _issue(user: User) -> TokenPair:
|
||||
return TokenPair(
|
||||
access_token=create_access_token(user.id, extra={"role": user.role}),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
def _client_ip(req: Request) -> str | None:
|
||||
fwd = req.headers.get("x-forwarded-for")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return req.client.host if req.client else None
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
def login_json(payload: LoginIn, request: Request, db: Session = Depends(get_db)) -> TokenPair:
|
||||
user = db.query(User).filter(User.email == payload.email).first()
|
||||
ip = _client_ip(request)
|
||||
if not user or not verify_password(payload.password, user.hashed_password):
|
||||
add_audit(db, actor=payload.email, action="login.fail", ip=ip, detail="invalid credentials")
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||
if not user.is_active:
|
||||
add_audit(db, actor=payload.email, action="login.fail", ip=ip, detail="user disabled")
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "user disabled")
|
||||
add_audit(db, actor=user.email, action="login.success", ip=ip)
|
||||
return _issue(user)
|
||||
|
||||
|
||||
@router.post("/login/form", response_model=TokenPair, include_in_schema=False)
|
||||
def login_form(
|
||||
request: Request,
|
||||
form: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TokenPair:
|
||||
"""Совместимость со Swagger «Authorize»."""
|
||||
user = db.query(User).filter(User.email == form.username).first()
|
||||
ip = _client_ip(request)
|
||||
if not user or not verify_password(form.password, user.hashed_password):
|
||||
add_audit(db, actor=form.username, action="login.fail", ip=ip, detail="invalid credentials")
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||
add_audit(db, actor=user.email, action="login.success", ip=ip)
|
||||
return _issue(user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair)
|
||||
def refresh(payload: RefreshIn, db: Session = Depends(get_db)) -> TokenPair:
|
||||
try:
|
||||
data = decode_token(payload.refresh_token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
|
||||
if data.get("type") != "refresh":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "wrong token type")
|
||||
user = db.query(User).filter(User.id == int(data["sub"])).first()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
|
||||
return _issue(user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: User = Depends(get_current_user)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
payload: ChangePasswordIn,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
if not verify_password(payload.current, user.hashed_password):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Текущий пароль неверный")
|
||||
if len(payload.new) < 4:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Новый пароль слишком короткий")
|
||||
user.hashed_password = hash_password(payload.new)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from ...models.backup import DeviceBackup
|
||||
from ...models.device import Device
|
||||
from ...models.user import User
|
||||
from ...schemas.backup import BackupOut
|
||||
from ...services.routeros.backup import create_and_download_backup
|
||||
from ...services.routeros.client import RouterOSCredentials, RouterOSError
|
||||
from ...services.backup_ftp_server import detect_push_host
|
||||
from ...core.config import get_settings
|
||||
from ..deps import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_BACKUPS_PER_DEVICE = 10
|
||||
|
||||
|
||||
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,
|
||||
timeout=15.0,
|
||||
)
|
||||
|
||||
|
||||
def _rotate(db: Session, device_id: int) -> None:
|
||||
"""Удаляет старые записи, если их больше MAX_BACKUPS_PER_DEVICE.
|
||||
Считаем по уникальному base_name (.backup и .rsc — одна пара)."""
|
||||
rows = (
|
||||
db.query(DeviceBackup)
|
||||
.filter(DeviceBackup.device_id == device_id)
|
||||
.order_by(DeviceBackup.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
seen: set[str] = set()
|
||||
keep_ids: set[int] = set()
|
||||
for r in rows:
|
||||
base = r.filename.rsplit(".", 1)[0]
|
||||
if base in seen or len(seen) < MAX_BACKUPS_PER_DEVICE:
|
||||
seen.add(base)
|
||||
keep_ids.add(r.id)
|
||||
for r in rows:
|
||||
if r.id not in keep_ids:
|
||||
db.delete(r)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}/backups", response_model=list[BackupOut])
|
||||
def list_backups(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[DeviceBackup]:
|
||||
if not db.get(Device, device_id):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
|
||||
return (
|
||||
db.query(DeviceBackup)
|
||||
.filter(DeviceBackup.device_id == device_id)
|
||||
.order_by(DeviceBackup.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/devices/{device_id}/backups",
|
||||
response_model=list[BackupOut],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_backup(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin", "operator")),
|
||||
) -> list[DeviceBackup]:
|
||||
"""Создать бэкап (binary + text), скачать через SFTP, сохранить в БД."""
|
||||
d = db.get(Device, device_id)
|
||||
if not d:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
# router id = identity устройства (как оно зовётся в RouterOS),
|
||||
# fallback: name из БД, потом host. Чистим до [A-Za-z0-9_-].
|
||||
raw_id = (d.identity or d.name or d.host or "device").strip()
|
||||
safe_id = re.sub(r"[^A-Za-z0-9_-]+", "_", raw_id).strip("_") or "device"
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
base = f"{safe_id}-{ts}"
|
||||
|
||||
cfg = get_settings()
|
||||
push_host = cfg.backup_push_host or detect_push_host()
|
||||
push_port = cfg.backup_ftp_port
|
||||
|
||||
try:
|
||||
files = create_and_download_backup(
|
||||
_creds(d), base, push_host=push_host, push_port=push_port,
|
||||
)
|
||||
except RouterOSError as exc:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
|
||||
|
||||
rec_bin = DeviceBackup(
|
||||
device_id=d.id, filename=files.binary_name, fmt="binary",
|
||||
size=len(files.binary_data), content=files.binary_data,
|
||||
)
|
||||
rec_txt = DeviceBackup(
|
||||
device_id=d.id, filename=files.text_name, fmt="text",
|
||||
size=len(files.text_data), content=files.text_data,
|
||||
)
|
||||
db.add(rec_bin)
|
||||
db.add(rec_txt)
|
||||
db.commit()
|
||||
db.refresh(rec_bin)
|
||||
db.refresh(rec_txt)
|
||||
|
||||
_rotate(db, d.id)
|
||||
|
||||
return (
|
||||
db.query(DeviceBackup)
|
||||
.filter(DeviceBackup.device_id == device_id)
|
||||
.order_by(DeviceBackup.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/backups/{backup_id}/download")
|
||||
def download_backup(
|
||||
backup_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
rec = db.get(DeviceBackup, backup_id)
|
||||
if not rec:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "backup not found")
|
||||
media_type = "application/octet-stream" if rec.fmt == "binary" else "text/plain; charset=utf-8"
|
||||
return Response(
|
||||
content=rec.content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{rec.filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/backups/{backup_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_backup(
|
||||
backup_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> Response:
|
||||
rec = db.get(DeviceBackup, backup_id)
|
||||
if not rec:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "backup not found")
|
||||
db.delete(rec)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.db import get_db
|
||||
from ...core.security import decrypt_secret
|
||||
from ...models.device import Device
|
||||
from ...models.user import User
|
||||
from ...services.events import add_audit
|
||||
from ...services.routeros.client import (
|
||||
RouterOSCredentials,
|
||||
RouterOSError,
|
||||
execute_cli,
|
||||
)
|
||||
from ..deps import require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Опасные команды требуют явного подтверждения через query ?confirm=1
|
||||
DANGEROUS_PREFIXES = (
|
||||
"/system/reboot",
|
||||
"/system/shutdown",
|
||||
"/system/reset-configuration",
|
||||
"/system/routerboard/upgrade",
|
||||
"/file/remove",
|
||||
)
|
||||
|
||||
|
||||
class CLIRunIn(BaseModel):
|
||||
device_ids: list[int] = Field(default_factory=list)
|
||||
command: str
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
class CLIDeviceResult(BaseModel):
|
||||
device_id: int
|
||||
device_name: str | None = None
|
||||
ok: bool
|
||||
rows: list[dict[str, Any]] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class CLIRunOut(BaseModel):
|
||||
command: str
|
||||
results: list[CLIDeviceResult]
|
||||
|
||||
|
||||
@router.post("/run", response_model=CLIRunOut)
|
||||
def run_cli(
|
||||
payload: CLIRunIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_role("admin", "operator")),
|
||||
) -> CLIRunOut:
|
||||
if not payload.device_ids:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "device_ids is empty")
|
||||
cmd = payload.command.strip()
|
||||
if not cmd:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "command is empty")
|
||||
|
||||
is_dangerous = any(cmd.startswith(p) for p in DANGEROUS_PREFIXES)
|
||||
if is_dangerous and not payload.confirm:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"dangerous command requires confirmation (set confirm=true)",
|
||||
)
|
||||
|
||||
results: list[CLIDeviceResult] = []
|
||||
for did in payload.device_ids:
|
||||
d = db.get(Device, did)
|
||||
if not d:
|
||||
results.append(CLIDeviceResult(device_id=did, ok=False, error="device not found"))
|
||||
continue
|
||||
try:
|
||||
rows = execute_cli(
|
||||
RouterOSCredentials(
|
||||
host=d.host,
|
||||
username=d.username,
|
||||
password=decrypt_secret(d.password_enc),
|
||||
port=d.port,
|
||||
use_tls=d.use_tls,
|
||||
timeout=10.0,
|
||||
),
|
||||
cmd,
|
||||
)
|
||||
results.append(
|
||||
CLIDeviceResult(device_id=did, device_name=d.identity or d.name, ok=True, rows=rows)
|
||||
)
|
||||
except RouterOSError as exc:
|
||||
results.append(
|
||||
CLIDeviceResult(device_id=did, device_name=d.identity or d.name, ok=False, error=str(exc))
|
||||
)
|
||||
add_audit(
|
||||
db,
|
||||
actor=user.email,
|
||||
action="cli.run",
|
||||
target=f"device:{did}",
|
||||
detail=cmd[:200],
|
||||
)
|
||||
|
||||
return CLIRunOut(command=cmd, results=results)
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
|
||||
from ...models.user import User
|
||||
from ...services.controller_backup import (
|
||||
make_config_only_archive,
|
||||
make_full_archive,
|
||||
restore_full_archive,
|
||||
)
|
||||
from ..deps import require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def download_config_backup(
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> Response:
|
||||
name, data = make_config_only_archive()
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/gzip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/full")
|
||||
def download_full_backup(
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> Response:
|
||||
try:
|
||||
name, data = make_full_archive()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(exc)) from exc
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/gzip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_backup(
|
||||
file: UploadFile = File(...),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
"""Развёртывание full-бэкапа (tar.gz с db.dump). Деструктивно: дропает текущую БД."""
|
||||
if not file.filename or not file.filename.endswith((".tar.gz", ".tgz")):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Ожидается файл .tar.gz")
|
||||
data = await file.read()
|
||||
if len(data) > 500 * 1024 * 1024:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Архив слишком большой (>500 MiB)")
|
||||
try:
|
||||
return restore_full_archive(data)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(exc)) from exc
|
||||
@@ -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)
|
||||
@@ -0,0 +1,359 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os.path
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.db import get_db
|
||||
from ...models.firmware import Firmware
|
||||
from ...models.user import User
|
||||
from ...schemas.firmware import (
|
||||
FirmwareBulkImportIn,
|
||||
FirmwareBulkOut,
|
||||
FirmwareBulkResult,
|
||||
FirmwareImportIn,
|
||||
FirmwareOut,
|
||||
FirmwareUpdateIn,
|
||||
)
|
||||
from ...services.firmware_check import CHANNELS, check_and_alert, get_state
|
||||
from ..deps import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_FIRMWARE_SIZE = 200 * 1024 * 1024 # 200 MiB лимит
|
||||
|
||||
# Известные архитектуры RouterOS v7 для bulk-импорта.
|
||||
KNOWN_ARCHITECTURES = [
|
||||
"arm64", "arm", "mipsbe", "mmips", "mipsle", "smips",
|
||||
"tile", "ppc", "x86", "x86_64",
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=list[FirmwareOut])
|
||||
def list_firmware(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[Firmware]:
|
||||
return db.query(Firmware).order_by(Firmware.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
def manual_check(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin", "operator")),
|
||||
) -> dict:
|
||||
"""Ручная проверка наличия новых версий RouterOS по всем каналам."""
|
||||
state = check_and_alert(db)
|
||||
if not state:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "upstream check failed")
|
||||
# Для совместимости со старым UI возвращаем top-level stable.
|
||||
stable = state.get("stable") or {}
|
||||
return {
|
||||
"latest_version": stable.get("version", ""),
|
||||
"released_at": stable.get("released_at", ""),
|
||||
"channels": state,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
def list_channels(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Текущее состояние по каждому каналу + список известных архитектур."""
|
||||
return {
|
||||
"channels": get_state(db),
|
||||
"available_channels": list(CHANNELS.keys()),
|
||||
"architectures": KNOWN_ARCHITECTURES,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import", response_model=FirmwareOut, status_code=status.HTTP_201_CREATED)
|
||||
def import_firmware(
|
||||
payload: FirmwareImportIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin", "operator")),
|
||||
) -> Firmware:
|
||||
"""Скачать прошивку с указанного URL и сохранить во внутреннем репозитории.
|
||||
|
||||
Если прошивка с таким же `source_url` или (`version`+`architecture`) уже
|
||||
есть — повторно не скачивается, возвращается существующая запись (HTTP 200
|
||||
с тем же телом, как и для свежесозданной).
|
||||
"""
|
||||
url = str(payload.url)
|
||||
|
||||
# 1) Дедуп по URL источника.
|
||||
existing = db.query(Firmware).filter(Firmware.source_url == url).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# 2) Дедуп по (version, architecture), если оба поля переданы.
|
||||
if payload.version and payload.architecture:
|
||||
existing = (
|
||||
db.query(Firmware)
|
||||
.filter(
|
||||
Firmware.version == payload.version,
|
||||
Firmware.architecture == payload.architecture,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
try:
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=120.0) as resp:
|
||||
resp.raise_for_status()
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_FIRMWARE_SIZE:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"download failed: {exc}") from exc
|
||||
|
||||
name = payload.name or os.path.basename(url.split("?")[0]) or "firmware.bin"
|
||||
sha = hashlib.sha256(data).hexdigest()
|
||||
|
||||
# 3) Дедуп по sha256 (на случай разных URL с тем же содержимым).
|
||||
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
rec = Firmware(
|
||||
name=name,
|
||||
version=payload.version,
|
||||
architecture=payload.architecture,
|
||||
channel=payload.channel,
|
||||
size=len(data),
|
||||
sha256=sha,
|
||||
source_url=url,
|
||||
content=data,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def _download_firmware_url(url: str) -> bytes:
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=180.0) as resp:
|
||||
resp.raise_for_status()
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_FIRMWARE_SIZE:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
@router.post("/import-bulk", response_model=FirmwareBulkOut)
|
||||
def import_bulk(
|
||||
payload: FirmwareBulkImportIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin", "operator")),
|
||||
) -> FirmwareBulkOut:
|
||||
"""Загрузить .npk для указанной версии по списку архитектур одним вызовом."""
|
||||
results: list[FirmwareBulkResult] = []
|
||||
base = "https://download.mikrotik.com/routeros"
|
||||
for arch in payload.architectures:
|
||||
url = f"{base}/{payload.version}/routeros-{payload.version}-{arch}.npk"
|
||||
# Дедуп до закачки: по URL или (version+architecture).
|
||||
existing = (
|
||||
db.query(Firmware)
|
||||
.filter(
|
||||
(Firmware.source_url == url)
|
||||
| ((Firmware.version == payload.version) & (Firmware.architecture == arch))
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
results.append(FirmwareBulkResult(
|
||||
architecture=arch, ok=True, firmware_id=existing.id, skipped=True,
|
||||
))
|
||||
continue
|
||||
try:
|
||||
data = _download_firmware_url(url)
|
||||
sha = hashlib.sha256(data).hexdigest()
|
||||
# Дедуп по содержимому.
|
||||
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
|
||||
if existing:
|
||||
results.append(FirmwareBulkResult(
|
||||
architecture=arch, ok=True, firmware_id=existing.id, skipped=True,
|
||||
))
|
||||
continue
|
||||
rec = Firmware(
|
||||
name=os.path.basename(url),
|
||||
version=payload.version,
|
||||
architecture=arch,
|
||||
channel=payload.channel,
|
||||
size=len(data),
|
||||
sha256=sha,
|
||||
source_url=url,
|
||||
content=data,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
results.append(FirmwareBulkResult(architecture=arch, ok=True, firmware_id=rec.id))
|
||||
except HTTPException as exc:
|
||||
results.append(FirmwareBulkResult(architecture=arch, ok=False, error=str(exc.detail)))
|
||||
except httpx.HTTPError as exc:
|
||||
results.append(FirmwareBulkResult(architecture=arch, ok=False, error=str(exc)))
|
||||
return FirmwareBulkOut(version=payload.version, channel=payload.channel, results=results)
|
||||
|
||||
|
||||
# routeros-7.16.1-arm64.npk / routeros-7.16.1-arm-7.16.1.npk и т.п.
|
||||
_FW_NAME_RE = re.compile(
|
||||
r"^routeros-(?P<version>\d+(?:\.\d+){1,2}(?:[a-z0-9.\-]*)?)-(?P<arch>[a-z0-9_]+)\.npk$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _guess_meta(filename: str) -> tuple[str | None, str | None]:
|
||||
"""Из имени файла вытащить (version, architecture). Возвращает (None, None) если не разобрали."""
|
||||
m = _FW_NAME_RE.match(filename.strip().lower())
|
||||
if not m:
|
||||
return None, None
|
||||
return m.group("version"), m.group("arch")
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FirmwareOut, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_firmware(
|
||||
file: UploadFile = File(..., description=".npk файл прошивки RouterOS"),
|
||||
name: str | None = Form(None),
|
||||
version: str | None = Form(None),
|
||||
architecture: str | None = Form(None),
|
||||
channel: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin", "operator")),
|
||||
) -> Firmware:
|
||||
"""Загрузка прошивки вручную с диска пользователя (multipart/form-data).
|
||||
|
||||
Если `version`/`architecture` не указаны — попытка распарсить из имени файла
|
||||
(формат `routeros-<version>-<arch>.npk`). Дедуп по sha256 / (version+architecture).
|
||||
"""
|
||||
fname = (name or file.filename or "firmware.bin").strip()
|
||||
if not fname.lower().endswith(".npk"):
|
||||
# Не блокируем строго, но предупреждаем — RouterOS принимает только .npk.
|
||||
# Разрешаем — пусть админ сам решает.
|
||||
pass
|
||||
|
||||
# Читаем тело с лимитом
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_FIRMWARE_SIZE:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
if not data:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "empty file")
|
||||
|
||||
# Автоопределение метаданных из имени файла
|
||||
if not version or not architecture:
|
||||
guessed_ver, guessed_arch = _guess_meta(fname)
|
||||
version = version or guessed_ver
|
||||
architecture = architecture or guessed_arch
|
||||
|
||||
sha = hashlib.sha256(data).hexdigest()
|
||||
|
||||
# Дедуп: по sha256 → возвращаем существующую запись
|
||||
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
|
||||
if existing:
|
||||
return existing
|
||||
# Дедуп по (version, architecture)
|
||||
if version and architecture:
|
||||
existing = (
|
||||
db.query(Firmware)
|
||||
.filter(
|
||||
Firmware.version == version,
|
||||
Firmware.architecture == architecture,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
rec = Firmware(
|
||||
name=fname,
|
||||
version=version,
|
||||
architecture=architecture,
|
||||
channel=channel,
|
||||
size=len(data),
|
||||
sha256=sha,
|
||||
source_url=None,
|
||||
content=data,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
@router.patch("/{firmware_id}", response_model=FirmwareOut)
|
||||
def update_firmware(
|
||||
firmware_id: int,
|
||||
payload: FirmwareUpdateIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin", "operator")),
|
||||
) -> Firmware:
|
||||
rec = db.get(Firmware, firmware_id)
|
||||
if not rec:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
|
||||
for k, v in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(rec, k, v)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
@router.get("/{firmware_id}/download")
|
||||
def download_firmware(
|
||||
firmware_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
rec = db.get(Firmware, firmware_id)
|
||||
if not rec:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
|
||||
return Response(
|
||||
content=rec.content,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{rec.name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{firmware_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_firmware(
|
||||
firmware_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> Response:
|
||||
rec = db.get(Firmware, firmware_id)
|
||||
if not rec:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
|
||||
db.delete(rec)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
APP_NAME = "ROSzetta"
|
||||
APP_VERSION = "0.6.0"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
def version() -> dict[str, str]:
|
||||
return {"name": APP_NAME, "version": APP_VERSION}
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.db import get_db
|
||||
from ...models.device import Device
|
||||
from ...models.metric import DeviceMetric
|
||||
from ...models.user import User
|
||||
from ...schemas.metric import MetricPoint
|
||||
from ..deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/devices/{device_id}/metrics", response_model=list[MetricPoint])
|
||||
def get_metrics(
|
||||
device_id: int,
|
||||
hours: int = 24,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[MetricPoint]:
|
||||
if not db.get(Device, device_id):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=max(1, min(hours, 24 * 30)))
|
||||
rows = (
|
||||
db.query(DeviceMetric)
|
||||
.filter(DeviceMetric.device_id == device_id, DeviceMetric.created_at >= since)
|
||||
.order_by(DeviceMetric.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
MetricPoint(
|
||||
ts=r.created_at,
|
||||
cpu_load=r.cpu_load,
|
||||
mem_used_pct=r.mem_used_pct,
|
||||
uptime_seconds=r.uptime_seconds,
|
||||
internet_ok=r.internet_ok,
|
||||
rx_bps=r.rx_bps,
|
||||
tx_bps=r.tx_bps,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/heartbeat")
|
||||
def heartbeat(
|
||||
hours: float = 24,
|
||||
bins: int = 48,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Сводка статусов всех устройств по бинам времени для heartbeat-графика.
|
||||
|
||||
Каждый бин получает один из статусов:
|
||||
- "up" — есть метрика, internet_ok != False
|
||||
- "no-net" — есть метрика, internet_ok == False
|
||||
- "down" — нет ни одной метрики в окне
|
||||
- "none" — нет данных вообще
|
||||
Приоритет внутри бина: down/no-net > up.
|
||||
"""
|
||||
hours = max(0.25, min(float(hours), 24 * 7))
|
||||
bins = max(6, min(bins, 288))
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(hours=hours)
|
||||
bin_seconds = (hours * 3600) / bins
|
||||
# Один сэмпл «закрашивает» окно вокруг себя, чтобы не было полосатости,
|
||||
# когда интервал опроса больше длины бина (например, 1 мин probe и 30 сек бин).
|
||||
halo_seconds = max(bin_seconds * 1.5, 90.0)
|
||||
|
||||
devices = db.query(Device).order_by(Device.name.asc()).all()
|
||||
rows = (
|
||||
db.query(DeviceMetric)
|
||||
.filter(DeviceMetric.created_at >= since - timedelta(seconds=halo_seconds))
|
||||
.order_by(DeviceMetric.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
by_dev: dict[int, list[DeviceMetric]] = {}
|
||||
for r in rows:
|
||||
by_dev.setdefault(r.device_id, []).append(r)
|
||||
|
||||
# Приоритет: no-net побеждает up; down/none перекрываются любой выборкой.
|
||||
def _promote(cur: str, new: str) -> str:
|
||||
if new == "no-net":
|
||||
return "no-net"
|
||||
if cur in ("none", "down") and new == "up":
|
||||
return "up"
|
||||
return cur
|
||||
|
||||
out_devices = []
|
||||
for dev in devices:
|
||||
buckets = ["none"] * bins
|
||||
for r in by_dev.get(dev.id, []):
|
||||
ts = r.created_at
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
offset = (ts - since).total_seconds()
|
||||
lo = int((offset - halo_seconds) // bin_seconds)
|
||||
hi = int((offset + halo_seconds) // bin_seconds)
|
||||
new_state = "no-net" if r.internet_ok is False else "up"
|
||||
for idx in range(max(0, lo), min(bins, hi + 1)):
|
||||
buckets[idx] = _promote(buckets[idx], new_state)
|
||||
out_devices.append({
|
||||
"id": dev.id,
|
||||
"name": dev.identity or dev.name,
|
||||
"host": dev.host,
|
||||
"status": dev.status,
|
||||
"buckets": buckets,
|
||||
})
|
||||
return {
|
||||
"since": since.isoformat(),
|
||||
"until": now.isoformat(),
|
||||
"bins": bins,
|
||||
"hours": hours,
|
||||
"devices": out_devices,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.db import get_db
|
||||
from ...models.user import User
|
||||
from ...services.settings import get_settings_dict, update_settings_dict
|
||||
from ...services import telegram as tg
|
||||
from ..deps import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings_endpoint(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
s = get_settings_dict(db)
|
||||
# Маскируем токен бота при отдаче
|
||||
tg_cfg = s.get("telegram", {})
|
||||
if tg_cfg.get("bot_token"):
|
||||
tg_cfg = {**tg_cfg, "bot_token_masked": "***" + tg_cfg["bot_token"][-4:]}
|
||||
# Сам токен в открытую тоже отдаём админам через /settings (для редактирования)
|
||||
return s
|
||||
|
||||
|
||||
@router.put("")
|
||||
def put_settings_endpoint(
|
||||
patch: dict[str, Any] = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict[str, Any]:
|
||||
out = update_settings_dict(db, patch)
|
||||
# Если изменён интервал автоопроса — переплинируем джобу.
|
||||
new_pm = (out.get("ui") or {}).get("probe_interval_minutes")
|
||||
if isinstance(new_pm, int):
|
||||
from ...main import reschedule_probe_job
|
||||
try:
|
||||
reschedule_probe_job(new_pm)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/telegram/test")
|
||||
def telegram_test(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict[str, Any]:
|
||||
s = get_settings_dict(db)
|
||||
cfg = s.get("telegram", {})
|
||||
ok, msg = tg.test_credentials(cfg.get("bot_token", ""), cfg.get("chat_id", ""))
|
||||
return {"ok": ok, "message": msg}
|
||||
Reference in New Issue
Block a user