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
+85
View File
@@ -0,0 +1,85 @@
# Insight Updater - Environment Template
# Copy to .env and fill in secrets
# =============================================================================
# CORE APPLICATION
# =============================================================================
APP_ENV=development
SECRET_KEY=change-me-min-32-characters-random
ENCRYPTION_KEY=change-me-32-bytes-base64-encoded
JWT_ALGORITHM=RS256
JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# =============================================================================
# DATABASE
# =============================================================================
# Development: SQLite (file-based, zero config)
DATABASE_URL=sqlite+aiosqlite:///./data/app.db
# Production: PostgreSQL (uncomment and configure)
# DATABASE_URL=postgresql+asyncpg://updater:secure-password@db:5432/insight_updater
# =============================================================================
# REDIS (for Socket.io pub/sub, caching, rate limiting)
# =============================================================================
REDIS_URL=redis://redis:6379/0
# =============================================================================
# WINRM CONFIGURATION
# =============================================================================
WINRM_TRANSPORT=ntlm # ntlm | kerberos | credssp
WINRM_CERT_VALIDATION=ignore # ignore | validate
WINRM_OPERATION_TIMEOUT=60
WINRM_READ_TIMEOUT=120
WINRM_KERBEROS_DELEGATION=true
# =============================================================================
# SSH CONFIGURATION
# =============================================================================
SSH_TIMEOUT=30
SSH_KEY_PATH=/app/keys/ssh_host_key # optional host key for SSH server
# =============================================================================
# LDAP / ACTIVE DIRECTORY (STUB - prepared for future implementation)
# =============================================================================
LDAP_ENABLED=false
LDAP_URI=ldaps://dc.insight.local:636
LDAP_BIND_DN=CN=svc_updater,OU=Services,DC=insight,DC=local
LDAP_BIND_PASSWORD=
LDAP_USER_SEARCH_BASE=OU=Users,DC=insight,DC=local
LDAP_USER_FILTER=(sAMAccountName={username})
LDAP_GROUP_SEARCH_BASE=OU=Groups,DC=insight,DC=local
LDAP_GROUP_FILTER=(member={user_dn})
LDAP_CA_CERT_PATH=/app/certs/ldap-ca.pem
# =============================================================================
# FRONTEND (injected at build time via Vite)
# =============================================================================
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
VITE_APP_TITLE=Insight Updater
# =============================================================================
# LOGGING
# =============================================================================
LOG_LEVEL=INFO
LOG_FORMAT=json # json | console
# =============================================================================
# DOCKER / DEPLOYMENT
# =============================================================================
COMPOSE_PROJECT_NAME=insight-updater
TRAEFIK_NETWORK=traefik-public
DOMAIN=updater.insight-it.de
# =============================================================================
# EXTERNAL SERVICES
# =============================================================================
# Vaultwarden (for CI/CD secrets)
VAULTWARDEN_URL=https://p.hartmannsche.cloud
# Gitea
GITEA_URL=https://gitea.insight-it.de
GITEA_REPO=b0rbor4d/insight-updater
+92
View File
@@ -0,0 +1,92 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
env/
ENV/
# Poetry
poetry.lock
# UV
uv.lock
# Distribution / packaging
dist/
build/
*.egg-info/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment files
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite
*.sqlite3
data/*.db
data/*.sqlite
data/*.sqlite3
# Keys & Certificates
keys/
*.pem
*.key
*.crt
*.p12
# Logs
*.log
logs/
# Coverage
htmlcov/
.coverage
.coverage.*
coverage.xml
# Test artifacts
.pytest_cache/
.tox/
.mypy_cache/
.ruff_cache/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist/
build/
*.local
# Docker
docker-compose.override.yml
# Editor
*.sublime-workspace
*.sublime-project
# Misc
*.bak
*.tmp
*.temp
+150
View File
@@ -0,0 +1,150 @@
# Insight Updater - Agent Orientation
## Project Purpose
Self-hosted update orchestration for Windows (CAU/WSUS) and Linux servers via WinRM/SSH. Web UI to manage inventory, trigger updates, stream live progress via WebSocket.
## Quick Start
```bash
# Local development
cd ~/projects/insight-updater
docker compose up -d --build
# Backend only
cd backend && pip install -e . && uvicorn app.main:app --reload
# Frontend only
cd frontend && npm install && npm run dev
```
## Architecture Overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Frontend │────▶│ Backend │────▶│ Database │
│ (Vue 3) │ WS │ (FastAPI) │ │ (SQLite/ │
│ Port 3000 │◀─── │ Port 8000 │ │ PostgreSQL)│
└─────────────┘ └──────┬──────┘ └─────────────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ WinRM │ │ SSH │ │ CAU │
│ Service │ │ Service │ │ Service │
└─────────┘ └─────────┘ └─────────┘
```
## Key Directories
| Path | Purpose |
|------|---------|
| `backend/app/api/` | FastAPI route definitions (REST + WS) |
| `backend/app/core/` | Config, security, database, logging |
| `backend/app/models/` | SQLAlchemy ORM models |
| `backend/app/schemas/` | Pydantic request/response models |
| `backend/app/services/` | Business logic: winrm, ssh, cau, audit |
| `backend/app/websocket/` | Socket.io handlers for live updates |
| `frontend/src/views/` | Page components (Dashboard, Servers, Updates, Audit) |
| `frontend/src/components/` | Reusable UI components |
| `frontend/src/stores/` | Pinia stores (auth, servers, updates) |
| `frontend/src/api/` | Axios/Socket.io client setup |
## Core Models
| Model | Description |
|-------|-------------|
| `Server` | Inventory item: Windows/WinRM, Linux/SSH, CAU-Cluster |
| `Credential` | Encrypted credentials (WinRM user/pass, SSH key/pass) |
| `UpdateJob` | One update execution: server, status, started_by, started_at, finished_at |
| `UpdateLog` | Streamed log lines per job (WebSocket → DB) |
| `AuditLog` | Immutable audit trail: user, action, target, result |
| `User` | Local admin or LDAP-mapped user |
## Key Services
| Service | File | Responsibility |
|---------|------|----------------|
| `WinRMService` | `services/winrm.py` | Connect, run PS commands, stream output |
| `SSHService` | `services/ssh.py` | Connect, run commands, sudo handling |
| `CAUService` | `services/cau.py` | `Invoke-CauRun`, cluster status, node phases |
| `AuditService` | `services/audit.py` | Structured logging to DB + JSON file |
| `EncryptionService` | `core/security.py` | Fernet encrypt/decrypt credentials |
## WebSocket Events
| Event | Direction | Payload |
|-------|-----------|---------|
| `job:start` | Server→Client | `{job_id, server_id, type}` |
| `job:log` | Server→Client | `{job_id, line, timestamp, level}` |
| `job:progress` | Server→Client | `{job_id, percent, phase, node?}` |
| `job:complete` | Server→Client | `{job_id, status, duration}` |
| `job:cancel` | Client→Server | `{job_id}` |
## API Endpoints (Key)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/servers` | List all servers |
| POST | `/api/servers` | Create server |
| GET | `/api/servers/{id}/health` | Test WinRM/SSH connectivity |
| POST | `/api/updates/trigger` | Start update job |
| GET | `/api/updates/{job_id}/logs` | Get job logs (REST fallback) |
| WS | `/ws/updates` | Socket.io connection |
| GET | `/api/audit` | Paginated audit log |
| POST | `/api/auth/login` | JWT login |
| GET | `/health` | Health check |
## Environment Variables
See `.env.example` — key ones:
- `DATABASE_URL` — SQLite (dev) or PostgreSQL (prod)
- `SECRET_KEY` — JWT signing (32+ chars)
- `ENCRYPTION_KEY` — Fernet key for credentials (32 bytes base64)
- `WINRM_TRANSPORT``ntlm` \| `kerberos` \| `credssp`
- `LDAP_ENABLED``true`/`false` (stub)
## Testing
```bash
# Backend
cd backend && pytest -v
# Frontend
cd frontend && npm run test
# E2E (Playwright)
cd frontend && npm run test:e2e
```
## Deployment
**Target**: `monitoring` (10.0.2.105)
**User**: `b0rbor4d` (sudo via Vaultwarden)
**Reverse Proxy**: Traefik (Docker labels)
**Git Remote**: `ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git`
```bash
# On monitoring host
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
cd insight-updater
cp .env.example .env # fill secrets
docker compose -f docker-compose.prod.yml up -d --build
```
## Useful Commands
```bash
# DB migrations
cd backend && alembic upgrade head
# Generate keys
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
# Encrypt a test credential
python -c "from app.core.security import encrypt; print(encrypt('secret'))"
```
## Conventions
- **Language**: German for user-facing text, English for code/comments
- **Logging**: `structlog` JSON, level from `LOG_LEVEL` env
- **Errors**: Custom exceptions in `core/exceptions.py`, mapped to HTTP in `main.py`
- **Async**: All I/O async (asyncpg, asyncssh, httpx)
- **Types**: Strict mypy, Pydantic v2, SQLAlchemy 2.0
+154
View File
@@ -0,0 +1,154 @@
# Insight Updater - Project Prompt
## Overview
Build a self-hosted update orchestration tool for Windows (CAU/WSUS) and Linux servers via WinRM/SSH. Web UI to manage servers, trigger updates, monitor progress live via WebSocket.
## Target Stack
- **Backend**: FastAPI + Python 3.11+, SQLAlchemy + SQLite/PostgreSQL, structlog, python-winrm, paramiko
- **Frontend**: Vue 3 + TypeScript + Vite, Pinia, VueUse, Tailwind CSS, Socket.io client
- **Infra**: Docker Compose (backend, frontend, db, redis), Traefik labels for reverse proxy
- **CI/CD**: Gitea Actions / Woodpecker CI for build & deploy to monitoring (10.0.2.105)
## Core Features
1. **Server Inventory** - Add/edit/delete servers (Windows/WinRM, Linux/SSH, CAU-Cluster)
2. **Live Update Streaming** - WebSocket log stream with progress, status per node
3. **CAU Cluster Orchestration** - Trigger `Invoke-CauRun`, show per-node phases
4. **Linux Patch Management** - `apt/dnf/yum update` via SSH with sudo
5. **Audit Log** - Structured JSON logs: who, when, what server, outcome
5. **Health Checks** - `/health` endpoint, WinRM/SSH connectivity test
6. **LDAP-ready Auth** - JWT tokens, LDAP config schema prepared, local admin fallback
## Non-Goals
- No WSUS/SCCM replacement, no approval workflows
- No agent deployment (agentless WinRM/SSH only)
- No multi-tenancy / RBAC beyond admin/user
## Success Criteria
- Add server → see "Online/Offline", last patch date
- Click "Update" → live WebSocket log stream → final status Success/Failed
- CAU: Trigger cluster update, see per-node Pre/Post/Reboot phases
- Linux: Add SSH creds, trigger update, see apt/dnf output
- `docker compose up -d` → all healthy in <5 min on fresh VM
- Deploy to monitoring (10.0.2.105) via `git push` + CI works
- LDAP config schema exists, service stub wired, functional later
## Verification Commands
```bash
curl -f http://localhost:8000/health
curl -f http://localhost:3000/ # frontend
docker compose ps # all healthy
```
## Deployment Target
- **Host**: monitoring.insight.local (10.0.2.105)
- **User**: b0rbor4d (sudo via Vaultwarden)
- **Docker**: Podman/Docker Compose v2
- **Reverse Proxy**: Traefik (labels on compose services)
- **Git Remote**: ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
## Security
- Credentials encrypted at rest (Fernet/AES-GCM, key from env)
- WinRM: NTLM/Kerberos, HTTPS preferred, Cert validation configurable
- SSH: Key-based auth preferred, password fallback encrypted
- JWT: RS256, short expiry, refresh token rotation
- Audit log: immutable append-only (SQLite WAL / PG)
## Project Structure
```
~/projects/insight-updater/
├── backend/
│ ├── app/
│ │ ├── api/ # FastAPI routes
│ │ ├── core/ # config, security, db
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # business logic (winrm, ssh, cau, audit)
│ │ ├── websocket/ # Socket.io / FastAPI WS handlers
│ │ └── main.py
│ ├── tests/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── pyproject.toml
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ ├── views/
│ │ ├── stores/
│ │ ├── api/
│ │ └── main.ts
│ ├── Dockerfile
│ ├── package.json
│ └── vite.config.ts
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── .gitignore
├── README.md
├── AGENTS.md
└── PROMPT.md
```
## Key Libraries
- `fastapi`, `uvicorn`, `sqlalchemy[asyncio]`, `alembic`
- `python-winrm[kerberos]`, `paramiko`, `asyncssh`
- `python-socketio[asyncio]`, `redis`, `structlog`
- `cryptography`, `python-jose[cryptography]`, `passlib[bcrypt]`
- `pydantic-settings`, `pydantic[email]`
- `pytest`, `pytest-asyncio`, `httpx`
## Environment Variables (.env.example)
```env
# Core
APP_ENV=development
SECRET_KEY=change-me-32-chars-min
ENCRYPTION_KEY=change-me-32-chars-base64
JWT_ALGORITHM=RS256
JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
# Database
DATABASE_URL=sqlite+aiosqlite:///./data/app.db
# DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/updater
# Redis
REDIS_URL=redis://redis:6379/0
# WinRM
WINRM_TRANSPORT=ntlm
WINRM_CERT_VALIDATION=ignore
# LDAP (stub)
LDAP_ENABLED=false
LDAP_URI=ldaps://dc.insight.local:636
LDAP_BIND_DN=CN=svc_updater,OU=Services,DC=insight,DC=local
LDAP_BIND_PASSWORD=
LDAP_USER_SEARCH_BASE=OU=Users,DC=insight,DC=local
LDAP_USER_FILTER=(sAMAccountName={username})
# Frontend
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
```
## Development Workflow
```bash
# Local dev
cd backend && pip install -e . && uvicorn app.main:app --reload
cd frontend && npm install && npm run dev
# Docker dev
docker compose up -d --build
# Tests
cd backend && pytest
cd frontend && npm run test
```
## Remote Deploy (monitoring)
```bash
# On monitoring host
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
cd insight-updater
cp .env.example .env # fill secrets
docker compose -f docker-compose.prod.yml up -d --build
```
+172
View File
@@ -0,0 +1,172 @@
# Insight Updater
Self-hosted update orchestration for Windows (CAU/WSUS) and Linux servers via WinRM/SSH. Web UI to manage inventory, trigger updates, stream live progress via WebSocket.
## Features
- **Server Inventory** — Windows/WinRM, Linux/SSH, CAU Clusters
- **Live Updates** — WebSocket log stream with progress per node
- **CAU Support** — Trigger `Invoke-CauRun`, track per-node phases
- **Linux Patching** — `apt/dnf/yum update` via SSH with sudo
- **Audit Log** — Structured JSON: who, when, what server, outcome
- **Health Checks** — WinRM/SSH connectivity test
- **LDAP Ready** — Config schema + stub for Active Directory auth
## Quick Start (Development)
```bash
# Clone and enter
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
cd insight-updater
# Configure environment
cp .env.example .env
# Edit .env with your secrets
# Start all services
docker compose up -d --build
# Access
# Frontend: http://localhost:3000
# Backend API: http://localhost:8000
# API Docs: http://localhost:8000/docs
```
## Production Deployment (monitoring.insight.local)
```bash
# On monitoring host (10.0.2.105)
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
cd insight-updater
# Configure production environment
cp .env.example .env
# Fill in all secrets: SECRET_KEY, ENCRYPTION_KEY, DB_PASSWORD, LDAP creds, etc.
# Generate JWT keys
mkdir -p keys
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
# Deploy
docker compose -f docker-compose.prod.yml up -d --build
```
## Architecture
```
┌─────────────┐ WebSocket ┌─────────────┐
│ Frontend │ ◀─────────────▶ │ Backend │
│ (Vue 3) │ REST + WS │ (FastAPI) │
└─────────────┘ └──────┬──────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ WinRM │ │ SSH │ │ CAU │
│ Service │ │ Service │ │ Service │
└───────────┘ └───────────┘ └───────────┘
```
## Tech Stack
| Layer | Technology |
|-------|------------|
| Backend | Python 3.11+, FastAPI, SQLAlchemy 2.0, Alembic |
| Frontend | Vue 3, TypeScript, Vite, Pinia, Tailwind CSS |
| Database | SQLite (dev) / PostgreSQL (prod) |
| Cache/Queue | Redis 7 |
| Auth | JWT (RS256), LDAP stub |
| Encryption | Fernet (cryptography) |
| WebSocket | python-socketio |
| Windows | python-winrm (Kerberos/NTLM) |
| Linux | asyncssh / paramiko |
| Deploy | Docker Compose, Traefik |
## Project Structure
```
insight-updater/
├── backend/
│ ├── app/
│ │ ├── api/ # REST routes
│ │ ├── core/ # config, security, db
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # winrm, ssh, cau, audit
│ │ ├── websocket/ # Socket.io handlers
│ │ └── main.py
│ ├── tests/
│ ├── Dockerfile
│ ├── pyproject.toml
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ ├── views/
│ │ ├── stores/
│ │ ├── api/
│ │ └── main.ts
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── package.json
│ └── vite.config.ts
├── docker-compose.yml # Development
├── docker-compose.prod.yml # Production
├── .env.example
├── .gitignore
├── AGENTS.md
├── PROMPT.md
└── README.md
```
## Environment Variables
Key variables (see `.env.example` for full list):
| Variable | Description |
|----------|-------------|
| `SECRET_KEY` | JWT signing key (32+ chars) |
| `ENCRYPTION_KEY` | Fernet key for credentials (32 bytes base64) |
| `DATABASE_URL` | SQLite (dev) or PostgreSQL (prod) |
| `WINRM_TRANSPORT` | `ntlm` \| `kerberos` \| `credssp` |
| `LDAP_ENABLED` | Enable LDAP auth stub |
| `JWT_PRIVATE_KEY_PATH` | Path to RS256 private key |
| `JWT_PUBLIC_KEY_PATH` | Path to RS256 public key |
## Development Commands
```bash
# Backend
cd backend
pip install -e .
uvicorn app.main:app --reload
# Run tests
pytest -v
# Lint
ruff check .
mypy .
# Frontend
cd frontend
npm install
npm run dev
# Build
npm run build
# Type check
vue-tsc --noEmit
```
## API Documentation
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
- WebSocket: `ws://localhost:8000/ws/updates`
## License
MIT — Insight IT
+32
View File
@@ -0,0 +1,32 @@
FROM python:3.11-slim
WORKDIR /app
# System deps for kerberos, cryptography, psycopg2
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libkrb5-dev \
libffi-dev \
libssl-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install uv for faster pip
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Copy dependency files
COPY pyproject.toml ./
# Install dependencies
RUN uv pip install --system --no-cache .
# Copy application
COPY . .
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+12
View File
@@ -0,0 +1,12 @@
# Insight Updater Backend
FastAPI backend for server update orchestration (WinRM/SSH/CAU) with WebSocket live logs.
## Development
```bash
pip install -e ".[dev]"
uvicorn app.main:app --reload
```
API docs: http://localhost:8000/docs
+3
View File
@@ -0,0 +1,3 @@
"""Insight Updater backend application package."""
__version__ = "0.1.0"
+11
View File
@@ -0,0 +1,11 @@
"""API routers."""
from fastapi import APIRouter
from app.api.routes import audit, auth, servers, updates
api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
api_router.include_router(updates.router, prefix="/updates", tags=["updates"])
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
+42
View File
@@ -0,0 +1,42 @@
"""Shared API dependencies: current user extraction from JWT."""
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.exceptions import ForbiddenError, UnauthorizedError
from app.core.security import decode_token
from app.models.user import User
bearer_scheme = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
if credentials is None:
raise UnauthorizedError("Authorization header fehlt")
payload = decode_token(credentials.credentials)
username = payload.get("sub")
if not username:
raise UnauthorizedError("Token enthält keinen Benutzer")
from sqlalchemy import select
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise UnauthorizedError("Benutzer unbekannt oder deaktiviert")
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise ForbiddenError("Administratorrechte erforderlich")
return user
def client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
+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}
+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
+108
View File
@@ -0,0 +1,108 @@
"""FastAPI application entrypoint.
Mounts:
- REST API under /api
- Socket.io under /socket.io (path) -> frontend connects to ws://host/socket.io
- /health liveness probe
"""
from contextlib import asynccontextmanager
import socketio
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import api_router
from app.core.config import get_settings
from app.core.database import init_db
from app.core.exceptions import AppError
from app.core.logging import get_logger, setup_logging
from app.websocket import sio
settings = get_settings()
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
setup_logging()
logger.info("app.starting", env=settings.app_env)
await init_db()
await _seed_default_admin()
# Attach Redis manager for Socket.io pub/sub (optional in dev)
try:
from socketio import AsyncRedisManager
sio.manager = AsyncRedisManager(settings.redis_url)
logger.info("ws.redis_manager_attached", url=settings.redis_url)
except Exception as exc: # noqa: BLE001 - Redis optional for scaffold
logger.warning("ws.redis_unavailable", error=str(exc))
yield
logger.info("app.stopping")
async def _seed_default_admin() -> None:
"""Create the initial admin user if no users exist (dev bootstrap).
Password comes from ADMIN_INITIAL_PASSWORD env, default 'admin' (dev only).
"""
import os
from sqlalchemy import func, select
from app.core.database import async_session_factory
from app.core.security import hash_password
from app.models.user import User
async with async_session_factory() as db:
count = await db.scalar(select(func.count(User.id)))
if count and count > 0:
return
password = os.environ.get("ADMIN_INITIAL_PASSWORD", "admin")
db.add(
User(
username="admin",
email=None,
full_name="Administrator",
password_hash=hash_password(password),
is_admin=True,
)
)
await db.commit()
logger.info("app.default_admin_created", username="admin")
fastapi_app = FastAPI(
title=settings.app_name,
version="0.1.0",
lifespan=lifespan,
)
fastapi_app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@fastapi_app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@fastapi_app.get("/health")
async def health() -> dict:
return {"status": "ok", "env": settings.app_env, "version": "0.1.0"}
fastapi_app.include_router(api_router)
# Combined ASGI app: FastAPI + Socket.io
app = socketio.ASGIApp(sio, other_asgi_app=fastapi_app, socketio_path="socket.io")
+16
View File
@@ -0,0 +1,16 @@
"""SQLAlchemy ORM models."""
from app.models.audit_log import AuditLog
from app.models.credential import Credential
from app.models.server import Server
from app.models.update_job import UpdateJob, UpdateLog
from app.models.user import User
__all__ = [
"AuditLog",
"Credential",
"Server",
"UpdateJob",
"UpdateLog",
"User",
]
+23
View File
@@ -0,0 +1,23 @@
"""Immutable audit trail model."""
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
)
username: Mapped[str] = mapped_column(String(255), index=True)
action: Mapped[str] = mapped_column(String(100), index=True) # e.g. server.create
target: Mapped[str | None] = mapped_column(String(255), nullable=True) # e.g. server name
result: Mapped[str] = mapped_column(String(50), default="success") # success | failure
details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
+39
View File
@@ -0,0 +1,39 @@
"""Credential model - secrets stored Fernet-encrypted."""
from datetime import UTC, datetime
from sqlalchemy import DateTime, Enum, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
import enum
class CredentialType(str, enum.Enum):
WINRM_USERPASS = "winrm_userpass"
SSH_USERPASS = "ssh_userpass"
SSH_KEY = "ssh_key"
class Credential(Base):
__tablename__ = "credentials"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True)
type: Mapped[CredentialType] = mapped_column(Enum(CredentialType))
username: Mapped[str] = mapped_column(String(255))
# Encrypted at rest via core.security.encrypt()
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
private_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
key_passphrase_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
+48
View File
@@ -0,0 +1,48 @@
"""Server inventory model."""
import enum
from datetime import UTC, datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ServerType(str, enum.Enum):
WINDOWS = "windows" # WinRM
LINUX = "linux" # SSH
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
class Server(Base):
__tablename__ = "servers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hostname: Mapped[str] = mapped_column(String(255))
port: Mapped[int] = mapped_column(default=5985)
type: Mapped[ServerType] = mapped_column(Enum(ServerType), default=ServerType.WINDOWS)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
credential_id: Mapped[int | None] = mapped_column(
ForeignKey("credentials.id"), nullable=True
)
credential: Mapped["Credential | None"] = relationship(lazy="selectin") # noqa: F821
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="server", cascade="all, delete-orphan"
)
+64
View File
@@ -0,0 +1,64 @@
"""Update job + streamed log line models."""
import enum
from datetime import UTC, datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class JobStatus(str, enum.Enum):
PENDING = "pending"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELLED = "cancelled"
class JobType(str, enum.Enum):
WINDOWS_UPDATE = "windows_update"
LINUX_UPDATE = "linux_update"
CAU_RUN = "cau_run"
HEALTH_CHECK = "health_check"
class UpdateJob(Base):
__tablename__ = "update_jobs"
id: Mapped[int] = mapped_column(primary_key=True)
server_id: Mapped[int] = mapped_column(ForeignKey("servers.id"), index=True)
server: Mapped["Server"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
type: Mapped[JobType] = mapped_column(Enum(JobType))
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
current_phase: Mapped[str | None] = mapped_column(String(255), nullable=True)
started_by: Mapped[str] = mapped_column(String(255)) # username
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
logs: Mapped[list["UpdateLog"]] = relationship(
back_populates="job", cascade="all, delete-orphan", order_by="UpdateLog.id"
)
class UpdateLog(Base):
__tablename__ = "update_logs"
id: Mapped[int] = mapped_column(primary_key=True)
job_id: Mapped[int] = mapped_column(ForeignKey("update_jobs.id"), index=True)
job: Mapped[UpdateJob] = relationship(back_populates="logs")
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
level: Mapped[str] = mapped_column(String(20), default="info")
line: Mapped[str] = mapped_column(Text)
+27
View File
@@ -0,0 +1,27 @@
"""User model - local admins or LDAP-mapped users."""
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(255), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # null = LDAP only
is_ldap: Mapped[bool] = mapped_column(Boolean, default=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+18
View File
@@ -0,0 +1,18 @@
"""Pydantic schemas (request/response)."""
from app.schemas.audit import AuditLogRead
from app.schemas.auth import LoginRequest, TokenResponse
from app.schemas.server import ServerCreate, ServerRead, ServerUpdate
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
__all__ = [
"AuditLogRead",
"LoginRequest",
"TokenResponse",
"ServerCreate",
"ServerRead",
"ServerUpdate",
"JobTriggerRequest",
"UpdateJobRead",
"UpdateLogRead",
]
+25
View File
@@ -0,0 +1,25 @@
"""Audit log schemas."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
timestamp: datetime
username: str
action: str
target: str | None
result: str
details: str | None
ip_address: str | None
class AuditLogPage(BaseModel):
items: list[AuditLogRead]
total: int
page: int
page_size: int
+22
View File
@@ -0,0 +1,22 @@
"""Auth schemas."""
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int # seconds
class UserRead(BaseModel):
id: int
username: str
email: str | None
full_name: str | None
is_admin: bool
+52
View File
@@ -0,0 +1,52 @@
"""Server schemas."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.models.server import ServerType
class ServerCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
hostname: str = Field(min_length=1, max_length=255)
port: int = 5985
type: ServerType = ServerType.WINDOWS
description: str | None = None
tags: str | None = None
credential_id: int | None = None
class ServerUpdate(BaseModel):
name: str | None = None
hostname: str | None = None
port: int | None = None
type: ServerType | None = None
description: str | None = None
tags: str | None = None
credential_id: int | None = None
class ServerRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
hostname: str
port: int
type: ServerType
description: str | None
tags: str | None
credential_id: int | None
last_health_at: datetime | None
last_health_ok: bool | None
created_at: datetime
updated_at: datetime
class HealthCheckResult(BaseModel):
server_id: int
ok: bool
latency_ms: float | None = None
message: str
checked_at: datetime
+42
View File
@@ -0,0 +1,42 @@
"""Update job schemas."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.update_job import JobStatus, JobType
class JobTriggerRequest(BaseModel):
server_id: int
type: JobType
# CAU-specific options
cluster_name: str | None = None
# Linux-specific options
reboot_if_required: bool = False
class UpdateJobRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
server_id: int
type: JobType
status: JobStatus
progress_percent: int
current_phase: str | None
started_by: str
started_at: datetime | None
finished_at: datetime | None
error: str | None
created_at: datetime
class UpdateLogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
job_id: int
timestamp: datetime
level: str
line: str
+1
View File
@@ -0,0 +1 @@
"""Business logic services: winrm, ssh, cau, audit."""
+46
View File
@@ -0,0 +1,46 @@
"""Audit service: write structured, immutable audit entries to DB."""
import json
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models.audit_log import AuditLog
logger = get_logger(__name__)
class AuditService:
"""Persists audit events. Every mutating API action should call this."""
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def log(
self,
username: str,
action: str,
target: str | None = None,
result: str = "success",
details: dict[str, Any] | None = None,
ip_address: str | None = None,
) -> AuditLog:
entry = AuditLog(
username=username,
action=action,
target=target,
result=result,
details=json.dumps(details) if details else None,
ip_address=ip_address,
)
self.db.add(entry)
await self.db.flush()
logger.info(
"audit",
username=username,
action=action,
target=target,
result=result,
)
return entry
+77
View File
@@ -0,0 +1,77 @@
"""CAU service: Cluster-Aware Updating via PowerShell remoting (WinRM)."""
from collections.abc import AsyncIterator
from app.core.exceptions import CAUError
from app.core.logging import get_logger
from app.services.winrm import WinRMCredentials, WinRMService
logger = get_logger(__name__)
class CAUService:
"""Orchestrates Invoke-CauRun against a Windows failover cluster."""
def __init__(
self,
cluster_name: str,
access_node: str,
port: int = 5985,
credentials: WinRMCredentials | None = None,
) -> None:
self.cluster_name = cluster_name
self.winrm = WinRMService(access_node, port=port, credentials=credentials)
async def get_cluster_nodes(self) -> list[dict[str, str]]:
"""List cluster nodes and their state."""
script = f"""
Import-Module FailoverClusters
Get-ClusterNode -Cluster {self.cluster_name} |
Select-Object Name, State |
ForEach-Object {{ "$($_.Name)|$($_.State)" }}
"""
output = await self.winrm.run_powershell(script)
nodes = []
for line in output.splitlines():
if "|" in line:
name, state = line.split("|", 1)
nodes.append({"name": name.strip(), "state": state.strip()})
return nodes
async def get_cau_status(self) -> str:
"""Return the last CAU run summary."""
script = f"""
Import-Module ClusterAwareUpdating
Get-CauRun -ClusterName {self.cluster_name} |
Select-Object -First 1 |
Format-List | Out-String
"""
return await self.winrm.run_powershell(script)
async def test_cluster(self) -> tuple[bool, str]:
"""Verify the cluster is reachable and CAU module present."""
try:
nodes = await self.get_cluster_nodes()
if not nodes:
return False, "Keine Cluster-Knoten gefunden"
return True, f"Cluster OK — {len(nodes)} Knoten"
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def invoke_cau_run(self) -> AsyncIterator[str]:
"""Start a CAU run and stream per-node progress lines.
Scaffold implementation: starts Invoke-CauRun and polls Get-CauReport.
TODO: true live streaming of per-node phases via event log polling.
"""
script = f"""
Import-Module ClusterAwareUpdating
Invoke-CauRun -ClusterName {self.cluster_name} `
-Force -Confirm:$false -WaitForCompletion |
Out-String
"""
try:
async for line in self.winrm.stream_powershell(script):
yield line
except Exception as exc: # noqa: BLE001
raise CAUError(f"CAU-Lauf fehlgeschlagen: {exc}") from exc
+138
View File
@@ -0,0 +1,138 @@
"""Job runner: executes update jobs in background tasks, streams logs to WS + DB."""
import asyncio
from datetime import UTC, datetime
from app.core.database import async_session_factory
from app.core.logging import get_logger
from app.models.credential import Credential, CredentialType
from app.models.server import Server, ServerType
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
from app.services.cau import CAUService
from app.services.ssh import SSHCredentials, SSHService
from app.services.winrm import WinRMCredentials, WinRMService
from app.websocket import handlers as ws
logger = get_logger(__name__)
class JobRunner:
"""Manages asyncio tasks for running update jobs."""
def __init__(self) -> None:
self._tasks: dict[int, asyncio.Task] = {} # type: ignore[type-arg]
async def start(self, job_id: int) -> None:
task = asyncio.create_task(self._run(job_id), name=f"job-{job_id}")
self._tasks[job_id] = task
task.add_done_callback(lambda _t: self._tasks.pop(job_id, None))
async def cancel(self, job_id: int) -> bool:
task = self._tasks.get(job_id)
if not task:
return False
task.cancel()
return True
# ------------------------------------------------------------------
async def _run(self, job_id: int) -> None:
async with async_session_factory() as db:
job = await db.get(UpdateJob, job_id)
if not job:
logger.error("job.not_found", job_id=job_id)
return
server = await db.get(Server, job.server_id)
if not server:
await self._finish(db, job, JobStatus.FAILED, "Server nicht gefunden")
return
job.status = JobStatus.RUNNING
job.started_at = datetime.now(UTC)
await db.commit()
await ws.emit_job_start(job.id, server.id, job.type.value)
started = datetime.now(UTC)
try:
await self._dispatch(db, job, server)
await self._finish(db, job, JobStatus.SUCCESS, None, started)
except asyncio.CancelledError:
await self._finish(db, job, JobStatus.CANCELLED, "Vom Benutzer abgebrochen", started)
raise
except Exception as exc: # noqa: BLE001
logger.error("job.failed", job_id=job_id, error=str(exc))
await self._finish(db, job, JobStatus.FAILED, str(exc), started)
async def _dispatch(self, db, job: UpdateJob, server: Server) -> None: # type: ignore[no-untyped-def]
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
if server.type == ServerType.LINUX:
creds = self._ssh_creds(credential)
service = SSHService(server.hostname, port=server.port, credentials=creds)
async for line in service.stream_updates():
await self._log(db, job, line)
elif server.type == ServerType.WINDOWS:
creds = self._winrm_creds(credential)
service = WinRMService(server.hostname, port=server.port, credentials=creds)
async for line in service.install_updates():
await self._log(db, job, line)
elif server.type == ServerType.CAU_CLUSTER:
creds = self._winrm_creds(credential)
service = CAUService(server.hostname, access_node=server.hostname, port=server.port, credentials=creds)
async for line in service.invoke_cau_run():
await self._log(db, job, line)
async def _log(self, db, job: UpdateJob, line: str, level: str = "info") -> None: # type: ignore[no-untyped-def]
db.add(UpdateLog(job_id=job.id, level=level, line=line))
await db.commit()
await ws.emit_job_log(job.id, line, level)
async def _finish( # type: ignore[no-untyped-def]
self, db, job: UpdateJob, status: JobStatus, error: str | None, started: datetime | None = None
) -> None:
job.status = status
job.error = error
job.finished_at = datetime.now(UTC)
if status == JobStatus.SUCCESS:
job.progress_percent = 100
await db.commit()
duration = (
(job.finished_at - started).total_seconds() if started else None
)
await ws.emit_job_complete(job.id, status.value, duration)
# ------------------------------------------------------------------
@staticmethod
def _winrm_creds(credential: Credential | None) -> WinRMCredentials | None:
if not credential or credential.type != CredentialType.WINRM_USERPASS:
return None
from app.core.security import decrypt
return WinRMCredentials(
username=credential.username,
password=decrypt(credential.password_encrypted or ""),
)
@staticmethod
def _ssh_creds(credential: Credential | None) -> SSHCredentials | None:
if not credential:
return None
from app.core.security import decrypt
return SSHCredentials(
username=credential.username,
password=decrypt(credential.password_encrypted) if credential.password_encrypted else None,
private_key=decrypt(credential.private_key_encrypted)
if credential.private_key_encrypted
else None,
passphrase=decrypt(credential.key_passphrase_encrypted)
if credential.key_passphrase_encrypted
else None,
)
job_runner = JobRunner()
+118
View File
@@ -0,0 +1,118 @@
"""SSH service: connect to Linux hosts via asyncssh, run updates with sudo."""
from collections.abc import AsyncIterator
from dataclasses import dataclass
from app.core.config import get_settings
from app.core.exceptions import SSHError
from app.core.logging import get_logger
settings = get_settings()
logger = get_logger(__name__)
@dataclass
class SSHCredentials:
username: str
password: str | None = None
private_key: str | None = None
passphrase: str | None = None
class SSHService:
"""Async SSH operations via asyncssh."""
def __init__(
self,
hostname: str,
port: int = 22,
credentials: SSHCredentials | None = None,
) -> None:
self.hostname = hostname
self.port = port
self.credentials = credentials
def _connect_kwargs(self) -> dict:
kwargs: dict = {
"host": self.hostname,
"port": self.port,
"known_hosts": None, # scaffold: accept any host key
"connect_timeout": settings.ssh_timeout,
}
if self.credentials:
kwargs["username"] = self.credentials.username
if self.credentials.password:
kwargs["password"] = self.credentials.password
if self.credentials.private_key:
kwargs["client_keys"] = [
__import__("asyncssh").import_private_key(
self.credentials.private_key,
passphrase=self.credentials.passphrase,
)
]
return kwargs
async def test_connection(self) -> tuple[bool, str]:
import asyncssh
try:
async with asyncssh.connect(**self._connect_kwargs()) as conn:
result = await conn.run("hostname", check=True)
return True, f"Verbunden mit {result.stdout.strip()}" # type: ignore[union-attr]
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def run_command(self, command: str, sudo: bool = False) -> str:
"""Run a command, optionally via sudo -S with the stored password."""
import asyncssh
if sudo:
password = (self.credentials.password if self.credentials else None) or ""
command = f"echo '{password}' | sudo -S {command}"
async with asyncssh.connect(**self._connect_kwargs()) as conn:
result = await conn.run(command, check=False)
if result.returncode != 0:
raise SSHError(
f"Command failed (exit {result.returncode}): {str(result.stderr).strip()}"
)
return str(result.stdout)
async def detect_package_manager(self) -> str:
"""Detect apt, dnf, or yum on the target."""
import asyncssh
async with asyncssh.connect(**self._connect_kwargs()) as conn:
for pm in ("apt-get", "dnf", "yum"):
result = await conn.run(f"command -v {pm}", check=False)
if result.returncode == 0:
return pm
raise SSHError("Kein unterstützter Paketmanager gefunden (apt/dnf/yum)")
async def stream_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
"""Run the distro update command and yield output lines live."""
import asyncssh
pm = await self.detect_package_manager()
if pm == "apt-get":
cmd = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
else:
cmd = f"{pm} update -y"
password = (self.credentials.password if self.credentials else None) or ""
full_cmd = f"echo '{password}' | sudo -S sh -c '{cmd}'"
async with asyncssh.connect(**self._connect_kwargs()) as conn:
async with conn.create_process(full_cmd) as process:
async for line in process.stdout: # type: ignore[union-attr]
yield str(line).rstrip()
await process.wait()
if process.returncode != 0:
raise SSHError(f"Update fehlgeschlagen (exit {process.returncode})")
if reboot_if_required:
async with asyncssh.connect(**self._connect_kwargs()) as conn:
check = await conn.run("test -f /var/run/reboot-required", check=False)
if check.returncode == 0:
yield "REBOOT erforderlich — wird ausgeführt..."
await conn.run(f"echo '{password}' | sudo -S reboot", check=False)
+122
View File
@@ -0,0 +1,122 @@
"""WinRM service: connect to Windows hosts, run PowerShell, stream output.
Uses python-winrm (pywinrm). All blocking calls run in a thread pool
so the async event loop is never blocked.
"""
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from app.core.config import get_settings
from app.core.exceptions import WinRMError
from app.core.logging import get_logger
settings = get_settings()
logger = get_logger(__name__)
@dataclass
class WinRMCredentials:
username: str
password: str
class WinRMService:
"""Wraps pywinrm for async usage."""
def __init__(
self,
hostname: str,
port: int = 5985,
credentials: WinRMCredentials | None = None,
transport: str | None = None,
) -> None:
self.hostname = hostname
self.port = port
self.credentials = credentials
self.transport = transport or settings.winrm_transport
def _build_session(self): # type: ignore[no-untyped-def]
import winrm # pywinrm
scheme = "https" if self.port == 5986 else "http"
endpoint = f"{scheme}://{self.hostname}:{self.port}/wsman"
kwargs: dict = {
"transport": self.transport,
"server_cert_validation": settings.winrm_cert_validation,
"operation_timeout_sec": settings.winrm_operation_timeout,
"read_timeout_sec": settings.winrm_read_timeout,
}
if self.credentials:
kwargs["username"] = self.credentials.username
kwargs["password"] = self.credentials.password
return winrm.Session(endpoint, **kwargs)
async def test_connection(self) -> tuple[bool, str]:
"""Run a trivial command to verify connectivity."""
def _run() -> tuple[bool, str]:
try:
session = self._build_session()
result = session.run_ps("$env:COMPUTERNAME")
if result.status_code == 0:
name = result.std_out.decode(errors="replace").strip()
return True, f"Verbunden mit {name}"
return False, result.std_err.decode(errors="replace").strip()
except Exception as exc: # noqa: BLE001 - surface any transport error
return False, str(exc)
return await asyncio.to_thread(_run)
async def run_powershell(self, script: str) -> str:
"""Run a PowerShell script and return stdout. Raises WinRMError on failure."""
def _run() -> str:
session = self._build_session()
result = session.run_ps(script)
if result.status_code != 0:
err = result.std_err.decode(errors="replace").strip()
raise WinRMError(f"PowerShell exit {result.status_code}: {err}")
return result.std_out.decode(errors="replace")
return await asyncio.to_thread(_run)
async def stream_powershell(self, script: str) -> AsyncIterator[str]:
"""Yield output lines as they arrive (scaffold: runs to completion, then yields).
TODO: switch to winrm Protocol with shell polling for true streaming.
"""
output = await self.run_powershell(script)
for line in output.splitlines():
yield line
await asyncio.sleep(0) # keep the loop responsive
async def get_pending_updates(self) -> list[str]:
"""Query Windows Update for pending updates (titles only)."""
script = """
$session = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0")
$result.Updates | ForEach-Object { $_.Title }
"""
output = await self.run_powershell(script)
return [line.strip() for line in output.splitlines() if line.strip()]
async def install_updates(self) -> AsyncIterator[str]:
"""Install all pending Windows Updates, yielding progress lines."""
script = """
$session = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0")
Write-Output "Gefundene Updates: $($result.Updates.Count)"
$toInstall = New-Object -ComObject Microsoft.Update.UpdateColl
$result.Updates | ForEach-Object { $toInstall.Add($_) | Out-Null }
$installer = $session.CreateUpdateInstaller()
$installer.Updates = $toInstall
$installResult = $installer.Install()
Write-Output "ResultCode: $($installResult.ResultCode)"
Write-Output "RebootRequired: $($installResult.RebootRequired)"
"""
async for line in self.stream_powershell(script):
yield line
+6
View File
@@ -0,0 +1,6 @@
"""WebSocket layer: Socket.io server, connection manager, event handlers."""
from app.websocket.handlers import sio
from app.websocket.manager import WSManager, ws_manager
__all__ = ["WSManager", "sio", "ws_manager"]
+103
View File
@@ -0,0 +1,103 @@
"""Socket.io server instance and event handlers.
Events (see AGENTS.md):
Server -> Client: job:start, job:log, job:progress, job:complete
Client -> Server: job:subscribe, job:unsubscribe, job:cancel
"""
from typing import Any
import socketio
from app.core.config import get_settings
from app.core.logging import get_logger
from app.websocket.manager import ws_manager
settings = get_settings()
logger = get_logger(__name__)
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins=settings.cors_origin_list or "*",
# Redis manager for multi-worker pub/sub; set in main.py when Redis is up
)
@sio.event
async def connect(sid: str, environ: dict, auth: dict | None) -> None: # noqa: ARG001
# TODO: validate JWT from auth payload before accepting
ws_manager.register(sid)
@sio.event
async def disconnect(sid: str) -> None:
ws_manager.unregister(sid)
@sio.event
async def subscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
"""Client subscribes to a job's live log room."""
job_id = int(data.get("job_id", 0))
room = ws_manager.subscribe(sid, job_id)
await sio.enter_room(sid, room)
logger.info("ws.subscribed", sid=sid, room=room)
return {"ok": True, "room": room}
@sio.event
async def unsubscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
job_id = int(data.get("job_id", 0))
room = ws_manager.unsubscribe(sid, job_id)
await sio.leave_room(sid, room)
return {"ok": True}
@sio.event
async def cancel_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
"""Client requests job cancellation."""
from app.services.job_runner import job_runner
job_id = int(data.get("job_id", 0))
cancelled = await job_runner.cancel(job_id)
return {"ok": cancelled}
# ---------------------------------------------------------------------------
# Emit helpers used by services / job runner
# ---------------------------------------------------------------------------
async def emit_job_start(job_id: int, server_id: int, job_type: str) -> None:
await sio.emit(
"job:start",
{"job_id": job_id, "server_id": server_id, "type": job_type},
room=ws_manager.room_for(job_id),
)
async def emit_job_log(job_id: int, line: str, level: str = "info") -> None:
from datetime import UTC, datetime
await sio.emit(
"job:log",
{"job_id": job_id, "line": line, "level": level, "timestamp": datetime.now(UTC).isoformat()},
room=ws_manager.room_for(job_id),
)
async def emit_job_progress(
job_id: int, percent: int, phase: str, node: str | None = None
) -> None:
await sio.emit(
"job:progress",
{"job_id": job_id, "percent": percent, "phase": phase, "node": node},
room=ws_manager.room_for(job_id),
)
async def emit_job_complete(job_id: int, status: str, duration: float | None) -> None:
await sio.emit(
"job:complete",
{"job_id": job_id, "status": status, "duration": duration},
room=ws_manager.room_for(job_id),
)
+43
View File
@@ -0,0 +1,43 @@
"""Connection manager for Socket.io rooms (one room per update job)."""
from app.core.logging import get_logger
logger = get_logger(__name__)
class WSManager:
"""Tracks active Socket.io sessions and job-room subscriptions."""
def __init__(self) -> None:
# sid -> set of job rooms the client subscribed to
self._subscriptions: dict[str, set[str]] = {}
def register(self, sid: str) -> None:
self._subscriptions.setdefault(sid, set())
logger.info("ws.client_connected", sid=sid)
def unregister(self, sid: str) -> None:
self._subscriptions.pop(sid, None)
logger.info("ws.client_disconnected", sid=sid)
def subscribe(self, sid: str, job_id: int) -> str:
room = self.room_for(job_id)
self._subscriptions.setdefault(sid, set()).add(room)
return room
def unsubscribe(self, sid: str, job_id: int) -> str:
room = self.room_for(job_id)
if sid in self._subscriptions:
self._subscriptions[sid].discard(room)
return room
@staticmethod
def room_for(job_id: int) -> str:
return f"job:{job_id}"
@property
def client_count(self) -> int:
return len(self._subscriptions)
ws_manager = WSManager()
+104
View File
@@ -0,0 +1,104 @@
[project]
name = "insight-updater-backend"
version = "0.1.0"
description = "Insight Updater Backend - FastAPI application for server update orchestration"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi[standard]>=0.109.0",
"uvicorn[standard]>=0.27.0",
"sqlalchemy[asyncio]>=2.0.25",
"alembic>=1.13.0",
"aiosqlite>=0.19.0",
"asyncpg>=0.29.0",
"redis>=5.0.0",
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.1.0",
"cryptography>=42.0.0",
"pydantic[email]>=2.5.0",
"pydantic-settings>=2.1.0",
"pywinrm[kerberos]>=0.4.3",
"paramiko>=3.4.0",
"asyncssh>=2.14.0",
"python-socketio>=5.10.0",
"structlog>=24.1.0",
"python-json-logger>=2.0.7",
"python-multipart>=0.0.6",
"httpx>=0.26.0",
"tenacity>=8.2.0",
"pytz>=2024.1",
"python-dateutil>=2.8.2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"httpx>=0.26.0",
"faker>=22.0.0",
"ruff>=0.2.0",
"mypy>=1.8.0",
"pre-commit>=3.6.0",
]
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.uv]
dev-dependencies = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"httpx>=0.26.0",
"faker>=22.0.0",
"ruff>=0.2.0",
"mypy>=1.8.0",
"pre-commit>=3.6.0",
]
[tool.ruff]
target-version = "py311"
line-length = 100
select = ["E", "W", "F", "I", "N", "UP", "B", "C4", "T20"]
ignore = ["E501", "B008"]
per-file-ignores = { "tests/*" = ["S101", "S106"] }
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_optional = true
enable_error_code = ["unused-ignore"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
[tool.coverage.run]
source = ["app"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if __name__ == \"__main__\":",
]
+51
View File
@@ -0,0 +1,51 @@
# Core
fastapi==0.110.0
uvicorn[standard]==0.29.0
pydantic[email]==2.6.4
pydantic-settings==2.2.1
python-multipart==0.0.6
# Database
sqlalchemy[asyncio]==2.0.29
alembic==1.13.1
aiosqlite==0.19.0
asyncpg==0.29.0
# Redis & Caching
redis==5.0.1
# Security & Auth
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
cryptography==42.0.5
python-dotenv==1.0.1
# WinRM / SSH / CAU
python-winrm[kerberos]==0.5.0
paramiko==3.4.0
asyncssh==2.14.2
# WebSocket
python-socketio[asyncio]==5.10.0
# Logging & Observability
structlog==24.1.0
python-json-logger==2.0.7
# Validation & Utils
email-validator==2.1.0
python-slugify==8.0.1
pendulum==2.1.2
# Testing
pytest==8.1.1
pytest-asyncio==0.23.6
pytest-cov==4.1.0
httpx==0.27.0
faker==25.1.0
# Type Checking
mypy==1.9.0
types-python-jose==3.3.0.20240106
types-passlib==1.7.4.20240106
types-requests==2.31.0.20240106
+138
View File
@@ -0,0 +1,138 @@
version: '3.8'
services:
# ---------------------------------------------------------------
# Backend - FastAPI (Production)
# ---------------------------------------------------------------
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: production
container_name: insight-updater-backend
restart: unless-stopped
environment:
- APP_ENV=production
- DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
- JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
- WINRM_TRANSPORT=${WINRM_TRANSPORT:-ntlm}
- WINRM_CERT_VALIDATION=${WINRM_CERT_VALIDATION:-validate}
- LDAP_ENABLED=${LDAP_ENABLED:-false}
- LDAP_URI=${LDAP_URI}
- LDAP_BIND_DN=${LDAP_BIND_DN}
- LDAP_BIND_PASSWORD=${LDAP_BIND_PASSWORD}
- LDAP_USER_SEARCH_BASE=${LDAP_USER_SEARCH_BASE}
- LDAP_USER_FILTER=${LDAP_USER_FILTER}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- LOG_FORMAT=json
volumes:
- ./data:/app/data
- ./keys:/app/keys:ro
- ./certs:/app/certs:ro
networks:
- internal
- traefik-public
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.updater-api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)"
- "traefik.http.routers.updater-api.entrypoints=websecure"
- "traefik.http.routers.updater-api.tls.certresolver=letsencrypt"
- "traefik.http.services.updater-api.loadbalancer.server.port=8000"
- "traefik.http.routers.updater-ws.rule=Host(`${DOMAIN}`) && PathPrefix(`/ws`)"
- "traefik.http.routers.updater-ws.entrypoints=websecure"
- "traefik.http.routers.updater-ws.tls.certresolver=letsencrypt"
- "traefik.http.services.updater-ws.loadbalancer.server.port=8000"
# ---------------------------------------------------------------
# Frontend - Nginx (Production)
# ---------------------------------------------------------------
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: production
container_name: insight-updater-frontend
restart: unless-stopped
networks:
- internal
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.updater-web.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.updater-web.entrypoints=websecure"
- "traefik.http.routers.updater-web.tls.certresolver=letsencrypt"
- "traefik.http.services.updater-web.loadbalancer.server.port=80"
# ---------------------------------------------------------------
# Redis - Pub/Sub, Caching, Rate Limiting
# ---------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: insight-updater-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- internal
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
# ---------------------------------------------------------------
# PostgreSQL - Production Database
# ---------------------------------------------------------------
db:
image: postgres:16-alpine
container_name: insight-updater-db
restart: unless-stopped
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pg-data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 3s
retries: 3
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
networks:
internal:
driver: bridge
traefik-public:
external: true
volumes:
redis-data:
pg-data:
+112
View File
@@ -0,0 +1,112 @@
version: '3.8'
services:
# ---------------------------------------------------------------------------
# BACKEND - FastAPI
# ---------------------------------------------------------------------------
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: insight-updater-backend
environment:
- APP_ENV=development
- DATABASE_URL=sqlite+aiosqlite:///./data/app.db
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY:-dev-secret-change-me-32-chars-min}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-dev-encryption-key-32-chars-base64}
- JWT_ALGORITHM=RS256
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
- JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
- WINRM_TRANSPORT=ntlm
- WINRM_CERT_VALIDATION=ignore
- LDAP_ENABLED=false
- LOG_LEVEL=DEBUG
volumes:
- ./backend:/app
- ./data:/app/data
- ./backend/keys:/app/keys:ro
ports:
- "8000:8000"
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- insight-updater-network
# ---------------------------------------------------------------------------
# FRONTEND - Vue 3 + Vite (dev) / Nginx (prod)
# ---------------------------------------------------------------------------
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
container_name: insight-updater-frontend
environment:
- VITE_API_URL=http://localhost:8000
- VITE_WS_URL=ws://localhost:8000
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "3000:3000"
depends_on:
- backend
networks:
- insight-updater-network
# ---------------------------------------------------------------------------
# REDIS - for WebSocket pub/sub, caching, rate limiting
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: insight-updater-redis
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- insight-updater-network
# ---------------------------------------------------------------------------
# POSTGRESQL (optional, for production)
# ---------------------------------------------------------------------------
# postgres:
# image: postgres:16-alpine
# container_name: insight-updater-postgres
# environment:
# - POSTGRES_DB=updater
# - POSTGRES_USER=updater
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
# volumes:
# - postgres-data:/var/lib/postgresql/data
# ports:
# - "5432:5432"
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U updater -d updater"]
# interval: 5s
# timeout: 5s
# retries: 5
# networks:
# - insight-updater-network
volumes:
redis-data:
# postgres-data:
networks:
insight-updater-network:
driver: bridge
+38
View File
@@ -0,0 +1,38 @@
# Development stage
FROM node:20-alpine AS development
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
# Production build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Insight Updater</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/rss+xml image/svg+xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# API proxy (optional, for same-origin calls)
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket proxy
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# SPA fallback - serve index.html for all non-file routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
+6471
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "insight-updater-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest",
"test:e2e": "playwright test",
"lint": "eslint . --ext .vue,.ts,.js --fix",
"format": "prettier --write ."
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.0",
"socket.io-client": "^4.7.0",
"date-fns": "^3.6.0",
"zod": "^3.22.0",
"@tanstack/vue-query": "^5.0.0",
"lucide-vue-next": "^0.378.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.2.0",
"vue-tsc": "^2.0.0",
"typescript": "^5.4.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"@vue/eslint-config-typescript": "^13.0.0",
"prettier": "^3.2.0",
"@tailwindcss/forms": "^0.5.0",
"vitest": "^1.5.0",
"@vue/test-utils": "^2.4.0",
"jsdom": "^24.0.0",
"@playwright/test": "^1.43.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppLayout from '@/components/AppLayout.vue'
const route = useRoute()
const isLogin = computed(() => route.name === 'login')
</script>
<template>
<router-view v-if="isLogin" />
<AppLayout v-else>
<router-view />
</AppLayout>
</template>
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const baseURL = import.meta.env.VITE_API_URL || ''
export const apiClient = axios.create({
baseURL,
timeout: 30000,
})
apiClient.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const auth = useAuthStore()
auth.logout()
router.push({ name: 'login' })
}
return Promise.reject(error)
},
)
+22
View File
@@ -0,0 +1,22 @@
import { io, type Socket } from 'socket.io-client'
let socket: Socket | null = null
export function getSocket(): Socket {
if (!socket) {
const url = import.meta.env.VITE_WS_URL || window.location.origin
socket = io(url, {
path: '/socket.io',
transports: ['websocket', 'polling'],
autoConnect: true,
})
}
return socket
}
export function disconnectSocket(): void {
if (socket) {
socket.disconnect()
socket = null
}
}
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
const navItems = [
{ name: 'dashboard', label: 'Dashboard' },
{ name: 'servers', label: 'Server' },
{ name: 'updates', label: 'Updates' },
{ name: 'audit', label: 'Audit' },
]
function logout(): void {
auth.logout()
router.push({ name: 'login' })
}
</script>
<template>
<div class="min-h-screen">
<nav class="bg-slate-800 text-white shadow">
<div class="mx-auto max-w-7xl px-4">
<div class="flex h-14 items-center justify-between">
<div class="flex items-center gap-6">
<span class="text-lg font-bold">Insight Updater</span>
<router-link
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="rounded px-3 py-1.5 text-sm hover:bg-slate-700"
active-class="bg-slate-900 font-semibold"
>
{{ item.label }}
</router-link>
</div>
<button
class="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
@click="logout"
>
Abmelden
</button>
</div>
</div>
</nav>
<main class="mx-auto max-w-7xl px-4 py-6">
<slot />
</main>
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+46
View File
@@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
{
path: '/',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: '/servers',
name: 'servers',
component: () => import('@/views/ServersView.vue'),
},
{
path: '/updates',
name: 'updates',
component: () => import('@/views/UpdatesView.vue'),
},
{
path: '/audit',
name: 'audit',
component: () => import('@/views/AuditView.vue'),
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.name !== 'login' && !auth.isAuthenticated) {
return { name: 'login' }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'dashboard' }
}
return true
})
export default router
+32
View File
@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/api/client'
import { disconnectSocket } from '@/api/socket'
const TOKEN_KEY = 'iu_token'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const username = ref<string | null>(null)
const isAuthenticated = computed(() => token.value !== null)
async function login(user: string, password: string): Promise<void> {
const { data } = await apiClient.post('/api/auth/login', {
username: user,
password,
})
token.value = data.access_token
username.value = user
localStorage.setItem(TOKEN_KEY, data.access_token)
}
function logout(): void {
token.value = null
username.value = null
localStorage.removeItem(TOKEN_KEY)
disconnectSocket()
}
return { token, username, isAuthenticated, login, logout }
})
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Server, HealthResult } from '@/types'
export const useServersStore = defineStore('servers', () => {
const servers = ref<Server[]>([])
const loading = ref(false)
const healthResults = ref<Record<number, HealthResult>>({})
async function fetchServers(): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<Server[]>('/api/servers')
servers.value = data
} finally {
loading.value = false
}
}
async function createServer(payload: Partial<Server>): Promise<Server> {
const { data } = await apiClient.post<Server>('/api/servers', payload)
servers.value.push(data)
return data
}
async function deleteServer(id: number): Promise<void> {
await apiClient.delete(`/api/servers/${id}`)
servers.value = servers.value.filter((s) => s.id !== id)
}
async function checkHealth(id: number): Promise<HealthResult> {
const { data } = await apiClient.get<HealthResult>(`/api/servers/${id}/health`)
healthResults.value[id] = data
return data
}
return { servers, loading, healthResults, fetchServers, createServer, deleteServer, checkHealth }
})
+90
View File
@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { ref, onUnmounted } from 'vue'
import { apiClient } from '@/api/client'
import { getSocket } from '@/api/socket'
import type { UpdateJob, UpdateLogLine, JobType } from '@/types'
interface WsLogPayload {
job_id: number
line: string
level: string
timestamp: string
}
interface WsCompletePayload {
job_id: number
status: string
duration: number | null
}
export const useUpdatesStore = defineStore('updates', () => {
const jobs = ref<UpdateJob[]>([])
const loading = ref(false)
const liveLogs = ref<Record<number, UpdateLogLine[]>>({})
async function fetchJobs(): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<UpdateJob[]>('/api/updates')
jobs.value = data
} finally {
loading.value = false
}
}
async function triggerUpdate(serverId: number, type: JobType): Promise<UpdateJob> {
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
server_id: serverId,
type,
})
jobs.value.unshift(data)
return data
}
async function cancelJob(jobId: number): Promise<void> {
await apiClient.post(`/api/updates/${jobId}/cancel`)
}
async function fetchLogs(jobId: number, afterId = 0): Promise<void> {
const { data } = await apiClient.get<UpdateLogLine[]>(`/api/updates/${jobId}/logs`, {
params: { after_id: afterId },
})
const existing = liveLogs.value[jobId] || []
liveLogs.value[jobId] = afterId === 0 ? data : [...existing, ...data]
}
function subscribeJob(jobId: number): void {
const socket = getSocket()
socket.emit('subscribe_job', { job_id: jobId })
socket.off('job:log')
socket.on('job:log', (payload: WsLogPayload) => {
const list = liveLogs.value[payload.job_id] || []
liveLogs.value[payload.job_id] = [
...list,
{ id: list.length + 1, job_id: payload.job_id, timestamp: payload.timestamp, level: payload.level, line: payload.line },
]
})
socket.off('job:complete')
socket.on('job:complete', (payload: WsCompletePayload) => {
const job = jobs.value.find((j) => j.id === payload.job_id)
if (job) {
job.status = payload.status as UpdateJob['status']
}
})
}
function unsubscribeJob(jobId: number): void {
const socket = getSocket()
socket.emit('unsubscribe_job', { job_id: jobId })
}
onUnmounted(() => {
const socket = getSocket()
socket.off('job:log')
socket.off('job:complete')
})
return { jobs, loading, liveLogs, fetchJobs, triggerUpdate, cancelJob, fetchLogs, subscribeJob, unsubscribeJob }
})
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-100 text-slate-900 antialiased;
}
+59
View File
@@ -0,0 +1,59 @@
export type ServerType = 'windows' | 'linux' | 'cau_cluster'
export type JobStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
export type JobType = 'windows_update' | 'linux_update' | 'cau_run' | 'health_check'
export interface Server {
id: number
name: string
hostname: string
port: number
type: ServerType
description: string | null
tags: string | null
credential_id: number | null
last_health_at: string | null
last_health_ok: boolean | null
created_at: string
updated_at: string
}
export interface UpdateJob {
id: number
server_id: number
type: JobType
status: JobStatus
progress_percent: number
current_phase: string | null
started_by: string
started_at: string | null
finished_at: string | null
error: string | null
created_at: string
}
export interface UpdateLogLine {
id: number
job_id: number
timestamp: string
level: string
line: string
}
export interface AuditEntry {
id: number
timestamp: string
username: string
action: string
target: string | null
result: string
details: string | null
ip_address: string | null
}
export interface HealthResult {
server_id: number
ok: boolean
latency_ms: number | null
message: string
checked_at: string
}
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import type { AuditEntry } from '@/types'
interface AuditPage {
items: AuditEntry[]
total: number
page: number
page_size: number
}
const entries = ref<AuditEntry[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const error = ref<string | null>(null)
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
const { data } = await apiClient.get<AuditPage>('/api/audit', {
params: { page: page.value, page_size: pageSize },
})
entries.value = data.items
total.value = data.total
} catch {
error.value = 'Audit-Log konnte nicht geladen werden (Admin-Rechte erforderlich).'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold">Audit-Log</h1>
<p v-if="error" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<div class="overflow-hidden rounded-lg bg-white shadow">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-medium">Zeitpunkt</th>
<th class="px-4 py-2 text-left font-medium">Benutzer</th>
<th class="px-4 py-2 text-left font-medium">Aktion</th>
<th class="px-4 py-2 text-left font-medium">Ziel</th>
<th class="px-4 py-2 text-left font-medium">Ergebnis</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="entry in entries" :key="entry.id">
<td class="px-4 py-2">{{ new Date(entry.timestamp).toLocaleString('de-DE') }}</td>
<td class="px-4 py-2">{{ entry.username }}</td>
<td class="px-4 py-2">{{ entry.action }}</td>
<td class="px-4 py-2">{{ entry.target || '—' }}</td>
<td class="px-4 py-2">
<span :class="entry.result === 'success' ? 'text-green-600' : 'text-red-600'">
{{ entry.result }}
</span>
</td>
</tr>
<tr v-if="entries.length === 0 && !loading">
<td colspan="5" class="px-4 py-6 text-center text-slate-500">Keine Einträge.</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm">
<span>{{ total }} Einträge gesamt</span>
<div class="flex gap-2">
<button
:disabled="page <= 1"
class="rounded bg-slate-200 px-3 py-1 disabled:opacity-50"
@click="page--; load()"
>
Zurück
</button>
<button
:disabled="page * pageSize >= total"
class="rounded bg-slate-200 px-3 py-1 disabled:opacity-50"
@click="page++; load()"
>
Weiter
</button>
</div>
</div>
</div>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import { apiClient } from '@/api/client'
const serversStore = useServersStore()
const updatesStore = useUpdatesStore()
const stats = ref<{ total: number; running: number; failed: number }>({
total: 0,
running: 0,
failed: 0,
})
const healthyCount = computed(
() => serversStore.servers.filter((s) => s.last_health_ok === true).length,
)
onMounted(async () => {
await Promise.all([
serversStore.fetchServers(),
updatesStore.fetchJobs(),
apiClient.get('/api/updates/stats/summary').then(({ data }) => (stats.value = data)),
])
})
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Server gesamt</p>
<p class="mt-1 text-3xl font-bold">{{ serversStore.servers.length }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Erreichbar</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ healthyCount }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Jobs laufend</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ stats.running }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Jobs fehlgeschlagen</p>
<p class="mt-1 text-3xl font-bold text-red-600">{{ stats.failed }}</p>
</div>
</div>
<h2 class="mb-3 mt-8 text-lg font-semibold">Letzte Jobs</h2>
<div class="overflow-hidden rounded-lg bg-white shadow">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-medium">ID</th>
<th class="px-4 py-2 text-left font-medium">Server</th>
<th class="px-4 py-2 text-left font-medium">Typ</th>
<th class="px-4 py-2 text-left font-medium">Status</th>
<th class="px-4 py-2 text-left font-medium">Gestartet von</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="job in updatesStore.jobs.slice(0, 10)" :key="job.id">
<td class="px-4 py-2">{{ job.id }}</td>
<td class="px-4 py-2">{{ job.server_id }}</td>
<td class="px-4 py-2">{{ job.type }}</td>
<td class="px-4 py-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="{
'bg-green-100 text-green-800': job.status === 'success',
'bg-red-100 text-red-800': job.status === 'failed',
'bg-blue-100 text-blue-800': job.status === 'running',
'bg-slate-100 text-slate-800': job.status === 'pending' || job.status === 'cancelled',
}"
>
{{ job.status }}
</span>
</td>
<td class="px-4 py-2">{{ job.started_by }}</td>
</tr>
<tr v-if="updatesStore.jobs.length === 0">
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
Noch keine Jobs vorhanden.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function submit(): Promise<void> {
error.value = null
loading.value = true
try {
await auth.login(username.value, password.value)
router.push({ name: 'dashboard' })
} catch {
error.value = 'Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch.'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-slate-800">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-xl">
<h1 class="mb-6 text-center text-2xl font-bold">Insight Updater</h1>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm font-medium">Benutzername</label>
<input
v-model="username"
type="text"
required
class="w-full rounded border-slate-300"
autocomplete="username"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Passwort</label>
<input
v-model="password"
type="password"
required
class="w-full rounded border-slate-300"
autocomplete="current-password"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full rounded bg-slate-800 py-2 font-semibold text-white hover:bg-slate-700 disabled:opacity-50"
>
{{ loading ? 'Anmelden…' : 'Anmelden' }}
</button>
</form>
</div>
</div>
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useServersStore } from '@/stores/servers'
import type { ServerType } from '@/types'
const store = useServersStore()
const showForm = ref(false)
const form = reactive({
name: '',
hostname: '',
port: 5985,
type: 'windows' as ServerType,
description: '',
})
const typeLabels: Record<ServerType, string> = {
windows: 'Windows (WinRM)',
linux: 'Linux (SSH)',
cau_cluster: 'CAU Cluster',
}
onMounted(() => store.fetchServers())
async function submit(): Promise<void> {
await store.createServer({ ...form })
showForm.value = false
form.name = ''
form.hostname = ''
form.port = 5985
form.type = 'windows'
form.description = ''
}
async function remove(id: number): Promise<void> {
if (confirm('Server wirklich löschen?')) {
await store.deleteServer(id)
}
}
</script>
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Server</h1>
<button
class="rounded bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700"
@click="showForm = !showForm"
>
{{ showForm ? 'Abbrechen' : 'Server hinzufügen' }}
</button>
</div>
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm font-medium">Name</label>
<input v-model="form.name" required class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Hostname / FQDN</label>
<input v-model="form.hostname" required class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Port</label>
<input v-model.number="form.port" type="number" required class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Typ</label>
<select v-model="form.type" class="w-full rounded border-slate-300">
<option v-for="(label, value) in typeLabels" :key="value" :value="value">
{{ label }}
</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-sm font-medium">Beschreibung</label>
<input v-model="form.description" class="w-full rounded border-slate-300" />
</div>
<div class="sm:col-span-2">
<button type="submit" class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600">
Speichern
</button>
</div>
</form>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-medium">Name</th>
<th class="px-4 py-2 text-left font-medium">Hostname</th>
<th class="px-4 py-2 text-left font-medium">Typ</th>
<th class="px-4 py-2 text-left font-medium">Health</th>
<th class="px-4 py-2 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="server in store.servers" :key="server.id">
<td class="px-4 py-2 font-medium">{{ server.name }}</td>
<td class="px-4 py-2">{{ server.hostname }}:{{ server.port }}</td>
<td class="px-4 py-2">{{ typeLabels[server.type] }}</td>
<td class="px-4 py-2">
<span v-if="server.last_health_ok === true" class="text-green-600">OK</span>
<span v-else-if="server.last_health_ok === false" class="text-red-600">Fehler</span>
<span v-else class="text-slate-400"></span>
</td>
<td class="px-4 py-2 text-right">
<button
class="mr-2 rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
@click="store.checkHealth(server.id)"
>
Health-Check
</button>
<button
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click="remove(server.id)"
>
Löschen
</button>
</td>
</tr>
<tr v-if="store.servers.length === 0">
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
Noch keine Server im Inventar.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import type { JobType } from '@/types'
const serversStore = useServersStore()
const updatesStore = useUpdatesStore()
const selectedServerId = ref<number | null>(null)
const selectedJobId = ref<number | null>(null)
const jobTypeForServer = computed<JobType>(() => {
const server = serversStore.servers.find((s) => s.id === selectedServerId.value)
if (!server) return 'windows_update'
if (server.type === 'linux') return 'linux_update'
if (server.type === 'cau_cluster') return 'cau_run'
return 'windows_update'
})
const selectedLogs = computed(() =>
selectedJobId.value ? updatesStore.liveLogs[selectedJobId.value] || [] : [],
)
onMounted(async () => {
await Promise.all([serversStore.fetchServers(), updatesStore.fetchJobs()])
})
async function trigger(): Promise<void> {
if (!selectedServerId.value) return
const job = await updatesStore.triggerUpdate(selectedServerId.value, jobTypeForServer.value)
watchJob(job.id)
}
async function watchJob(jobId: number): Promise<void> {
selectedJobId.value = jobId
await updatesStore.fetchLogs(jobId)
updatesStore.subscribeJob(jobId)
}
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold">Updates</h1>
<div class="mb-6 flex items-end gap-3 rounded-lg bg-white p-5 shadow">
<div class="flex-1">
<label class="mb-1 block text-sm font-medium">Server auswählen</label>
<select v-model="selectedServerId" class="w-full rounded border-slate-300">
<option :value="null" disabled> bitte wählen </option>
<option v-for="server in serversStore.servers" :key="server.id" :value="server.id">
{{ server.name }} ({{ server.hostname }})
</option>
</select>
</div>
<button
:disabled="!selectedServerId"
class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-50"
@click="trigger"
>
Update starten
</button>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="overflow-hidden rounded-lg bg-white shadow">
<h2 class="border-b bg-slate-50 px-4 py-2 font-semibold">Jobs</h2>
<table class="min-w-full divide-y divide-slate-200 text-sm">
<tbody class="divide-y divide-slate-100">
<tr
v-for="job in updatesStore.jobs"
:key="job.id"
class="cursor-pointer hover:bg-slate-50"
:class="{ 'bg-blue-50': job.id === selectedJobId }"
@click="watchJob(job.id)"
>
<td class="px-4 py-2">#{{ job.id }}</td>
<td class="px-4 py-2">{{ job.type }}</td>
<td class="px-4 py-2">{{ job.status }}</td>
<td class="px-4 py-2 text-right">
<button
v-if="job.status === 'running' || job.status === 'pending'"
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click.stop="updatesStore.cancelJob(job.id)"
>
Abbrechen
</button>
</td>
</tr>
<tr v-if="updatesStore.jobs.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-slate-500">Keine Jobs.</td>
</tr>
</tbody>
</table>
</div>
<div class="rounded-lg bg-slate-900 p-4 font-mono text-xs text-green-300 shadow">
<h2 class="mb-2 font-sans text-sm font-semibold text-slate-300">
Live-Log {{ selectedJobId ? `(Job #${selectedJobId})` : '' }}
</h2>
<div class="max-h-96 overflow-y-auto whitespace-pre-wrap">
<p v-if="selectedLogs.length === 0" class="text-slate-500">
Kein Job ausgewählt klicke links einen Job an.
</p>
<p v-for="log in selectedLogs" :key="log.id">{{ log.line }}</p>
</div>
</div>
</div>
</div>
</template>
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/forms')],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://backend:8000',
ws: true,
},
},
},
})