This commit is contained in:
2026-05-17 20:54:53 +05:00
parent 65a0babeab
commit 27eb4fd606
90 changed files with 12343 additions and 0 deletions
View File
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from loguru import logger
from sqlalchemy.orm import Session
from .config import get_settings
from .db import Base, SessionLocal, engine
from .security import hash_password
from ..models.user import User
def init_db() -> None:
# Импортируем модели, чтобы они зарегистрировались в Base.metadata
from ..models import device as _device # noqa: F401
from ..models import user as _user # noqa: F401
from ..models import backup as _backup # noqa: F401
from ..models import firmware as _firmware # noqa: F401
from ..models import alert as _alert # noqa: F401
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
Base.metadata.create_all(bind=engine)
_ensure_columns()
_ensure_admin()
def _ensure_columns() -> None:
"""Лёгкие миграции на ALTER TABLE для совместимости со старыми БД."""
from sqlalchemy import text
statements = [
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_error TEXT",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS internet_ok BOOLEAN",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_uptime_seconds INTEGER",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS abnormal_reboot BOOLEAN NOT NULL DEFAULT FALSE",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_log_warning TEXT",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS monitored_interfaces TEXT",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS uplink_interfaces TEXT",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS interface_history_hours INTEGER NOT NULL DEFAULT 24",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'router'",
"ALTER TABLE devices ADD COLUMN IF NOT EXISTS architecture VARCHAR(32)",
]
with engine.begin() as conn:
for s in statements:
try:
conn.execute(text(s))
except Exception as exc: # pragma: no cover
logger.warning("migration failed: {} ({})", s, exc)
def _ensure_admin() -> None:
settings = get_settings()
db: Session = SessionLocal()
try:
exists = db.query(User).filter(User.email == settings.bootstrap_admin_email).first()
if exists:
return
admin = User(
email=settings.bootstrap_admin_email,
hashed_password=hash_password(settings.bootstrap_admin_password),
role="admin",
is_active=True,
)
db.add(admin)
db.commit()
logger.info("Created bootstrap admin: {}", settings.bootstrap_admin_email)
finally:
db.close()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from functools import lru_cache
from typing import List
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "dev"
secret_key: str = "dev-secret-change-me"
access_token_expire_minutes: int = 60
refresh_token_expire_days: int = 14
database_url: str = (
"postgresql+psycopg2://mikrocloud:mikrocloud@postgres:5432/mikrocloud"
)
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"
bootstrap_admin_email: str = "admin"
bootstrap_admin_password: str = "admin"
cors_origins: str = "http://localhost:5173"
# sprint 06: периодические задачи
firmware_check_interval_hours: int = 24
device_probe_interval_minutes: int = 5
# sprint 08: push-доставка бэкапов
backup_ftp_host: str = "0.0.0.0"
backup_ftp_port: int = 2121
backup_push_host: str = "" # пусто → автоопределение detect_push_host()
@property
def cors_origins_list(self) -> List[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import get_settings
settings = get_settings()
connect_args: dict = {}
if settings.database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(
settings.database_url, pool_pre_ping=True, future=True, connect_args=connect_args
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db: Session = SessionLocal()
try:
yield db
finally:
db.close()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from cryptography.fernet import Fernet
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def _now() -> datetime:
return datetime.now(timezone.utc)
def create_access_token(subject: str | int, extra: dict[str, Any] | None = None) -> str:
payload: dict[str, Any] = {
"sub": str(subject),
"type": "access",
"iat": _now(),
"exp": _now() + timedelta(minutes=settings.access_token_expire_minutes),
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def create_refresh_token(subject: str | int) -> str:
payload = {
"sub": str(subject),
"type": "refresh",
"iat": _now(),
"exp": _now() + timedelta(days=settings.refresh_token_expire_days),
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except JWTError as exc: # pragma: no cover
raise ValueError(f"invalid token: {exc}") from exc
# --- Симметричное шифрование секретов устройств -----------------------------
# Производный ключ из SECRET_KEY (для dev). В prod — KMS / Vault.
def _fernet() -> Fernet:
import base64
import hashlib
digest = hashlib.sha256(settings.secret_key.encode()).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt_secret(value: str) -> str:
return _fernet().encrypt(value.encode()).decode()
def decrypt_secret(token: str) -> str:
return _fernet().decrypt(token.encode()).decode()