From cc4c3fcecb5e3ed7be469aba90c8ca40895c5ed6 Mon Sep 17 00:00:00 2001 From: B0rbor4d Date: Fri, 7 Aug 2026 03:42:06 +0000 Subject: [PATCH] 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 --- .env.example | 37 ++-- .gitignore | 4 + AGENTS.md | 209 ++++++++++------------ PROMPT.md | 188 ++++++++----------- README.md | 207 ++++++++------------- backend/app/api/__init__.py | 5 +- backend/app/api/deps.py | 24 ++- backend/app/api/routes/audit.py | 4 + backend/app/api/routes/customers.py | 105 +++++++++++ backend/app/api/routes/satellite_api.py | 228 ++++++++++++++++++++++++ backend/app/api/routes/satellites.py | 111 ++++++++++++ backend/app/api/routes/servers.py | 96 +++------- backend/app/api/routes/updates.py | 147 +++++++++++---- backend/app/core/config.py | 16 +- backend/app/core/exceptions.py | 5 + backend/app/core/security.py | 54 +----- backend/app/main.py | 37 ++-- backend/app/models/__init__.py | 6 +- backend/app/models/audit_log.py | 7 +- backend/app/models/credential.py | 39 ---- backend/app/models/customer.py | 36 ++++ backend/app/models/satellite.py | 52 ++++++ backend/app/models/server.py | 33 +++- backend/app/models/update_job.py | 40 ++++- backend/app/schemas/__init__.py | 19 +- backend/app/schemas/audit.py | 1 + backend/app/schemas/customer.py | 27 +++ backend/app/schemas/satellite.py | 30 ++++ backend/app/schemas/satellite_api.py | 71 ++++++++ backend/app/schemas/server.py | 18 +- backend/app/schemas/update.py | 30 +++- backend/app/services/audit.py | 3 + backend/app/services/cau.py | 77 -------- backend/app/services/janitor.py | 47 +++++ backend/app/services/job_runner.py | 138 -------------- backend/app/services/ssh.py | 118 ------------ backend/app/services/winrm.py | 122 ------------- backend/app/websocket/__init__.py | 6 - backend/app/websocket/handlers.py | 103 ----------- backend/app/websocket/manager.py | 43 ----- backend/pyproject.toml | 5 - docker-compose.internal.yml | 8 + docker-compose.prod.yml | 27 --- docker-compose.yml | 53 +----- frontend/package-lock.json | 66 +------ frontend/package.json | 1 - frontend/src/api/socket.ts | 22 --- frontend/src/components/AppLayout.vue | 38 +++- frontend/src/router/index.ts | 10 ++ frontend/src/stores/auth.ts | 2 - frontend/src/stores/customers.ts | 43 +++++ frontend/src/stores/satellites.ts | 41 +++++ frontend/src/stores/servers.ts | 16 +- frontend/src/stores/updates.ts | 101 +++++------ frontend/src/types.ts | 50 ++++-- frontend/src/views/CustomersView.vue | 93 ++++++++++ frontend/src/views/DashboardView.vue | 166 ++++++++++------- frontend/src/views/SatellitesView.vue | 136 ++++++++++++++ frontend/src/views/ServersView.vue | 58 ++++-- frontend/src/views/UpdatesView.vue | 152 ++++++++++++---- satellite/README.md | 64 +++++++ satellite/config.example.yaml | 19 ++ satellite/credentials.example.yaml | 20 +++ satellite/pyproject.toml | 32 ++++ satellite/satellite/__init__.py | 3 + satellite/satellite/cau_exec.py | 29 +++ satellite/satellite/client.py | 79 ++++++++ satellite/satellite/config.py | 67 +++++++ satellite/satellite/runner.py | 205 +++++++++++++++++++++ satellite/satellite/scanner.py | 72 ++++++++ satellite/satellite/ssh_exec.py | 84 +++++++++ satellite/satellite/winrm_exec.py | 96 ++++++++++ 72 files changed, 2759 insertions(+), 1642 deletions(-) create mode 100644 backend/app/api/routes/customers.py create mode 100644 backend/app/api/routes/satellite_api.py create mode 100644 backend/app/api/routes/satellites.py delete mode 100644 backend/app/models/credential.py create mode 100644 backend/app/models/customer.py create mode 100644 backend/app/models/satellite.py create mode 100644 backend/app/schemas/customer.py create mode 100644 backend/app/schemas/satellite.py create mode 100644 backend/app/schemas/satellite_api.py delete mode 100644 backend/app/services/cau.py create mode 100644 backend/app/services/janitor.py delete mode 100644 backend/app/services/job_runner.py delete mode 100644 backend/app/services/ssh.py delete mode 100644 backend/app/services/winrm.py delete mode 100644 backend/app/websocket/__init__.py delete mode 100644 backend/app/websocket/handlers.py delete mode 100644 backend/app/websocket/manager.py create mode 100644 docker-compose.internal.yml delete mode 100644 frontend/src/api/socket.ts create mode 100644 frontend/src/stores/customers.ts create mode 100644 frontend/src/stores/satellites.ts create mode 100644 frontend/src/views/CustomersView.vue create mode 100644 frontend/src/views/SatellitesView.vue create mode 100644 satellite/README.md create mode 100644 satellite/config.example.yaml create mode 100644 satellite/credentials.example.yaml create mode 100644 satellite/pyproject.toml create mode 100644 satellite/satellite/__init__.py create mode 100644 satellite/satellite/cau_exec.py create mode 100644 satellite/satellite/client.py create mode 100644 satellite/satellite/config.py create mode 100644 satellite/satellite/runner.py create mode 100644 satellite/satellite/scanner.py create mode 100644 satellite/satellite/ssh_exec.py create mode 100644 satellite/satellite/winrm_exec.py diff --git a/.env.example b/.env.example index 5edeb27..158353a 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +GITEA_REPO=b0rbor4d/insight-updater diff --git a/.gitignore b/.gitignore index 32e2a54..94d548f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 132b9a5..384eecf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 \ No newline at end of file +**Wichtig**: Zentrale muss von Kundenstandorten aus per HTTPS (443) erreichbar sein. diff --git a/PROMPT.md b/PROMPT.md index c678b1f..11c67da 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -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 -``` \ No newline at end of file +## 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. diff --git a/README.md b/README.md index 2b58b5c..2162977 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +MIT - Insight IT diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 5fd052a..e1276e3 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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"]) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 7df7a81..9f05e7a 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/routes/audit.py b/backend/app/api/routes/audit.py index 4ace058..2bfe5c8 100644 --- a/backend/app/api/routes/audit.py +++ b/backend/app/api/routes/audit.py @@ -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) diff --git a/backend/app/api/routes/customers.py b/backend/app/api/routes/customers.py new file mode 100644 index 0000000..ac1c8bf --- /dev/null +++ b/backend/app/api/routes/customers.py @@ -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) diff --git a/backend/app/api/routes/satellite_api.py b/backend/app/api/routes/satellite_api.py new file mode 100644 index 0000000..b1d628e --- /dev/null +++ b/backend/app/api/routes/satellite_api.py @@ -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 diff --git a/backend/app/api/routes/satellites.py b/backend/app/api/routes/satellites.py new file mode 100644 index 0000000..c40fbe7 --- /dev/null +++ b/backend/app/api/routes/satellites.py @@ -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) diff --git a/backend/app/api/routes/servers.py b/backend/app/api/routes/servers.py index 85d4223..6d7f5ac 100644 --- a/backend/app/api/routes/servers.py +++ b/backend/app/api/routes/servers.py @@ -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, - ) diff --git a/backend/app/api/routes/updates.py b/backend/app/api/routes/updates.py index 1cec5a0..ee8422d 100644 --- a/backend/app/api/routes/updates.py +++ b/backend/app/api/routes/updates.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 20d0da7..5b29cf7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index 6b90047..47916c3 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -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" diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 2d9dac5..b5ad799 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 0354d1d..e05fea5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 62fb779..87f9251 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index e513b0d..5d367d6 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -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) diff --git a/backend/app/models/credential.py b/backend/app/models/credential.py deleted file mode 100644 index 615ee39..0000000 --- a/backend/app/models/credential.py +++ /dev/null @@ -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), - ) diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..cad2ca5 --- /dev/null +++ b/backend/app/models/customer.py @@ -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" + ) diff --git a/backend/app/models/satellite.py b/backend/app/models/satellite.py new file mode 100644 index 0000000..73cc02f --- /dev/null +++ b/backend/app/models/satellite.py @@ -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" + ) diff --git a/backend/app/models/server.py b/backend/app/models/server.py index 79eb37a..929b0d2 100644 --- a/backend/app/models/server.py +++ b/backend/app/models/server.py @@ -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" ) diff --git a/backend/app/models/update_job.py b/backend/app/models/update_job.py index 10ec0e2..a7faabe 100644 --- a/backend/app/models/update_job.py +++ b/backend/app/models/update_job.py @@ -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( diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 93c914b..00969af 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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.""" diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py index 59f3b76..13a8073 100644 --- a/backend/app/schemas/audit.py +++ b/backend/app/schemas/audit.py @@ -10,6 +10,7 @@ class AuditLogRead(BaseModel): id: int timestamp: datetime + customer_id: int | None username: str action: str target: str | None diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py new file mode 100644 index 0000000..e08b9d7 --- /dev/null +++ b/backend/app/schemas/customer.py @@ -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 diff --git a/backend/app/schemas/satellite.py b/backend/app/schemas/satellite.py new file mode 100644 index 0000000..4f8479a --- /dev/null +++ b/backend/app/schemas/satellite.py @@ -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 diff --git a/backend/app/schemas/satellite_api.py b/backend/app/schemas/satellite_api.py new file mode 100644 index 0000000..20359e4 --- /dev/null +++ b/backend/app/schemas/satellite_api.py @@ -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 diff --git a/backend/app/schemas/server.py b/backend/app/schemas/server.py index 815ecc9..b9ca286 100644 --- a/backend/app/schemas/server.py +++ b/backend/app/schemas/server.py @@ -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 diff --git a/backend/app/schemas/update.py b/backend/app/schemas/update.py index 881e9e4..9d793f8 100644 --- a/backend/app/schemas/update.py +++ b/backend/app/schemas/update.py @@ -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] diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py index c990b1b..dd438cd 100644 --- a/backend/app/services/audit.py +++ b/backend/app/services/audit.py @@ -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 diff --git a/backend/app/services/cau.py b/backend/app/services/cau.py deleted file mode 100644 index ff9da3d..0000000 --- a/backend/app/services/cau.py +++ /dev/null @@ -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 diff --git a/backend/app/services/janitor.py b/backend/app/services/janitor.py new file mode 100644 index 0000000..807540f --- /dev/null +++ b/backend/app/services/janitor.py @@ -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() diff --git a/backend/app/services/job_runner.py b/backend/app/services/job_runner.py deleted file mode 100644 index a0e1056..0000000 --- a/backend/app/services/job_runner.py +++ /dev/null @@ -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() diff --git a/backend/app/services/ssh.py b/backend/app/services/ssh.py deleted file mode 100644 index 1b533ed..0000000 --- a/backend/app/services/ssh.py +++ /dev/null @@ -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) diff --git a/backend/app/services/winrm.py b/backend/app/services/winrm.py deleted file mode 100644 index 9c414f2..0000000 --- a/backend/app/services/winrm.py +++ /dev/null @@ -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 diff --git a/backend/app/websocket/__init__.py b/backend/app/websocket/__init__.py deleted file mode 100644 index 66fee00..0000000 --- a/backend/app/websocket/__init__.py +++ /dev/null @@ -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"] diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py deleted file mode 100644 index 123085e..0000000 --- a/backend/app/websocket/handlers.py +++ /dev/null @@ -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), - ) diff --git a/backend/app/websocket/manager.py b/backend/app/websocket/manager.py deleted file mode 100644 index f14e555..0000000 --- a/backend/app/websocket/manager.py +++ /dev/null @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ac61dac..9adbaf6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/docker-compose.internal.yml b/docker-compose.internal.yml new file mode 100644 index 0000000..04a7a57 --- /dev/null +++ b/docker-compose.internal.yml @@ -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" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e7445b1..feb3de9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0f72f69..7748872 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + driver: bridge diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fdc117a..e522008 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 630ae89..0304f65 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/socket.ts b/frontend/src/api/socket.ts deleted file mode 100644 index fa6be63..0000000 --- a/frontend/src/api/socket.ts +++ /dev/null @@ -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 - } -} diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index c49da0b..c8e9240 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -1,17 +1,31 @@ + + diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index ba88f51..7a7573a 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,93 +1,131 @@ diff --git a/frontend/src/views/SatellitesView.vue b/frontend/src/views/SatellitesView.vue new file mode 100644 index 0000000..4de8e77 --- /dev/null +++ b/frontend/src/views/SatellitesView.vue @@ -0,0 +1,136 @@ + + + diff --git a/frontend/src/views/ServersView.vue b/frontend/src/views/ServersView.vue index 030b0ab..8250435 100644 --- a/frontend/src/views/ServersView.vue +++ b/frontend/src/views/ServersView.vue @@ -1,9 +1,13 @@