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 @@
"""API route modules."""
+44
View File
@@ -0,0 +1,44 @@
"""Audit log routes (read-only, paginated)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.database import get_db
from app.models.audit_log import AuditLog
from app.models.user import User
from app.schemas.audit import AuditLogPage
router = APIRouter()
@router.get("", response_model=AuditLogPage)
async def list_audit_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
action: str | None = None,
username: str | None = None,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> AuditLogPage:
stmt = select(AuditLog).order_by(AuditLog.id.desc())
count_stmt = select(func.count(AuditLog.id))
if action:
stmt = stmt.where(AuditLog.action == action)
count_stmt = count_stmt.where(AuditLog.action == action)
if username:
stmt = stmt.where(AuditLog.username == username)
count_stmt = count_stmt.where(AuditLog.username == username)
total = await db.scalar(count_stmt) or 0
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
return AuditLogPage(
items=list(result.scalars().all()),
total=total,
page=page,
page_size=page_size,
)
+55
View File
@@ -0,0 +1,55 @@
"""Auth routes: login (local user or LDAP stub)."""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import client_ip
from app.core.config import get_settings
from app.core.database import get_db
from app.core.exceptions import UnauthorizedError
from app.core.security import create_access_token, verify_password
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse
from app.services.audit import AuditService
settings = get_settings()
router = APIRouter()
@router.post("/login", response_model=TokenResponse)
async def login(
payload: LoginRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
audit = AuditService(db)
result = await db.execute(select(User).where(User.username == payload.username))
user = result.scalar_one_or_none()
if user and user.password_hash and verify_password(payload.password, user.password_hash):
user.last_login_at = datetime.now(UTC)
await audit.log(
username=user.username,
action="auth.login",
ip_address=client_ip(request),
)
token = create_access_token(
subject=user.username,
extra_claims={"is_admin": user.is_admin},
)
return TokenResponse(
access_token=token,
expires_in=settings.jwt_access_token_expire_minutes * 60,
)
# LDAP stub: when enabled, attempt bind + search here (not yet implemented)
await audit.log(
username=payload.username,
action="auth.login",
result="failure",
ip_address=client_ip(request),
)
raise UnauthorizedError("Benutzername oder Passwort falsch")
+160
View File
@@ -0,0 +1,160 @@
"""Server inventory routes."""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import client_ip, get_current_user
from app.core.database import get_db
from app.core.exceptions import NotFoundError
from app.models.credential import Credential
from app.models.server import Server, ServerType
from app.models.user import User
from app.schemas.server import HealthCheckResult, ServerCreate, ServerRead, ServerUpdate
from app.services.audit import AuditService
from app.services.cau import CAUService
from app.services.job_runner import JobRunner
from app.services.ssh import SSHService
from app.services.winrm import WinRMService
router = APIRouter()
@router.get("", response_model=list[ServerRead])
async def list_servers(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[Server]:
result = await db.execute(select(Server).order_by(Server.name))
return list(result.scalars().all())
@router.post("", response_model=ServerRead, status_code=201)
async def create_server(
payload: ServerCreate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Server:
server = Server(**payload.model_dump())
db.add(server)
await db.flush()
await AuditService(db).log(
username=user.username,
action="server.create",
target=server.name,
ip_address=client_ip(request),
)
return server
@router.get("/{server_id}", response_model=ServerRead)
async def get_server(
server_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> Server:
server = await db.get(Server, server_id)
if not server:
raise NotFoundError("Server nicht gefunden")
return server
@router.patch("/{server_id}", response_model=ServerRead)
async def update_server(
server_id: int,
payload: ServerUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Server:
server = await db.get(Server, server_id)
if not server:
raise NotFoundError("Server nicht gefunden")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(server, field, value)
await AuditService(db).log(
username=user.username,
action="server.update",
target=server.name,
ip_address=client_ip(request),
)
return server
@router.delete("/{server_id}", status_code=204)
async def delete_server(
server_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
server = await db.get(Server, server_id)
if not server:
raise NotFoundError("Server nicht gefunden")
await AuditService(db).log(
username=user.username,
action="server.delete",
target=server.name,
ip_address=client_ip(request),
)
await db.delete(server)
@router.get("/{server_id}/health", response_model=HealthCheckResult)
async def check_server_health(
server_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> HealthCheckResult:
server = await db.get(Server, server_id)
if not server:
raise NotFoundError("Server nicht gefunden")
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
if server.type == ServerType.LINUX:
service = SSHService(
server.hostname,
port=server.port,
credentials=JobRunner._ssh_creds(credential),
)
elif server.type == ServerType.CAU_CLUSTER:
cau = CAUService(
server.hostname,
access_node=server.hostname,
port=server.port,
credentials=JobRunner._winrm_creds(credential),
)
ok, message = await cau.test_cluster()
server.last_health_at = datetime.now(UTC)
server.last_health_ok = ok
return HealthCheckResult(
server_id=server.id,
ok=ok,
message=message,
checked_at=server.last_health_at,
)
else:
service = WinRMService(
server.hostname,
port=server.port,
credentials=JobRunner._winrm_creds(credential),
)
started = datetime.now(UTC)
ok, message = await service.test_connection()
latency_ms = (datetime.now(UTC) - started).total_seconds() * 1000
server.last_health_at = datetime.now(UTC)
server.last_health_ok = ok
return HealthCheckResult(
server_id=server.id,
ok=ok,
latency_ms=round(latency_ms, 1),
message=message,
checked_at=server.last_health_at,
)
+134
View File
@@ -0,0 +1,134 @@
"""Update job routes: trigger, list, logs, cancel."""
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import client_ip, get_current_user
from app.core.database import get_db
from app.core.exceptions import JobNotCancellableError, NotFoundError
from app.models.server import Server
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
from app.models.user import User
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
from app.services.audit import AuditService
from app.services.job_runner import job_runner
router = APIRouter()
@router.post("/trigger", response_model=UpdateJobRead, status_code=201)
async def trigger_update(
payload: JobTriggerRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> UpdateJob:
server = await db.get(Server, payload.server_id)
if not server:
raise NotFoundError("Server nicht gefunden")
job = UpdateJob(
server_id=server.id,
type=payload.type,
started_by=user.username,
)
db.add(job)
await db.flush()
await AuditService(db).log(
username=user.username,
action="update.trigger",
target=server.name,
details={"job_id": job.id, "type": payload.type.value},
ip_address=client_ip(request),
)
await db.commit()
await job_runner.start(job.id)
return job
@router.get("", response_model=list[UpdateJobRead])
async def list_jobs(
status: JobStatus | None = None,
limit: int = Query(default=50, le=200),
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[UpdateJob]:
stmt = select(UpdateJob).order_by(UpdateJob.id.desc()).limit(limit)
if status:
stmt = stmt.where(UpdateJob.status == status)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.get("/{job_id}", response_model=UpdateJobRead)
async def get_job(
job_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> UpdateJob:
job = await db.get(UpdateJob, job_id)
if not job:
raise NotFoundError("Job nicht gefunden")
return job
@router.get("/{job_id}/logs", response_model=list[UpdateLogRead])
async def get_job_logs(
job_id: int,
after_id: int = 0,
limit: int = Query(default=500, le=2000),
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[UpdateLog]:
stmt = (
select(UpdateLog)
.where(UpdateLog.job_id == job_id, UpdateLog.id > after_id)
.order_by(UpdateLog.id)
.limit(limit)
)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/{job_id}/cancel", response_model=UpdateJobRead)
async def cancel_job(
job_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> UpdateJob:
job = await db.get(UpdateJob, job_id)
if not job:
raise NotFoundError("Job nicht gefunden")
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
raise JobNotCancellableError()
cancelled = await job_runner.cancel(job_id)
if not cancelled:
job.status = JobStatus.CANCELLED
await AuditService(db).log(
username=user.username,
action="update.cancel",
target=f"job:{job_id}",
ip_address=client_ip(request),
)
return job
@router.get("/stats/summary")
async def job_stats(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> dict:
total = await db.scalar(select(func.count(UpdateJob.id)))
running = await db.scalar(
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.RUNNING)
)
failed = await db.scalar(
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.FAILED)
)
return {"total": total or 0, "running": running or 0, "failed": failed or 0}