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

- Backend: Customer/Satellite Models, customer_id auf Server/Job/Audit
- Satellite-API: heartbeat, poll (atomares Claiming), logs, result,
  scan-result, health-report - Auth via X-Api-Key (SHA-256 gehasht)
- Job-Queue: pending/claimed/running/success/failed + Stale-Janitor
- Batch-Trigger: ein Job pro Server, Satellite arbeitet sequenziell ab
- Credentials bleiben lokal: nur symbolische credential_ref zentral
- Neues Paket satellite/: Pull-Loop, WinRM/SSH/CAU/Scanner, PyInstaller-tauglich
- Frontend: Kunden-Switcher, Satelliten-View, Polling statt WebSocket
- Entfernt: WebSocket/Socket.io, Redis, zentrale Credentials, JobRunner
- Docs: README/AGENTS/PROMPT auf neue Architektur aktualisiert
This commit is contained in:
B0rbor4d
2026-08-07 03:42:06 +00:00
parent b91dd66fee
commit cc4c3fcecb
72 changed files with 2759 additions and 1642 deletions
+4 -1
View File
@@ -2,10 +2,13 @@
from fastapi import APIRouter
from app.api.routes import audit, auth, servers, updates
from app.api.routes import audit, auth, customers, satellite_api, satellites, servers, updates
api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(customers.router, prefix="/customers", tags=["customers"])
api_router.include_router(satellites.router, prefix="/satellites", tags=["satellites"])
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
api_router.include_router(updates.router, prefix="/updates", tags=["updates"])
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
api_router.include_router(satellite_api.router, prefix="/satellite", tags=["satellite-api"])
+20 -4
View File
@@ -1,12 +1,14 @@
"""Shared API dependencies: current user extraction from JWT."""
"""Shared API dependencies: dashboard user auth (JWT) and satellite auth (API key)."""
from fastapi import Depends, Request
from fastapi import Depends, Header, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.exceptions import ForbiddenError, UnauthorizedError
from app.core.security import decode_token
from app.models.satellite import Satellite, hash_api_key
from app.models.user import User
bearer_scheme = HTTPBearer(auto_error=False)
@@ -23,8 +25,6 @@ async def get_current_user(
if not username:
raise UnauthorizedError("Token enthält keinen Benutzer")
from sqlalchemy import select
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if not user or not user.is_active:
@@ -38,5 +38,21 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
return user
async def get_current_satellite(
x_api_key: str | None = Header(default=None),
db: AsyncSession = Depends(get_db),
) -> Satellite:
"""Authenticate a satellite by its API key (X-Api-Key header)."""
if not x_api_key:
raise UnauthorizedError("X-Api-Key header fehlt")
result = await db.execute(
select(Satellite).where(Satellite.api_key_hash == hash_api_key(x_api_key))
)
satellite = result.scalar_one_or_none()
if not satellite or not satellite.is_active:
raise UnauthorizedError("Satellite unbekannt oder deaktiviert")
return satellite
def client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
+4
View File
@@ -19,6 +19,7 @@ async def list_audit_logs(
page_size: int = Query(default=50, ge=1, le=200),
action: str | None = None,
username: str | None = None,
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> AuditLogPage:
@@ -31,6 +32,9 @@ async def list_audit_logs(
if username:
stmt = stmt.where(AuditLog.username == username)
count_stmt = count_stmt.where(AuditLog.username == username)
if customer_id is not None:
stmt = stmt.where(AuditLog.customer_id == customer_id)
count_stmt = count_stmt.where(AuditLog.customer_id == customer_id)
total = await db.scalar(count_stmt) or 0
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
+105
View File
@@ -0,0 +1,105 @@
"""Customer routes (tenant management)."""
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import client_ip, get_current_user
from app.core.database import get_db
from app.core.exceptions import ConflictError, NotFoundError
from app.models.customer import Customer
from app.models.user import User
from app.schemas.customer import CustomerCreate, CustomerRead, CustomerUpdate
from app.services.audit import AuditService
router = APIRouter()
@router.get("", response_model=list[CustomerRead])
async def list_customers(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[Customer]:
result = await db.execute(select(Customer).order_by(Customer.name))
return list(result.scalars().all())
@router.post("", response_model=CustomerRead, status_code=201)
async def create_customer(
payload: CustomerCreate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Customer:
existing = await db.execute(
select(Customer).where((Customer.name == payload.name) | (Customer.slug == payload.slug))
)
if existing.scalar_one_or_none():
raise ConflictError("Kunde mit diesem Namen oder Slug existiert bereits")
customer = Customer(**payload.model_dump())
db.add(customer)
await db.flush()
await AuditService(db).log(
username=user.username,
action="customer.create",
target=customer.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
return customer
@router.get("/{customer_id}", response_model=CustomerRead)
async def get_customer(
customer_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> Customer:
customer = await db.get(Customer, customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
return customer
@router.patch("/{customer_id}", response_model=CustomerRead)
async def update_customer(
customer_id: int,
payload: CustomerUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Customer:
customer = await db.get(Customer, customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(customer, field, value)
await AuditService(db).log(
username=user.username,
action="customer.update",
target=customer.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
return customer
@router.delete("/{customer_id}", status_code=204)
async def delete_customer(
customer_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
customer = await db.get(Customer, customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
await AuditService(db).log(
username=user.username,
action="customer.delete",
target=customer.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
await db.delete(customer)
+228
View File
@@ -0,0 +1,228 @@
"""Satellite agent API - polled by remote satellites, authenticated via X-Api-Key.
Pull model: satellites poll for jobs, execute them locally in the customer
network, push log batches and final results back here.
"""
import json
from datetime import UTC, datetime
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_satellite
from app.core.database import get_db
from app.core.exceptions import ForbiddenError, NotFoundError
from app.core.logging import get_logger
from app.models.satellite import Satellite
from app.models.server import Server, ServerType
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
from app.schemas.satellite_api import (
HealthReportRequest,
HeartbeatRequest,
JobResultRequest,
LogBatchRequest,
PollResponse,
SatelliteJob,
ScanResultRequest,
)
from app.services.audit import AuditService
logger = get_logger(__name__)
router = APIRouter()
@router.post("/heartbeat")
async def heartbeat(
payload: HeartbeatRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
satellite.last_seen_at = datetime.now(UTC)
satellite.version = payload.version
satellite.hostname = payload.hostname
return {"ok": True, "server_time": datetime.now(UTC).isoformat()}
@router.get("/poll", response_model=PollResponse)
async def poll_jobs(
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> PollResponse:
"""Claim and return pending jobs for this satellite's customer.
Claiming is atomic-ish: status flips pending -> claimed in the same
transaction, so two satellites of one customer do not get the same job.
"""
result = await db.execute(
select(UpdateJob)
.where(
UpdateJob.customer_id == satellite.customer_id,
UpdateJob.status == JobStatus.PENDING,
)
.order_by(UpdateJob.id)
.limit(5)
.with_for_update()
)
jobs = list(result.scalars().all())
now = datetime.now(UTC)
out: list[SatelliteJob] = []
for job in jobs:
job.status = JobStatus.CLAIMED
job.satellite_id = satellite.id
job.claimed_at = now
job.last_report_at = now
params = json.loads(job.params) if job.params else {}
server = job.server
out.append(
SatelliteJob(
job_id=job.id,
type=job.type,
server_id=server.id if server else None,
server_name=server.name if server else None,
hostname=server.hostname if server else None,
port=server.port if server else None,
server_type=server.type.value if server else None,
credential_ref=server.credential_ref if server else None,
reboot_if_required=bool(params.get("reboot_if_required", False)),
scan_subnet=params.get("scan_subnet"),
)
)
if out:
logger.info(
"satellite.jobs_claimed",
satellite=satellite.name,
customer_id=satellite.customer_id,
count=len(out),
)
return PollResponse(jobs=out)
@router.post("/logs")
async def push_logs(
payload: LogBatchRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
job = await _get_own_job(db, payload.job_id, satellite)
if job.status == JobStatus.CLAIMED:
job.status = JobStatus.RUNNING
job.started_at = datetime.now(UTC)
for line in payload.lines:
db.add(
UpdateLog(
job_id=job.id,
timestamp=line.timestamp,
level=line.level,
line=line.line,
)
)
if payload.progress_percent is not None:
job.progress_percent = payload.progress_percent
if payload.current_phase is not None:
job.current_phase = payload.current_phase
job.last_report_at = datetime.now(UTC)
return {"ok": True, "accepted": len(payload.lines)}
@router.post("/result")
async def push_result(
payload: JobResultRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
job = await _get_own_job(db, payload.job_id, satellite)
job.status = JobStatus.SUCCESS if payload.status == "success" else JobStatus.FAILED
job.error = payload.error
job.finished_at = datetime.now(UTC)
job.last_report_at = job.finished_at
if job.status == JobStatus.SUCCESS:
job.progress_percent = 100
await AuditService(db).log(
username=f"satellite:{satellite.name}",
action="job.result",
target=f"job:{job.id}",
result="success" if payload.status == "success" else "failure",
customer_id=satellite.customer_id,
details={"type": job.type.value, "error": payload.error},
)
return {"ok": True}
@router.post("/scan-result")
async def push_scan_result(
payload: ScanResultRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Ingest discovered hosts from a NETWORK_SCAN job as server candidates."""
job = await _get_own_job(db, payload.job_id, satellite)
if job.type != JobType.NETWORK_SCAN:
raise ForbiddenError("Scan-Ergebnisse nur für NETWORK_SCAN Jobs")
created = 0
for host in payload.hosts:
existing = await db.execute(
select(Server).where(
Server.customer_id == satellite.customer_id,
Server.hostname.in_([host.hostname, host.ip]),
)
)
if existing.scalar_one_or_none():
continue
if host.winrm_open:
stype, port = ServerType.WINDOWS, 5985
elif host.ssh_open:
stype, port = ServerType.LINUX, 22
else:
continue # not manageable - skip
db.add(
Server(
customer_id=satellite.customer_id,
name=host.hostname,
hostname=host.ip,
port=port,
type=stype,
description=f"Auto-Discovery via Scan (Job #{job.id})",
discovered_by_scan=True,
)
)
created += 1
return {"ok": True, "created": created}
@router.post("/health-report")
async def push_health_report(
payload: HealthReportRequest,
satellite: Satellite = Depends(get_current_satellite),
db: AsyncSession = Depends(get_db),
) -> dict:
server = await db.get(Server, payload.server_id)
if not server or server.customer_id != satellite.customer_id:
raise NotFoundError("Server nicht gefunden")
server.last_health_at = datetime.now(UTC)
server.last_health_ok = payload.ok
server.last_health_message = payload.message
return {"ok": True}
async def _get_own_job(
db: AsyncSession, job_id: int, satellite: Satellite
) -> UpdateJob:
job = await db.get(UpdateJob, job_id)
if not job or job.customer_id != satellite.customer_id:
raise NotFoundError("Job nicht gefunden")
return job
+111
View File
@@ -0,0 +1,111 @@
"""Satellite management routes (dashboard side).
The plaintext API key is returned exactly once on creation.
"""
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import client_ip, get_current_user
from app.core.database import get_db
from app.core.exceptions import NotFoundError
from app.models.customer import Customer
from app.models.satellite import Satellite, generate_api_key, hash_api_key
from app.models.user import User
from app.schemas.satellite import SatelliteCreate, SatelliteCreated, SatelliteRead
from app.services.audit import AuditService
router = APIRouter()
def _created_response(satellite: Satellite, api_key: str) -> SatelliteCreated:
data = SatelliteRead.model_validate(satellite).model_dump()
return SatelliteCreated(**data, api_key=api_key)
@router.get("", response_model=list[SatelliteRead])
async def list_satellites(
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[Satellite]:
stmt = select(Satellite).order_by(Satellite.id)
if customer_id is not None:
stmt = stmt.where(Satellite.customer_id == customer_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("", response_model=SatelliteCreated, status_code=201)
async def create_satellite(
payload: SatelliteCreate,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> SatelliteCreated:
customer = await db.get(Customer, payload.customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
api_key = generate_api_key()
satellite = Satellite(
customer_id=payload.customer_id,
name=payload.name,
api_key_hash=hash_api_key(api_key),
api_key_prefix=api_key[:11],
)
db.add(satellite)
await db.flush()
await AuditService(db).log(
username=user.username,
action="satellite.create",
target=f"{customer.name}/{satellite.name}",
customer_id=customer.id,
ip_address=client_ip(request),
)
return _created_response(satellite, api_key)
@router.delete("/{satellite_id}", status_code=204)
async def delete_satellite(
satellite_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
satellite = await db.get(Satellite, satellite_id)
if not satellite:
raise NotFoundError("Satellite nicht gefunden")
await AuditService(db).log(
username=user.username,
action="satellite.delete",
target=satellite.name,
customer_id=satellite.customer_id,
ip_address=client_ip(request),
)
await db.delete(satellite)
@router.post("/{satellite_id}/rotate-key", response_model=SatelliteCreated)
async def rotate_satellite_key(
satellite_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> SatelliteCreated:
satellite = await db.get(Satellite, satellite_id)
if not satellite:
raise NotFoundError("Satellite nicht gefunden")
api_key = generate_api_key()
satellite.api_key_hash = hash_api_key(api_key)
satellite.api_key_prefix = api_key[:11]
await AuditService(db).log(
username=user.username,
action="satellite.rotate_key",
target=satellite.name,
customer_id=satellite.customer_id,
ip_address=client_ip(request),
)
return _created_response(satellite, api_key)
+28 -68
View File
@@ -1,6 +1,8 @@
"""Server inventory routes."""
"""Server inventory routes (customer-scoped).
from datetime import UTC, datetime
No direct connectivity from the central server - health checks are
HEALTH_CHECK jobs executed by the customer's satellite.
"""
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
@@ -8,26 +10,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import client_ip, get_current_user
from app.core.database import get_db
from app.core.exceptions import NotFoundError
from app.models.credential import Credential
from app.models.server import Server, ServerType
from app.core.exceptions import ConflictError, NotFoundError
from app.models.customer import Customer
from app.models.server import Server
from app.models.user import User
from app.schemas.server import HealthCheckResult, ServerCreate, ServerRead, ServerUpdate
from app.schemas.server import ServerCreate, ServerRead, ServerUpdate
from app.services.audit import AuditService
from app.services.cau import CAUService
from app.services.job_runner import JobRunner
from app.services.ssh import SSHService
from app.services.winrm import WinRMService
router = APIRouter()
@router.get("", response_model=list[ServerRead])
async def list_servers(
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[Server]:
result = await db.execute(select(Server).order_by(Server.name))
stmt = select(Server).order_by(Server.name)
if customer_id is not None:
stmt = stmt.where(Server.customer_id == customer_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@@ -38,6 +40,18 @@ async def create_server(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Server:
customer = await db.get(Customer, payload.customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
existing = await db.execute(
select(Server).where(
Server.customer_id == payload.customer_id, Server.name == payload.name
)
)
if existing.scalar_one_or_none():
raise ConflictError("Server mit diesem Namen existiert beim Kunden bereits")
server = Server(**payload.model_dump())
db.add(server)
await db.flush()
@@ -45,6 +59,7 @@ async def create_server(
username=user.username,
action="server.create",
target=server.name,
customer_id=customer.id,
ip_address=client_ip(request),
)
return server
@@ -79,6 +94,7 @@ async def update_server(
username=user.username,
action="server.update",
target=server.name,
customer_id=server.customer_id,
ip_address=client_ip(request),
)
return server
@@ -98,63 +114,7 @@ async def delete_server(
username=user.username,
action="server.delete",
target=server.name,
customer_id=server.customer_id,
ip_address=client_ip(request),
)
await db.delete(server)
@router.get("/{server_id}/health", response_model=HealthCheckResult)
async def check_server_health(
server_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> HealthCheckResult:
server = await db.get(Server, server_id)
if not server:
raise NotFoundError("Server nicht gefunden")
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
if server.type == ServerType.LINUX:
service = SSHService(
server.hostname,
port=server.port,
credentials=JobRunner._ssh_creds(credential),
)
elif server.type == ServerType.CAU_CLUSTER:
cau = CAUService(
server.hostname,
access_node=server.hostname,
port=server.port,
credentials=JobRunner._winrm_creds(credential),
)
ok, message = await cau.test_cluster()
server.last_health_at = datetime.now(UTC)
server.last_health_ok = ok
return HealthCheckResult(
server_id=server.id,
ok=ok,
message=message,
checked_at=server.last_health_at,
)
else:
service = WinRMService(
server.hostname,
port=server.port,
credentials=JobRunner._winrm_creds(credential),
)
started = datetime.now(UTC)
ok, message = await service.test_connection()
latency_ms = (datetime.now(UTC) - started).total_seconds() * 1000
server.last_health_at = datetime.now(UTC)
server.last_health_ok = ok
return HealthCheckResult(
server_id=server.id,
ok=ok,
latency_ms=round(latency_ms, 1),
message=message,
checked_at=server.last_health_at,
)
+115 -32
View File
@@ -1,4 +1,10 @@
"""Update job routes: trigger, list, logs, cancel."""
"""Update job routes: trigger (single + batch), list, logs, cancel.
Triggering only queues a job - a satellite of that customer picks it up
on its next poll and executes it locally.
"""
import json
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
@@ -6,13 +12,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import client_ip, get_current_user
from app.core.database import get_db
from app.core.exceptions import JobNotCancellableError, NotFoundError
from app.core.exceptions import JobNotCancellableError, NotFoundError, ValidationError
from app.models.customer import Customer
from app.models.server import Server
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
from app.models.user import User
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
from app.schemas.update import (
BatchJobTriggerRequest,
BatchJobTriggerResponse,
JobTriggerRequest,
UpdateJobRead,
UpdateLogRead,
)
from app.services.audit import AuditService
from app.services.job_runner import job_runner
router = APIRouter()
@@ -24,39 +36,69 @@ async def trigger_update(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> UpdateJob:
server = await db.get(Server, payload.server_id)
if not server:
raise NotFoundError("Server nicht gefunden")
job = UpdateJob(
server_id=server.id,
type=payload.type,
started_by=user.username,
)
db.add(job)
await db.flush()
job = await _create_job(db, payload, user.username)
await AuditService(db).log(
username=user.username,
action="update.trigger",
target=server.name,
details={"job_id": job.id, "type": payload.type.value},
target=f"job:{job.id}",
customer_id=payload.customer_id,
details={"type": payload.type.value, "server_id": payload.server_id},
ip_address=client_ip(request),
)
await db.commit()
await job_runner.start(job.id)
return job
@router.post("/trigger-batch", response_model=BatchJobTriggerResponse, status_code=201)
async def trigger_batch(
payload: BatchJobTriggerRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> BatchJobTriggerResponse:
"""Queue one job per server - the satellite works through them in order."""
stmt = select(Server).where(Server.customer_id == payload.customer_id)
if payload.server_ids:
stmt = stmt.where(Server.id.in_(payload.server_ids))
result = await db.execute(stmt.order_by(Server.name))
servers = list(result.scalars().all())
if not servers:
raise NotFoundError("Keine Server für diesen Kunden gefunden")
job_ids: list[int] = []
for server in servers:
job = await _create_job(
db,
JobTriggerRequest(
customer_id=payload.customer_id,
type=payload.type,
server_id=server.id,
reboot_if_required=payload.reboot_if_required,
),
user.username,
)
job_ids.append(job.id)
await AuditService(db).log(
username=user.username,
action="update.trigger_batch",
customer_id=payload.customer_id,
details={"type": payload.type.value, "count": len(job_ids)},
ip_address=client_ip(request),
)
return BatchJobTriggerResponse(created=len(job_ids), job_ids=job_ids)
@router.get("", response_model=list[UpdateJobRead])
async def list_jobs(
customer_id: int | None = None,
status: JobStatus | None = None,
limit: int = Query(default=50, le=200),
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> list[UpdateJob]:
stmt = select(UpdateJob).order_by(UpdateJob.id.desc()).limit(limit)
if customer_id is not None:
stmt = stmt.where(UpdateJob.customer_id == customer_id)
if status:
stmt = stmt.where(UpdateJob.status == status)
result = await db.execute(stmt)
@@ -103,17 +145,17 @@ async def cancel_job(
job = await db.get(UpdateJob, job_id)
if not job:
raise NotFoundError("Job nicht gefunden")
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
# Only pending jobs can be cancelled centrally - a claimed/running job
# is already on the satellite and finishes there.
if job.status != JobStatus.PENDING:
raise JobNotCancellableError()
cancelled = await job_runner.cancel(job_id)
if not cancelled:
job.status = JobStatus.CANCELLED
job.status = JobStatus.CANCELLED
await AuditService(db).log(
username=user.username,
action="update.cancel",
target=f"job:{job_id}",
customer_id=job.customer_id,
ip_address=client_ip(request),
)
return job
@@ -121,14 +163,55 @@ async def cancel_job(
@router.get("/stats/summary")
async def job_stats(
customer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
) -> dict:
total = await db.scalar(select(func.count(UpdateJob.id)))
base = select(func.count(UpdateJob.id))
if customer_id is not None:
base = base.where(UpdateJob.customer_id == customer_id)
total = await db.scalar(base)
running = await db.scalar(
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.RUNNING)
base.where(UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]))
)
failed = await db.scalar(
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.FAILED)
failed = await db.scalar(base.where(UpdateJob.status == JobStatus.FAILED))
pending = await db.scalar(base.where(UpdateJob.status == JobStatus.PENDING))
return {
"total": total or 0,
"running": running or 0,
"failed": failed or 0,
"pending": pending or 0,
}
async def _create_job(
db: AsyncSession, payload: JobTriggerRequest, username: str
) -> UpdateJob:
customer = await db.get(Customer, payload.customer_id)
if not customer:
raise NotFoundError("Kunde nicht gefunden")
if payload.type == JobType.NETWORK_SCAN:
if payload.server_id is not None:
raise ValidationError("NETWORK_SCAN hat keinen Ziel-Server")
else:
if payload.server_id is None:
raise ValidationError("server_id erforderlich")
server = await db.get(Server, payload.server_id)
if not server or server.customer_id != payload.customer_id:
raise NotFoundError("Server nicht gefunden")
params: dict = {"reboot_if_required": payload.reboot_if_required}
if payload.scan_subnet:
params["scan_subnet"] = payload.scan_subnet
job = UpdateJob(
customer_id=payload.customer_id,
server_id=payload.server_id,
type=payload.type,
created_by=username,
params=json.dumps(params),
)
return {"total": total or 0, "running": running or 0, "failed": failed or 0}
db.add(job)
await db.flush()
return job
+4 -12
View File
@@ -18,7 +18,6 @@ class Settings(BaseSettings):
app_env: str = "development"
app_name: str = "Insight Updater"
secret_key: str = "dev-secret-change-me-32-chars-min"
encryption_key: str = ""
log_level: str = "INFO"
log_format: str = "json"
@@ -29,19 +28,12 @@ class Settings(BaseSettings):
jwt_access_token_expire_minutes: int = 30
jwt_refresh_token_expire_days: int = 7
# Database / Redis
# Database
database_url: str = "sqlite+aiosqlite:///./data/app.db"
redis_url: str = "redis://localhost:6379/0"
# WinRM
winrm_transport: str = "ntlm"
winrm_cert_validation: str = "ignore"
winrm_operation_timeout: int = 60
winrm_read_timeout: int = 120
winrm_kerberos_delegation: bool = True
# SSH
ssh_timeout: int = 30
# Jobs: a claimed/running job without satellite reports for this many
# seconds is considered stale and marked failed by the janitor
job_stale_timeout: int = 3600
# LDAP (stub)
ldap_enabled: bool = False
+5
View File
@@ -62,5 +62,10 @@ class CAUError(AppError):
detail = "Cluster-Aware Updating operation failed"
class ValidationError(AppError):
status_code = 422
detail = "Validation failed"
class JobNotCancellableError(ConflictError):
detail = "Job cannot be cancelled in its current state"
+6 -48
View File
@@ -1,58 +1,21 @@
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
"""Security helpers: JWT issue/verify, password hashing.
No credential encryption here - target-system credentials live exclusively
on the satellites, never in the central database.
"""
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import bcrypt
from cryptography.fernet import Fernet, InvalidToken
from jose import JWTError, jwt
from app.core.config import get_settings
from app.core.exceptions import CredentialDecryptionError, InvalidTokenError
from app.core.exceptions import InvalidTokenError
settings = get_settings()
# ---------------------------------------------------------------------------
# Fernet encryption for stored credentials
# ---------------------------------------------------------------------------
_fernet: Fernet | None = None
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
key = settings.encryption_key
if not key:
# Dev fallback: derive a valid fernet key from SECRET_KEY
import base64
import hashlib
key = base64.urlsafe_b64encode(
hashlib.sha256(settings.secret_key.encode()).digest()
).decode()
_fernet = Fernet(key.encode() if isinstance(key, str) else key)
return _fernet
def encrypt(plaintext: str) -> str:
"""Encrypt a secret for at-rest storage."""
return _get_fernet().encrypt(plaintext.encode()).decode()
def decrypt(token: str) -> str:
"""Decrypt a stored secret. Raises CredentialDecryptionError on failure."""
try:
return _get_fernet().decrypt(token.encode()).decode()
except InvalidToken as exc:
raise CredentialDecryptionError("Stored credential cannot be decrypted") from exc
# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------
def hash_password(password: str) -> str:
# bcrypt hard limit: 72 bytes
@@ -66,11 +29,6 @@ def verify_password(plain: str, hashed: str) -> bool:
return False
# ---------------------------------------------------------------------------
# JWT (RS256 with key files, HS256 fallback for dev without keys)
# ---------------------------------------------------------------------------
def _read_key(path: str) -> str | None:
p = Path(path)
return p.read_text() if p.exists() else None
+15 -22
View File
@@ -1,14 +1,13 @@
"""FastAPI application entrypoint.
Mounts:
- REST API under /api
- Socket.io under /socket.io (path) -> frontend connects to ws://host/socket.io
Central instance of the Insight Updater hub:
- Dashboard REST API under /api (JWT auth)
- Satellite agent API under /api/satellite (X-Api-Key auth)
- /health liveness probe
"""
from contextlib import asynccontextmanager
import socketio
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
@@ -18,7 +17,6 @@ from app.core.config import get_settings
from app.core.database import init_db
from app.core.exceptions import AppError
from app.core.logging import get_logger, setup_logging
from app.websocket import sio
settings = get_settings()
logger = get_logger(__name__)
@@ -32,17 +30,15 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
await init_db()
await _seed_default_admin()
# Attach Redis manager for Socket.io pub/sub (optional in dev)
try:
from socketio import AsyncRedisManager
import asyncio
sio.manager = AsyncRedisManager(settings.redis_url)
logger.info("ws.redis_manager_attached", url=settings.redis_url)
except Exception as exc: # noqa: BLE001 - Redis optional for scaffold
logger.warning("ws.redis_unavailable", error=str(exc))
from app.services.janitor import run_janitor
janitor = asyncio.create_task(run_janitor(), name="job-janitor")
yield
janitor.cancel()
logger.info("app.stopping")
@@ -77,13 +73,13 @@ async def _seed_default_admin() -> None:
logger.info("app.default_admin_created", username="admin")
fastapi_app = FastAPI(
app = FastAPI(
title=settings.app_name,
version="0.1.0",
version="0.2.0",
lifespan=lifespan,
)
fastapi_app.add_middleware(
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
@@ -92,17 +88,14 @@ fastapi_app.add_middleware(
)
@fastapi_app.exception_handler(AppError)
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@fastapi_app.get("/health")
@app.get("/health")
async def health() -> dict:
return {"status": "ok", "env": settings.app_env, "version": "0.1.0"}
return {"status": "ok", "env": settings.app_env, "version": "0.2.0"}
fastapi_app.include_router(api_router)
# Combined ASGI app: FastAPI + Socket.io
app = socketio.ASGIApp(sio, other_asgi_app=fastapi_app, socketio_path="socket.io")
app.include_router(api_router)
+4 -2
View File
@@ -1,14 +1,16 @@
"""SQLAlchemy ORM models."""
from app.models.audit_log import AuditLog
from app.models.credential import Credential
from app.models.customer import Customer
from app.models.satellite import Satellite
from app.models.server import Server
from app.models.update_job import UpdateJob, UpdateLog
from app.models.user import User
__all__ = [
"AuditLog",
"Credential",
"Customer",
"Satellite",
"Server",
"UpdateJob",
"UpdateLog",
+5 -2
View File
@@ -2,7 +2,7 @@
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@@ -15,9 +15,12 @@ class AuditLog(Base):
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
)
customer_id: Mapped[int | None] = mapped_column(
ForeignKey("customers.id"), nullable=True, index=True
)
username: Mapped[str] = mapped_column(String(255), index=True)
action: Mapped[str] = mapped_column(String(100), index=True) # e.g. server.create
target: Mapped[str | None] = mapped_column(String(255), nullable=True) # e.g. server name
target: Mapped[str | None] = mapped_column(String(255), nullable=True)
result: Mapped[str] = mapped_column(String(50), default="success") # success | failure
details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
-39
View File
@@ -1,39 +0,0 @@
"""Credential model - secrets stored Fernet-encrypted."""
from datetime import UTC, datetime
from sqlalchemy import DateTime, Enum, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
import enum
class CredentialType(str, enum.Enum):
WINRM_USERPASS = "winrm_userpass"
SSH_USERPASS = "ssh_userpass"
SSH_KEY = "ssh_key"
class Credential(Base):
__tablename__ = "credentials"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True)
type: Mapped[CredentialType] = mapped_column(Enum(CredentialType))
username: Mapped[str] = mapped_column(String(255))
# Encrypted at rest via core.security.encrypt()
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
private_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
key_passphrase_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
+36
View File
@@ -0,0 +1,36 @@
"""Customer model - one per client site (tenant)."""
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
satellites: Mapped[list["Satellite"]] = relationship( # noqa: F821
back_populates="customer", cascade="all, delete-orphan"
)
servers: Mapped[list["Server"]] = relationship( # noqa: F821
back_populates="customer", cascade="all, delete-orphan"
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="customer", cascade="all, delete-orphan"
)
+52
View File
@@ -0,0 +1,52 @@
"""Satellite model - remote agent at a customer site.
The API key is stored as a SHA-256 hash; the plaintext key is shown
exactly once at creation time.
"""
import hashlib
import secrets
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
def generate_api_key() -> str:
"""Generate a new satellite API key (plaintext, show once)."""
return f"ius_{secrets.token_urlsafe(32)}"
def hash_api_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
class Satellite(Base):
__tablename__ = "satellites"
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id"), index=True
)
customer: Mapped["Customer"] = relationship(back_populates="satellites") # noqa: F821
name: Mapped[str] = mapped_column(String(255))
api_key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
api_key_prefix: Mapped[str] = mapped_column(String(12)) # for display in UI
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Filled by heartbeat
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
version: Mapped[str | None] = mapped_column(String(50), nullable=True)
hostname: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="satellite"
)
+24 -9
View File
@@ -1,15 +1,19 @@
"""Server inventory model."""
"""Server inventory model.
Credentials are NOT stored centrally. `credential_ref` is a symbolic name
that the satellite resolves against its local credentials.yaml.
"""
import enum
from datetime import UTC, datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ServerType(str, enum.Enum):
class ServerType(enum.StrEnum):
WINDOWS = "windows" # WinRM
LINUX = "linux" # SSH
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
@@ -17,22 +21,33 @@ class ServerType(str, enum.Enum):
class Server(Base):
__tablename__ = "servers"
__table_args__ = (
UniqueConstraint("customer_id", "name", name="uq_server_customer_name"),
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id"), index=True
)
customer: Mapped["Customer"] = relationship(back_populates="servers") # noqa: F821
name: Mapped[str] = mapped_column(String(255), index=True)
hostname: Mapped[str] = mapped_column(String(255))
port: Mapped[int] = mapped_column(default=5985)
type: Mapped[ServerType] = mapped_column(Enum(ServerType), default=ServerType.WINDOWS)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
credential_id: Mapped[int | None] = mapped_column(
ForeignKey("credentials.id"), nullable=True
)
credential: Mapped["Credential | None"] = relationship(lazy="selectin") # noqa: F821
# Symbolic reference to a credential stored locally on the satellite
credential_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Last health result reported by a satellite
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
last_health_message: Mapped[str | None] = mapped_column(Text, nullable=True)
# Set by network scan jobs
discovered_by_scan: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
@@ -44,5 +59,5 @@ class Server(Base):
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="server", cascade="all, delete-orphan"
back_populates="server"
)
+34 -6
View File
@@ -1,4 +1,10 @@
"""Update job + streamed log line models."""
"""Update job + log line models.
Job lifecycle (pull model):
pending -> claimed (satellite picked it up) -> running -> success | failed | cancelled
A claimed/running job whose satellite goes silent past the stale timeout
is marked failed by the janitor.
"""
import enum
from datetime import UTC, datetime
@@ -9,36 +15,58 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class JobStatus(str, enum.Enum):
class JobStatus(enum.StrEnum):
PENDING = "pending"
CLAIMED = "claimed"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELLED = "cancelled"
class JobType(str, enum.Enum):
class JobType(enum.StrEnum):
WINDOWS_UPDATE = "windows_update"
LINUX_UPDATE = "linux_update"
CAU_RUN = "cau_run"
HEALTH_CHECK = "health_check"
NETWORK_SCAN = "network_scan"
class UpdateJob(Base):
__tablename__ = "update_jobs"
id: Mapped[int] = mapped_column(primary_key=True)
server_id: Mapped[int] = mapped_column(ForeignKey("servers.id"), index=True)
server: Mapped["Server"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id"), index=True
)
customer: Mapped["Customer"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
# Null for NETWORK_SCAN jobs (target = whole local network)
server_id: Mapped[int | None] = mapped_column(
ForeignKey("servers.id"), nullable=True, index=True
)
server: Mapped["Server | None"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
# Set when a satellite claims the job
satellite_id: Mapped[int | None] = mapped_column(
ForeignKey("satellites.id"), nullable=True, index=True
)
satellite: Mapped["Satellite | None"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
type: Mapped[JobType] = mapped_column(Enum(JobType))
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
current_phase: Mapped[str | None] = mapped_column(String(255), nullable=True)
started_by: Mapped[str] = mapped_column(String(255)) # username
# Optional job parameters (e.g. reboot_if_required, scan_subnet)
params: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
created_by: Mapped[str] = mapped_column(String(255)) # dashboard username
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_report_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
+1 -18
View File
@@ -1,18 +1 @@
"""Pydantic schemas (request/response)."""
from app.schemas.audit import AuditLogRead
from app.schemas.auth import LoginRequest, TokenResponse
from app.schemas.server import ServerCreate, ServerRead, ServerUpdate
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
__all__ = [
"AuditLogRead",
"LoginRequest",
"TokenResponse",
"ServerCreate",
"ServerRead",
"ServerUpdate",
"JobTriggerRequest",
"UpdateJobRead",
"UpdateLogRead",
]
"""Pydantic request/response schemas."""
+1
View File
@@ -10,6 +10,7 @@ class AuditLogRead(BaseModel):
id: int
timestamp: datetime
customer_id: int | None
username: str
action: str
target: str | None
+27
View File
@@ -0,0 +1,27 @@
"""Customer schemas."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class CustomerCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")
notes: str | None = None
class CustomerUpdate(BaseModel):
name: str | None = None
notes: str | None = None
class CustomerRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
notes: str | None
created_at: datetime
updated_at: datetime
+30
View File
@@ -0,0 +1,30 @@
"""Satellite schemas."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class SatelliteCreate(BaseModel):
customer_id: int
name: str = Field(min_length=1, max_length=255)
class SatelliteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
customer_id: int
name: str
api_key_prefix: str
is_active: bool
last_seen_at: datetime | None
version: str | None
hostname: str | None
created_at: datetime
class SatelliteCreated(SatelliteRead):
"""Returned exactly once on creation - contains the plaintext API key."""
api_key: str
+71
View File
@@ -0,0 +1,71 @@
"""Satellite-facing schemas (agent API)."""
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.update_job import JobType
class HeartbeatRequest(BaseModel):
version: str = Field(max_length=50)
hostname: str = Field(max_length=255)
class PollResponse(BaseModel):
"""Jobs handed to the satellite on poll. Empty list = nothing to do."""
jobs: list["SatelliteJob"]
class SatelliteJob(BaseModel):
job_id: int
type: JobType
# Target info (absent for network scans)
server_id: int | None = None
server_name: str | None = None
hostname: str | None = None
port: int | None = None
server_type: str | None = None
credential_ref: str | None = None
# Parameters
reboot_if_required: bool = False
scan_subnet: str | None = None
class LogLine(BaseModel):
timestamp: datetime
level: str = "info"
line: str
class LogBatchRequest(BaseModel):
job_id: int
lines: list[LogLine]
progress_percent: int | None = None
current_phase: str | None = None
class JobResultRequest(BaseModel):
job_id: int
status: str # "success" | "failed"
error: str | None = None
class ScanResultHost(BaseModel):
hostname: str
ip: str
os_guess: str | None = None # "windows" | "linux" | None
winrm_open: bool = False
ssh_open: bool = False
class ScanResultRequest(BaseModel):
job_id: int
hosts: list[ScanResultHost]
class HealthReportRequest(BaseModel):
server_id: int
ok: bool
message: str
+7 -11
View File
@@ -8,13 +8,14 @@ from app.models.server import ServerType
class ServerCreate(BaseModel):
customer_id: int
name: str = Field(min_length=1, max_length=255)
hostname: str = Field(min_length=1, max_length=255)
port: int = 5985
type: ServerType = ServerType.WINDOWS
description: str | None = None
tags: str | None = None
credential_id: int | None = None
credential_ref: str | None = None
class ServerUpdate(BaseModel):
@@ -24,29 +25,24 @@ class ServerUpdate(BaseModel):
type: ServerType | None = None
description: str | None = None
tags: str | None = None
credential_id: int | None = None
credential_ref: str | None = None
class ServerRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
customer_id: int
name: str
hostname: str
port: int
type: ServerType
description: str | None
tags: str | None
credential_id: int | None
credential_ref: str | None
last_health_at: datetime | None
last_health_ok: bool | None
last_health_message: str | None
discovered_by_scan: bool
created_at: datetime
updated_at: datetime
class HealthCheckResult(BaseModel):
server_id: int
ok: bool
latency_ms: float | None = None
message: str
checked_at: datetime
+24 -6
View File
@@ -8,24 +8,28 @@ from app.models.update_job import JobStatus, JobType
class JobTriggerRequest(BaseModel):
server_id: int
customer_id: int
type: JobType
# CAU-specific options
cluster_name: str | None = None
# Linux-specific options
# Target server; not required for NETWORK_SCAN
server_id: int | None = None
# Optional parameters
reboot_if_required: bool = False
scan_subnet: str | None = None # e.g. "192.168.1.0/24"
class UpdateJobRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
server_id: int
customer_id: int
server_id: int | None
satellite_id: int | None
type: JobType
status: JobStatus
progress_percent: int
current_phase: str | None
started_by: str
created_by: str
claimed_at: datetime | None
started_at: datetime | None
finished_at: datetime | None
error: str | None
@@ -40,3 +44,17 @@ class UpdateLogRead(BaseModel):
timestamp: datetime
level: str
line: str
class BatchJobTriggerRequest(BaseModel):
"""Trigger one job per server (or all servers of a customer)."""
customer_id: int
type: JobType
server_ids: list[int] | None = None # None = all servers of the customer
reboot_if_required: bool = False
class BatchJobTriggerResponse(BaseModel):
created: int
job_ids: list[int]
+3
View File
@@ -25,6 +25,7 @@ class AuditService:
result: str = "success",
details: dict[str, Any] | None = None,
ip_address: str | None = None,
customer_id: int | None = None,
) -> AuditLog:
entry = AuditLog(
username=username,
@@ -33,6 +34,7 @@ class AuditService:
result=result,
details=json.dumps(details) if details else None,
ip_address=ip_address,
customer_id=customer_id,
)
self.db.add(entry)
await self.db.flush()
@@ -42,5 +44,6 @@ class AuditService:
action=action,
target=target,
result=result,
customer_id=customer_id,
)
return entry
-77
View File
@@ -1,77 +0,0 @@
"""CAU service: Cluster-Aware Updating via PowerShell remoting (WinRM)."""
from collections.abc import AsyncIterator
from app.core.exceptions import CAUError
from app.core.logging import get_logger
from app.services.winrm import WinRMCredentials, WinRMService
logger = get_logger(__name__)
class CAUService:
"""Orchestrates Invoke-CauRun against a Windows failover cluster."""
def __init__(
self,
cluster_name: str,
access_node: str,
port: int = 5985,
credentials: WinRMCredentials | None = None,
) -> None:
self.cluster_name = cluster_name
self.winrm = WinRMService(access_node, port=port, credentials=credentials)
async def get_cluster_nodes(self) -> list[dict[str, str]]:
"""List cluster nodes and their state."""
script = f"""
Import-Module FailoverClusters
Get-ClusterNode -Cluster {self.cluster_name} |
Select-Object Name, State |
ForEach-Object {{ "$($_.Name)|$($_.State)" }}
"""
output = await self.winrm.run_powershell(script)
nodes = []
for line in output.splitlines():
if "|" in line:
name, state = line.split("|", 1)
nodes.append({"name": name.strip(), "state": state.strip()})
return nodes
async def get_cau_status(self) -> str:
"""Return the last CAU run summary."""
script = f"""
Import-Module ClusterAwareUpdating
Get-CauRun -ClusterName {self.cluster_name} |
Select-Object -First 1 |
Format-List | Out-String
"""
return await self.winrm.run_powershell(script)
async def test_cluster(self) -> tuple[bool, str]:
"""Verify the cluster is reachable and CAU module present."""
try:
nodes = await self.get_cluster_nodes()
if not nodes:
return False, "Keine Cluster-Knoten gefunden"
return True, f"Cluster OK — {len(nodes)} Knoten"
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def invoke_cau_run(self) -> AsyncIterator[str]:
"""Start a CAU run and stream per-node progress lines.
Scaffold implementation: starts Invoke-CauRun and polls Get-CauReport.
TODO: true live streaming of per-node phases via event log polling.
"""
script = f"""
Import-Module ClusterAwareUpdating
Invoke-CauRun -ClusterName {self.cluster_name} `
-Force -Confirm:$false -WaitForCompletion |
Out-String
"""
try:
async for line in self.winrm.stream_powershell(script):
yield line
except Exception as exc: # noqa: BLE001
raise CAUError(f"CAU-Lauf fehlgeschlagen: {exc}") from exc
+47
View File
@@ -0,0 +1,47 @@
"""Stale job janitor: marks claimed/running jobs as failed when their
satellite stops reporting (e.g. satellite offline, job crashed).
"""
import asyncio
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from app.core.config import get_settings
from app.core.database import async_session_factory
from app.core.logging import get_logger
from app.models.update_job import JobStatus, UpdateJob
settings = get_settings()
logger = get_logger(__name__)
CHECK_INTERVAL = 60 # seconds
async def run_janitor() -> None:
"""Background task; runs until cancelled."""
while True:
try:
await _sweep()
except Exception as exc: # noqa: BLE001 - janitor must never die
logger.error("janitor.error", error=str(exc))
await asyncio.sleep(CHECK_INTERVAL)
async def _sweep() -> None:
cutoff = datetime.now(UTC) - timedelta(seconds=settings.job_stale_timeout)
async with async_session_factory() as db:
result = await db.execute(
select(UpdateJob).where(
UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]),
UpdateJob.last_report_at < cutoff,
)
)
stale = list(result.scalars().all())
for job in stale:
job.status = JobStatus.FAILED
job.error = "Satellite meldet sich nicht mehr (Timeout)"
job.finished_at = datetime.now(UTC)
logger.warning("janitor.job_stale", job_id=job.id, satellite_id=job.satellite_id)
if stale:
await db.commit()
-138
View File
@@ -1,138 +0,0 @@
"""Job runner: executes update jobs in background tasks, streams logs to WS + DB."""
import asyncio
from datetime import UTC, datetime
from app.core.database import async_session_factory
from app.core.logging import get_logger
from app.models.credential import Credential, CredentialType
from app.models.server import Server, ServerType
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
from app.services.cau import CAUService
from app.services.ssh import SSHCredentials, SSHService
from app.services.winrm import WinRMCredentials, WinRMService
from app.websocket import handlers as ws
logger = get_logger(__name__)
class JobRunner:
"""Manages asyncio tasks for running update jobs."""
def __init__(self) -> None:
self._tasks: dict[int, asyncio.Task] = {} # type: ignore[type-arg]
async def start(self, job_id: int) -> None:
task = asyncio.create_task(self._run(job_id), name=f"job-{job_id}")
self._tasks[job_id] = task
task.add_done_callback(lambda _t: self._tasks.pop(job_id, None))
async def cancel(self, job_id: int) -> bool:
task = self._tasks.get(job_id)
if not task:
return False
task.cancel()
return True
# ------------------------------------------------------------------
async def _run(self, job_id: int) -> None:
async with async_session_factory() as db:
job = await db.get(UpdateJob, job_id)
if not job:
logger.error("job.not_found", job_id=job_id)
return
server = await db.get(Server, job.server_id)
if not server:
await self._finish(db, job, JobStatus.FAILED, "Server nicht gefunden")
return
job.status = JobStatus.RUNNING
job.started_at = datetime.now(UTC)
await db.commit()
await ws.emit_job_start(job.id, server.id, job.type.value)
started = datetime.now(UTC)
try:
await self._dispatch(db, job, server)
await self._finish(db, job, JobStatus.SUCCESS, None, started)
except asyncio.CancelledError:
await self._finish(db, job, JobStatus.CANCELLED, "Vom Benutzer abgebrochen", started)
raise
except Exception as exc: # noqa: BLE001
logger.error("job.failed", job_id=job_id, error=str(exc))
await self._finish(db, job, JobStatus.FAILED, str(exc), started)
async def _dispatch(self, db, job: UpdateJob, server: Server) -> None: # type: ignore[no-untyped-def]
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
if server.type == ServerType.LINUX:
creds = self._ssh_creds(credential)
service = SSHService(server.hostname, port=server.port, credentials=creds)
async for line in service.stream_updates():
await self._log(db, job, line)
elif server.type == ServerType.WINDOWS:
creds = self._winrm_creds(credential)
service = WinRMService(server.hostname, port=server.port, credentials=creds)
async for line in service.install_updates():
await self._log(db, job, line)
elif server.type == ServerType.CAU_CLUSTER:
creds = self._winrm_creds(credential)
service = CAUService(server.hostname, access_node=server.hostname, port=server.port, credentials=creds)
async for line in service.invoke_cau_run():
await self._log(db, job, line)
async def _log(self, db, job: UpdateJob, line: str, level: str = "info") -> None: # type: ignore[no-untyped-def]
db.add(UpdateLog(job_id=job.id, level=level, line=line))
await db.commit()
await ws.emit_job_log(job.id, line, level)
async def _finish( # type: ignore[no-untyped-def]
self, db, job: UpdateJob, status: JobStatus, error: str | None, started: datetime | None = None
) -> None:
job.status = status
job.error = error
job.finished_at = datetime.now(UTC)
if status == JobStatus.SUCCESS:
job.progress_percent = 100
await db.commit()
duration = (
(job.finished_at - started).total_seconds() if started else None
)
await ws.emit_job_complete(job.id, status.value, duration)
# ------------------------------------------------------------------
@staticmethod
def _winrm_creds(credential: Credential | None) -> WinRMCredentials | None:
if not credential or credential.type != CredentialType.WINRM_USERPASS:
return None
from app.core.security import decrypt
return WinRMCredentials(
username=credential.username,
password=decrypt(credential.password_encrypted or ""),
)
@staticmethod
def _ssh_creds(credential: Credential | None) -> SSHCredentials | None:
if not credential:
return None
from app.core.security import decrypt
return SSHCredentials(
username=credential.username,
password=decrypt(credential.password_encrypted) if credential.password_encrypted else None,
private_key=decrypt(credential.private_key_encrypted)
if credential.private_key_encrypted
else None,
passphrase=decrypt(credential.key_passphrase_encrypted)
if credential.key_passphrase_encrypted
else None,
)
job_runner = JobRunner()
-118
View File
@@ -1,118 +0,0 @@
"""SSH service: connect to Linux hosts via asyncssh, run updates with sudo."""
from collections.abc import AsyncIterator
from dataclasses import dataclass
from app.core.config import get_settings
from app.core.exceptions import SSHError
from app.core.logging import get_logger
settings = get_settings()
logger = get_logger(__name__)
@dataclass
class SSHCredentials:
username: str
password: str | None = None
private_key: str | None = None
passphrase: str | None = None
class SSHService:
"""Async SSH operations via asyncssh."""
def __init__(
self,
hostname: str,
port: int = 22,
credentials: SSHCredentials | None = None,
) -> None:
self.hostname = hostname
self.port = port
self.credentials = credentials
def _connect_kwargs(self) -> dict:
kwargs: dict = {
"host": self.hostname,
"port": self.port,
"known_hosts": None, # scaffold: accept any host key
"connect_timeout": settings.ssh_timeout,
}
if self.credentials:
kwargs["username"] = self.credentials.username
if self.credentials.password:
kwargs["password"] = self.credentials.password
if self.credentials.private_key:
kwargs["client_keys"] = [
__import__("asyncssh").import_private_key(
self.credentials.private_key,
passphrase=self.credentials.passphrase,
)
]
return kwargs
async def test_connection(self) -> tuple[bool, str]:
import asyncssh
try:
async with asyncssh.connect(**self._connect_kwargs()) as conn:
result = await conn.run("hostname", check=True)
return True, f"Verbunden mit {result.stdout.strip()}" # type: ignore[union-attr]
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def run_command(self, command: str, sudo: bool = False) -> str:
"""Run a command, optionally via sudo -S with the stored password."""
import asyncssh
if sudo:
password = (self.credentials.password if self.credentials else None) or ""
command = f"echo '{password}' | sudo -S {command}"
async with asyncssh.connect(**self._connect_kwargs()) as conn:
result = await conn.run(command, check=False)
if result.returncode != 0:
raise SSHError(
f"Command failed (exit {result.returncode}): {str(result.stderr).strip()}"
)
return str(result.stdout)
async def detect_package_manager(self) -> str:
"""Detect apt, dnf, or yum on the target."""
import asyncssh
async with asyncssh.connect(**self._connect_kwargs()) as conn:
for pm in ("apt-get", "dnf", "yum"):
result = await conn.run(f"command -v {pm}", check=False)
if result.returncode == 0:
return pm
raise SSHError("Kein unterstützter Paketmanager gefunden (apt/dnf/yum)")
async def stream_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
"""Run the distro update command and yield output lines live."""
import asyncssh
pm = await self.detect_package_manager()
if pm == "apt-get":
cmd = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
else:
cmd = f"{pm} update -y"
password = (self.credentials.password if self.credentials else None) or ""
full_cmd = f"echo '{password}' | sudo -S sh -c '{cmd}'"
async with asyncssh.connect(**self._connect_kwargs()) as conn:
async with conn.create_process(full_cmd) as process:
async for line in process.stdout: # type: ignore[union-attr]
yield str(line).rstrip()
await process.wait()
if process.returncode != 0:
raise SSHError(f"Update fehlgeschlagen (exit {process.returncode})")
if reboot_if_required:
async with asyncssh.connect(**self._connect_kwargs()) as conn:
check = await conn.run("test -f /var/run/reboot-required", check=False)
if check.returncode == 0:
yield "REBOOT erforderlich — wird ausgeführt..."
await conn.run(f"echo '{password}' | sudo -S reboot", check=False)
-122
View File
@@ -1,122 +0,0 @@
"""WinRM service: connect to Windows hosts, run PowerShell, stream output.
Uses python-winrm (pywinrm). All blocking calls run in a thread pool
so the async event loop is never blocked.
"""
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from app.core.config import get_settings
from app.core.exceptions import WinRMError
from app.core.logging import get_logger
settings = get_settings()
logger = get_logger(__name__)
@dataclass
class WinRMCredentials:
username: str
password: str
class WinRMService:
"""Wraps pywinrm for async usage."""
def __init__(
self,
hostname: str,
port: int = 5985,
credentials: WinRMCredentials | None = None,
transport: str | None = None,
) -> None:
self.hostname = hostname
self.port = port
self.credentials = credentials
self.transport = transport or settings.winrm_transport
def _build_session(self): # type: ignore[no-untyped-def]
import winrm # pywinrm
scheme = "https" if self.port == 5986 else "http"
endpoint = f"{scheme}://{self.hostname}:{self.port}/wsman"
kwargs: dict = {
"transport": self.transport,
"server_cert_validation": settings.winrm_cert_validation,
"operation_timeout_sec": settings.winrm_operation_timeout,
"read_timeout_sec": settings.winrm_read_timeout,
}
if self.credentials:
kwargs["username"] = self.credentials.username
kwargs["password"] = self.credentials.password
return winrm.Session(endpoint, **kwargs)
async def test_connection(self) -> tuple[bool, str]:
"""Run a trivial command to verify connectivity."""
def _run() -> tuple[bool, str]:
try:
session = self._build_session()
result = session.run_ps("$env:COMPUTERNAME")
if result.status_code == 0:
name = result.std_out.decode(errors="replace").strip()
return True, f"Verbunden mit {name}"
return False, result.std_err.decode(errors="replace").strip()
except Exception as exc: # noqa: BLE001 - surface any transport error
return False, str(exc)
return await asyncio.to_thread(_run)
async def run_powershell(self, script: str) -> str:
"""Run a PowerShell script and return stdout. Raises WinRMError on failure."""
def _run() -> str:
session = self._build_session()
result = session.run_ps(script)
if result.status_code != 0:
err = result.std_err.decode(errors="replace").strip()
raise WinRMError(f"PowerShell exit {result.status_code}: {err}")
return result.std_out.decode(errors="replace")
return await asyncio.to_thread(_run)
async def stream_powershell(self, script: str) -> AsyncIterator[str]:
"""Yield output lines as they arrive (scaffold: runs to completion, then yields).
TODO: switch to winrm Protocol with shell polling for true streaming.
"""
output = await self.run_powershell(script)
for line in output.splitlines():
yield line
await asyncio.sleep(0) # keep the loop responsive
async def get_pending_updates(self) -> list[str]:
"""Query Windows Update for pending updates (titles only)."""
script = """
$session = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0")
$result.Updates | ForEach-Object { $_.Title }
"""
output = await self.run_powershell(script)
return [line.strip() for line in output.splitlines() if line.strip()]
async def install_updates(self) -> AsyncIterator[str]:
"""Install all pending Windows Updates, yielding progress lines."""
script = """
$session = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0")
Write-Output "Gefundene Updates: $($result.Updates.Count)"
$toInstall = New-Object -ComObject Microsoft.Update.UpdateColl
$result.Updates | ForEach-Object { $toInstall.Add($_) | Out-Null }
$installer = $session.CreateUpdateInstaller()
$installer.Updates = $toInstall
$installResult = $installer.Install()
Write-Output "ResultCode: $($installResult.ResultCode)"
Write-Output "RebootRequired: $($installResult.RebootRequired)"
"""
async for line in self.stream_powershell(script):
yield line
-6
View File
@@ -1,6 +0,0 @@
"""WebSocket layer: Socket.io server, connection manager, event handlers."""
from app.websocket.handlers import sio
from app.websocket.manager import WSManager, ws_manager
__all__ = ["WSManager", "sio", "ws_manager"]
-103
View File
@@ -1,103 +0,0 @@
"""Socket.io server instance and event handlers.
Events (see AGENTS.md):
Server -> Client: job:start, job:log, job:progress, job:complete
Client -> Server: job:subscribe, job:unsubscribe, job:cancel
"""
from typing import Any
import socketio
from app.core.config import get_settings
from app.core.logging import get_logger
from app.websocket.manager import ws_manager
settings = get_settings()
logger = get_logger(__name__)
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins=settings.cors_origin_list or "*",
# Redis manager for multi-worker pub/sub; set in main.py when Redis is up
)
@sio.event
async def connect(sid: str, environ: dict, auth: dict | None) -> None: # noqa: ARG001
# TODO: validate JWT from auth payload before accepting
ws_manager.register(sid)
@sio.event
async def disconnect(sid: str) -> None:
ws_manager.unregister(sid)
@sio.event
async def subscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
"""Client subscribes to a job's live log room."""
job_id = int(data.get("job_id", 0))
room = ws_manager.subscribe(sid, job_id)
await sio.enter_room(sid, room)
logger.info("ws.subscribed", sid=sid, room=room)
return {"ok": True, "room": room}
@sio.event
async def unsubscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
job_id = int(data.get("job_id", 0))
room = ws_manager.unsubscribe(sid, job_id)
await sio.leave_room(sid, room)
return {"ok": True}
@sio.event
async def cancel_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
"""Client requests job cancellation."""
from app.services.job_runner import job_runner
job_id = int(data.get("job_id", 0))
cancelled = await job_runner.cancel(job_id)
return {"ok": cancelled}
# ---------------------------------------------------------------------------
# Emit helpers used by services / job runner
# ---------------------------------------------------------------------------
async def emit_job_start(job_id: int, server_id: int, job_type: str) -> None:
await sio.emit(
"job:start",
{"job_id": job_id, "server_id": server_id, "type": job_type},
room=ws_manager.room_for(job_id),
)
async def emit_job_log(job_id: int, line: str, level: str = "info") -> None:
from datetime import UTC, datetime
await sio.emit(
"job:log",
{"job_id": job_id, "line": line, "level": level, "timestamp": datetime.now(UTC).isoformat()},
room=ws_manager.room_for(job_id),
)
async def emit_job_progress(
job_id: int, percent: int, phase: str, node: str | None = None
) -> None:
await sio.emit(
"job:progress",
{"job_id": job_id, "percent": percent, "phase": phase, "node": node},
room=ws_manager.room_for(job_id),
)
async def emit_job_complete(job_id: int, status: str, duration: float | None) -> None:
await sio.emit(
"job:complete",
{"job_id": job_id, "status": status, "duration": duration},
room=ws_manager.room_for(job_id),
)
-43
View File
@@ -1,43 +0,0 @@
"""Connection manager for Socket.io rooms (one room per update job)."""
from app.core.logging import get_logger
logger = get_logger(__name__)
class WSManager:
"""Tracks active Socket.io sessions and job-room subscriptions."""
def __init__(self) -> None:
# sid -> set of job rooms the client subscribed to
self._subscriptions: dict[str, set[str]] = {}
def register(self, sid: str) -> None:
self._subscriptions.setdefault(sid, set())
logger.info("ws.client_connected", sid=sid)
def unregister(self, sid: str) -> None:
self._subscriptions.pop(sid, None)
logger.info("ws.client_disconnected", sid=sid)
def subscribe(self, sid: str, job_id: int) -> str:
room = self.room_for(job_id)
self._subscriptions.setdefault(sid, set()).add(room)
return room
def unsubscribe(self, sid: str, job_id: int) -> str:
room = self.room_for(job_id)
if sid in self._subscriptions:
self._subscriptions[sid].discard(room)
return room
@staticmethod
def room_for(job_id: int) -> str:
return f"job:{job_id}"
@property
def client_count(self) -> int:
return len(self._subscriptions)
ws_manager = WSManager()