Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31f16ca412 | |||
| edcfe693b7 | |||
| 2115c838dd | |||
| c3422f7c67 | |||
| a552e55c29 | |||
| 6aa5399452 | |||
| 5d7a7b288c | |||
| 6837c09294 |
@@ -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.
|
||||||
@@ -12,6 +12,7 @@ from .v1 import firmware as firmware_router
|
|||||||
from .v1 import health as health_router
|
from .v1 import health as health_router
|
||||||
from .v1 import metrics as metrics_router
|
from .v1 import metrics as metrics_router
|
||||||
from .v1 import settings as settings_router
|
from .v1 import settings as settings_router
|
||||||
|
from .v1 import vault as vault_router
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health_router.router, tags=["health"])
|
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(cli_router.router, prefix="/cli", tags=["cli"])
|
||||||
api_router.include_router(controller_backup_router.router, prefix="/controller/backup", tags=["controller"])
|
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(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
|
from fastapi import APIRouter
|
||||||
|
|
||||||
APP_NAME = "ROSzetta"
|
APP_NAME = "ROSzetta"
|
||||||
APP_VERSION = "0.6.0"
|
APP_VERSION = "0.7.0"
|
||||||
|
|
||||||
router = APIRouter()
|
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 metric as _metric # noqa: F401
|
||||||
from ..models import settings as _settings # noqa: F401
|
from ..models import settings as _settings # noqa: F401
|
||||||
from ..models import interface_stat as _ifs # 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)
|
Base.metadata.create_all(bind=engine)
|
||||||
_ensure_columns()
|
_ensure_columns()
|
||||||
|
|||||||
@@ -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 base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
@@ -69,8 +79,30 @@ def _fernet() -> Fernet:
|
|||||||
|
|
||||||
|
|
||||||
def encrypt_secret(value: str) -> str:
|
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:
|
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 contextlib import asynccontextmanager
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .api.router import api_router
|
from .api.router import api_router
|
||||||
from .core.bootstrap import init_db
|
from .core.bootstrap import init_db
|
||||||
from .core.config import get_settings
|
from .core.config import get_settings
|
||||||
from .core.db import SessionLocal
|
from .core.db import SessionLocal
|
||||||
|
from .services.vault import VaultLocked, VaultNotInitialized, vault_service
|
||||||
|
|
||||||
|
|
||||||
def _job_firmware_check() -> None:
|
def _job_firmware_check() -> None:
|
||||||
@@ -36,6 +38,14 @@ def _job_probe_devices() -> None:
|
|||||||
fetch_identity, fetch_interface_stats, fetch_resource, parse_uptime,
|
fetch_identity, fetch_interface_stats, fetch_resource, parse_uptime,
|
||||||
)
|
)
|
||||||
from datetime import datetime, timedelta, timezone
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
for d in db.query(Device).all():
|
for d in db.query(Device).all():
|
||||||
@@ -160,6 +170,18 @@ async def lifespan(_: FastAPI):
|
|||||||
logger.info("Starting ROSzetta API ({} env)", settings.app_env)
|
logger.info("Starting ROSzetta API ({} env)", settings.app_env)
|
||||||
init_db()
|
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
|
# FTP-сервер для приёма push-бэкапов от MikroTik
|
||||||
try:
|
try:
|
||||||
from .services.backup_ftp_server import start_server
|
from .services.backup_ftp_server import start_server
|
||||||
@@ -225,6 +247,20 @@ def create_app() -> FastAPI:
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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)
|
app.include_router(api_router)
|
||||||
return app
|
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}
|
||||||
@@ -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;
|
hours: number;
|
||||||
devices: HeartbeatDevice[];
|
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,
|
CheckCircle2, AlertTriangle, Bell, Terminal,
|
||||||
Menu, X, Settings as SettingsIcon,
|
Menu, X, Settings as SettingsIcon,
|
||||||
ChevronDown, ChevronUp,
|
ChevronDown, ChevronUp,
|
||||||
|
Lock, Unlock, ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/store/auth';
|
import { useAuth } from '@/store/auth';
|
||||||
import { api, Device } from '@/api/client';
|
import { api, Device, vaultApi, VaultStatus } from '@/api/client';
|
||||||
import AboutModal from './AboutModal';
|
import AboutModal from './AboutModal';
|
||||||
import { useSettings } from '@/store/settings';
|
import { useSettings } from '@/store/settings';
|
||||||
import { pickOkMessage } from '@/utils/okMessages';
|
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() {
|
function AlertsBell() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
@@ -431,6 +477,7 @@ export default function AppLayout() {
|
|||||||
v{version}
|
v{version}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<VaultBadge />
|
||||||
<AlertsBell />
|
<AlertsBell />
|
||||||
<button
|
<button
|
||||||
onClick={() => setAboutOpen(true)}
|
onClick={() => setAboutOpen(true)}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export interface DeviceMockupProps {
|
|||||||
const isHapAcLite = (b?: string | null): boolean =>
|
const isHapAcLite = (b?: string | null): boolean =>
|
||||||
!!b && /h\s*A\s*P\s*ac\s*lite/i.test(b);
|
!!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 isHapLike = (b?: string | null): boolean => !!b && /\bh\s*A\s*P\b/i.test(b);
|
||||||
|
|
||||||
const isRb5009 = (b?: string | null): boolean =>
|
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) {
|
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} />;
|
return <HapAcLiteMockup interfaces={interfaces} />;
|
||||||
}
|
}
|
||||||
if (isRb5009(boardName)) {
|
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+ ---------
|
// --------- RB5009UG+S+ ---------
|
||||||
// Чёрный корпус, 8 GigE портов (ether1..ether8) + 1 SFP+ (sfp-sfpplus1).
|
// Чёрный корпус, 8 GigE портов (ether1..ether8) + 1 SFP+ (sfp-sfpplus1).
|
||||||
// Слева: DC jack 12-57V, кнопка R (reset), USB 3.0 порт.
|
// Слева: DC jack 12-57V, кнопка R (reset), USB 3.0 порт.
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Database, Settings as SettingsIcon, Download, Upload, RefreshCw, Eye, Save,
|
Database, Settings as SettingsIcon, Download, Upload, RefreshCw, Eye, Save,
|
||||||
Globe, Palette, Tag, Activity, Radar, AlertTriangle, User as UserIcon, KeyRound,
|
Globe, Palette, Tag, Activity, Radar, AlertTriangle, User as UserIcon, KeyRound,
|
||||||
|
Lock, Unlock, ShieldCheck, ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api, AppSettings } from '@/api/client';
|
import { api, AppSettings, vaultApi, VaultStatus, VaultMigration } from '@/api/client';
|
||||||
import { useAuth } from '@/store/auth';
|
import { useAuth } from '@/store/auth';
|
||||||
import { useSettings } from '@/store/settings';
|
import { useSettings } from '@/store/settings';
|
||||||
import { useT, LOCALES, THEMES, HEARTBEAT_RANGES, PROBE_INTERVALS } from '@/i18n';
|
import { useT, LOCALES, THEMES, HEARTBEAT_RANGES, PROBE_INTERVALS } from '@/i18n';
|
||||||
@@ -19,11 +20,12 @@ const MENU_LABELS: Record<keyof AppSettings['menu'], string> = {
|
|||||||
settings: 'Настройки',
|
settings: 'Настройки',
|
||||||
};
|
};
|
||||||
|
|
||||||
type TabKey = 'general' | 'probe' | 'user' | 'menu' | 'backup';
|
type TabKey = 'general' | 'probe' | 'user' | 'security' | 'menu' | 'backup';
|
||||||
|
|
||||||
function parseHash(h: string): TabKey {
|
function parseHash(h: string): TabKey {
|
||||||
const v = h.replace(/^#/, '');
|
const v = h.replace(/^#/, '');
|
||||||
if (v === 'users' || v === 'password' || v === 'user') return 'user';
|
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 === 'menu') return 'menu';
|
||||||
if (v === 'backup') return 'backup';
|
if (v === 'backup') return 'backup';
|
||||||
if (v === 'probe') return 'probe';
|
if (v === 'probe') return 'probe';
|
||||||
@@ -32,11 +34,12 @@ function parseHash(h: string): TabKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||||
{ key: 'general', label: 'Общие', icon: SettingsIcon },
|
{ key: 'general', label: 'Общие', icon: SettingsIcon },
|
||||||
{ key: 'probe', label: 'Опрос', icon: Radar },
|
{ key: 'probe', label: 'Опрос', icon: Radar },
|
||||||
{ key: 'user', label: 'Пользователь', icon: UserIcon },
|
{ key: 'user', label: 'Пользователь', icon: UserIcon },
|
||||||
{ key: 'menu', label: 'Меню', icon: Eye },
|
{ key: 'security', label: 'Безопасность', icon: ShieldCheck },
|
||||||
{ key: 'backup', label: 'Бэкап', icon: Database },
|
{ key: 'menu', label: 'Меню', icon: Eye },
|
||||||
|
{ key: 'backup', label: 'Бэкап', icon: Database },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -131,8 +134,9 @@ export default function SettingsPage() {
|
|||||||
const updUi = (k: keyof AppSettings['ui'], v: any) =>
|
const updUi = (k: keyof AppSettings['ui'], v: any) =>
|
||||||
setDraft({ ...draft, ui: { ...ui, [k]: v } });
|
setDraft({ ...draft, ui: { ...ui, [k]: v } });
|
||||||
|
|
||||||
// На вкладке "Пользователь" своя кнопка сохранения — основная "Сохранить" не нужна.
|
// На вкладках "Пользователь" и "Безопасность" — свои кнопки в формах,
|
||||||
const showSaveBtn = tab !== 'user';
|
// глобальный "Сохранить" не нужен.
|
||||||
|
const showSaveBtn = tab !== 'user' && tab !== 'security';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-3xl">
|
<div className="space-y-4 max-w-3xl">
|
||||||
@@ -302,6 +306,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{tab === 'user' && <UserTab email={email} />}
|
{tab === 'user' && <UserTab email={email} />}
|
||||||
|
|
||||||
|
{tab === 'security' && <SecurityTab />}
|
||||||
|
|
||||||
{tab === 'menu' && (
|
{tab === 'menu' && (
|
||||||
<div className="card space-y-3">
|
<div className="card space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -481,3 +487,232 @@ function UserTab({ email }: { email: string | null }) {
|
|||||||
</div>
|
</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