Hub-and-Spoke Umbau: Multi-Tenant Zentrale + Satellite-Agent

- Backend: Customer/Satellite Models, customer_id auf Server/Job/Audit
- Satellite-API: heartbeat, poll (atomares Claiming), logs, result,
  scan-result, health-report - Auth via X-Api-Key (SHA-256 gehasht)
- Job-Queue: pending/claimed/running/success/failed + Stale-Janitor
- Batch-Trigger: ein Job pro Server, Satellite arbeitet sequenziell ab
- Credentials bleiben lokal: nur symbolische credential_ref zentral
- Neues Paket satellite/: Pull-Loop, WinRM/SSH/CAU/Scanner, PyInstaller-tauglich
- Frontend: Kunden-Switcher, Satelliten-View, Polling statt WebSocket
- Entfernt: WebSocket/Socket.io, Redis, zentrale Credentials, JobRunner
- Docs: README/AGENTS/PROMPT auf neue Architektur aktualisiert
This commit is contained in:
B0rbor4d
2026-08-07 03:42:06 +00:00
parent b91dd66fee
commit cc4c3fcecb
72 changed files with 2759 additions and 1642 deletions
+10 -27
View File
@@ -1,49 +1,38 @@
# Insight Updater - Environment Template
# Insight Updater - Environment Template (Zentrale)
# Copy to .env and fill in secrets
# Hinweis: WinRM/SSH/Credentials sind in den Satellite gewandert (satellite/config.yaml)
# =============================================================================
# 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
# Initialer Admin (nur beim ersten Start, wenn keine User existieren)
ADMIN_INITIAL_PASSWORD=admin
# =============================================================================
# DATABASE
# =============================================================================
# Development: SQLite (file-based, zero config)
DATABASE_URL=sqlite+aiosqlite:///./data/app.db
# Production: PostgreSQL (uncomment and configure)
# Production: PostgreSQL
# DATABASE_URL=postgresql+asyncpg://updater:secure-password@db:5432/insight_updater
DB_NAME=insight_updater
DB_USER=updater
DB_PASSWORD=change-me-db-password
# =============================================================================
# REDIS (for Socket.io pub/sub, caching, rate limiting)
# JOBS
# =============================================================================
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
# Sekunden ohne Satellite-Report, bevor ein claimed/running Job als failed gilt
JOB_STALE_TIMEOUT=3600
# =============================================================================
# LDAP / ACTIVE DIRECTORY (STUB - prepared for future implementation)
@@ -54,15 +43,11 @@ 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
# =============================================================================
@@ -81,8 +66,6 @@ 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
GITEA_REPO=b0rbor4d/insight-updater
+4
View File
@@ -36,6 +36,10 @@ Thumbs.db
.env.local
.env.*.local
# Satellite local config (enthaelt API-Key und Credentials!)
satellite/config.yaml
satellite/credentials.yaml
# Database
*.db
*.sqlite
+92 -117
View File
@@ -1,11 +1,13 @@
# 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.
Zentrale, mandantenfaehige Update-Orchestrierung (Hub-and-Spoke). Zentrale (Docker) +
Satelliten beim Kunden (Binary, kein Docker). Satelliten pollen Jobs, fuehren sie lokal
im Kundennetz aus (WinRM/SSH/CAU/Scan) und melden Ergebnisse zurueck.
## Quick Start
```bash
# Local development
# Zentrale lokal
cd ~/projects/insight-updater
docker compose up -d --build
@@ -14,137 +16,110 @@ cd backend && pip install -e . && uvicorn app.main:app --reload
# Frontend only
cd frontend && npm install && npm run dev
# Satellite (Dev, gegen lokale Zentrale)
cd satellite && pip install -e .
cp config.example.yaml config.yaml # api_key eintragen
insight-satellite
```
## Architecture Overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Frontend │────▶│ Backend │────▶│ Database │
(Vue 3) │ WS (FastAPI) │ │ (SQLite/
Port 3000 │◀─── │ Port 8000 │ │ PostgreSQL)
└─────────────┘ └──────┬──────┘ └─────────────┘
┌────────────┼────────────
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ WinRM │ │ SSH │ │ CAU │
│ Service │ │ Service │ │ Service │
└─────────┘ └─────────┘ └─────────┘
Kundennetz Zentrale
┌────────────┐ outbound HTTPS ┌──────────────────┐
Satellite │ ──────────────────▶ │ FastAPI Backend
(pollt │ X-Api-Key Auth │ /api/satellite
│ alle 30s) │ ◀────────────────── │ /api (Dashboard │
└────────────┘ Jobs (claimed) JWT) + DB
│ WinRM/SSH/CAU lokal ──────────────────
▼ ▲
Server im Kundennetz Vue 3 Frontend (JWT, Kunden-Switcher)
```
## 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 |
| Pfad | Zweck |
|------|-------|
| `backend/app/api/routes/satellite_api.py` | Agent-API: heartbeat, poll, logs, result, scan-result, health-report |
| `backend/app/api/routes/` | Dashboard-REST: auth, customers, satellites, servers, updates, audit |
| `backend/app/models/` | Customer, Satellite, Server, UpdateJob, UpdateLog, AuditLog, User |
| `backend/app/services/janitor.py` | Markiert stale Jobs (Satellite meldet nicht mehr) als failed |
| `satellite/satellite/runner.py` | Main-Loop: heartbeat, poll, execute, report |
| `satellite/satellite/winrm_exec.py` | Windows Update via pywinrm |
| `satellite/satellite/ssh_exec.py` | Linux Update via asyncssh |
| `satellite/satellite/cau_exec.py` | Invoke-CauRun via WinRM |
| `satellite/satellite/scanner.py` | Ping-Sweep + Port-Probe (5985/22) |
| `frontend/src/stores/` | Pinia: auth, customers, satellites, servers, updates |
## 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 |
| Model | Beschreibung |
|-------|--------------|
| `Customer` | Tenant: name, slug |
| `Satellite` | Agent beim Kunden: api_key_hash, last_seen, version, hostname |
| `Server` | Inventar pro Kunde: hostname, type, credential_ref (symbolisch!) |
| `UpdateJob` | customer_id, server_id (null bei Scan), satellite_id, status, params (JSON) |
| `UpdateLog` | Log-Zeilen pro Job (Batch-Upload vom Satellite) |
| `AuditLog` | Wer, wann, was - mit customer_id |
| `User` | Dashboard-User (lokal oder LDAP-Stub) |
## Key Services
## Job Lifecycle (Pull-Modell)
| 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
```
pending -> claimed (beim Poll) -> running (erster Log-Push) -> success | failed
| cancelled (nur aus pending)
```
- Claiming passiert atomar im Poll (`with_for_update`), zwei Satelliten eines Kunden
bekommen nie denselben Job
- Janitor (`services/janitor.py`, alle 60s): claimed/running ohne Report seit
`JOB_STALE_TIMEOUT` (default 3600s) -> failed
- Cancel nur moeglich solange pending
## API Endpoints (Dashboard, JWT)
| Method | Path | Beschreibung |
|--------|------|--------------|
| POST | `/api/auth/login` | Login |
| GET/POST/PATCH/DELETE | `/api/customers[/{id}]` | Kunden CRUD |
| GET/POST/DELETE | `/api/satellites[/{id}]` | Satelliten CRUD |
| POST | `/api/satellites/{id}/rotate-key` | Neuer API-Key |
| GET/POST/PATCH/DELETE | `/api/servers[/{id}]` | Inventar (customer_id scoped) |
| POST | `/api/updates/trigger` | Einzelner Job |
| POST | `/api/updates/trigger-batch` | Ein Job pro Server |
| GET | `/api/updates/{id}/logs` | Job-Logs |
| GET | `/api/audit` | Audit (filterbar per customer_id) |
## API Endpoints (Satellite, X-Api-Key)
| Method | Path | Beschreibung |
|--------|------|--------------|
| POST | `/api/satellite/heartbeat` | Lebenszeichen + Version |
| GET | `/api/satellite/poll` | Pending Jobs claimen + abholen |
| POST | `/api/satellite/logs` | Log-Batch + Progress |
| POST | `/api/satellite/result` | Abschluss success/failed |
| POST | `/api/satellite/scan-result` | Gefundene Hosts (legt Server an) |
| POST | `/api/satellite/health-report` | Health-Ergebnis pro Server |
## Satellite-Deployment beim Kunden
- Binary via PyInstaller: `pyinstaller --onefile satellite/runner.py`
- `config.yaml`: central_url, api_key, poll_interval
- `credentials.yaml`: WinRM/SSH Zugangsdaten (bleiben lokal!)
- Start: Scheduled Task (Windows) oder systemd (Linux), siehe `satellite/README.md`
- 1-2 Stueck pro Kunde reichen - steuern das ganze Netz
## Conventions
- **Sprache**: Deutsch fuer User-facing Text, Englisch fuer Code/Kommentare
- **Keine Umlaute in .ps1/Python-Dateien** (ae, oe, ue, ss)
- **Keine Credentials zentral** - nur `credential_ref` Strings
- **Async**: Backend vollstaendig async; Satellite async mit to_thread fuer pywinrm
- **Types**: Strict mypy, Pydantic v2, SQLAlchemy 2.0
## Deployment
**Target**: `monitoring` (10.0.2.105)
**User**: `b0rbor4d` (sudo via Vaultwarden)
**Reverse Proxy**: Traefik (Docker labels)
**Target Zentrale**: `monitoring` (10.0.2.105)
**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
**Wichtig**: Zentrale muss von Kundenstandorten aus per HTTPS (443) erreichbar sein.
+73 -115
View File
@@ -1,42 +1,61 @@
# 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.
Zentrale, mandantenfaehige Update-Orchestrierung fuer Kundennetzwerke (MSP-Modell).
Hub-and-Spoke: eine zentrale Instanz (Docker, bei Insight-IT) plus schlanke
Satelliten pro Kunde (eine Binary, kein Docker beim Kunden noetig). Satelliten pollen
Jobs von der Zentrale, fuehren Updates lokal im Kundennetz aus und melden Ergebnisse
zurueck. Dashboard zeigt alles nach Kunde sortiert.
## 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)
- **Zentrale Backend**: FastAPI + Python 3.11+, SQLAlchemy + SQLite/PostgreSQL, structlog
- **Zentrale Frontend**: Vue 3 + TypeScript + Vite, Pinia, Tailwind CSS
- **Satellite**: Python 3.11+, pywinrm, asyncssh, httpx, PyInstaller One-File-Binary
- **Infra**: Docker Compose (nur Zentrale), Traefik Labels
- **CI/CD**: Gitea Actions / Woodpecker CI fuer Build + Deploy auf monitoring (10.0.2.105)
## Architektur-Entscheidungen
- **Pull-Modell**: Satelliten pollen (default 30s). Keine eingehenden Verbindungen beim
Kunden, kein VPN, keine Firewall-Ausnahmen - nur ausgehend 443 zur Zentrale.
- **Keine Live-Daten**: Log-Batches statt WebSocket. Dashboard refresht alle 10s.
- **Credentials lokal**: WinRM/SSH-Zugangsdaten nur auf dem Satellite (credentials.yaml).
Zentral gibt es nur symbolische `credential_ref`-Namen.
- **Netz-Orchestrierung**: 1-2 Satelliten pro Kunde steuern alle Server des Kunden,
aehnlich wie CAU einen Cluster steuert. Batch-Trigger legt pro Server einen Job an,
der Satellite arbeitet sie sequenziell ab.
- **Job-Claiming atomar**: zwei Satelliten eines Kunden bekommen nie denselben Job.
## 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
1. **Kunden + Satelliten** - CRUD, API-Key einmalig angezeigt, rotierbar
2. **Server-Inventar pro Kunde** - manuell oder per Netzwerk-Scan (Auto-Discovery)
3. **Job-Queue** - pending/claimed/running/success/failed/cancelled + Stale-Janitor
4. **Windows Update** - WinRM, Microsoft.Update.Session, optional Reboot
5. **Linux Update** - apt/dnf/yum via SSH mit sudo, optional Reboot
6. **CAU** - Invoke-CauRun auf Failover-Clustern
7. **Netzwerk-Scan** - Ping + Port 5985/22, legt Hosts zentral als Server an
8. **Audit-Log** - strukturiert, pro Kunde filterbar
## Non-Goals
- No WSUS/SCCM replacement, no approval workflows
- No agent deployment (agentless WinRM/SSH only)
- No multi-tenancy / RBAC beyond admin/user
- Kein WSUS/SCCM-Ersatz, keine Approval-Workflows
- Kein Live-Streaming (bewusst: Pull + Batches)
- Kein RBAC ueber admin/user hinaus
- Keine zentral gespeicherten Kunden-Credentials
## 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
- Kunde anlegen -> Satellite anlegen -> API-Key einmalig angezeigt
- Satellite startet -> erscheint als "online" im Dashboard (Heartbeat)
- Netzwerk-Scan -> gefundene Hosts im Inventar (discovered_by_scan)
- Update triggern -> Satellite claimed Job -> Logs + Ergebnis im Dashboard
- Batch: "Alle Server updaten" -> ein Job pro Server, sequenzielle Abarbeitung
- Zwei Satelliten eines Kunden: kein Job doppelt
- Satellite offline waehrend Job -> Janitor markiert Job nach Timeout als failed
- `docker compose up -d` -> Zentrale healthy in <5 min
## Verification Commands
```bash
curl -f http://localhost:8000/health
curl -f http://localhost:3000/ # frontend
docker compose ps # all healthy
curl -f http://localhost:3000/
docker compose ps
```
## Deployment Target
@@ -44,111 +63,50 @@ docker compose ps # all healthy
- **User**: b0rbor4d (sudo via Vaultwarden)
- **Docker**: Podman/Docker Compose v2
- **Reverse Proxy**: Traefik (labels on compose services)
- **Erreichbarkeit**: HTTPS 443 von Kundenstandorten aus (ausgehend)
- **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)
- Dashboard: JWT RS256, kurze Expiry
- Satelliten: API-Key (ius_...), SHA-256 gehasht in DB, Prefix fuer Anzeige
- Keine Kunden-Credentials in der zentralen DB
- Audit-Log: append-only
- TLS: Traefik + LetsEncrypt
## Project Structure
```
~/projects/insight-updater/
├── backend/
├── backend/ # Zentrale API
│ ├── 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
│ │ ├── api/routes/ # auth, customers, satellites, servers, updates, audit, satellite_api
│ │ ├── core/ # config, security, db, logging, exceptions
│ │ ├── models/ # Customer, Satellite, Server, UpdateJob, UpdateLog, AuditLog, User
│ │ ├── schemas/ # Pydantic
│ │ ├── services/ # audit, janitor
│ │ └── main.py
│ ├── tests/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── pyproject.toml
├── frontend/
├── frontend/ # Dashboard
│ ├── src/
│ │ ├── components/
│ │ ├── views/
│ │ ── stores/
│ │ ├── api/
│ │ └── main.ts
│ │ ├── views/ # Dashboard, Customers, Satellites, Servers, Updates, Audit, Login
│ │ ├── stores/ # auth, customers, satellites, servers, updates
│ │ ── components/ # AppLayout (mit Kunden-Switcher)
│ ├── Dockerfile
── package.json
│ └── vite.config.ts
── package.json
├── satellite/ # Remote-Agent
│ ├── satellite/
│ │ ├── runner.py # Main-Loop
│ │ ├── client.py # Zentral-API-Client
│ │ ├── config.py # config.yaml + credentials.yaml
│ │ ├── winrm_exec.py, ssh_exec.py, cau_exec.py, scanner.py
│ ├── config.example.yaml
│ ├── credentials.example.yaml
│ └── pyproject.toml
├── docker-compose.yml
├── docker-compose.prod.yml
── .env.example
├── .gitignore
├── README.md
├── AGENTS.md
└── PROMPT.md
── .env.example
```
## 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
```
## Environment Variables
Zentrale siehe `.env.example`: SECRET_KEY, DATABASE_URL, JWT-Keys, LDAP-Stub.
Satellite siehe `satellite/config.example.yaml`: central_url, api_key, poll_interval.
+75 -132
View File
@@ -1,172 +1,115 @@
# 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.
Zentrale, mandantenfaehige Update-Orchestrierung fuer Kundennetzwerke. Hub-and-Spoke:
eine zentrale Instanz (Docker) plus schlanke Satelliten beim Kunden (einzelne Binary,
kein Docker noetig). Satelliten pollen Jobs von der Zentrale, fuehren Updates lokal
im Kundennetz aus (WinRM / SSH / CAU) und melden Ergebnisse zurueck.
## Architektur
```
Kundennetz A Kundennetz B Zentrale
┌──────────────┐ ┌──────────────┐ ┌───────────────┐
│ Satellite 1 │ │ Satellite 1 │ outbound │ Backend │
│ (Binary) │───┐ │ (Binary) │───┐ HTTPS │ (FastAPI) │
└──────────────┘ │ └──────────────┘ │────────────▶│ + DB │
┌──────────────┐ │ ┌──────────────┐ │ │ + Frontend │
│ Satellite 2 │───┘ │ Satellite 2 │───┘ │ (Dashboard, │
└──────────────┘ WinRM/SSH └──────────────┘ │ nach Kunde) │
lokal └───────────────┘
```
- **Pull-Modell**: Satelliten fragen alle N Sekunden nach Jobs - keine eingehenden Verbindungen beim Kunden noetig
- **Multi-Tenant**: jede Entitaet (Server, Jobs, Satelliten, Audit) haengt an einem Kunden
- **Credentials bleiben lokal**: WinRM/SSH-Zugangsdaten liegen nur auf dem Satellite (`credentials.yaml`), niemals zentral
- **Netz-Orchestrierung**: 1-2 Satelliten pro Kunde steuern das ganze Netz, aehnlich CAU im Cluster
## Komponenten
| Teil | Pfad | Technologie |
|------|------|-------------|
| Zentrale (Backend) | `backend/` | FastAPI, SQLAlchemy 2.0, SQLite/PostgreSQL |
| Zentrale (Frontend) | `frontend/` | Vue 3, TypeScript, Vite, Pinia, Tailwind |
| Satellite (Agent) | `satellite/` | Python, pywinrm, asyncssh, httpx, PyInstaller |
## 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
- **Kunden-Verwaltung** mit Satelliten pro Kunde (API-Key, einmalig angezeigt)
- **Server-Inventar** pro Kunde, inkl. Auto-Discovery per Netzwerk-Scan
- **Job-Queue**: pending - claimed - running - success/failed, mit Stale-Janitor
- **Batch-Trigger**: Update-Jobs fuer alle Server eines Kunden auf einmal
- **Netzwerk-Scan**: Ping + Port-Probe (5985/22), legt gefundene Hosts zentral an
- **Log-Upload** in Batches (kein Live-Stream noetig, 10s Dashboard-Refresh)
- **Audit-Log**: strukturiert, pro Kunde filterbar
- **Auth**: JWT (RS256) fuer Dashboard-User, API-Key (SHA-256 gehasht) fuer Satelliten
## Quick Start (Development)
## Quick Start (Zentrale, 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
# Backend API: http://localhost:8000/docs
# Login: admin / admin (bzw. ADMIN_INITIAL_PASSWORD)
```
## Satellite beim Kunden
```bash
cd satellite
pip install -e .
cp config.example.yaml config.yaml # central_url + api_key eintragen
cp credentials.example.yaml credentials.yaml # lokale Zugangsdaten
insight-satellite
```
Produktiv als Windows-Binary: `pyinstaller --onefile satellite/runner.py`, Details in `satellite/README.md`.
## Workflow
1. Kunde anlegen (Dashboard - Kunden)
2. Satellite anlegen - API-Key wird einmalig angezeigt
3. Satellite beim Kunden installieren (Binary + config.yaml + credentials.yaml)
4. Netzwerk-Scan starten - gefundene Hosts landen im Inventar
5. Updates triggern: einzeln, per Batch oder ganzer Kunde
6. Ergebnisse im Dashboard (nach Kunde sortiert) pruefen
## 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
cp .env.example .env # Secrets fuellen
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
Wichtig: die Zentrale muss per HTTPS von den Kunden-Standorten aus erreichbar sein
(ausgehend 443 reicht). Traefik-Labels sind vorbereitet.
```
┌─────────────┐ WebSocket ┌─────────────┐
│ Frontend │ ◀─────────────▶ │ Backend │
│ (Vue 3) │ REST + WS │ (FastAPI) │
└─────────────┘ └──────┬──────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ WinRM │ │ SSH │ │ CAU │
│ Service │ │ Service │ │ Service │
└───────────┘ └───────────┘ └───────────┘
```
## Umgebungsvariablen
## Tech Stack
Siehe `.env.example`. Zentrale braucht nur noch: SECRET_KEY, DATABASE_URL, JWT-Keys,
optional LDAP. WinRM/SSH-Config ist in den Satellite gewandert (dessen `config.yaml`).
| 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
## Development
```bash
# Backend
cd backend
pip install -e .
uvicorn app.main:app --reload
# Run tests
pytest -v
# Lint
ruff check .
mypy .
cd backend && pip install -e . && uvicorn app.main:app --reload
# Frontend
cd frontend
npm install
npm run dev
cd frontend && npm install && npm run dev
# Build
npm run build
# Type check
vue-tsc --noEmit
# Satellite (lokaler Test gegen Dev-Zentrale)
cd satellite && pip install -e . && insight-satellite
```
## API Documentation
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
- WebSocket: `ws://localhost:8000/ws/updates`
## License
MIT Insight IT
MIT - Insight IT
+4 -1
View File
@@ -2,10 +2,13 @@
from fastapi import APIRouter
from app.api.routes import audit, auth, servers, updates
from app.api.routes import audit, auth, customers, satellite_api, satellites, servers, updates
api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(customers.router, prefix="/customers", tags=["customers"])
api_router.include_router(satellites.router, prefix="/satellites", tags=["satellites"])
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"])
api_router.include_router(satellite_api.router, prefix="/satellite", tags=["satellite-api"])
+20 -4
View File
@@ -1,12 +1,14 @@
"""Shared API dependencies: current user extraction from JWT."""
"""Shared API dependencies: dashboard user auth (JWT) and satellite auth (API key)."""
from fastapi import Depends, Request
from fastapi import Depends, Header, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
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.satellite import Satellite, hash_api_key
from app.models.user import User
bearer_scheme = HTTPBearer(auto_error=False)
@@ -23,8 +25,6 @@ async def get_current_user(
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:
@@ -38,5 +38,21 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
return user
async def get_current_satellite(
x_api_key: str | None = Header(default=None),
db: AsyncSession = Depends(get_db),
) -> Satellite:
"""Authenticate a satellite by its API key (X-Api-Key header)."""
if not x_api_key:
raise UnauthorizedError("X-Api-Key header fehlt")
result = await db.execute(
select(Satellite).where(Satellite.api_key_hash == hash_api_key(x_api_key))
)
satellite = result.scalar_one_or_none()
if not satellite or not satellite.is_active:
raise UnauthorizedError("Satellite unbekannt oder deaktiviert")
return satellite
def client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
+4
View File
@@ -19,6 +19,7 @@ async def list_audit_logs(
page_size: int = Query(default=50, ge=1, le=200),
action: str | None = None,
username: str | None = None,
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> AuditLogPage:
@@ -31,6 +32,9 @@ async def list_audit_logs(
if username:
stmt = stmt.where(AuditLog.username == username)
count_stmt = count_stmt.where(AuditLog.username == username)
if customer_id is not None:
stmt = stmt.where(AuditLog.customer_id == customer_id)
count_stmt = count_stmt.where(AuditLog.customer_id == customer_id)
total = await db.scalar(count_stmt) or 0
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
+105
View File
@@ -0,0 +1,105 @@
"""Customer routes (tenant management)."""
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 ConflictError, NotFoundError
from app.models.customer import Customer
from app.models.user import User
from app.schemas.customer import CustomerCreate, CustomerRead, CustomerUpdate
from app.services.audit import AuditService
router = APIRouter()
@router.get("", response_model=list[CustomerRead])
async def list_customers(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[Customer]:
result = await db.execute(select(Customer).order_by(Customer.name))
return list(result.scalars().all())
@router.post("", response_model=CustomerRead, status_code=201)
async def create_customer(
payload: CustomerCreate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Customer:
existing = await db.execute(
select(Customer).where((Customer.name == payload.name) | (Customer.slug == payload.slug))
)
if existing.scalar_one_or_none():
raise ConflictError("Kunde mit diesem Namen oder Slug existiert bereits")
customer = Customer(**payload.model_dump())
db.add(customer)
await db.flush()
await AuditService(db).log(
username=user.username,
action="customer.create",
target=customer.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
return customer
@router.get("/{customer_id}", response_model=CustomerRead)
async def get_customer(
customer_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> Customer:
customer = await db.get(Customer, customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
return customer
@router.patch("/{customer_id}", response_model=CustomerRead)
async def update_customer(
customer_id: int,
payload: CustomerUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Customer:
customer = await db.get(Customer, customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(customer, field, value)
await AuditService(db).log(
username=user.username,
action="customer.update",
target=customer.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
return customer
@router.delete("/{customer_id}", status_code=204)
async def delete_customer(
customer_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
customer = await db.get(Customer, customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
await AuditService(db).log(
username=user.username,
action="customer.delete",
target=customer.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
await db.delete(customer)
+228
View File
@@ -0,0 +1,228 @@
"""Satellite agent API - polled by remote satellites, authenticated via X-Api-Key.
Pull model: satellites poll for jobs, execute them locally in the customer
network, push log batches and final results back here.
"""
import json
from datetime import UTC, datetime
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_satellite
from app.core.database import get_db
from app.core.exceptions import ForbiddenError, NotFoundError
from app.core.logging import get_logger
from app.models.satellite import Satellite
from app.models.server import Server, ServerType
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
from app.schemas.satellite_api import (
HealthReportRequest,
HeartbeatRequest,
JobResultRequest,
LogBatchRequest,
PollResponse,
SatelliteJob,
ScanResultRequest,
)
from app.services.audit import AuditService
logger = get_logger(__name__)
router = APIRouter()
@router.post("/heartbeat")
async def heartbeat(
payload: HeartbeatRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
satellite.last_seen_at = datetime.now(UTC)
satellite.version = payload.version
satellite.hostname = payload.hostname
return {"ok": True, "server_time": datetime.now(UTC).isoformat()}
@router.get("/poll", response_model=PollResponse)
async def poll_jobs(
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> PollResponse:
"""Claim and return pending jobs for this satellite's customer.
Claiming is atomic-ish: status flips pending -> claimed in the same
transaction, so two satellites of one customer do not get the same job.
"""
result = await db.execute(
select(UpdateJob)
.where(
UpdateJob.customer_id == satellite.customer_id,
UpdateJob.status == JobStatus.PENDING,
)
.order_by(UpdateJob.id)
.limit(5)
.with_for_update()
)
jobs = list(result.scalars().all())
now = datetime.now(UTC)
out: list[SatelliteJob] = []
for job in jobs:
job.status = JobStatus.CLAIMED
job.satellite_id = satellite.id
job.claimed_at = now
job.last_report_at = now
params = json.loads(job.params) if job.params else {}
server = job.server
out.append(
SatelliteJob(
job_id=job.id,
type=job.type,
server_id=server.id if server else None,
server_name=server.name if server else None,
hostname=server.hostname if server else None,
port=server.port if server else None,
server_type=server.type.value if server else None,
credential_ref=server.credential_ref if server else None,
reboot_if_required=bool(params.get("reboot_if_required", False)),
scan_subnet=params.get("scan_subnet"),
)
)
if out:
logger.info(
"satellite.jobs_claimed",
satellite=satellite.name,
customer_id=satellite.customer_id,
count=len(out),
)
return PollResponse(jobs=out)
@router.post("/logs")
async def push_logs(
payload: LogBatchRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
job = await _get_own_job(db, payload.job_id, satellite)
if job.status == JobStatus.CLAIMED:
job.status = JobStatus.RUNNING
job.started_at = datetime.now(UTC)
for line in payload.lines:
db.add(
UpdateLog(
job_id=job.id,
timestamp=line.timestamp,
level=line.level,
line=line.line,
)
)
if payload.progress_percent is not None:
job.progress_percent = payload.progress_percent
if payload.current_phase is not None:
job.current_phase = payload.current_phase
job.last_report_at = datetime.now(UTC)
return {"ok": True, "accepted": len(payload.lines)}
@router.post("/result")
async def push_result(
payload: JobResultRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
job = await _get_own_job(db, payload.job_id, satellite)
job.status = JobStatus.SUCCESS if payload.status == "success" else JobStatus.FAILED
job.error = payload.error
job.finished_at = datetime.now(UTC)
job.last_report_at = job.finished_at
if job.status == JobStatus.SUCCESS:
job.progress_percent = 100
await AuditService(db).log(
username=f"satellite:{satellite.name}",
action="job.result",
target=f"job:{job.id}",
result="success" if payload.status == "success" else "failure",
customer_id=satellite.customer_id,
details={"type": job.type.value, "error": payload.error},
)
return {"ok": True}
@router.post("/scan-result")
async def push_scan_result(
payload: ScanResultRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Ingest discovered hosts from a NETWORK_SCAN job as server candidates."""
job = await _get_own_job(db, payload.job_id, satellite)
if job.type != JobType.NETWORK_SCAN:
raise ForbiddenError("Scan-Ergebnisse nur für NETWORK_SCAN Jobs")
created = 0
for host in payload.hosts:
existing = await db.execute(
select(Server).where(
Server.customer_id == satellite.customer_id,
Server.hostname.in_([host.hostname, host.ip]),
)
)
if existing.scalar_one_or_none():
continue
if host.winrm_open:
stype, port = ServerType.WINDOWS, 5985
elif host.ssh_open:
stype, port = ServerType.LINUX, 22
else:
continue # not manageable - skip
db.add(
Server(
customer_id=satellite.customer_id,
name=host.hostname,
hostname=host.ip,
port=port,
type=stype,
description=f"Auto-Discovery via Scan (Job #{job.id})",
discovered_by_scan=True,
)
)
created += 1
return {"ok": True, "created": created}
@router.post("/health-report")
async def push_health_report(
payload: HealthReportRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
server = await db.get(Server, payload.server_id)
if not server or server.customer_id != satellite.customer_id:
raise NotFoundError("Server nicht gefunden")
server.last_health_at = datetime.now(UTC)
server.last_health_ok = payload.ok
server.last_health_message = payload.message
return {"ok": True}
async def _get_own_job(
db: AsyncSession, job_id: int, satellite: Satellite
) -> UpdateJob:
job = await db.get(UpdateJob, job_id)
if not job or job.customer_id != satellite.customer_id:
raise NotFoundError("Job nicht gefunden")
return job
+111
View File
@@ -0,0 +1,111 @@
"""Satellite management routes (dashboard side).
The plaintext API key is returned exactly once on creation.
"""
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.customer import Customer
from app.models.satellite import Satellite, generate_api_key, hash_api_key
from app.models.user import User
from app.schemas.satellite import SatelliteCreate, SatelliteCreated, SatelliteRead
from app.services.audit import AuditService
router = APIRouter()
def _created_response(satellite: Satellite, api_key: str) -> SatelliteCreated:
data = SatelliteRead.model_validate(satellite).model_dump()
return SatelliteCreated(**data, api_key=api_key)
@router.get("", response_model=list[SatelliteRead])
async def list_satellites(
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[Satellite]:
stmt = select(Satellite).order_by(Satellite.id)
if customer_id is not None:
stmt = stmt.where(Satellite.customer_id == customer_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("", response_model=SatelliteCreated, status_code=201)
async def create_satellite(
payload: SatelliteCreate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> SatelliteCreated:
customer = await db.get(Customer, payload.customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
api_key = generate_api_key()
satellite = Satellite(
customer_id=payload.customer_id,
name=payload.name,
api_key_hash=hash_api_key(api_key),
api_key_prefix=api_key[:11],
)
db.add(satellite)
await db.flush()
await AuditService(db).log(
username=user.username,
action="satellite.create",
target=f"{customer.name}/{satellite.name}",
customer_id=customer.id,
ip_address=client_ip(request),
)
return _created_response(satellite, api_key)
@router.delete("/{satellite_id}", status_code=204)
async def delete_satellite(
satellite_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
satellite = await db.get(Satellite, satellite_id)
if not satellite:
raise NotFoundError("Satellite nicht gefunden")
await AuditService(db).log(
username=user.username,
action="satellite.delete",
target=satellite.name,
customer_id=satellite.customer_id,
ip_address=client_ip(request),
)
await db.delete(satellite)
@router.post("/{satellite_id}/rotate-key", response_model=SatelliteCreated)
async def rotate_satellite_key(
satellite_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> SatelliteCreated:
satellite = await db.get(Satellite, satellite_id)
if not satellite:
raise NotFoundError("Satellite nicht gefunden")
api_key = generate_api_key()
satellite.api_key_hash = hash_api_key(api_key)
satellite.api_key_prefix = api_key[:11]
await AuditService(db).log(
username=user.username,
action="satellite.rotate_key",
target=satellite.name,
customer_id=satellite.customer_id,
ip_address=client_ip(request),
)
return _created_response(satellite, api_key)
+28 -68
View File
@@ -1,6 +1,8 @@
"""Server inventory routes."""
"""Server inventory routes (customer-scoped).
from datetime import UTC, datetime
No direct connectivity from the central server - health checks are
HEALTH_CHECK jobs executed by the customer's satellite.
"""
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
@@ -8,26 +10,26 @@ 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.core.exceptions import ConflictError, NotFoundError
from app.models.customer import Customer
from app.models.server import Server
from app.models.user import User
from app.schemas.server import HealthCheckResult, ServerCreate, ServerRead, ServerUpdate
from app.schemas.server import 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(
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[Server]:
result = await db.execute(select(Server).order_by(Server.name))
stmt = select(Server).order_by(Server.name)
if customer_id is not None:
stmt = stmt.where(Server.customer_id == customer_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@@ -38,6 +40,18 @@ async def create_server(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Server:
customer = await db.get(Customer, payload.customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
existing = await db.execute(
select(Server).where(
Server.customer_id == payload.customer_id, Server.name == payload.name
)
)
if existing.scalar_one_or_none():
raise ConflictError("Server mit diesem Namen existiert beim Kunden bereits")
server = Server(**payload.model_dump())
db.add(server)
await db.flush()
@@ -45,6 +59,7 @@ async def create_server(
username=user.username,
action="server.create",
target=server.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
return server
@@ -79,6 +94,7 @@ async def update_server(
username=user.username,
action="server.update",
target=server.name,
customer_id=server.customer_id,
ip_address=client_ip(request),
)
return server
@@ -98,63 +114,7 @@ async def delete_server(
username=user.username,
action="server.delete",
target=server.name,
customer_id=server.customer_id,
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,
)
+115 -32
View File
@@ -1,4 +1,10 @@
"""Update job routes: trigger, list, logs, cancel."""
"""Update job routes: trigger (single + batch), list, logs, cancel.
Triggering only queues a job - a satellite of that customer picks it up
on its next poll and executes it locally.
"""
import json
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
@@ -6,13 +12,19 @@ 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.core.exceptions import JobNotCancellableError, NotFoundError, ValidationError
from app.models.customer import Customer
from app.models.server import Server
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
from app.models.user import User
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
from app.schemas.update import (
BatchJobTriggerRequest,
BatchJobTriggerResponse,
JobTriggerRequest,
UpdateJobRead,
UpdateLogRead,
)
from app.services.audit import AuditService
from app.services.job_runner import job_runner
router = APIRouter()
@@ -24,39 +36,69 @@ async def trigger_update(
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()
job = await _create_job(db, payload, user.username)
await AuditService(db).log(
username=user.username,
action="update.trigger",
target=server.name,
details={"job_id": job.id, "type": payload.type.value},
target=f"job:{job.id}",
customer_id=payload.customer_id,
details={"type": payload.type.value, "server_id": payload.server_id},
ip_address=client_ip(request),
)
await db.commit()
await job_runner.start(job.id)
return job
@router.post("/trigger-batch", response_model=BatchJobTriggerResponse, status_code=201)
async def trigger_batch(
payload: BatchJobTriggerRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> BatchJobTriggerResponse:
"""Queue one job per server - the satellite works through them in order."""
stmt = select(Server).where(Server.customer_id == payload.customer_id)
if payload.server_ids:
stmt = stmt.where(Server.id.in_(payload.server_ids))
result = await db.execute(stmt.order_by(Server.name))
servers = list(result.scalars().all())
if not servers:
raise NotFoundError("Keine Server für diesen Kunden gefunden")
job_ids: list[int] = []
for server in servers:
job = await _create_job(
db,
JobTriggerRequest(
customer_id=payload.customer_id,
type=payload.type,
server_id=server.id,
reboot_if_required=payload.reboot_if_required,
),
user.username,
)
job_ids.append(job.id)
await AuditService(db).log(
username=user.username,
action="update.trigger_batch",
customer_id=payload.customer_id,
details={"type": payload.type.value, "count": len(job_ids)},
ip_address=client_ip(request),
)
return BatchJobTriggerResponse(created=len(job_ids), job_ids=job_ids)
@router.get("", response_model=list[UpdateJobRead])
async def list_jobs(
customer_id: int | None = None,
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 customer_id is not None:
stmt = stmt.where(UpdateJob.customer_id == customer_id)
if status:
stmt = stmt.where(UpdateJob.status == status)
result = await db.execute(stmt)
@@ -103,17 +145,17 @@ async def cancel_job(
job = await db.get(UpdateJob, job_id)
if not job:
raise NotFoundError("Job nicht gefunden")
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
# Only pending jobs can be cancelled centrally - a claimed/running job
# is already on the satellite and finishes there.
if job.status != JobStatus.PENDING:
raise JobNotCancellableError()
cancelled = await job_runner.cancel(job_id)
if not cancelled:
job.status = JobStatus.CANCELLED
job.status = JobStatus.CANCELLED
await AuditService(db).log(
username=user.username,
action="update.cancel",
target=f"job:{job_id}",
customer_id=job.customer_id,
ip_address=client_ip(request),
)
return job
@@ -121,14 +163,55 @@ async def cancel_job(
@router.get("/stats/summary")
async def job_stats(
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> dict:
total = await db.scalar(select(func.count(UpdateJob.id)))
base = select(func.count(UpdateJob.id))
if customer_id is not None:
base = base.where(UpdateJob.customer_id == customer_id)
total = await db.scalar(base)
running = await db.scalar(
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.RUNNING)
base.where(UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]))
)
failed = await db.scalar(
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.FAILED)
failed = await db.scalar(base.where(UpdateJob.status == JobStatus.FAILED))
pending = await db.scalar(base.where(UpdateJob.status == JobStatus.PENDING))
return {
"total": total or 0,
"running": running or 0,
"failed": failed or 0,
"pending": pending or 0,
}
async def _create_job(
db: AsyncSession, payload: JobTriggerRequest, username: str
) -> UpdateJob:
customer = await db.get(Customer, payload.customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
if payload.type == JobType.NETWORK_SCAN:
if payload.server_id is not None:
raise ValidationError("NETWORK_SCAN hat keinen Ziel-Server")
else:
if payload.server_id is None:
raise ValidationError("server_id erforderlich")
server = await db.get(Server, payload.server_id)
if not server or server.customer_id != payload.customer_id:
raise NotFoundError("Server nicht gefunden")
params: dict = {"reboot_if_required": payload.reboot_if_required}
if payload.scan_subnet:
params["scan_subnet"] = payload.scan_subnet
job = UpdateJob(
customer_id=payload.customer_id,
server_id=payload.server_id,
type=payload.type,
created_by=username,
params=json.dumps(params),
)
return {"total": total or 0, "running": running or 0, "failed": failed or 0}
db.add(job)
await db.flush()
return job
+4 -12
View File
@@ -18,7 +18,6 @@ class Settings(BaseSettings):
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"
@@ -29,19 +28,12 @@ class Settings(BaseSettings):
jwt_access_token_expire_minutes: int = 30
jwt_refresh_token_expire_days: int = 7
# Database / Redis
# Database
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
# Jobs: a claimed/running job without satellite reports for this many
# seconds is considered stale and marked failed by the janitor
job_stale_timeout: int = 3600
# LDAP (stub)
ldap_enabled: bool = False
+5
View File
@@ -62,5 +62,10 @@ class CAUError(AppError):
detail = "Cluster-Aware Updating operation failed"
class ValidationError(AppError):
status_code = 422
detail = "Validation failed"
class JobNotCancellableError(ConflictError):
detail = "Job cannot be cancelled in its current state"
+6 -48
View File
@@ -1,58 +1,21 @@
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
"""Security helpers: JWT issue/verify, password hashing.
No credential encryption here - target-system credentials live exclusively
on the satellites, never in the central database.
"""
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
from app.core.exceptions import 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
@@ -66,11 +29,6 @@ def verify_password(plain: str, hashed: str) -> bool:
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
+15 -22
View File
@@ -1,14 +1,13 @@
"""FastAPI application entrypoint.
Mounts:
- REST API under /api
- Socket.io under /socket.io (path) -> frontend connects to ws://host/socket.io
Central instance of the Insight Updater hub:
- Dashboard REST API under /api (JWT auth)
- Satellite agent API under /api/satellite (X-Api-Key auth)
- /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
@@ -18,7 +17,6 @@ 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__)
@@ -32,17 +30,15 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
await init_db()
await _seed_default_admin()
# Attach Redis manager for Socket.io pub/sub (optional in dev)
try:
from socketio import AsyncRedisManager
import asyncio
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))
from app.services.janitor import run_janitor
janitor = asyncio.create_task(run_janitor(), name="job-janitor")
yield
janitor.cancel()
logger.info("app.stopping")
@@ -77,13 +73,13 @@ async def _seed_default_admin() -> None:
logger.info("app.default_admin_created", username="admin")
fastapi_app = FastAPI(
app = FastAPI(
title=settings.app_name,
version="0.1.0",
version="0.2.0",
lifespan=lifespan,
)
fastapi_app.add_middleware(
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
@@ -92,17 +88,14 @@ fastapi_app.add_middleware(
)
@fastapi_app.exception_handler(AppError)
@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")
@app.get("/health")
async def health() -> dict:
return {"status": "ok", "env": settings.app_env, "version": "0.1.0"}
return {"status": "ok", "env": settings.app_env, "version": "0.2.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")
app.include_router(api_router)
+4 -2
View File
@@ -1,14 +1,16 @@
"""SQLAlchemy ORM models."""
from app.models.audit_log import AuditLog
from app.models.credential import Credential
from app.models.customer import Customer
from app.models.satellite import Satellite
from app.models.server import Server
from app.models.update_job import UpdateJob, UpdateLog
from app.models.user import User
__all__ = [
"AuditLog",
"Credential",
"Customer",
"Satellite",
"Server",
"UpdateJob",
"UpdateLog",
+5 -2
View File
@@ -2,7 +2,7 @@
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@@ -15,9 +15,12 @@ class AuditLog(Base):
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
)
customer_id: Mapped[int | None] = mapped_column(
ForeignKey("customers.id"), nullable=True, 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
target: Mapped[str | None] = mapped_column(String(255), nullable=True)
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
@@ -1,39 +0,0 @@
"""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),
)
+36
View File
@@ -0,0 +1,36 @@
"""Customer model - one per client site (tenant)."""
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
notes: 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),
)
satellites: Mapped[list["Satellite"]] = relationship( # noqa: F821
back_populates="customer", cascade="all, delete-orphan"
)
servers: Mapped[list["Server"]] = relationship( # noqa: F821
back_populates="customer", cascade="all, delete-orphan"
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="customer", cascade="all, delete-orphan"
)
+52
View File
@@ -0,0 +1,52 @@
"""Satellite model - remote agent at a customer site.
The API key is stored as a SHA-256 hash; the plaintext key is shown
exactly once at creation time.
"""
import hashlib
import secrets
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
def generate_api_key() -> str:
"""Generate a new satellite API key (plaintext, show once)."""
return f"ius_{secrets.token_urlsafe(32)}"
def hash_api_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
class Satellite(Base):
__tablename__ = "satellites"
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id"), index=True
)
customer: Mapped["Customer"] = relationship(back_populates="satellites") # noqa: F821
name: Mapped[str] = mapped_column(String(255))
api_key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
api_key_prefix: Mapped[str] = mapped_column(String(12)) # for display in UI
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Filled by heartbeat
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
version: Mapped[str | None] = mapped_column(String(50), nullable=True)
hostname: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="satellite"
)
+24 -9
View File
@@ -1,15 +1,19 @@
"""Server inventory model."""
"""Server inventory model.
Credentials are NOT stored centrally. `credential_ref` is a symbolic name
that the satellite resolves against its local credentials.yaml.
"""
import enum
from datetime import UTC, datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ServerType(str, enum.Enum):
class ServerType(enum.StrEnum):
WINDOWS = "windows" # WinRM
LINUX = "linux" # SSH
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
@@ -17,22 +21,33 @@ class ServerType(str, enum.Enum):
class Server(Base):
__tablename__ = "servers"
__table_args__ = (
UniqueConstraint("customer_id", "name", name="uq_server_customer_name"),
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id"), index=True
)
customer: Mapped["Customer"] = relationship(back_populates="servers") # noqa: F821
name: Mapped[str] = mapped_column(String(255), 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
# Symbolic reference to a credential stored locally on the satellite
credential_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Last health result reported by a satellite
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
last_health_message: Mapped[str | None] = mapped_column(Text, nullable=True)
# Set by network scan jobs
discovered_by_scan: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
@@ -44,5 +59,5 @@ class Server(Base):
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="server", cascade="all, delete-orphan"
back_populates="server"
)
+34 -6
View File
@@ -1,4 +1,10 @@
"""Update job + streamed log line models."""
"""Update job + log line models.
Job lifecycle (pull model):
pending -> claimed (satellite picked it up) -> running -> success | failed | cancelled
A claimed/running job whose satellite goes silent past the stale timeout
is marked failed by the janitor.
"""
import enum
from datetime import UTC, datetime
@@ -9,36 +15,58 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class JobStatus(str, enum.Enum):
class JobStatus(enum.StrEnum):
PENDING = "pending"
CLAIMED = "claimed"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELLED = "cancelled"
class JobType(str, enum.Enum):
class JobType(enum.StrEnum):
WINDOWS_UPDATE = "windows_update"
LINUX_UPDATE = "linux_update"
CAU_RUN = "cau_run"
HEALTH_CHECK = "health_check"
NETWORK_SCAN = "network_scan"
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
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id"), index=True
)
customer: Mapped["Customer"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
# Null for NETWORK_SCAN jobs (target = whole local network)
server_id: Mapped[int | None] = mapped_column(
ForeignKey("servers.id"), nullable=True, index=True
)
server: Mapped["Server | None"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
# Set when a satellite claims the job
satellite_id: Mapped[int | None] = mapped_column(
ForeignKey("satellites.id"), nullable=True, index=True
)
satellite: Mapped["Satellite | None"] = 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
# Optional job parameters (e.g. reboot_if_required, scan_subnet)
params: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
created_by: Mapped[str] = mapped_column(String(255)) # dashboard username
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_report_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(
+1 -18
View File
@@ -1,18 +1 @@
"""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",
]
"""Pydantic request/response schemas."""
+1
View File
@@ -10,6 +10,7 @@ class AuditLogRead(BaseModel):
id: int
timestamp: datetime
customer_id: int | None
username: str
action: str
target: str | None
+27
View File
@@ -0,0 +1,27 @@
"""Customer schemas."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class CustomerCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")
notes: str | None = None
class CustomerUpdate(BaseModel):
name: str | None = None
notes: str | None = None
class CustomerRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
notes: str | None
created_at: datetime
updated_at: datetime
+30
View File
@@ -0,0 +1,30 @@
"""Satellite schemas."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class SatelliteCreate(BaseModel):
customer_id: int
name: str = Field(min_length=1, max_length=255)
class SatelliteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
customer_id: int
name: str
api_key_prefix: str
is_active: bool
last_seen_at: datetime | None
version: str | None
hostname: str | None
created_at: datetime
class SatelliteCreated(SatelliteRead):
"""Returned exactly once on creation - contains the plaintext API key."""
api_key: str
+71
View File
@@ -0,0 +1,71 @@
"""Satellite-facing schemas (agent API)."""
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.update_job import JobType
class HeartbeatRequest(BaseModel):
version: str = Field(max_length=50)
hostname: str = Field(max_length=255)
class PollResponse(BaseModel):
"""Jobs handed to the satellite on poll. Empty list = nothing to do."""
jobs: list["SatelliteJob"]
class SatelliteJob(BaseModel):
job_id: int
type: JobType
# Target info (absent for network scans)
server_id: int | None = None
server_name: str | None = None
hostname: str | None = None
port: int | None = None
server_type: str | None = None
credential_ref: str | None = None
# Parameters
reboot_if_required: bool = False
scan_subnet: str | None = None
class LogLine(BaseModel):
timestamp: datetime
level: str = "info"
line: str
class LogBatchRequest(BaseModel):
job_id: int
lines: list[LogLine]
progress_percent: int | None = None
current_phase: str | None = None
class JobResultRequest(BaseModel):
job_id: int
status: str # "success" | "failed"
error: str | None = None
class ScanResultHost(BaseModel):
hostname: str
ip: str
os_guess: str | None = None # "windows" | "linux" | None
winrm_open: bool = False
ssh_open: bool = False
class ScanResultRequest(BaseModel):
job_id: int
hosts: list[ScanResultHost]
class HealthReportRequest(BaseModel):
server_id: int
ok: bool
message: str
+7 -11
View File
@@ -8,13 +8,14 @@ from app.models.server import ServerType
class ServerCreate(BaseModel):
customer_id: int
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
credential_ref: str | None = None
class ServerUpdate(BaseModel):
@@ -24,29 +25,24 @@ class ServerUpdate(BaseModel):
type: ServerType | None = None
description: str | None = None
tags: str | None = None
credential_id: int | None = None
credential_ref: str | None = None
class ServerRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
customer_id: int
name: str
hostname: str
port: int
type: ServerType
description: str | None
tags: str | None
credential_id: int | None
credential_ref: str | None
last_health_at: datetime | None
last_health_ok: bool | None
last_health_message: str | None
discovered_by_scan: bool
created_at: datetime
updated_at: datetime
class HealthCheckResult(BaseModel):
server_id: int
ok: bool
latency_ms: float | None = None
message: str
checked_at: datetime
+24 -6
View File
@@ -8,24 +8,28 @@ from app.models.update_job import JobStatus, JobType
class JobTriggerRequest(BaseModel):
server_id: int
customer_id: int
type: JobType
# CAU-specific options
cluster_name: str | None = None
# Linux-specific options
# Target server; not required for NETWORK_SCAN
server_id: int | None = None
# Optional parameters
reboot_if_required: bool = False
scan_subnet: str | None = None # e.g. "192.168.1.0/24"
class UpdateJobRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
server_id: int
customer_id: int
server_id: int | None
satellite_id: int | None
type: JobType
status: JobStatus
progress_percent: int
current_phase: str | None
started_by: str
created_by: str
claimed_at: datetime | None
started_at: datetime | None
finished_at: datetime | None
error: str | None
@@ -40,3 +44,17 @@ class UpdateLogRead(BaseModel):
timestamp: datetime
level: str
line: str
class BatchJobTriggerRequest(BaseModel):
"""Trigger one job per server (or all servers of a customer)."""
customer_id: int
type: JobType
server_ids: list[int] | None = None # None = all servers of the customer
reboot_if_required: bool = False
class BatchJobTriggerResponse(BaseModel):
created: int
job_ids: list[int]
+3
View File
@@ -25,6 +25,7 @@ class AuditService:
result: str = "success",
details: dict[str, Any] | None = None,
ip_address: str | None = None,
customer_id: int | None = None,
) -> AuditLog:
entry = AuditLog(
username=username,
@@ -33,6 +34,7 @@ class AuditService:
result=result,
details=json.dumps(details) if details else None,
ip_address=ip_address,
customer_id=customer_id,
)
self.db.add(entry)
await self.db.flush()
@@ -42,5 +44,6 @@ class AuditService:
action=action,
target=target,
result=result,
customer_id=customer_id,
)
return entry
-77
View File
@@ -1,77 +0,0 @@
"""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
+47
View File
@@ -0,0 +1,47 @@
"""Stale job janitor: marks claimed/running jobs as failed when their
satellite stops reporting (e.g. satellite offline, job crashed).
"""
import asyncio
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from app.core.config import get_settings
from app.core.database import async_session_factory
from app.core.logging import get_logger
from app.models.update_job import JobStatus, UpdateJob
settings = get_settings()
logger = get_logger(__name__)
CHECK_INTERVAL = 60 # seconds
async def run_janitor() -> None:
"""Background task; runs until cancelled."""
while True:
try:
await _sweep()
except Exception as exc: # noqa: BLE001 - janitor must never die
logger.error("janitor.error", error=str(exc))
await asyncio.sleep(CHECK_INTERVAL)
async def _sweep() -> None:
cutoff = datetime.now(UTC) - timedelta(seconds=settings.job_stale_timeout)
async with async_session_factory() as db:
result = await db.execute(
select(UpdateJob).where(
UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]),
UpdateJob.last_report_at < cutoff,
)
)
stale = list(result.scalars().all())
for job in stale:
job.status = JobStatus.FAILED
job.error = "Satellite meldet sich nicht mehr (Timeout)"
job.finished_at = datetime.now(UTC)
logger.warning("janitor.job_stale", job_id=job.id, satellite_id=job.satellite_id)
if stale:
await db.commit()
-138
View File
@@ -1,138 +0,0 @@
"""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
@@ -1,118 +0,0 @@
"""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
@@ -1,122 +0,0 @@
"""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
@@ -1,6 +0,0 @@
"""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
@@ -1,103 +0,0 @@
"""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
@@ -1,43 +0,0 @@
"""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()
-5
View File
@@ -11,16 +11,11 @@ dependencies = [
"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",
+8
View File
@@ -0,0 +1,8 @@
version: '3.8'
# Internal-only override: publish the frontend directly on the host.
# No Traefik server required; API/WS stay behind the frontend nginx proxy.
services:
frontend:
ports:
- "8080:80"
-27
View File
@@ -14,13 +14,9 @@ services:
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}
@@ -57,10 +53,6 @@ services:
- "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)
@@ -83,24 +75,6 @@ services:
- "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
# ---------------------------------------------------------------
@@ -134,5 +108,4 @@ networks:
external: true
volumes:
redis-data:
pg-data:
+5 -48
View File
@@ -1,9 +1,9 @@
version: '3.8'
# Zentrale Insight-Updater-Instanz (Hub). Satelliten laufen beim Kunden,
# nicht hier - siehe satellite/.
services:
# ---------------------------------------------------------------------------
# BACKEND - FastAPI
# ---------------------------------------------------------------------------
backend:
build:
context: ./backend
@@ -13,14 +13,10 @@ services:
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:
@@ -29,9 +25,6 @@ services:
- ./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
@@ -41,9 +34,6 @@ services:
networks:
- insight-updater-network
# ---------------------------------------------------------------------------
# FRONTEND - Vue 3 + Vite (dev) / Nginx (prod)
# ---------------------------------------------------------------------------
frontend:
build:
context: ./frontend
@@ -52,7 +42,6 @@ services:
container_name: insight-updater-frontend
environment:
- VITE_API_URL=http://localhost:8000
- VITE_WS_URL=ws://localhost:8000
volumes:
- ./frontend:/app
- /app/node_modules
@@ -63,28 +52,7 @@ services:
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)
# ---------------------------------------------------------------------------
# PostgreSQL (optional, fuer Produktion)
# postgres:
# image: postgres:16-alpine
# container_name: insight-updater-postgres
@@ -94,20 +62,9 @@ services:
# - 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
driver: bridge
+1 -65
View File
@@ -15,7 +15,6 @@
"date-fns": "^3.6.0",
"lucide-vue-next": "^0.378.0",
"pinia": "^2.1.0",
"socket.io-client": "^4.7.0",
"tailwind-merge": "^2.2.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
@@ -1294,12 +1293,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz",
@@ -2741,28 +2734,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -5300,34 +5271,6 @@
"node": ">=8"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6402,6 +6345,7 @@
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -6437,14 +6381,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
-1
View File
@@ -18,7 +18,6 @@
"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",
-22
View File
@@ -1,22 +0,0 @@
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
}
}
+32 -6
View File
@@ -1,17 +1,31 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useCustomersStore } from '@/stores/customers'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
const customersStore = useCustomersStore()
const navItems = [
{ name: 'dashboard', label: 'Dashboard' },
{ name: 'customers', label: 'Kunden' },
{ name: 'satellites', label: 'Satelliten' },
{ name: 'servers', label: 'Server' },
{ name: 'updates', label: 'Updates' },
{ name: 'audit', label: 'Audit' },
]
const selected = computed({
get: () => customersStore.selectedId,
set: (id: number | null) => {
if (id !== null) customersStore.select(id)
},
})
onMounted(() => customersStore.fetchCustomers())
function logout(): void {
auth.logout()
router.push({ name: 'login' })
@@ -35,12 +49,24 @@ function logout(): void {
{{ 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 class="flex items-center gap-3">
<select
v-model="selected"
class="rounded bg-slate-700 px-2 py-1.5 text-sm text-white"
title="Aktiver Kunde"
>
<option :value="null" disabled>Kunde wählen</option>
<option v-for="c in customersStore.customers" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
<button
class="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
@click="logout"
>
Abmelden
</button>
</div>
</div>
</div>
</nav>
+10
View File
@@ -14,6 +14,16 @@ const router = createRouter({
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: '/customers',
name: 'customers',
component: () => import('@/views/CustomersView.vue'),
},
{
path: '/satellites',
name: 'satellites',
component: () => import('@/views/SatellitesView.vue'),
},
{
path: '/servers',
name: 'servers',
-2
View File
@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/api/client'
import { disconnectSocket } from '@/api/socket'
const TOKEN_KEY = 'iu_token'
@@ -25,7 +24,6 @@ export const useAuthStore = defineStore('auth', () => {
token.value = null
username.value = null
localStorage.removeItem(TOKEN_KEY)
disconnectSocket()
}
return { token, username, isAuthenticated, login, logout }
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Customer } from '@/types'
export const useCustomersStore = defineStore('customers', () => {
const customers = ref<Customer[]>([])
const selectedId = ref<number | null>(null)
const loading = ref(false)
async function fetchCustomers(): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<Customer[]>('/api/customers')
customers.value = data
if (selectedId.value === null && data.length > 0) {
selectedId.value = data[0].id
}
} finally {
loading.value = false
}
}
async function createCustomer(payload: { name: string; slug: string; notes?: string }): Promise<Customer> {
const { data } = await apiClient.post<Customer>('/api/customers', payload)
customers.value.push(data)
return data
}
async function deleteCustomer(id: number): Promise<void> {
await apiClient.delete(`/api/customers/${id}`)
customers.value = customers.value.filter((c) => c.id !== id)
if (selectedId.value === id) {
selectedId.value = customers.value[0]?.id ?? null
}
}
function select(id: number): void {
selectedId.value = id
}
return { customers, selectedId, loading, fetchCustomers, createCustomer, deleteCustomer, select }
})
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Satellite, SatelliteCreated } from '@/types'
export const useSatellitesStore = defineStore('satellites', () => {
const satellites = ref<Satellite[]>([])
const loading = ref(false)
async function fetchSatellites(customerId?: number): Promise<void> {
loading.value = true
try {
const params = customerId ? { customer_id: customerId } : {}
const { data } = await apiClient.get<Satellite[]>('/api/satellites', { params })
satellites.value = data
} finally {
loading.value = false
}
}
async function createSatellite(customerId: number, name: string): Promise<SatelliteCreated> {
const { data } = await apiClient.post<SatelliteCreated>('/api/satellites', {
customer_id: customerId,
name,
})
satellites.value.push(data)
return data
}
async function deleteSatellite(id: number): Promise<void> {
await apiClient.delete(`/api/satellites/${id}`)
satellites.value = satellites.value.filter((s) => s.id !== id)
}
async function rotateKey(id: number): Promise<SatelliteCreated> {
const { data } = await apiClient.post<SatelliteCreated>(`/api/satellites/${id}/rotate-key`)
return data
}
return { satellites, loading, fetchSatellites, createSatellite, deleteSatellite, rotateKey }
})
+5 -11
View File
@@ -1,17 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Server, HealthResult } from '@/types'
import type { Server } 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> {
async function fetchServers(customerId?: number | null): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<Server[]>('/api/servers')
const params = customerId ? { customer_id: customerId } : {}
const { data } = await apiClient.get<Server[]>('/api/servers', { params })
servers.value = data
} finally {
loading.value = false
@@ -29,11 +29,5 @@ export const useServersStore = defineStore('servers', () => {
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 }
return { servers, loading, fetchServers, createServer, deleteServer }
})
+42 -59
View File
@@ -1,90 +1,73 @@
import { defineStore } from 'pinia'
import { ref, onUnmounted } from 'vue'
import { ref } 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[]>>({})
const logs = ref<Record<number, UpdateLogLine[]>>({})
async function fetchJobs(): Promise<void> {
async function fetchJobs(customerId?: number | null): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<UpdateJob[]>('/api/updates')
const params = customerId ? { customer_id: customerId } : {}
const { data } = await apiClient.get<UpdateJob[]>('/api/updates', { params })
jobs.value = data
} finally {
loading.value = false
}
}
async function triggerUpdate(serverId: number, type: JobType): Promise<UpdateJob> {
async function triggerUpdate(
customerId: number,
serverId: number,
type: JobType,
rebootIfRequired = false,
): Promise<UpdateJob> {
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
customer_id: customerId,
server_id: serverId,
type,
reboot_if_required: rebootIfRequired,
})
jobs.value.unshift(data)
return data
}
async function triggerScan(customerId: number, subnet: string): Promise<UpdateJob> {
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
customer_id: customerId,
type: 'network_scan',
scan_subnet: subnet,
})
jobs.value.unshift(data)
return data
}
async function triggerBatch(
customerId: number,
type: JobType,
serverIds?: number[],
): Promise<number> {
const { data } = await apiClient.post<{ created: number }>('/api/updates/trigger-batch', {
customer_id: customerId,
type,
server_ids: serverIds ?? null,
})
return data.created
}
async function cancelJob(jobId: number): Promise<void> {
await apiClient.post(`/api/updates/${jobId}/cancel`)
const job = jobs.value.find((j) => j.id === jobId)
if (job) job.status = 'cancelled'
}
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]
async function fetchLogs(jobId: number): Promise<void> {
const { data } = await apiClient.get<UpdateLogLine[]>(`/api/updates/${jobId}/logs`)
logs.value[jobId] = 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 }
return { jobs, loading, logs, fetchJobs, triggerUpdate, triggerScan, triggerBatch, cancelJob, fetchLogs }
})
+37 -13
View File
@@ -1,30 +1,61 @@
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 type JobStatus = 'pending' | 'claimed' | 'running' | 'success' | 'failed' | 'cancelled'
export type JobType = 'windows_update' | 'linux_update' | 'cau_run' | 'health_check' | 'network_scan'
export interface Customer {
id: number
name: string
slug: string
notes: string | null
created_at: string
updated_at: string
}
export interface Satellite {
id: number
customer_id: number
name: string
api_key_prefix: string
is_active: boolean
last_seen_at: string | null
version: string | null
hostname: string | null
created_at: string
}
export interface SatelliteCreated extends Satellite {
api_key: string
}
export interface Server {
id: number
customer_id: number
name: string
hostname: string
port: number
type: ServerType
description: string | null
tags: string | null
credential_id: number | null
credential_ref: string | null
last_health_at: string | null
last_health_ok: boolean | null
last_health_message: string | null
discovered_by_scan: boolean
created_at: string
updated_at: string
}
export interface UpdateJob {
id: number
server_id: number
customer_id: number
server_id: number | null
satellite_id: number | null
type: JobType
status: JobStatus
progress_percent: number
current_phase: string | null
started_by: string
created_by: string
claimed_at: string | null
started_at: string | null
finished_at: string | null
error: string | null
@@ -42,6 +73,7 @@ export interface UpdateLogLine {
export interface AuditEntry {
id: number
timestamp: string
customer_id: number | null
username: string
action: string
target: string | null
@@ -49,11 +81,3 @@ export interface AuditEntry {
details: string | null
ip_address: string | null
}
export interface HealthResult {
server_id: number
ok: boolean
latency_ms: number | null
message: string
checked_at: string
}
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useCustomersStore } from '@/stores/customers'
const store = useCustomersStore()
const showForm = ref(false)
const form = reactive({ name: '', slug: '', notes: '' })
onMounted(() => store.fetchCustomers())
async function submit(): Promise<void> {
await store.createCustomer({ ...form })
showForm.value = false
form.name = ''
form.slug = ''
form.notes = ''
}
async function remove(id: number): Promise<void> {
if (confirm('Kunde inkl. aller Server und Jobs wirklich löschen?')) {
await store.deleteCustomer(id)
}
}
</script>
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Kunden</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' : 'Kunde 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">Slug (a-z, 0-9, -)</label>
<input v-model="form.slug" required pattern="[a-z0-9-]+" class="w-full rounded border-slate-300" />
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-sm font-medium">Notizen</label>
<input v-model="form.notes" 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">Slug</th>
<th class="px-4 py-2 text-left font-medium">Notizen</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="customer in store.customers" :key="customer.id">
<td class="px-4 py-2 font-medium">{{ customer.name }}</td>
<td class="px-4 py-2 font-mono text-xs">{{ customer.slug }}</td>
<td class="px-4 py-2">{{ customer.notes }}</td>
<td class="px-4 py-2 text-right">
<button
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click="remove(customer.id)"
>
Löschen
</button>
</td>
</tr>
<tr v-if="store.customers.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-slate-500">
Noch keine Kunden angelegt.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+102 -64
View File
@@ -1,93 +1,131 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useServersStore } from '@/stores/servers'
import { useSatellitesStore } from '@/stores/satellites'
import { useUpdatesStore } from '@/stores/updates'
import { apiClient } from '@/api/client'
const customersStore = useCustomersStore()
const serversStore = useServersStore()
const satellitesStore = useSatellitesStore()
const updatesStore = useUpdatesStore()
const stats = ref<{ total: number; running: number; failed: number }>({
const stats = ref<{ total: number; running: number; failed: number; pending: number }>({
total: 0,
running: 0,
failed: 0,
pending: 0,
})
const customerId = computed(() => customersStore.selectedId)
const healthyCount = computed(
() => serversStore.servers.filter((s) => s.last_health_ok === true).length,
)
const onlineSatellites = computed(
() =>
satellitesStore.satellites.filter(
(s) => s.last_seen_at && Date.now() - new Date(s.last_seen_at).getTime() < 3 * 60 * 1000,
).length,
)
onMounted(async () => {
await Promise.all([
serversStore.fetchServers(),
updatesStore.fetchJobs(),
apiClient.get('/api/updates/stats/summary').then(({ data }) => (stats.value = data)),
])
await customersStore.fetchCustomers()
await load()
})
watch(customerId, load)
async function load(): Promise<void> {
if (!customerId.value) return
await Promise.all([
serversStore.fetchServers(customerId.value),
satellitesStore.fetchSatellites(customerId.value),
updatesStore.fetchJobs(customerId.value),
apiClient
.get('/api/updates/stats/summary', { params: { customer_id: customerId.value } })
.then(({ data }) => (stats.value = data)),
])
}
const statusColors: Record<string, string> = {
success: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
running: 'bg-blue-100 text-blue-800',
claimed: 'bg-amber-100 text-amber-800',
pending: 'bg-slate-100 text-slate-800',
cancelled: 'bg-slate-100 text-slate-500',
}
</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 v-if="!customerId" class="rounded-lg bg-white p-8 text-center text-slate-500 shadow">
Noch kein Kunde angelegt - zuerst unter "Kunden" einen Kunden erstellen.
</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>
<template v-else>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Satelliten online</p>
<p class="mt-1 text-3xl font-bold" :class="onlineSatellites > 0 ? 'text-green-600' : 'text-red-600'">
{{ onlineSatellites }}/{{ satellitesStore.satellites.length }}
</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Server</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">Jobs wartend</p>
<p class="mt-1 text-3xl font-bold text-amber-600">{{ stats.pending }}</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">Typ</th>
<th class="px-4 py-2 text-left font-medium">Status</th>
<th class="px-4 py-2 text-left font-medium">Satellite</th>
<th class="px-4 py-2 text-left font-medium">Erstellt 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.type }}</td>
<td class="px-4 py-2">
<span class="rounded-full px-2 py-0.5 text-xs font-semibold" :class="statusColors[job.status]">
{{ job.status }}
</span>
</td>
<td class="px-4 py-2">{{ job.satellite_id || '-' }}</td>
<td class="px-4 py-2">{{ job.created_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>
</template>
</div>
</template>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useSatellitesStore } from '@/stores/satellites'
import type { SatelliteCreated } from '@/types'
const customersStore = useCustomersStore()
const store = useSatellitesStore()
const newName = ref('')
const newKey = ref<SatelliteCreated | null>(null)
const copied = ref(false)
const customerId = computed(() => customersStore.selectedId)
onMounted(async () => {
await customersStore.fetchCustomers()
if (customerId.value) await store.fetchSatellites(customerId.value)
})
watch(customerId, async (id) => {
if (id) await store.fetchSatellites(id)
})
async function create(): Promise<void> {
if (!customerId.value || !newName.value) return
newKey.value = await store.createSatellite(customerId.value, newName.value)
newName.value = ''
}
async function rotate(id: number): Promise<void> {
if (confirm('Neuen API-Key generieren? Der alte wird sofort ungültig.')) {
newKey.value = await store.rotateKey(id)
}
}
async function remove(id: number): Promise<void> {
if (confirm('Satellite wirklich löschen?')) {
await store.deleteSatellite(id)
}
}
async function copyKey(): Promise<void> {
if (!newKey.value) return
await navigator.clipboard.writeText(newKey.value.api_key)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
function isOnline(lastSeen: string | null): boolean {
if (!lastSeen) return false
return Date.now() - new Date(lastSeen).getTime() < 3 * 60 * 1000
}
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold">Satelliten</h1>
<div v-if="newKey" class="mb-6 rounded-lg border-2 border-amber-400 bg-amber-50 p-5">
<p class="mb-2 font-semibold text-amber-800">
API-Key für "{{ newKey.name }}" - wird nur einmal angezeigt!
</p>
<div class="flex items-center gap-2">
<code class="flex-1 overflow-x-auto rounded bg-white px-3 py-2 font-mono text-xs">{{ newKey.api_key }}</code>
<button class="rounded bg-slate-800 px-3 py-2 text-xs text-white" @click="copyKey">
{{ copied ? 'Kopiert!' : 'Kopieren' }}
</button>
<button class="rounded bg-slate-300 px-3 py-2 text-xs" @click="newKey = null">Schließen</button>
</div>
<p class="mt-2 text-xs text-amber-700">
In config.yaml auf dem Satellite eintragen: <code>api_key: "..."</code>
</p>
</div>
<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">Neuer Satellite (Kunde: im Header wählen)</label>
<input v-model="newName" placeholder="z.B. sat-hauptstandort" class="w-full rounded border-slate-300" />
</div>
<button
:disabled="!newName || !customerId"
class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-50"
@click="create"
>
Anlegen
</button>
</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">Status</th>
<th class="px-4 py-2 text-left font-medium">Version</th>
<th class="px-4 py-2 text-left font-medium">Hostname</th>
<th class="px-4 py-2 text-left font-medium">Key-Prefix</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="sat in store.satellites" :key="sat.id">
<td class="px-4 py-2 font-medium">{{ sat.name }}</td>
<td class="px-4 py-2">
<span v-if="isOnline(sat.last_seen_at)" class="text-green-600">Online</span>
<span v-else class="text-slate-400">Offline</span>
</td>
<td class="px-4 py-2">{{ sat.version || '-' }}</td>
<td class="px-4 py-2">{{ sat.hostname || '-' }}</td>
<td class="px-4 py-2 font-mono text-xs">{{ sat.api_key_prefix }}...</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="rotate(sat.id)"
>
Key rotieren
</button>
<button
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click="remove(sat.id)"
>
Löschen
</button>
</td>
</tr>
<tr v-if="store.satellites.length === 0">
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
Keine Satelliten für diesen Kunden.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+47 -11
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import type { ServerType } from '@/types'
const customersStore = useCustomersStore()
const store = useServersStore()
const updatesStore = useUpdatesStore()
const showForm = ref(false)
const form = reactive({
@@ -11,6 +15,7 @@ const form = reactive({
hostname: '',
port: 5985,
type: 'windows' as ServerType,
credential_ref: '',
description: '',
})
@@ -20,15 +25,31 @@ const typeLabels: Record<ServerType, string> = {
cau_cluster: 'CAU Cluster',
}
onMounted(() => store.fetchServers())
const customerId = computed(() => customersStore.selectedId)
onMounted(async () => {
await customersStore.fetchCustomers()
if (customerId.value) await store.fetchServers(customerId.value)
})
watch(customerId, async (id) => {
if (id) await store.fetchServers(id)
})
async function submit(): Promise<void> {
await store.createServer({ ...form })
if (!customerId.value) return
await store.createServer({
...form,
customer_id: customerId.value,
credential_ref: form.credential_ref || null,
description: form.description || null,
})
showForm.value = false
form.name = ''
form.hostname = ''
form.port = 5985
form.type = 'windows'
form.credential_ref = ''
form.description = ''
}
@@ -37,6 +58,12 @@ async function remove(id: number): Promise<void> {
await store.deleteServer(id)
}
}
async function healthCheck(id: number): Promise<void> {
if (!customerId.value) return
await updatesStore.triggerUpdate(customerId.value, id, 'health_check')
alert('Health-Check Job wurde in die Queue gelegt. Der Satellite führt ihn aus.')
}
</script>
<template>
@@ -58,7 +85,7 @@ async function remove(id: number): Promise<void> {
<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>
<label class="mb-1 block text-sm font-medium">Hostname / IP</label>
<input v-model="form.hostname" required class="w-full rounded border-slate-300" />
</div>
<div>
@@ -73,7 +100,11 @@ async function remove(id: number): Promise<void> {
</option>
</select>
</div>
<div class="sm:col-span-2">
<div>
<label class="mb-1 block text-sm font-medium">Credential-Ref (Name aus credentials.yaml)</label>
<input v-model="form.credential_ref" placeholder="z.B. winrm-admin" class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Beschreibung</label>
<input v-model="form.description" class="w-full rounded border-slate-300" />
</div>
@@ -92,24 +123,29 @@ async function remove(id: number): Promise<void> {
<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">Credential</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 font-medium">
{{ server.name }}
<span v-if="server.discovered_by_scan" class="ml-1 rounded bg-blue-100 px-1 text-xs text-blue-700">Scan</span>
</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">
<td class="px-4 py-2 font-mono text-xs">{{ server.credential_ref || '-' }}</td>
<td class="px-4 py-2" :title="server.last_health_message || ''">
<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>
<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)"
@click="healthCheck(server.id)"
>
Health-Check
</button>
@@ -122,8 +158,8 @@ async function remove(id: number): Promise<void> {
</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 colspan="6" class="px-4 py-6 text-center text-slate-500">
Keine Server für diesen Kunden. Manuell anlegen oder Netzwerk-Scan starten.
</td>
</tr>
</tbody>
+122 -30
View File
@@ -1,14 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import type { JobType } from '@/types'
const customersStore = useCustomersStore()
const serversStore = useServersStore()
const updatesStore = useUpdatesStore()
const selectedServerId = ref<number | null>(null)
const selectedJobId = ref<number | null>(null)
const rebootIfRequired = ref(false)
const scanSubnet = ref('')
const customerId = computed(() => customersStore.selectedId)
const jobTypeForServer = computed<JobType>(() => {
const server = serversStore.servers.find((s) => s.id === selectedServerId.value)
@@ -19,23 +25,76 @@ const jobTypeForServer = computed<JobType>(() => {
})
const selectedLogs = computed(() =>
selectedJobId.value ? updatesStore.liveLogs[selectedJobId.value] || [] : [],
selectedJobId.value ? updatesStore.logs[selectedJobId.value] || [] : [],
)
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await Promise.all([serversStore.fetchServers(), updatesStore.fetchJobs()])
await customersStore.fetchCustomers()
if (customerId.value) {
await Promise.all([
serversStore.fetchServers(customerId.value),
updatesStore.fetchJobs(customerId.value),
])
}
refreshTimer = setInterval(refresh, 10000)
})
async function trigger(): Promise<void> {
if (!selectedServerId.value) return
const job = await updatesStore.triggerUpdate(selectedServerId.value, jobTypeForServer.value)
watchJob(job.id)
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
watch(customerId, async (id) => {
if (id) {
selectedJobId.value = null
await Promise.all([serversStore.fetchServers(id), updatesStore.fetchJobs(id)])
}
})
async function refresh(): Promise<void> {
if (!customerId.value) return
await updatesStore.fetchJobs(customerId.value)
if (selectedJobId.value) await updatesStore.fetchLogs(selectedJobId.value)
}
async function watchJob(jobId: number): Promise<void> {
async function trigger(): Promise<void> {
if (!selectedServerId.value || !customerId.value) return
const job = await updatesStore.triggerUpdate(
customerId.value,
selectedServerId.value,
jobTypeForServer.value,
rebootIfRequired.value,
)
await viewJob(job.id)
}
async function triggerAll(): Promise<void> {
if (!customerId.value) return
if (!confirm('Update-Jobs für ALLE Server dieses Kunden anlegen?')) return
const created = await updatesStore.triggerBatch(customerId.value, 'windows_update')
alert(`${created} Jobs angelegt - der Satellite arbeitet sie der Reihe nach ab.`)
await refresh()
}
async function startScan(): Promise<void> {
if (!customerId.value || !scanSubnet.value) return
const job = await updatesStore.triggerScan(customerId.value, scanSubnet.value)
await viewJob(job.id)
}
async function viewJob(jobId: number): Promise<void> {
selectedJobId.value = jobId
await updatesStore.fetchLogs(jobId)
updatesStore.subscribeJob(jobId)
}
const statusColors: Record<string, string> = {
success: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
running: 'bg-blue-100 text-blue-800',
claimed: 'bg-amber-100 text-amber-800',
pending: 'bg-slate-100 text-slate-800',
cancelled: 'bg-slate-100 text-slate-500',
}
</script>
@@ -43,28 +102,54 @@ async function watchJob(jobId: number): Promise<void> {
<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>
<div class="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg bg-white p-5 shadow">
<h2 class="mb-3 font-semibold">Einzelnes Update</h2>
<label class="mb-1 block text-sm font-medium">Server</label>
<select v-model="selectedServerId" class="mb-3 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>
<label class="mb-3 flex items-center gap-2 text-sm">
<input v-model="rebootIfRequired" type="checkbox" class="rounded" />
Reboot falls erforderlich
</label>
<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 einreihen
</button>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<h2 class="mb-3 font-semibold">Netz & Batch</h2>
<label class="mb-1 block text-sm font-medium">Netzwerk-Scan (Subnetz)</label>
<div class="mb-3 flex gap-2">
<input v-model="scanSubnet" placeholder="192.168.1.0/24" class="flex-1 rounded border-slate-300" />
<button
:disabled="!scanSubnet"
class="rounded bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
@click="startScan"
>
Scan starten
</button>
</div>
<button
class="rounded bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-500"
@click="triggerAll"
>
Alle Windows-Server updaten
</button>
</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>
<h2 class="border-b bg-slate-50 px-4 py-2 font-semibold">Jobs (auto-refresh 10s)</h2>
<table class="min-w-full divide-y divide-slate-200 text-sm">
<tbody class="divide-y divide-slate-100">
<tr
@@ -72,23 +157,28 @@ async function watchJob(jobId: number): Promise<void> {
:key="job.id"
class="cursor-pointer hover:bg-slate-50"
:class="{ 'bg-blue-50': job.id === selectedJobId }"
@click="watchJob(job.id)"
@click="viewJob(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">
<span class="rounded-full px-2 py-0.5 text-xs font-semibold" :class="statusColors[job.status]">
{{ job.status }}
</span>
</td>
<td class="px-4 py-2 text-xs text-slate-500">{{ job.progress_percent }}%</td>
<td class="px-4 py-2 text-right">
<button
v-if="job.status === 'running' || job.status === 'pending'"
v-if="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
Stornieren
</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>
<td colspan="5" class="px-4 py-6 text-center text-slate-500">Keine Jobs.</td>
</tr>
</tbody>
</table>
@@ -96,13 +186,15 @@ async function watchJob(jobId: number): Promise<void> {
<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})` : '' }}
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.
Kein Job ausgewählt - klicke links einen Job an.
</p>
<p v-for="log in selectedLogs" :key="log.id" :class="{ 'text-red-400': log.level === 'error' }">
{{ log.line }}
</p>
<p v-for="log in selectedLogs" :key="log.id">{{ log.line }}</p>
</div>
</div>
</div>
+64
View File
@@ -0,0 +1,64 @@
# Insight Updater Satellite
Remote-Agent fuer Kundennetzwerke. Laedt Jobs von der zentralen Insight-Updater-Instanz,
fuehrt sie lokal im Kundennetz aus (WinRM / SSH / CAU / Netzwerk-Scan) und meldet
Logs und Ergebnisse zurueck. Kein Docker noetig - eine einzelne Binary genuegt.
## Prinzip
- Nur ausgehende HTTPS-Verbindungen zur Zentrale (keine Firewall-Loecher beim Kunden)
- Pull-Modell: der Satellite pollt alle N Sekunden nach Jobs
- Credentials (WinRM/SSH) liegen ausschliesslich lokal in `credentials.yaml`
- 1-2 Satelliten pro Kunde reichen - sie steuern das ganze Netz (wie CAU im Cluster)
## Setup (Development)
```bash
cd satellite
pip install -e .
cp config.example.yaml config.yaml
cp credentials.example.yaml credentials.yaml
# config.yaml: central_url + api_key eintragen (Key aus dem Dashboard)
# credentials.yaml: WinRM-/SSH-Zugangsdaten des Kundennetzes pflegen
insight-satellite --config config.yaml --credentials credentials.yaml
```
## Windows-Binary bauen
```bash
pip install -e ".[build]"
pyinstaller --onefile --name insight-satellite satellite/runner.py
# Ergebnis: dist/insight-satellite.exe
```
Auf dem Zielsystem (Windows-Server beim Kunden):
```
C:\insight-satellite\
insight-satellite.exe
config.yaml
credentials.yaml
```
Start als Scheduled Task (Beispiel, ohne Umlaute):
```powershell
schtasks /create /tn "InsightUpdaterSatellite" /tr "C:\insight-satellite\insight-satellite.exe --config C:\insight-satellite\config.yaml --credentials C:\insight-satellite\credentials.yaml" /sc onstart /ru SYSTEM /rl HIGHEST
```
## Job-Typen
| Typ | Aktion |
|---|---|
| `windows_update` | Windows Update via WinRM (COM Microsoft.Update.Session) |
| `linux_update` | apt/dnf/yum upgrade via SSH mit sudo |
| `cau_run` | Invoke-CauRun auf einem Failover-Cluster |
| `health_check` | Verbindungstest, Ergebnis geht an die Zentrale |
| `network_scan` | Ping-Sweep + Port-Probe (5985/22), legt gefundene Hosts zentral an |
## Ablauf pro Job
1. `GET /api/satellite/poll` - Jobs abholen (werden dabei claimed)
2. Lokal ausfuehren, Log-Zeilen sammeln
3. `POST /api/satellite/logs` - Batches waehrend der Ausfuehrung
4. `POST /api/satellite/result` - Abschluss (success/failed + Fehlertext)
+19
View File
@@ -0,0 +1,19 @@
# Insight Updater Satellite
# Zentrale Verbindung
central_url: https://updater.insight-it.de
api_key: "ius_..." # API-Key aus dem Dashboard (Satellite anlegen)
# Verhalten
poll_interval: 30 # Sekunden zwischen Job-Polls
heartbeat_interval: 60 # Sekunden zwischen Heartbeats
log_batch_size: 50 # Log-Zeilen pro Upload
log_flush_interval: 10 # Sekunden, nach denen Log-Puffer geflusht wird
# WinRM-Defaults (koennen pro Credential ueberschrieben werden)
winrm_transport: ntlm # ntlm | kerberos | credssp
winrm_cert_validation: ignore
# Netzwerk-Scan Defaults
scan_default_subnet: "" # z.B. 192.168.1.0/24 - leer = Job-Parameter pflicht
scan_ping_timeout_ms: 500
scan_port_timeout_ms: 1500
+20
View File
@@ -0,0 +1,20 @@
# Lokale Credentials - NUR auf dem Satellite, niemals zentral!
# Server in der Zentrale referenzieren diese Namen ueber "credential_ref".
#
# Beispiele:
winrm-admin:
type: winrm
username: "DOMAIN\\svc_update"
password: "geheim"
# transport: ntlm # optional, ueberschreibt config.yaml
linux-root:
type: ssh
username: "root"
password: "geheim"
linux-key:
type: ssh
username: "update"
private_key_path: "/etc/insight-satellite/id_ed25519"
passphrase: ""
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "insight-updater-satellite"
version = "0.2.0"
description = "Insight Updater Satellite - remote update agent for customer networks"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.26.0",
"pywinrm>=0.4.3",
"asyncssh>=2.14.0",
"pyyaml>=6.0.1",
"structlog>=24.1.0",
"tenacity>=8.2.0",
]
[project.optional-dependencies]
kerberos = ["pywinrm[kerberos]>=0.4.3"]
build = ["pyinstaller>=6.0.0"]
[project.scripts]
insight-satellite = "satellite.runner:main"
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["satellite*"]
[tool.ruff]
target-version = "py311"
line-length = 100
+3
View File
@@ -0,0 +1,3 @@
"""Insight Updater Satellite - remote agent for customer networks."""
__version__ = "0.2.0"
+29
View File
@@ -0,0 +1,29 @@
"""CAU executor: Cluster-Aware Updating via PowerShell remoting."""
from collections.abc import AsyncIterator
from satellite.config import Config, Credential
from satellite.winrm_exec import Target, WinRMExecutor
class CAUError(Exception):
pass
class CAUExecutor:
def __init__(self, config: Config, cluster_name: str, port: int, credential: Credential) -> None:
self.cluster_name = cluster_name
self.winrm = WinRMExecutor(config, Target(cluster_name, port), credential)
async def invoke_cau_run(self) -> AsyncIterator[str]:
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:
raise CAUError(f"CAU-Lauf fehlgeschlagen: {exc}") from exc
+79
View File
@@ -0,0 +1,79 @@
"""HTTP client for the central Insight Updater API."""
from datetime import UTC, datetime
from typing import Any
import httpx
from satellite.config import Config
class CentralClient:
def __init__(self, config: Config, version: str) -> None:
self.config = config
self.version = version
self._client = httpx.AsyncClient(
base_url=config.central_url,
headers={"X-Api-Key": config.api_key},
timeout=httpx.Timeout(30.0),
)
async def close(self) -> None:
await self._client.aclose()
async def heartbeat(self, hostname: str) -> None:
r = await self._client.post(
"/api/satellite/heartbeat",
json={"version": self.version, "hostname": hostname},
)
r.raise_for_status()
async def poll(self) -> list[dict[str, Any]]:
r = await self._client.get("/api/satellite/poll")
r.raise_for_status()
return list(r.json().get("jobs", []))
async def push_logs(
self,
job_id: int,
lines: list[tuple[str, str]], # (level, line)
progress_percent: int | None = None,
current_phase: str | None = None,
) -> None:
payload: dict[str, Any] = {
"job_id": job_id,
"lines": [
{"timestamp": datetime.now(UTC).isoformat(), "level": lvl, "line": ln}
for lvl, ln in lines
],
}
if progress_percent is not None:
payload["progress_percent"] = progress_percent
if current_phase is not None:
payload["current_phase"] = current_phase
r = await self._client.post("/api/satellite/logs", json=payload)
r.raise_for_status()
async def push_result(self, job_id: int, success: bool, error: str | None = None) -> None:
r = await self._client.post(
"/api/satellite/result",
json={
"job_id": job_id,
"status": "success" if success else "failed",
"error": error,
},
)
r.raise_for_status()
async def push_scan_result(self, job_id: int, hosts: list[dict[str, Any]]) -> None:
r = await self._client.post(
"/api/satellite/scan-result", json={"job_id": job_id, "hosts": hosts}
)
r.raise_for_status()
async def push_health_report(self, server_id: int, ok: bool, message: str) -> None:
r = await self._client.post(
"/api/satellite/health-report",
json={"server_id": server_id, "ok": ok, "message": message},
)
r.raise_for_status()
+67
View File
@@ -0,0 +1,67 @@
"""Satellite configuration: config.yaml + credentials.yaml loading."""
from dataclasses import dataclass, field
from pathlib import Path
import yaml
@dataclass
class Credential:
name: str
type: str # "winrm" | "ssh"
username: str
password: str | None = None
private_key_path: str | None = None
passphrase: str | None = None
transport: str | None = None
@dataclass
class Config:
central_url: str
api_key: str
poll_interval: int = 30
heartbeat_interval: int = 60
log_batch_size: int = 50
log_flush_interval: int = 10
winrm_transport: str = "ntlm"
winrm_cert_validation: str = "ignore"
scan_default_subnet: str = ""
scan_ping_timeout_ms: int = 500
scan_port_timeout_ms: int = 1500
credentials: dict[str, Credential] = field(default_factory=dict)
def credential(self, ref: str | None) -> Credential | None:
if not ref:
return None
cred = self.credentials.get(ref)
if cred is None:
raise KeyError(f"Credential '{ref}' nicht in credentials.yaml gefunden")
return cred
def load_config(config_path: str = "config.yaml", credentials_path: str = "credentials.yaml") -> Config:
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) or {}
creds: dict[str, Credential] = {}
cred_file = Path(credentials_path)
if cred_file.exists():
raw_creds = yaml.safe_load(cred_file.read_text(encoding="utf-8")) or {}
for name, c in raw_creds.items():
creds[name] = Credential(name=name, **c)
return Config(
central_url=raw["central_url"].rstrip("/"),
api_key=raw["api_key"],
poll_interval=int(raw.get("poll_interval", 30)),
heartbeat_interval=int(raw.get("heartbeat_interval", 60)),
log_batch_size=int(raw.get("log_batch_size", 50)),
log_flush_interval=int(raw.get("log_flush_interval", 10)),
winrm_transport=raw.get("winrm_transport", "ntlm"),
winrm_cert_validation=raw.get("winrm_cert_validation", "ignore"),
scan_default_subnet=raw.get("scan_default_subnet", ""),
scan_ping_timeout_ms=int(raw.get("scan_ping_timeout_ms", 500)),
scan_port_timeout_ms=int(raw.get("scan_port_timeout_ms", 1500)),
credentials=creds,
)
+205
View File
@@ -0,0 +1,205 @@
"""Satellite runner: main loop - heartbeat, poll, execute, report."""
import argparse
import asyncio
import socket
import sys
import time
from typing import Any
import structlog
from satellite import __version__
from satellite.cau_exec import CAUExecutor
from satellite.client import CentralClient
from satellite.config import Config, load_config
from satellite.scanner import scan_subnet
from satellite.ssh_exec import SSHExecutor
from satellite.ssh_exec import Target as SSHTarget
from satellite.winrm_exec import Target as WinRMTarget
from satellite.winrm_exec import WinRMExecutor
logger = structlog.get_logger(__name__)
class LogBuffer:
"""Collects log lines and flushes them in batches to the central."""
def __init__(self, client: CentralClient, config: Config, job_id: int) -> None:
self.client = client
self.config = config
self.job_id = job_id
self.lines: list[tuple[str, str]] = []
self.last_flush = time.monotonic()
async def add(self, line: str, level: str = "info") -> None:
self.lines.append((level, line))
logger.info("job.log", job_id=self.job_id, line=line)
if (
len(self.lines) >= self.config.log_batch_size
or time.monotonic() - self.last_flush >= self.config.log_flush_interval
):
await self.flush()
async def flush(self, progress: int | None = None, phase: str | None = None) -> None:
if not self.lines and progress is None:
return
try:
await self.client.push_logs(
self.job_id, self.lines, progress_percent=progress, current_phase=phase
)
except Exception as exc: # noqa: BLE001 - nicht wegen Log-Upload abbrechen
logger.warning("log_upload_failed", error=str(exc))
self.lines = []
self.last_flush = time.monotonic()
async def execute_job(client: CentralClient, config: Config, job: dict[str, Any]) -> None:
job_id: int = job["job_id"]
buf = LogBuffer(client, config, job_id)
await buf.add(f"Job #{job_id} gestartet: {job['type']}")
try:
if job["type"] == "network_scan":
await _run_scan(client, config, job, buf)
elif job["type"] == "health_check":
await _run_health_check(client, config, job, buf)
elif job["type"] == "linux_update":
await _run_linux_update(config, job, buf)
elif job["type"] == "windows_update":
await _run_windows_update(config, job, buf)
elif job["type"] == "cau_run":
await _run_cau(config, job, buf)
else:
raise ValueError(f"Unbekannter Job-Typ: {job['type']}")
await buf.flush(progress=100)
await client.push_result(job_id, success=True)
logger.info("job.done", job_id=job_id)
except Exception as exc: # noqa: BLE001
await buf.add(f"FEHLER: {exc}", level="error")
await buf.flush()
await client.push_result(job_id, success=False, error=str(exc))
logger.error("job.failed", job_id=job_id, error=str(exc))
async def _run_scan(client: CentralClient, config: Config, job: dict, buf: LogBuffer) -> None:
subnet = job.get("scan_subnet") or config.scan_default_subnet
if not subnet:
raise ValueError("Kein Subnetz angegeben (scan_subnet oder scan_default_subnet)")
await buf.add(f"Scanne Subnetz {subnet} ...")
hosts = await scan_subnet(subnet, config.scan_ping_timeout_ms, config.scan_port_timeout_ms)
await buf.add(f"{len(hosts)} verwaltbare Hosts gefunden")
await buf.flush(progress=90)
await client.push_scan_result(job["job_id"], hosts)
async def _run_health_check(client: CentralClient, config: Config, job: dict, buf: LogBuffer) -> None:
ok, message = await _test_connection(config, job)
await buf.add(message, level="info" if ok else "error")
server_id = job.get("server_id")
if server_id:
await client.push_health_report(server_id, ok, message)
if not ok:
raise ConnectionError(message)
async def _run_linux_update(config: Config, job: dict, buf: LogBuffer) -> None:
cred = config.credential(job.get("credential_ref"))
if not cred or cred.type != "ssh":
raise ValueError("SSH-Credential erforderlich")
executor = SSHExecutor(SSHTarget(job["hostname"], job.get("port") or 22), cred)
async for line in executor.stream_updates(job.get("reboot_if_required", False)):
await buf.add(line)
async def _run_windows_update(config: Config, job: dict, buf: LogBuffer) -> None:
cred = config.credential(job.get("credential_ref"))
if not cred or cred.type != "winrm":
raise ValueError("WinRM-Credential erforderlich")
executor = WinRMExecutor(config, WinRMTarget(job["hostname"], job.get("port") or 5985), cred)
async for line in executor.install_updates(job.get("reboot_if_required", False)):
await buf.add(line)
async def _run_cau(config: Config, job: dict, buf: LogBuffer) -> None:
cred = config.credential(job.get("credential_ref"))
if not cred or cred.type != "winrm":
raise ValueError("WinRM-Credential erforderlich")
executor = CAUExecutor(config, job["hostname"], job.get("port") or 5985, cred)
async for line in executor.invoke_cau_run():
await buf.add(line)
async def _test_connection(config: Config, job: dict) -> tuple[bool, str]:
cred = config.credential(job.get("credential_ref"))
if cred is None:
return False, "Kein Credential referenziert"
if cred.type == "ssh":
executor = SSHExecutor(SSHTarget(job["hostname"], job.get("port") or 22), cred)
return await executor.test_connection()
executor = WinRMExecutor(config, WinRMTarget(job["hostname"], job.get("port") or 5985), cred)
return await executor.test_connection()
async def run(config_path: str, credentials_path: str) -> None:
config = load_config(config_path, credentials_path)
client = CentralClient(config, __version__)
hostname = socket.gethostname()
last_heartbeat = 0.0
logger.info(
"satellite.starting",
version=__version__,
central=config.central_url,
credentials=len(config.credentials),
)
try:
while True:
now = time.monotonic()
if now - last_heartbeat >= config.heartbeat_interval:
try:
await client.heartbeat(hostname)
last_heartbeat = now
except Exception as exc: # noqa: BLE001
logger.warning("heartbeat_failed", error=str(exc))
try:
jobs = await client.poll()
except Exception as exc: # noqa: BLE001
logger.warning("poll_failed", error=str(exc))
await asyncio.sleep(config.poll_interval)
continue
for job in jobs:
await execute_job(client, config, job)
if not jobs:
await asyncio.sleep(config.poll_interval)
finally:
await client.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Insight Updater Satellite")
parser.add_argument("--config", default="config.yaml")
parser.add_argument("--credentials", default="credentials.yaml")
args = parser.parse_args()
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.dev.ConsoleRenderer(),
]
)
try:
asyncio.run(run(args.config, args.credentials))
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
"""Network scanner: ping sweep + WinRM/SSH port probe for auto-discovery."""
import asyncio
import ipaddress
import shutil
async def _ping(ip: str, timeout_ms: int) -> bool:
if not shutil.which("ping"):
return True # kein ping verfuegbar -> trotzdem Port-Check versuchen
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", str(max(1, timeout_ms // 1000)), ip,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return proc.returncode == 0
async def _port_open(ip: str, port: int, timeout_ms: int) -> bool:
try:
_reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port), timeout=timeout_ms / 1000
)
writer.close()
await writer.wait_closed()
return True
except (TimeoutError, OSError):
return False
async def _resolve(ip: str) -> str:
def _do() -> str:
import socket
try:
return socket.gethostbyaddr(ip)[0]
except OSError:
return ip
return await asyncio.to_thread(_do)
async def scan_subnet(subnet: str, ping_timeout_ms: int, port_timeout_ms: int) -> list[dict]:
"""Scan a subnet; return hosts with winrm_open / ssh_open flags."""
network = ipaddress.ip_network(subnet, strict=False)
hosts: list[dict] = []
sem = asyncio.Semaphore(64)
async def probe(ip: str) -> None:
async with sem:
if not await _ping(ip, ping_timeout_ms):
return
winrm, ssh = await asyncio.gather(
_port_open(ip, 5985, port_timeout_ms),
_port_open(ip, 22, port_timeout_ms),
)
if not winrm and not ssh:
return
hostname = await _resolve(ip)
hosts.append(
{
"hostname": hostname,
"ip": ip,
"os_guess": "windows" if winrm else ("linux" if ssh else None),
"winrm_open": winrm,
"ssh_open": ssh,
}
)
await asyncio.gather(*[probe(str(ip)) for ip in network.hosts()])
return sorted(hosts, key=lambda h: h["ip"])
+84
View File
@@ -0,0 +1,84 @@
"""SSH executor: run updates on Linux targets via asyncssh."""
from collections.abc import AsyncIterator
from dataclasses import dataclass
from satellite.config import Credential
class SSHError(Exception):
pass
@dataclass
class Target:
hostname: str
port: int = 22
class SSHExecutor:
def __init__(self, target: Target, credential: Credential) -> None:
self.target = target
self.credential = credential
def _connect_kwargs(self) -> dict:
kwargs: dict = {
"host": self.target.hostname,
"port": self.target.port,
"known_hosts": None,
"connect_timeout": 30,
"username": self.credential.username,
}
if self.credential.password:
kwargs["password"] = self.credential.password
if self.credential.private_key_path:
kwargs["client_keys"] = [self.credential.private_key_path]
if self.credential.passphrase:
kwargs["passphrase"] = self.credential.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 {str(result.stdout).strip()}"
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def stream_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
import asyncssh
async with asyncssh.connect(**self._connect_kwargs()) as conn:
pm = None
for candidate in ("apt-get", "dnf", "yum"):
result = await conn.run(f"command -v {candidate}", check=False)
if result.returncode == 0:
pm = candidate
break
if not pm:
raise SSHError("Kein unterstuetzter Paketmanager gefunden (apt/dnf/yum)")
yield f"Paketmanager: {pm}"
if pm == "apt-get":
cmd = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
else:
cmd = f"{pm} update -y"
password = self.credential.password or ""
full_cmd = f"echo '{password}' | sudo -S sh -c '{cmd}'"
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 ausgefuehrt..."
await conn.run(f"echo '{password}' | sudo -S reboot", check=False)
+96
View File
@@ -0,0 +1,96 @@
"""WinRM executor: run PowerShell on Windows targets via pywinrm."""
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from satellite.config import Config, Credential
class WinRMError(Exception):
pass
@dataclass
class Target:
hostname: str
port: int = 5985
class WinRMExecutor:
def __init__(self, config: Config, target: Target, credential: Credential) -> None:
self.config = config
self.target = target
self.credential = credential
def _build_session(self): # type: ignore[no-untyped-def]
import winrm # pywinrm
scheme = "https" if self.target.port == 5986 else "http"
endpoint = f"{scheme}://{self.target.hostname}:{self.target.port}/wsman"
return winrm.Session(
endpoint,
auth=(self.credential.username, self.credential.password),
transport=self.credential.transport or self.config.winrm_transport,
server_cert_validation=self.config.winrm_cert_validation,
operation_timeout_sec=60,
read_timeout_sec=3600, # Windows Update kann lange laufen
)
async def run_powershell(self, script: str) -> str:
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]:
output = await self.run_powershell(script)
for line in output.splitlines():
yield line
await asyncio.sleep(0)
async def test_connection(self) -> tuple[bool, str]:
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
return False, str(exc)
return await asyncio.to_thread(_run)
async def install_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
reboot_block = (
"if ($installResult.RebootRequired) { Write-Output 'REBOOT erforderlich - wird ausgefuehrt'; Restart-Computer -Force }"
if reboot_if_required
else "Write-Output \"RebootRequired: $($installResult.RebootRequired)\""
)
script = f"""
$session = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0")
Write-Output "Gefundene Updates: $($result.Updates.Count)"
if ($result.Updates.Count -gt 0) {{
$toInstall = New-Object -ComObject Microsoft.Update.UpdateColl
$result.Updates | ForEach-Object {{
Write-Output " - $($_.Title)"
$toInstall.Add($_) | Out-Null
}}
$installer = $session.CreateUpdateInstaller()
$installer.Updates = $toInstall
$installResult = $installer.Install()
Write-Output "ResultCode: $($installResult.ResultCode)"
{reboot_block}
}}
"""
async for line in self.stream_powershell(script):
yield line