11 Commits

15 changed files with 1539 additions and 66 deletions
+1 -1
View File
@@ -1 +1 @@
ROSzetta
ROSzetta Self-hosted web application for managing MikroTik device fleets. Monitor, configure, upgrade, and backup your devices from a single dashboard with real-time updates.
+2
View File
@@ -12,6 +12,7 @@ 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
from .v1 import vault as vault_router
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health_router.router, tags=["health"])
@@ -24,3 +25,4 @@ 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"])
api_router.include_router(vault_router.router, prefix="/vault", tags=["vault"])
+1 -1
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter
APP_NAME = "ROSzetta"
APP_VERSION = "0.6.0"
APP_VERSION = "0.7.0"
router = APIRouter()
+118
View File
@@ -0,0 +1,118 @@
"""API мастер-пароля / vault.
Эндпоинты:
GET /api/v1/vault/status — состояние (initialized, unlocked); доступно всем авторизованным.
POST /api/v1/vault/init — установить первичный мастер-пароль (admin, только если не init).
POST /api/v1/vault/unlock — разблокировать DEK мастер-паролем (admin).
POST /api/v1/vault/lock — забыть DEK (admin).
POST /api/v1/vault/rotate — сменить мастер-пароль (admin).
После init/unlock автоматически выполняется миграция legacy v1-секретов в v2.
"""
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...models.user import User
from ...services.vault import (
InvalidMasterPassword,
VaultAlreadyInitialized,
VaultError,
VaultLocked,
VaultNotInitialized,
migrate_legacy_device_secrets,
vault_service,
)
from ..deps import get_current_user, require_role
router = APIRouter()
class InitPayload(BaseModel):
master_password: str = Field(min_length=8, max_length=256)
class UnlockPayload(BaseModel):
master_password: str = Field(min_length=1, max_length=256)
class RotatePayload(BaseModel):
old_password: str = Field(min_length=1, max_length=256)
new_password: str = Field(min_length=8, max_length=256)
@router.get("/status")
def vault_status(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict:
return vault_service.status(db).as_dict()
@router.post("/init", status_code=status.HTTP_201_CREATED)
def vault_init(
payload: InitPayload,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> dict:
try:
vault_service.init_master_password(db, payload.master_password)
except VaultAlreadyInitialized as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc))
except VaultError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
# После init обычно паролей ещё нет, но всё равно прогоним миграцию на всякий случай.
migration = migrate_legacy_device_secrets(db)
return {
"status": vault_service.status(db).as_dict(),
"migration": migration,
}
@router.post("/unlock")
def vault_unlock(
payload: UnlockPayload,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> dict:
try:
vault_service.unlock(db, payload.master_password)
except VaultNotInitialized as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc))
except InvalidMasterPassword as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc))
except VaultError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
migration = migrate_legacy_device_secrets(db)
return {
"status": vault_service.status(db).as_dict(),
"migration": migration,
}
@router.post("/lock")
def vault_lock(
_: User = Depends(require_role("admin")),
) -> dict:
vault_service.lock()
return {"unlocked": False}
@router.post("/rotate")
def vault_rotate(
payload: RotatePayload,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> dict:
try:
vault_service.rotate_master_password(db, payload.old_password, payload.new_password)
except VaultNotInitialized as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc))
except InvalidMasterPassword as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc))
except VaultError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
return {"status": vault_service.status(db).as_dict()}
+1
View File
@@ -19,6 +19,7 @@ def init_db() -> None:
from ..models import metric as _metric # noqa: F401
from ..models import settings as _settings # noqa: F401
from ..models import interface_stat as _ifs # noqa: F401
from ..models import vault as _vault # noqa: F401
Base.metadata.create_all(bind=engine)
_ensure_columns()
+36 -4
View File
@@ -57,9 +57,19 @@ def decode_token(token: str) -> dict[str, Any]:
# --- Симметричное шифрование секретов устройств -----------------------------
# Производный ключ из SECRET_KEY (для dev). В prod — KMS / Vault.
# Двухслойная схема:
# v1: Fernet от SHA256(SECRET_KEY) — устаревший формат (без префикса для
# обратной совместимости). Только дешифрация. Запись новых v1 запрещена.
# v2: AES-256-GCM от DEK из services.vault, префикс "v2:".
#
# encrypt_secret() требует, чтобы vault был инициализирован и разблокирован.
# decrypt_secret() умеет читать оба формата.
# Подробнее см. services/vault.py.
def _fernet() -> Fernet:
SECRET_PREFIX_V2 = "v2:"
def _legacy_fernet() -> Fernet:
import base64
import hashlib
@@ -69,8 +79,30 @@ def _fernet() -> Fernet:
def encrypt_secret(value: str) -> str:
return _fernet().encrypt(value.encode()).decode()
"""Шифрует секрет DEK'ом из vault. Если vault ещё не инициализирован
(свежий апгрейд с 0.6.x) — fallback на legacy Fernet, чтобы существующие
сценарии не ломались. После /api/v1/vault/init все секреты пишутся как v2.
"""
from ..services.vault import vault_service # локальный импорт против цикла
initialized = vault_service.is_initialized_cached()
if initialized is False:
# legacy v1: пишем Fernet-токен без префикса
return _legacy_fernet().encrypt(value.encode()).decode()
# initialized is True (или None — тогда поверим, что инициализирован, и
# если на самом деле нет — VaultNotInitialized всплывёт; админу всё равно
# пора создавать мастер-пароль).
return vault_service.encrypt_secret(value)
def decrypt_secret(token: str) -> str:
return _fernet().decrypt(token.encode()).decode()
"""Дешифрует секрет. Поддерживает v2 (vault) и legacy v1 (SECRET_KEY)."""
if token.startswith(SECRET_PREFIX_V2):
from ..services.vault import vault_service
return vault_service.decrypt_secret_v2(token)
# Legacy: Fernet-токен без префикса.
return _legacy_fernet().decrypt(token.encode()).decode()
def is_legacy_secret(token: str) -> bool:
"""True, если секрет ещё не мигрирован на v2 (Fernet от SECRET_KEY)."""
return not token.startswith(SECRET_PREFIX_V2)
+37 -1
View File
@@ -3,14 +3,16 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from loguru import logger
from .api.router import api_router
from .core.bootstrap import init_db
from .core.config import get_settings
from .core.db import SessionLocal
from .services.vault import VaultLocked, VaultNotInitialized, vault_service
def _job_firmware_check() -> None:
@@ -36,6 +38,14 @@ def _job_probe_devices() -> None:
fetch_identity, fetch_interface_stats, fetch_resource, parse_uptime,
)
from datetime import datetime, timedelta, timezone
# Если vault уже инициализирован, но заблокирован — пропускаем итерацию:
# без DEK не получится расшифровать password_enc устройств в формате v2.
# До инициализации (legacy-режим) опрос продолжается со старым ключом из SECRET_KEY.
if vault_service.is_initialized_cached() is True and not vault_service.is_unlocked():
logger.info("probe_devices: vault locked, пропускаем итерацию")
return
db = SessionLocal()
try:
for d in db.query(Device).all():
@@ -160,6 +170,18 @@ async def lifespan(_: FastAPI):
logger.info("Starting ROSzetta API ({} env)", settings.app_env)
init_db()
# Прогреваем кеш «vault initialized?» — нужен encrypt_secret() для legacy-fallback
# и probe-джобе, чтобы решать skip/run без обращения к БД.
try:
_db = SessionLocal()
try:
initialized = vault_service.refresh_initialized_cache(_db)
logger.info("Vault initialized={}, unlocked={}", initialized, vault_service.is_unlocked())
finally:
_db.close()
except Exception as exc: # pragma: no cover
logger.warning("vault init-cache refresh failed: {}", exc)
# FTP-сервер для приёма push-бэкапов от MikroTik
try:
from .services.backup_ftp_server import start_server
@@ -225,6 +247,20 @@ def create_app() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(VaultLocked)
async def _vault_locked(_: Request, exc: VaultLocked) -> JSONResponse:
# 423 Locked — стандартный HTTP-код для «ресурс заперт»; фронт ловит его и
# показывает форму ввода мастер-пароля.
return JSONResponse(status_code=423, content={"detail": str(exc), "code": "vault_locked"})
@app.exception_handler(VaultNotInitialized)
async def _vault_uninit(_: Request, exc: VaultNotInitialized) -> JSONResponse:
return JSONResponse(
status_code=412,
content={"detail": str(exc), "code": "vault_not_initialized"},
)
app.include_router(api_router)
return app
+34
View File
@@ -0,0 +1,34 @@
"""Хранилище ключа шифрования секретов устройств (envelope encryption).
В таблице ровно одна запись (id=1). Поля:
- kdf_salt — соль для PBKDF2 (16 B), base64
- kdf_iterations — число итераций PBKDF2 (по умолчанию 200_000)
- verifier — короткий тест-токен AES-GCM, зашифрованный KEK; используется,
чтобы проверить корректность мастер-пароля без расшифровки DEK
- dek_wrapped — DEK (32 B), завёрнутый AES-GCM от KEK; формат nonce|cipher|tag, base64
- created_at / updated_at
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from ..core.db import Base
class Vault(Base):
__tablename__ = "vault"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kdf_salt: Mapped[str] = mapped_column(String(64), nullable=False)
kdf_iterations: Mapped[int] = mapped_column(Integer, nullable=False, default=200_000)
verifier: Mapped[str] = mapped_column(Text, nullable=False)
dek_wrapped: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
+269
View File
@@ -0,0 +1,269 @@
"""Сервис мастер-пароля и шифрования секретов устройств (envelope encryption).
Архитектура:
• DEK — случайные 32 байта, шифруют все секреты устройств AES-256-GCM.
• KEK — производный ключ из мастер-пароля (PBKDF2-HMAC-SHA256, по умолчанию
200_000 итераций, соль 16 B). KEK существует только в момент init/unlock/rotate.
• В таблице `vault` хранится: соль, число итераций, verifier (короткий
AES-GCM-токен от KEK для проверки пароля) и dek_wrapped (DEK, завёрнутый KEK).
• После unlock'а DEK кешируется в памяти процесса; после рестарта vault
автоматически locked, фоновые задачи и API устройств получают VaultLocked.
Мастер-пароль в БД НЕ хранится — только производные. Забыл — данные потеряны
безвозвратно (это by design). См. /api/v1/vault/rotate для смены пароля.
"""
from __future__ import annotations
import base64
import os
import secrets
import threading
from dataclasses import dataclass
from typing import Optional
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from sqlalchemy.orm import Session
from ..models.vault import Vault
DEFAULT_KDF_ITERATIONS = 200_000
DEK_BYTES = 32 # AES-256
SALT_BYTES = 16
NONCE_BYTES = 12 # стандарт AES-GCM
VERIFIER_PLAINTEXT = b"roszetta-vault-v2"
SECRET_PREFIX_V2 = "v2:" # формат: v2:<base64(nonce|ct|tag)>
class VaultError(Exception):
"""Общий класс ошибок vault."""
class VaultLocked(VaultError):
"""Vault заблокирован — нужно ввести мастер-пароль через /api/v1/vault/unlock."""
class VaultNotInitialized(VaultError):
"""Мастер-пароль ещё не задан — нужен /api/v1/vault/init."""
class VaultAlreadyInitialized(VaultError):
"""Попытка повторного init — нужно использовать /rotate."""
class InvalidMasterPassword(VaultError):
"""Мастер-пароль не подходит."""
# --- helpers --------------------------------------------------------------
def _b64e(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _b64d(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"))
def _derive_kek(master_password: str, salt: bytes, iterations: int) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=iterations,
)
return kdf.derive(master_password.encode("utf-8"))
def _aead_encrypt(key: bytes, plaintext: bytes) -> str:
"""AES-256-GCM. Возвращает base64(nonce|ct|tag)."""
aead = AESGCM(key)
nonce = os.urandom(NONCE_BYTES)
ct_with_tag = aead.encrypt(nonce, plaintext, associated_data=None)
return _b64e(nonce + ct_with_tag)
def _aead_decrypt(key: bytes, token_b64: str) -> bytes:
raw = _b64d(token_b64)
if len(raw) < NONCE_BYTES + 16:
raise InvalidMasterPassword("слишком короткий ciphertext")
nonce, ct_with_tag = raw[:NONCE_BYTES], raw[NONCE_BYTES:]
aead = AESGCM(key)
return aead.decrypt(nonce, ct_with_tag, associated_data=None)
# --- основной сервис -------------------------------------------------------
@dataclass
class VaultStatus:
initialized: bool
unlocked: bool
def as_dict(self) -> dict:
return {"initialized": self.initialized, "unlocked": self.unlocked}
class VaultService:
"""Singleton-сервис. Держит DEK в памяти процесса."""
def __init__(self) -> None:
self._dek: Optional[bytes] = None
self._lock = threading.RLock()
# Кеш «инициализирован ли vault» — обновляется при init/status, чтобы
# encrypt_secret() мог решить legacy-fallback без передачи db-сессии.
self._initialized_cache: Optional[bool] = None
def refresh_initialized_cache(self, db: Session) -> bool:
with self._lock:
self._initialized_cache = db.query(Vault).first() is not None
return self._initialized_cache
# ---- состояние ----
def status(self, db: Session) -> VaultStatus:
row = db.query(Vault).first()
self._initialized_cache = row is not None
return VaultStatus(initialized=row is not None, unlocked=self._dek is not None)
def is_unlocked(self) -> bool:
return self._dek is not None
def is_initialized_cached(self) -> Optional[bool]:
"""None — кеш ещё не заполнен; True/False — последнее состояние."""
return self._initialized_cache
def _require_unlocked(self) -> bytes:
if self._dek is None:
raise VaultLocked("vault locked: введите мастер-пароль в Настройках → Безопасность")
return self._dek
# ---- init / unlock / lock / rotate ----
def init_master_password(self, db: Session, master_password: str) -> None:
if not master_password or len(master_password) < 8:
raise VaultError("мастер-пароль должен быть не короче 8 символов")
with self._lock:
existing = db.query(Vault).first()
if existing is not None:
raise VaultAlreadyInitialized("vault уже инициализирован — используйте rotate")
salt = os.urandom(SALT_BYTES)
kek = _derive_kek(master_password, salt, DEFAULT_KDF_ITERATIONS)
dek = secrets.token_bytes(DEK_BYTES)
row = Vault(
kdf_salt=_b64e(salt),
kdf_iterations=DEFAULT_KDF_ITERATIONS,
verifier=_aead_encrypt(kek, VERIFIER_PLAINTEXT),
dek_wrapped=_aead_encrypt(kek, dek),
)
db.add(row)
db.commit()
self._dek = dek # сразу разблокирован после init
self._initialized_cache = True
def unlock(self, db: Session, master_password: str) -> None:
with self._lock:
row = db.query(Vault).first()
if row is None:
raise VaultNotInitialized("сначала установите мастер-пароль")
salt = _b64d(row.kdf_salt)
kek = _derive_kek(master_password, salt, row.kdf_iterations)
# 1) проверяем пароль через verifier
try:
check = _aead_decrypt(kek, row.verifier)
except Exception as exc: # noqa: BLE001 — любая ошибка дешифровки = неверный пароль
raise InvalidMasterPassword("неверный мастер-пароль") from exc
if check != VERIFIER_PLAINTEXT:
raise InvalidMasterPassword("неверный мастер-пароль")
# 2) разворачиваем DEK
dek = _aead_decrypt(kek, row.dek_wrapped)
if len(dek) != DEK_BYTES:
raise VaultError("повреждённый dek_wrapped")
self._dek = dek
self._initialized_cache = True
def lock(self) -> None:
with self._lock:
self._dek = None
def rotate_master_password(self, db: Session, old_password: str, new_password: str) -> None:
if not new_password or len(new_password) < 8:
raise VaultError("новый мастер-пароль должен быть не короче 8 символов")
with self._lock:
row = db.query(Vault).first()
if row is None:
raise VaultNotInitialized("vault не инициализирован")
# Сначала проверяем старый пароль и достаём DEK
salt_old = _b64d(row.kdf_salt)
kek_old = _derive_kek(old_password, salt_old, row.kdf_iterations)
try:
_ = _aead_decrypt(kek_old, row.verifier)
dek = _aead_decrypt(kek_old, row.dek_wrapped)
except Exception as exc: # noqa: BLE001
raise InvalidMasterPassword("текущий мастер-пароль неверен") from exc
# Генерим новую соль и перешифровываем verifier/DEK новым KEK
new_salt = os.urandom(SALT_BYTES)
kek_new = _derive_kek(new_password, new_salt, DEFAULT_KDF_ITERATIONS)
row.kdf_salt = _b64e(new_salt)
row.kdf_iterations = DEFAULT_KDF_ITERATIONS
row.verifier = _aead_encrypt(kek_new, VERIFIER_PLAINTEXT)
row.dek_wrapped = _aead_encrypt(kek_new, dek)
db.commit()
self._dek = dek # остаётся разблокированным с тем же DEK
# ---- шифрование секретов устройств ----
def encrypt_secret(self, value: str) -> str:
dek = self._require_unlocked()
token = _aead_encrypt(dek, value.encode("utf-8"))
return SECRET_PREFIX_V2 + token
def decrypt_secret_v2(self, token: str) -> str:
dek = self._require_unlocked()
if not token.startswith(SECRET_PREFIX_V2):
raise VaultError("not a v2 ciphertext")
payload = token[len(SECRET_PREFIX_V2):]
try:
return _aead_decrypt(dek, payload).decode("utf-8")
except Exception as exc: # noqa: BLE001
raise VaultError(f"не удалось расшифровать секрет: {exc}") from exc
# Глобальный экземпляр (живёт всё время uvicorn-процесса)
vault_service = VaultService()
def migrate_legacy_device_secrets(db: Session) -> dict:
"""Перешифровывает password_enc у всех устройств с v1 (Fernet от SECRET_KEY)
в v2 (AES-GCM от DEK). Безопасно вызывать многократно — уже v2 пропускаются.
Возвращает {migrated, failed, skipped} для логов/UI.
"""
from ..core.security import _legacy_fernet, is_legacy_secret
from ..models.device import Device
if not vault_service.is_unlocked():
raise VaultLocked("нельзя мигрировать при заблокированном vault")
migrated = 0
failed = 0
skipped = 0
legacy = _legacy_fernet()
for d in db.query(Device).all():
if not is_legacy_secret(d.password_enc):
skipped += 1
continue
try:
plaintext = legacy.decrypt(d.password_enc.encode()).decode()
except Exception: # noqa: BLE001 — старый ключ не подошёл, пропустим, чтобы не терять данные
failed += 1
continue
d.password_enc = vault_service.encrypt_secret(plaintext)
migrated += 1
db.commit()
return {"migrated": migrated, "failed": failed, "skipped": skipped}
+3
View File
@@ -0,0 +1,3 @@
sudo apt install docker-compose-v2 git -y
git clone https://git.core.uz/mikrotik/ROSzetta.git
sudo docker compose up -d --build
+41
View File
@@ -232,3 +232,44 @@ export interface HeartbeatOut {
hours: number;
devices: HeartbeatDevice[];
}
// --- Vault (мастер-пароль / шифрование секретов устройств) -------------------
export interface VaultStatus {
initialized: boolean;
unlocked: boolean;
}
export interface VaultMigration {
migrated: number;
failed: number;
skipped: number;
}
export interface VaultActionOut {
status: VaultStatus;
migration?: VaultMigration;
}
export const vaultApi = {
async status(): Promise<VaultStatus> {
const r = await api.get<VaultStatus>('/vault/status');
return r.data;
},
async init(master_password: string): Promise<VaultActionOut> {
const r = await api.post<VaultActionOut>('/vault/init', { master_password });
return r.data;
},
async unlock(master_password: string): Promise<VaultActionOut> {
const r = await api.post<VaultActionOut>('/vault/unlock', { master_password });
return r.data;
},
async lock(): Promise<{ unlocked: false }> {
const r = await api.post<{ unlocked: false }>('/vault/lock');
return r.data;
},
async rotate(old_password: string, new_password: string): Promise<VaultActionOut> {
const r = await api.post<VaultActionOut>('/vault/rotate', { old_password, new_password });
return r.data;
},
};
+48 -1
View File
@@ -5,9 +5,10 @@ import {
CheckCircle2, AlertTriangle, Bell, Terminal,
Menu, X, Settings as SettingsIcon,
ChevronDown, ChevronUp,
Lock, Unlock, ShieldAlert,
} from 'lucide-react';
import { useAuth } from '@/store/auth';
import { api, Device } from '@/api/client';
import { api, Device, vaultApi, VaultStatus } from '@/api/client';
import AboutModal from './AboutModal';
import { useSettings } from '@/store/settings';
import { pickOkMessage } from '@/utils/okMessages';
@@ -118,6 +119,51 @@ function GlobalHealth() {
);
}
function VaultBadge() {
const navigate = useNavigate();
const [s, setS] = useState<VaultStatus | null>(null);
useEffect(() => {
const load = () => vaultApi.status().then(setS).catch(() => {});
load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, []);
if (!s) return null;
const goto = () => navigate('/settings#security');
// Три состояния: ok (зелёный замок открыт), locked (жёлтый замок закрыт), uninit (красный щит)
if (!s.initialized) {
return (
<button
onClick={goto}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-err hover:bg-white/[0.04]"
title="Vault не инициализирован — задайте мастер-пароль"
>
<ShieldAlert size={14} /> <span className="hidden md:inline">vault</span>
</button>
);
}
if (!s.unlocked) {
return (
<button
onClick={goto}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-warn hover:bg-white/[0.04]"
title="Vault заблокирован — введите мастер-пароль, опрос устройств приостановлен"
>
<Lock size={14} /> <span className="hidden md:inline">locked</span>
</button>
);
}
return (
<button
onClick={goto}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-ok hover:bg-white/[0.04]"
title="Vault разблокирован — секреты устройств доступны"
>
<Unlock size={14} />
</button>
);
}
function AlertsBell() {
const navigate = useNavigate();
const [count, setCount] = useState(0);
@@ -431,6 +477,7 @@ export default function AppLayout() {
v{version}
</span>
)}
<VaultBadge />
<AlertsBell />
<button
onClick={() => setAboutOpen(true)}
+557 -48
View File
@@ -1,5 +1,7 @@
import { FormEvent, useState } from 'react';
import { X, Send, Bot } from 'lucide-react';
import { FormEvent, useState, useEffect, useRef } from 'react';
import { X, Send, Bot, Settings, Loader2, Trash2, Save } from 'lucide-react';
import { api } from '@/api/client';
import type { Device, InterfaceInfo, FirmwareChannelsOut } from '@/api/client';
interface Msg {
who: 'bot' | 'me';
@@ -7,18 +9,383 @@ interface Msg {
ts: number;
}
const HINT = `Это заглушка чат-бота. Здесь будет интеграция с Telegram/AI.
Можно спрашивать про устройства, настройки, бэкапы.`;
function botReply(q: string): string {
const s = q.toLowerCase();
if (/устройств|devices/.test(s)) return 'Список устройств доступен в разделе "Devices".';
if (/бэкап|backup/.test(s)) return 'Бэкапы создаются на странице устройства, кнопкой "Backup".';
if (/прошив|firmware/.test(s)) return 'Репозиторий прошивок — в левом меню "Прошивки".';
if (/привет|hi|hello/.test(s)) return 'Привет! Чем помочь?';
return 'Ок, принял. (бот пока в режиме заглушки)';
interface OpenAIConfig {
host: string;
endpointPath: string;
apiKey: string;
model: string;
systemPrompt: string;
}
const DEFAULT_CONFIG: OpenAIConfig = {
host: 'http://llm2.lab.local:9911/v1',
endpointPath: '/chat/completions',
apiKey: '',
model: 'qwen3-coder-next-q8-2gpu-nooffload',
systemPrompt: `You are a helpful assistant that answers questions about device management, backups, firmware updates, and network configurations.
You have access to a set of tools (functions) that allow you to retrieve real-time data from MikroTik devices. Always use these tools when the user asks for device lists, statuses, interfaces, firmware channels, or wants to execute CLI commands. Do not guess or fabricate data.
Available tools:
- get_devices_list: returns all devices with names, IPs, statuses.
- get_device_status(identifier): detailed info about a specific device (name, IP, RouterOS version, uptime, etc.).
- get_device_interfaces(identifier): list of interfaces with statuses, comments, MACs.
- get_firmware_channels: current RouterOS channel versions and check timestamps.
- trigger_firmware_check: manually request a firmware update check.
- execute_device_command(device_identifier, command): run any CLI command on a device and return the output.
When the user asks something like "show devices", "list devices", "status of router1", "interfaces of hAP ac lite", "firmware channels", "check for updates", or "execute /system/resource/print on device router1" you MUST call the corresponding tool. If the user is ambiguous, ask for clarification (e.g., which device?).
Important for commands: Use RouterOS syntax with slashes, e.g., "/system/resource/print details" or "/interface/print", just like a path to executable program in Linux. If you write with spaces like "/system resource print", it will be error! Arguments (like "details") should be separated by a space after the command. Example: "/system/resource/print details"
After receiving tool results, summarize the information clearly for the user.`,
};
const STORAGE_KEY = 'openai-chat-config';
// ======================== API-ФУНКЦИИ (ИНСТРУМЕНТЫ) ========================
async function getDevicesList(): Promise<string> {
try {
const response = await api.get<Device[]>('/devices');
const devices = response.data;
if (!devices.length) return '📡 No devices found.';
const lines = devices.map(d => {
const name = d.hostname || d.board_name || d.name || d.id;
const ip = d.address || d.management_ip || 'IP unknown';
const statusIcon = d.status === 'up' ? '✅' : d.status === 'down' ? '❌' : '⚠️';
return `${statusIcon} **${name}** (${d.status}) — ${ip}`;
});
return `📋 **Device list (${devices.length})**\n\n${lines.join('\n')}`;
} catch (err) {
console.error('getDevicesList failed', err);
return '❌ Failed to retrieve device list. Please check server connection.';
}
}
async function getDeviceStatus(identifier: string): Promise<string> {
try {
const devicesResp = await api.get<Device[]>('/devices');
const device = devicesResp.data.find(d =>
d.id === identifier ||
d.hostname?.toLowerCase() === identifier.toLowerCase() ||
d.board_name?.toLowerCase() === identifier.toLowerCase() ||
d.name?.toLowerCase() === identifier.toLowerCase()
);
if (!device) return `❌ Device "${identifier}" not found. Use "list devices" to see available ones.`;
const lines = [
`🖥️ **${device.hostname || device.board_name || device.name || device.id}**`,
`├─ ID: ${device.id}`,
`├─ Status: ${device.status === 'up' ? '✅ up' : device.status === 'down' ? '❌ down' : '⚠️ unknown'}`,
`├─ IP address: ${device.address || device.management_ip || '—'}`,
`├─ Model: ${device.board_name || '—'}`,
`├─ RouterOS version: ${device.ros_version || '—'}`,
`├─ Uptime: ${device.uptime || '—'}`,
];
if (device.internet_ok !== undefined) lines.push(`├─ Internet access: ${device.internet_ok ? '✅ yes' : '❌ no'}`);
if (device.abnormal_reboot) lines.push(`└─ ⚠️ Abnormal reboot: ${device.abnormal_reboot}`);
else lines.push(`└─ No abnormal reboots`);
return lines.join('\n');
} catch (err) {
console.error('getDeviceStatus failed', err);
return '❌ Failed to retrieve device status.';
}
}
async function getDeviceInterfaces(identifier: string): Promise<string> {
try {
const devicesResp = await api.get<Device[]>('/devices');
const device = devicesResp.data.find(d =>
d.id === identifier ||
d.hostname?.toLowerCase() === identifier.toLowerCase() ||
d.board_name?.toLowerCase() === identifier.toLowerCase() ||
d.name?.toLowerCase() === identifier.toLowerCase()
);
if (!device) return `❌ Device "${identifier}" not found.`;
const ifaceResp = await api.get<InterfaceInfo[]>(`/devices/${device.id}/interfaces`);
const interfaces = ifaceResp.data;
if (!interfaces.length) return `🔌 Device **${device.hostname || device.id}** has no interfaces.`;
const lines = interfaces.map(iface => {
const status = iface.running ? '🟢 up' : iface.disabled ? '⚪ disabled' : '🔴 down';
let line = `- **${iface.name}** (${iface.type || 'ether'}) — ${status}`;
if (iface.comment) line += ` · ${iface.comment}`;
if (iface.mac_address) line += ` · MAC: ${iface.mac_address}`;
return line;
});
return `🔌 **Interfaces of ${device.hostname || device.id}**\n\n${lines.join('\n')}`;
} catch (err) {
console.error('getDeviceInterfaces failed', err);
return '❌ Failed to retrieve interfaces.';
}
}
async function getFirmwareChannelsInfo(): Promise<string> {
try {
const resp = await api.get<FirmwareChannelsOut>('/firmware/channels');
const data = resp.data;
const order = data.available_channels;
const lines = order.map(ch => {
const info = data.channels[ch];
if (!info) return `- **${ch}**: no data`;
const ok = info.last_check_ok !== false && info.version;
return `- **${ch}**: ${info.version || '—'} ${ok ? '✅' : '⚠️'} (checked at ${new Date(info.last_check).toLocaleString()})`;
});
return `📦 **RouterOS channels**\n\n${lines.join('\n')}`;
} catch (err) {
console.error('getFirmwareChannelsInfo failed', err);
return '❌ Failed to retrieve firmware channels.';
}
}
async function triggerFirmwareCheck(): Promise<string> {
try {
await api.post('/firmware/check');
return '🔄 Firmware update check started. New versions will appear in the channels list in a few seconds.';
} catch (err: any) {
const msg = err?.response?.data?.message || err.message;
return `❌ Failed to trigger firmware check: ${msg}`;
}
}
async function executeDeviceCommand(deviceIdentifier: string, command: string): Promise<string> {
try {
const devicesResp = await api.get<Device[]>('/devices');
const device = devicesResp.data.find(d =>
d.id === deviceIdentifier ||
d.hostname?.toLowerCase() === deviceIdentifier.toLowerCase() ||
d.board_name?.toLowerCase() === deviceIdentifier.toLowerCase() ||
d.name?.toLowerCase() === deviceIdentifier.toLowerCase()
);
if (!device) return `❌ Device "${deviceIdentifier}" not found.`;
// Преобразуем id в число (если он приходит строкой, но API может ждать number)
const deviceId = typeof device.id === 'string' ? parseInt(device.id, 10) : device.id;
if (isNaN(deviceId)) return `❌ Invalid device ID: ${device.id}`;
// Новый эндпоинт и тело запроса
const resp = await api.post<{ output?: string; result?: string; stdout?: string }>(
'/cli/run', // или '/api/v1/cli/run' уточните по своему api.client
{
device_ids: [deviceId],
command: command,
confirm: false,
}
);
let output = resp.data.output || resp.data.results || resp.data.result || resp.data.stdout;
if (!output) return `✅ Command executed, no output returned.`;
output = JSON.stringify(output); // А надобно будет потом нормально распарсить по-хорошему
const truncated = output.length > 1800 ? output.slice(0, 1800) + '\n… (output truncated)' : output;
return `🖥️ **Result of command** on device ${device.id}:\n\`\`\`\n${truncated}\n\`\`\``;
} catch (err: any) {
const msg = err?.response?.data?.message || err?.response?.data?.error || err.message;
return `❌ Command execution failed: ${msg || 'unknown error'}`;
}
}
// ======================== OPENAI FUNCTION CALLING (TOOLS) ========================
const tools = [
{
type: 'function',
function: {
name: 'get_devices_list',
description: 'Get the list of all MikroTik devices with their statuses and IP addresses.',
parameters: { type: 'object', properties: {}, required: [] },
},
},
{
type: 'function',
function: {
name: 'get_device_status',
description: 'Get detailed status of a specific device (uptime, RouterOS version, internet connectivity, etc.).',
parameters: {
type: 'object',
properties: {
identifier: { type: 'string', description: 'Device ID, hostname, board name, or name (case-insensitive partial match).' },
},
required: ['identifier'],
},
},
},
{
type: 'function',
function: {
name: 'get_device_interfaces',
description: 'List all network interfaces of a specific device with their statuses (up/down/disabled), comments, and MAC addresses.',
parameters: {
type: 'object',
properties: {
identifier: { type: 'string', description: 'Device ID, hostname, board name, or name.' },
},
required: ['identifier'],
},
},
},
{
type: 'function',
function: {
name: 'get_firmware_channels',
description: 'Get current RouterOS firmware channels (stable, testing, development) with versions and last check timestamps.',
parameters: { type: 'object', properties: {}, required: [] },
},
},
{
type: 'function',
function: {
name: 'trigger_firmware_check',
description: 'Manually trigger a firmware update check. Useful when the user asks to check for new RouterOS updates.',
parameters: { type: 'object', properties: {}, required: [] },
},
},
{
type: 'function',
function: {
name: 'execute_device_command',
description: 'Execute an arbitrary CLI command on a device (e.g., "/system/resource/print", "/interface/print").',
parameters: {
type: 'object',
properties: {
device_identifier: { type: 'string', description: 'Device ID, hostname, board name, or name.' },
command: { type: 'string', description: 'RouterOS command to execute.' },
},
required: ['device_identifier', 'command'],
},
},
},
];
async function callTool(name: string, args: any): Promise<string> {
switch (name) {
case 'get_devices_list':
return await getDevicesList();
case 'get_device_status':
return await getDeviceStatus(args.identifier);
case 'get_device_interfaces':
return await getDeviceInterfaces(args.identifier);
case 'get_firmware_channels':
return await getFirmwareChannelsInfo();
case 'trigger_firmware_check':
return await triggerFirmwareCheck();
case 'execute_device_command':
return await executeDeviceCommand(args.device_identifier, args.command);
default:
return `Unknown tool: ${name}`;
}
}
/**
* Send messages to LLM, handle tool_calls recursively, return final assistant message.
* Modifies the conversation history (adds assistant message + tool responses).
*/
async function sendWithTools(
messages: Msg[],
config: OpenAIConfig,
systemPrompt: string,
setLoading: (loading: boolean) => void,
setError: (error: string | null) => void,
updateMessages: (newMsgs: Msg[]) => void
): Promise<void> {
if (!config.apiKey.trim()) {
setError('API key is required. Please configure settings.');
return;
}
if (!config.host.trim()) {
setError('Host URL is required.');
return;
}
setLoading(true);
setError(null);
// Convert internal messages to OpenAI format, prepend system
const openAiMsgs: { role: string; content: string; tool_calls?: any; name?: string }[] = [];
if (systemPrompt.trim()) {
openAiMsgs.push({ role: 'system', content: systemPrompt });
}
for (const m of messages) {
openAiMsgs.push({ role: m.who === 'me' ? 'user' : 'assistant', content: m.text });
}
const endpoint = `${config.host.replace(/\/$/, '')}${config.endpointPath}`;
const makeRequest = async (msgs: any[]): Promise<any> => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: msgs,
tools: tools,
tool_choice: 'auto',
stream: false,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API error (${response.status}): ${errorText.slice(0, 200)}`);
}
return await response.json();
};
try {
let currentMessages = [...openAiMsgs];
let finalAssistantContent: string | null = null;
while (true) {
const data = await makeRequest(currentMessages);
const assistantMessage = data.choices?.[0]?.message;
if (!assistantMessage) throw new Error('No message in response');
// If no tool calls, we're done
if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
finalAssistantContent = assistantMessage.content || '';
break;
}
// Append assistant message with tool_calls to conversation
currentMessages.push(assistantMessage);
// Execute each tool call and append tool response messages
for (const toolCall of assistantMessage.tool_calls) {
const func = toolCall.function;
let args: any = {};
try {
args = JSON.parse(func.arguments);
} catch (e) {
args = {};
}
const result = await callTool(func.name, args);
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result,
});
}
// Continue loop LLM will see tool outputs and decide final answer or more calls
}
if (finalAssistantContent !== null) {
// Add the final assistant message to UI
updateMessages([...messages, { who: 'bot', text: finalAssistantContent, ts: Date.now() }]);
} else {
throw new Error('No final response from model');
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
setError(errorMsg);
// Optionally show error as a bot message
updateMessages([...messages, { who: 'bot', text: `⚠️ Error: ${errorMsg}`, ts: Date.now() }]);
} finally {
setLoading(false);
}
}
// ======================== КОМПОНЕНТ CHATBOT ========================
interface ChatBotProps {
open?: boolean;
onClose?: () => void;
@@ -26,68 +393,210 @@ interface ChatBotProps {
}
export default function ChatBot({ open = true, onClose, embedded = false }: ChatBotProps) {
const [config, setConfig] = useState<OpenAIConfig>(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
return { ...DEFAULT_CONFIG, ...JSON.parse(saved) };
} catch {
return DEFAULT_CONFIG;
}
}
return DEFAULT_CONFIG;
});
const [showConfig, setShowConfig] = useState(false);
const [msgs, setMsgs] = useState<Msg[]>([
{ who: 'bot', text: HINT, ts: Date.now() },
{ who: 'bot', text: '👋 I can now use real API tools! Ask me about devices, interfaces, firmware, or execute commands. I will automatically fetch live data.\n\nTry:\n- "list devices"\n- "status of hAP ac lite"\n- "interfaces of router1"\n- "firmware channels"\n- "check for updates"\n- "execute /system/resource/print on device router1"', ts: Date.now() },
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const send = (e: FormEvent) => {
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [msgs]);
const updateConfig = (updates: Partial<OpenAIConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConfig));
};
const clearChat = () => {
setMsgs([{ who: 'bot', text: 'Chat cleared. Start a new conversation!', ts: Date.now() }]);
setError(null);
};
const send = async (e: FormEvent) => {
e.preventDefault();
const text = input.trim();
if (!text) return;
const now = Date.now();
setMsgs((m) => [...m, { who: 'me', text, ts: now }]);
if (!text || loading) return;
const userMsg: Msg = { who: 'me', text, ts: Date.now() };
const newMsgs = [...msgs, userMsg];
setMsgs(newMsgs);
setInput('');
setTimeout(() => {
setMsgs((m) => [...m, { who: 'bot', text: botReply(text), ts: Date.now() }]);
}, 350);
setError(null);
// Call LLM with tools
await sendWithTools(newMsgs, config, config.systemPrompt, setLoading, setError, setMsgs);
};
if (!open) return null;
const wrapperCls = embedded
? 'card p-0 flex flex-col h-[60vh] min-h-[360px]'
: 'fixed bottom-5 left-60 z-40 w-80 h-96 card p-0 flex flex-col shadow-2xl';
: 'fixed bottom-5 left-60 z-40 w-96 h-[560px] card p-0 flex flex-col shadow-2xl';
return (
<div className={wrapperCls}>
<div className="px-4 py-3 border-b border-mk-border flex items-center gap-2">
<div className="px-4 py-3 border-b border-mk-border flex items-center gap-2 shrink-0">
<Bot size={18} className="text-mk-accent2" />
<div className="font-medium text-sm">Помощник</div>
<span className="ml-2 text-xs text-mk-mute">beta</span>
<div className="font-medium text-sm">AI Assistant</div>
<span className="ml-2 text-xs text-mk-mute">Tools · Function Calling</span>
<button
onClick={() => setShowConfig(!showConfig)}
className={`ml-auto p-1 rounded hover:bg-mk-panel2 transition-colors ${showConfig ? 'bg-mk-panel2 text-mk-accent' : 'text-mk-mute hover:text-mk-text'}`}
aria-label="Settings"
title="API Settings"
>
<Settings size={16} />
</button>
<button
onClick={clearChat}
className="p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text transition-colors"
aria-label="Clear chat"
title="Clear conversation"
>
<Trash2 size={14} />
</button>
{!embedded && onClose && (
<button
onClick={onClose}
className="ml-auto p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text"
aria-label="Закрыть"
className="p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text"
aria-label="Close"
>
<X size={16} />
</button>
)}
</div>
<div className="flex-1 overflow-auto p-3 space-y-2 text-sm">
{msgs.map((m, i) => (
<div
key={i}
className={`max-w-[85%] px-3 py-2 rounded-lg whitespace-pre-wrap ${
m.who === 'me'
? 'ml-auto bg-mk-accent/20 text-mk-text'
: 'mr-auto bg-mk-panel2 text-mk-text'
}`}
>
{m.text}
</div>
))}
{showConfig && (
<div className="p-3 border-b border-mk-border bg-mk-panel/30 space-y-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-mk-mute block mb-1">Host URL</label>
<input
type="text"
className="input text-xs w-full"
value={config.host}
onChange={(e) => updateConfig({ host: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div>
<label className="text-xs text-mk-mute block mb-1">Endpoint path</label>
<input
type="text"
className="input text-xs w-full"
value={config.endpointPath}
onChange={(e) => updateConfig({ endpointPath: e.target.value })}
placeholder="/chat/completions"
/>
</div>
</div>
<form onSubmit={send} className="p-2 border-t border-mk-border flex gap-2">
<div>
<label className="text-xs text-mk-mute block mb-1">API Key</label>
<input
className="input"
placeholder="Спросите бота…"
value={input}
onChange={(e) => setInput(e.target.value)}
type="password"
className="input text-xs w-full"
value={config.apiKey}
onChange={(e) => updateConfig({ apiKey: e.target.value })}
placeholder="sk-..."
/>
<button className="btn-primary" type="submit" aria-label="Отправить">
<Send size={14} />
</button>
</form>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-mk-mute block mb-1">Model</label>
<input
type="text"
className="input text-xs w-full"
value={config.model}
onChange={(e) => updateConfig({ model: e.target.value })}
placeholder="gpt-3.5-turbo"
/>
</div>
<div className="flex items-end">
<button
onClick={() => setShowConfig(false)}
className="btn-primary text-xs py-1.5 w-full"
>
<Save size={12} className="inline mr-1" /> Save
</button>
</div>
</div>
<div>
<label className="text-xs text-mk-mute block mb-1">System Prompt</label>
<textarea
className="input text-xs w-full"
rows={3}
value={config.systemPrompt}
onChange={(e) => updateConfig({ systemPrompt: e.target.value })}
placeholder="You are a helpful assistant..."
/>
</div>
</div>
)}
<div className="flex-1 overflow-auto p-3 space-y-2 text-sm">
{msgs.map((m, i) => (
<div
key={i}
className={`max-w-[85%] px-3 py-2 rounded-lg whitespace-pre-wrap ${
m.who === 'me'
? 'ml-auto bg-mk-accent/20 text-mk-text'
: 'mr-auto bg-mk-panel2 text-mk-text'
}`}
>
{m.text}
</div>
))}
{loading && (
<div className="mr-auto bg-mk-panel2 text-mk-text px-3 py-2 rounded-lg inline-flex items-center gap-2">
<Loader2 size={14} className="animate-spin" />
<span>Thinking & calling tools...</span>
</div>
)}
{error && !loading && (
<div className="mr-auto bg-red-500/20 text-red-300 px-3 py-2 rounded-lg text-xs">
{error}
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={send} className="p-2 border-t border-mk-border flex gap-2 shrink-0">
<input
className="input flex-1"
placeholder="Ask about devices, interfaces, firmware, or run a command..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading}
/>
<button
className="btn-primary"
type="submit"
disabled={loading || !input.trim()}
aria-label="Send"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
</button>
</form>
</div>
);
}
+147 -1
View File
@@ -14,6 +14,11 @@ export interface DeviceMockupProps {
const isHapAcLite = (b?: string | null): boolean =>
!!b && /h\s*A\s*P\s*ac\s*lite/i.test(b);
// hAP ac² (RBD52G-5HacD2HnD): отличаем по цифре «2» / «²» после «ac»,
// чтобы случайно не перехватить hAP ac lite.
const isHapAc2 = (b?: string | null): boolean =>
!!b && (/h\s*A\s*P\s*ac[\s\^]*[²2]/i.test(b) || /RBD52G/i.test(b));
const isHapLike = (b?: string | null): boolean => !!b && /\bh\s*A\s*P\b/i.test(b);
const isRb5009 = (b?: string | null): boolean =>
@@ -49,7 +54,13 @@ function portColor(it: InterfaceInfo | undefined): { fill: string; stroke: strin
}
export default function DeviceMockup({ boardName, interfaces }: DeviceMockupProps) {
if (isHapAcLite(boardName) || (isHapLike(boardName) && interfaces.filter((it) => /^ether/.test(it.name)).length === 5)) {
if (isHapAcLite(boardName)) {
return <HapAcLiteMockup interfaces={interfaces} />;
}
if (isHapAc2(boardName)) {
return <HapAc2Mockup interfaces={interfaces} />;
}
if (isHapLike(boardName) && interfaces.filter((it) => /^ether/.test(it.name)).length === 5) {
return <HapAcLiteMockup interfaces={interfaces} />;
}
if (isRb5009(boardName)) {
@@ -228,6 +239,141 @@ function HapAcLiteMockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
);
}
// --------- hAP ac² ---------
// Чёрный пластиковый корпус (RBD52G-5HacD2HnD).
// Слева: DC 12-28V, утопленная кнопка res/wps, индикаторы pwr / usr.
// Справа: 5 GigE портов — ether1 «Internet/PoE in», ether2..ether5 «LAN».
// PoE-out нет (в отличие от hAP ac lite).
function HapAc2Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
const ports = [
{ name: 'ether1', label: '1', accent: 'poe-in' as const },
{ name: 'ether2', label: '2', accent: null as const },
{ name: 'ether3', label: '3', accent: null as const },
{ name: 'ether4', label: '4', accent: null as const },
{ name: 'ether5', label: '5', accent: null as const },
];
// Соотношение фото задней панели ~4.3:1. При height: 62px ширина ≈ 268px.
const W = 1180, H = 274;
const bodyR = 20;
const portW = 130, portH = 130;
const portGap = 14;
const firstPortX = 410;
const portsTopY = 60;
const lanStartX = firstPortX + portW + portGap;
const lanSpanW = 4 * portW + 3 * portGap;
return (
<div className="card">
<div className="text-xs text-mk-mute mb-2">
Лицевая панель <b>hAP ac²</b> · подсветка портов в реальном времени
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
xmlns="http://www.w3.org/2000/svg"
style={{ height: '62px', width: 'auto', maxWidth: '100%', display: 'block' }}
>
{/* Чёрный пластиковый корпус */}
<rect x="2" y="2" width={W - 4} height={H - 4} rx={bodyR} ry={bodyR} fill="#1f1f1f" stroke="#050505" strokeWidth="2" />
{/* Утопленная плашка отсека (чуть темнее, со внутренней тенью обводки) */}
<rect x="20" y="22" width={W - 40} height={H - 64} rx="12" fill="#161616" stroke="#000" strokeWidth="1" />
{/* DC разъём */}
<circle cx="92" cy="148" r="36" fill="#0a0a0a" stroke="#3a3a3a" strokeWidth="3" />
<circle cx="92" cy="148" r="10" fill="#1a1a1a" stroke="#000" strokeWidth="2" />
<text x="92" y="235" fontSize="22" fill="#ffffff" textAnchor="middle" fontWeight="700">DC</text>
<text x="92" y="256" fontSize="14" fill="#cccccc" textAnchor="middle">12-28V</text>
{/* res/wps — утопленная кнопка */}
<circle cx="188" cy="148" r="10" fill="#0a0a0a" stroke="#555" strokeWidth="1.5" />
<circle cx="188" cy="148" r="3" fill="#222" />
<text x="188" y="92" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">res/wps</text>
{/* pwr LED */}
<circle cx="252" cy="148" r="5" fill="#1f6f1f" />
<text x="252" y="92" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">pwr</text>
{/* usr LED */}
<circle cx="312" cy="148" r="5" fill="#3a3a3a" />
<text x="312" y="92" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">usr</text>
{/* Цифры над портами */}
{ports.map((p, i) => {
const x = firstPortX + i * (portW + portGap);
return (
<text
key={`lbl-${p.name}`}
x={x + portW / 2}
y="48"
fontSize="22"
fill="#ffffff"
fontWeight="700"
textAnchor="middle"
>
{p.label}
</text>
);
})}
{/* Порты */}
{ports.map((p, i) => {
const x = firstPortX + i * (portW + portGap);
const it = findPort(interfaces, p.name);
const col = portColor(it);
return (
<g key={p.name}>
{/* Металлический ободок RJ45 */}
<rect x={x} y={portsTopY} width={portW} height={portH} rx="6" fill="#c8c8c8" stroke="#666" strokeWidth="1.5" />
{/* Внутренний экран — закрашивается под статус */}
<rect x={x + 8} y={portsTopY + 8} width={portW - 16} height={portH - 16} rx="3" fill={col.fill} stroke={col.stroke} strokeWidth="3" />
{/* RJ45 «зубчики» */}
<rect x={x + 24} y={portsTopY + 16} width={portW - 48} height="18" fill="#000" />
<rect x={x + 32} y={portsTopY + 34} width={portW - 64} height="10" fill="#000" />
{/* LED-индикатор линка */}
<circle
cx={x + portW - 18}
cy={portsTopY + portH - 18}
r="5"
fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'}
/>
{/* Имя интерфейса под портом — мелким шрифтом, чтобы не сливалось с подписями групп */}
<text x={x + portW / 2} y={portsTopY + portH + 16} fontSize="11" fill="#888" textAnchor="middle">
{p.name}
</text>
<title>
{p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · Internet / PoE in' : ' · LAN'}
{'\n'}статус: {col.label}
{it?.comment ? `\ncomment: ${it.comment}` : ''}
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
</title>
</g>
);
})}
{/* Группа Internet/PoE in под портом 1 */}
<line x1={firstPortX - 2} y1={H - 36} x2={firstPortX + portW + 2} y2={H - 36} stroke="#9aa0a6" strokeWidth="1.2" />
<circle cx={firstPortX - 2} cy={H - 36} r="3" fill="#9aa0a6" />
<circle cx={firstPortX + portW + 2} cy={H - 36} r="3" fill="#9aa0a6" />
<text x={firstPortX + portW / 2} y={H - 14} fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">
Internet/PoE in
</text>
{/* Группа LAN под портами 2-5 */}
<line x1={lanStartX - 2} y1={H - 36} x2={lanStartX + lanSpanW + 2} y2={H - 36} stroke="#9aa0a6" strokeWidth="1.2" />
<circle cx={lanStartX - 2} cy={H - 36} r="3" fill="#9aa0a6" />
<circle cx={lanStartX + lanSpanW + 2} cy={H - 36} r="3" fill="#9aa0a6" />
<text x={lanStartX + lanSpanW / 2} y={H - 14} fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">
LAN
</text>
</svg>
</div>
<MockupLegend />
</div>
);
}
// --------- RB5009UG+S+ ---------
// Чёрный корпус, 8 GigE портов (ether1..ether8) + 1 SFP+ (sfp-sfpplus1).
// Слева: DC jack 12-57V, кнопка R (reset), USB 3.0 порт.
+244 -9
View File
@@ -3,8 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
import {
Database, Settings as SettingsIcon, Download, Upload, RefreshCw, Eye, Save,
Globe, Palette, Tag, Activity, Radar, AlertTriangle, User as UserIcon, KeyRound,
Lock, Unlock, ShieldCheck, ShieldAlert,
} from 'lucide-react';
import { api, AppSettings } from '@/api/client';
import { api, AppSettings, vaultApi, VaultStatus, VaultMigration } from '@/api/client';
import { useAuth } from '@/store/auth';
import { useSettings } from '@/store/settings';
import { useT, LOCALES, THEMES, HEARTBEAT_RANGES, PROBE_INTERVALS } from '@/i18n';
@@ -19,11 +20,12 @@ const MENU_LABELS: Record<keyof AppSettings['menu'], string> = {
settings: 'Настройки',
};
type TabKey = 'general' | 'probe' | 'user' | 'menu' | 'backup';
type TabKey = 'general' | 'probe' | 'user' | 'security' | 'menu' | 'backup';
function parseHash(h: string): TabKey {
const v = h.replace(/^#/, '');
if (v === 'users' || v === 'password' || v === 'user') return 'user';
if (v === 'security' || v === 'vault' || v === 'master') return 'security';
if (v === 'menu') return 'menu';
if (v === 'backup') return 'backup';
if (v === 'probe') return 'probe';
@@ -32,11 +34,12 @@ function parseHash(h: string): TabKey {
}
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'general', label: 'Общие', icon: SettingsIcon },
{ key: 'probe', label: 'Опрос', icon: Radar },
{ key: 'user', label: 'Пользователь', icon: UserIcon },
{ key: 'menu', label: 'Меню', icon: Eye },
{ key: 'backup', label: 'Бэкап', icon: Database },
{ key: 'general', label: 'Общие', icon: SettingsIcon },
{ key: 'probe', label: 'Опрос', icon: Radar },
{ key: 'user', label: 'Пользователь', icon: UserIcon },
{ key: 'security', label: 'Безопасность', icon: ShieldCheck },
{ key: 'menu', label: 'Меню', icon: Eye },
{ key: 'backup', label: 'Бэкап', icon: Database },
];
export default function SettingsPage() {
@@ -131,8 +134,9 @@ export default function SettingsPage() {
const updUi = (k: keyof AppSettings['ui'], v: any) =>
setDraft({ ...draft, ui: { ...ui, [k]: v } });
// На вкладке "Пользователь" своя кнопка сохранения — основная "Сохранить" не нужна.
const showSaveBtn = tab !== 'user';
// На вкладках "Пользователь" и "Безопасность" — свои кнопки в формах,
// глобальный "Сохранить" не нужен.
const showSaveBtn = tab !== 'user' && tab !== 'security';
return (
<div className="space-y-4 max-w-3xl">
@@ -302,6 +306,8 @@ export default function SettingsPage() {
{tab === 'user' && <UserTab email={email} />}
{tab === 'security' && <SecurityTab />}
{tab === 'menu' && (
<div className="card space-y-3">
<div className="flex items-center gap-2">
@@ -481,3 +487,232 @@ function UserTab({ email }: { email: string | null }) {
</div>
);
}
// ---------- Вкладка «Безопасность» (мастер-пароль / vault) ----------
function SecurityTab() {
const [status, setStatus] = useState<VaultStatus | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [migration, setMigration] = useState<VaultMigration | null>(null);
// init / unlock form
const [pwd, setPwd] = useState('');
const [pwd2, setPwd2] = useState('');
// rotate form
const [oldPwd, setOldPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [newPwd2, setNewPwd2] = useState('');
const refresh = async () => {
try { setStatus(await vaultApi.status()); }
catch (ex: any) { setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? String(ex) }); }
};
useEffect(() => { refresh(); }, []);
const doInit = async (e: FormEvent) => {
e.preventDefault();
setMsg(null); setMigration(null);
if (pwd.length < 8) { setMsg({ kind: 'err', text: 'Мастер-пароль должен быть не короче 8 символов' }); return; }
if (pwd !== pwd2) { setMsg({ kind: 'err', text: 'Пароли не совпадают' }); return; }
setBusy(true);
try {
const r = await vaultApi.init(pwd);
setStatus(r.status);
setMigration(r.migration ?? null);
setPwd(''); setPwd2('');
setMsg({ kind: 'ok', text: 'Мастер-пароль установлен. Vault разблокирован.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка инициализации' });
} finally { setBusy(false); }
};
const doUnlock = async (e: FormEvent) => {
e.preventDefault();
setMsg(null); setMigration(null);
setBusy(true);
try {
const r = await vaultApi.unlock(pwd);
setStatus(r.status);
setMigration(r.migration ?? null);
setPwd('');
setMsg({ kind: 'ok', text: 'Vault разблокирован.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка разблокировки' });
} finally { setBusy(false); }
};
const doLock = async () => {
setBusy(true); setMsg(null);
try {
await vaultApi.lock();
await refresh();
setMsg({ kind: 'ok', text: 'Vault заблокирован. Фоновые задачи опроса остановлены до следующей разблокировки.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка блокировки' });
} finally { setBusy(false); }
};
const doRotate = async (e: FormEvent) => {
e.preventDefault();
setMsg(null);
if (newPwd.length < 8) { setMsg({ kind: 'err', text: 'Новый мастер-пароль не короче 8 символов' }); return; }
if (newPwd !== newPwd2) { setMsg({ kind: 'err', text: 'Новые пароли не совпадают' }); return; }
setBusy(true);
try {
const r = await vaultApi.rotate(oldPwd, newPwd);
setStatus(r.status);
setOldPwd(''); setNewPwd(''); setNewPwd2('');
setMsg({ kind: 'ok', text: 'Мастер-пароль изменён. Все секреты остались валидными.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка смены пароля' });
} finally { setBusy(false); }
};
const renderStatus = () => {
if (!status) return <span className="text-mk-mute">Загрузка</span>;
if (!status.initialized) {
return <span className="inline-flex items-center gap-1.5 text-mk-warn"><ShieldAlert size={14}/> не инициализирован</span>;
}
if (status.unlocked) {
return <span className="inline-flex items-center gap-1.5 text-mk-ok"><Unlock size={14}/> разблокирован</span>;
}
return <span className="inline-flex items-center gap-1.5 text-mk-warn"><Lock size={14}/> заблокирован</span>;
};
return (
<div className="space-y-4">
<div className="card space-y-2">
<div className="flex items-center gap-2">
<ShieldCheck size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Шифрование секретов устройств</h3>
</div>
<p className="text-xs text-mk-mute leading-relaxed">
Пароли подключения к RouterOS-устройствам хранятся в БД зашифрованными
<b> AES-256-GCM</b>. Ключ шифрования (DEK) защищён вашим мастер-паролем
(PBKDF2-HMAC-SHA256, 200 000 итераций). Мастер-пароль в БД <b>не сохраняется</b>
его помнит только администратор. После рестарта контейнера vault блокируется
автоматически, до повторного ввода пароля фоновый опрос устройств приостанавливается.
</p>
<div className="text-sm">
<span className="text-mk-mute">Статус: </span>{renderStatus()}
</div>
{migration && (
<div className="text-[11px] text-mk-mute">
Миграция legacy-секретов: перешифровано <b>{migration.migrated}</b>,
пропущено уже-v2 <b>{migration.skipped}</b>,
не удалось <b>{migration.failed}</b>.
</div>
)}
</div>
{/* --- INIT (vault ещё не создан) --- */}
{status && !status.initialized && (
<form onSubmit={doInit} className="card space-y-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Создать мастер-пароль</h3>
</div>
<p className="text-[11px] text-mk-warn flex items-start gap-1">
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
<span>
Запомните его надёжно. Если потеряете пароли устройств в БД восстановить
будет нельзя, придётся завести устройства заново.
</span>
</p>
<div>
<label className="text-xs text-mk-mute">Мастер-пароль (мин. 8 символов)</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={pwd} onChange={(e) => setPwd(e.target.value)} required />
</div>
<div>
<label className="text-xs text-mk-mute">Повторите</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={pwd2} onChange={(e) => setPwd2(e.target.value)} required />
</div>
{msg && <div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>}
<button className="btn-primary !text-xs" disabled={busy}>
{busy ? 'Создаём…' : 'Создать мастер-пароль'}
</button>
</form>
)}
{/* --- UNLOCK (создан, но заблокирован) --- */}
{status && status.initialized && !status.unlocked && (
<form onSubmit={doUnlock} className="card space-y-3">
<div className="flex items-center gap-2">
<Unlock size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Разблокировать vault</h3>
</div>
<p className="text-xs text-mk-mute">
Введите мастер-пароль, чтобы возобновить опрос устройств и операции с их секретами.
</p>
<div>
<label className="text-xs text-mk-mute">Мастер-пароль</label>
<input className="input" type="password" autoComplete="current-password"
value={pwd} onChange={(e) => setPwd(e.target.value)} required autoFocus />
</div>
{msg && <div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>}
<button className="btn-primary !text-xs" disabled={busy}>
{busy ? 'Разблокируем…' : 'Разблокировать'}
</button>
</form>
)}
{/* --- LOCK + ROTATE (разблокирован) --- */}
{status && status.initialized && status.unlocked && (
<>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Lock size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Заблокировать сейчас</h3>
</div>
<p className="text-xs text-mk-mute">
После блокировки DEK будет очищен из памяти, и до следующей разблокировки
автоопрос приостановится, а API устройств начнёт отвечать <b>423 Locked</b>.
</p>
<button type="button" className="btn-ghost !text-xs" disabled={busy} onClick={doLock}>
<Lock size={13} /> Заблокировать
</button>
{msg && msg.kind === 'ok' && (
<div className="text-sm text-mk-ok">{msg.text}</div>
)}
</div>
<form onSubmit={doRotate} className="card space-y-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Сменить мастер-пароль</h3>
</div>
<p className="text-xs text-mk-mute">
Сами зашифрованные секреты при смене мастера не трогаются перешифровывается
только защитная обёртка DEK. Это безопасно и быстро.
</p>
<div>
<label className="text-xs text-mk-mute">Текущий мастер-пароль</label>
<input className="input" type="password" autoComplete="current-password"
value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} required />
</div>
<div>
<label className="text-xs text-mk-mute">Новый мастер-пароль (мин. 8 символов)</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={newPwd} onChange={(e) => setNewPwd(e.target.value)} required />
</div>
<div>
<label className="text-xs text-mk-mute">Повторите новый</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={newPwd2} onChange={(e) => setNewPwd2(e.target.value)} required />
</div>
{msg && msg.kind === 'err' && (
<div className="text-sm text-mk-err">{msg.text}</div>
)}
<button className="btn-primary !text-xs" disabled={busy}>
{busy ? 'Меняем…' : 'Сменить мастер-пароль'}
</button>
</form>
</>
)}
</div>
);
}