10 Commits

Author SHA1 Message Date
dimadenisjuk edcfe693b7 Merge branch 'main' into hap_ac2_fix 2026-05-18 01:37:55 +05:00
dimadenisjuk 2115c838dd Fixed hAP ac2 2026-05-17 23:35:52 +03:00
git_admin c3422f7c67 Key Encryption 2026-05-18 01:33:23 +05:00
git_admin a552e55c29 add hAP ac2 2026-05-18 01:05:34 +05:00
git_admin 6aa5399452 Merge branch 'main' of https://git.core.uz/mikrotik/roszetta.git 2026-05-18 00:28:48 +05:00
git_admin 5d7a7b288c readme 2026-05-18 00:23:24 +05:00
dimadenisjuk 6837c09294 Merge pull request 'AI-agent chatbot with commands execution' (#1) from chatbot into main
Reviewed-on: #1
2026-05-17 23:27:29 +05:00
dimadenisjuk 8f1522c430 Merge branch 'main' into chatbot 2026-05-17 23:26:54 +05:00
git_admin 6da3cd7223 fix 2026-05-17 23:11:42 +05:00
git_admin 440a2ba753 fix 2026-05-17 21:17:53 +05:00
18 changed files with 995 additions and 31 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 -2
View File
@@ -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
+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()
+2 -2
View File
@@ -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"
+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 -3
View File
@@ -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
+6 -6
View File
@@ -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"
+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)}
+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>
);
}