Initial scaffold: FastAPI backend + Vue 3 frontend + Docker setup

Backend: config/db/security/logging core, SQLAlchemy models (Server,
Credential, UpdateJob, UpdateLog, AuditLog, User), services (winrm, ssh,
cau, audit, job_runner), REST API (auth, servers, updates, audit),
Socket.io WebSocket layer.
Frontend: Vue 3 + TS + Pinia + Tailwind, Views (Dashboard, Servers,
Updates, Audit, Login), axios + socket.io-client, nginx prod config.
This commit is contained in:
B0rbor4d
2026-07-31 23:45:31 +00:00
commit cf7c29639c
72 changed files with 10610 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Core package: config, database, security, logging, exceptions."""
+68
View File
@@ -0,0 +1,68 @@
"""Application configuration via pydantic-settings.
All values are read from environment variables / .env file.
See .env.example for the full list.
"""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Central application settings."""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# Core
app_env: str = "development"
app_name: str = "Insight Updater"
secret_key: str = "dev-secret-change-me-32-chars-min"
encryption_key: str = ""
log_level: str = "INFO"
log_format: str = "json"
# JWT
jwt_algorithm: str = "RS256"
jwt_private_key_path: str = "keys/private.pem"
jwt_public_key_path: str = "keys/public.pem"
jwt_access_token_expire_minutes: int = 30
jwt_refresh_token_expire_days: int = 7
# Database / Redis
database_url: str = "sqlite+aiosqlite:///./data/app.db"
redis_url: str = "redis://localhost:6379/0"
# WinRM
winrm_transport: str = "ntlm"
winrm_cert_validation: str = "ignore"
winrm_operation_timeout: int = 60
winrm_read_timeout: int = 120
winrm_kerberos_delegation: bool = True
# SSH
ssh_timeout: int = 30
# LDAP (stub)
ldap_enabled: bool = False
ldap_uri: str = ""
ldap_bind_dn: str = ""
ldap_bind_password: str = ""
ldap_user_search_base: str = ""
ldap_user_filter: str = "(sAMAccountName={username})"
# CORS
cors_origins: str = "http://localhost:3000,http://localhost:8000"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def is_production(self) -> bool:
return self.app_env.lower() == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()
+47
View File
@@ -0,0 +1,47 @@
"""Database setup: async engine, session factory, declarative base."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=settings.log_level.upper() == "DEBUG",
pool_pre_ping=True,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
class Base(DeclarativeBase):
"""Declarative base for all ORM models."""
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency yielding an async DB session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def init_db() -> None:
"""Create all tables (scaffold mode; Alembic migrations come later)."""
# Import models so they register on the metadata
from app import models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+66
View File
@@ -0,0 +1,66 @@
"""Custom application exceptions, mapped to HTTP responses in main.py."""
class AppError(Exception):
"""Base class for application errors."""
status_code: int = 500
detail: str = "Internal server error"
def __init__(self, detail: str | None = None) -> None:
super().__init__(detail or self.detail)
if detail:
self.detail = detail
class NotFoundError(AppError):
status_code = 404
detail = "Resource not found"
class ConflictError(AppError):
status_code = 409
detail = "Resource conflict"
class UnauthorizedError(AppError):
status_code = 401
detail = "Authentication required"
class ForbiddenError(AppError):
status_code = 403
detail = "Permission denied"
class InvalidTokenError(UnauthorizedError):
detail = "Token is invalid or expired"
class CredentialDecryptionError(AppError):
status_code = 500
detail = "Stored credential cannot be decrypted"
class ConnectionTestError(AppError):
status_code = 502
detail = "Connection test failed"
class WinRMError(AppError):
status_code = 502
detail = "WinRM operation failed"
class SSHError(AppError):
status_code = 502
detail = "SSH operation failed"
class CAUError(AppError):
status_code = 502
detail = "Cluster-Aware Updating operation failed"
class JobNotCancellableError(ConflictError):
detail = "Job cannot be cancelled in its current state"
+44
View File
@@ -0,0 +1,44 @@
"""Structured logging setup with structlog."""
import logging
import sys
import structlog
from app.core.config import get_settings
settings = get_settings()
def setup_logging() -> None:
level = getattr(logging, settings.log_level.upper(), logging.INFO)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=level,
)
processors: list = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
if settings.log_format == "json":
processors.append(structlog.processors.JSONRenderer())
else:
processors.append(structlog.dev.ConsoleRenderer())
structlog.configure(
processors=processors,
wrapper_class=structlog.make_filtering_bound_logger(level),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name) # type: ignore[no-any-return]
+100
View File
@@ -0,0 +1,100 @@
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import bcrypt
from cryptography.fernet import Fernet, InvalidToken
from jose import JWTError, jwt
from app.core.config import get_settings
from app.core.exceptions import CredentialDecryptionError, InvalidTokenError
settings = get_settings()
# ---------------------------------------------------------------------------
# Fernet encryption for stored credentials
# ---------------------------------------------------------------------------
_fernet: Fernet | None = None
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
key = settings.encryption_key
if not key:
# Dev fallback: derive a valid fernet key from SECRET_KEY
import base64
import hashlib
key = base64.urlsafe_b64encode(
hashlib.sha256(settings.secret_key.encode()).digest()
).decode()
_fernet = Fernet(key.encode() if isinstance(key, str) else key)
return _fernet
def encrypt(plaintext: str) -> str:
"""Encrypt a secret for at-rest storage."""
return _get_fernet().encrypt(plaintext.encode()).decode()
def decrypt(token: str) -> str:
"""Decrypt a stored secret. Raises CredentialDecryptionError on failure."""
try:
return _get_fernet().decrypt(token.encode()).decode()
except InvalidToken as exc:
raise CredentialDecryptionError("Stored credential cannot be decrypted") from exc
# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------
def hash_password(password: str) -> str:
# bcrypt hard limit: 72 bytes
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode()[:72], hashed.encode())
except ValueError:
return False
# ---------------------------------------------------------------------------
# JWT (RS256 with key files, HS256 fallback for dev without keys)
# ---------------------------------------------------------------------------
def _read_key(path: str) -> str | None:
p = Path(path)
return p.read_text() if p.exists() else None
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
claims: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
if extra_claims:
claims.update(extra_claims)
private_key = _read_key(settings.jwt_private_key_path)
if private_key and settings.jwt_algorithm == "RS256":
return jwt.encode(claims, private_key, algorithm="RS256")
# Dev fallback when no key pair exists yet
return jwt.encode(claims, settings.secret_key, algorithm="HS256")
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT. Raises InvalidTokenError on failure."""
try:
public_key = _read_key(settings.jwt_public_key_path)
if public_key and settings.jwt_algorithm == "RS256":
return jwt.decode(token, public_key, algorithms=["RS256"]) # type: ignore[no-any-return]
return jwt.decode(token, settings.secret_key, algorithms=["HS256"]) # type: ignore[no-any-return]
except JWTError as exc:
raise InvalidTokenError("Token is invalid or expired") from exc