Compare commits
13 Commits
27eb4fd606
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 31f16ca412 | |||
| edcfe693b7 | |||
| 2115c838dd | |||
| c3422f7c67 | |||
| a552e55c29 | |||
| 6aa5399452 | |||
| 5d7a7b288c | |||
| 6837c09294 | |||
| 8f1522c430 | |||
| 6da3cd7223 | |||
| ca33a2f2c6 | |||
| d35aff90a3 | |||
| 440a2ba753 |
@@ -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.
|
||||
@@ -4,14 +4,14 @@ SECRET_KEY=change-me-to-32-bytes-random-secret
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=14
|
||||
|
||||
DATABASE_URL=postgresql+psycopg2://mikrocloud:mikrocloud@postgres:5432/mikrocloud
|
||||
DATABASE_URL=postgresql+psycopg2://ROSzetta:ROSzetta@postgres:5432/ROSzetta
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# MinIO / S3
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_ACCESS_KEY=minio
|
||||
S3_SECRET_KEY=minio12345
|
||||
S3_BUCKET=mikrocloud-backups
|
||||
S3_BUCKET=roszetta-backups
|
||||
|
||||
# Bootstrap admin (создаётся при первом запуске)
|
||||
BOOTSTRAP_ADMIN_EMAIL=admin
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
APP_NAME = "ROSzetta"
|
||||
APP_VERSION = "0.6.0"
|
||||
APP_VERSION = "0.7.0"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -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()}
|
||||
@@ -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()
|
||||
|
||||
@@ -16,14 +16,14 @@ class Settings(BaseSettings):
|
||||
refresh_token_expire_days: int = 14
|
||||
|
||||
database_url: str = (
|
||||
"postgresql+psycopg2://mikrocloud:mikrocloud@postgres:5432/mikrocloud"
|
||||
"postgresql+psycopg2://ROSzetta:ROSzetta@postgres:5432/ROSzetta"
|
||||
)
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
s3_endpoint: str = "http://minio:9000"
|
||||
s3_access_key: str = "minio"
|
||||
s3_secret_key: str = "minio12345"
|
||||
s3_bucket: str = "mikrocloud-backups"
|
||||
s3_bucket: str = "roszetta-backups"
|
||||
|
||||
bootstrap_admin_email: str = "admin"
|
||||
bootstrap_admin_password: str = "admin"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
-3
@@ -1,6 +1,6 @@
|
||||
POSTGRES_USER=mikrocloud
|
||||
POSTGRES_PASSWORD=mikrocloud
|
||||
POSTGRES_DB=mikrocloud
|
||||
POSTGRES_USER=ROSzetta
|
||||
POSTGRES_PASSWORD=ROSzetta
|
||||
POSTGRES_DB=ROSzetta
|
||||
|
||||
MINIO_ROOT_USER=minio
|
||||
MINIO_ROOT_PASSWORD=minio12345
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
name: mikrocloud
|
||||
name: ROSzetta
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: timescale/timescaledb:2.16.1-pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mikrocloud}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mikrocloud}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-mikrocloud}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ROSzetta}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ROSzetta}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-ROSzetta}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
@@ -49,12 +49,12 @@ services:
|
||||
environment:
|
||||
APP_ENV: dev
|
||||
SECRET_KEY: ${SECRET_KEY:-please-change-this-32-bytes-secret-now}
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-mikrocloud}:${POSTGRES_PASSWORD:-mikrocloud}@postgres:5432/${POSTGRES_DB:-mikrocloud}
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-ROSzetta}:${POSTGRES_PASSWORD:-ROSzetta}@postgres:5432/${POSTGRES_DB:-ROSzetta}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minio}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minio12345}
|
||||
S3_BUCKET: mikrocloud-backups
|
||||
S3_BUCKET: roszetta-backups
|
||||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin}
|
||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-admin}
|
||||
CORS_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173"
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 порт.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user