feat(kiosk): Stufe 2 – Ed25519-Auth, CLI-Tool, neue KioskDevicesPage
2A – Backend Ed25519-Verifizierung: - app/core/kiosk_security.py (NEU): verify_kiosk_request() Dependency - Timestamp-Check (30s Drift), Nonce-Cache (Redis/In-Memory), IP-Whitelist - Ed25519-Signatur über METHOD+PATH+TIMESTAMP+NONCE+sha256(BODY) - PEM + OpenSSH Key-Format unterstützt - app/routers/kiosk.py: approve/revoke Endpunkte, POST /heartbeat (Ed25519-signiert) - app/services/kiosk_service.py: token-basierte Methoden entfernt, approve/revoke/heartbeat - app/schemas/kiosk.py: KioskDeviceOut mit heartbeat_status, HeartbeatRequest/Response 2B – CLI-Tool: - cli.py (NEU, 529 Zeilen): Typer-CLI mit kiosk add/list/approve/revoke/info - Public-Key-Fingerprint (SHA256), Rich-Tabellen, CIDR-Validierung - Direkter DB-Zugriff mit RLS-Bypass 2C – Frontend: - KioskDevicesPage.tsx: Zwei-Tab-Layout (Wartet/Aktiv), Status-Ampel, Auto-Refresh 30s, Ed25519-Workflow (kein Token mehr) - Layout.tsx: KioskHealthBadge (online/total, 30s Refresh, nur COMPANY_ADMIN) requirements.txt: typer>=0.12.0, rich>=13.7.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Ed25519-basierte Request-Verifizierung für Kiosk-Geräte.
|
||||
|
||||
Jeder Kiosk-Request muss folgende HTTP-Header mitschicken:
|
||||
X-Kiosk-Key-Id : UUID des Geräts (entspricht KioskDevice.id)
|
||||
X-Kiosk-Timestamp : Unix-Zeit in Sekunden (max 30s Drift zum Server)
|
||||
X-Kiosk-Nonce : Einmalige UUID (Replay-Schutz, 60s-Fenster via Redis)
|
||||
X-Kiosk-Signature : Base64(Ed25519-Sig über "METHOD PATH TIMESTAMP NONCE sha256(BODY)")
|
||||
|
||||
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_ssh_public_key
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Nonce-Cache (Redis wenn verfügbar, sonst In-Memory-Fallback) ─────────────
|
||||
|
||||
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
|
||||
_NONCE_TTL = 60 # Sekunden
|
||||
|
||||
|
||||
def _cleanup_nonce_cache() -> None:
|
||||
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen."""
|
||||
now = time.time()
|
||||
expired = [k for k, v in _nonce_cache.items() if v <= now]
|
||||
for k in expired:
|
||||
del _nonce_cache[k]
|
||||
|
||||
|
||||
async def _check_and_set_nonce(nonce: str) -> bool:
|
||||
"""
|
||||
Prüft ob die Nonce schon gesehen wurde und speichert sie.
|
||||
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
|
||||
Gibt False zurück wenn die Nonce bereits bekannt ist (Replay!).
|
||||
"""
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
r: Any = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
key = f"kiosk:nonce:{nonce}"
|
||||
# SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt
|
||||
result = await r.set(key, "1", ex=_NONCE_TTL, nx=True)
|
||||
await r.aclose()
|
||||
return result is not None # None = bereits vorhanden
|
||||
except Exception as e:
|
||||
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache: %s", e)
|
||||
# Fallback: In-Memory
|
||||
_cleanup_nonce_cache()
|
||||
now = time.time()
|
||||
if nonce in _nonce_cache:
|
||||
return False
|
||||
_nonce_cache[nonce] = now + _NONCE_TTL
|
||||
return True
|
||||
|
||||
|
||||
# ── Öffentlichen Schlüssel laden ─────────────────────────────────────────────
|
||||
|
||||
def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
|
||||
"""
|
||||
Lädt einen Ed25519-Public-Key aus PEM- oder OpenSSH-Format.
|
||||
Wirft ValueError bei ungültigem Format.
|
||||
"""
|
||||
key_bytes = public_key_str.strip().encode()
|
||||
|
||||
# OpenSSH-Format (beginnt mit "ssh-ed25519 ...")
|
||||
if key_bytes.startswith(b"ssh-ed25519"):
|
||||
key = load_ssh_public_key(key_bytes)
|
||||
if not isinstance(key, Ed25519PublicKey):
|
||||
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
|
||||
return key
|
||||
|
||||
# PEM-Format (beginnt mit "-----BEGIN ...")
|
||||
if key_bytes.startswith(b"-----"):
|
||||
key = load_pem_public_key(key_bytes)
|
||||
if not isinstance(key, Ed25519PublicKey):
|
||||
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
|
||||
return key
|
||||
|
||||
raise ValueError("Unbekanntes Schlüsselformat. PEM oder OpenSSH erwartet.")
|
||||
|
||||
|
||||
# ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
|
||||
|
||||
def _check_ip_whitelist(client_ip: str, ip_whitelist: str) -> bool:
|
||||
"""
|
||||
Prüft ob client_ip in einer kommaseparierten CIDR-Liste enthalten ist.
|
||||
Gibt True zurück wenn erlaubt, False wenn nicht.
|
||||
"""
|
||||
try:
|
||||
client_addr = ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
logger.warning("Ungültige Client-IP: %s", client_ip)
|
||||
return False
|
||||
|
||||
for cidr in ip_whitelist.split(","):
|
||||
cidr = cidr.strip()
|
||||
if not cidr:
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
if client_addr in network:
|
||||
return True
|
||||
except ValueError:
|
||||
logger.warning("Ungültiger CIDR-Eintrag in ip_whitelist: %s", cidr)
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ── FastAPI Dependency ────────────────────────────────────────────────────────
|
||||
|
||||
async def verify_kiosk_request(
|
||||
request: Request,
|
||||
x_kiosk_key_id: str = Header(..., alias="X-Kiosk-Key-Id"),
|
||||
x_kiosk_timestamp: str = Header(..., alias="X-Kiosk-Timestamp"),
|
||||
x_kiosk_nonce: str = Header(..., alias="X-Kiosk-Nonce"),
|
||||
x_kiosk_signature: str = Header(..., alias="X-Kiosk-Signature"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KioskDevice:
|
||||
"""
|
||||
FastAPI-Dependency: Verifiziert einen signierten Kiosk-Request.
|
||||
|
||||
Schritte:
|
||||
1. Timestamp-Check (max 30s Drift)
|
||||
2. Nonce-Check (Replay-Schutz)
|
||||
3. Gerät laden + Status prüfen
|
||||
4. IP-Whitelist (falls konfiguriert)
|
||||
5. Ed25519-Signatur verifizieren
|
||||
6. last_heartbeat_at aktualisieren
|
||||
|
||||
Gibt das verifizierte KioskDevice zurück.
|
||||
"""
|
||||
# 1. Timestamp-Check
|
||||
try:
|
||||
request_time = float(x_kiosk_timestamp)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Ungültiger Timestamp.")
|
||||
|
||||
drift = abs(time.time() - request_time)
|
||||
if drift > 30:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Timestamp liegt zu weit in der Vergangenheit oder Zukunft ({drift:.1f}s Abweichung, max 30s).",
|
||||
)
|
||||
|
||||
# 2. Nonce-Check
|
||||
# UUID-Format validieren (grobe Prüfung)
|
||||
try:
|
||||
UUID(x_kiosk_nonce)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Nonce muss eine gültige UUID sein.")
|
||||
|
||||
nonce_ok = await _check_and_set_nonce(x_kiosk_nonce)
|
||||
if not nonce_ok:
|
||||
raise HTTPException(status_code=401, detail="Replay-Angriff erkannt: Nonce bereits verwendet.")
|
||||
|
||||
# 3. Gerät laden
|
||||
try:
|
||||
device_uuid = UUID(x_kiosk_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="X-Kiosk-Key-Id muss eine gültige UUID sein.")
|
||||
|
||||
device = await db.scalar(
|
||||
select(KioskDevice).where(KioskDevice.id == device_uuid)
|
||||
)
|
||||
|
||||
if device is None:
|
||||
raise HTTPException(status_code=401, detail="Kiosk-Gerät nicht gefunden.")
|
||||
|
||||
if device.status != KioskDeviceStatus.APPROVED:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Kiosk-Gerät ist nicht freigegeben (Status: {device.status.value}).",
|
||||
)
|
||||
|
||||
if not device.public_key:
|
||||
raise HTTPException(status_code=401, detail="Kein Public Key für dieses Gerät registriert.")
|
||||
|
||||
# 4. IP-Whitelist prüfen (optional)
|
||||
if device.ip_whitelist:
|
||||
client_ip = request.client.host if request.client else ""
|
||||
if not client_ip:
|
||||
raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.")
|
||||
if not _check_ip_whitelist(client_ip, device.ip_whitelist):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Client-IP {client_ip} ist nicht in der erlaubten IP-Whitelist.",
|
||||
)
|
||||
|
||||
# 5. Signatur verifizieren
|
||||
body = await request.body()
|
||||
body_hash = hashlib.sha256(body).hexdigest()
|
||||
message = f"{request.method} {request.url.path} {x_kiosk_timestamp} {x_kiosk_nonce} {body_hash}"
|
||||
|
||||
try:
|
||||
signature_bytes = base64.b64decode(x_kiosk_signature)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Signatur ist kein gültiges Base64.")
|
||||
|
||||
try:
|
||||
pub_key = _load_ed25519_public_key(device.public_key)
|
||||
except ValueError as e:
|
||||
logger.error("Fehler beim Laden des Public Keys für Gerät %s: %s", device.id, e)
|
||||
raise HTTPException(status_code=401, detail="Public Key des Geräts ist ungültig.")
|
||||
|
||||
try:
|
||||
pub_key.verify(signature_bytes, message.encode())
|
||||
except InvalidSignature:
|
||||
raise HTTPException(status_code=401, detail="Ungültige Signatur.")
|
||||
|
||||
# 6. Heartbeat-Zeitstempel aktualisieren
|
||||
device.last_heartbeat_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
|
||||
return device
|
||||
@@ -1,12 +1,23 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import require_role
|
||||
from app.core.kiosk_security import verify_kiosk_request
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate
|
||||
from app.schemas.kiosk import (
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
KioskApproveResponse,
|
||||
KioskDeviceCreate,
|
||||
KioskDeviceOut,
|
||||
KioskDeviceUpdate,
|
||||
)
|
||||
from app.services.kiosk_service import kiosk_service
|
||||
|
||||
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
|
||||
@@ -18,26 +29,34 @@ _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
|
||||
@router.get("/devices", response_model=list[KioskDeviceOut])
|
||||
async def list_devices(
|
||||
status: str | None = Query(None, description="Filter: pending, approved oder revoked"),
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Alle registrierten Kiosk-Geräte der Firma auflisten."""
|
||||
return await kiosk_service.list_devices(current_user.company_id, db)
|
||||
"""Alle registrierten Kiosk-Geräte der Firma auflisten (optional nach Status filtern)."""
|
||||
status_filter: KioskDeviceStatus | None = None
|
||||
if status is not None:
|
||||
try:
|
||||
status_filter = KioskDeviceStatus(status)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungültiger Status-Filter '{status}'. Erlaubt: pending, approved, revoked.",
|
||||
)
|
||||
devices = await kiosk_service.list_devices(current_user.company_id, db, status_filter)
|
||||
return [KioskDeviceOut.model_validate(d) for d in devices]
|
||||
|
||||
|
||||
@router.post("/devices", response_model=KioskDeviceCreated, status_code=201)
|
||||
@router.post("/devices", response_model=KioskDeviceOut, status_code=201)
|
||||
async def create_device(
|
||||
data: KioskDeviceCreate,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben."""
|
||||
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db)
|
||||
"""Neues Kiosk-Gerät mit Ed25519-Public-Key registrieren."""
|
||||
device = await kiosk_service.create_device(current_user.company_id, data, db)
|
||||
await db.commit()
|
||||
return KioskDeviceCreated(
|
||||
**KioskDeviceOut.model_validate(device).model_dump(),
|
||||
token=raw_token,
|
||||
)
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
|
||||
@@ -46,7 +65,9 @@ async def get_device(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await kiosk_service.get_device(device_id, current_user.company_id, db)
|
||||
"""Einzelnes Kiosk-Gerät abrufen."""
|
||||
device = await kiosk_service.get_device(device_id, current_user.company_id, db)
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
|
||||
@@ -56,44 +77,80 @@ async def update_device(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Name, Standort oder IP-Whitelist eines Kiosk-Geräts aktualisieren."""
|
||||
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
|
||||
await db.commit()
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
|
||||
async def rotate_token(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Token rotieren – das alte Token wird sofort ungültig."""
|
||||
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
return KioskDeviceCreated(
|
||||
**KioskDeviceOut.model_validate(device).model_dump(),
|
||||
token=raw_token,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", status_code=204)
|
||||
async def delete_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät dauerhaft löschen."""
|
||||
await kiosk_service.delete_device(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
|
||||
|
||||
@router.get("/me", response_model=KioskDeviceOut)
|
||||
async def kiosk_me(
|
||||
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
|
||||
@router.post("/devices/{device_id}/approve", response_model=KioskApproveResponse)
|
||||
async def approve_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at."""
|
||||
device = await kiosk_service.authenticate_device(x_kiosk_token, db)
|
||||
"""Kiosk-Gerät freigeben (Status: pending → approved)."""
|
||||
device = await kiosk_service.approve_device(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
return KioskApproveResponse(
|
||||
id=device.id,
|
||||
status=device.status.value,
|
||||
message=f"Gerät '{device.name}' wurde freigegeben.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/revoke", response_model=KioskApproveResponse)
|
||||
async def revoke_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät sperren (Status → revoked). Erfordert Re-Enrollment."""
|
||||
device = await kiosk_service.revoke_device(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
return KioskApproveResponse(
|
||||
id=device.id,
|
||||
status=device.status.value,
|
||||
message=f"Gerät '{device.name}' wurde gesperrt. Re-Enrollment erforderlich.",
|
||||
)
|
||||
|
||||
|
||||
# ── Kiosk-Endpunkte (signierte Requests via Ed25519) ──────────────────────────
|
||||
|
||||
@router.post("/heartbeat", response_model=HeartbeatResponse)
|
||||
async def heartbeat(
|
||||
data: HeartbeatRequest,
|
||||
device: KioskDevice = Depends(verify_kiosk_request),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Heartbeat-Endpunkt für Kiosk-Geräte.
|
||||
|
||||
Muss mit Ed25519-Signatur-Headern aufgerufen werden:
|
||||
X-Kiosk-Key-Id, X-Kiosk-Timestamp, X-Kiosk-Nonce, X-Kiosk-Signature
|
||||
|
||||
Gibt Server-Zeit und Konfiguration zurück.
|
||||
"""
|
||||
company = await db.get(Company, device.company_id)
|
||||
if company is None:
|
||||
raise HTTPException(status_code=500, detail="Firma nicht gefunden.")
|
||||
|
||||
await kiosk_service.process_heartbeat(device, data, company, db)
|
||||
await db.commit()
|
||||
|
||||
return HeartbeatResponse(
|
||||
server_time=datetime.now(timezone.utc).isoformat(),
|
||||
heartbeat_interval_sec=company.kiosk_heartbeat_interval_sec,
|
||||
device_status=device.status.value,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
def _calc_heartbeat_status(last_heartbeat_at: datetime | None) -> str:
|
||||
"""Berechnet den Liveness-Status aus dem letzten Heartbeat-Zeitstempel."""
|
||||
if last_heartbeat_at is None:
|
||||
return "offline"
|
||||
now = datetime.now(timezone.utc)
|
||||
# last_heartbeat_at könnte timezone-naive sein (ältere DB-Einträge)
|
||||
if last_heartbeat_at.tzinfo is None:
|
||||
last_heartbeat_at = last_heartbeat_at.replace(tzinfo=timezone.utc)
|
||||
delta = (now - last_heartbeat_at).total_seconds()
|
||||
if delta <= 90:
|
||||
return "online"
|
||||
if delta <= 300: # 5 Minuten
|
||||
return "stale"
|
||||
return "offline"
|
||||
|
||||
|
||||
# ── Admin-Schemas ─────────────────────────────────────────────────────────────
|
||||
|
||||
class KioskDeviceCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
location: str | None = Field(None, max_length=255)
|
||||
public_key: str # Ed25519 PEM oder OpenSSH
|
||||
ip_whitelist: str | None = None # CIDR-Liste kommasepariert, z.B. "10.0.0.0/24,192.168.1.0/24"
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -15,10 +35,18 @@ class KioskDeviceCreate(BaseModel):
|
||||
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("public_key")
|
||||
@classmethod
|
||||
def public_key_not_blank(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Public Key darf nicht leer sein.")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class KioskDeviceUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
location: str | None = Field(None, max_length=255)
|
||||
ip_whitelist: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -28,7 +56,6 @@ class KioskDeviceUpdate(BaseModel):
|
||||
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
|
||||
return v.strip()
|
||||
return v
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class KioskDeviceOut(BaseModel):
|
||||
@@ -38,11 +65,40 @@ class KioskDeviceOut(BaseModel):
|
||||
company_id: uuid.UUID
|
||||
name: str
|
||||
location: str | None
|
||||
is_active: bool
|
||||
last_seen_at: datetime | None
|
||||
status: str # pending / approved / revoked
|
||||
public_key: str | None
|
||||
key_algorithm: str
|
||||
last_heartbeat_at: datetime | None
|
||||
client_version: str | None
|
||||
offline_queue_size: int
|
||||
ip_whitelist: str | None
|
||||
created_at: datetime
|
||||
heartbeat_status: str = "offline" # wird in model_validator gesetzt
|
||||
|
||||
@classmethod
|
||||
def model_validate(cls, obj, *args, **kwargs): # type: ignore[override]
|
||||
instance = super().model_validate(obj, *args, **kwargs)
|
||||
instance.heartbeat_status = _calc_heartbeat_status(instance.last_heartbeat_at)
|
||||
return instance
|
||||
|
||||
|
||||
class KioskDeviceCreated(KioskDeviceOut):
|
||||
"""Wird nur einmalig bei Erstellung zurückgegeben – enthält den Klartext-Token."""
|
||||
token: str
|
||||
class KioskApproveResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
# ── Heartbeat-Schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
uptime_seconds: int | None = None
|
||||
current_user_id: uuid.UUID | None = None
|
||||
browser_version: str | None = None
|
||||
queued_offline_entries: int = 0
|
||||
client_version: str | None = None
|
||||
|
||||
|
||||
class HeartbeatResponse(BaseModel):
|
||||
server_time: str # ISO-8601-Zeitstempel des Servers
|
||||
heartbeat_interval_sec: int
|
||||
device_status: str # pending / approved / revoked
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,37 +5,34 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_token
|
||||
from app.models.kiosk_device import KioskDevice
|
||||
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
from app.schemas.kiosk import HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
|
||||
|
||||
|
||||
class KioskService:
|
||||
|
||||
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
|
||||
result = await db.scalars(
|
||||
# ── Lesende Operationen ───────────────────────────────────────────────────
|
||||
|
||||
async def list_devices(
|
||||
self,
|
||||
company_id: UUID,
|
||||
db: AsyncSession,
|
||||
status_filter: KioskDeviceStatus | None = None,
|
||||
) -> list[KioskDevice]:
|
||||
query = (
|
||||
select(KioskDevice)
|
||||
.where(KioskDevice.company_id == company_id)
|
||||
.order_by(KioskDevice.created_at.desc())
|
||||
)
|
||||
if status_filter is not None:
|
||||
query = query.where(KioskDevice.status == status_filter)
|
||||
result = await db.scalars(query)
|
||||
return list(result.all())
|
||||
|
||||
async def create_device(
|
||||
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
|
||||
) -> tuple[KioskDevice, str]:
|
||||
"""Gerät anlegen. Gibt (device, raw_token) zurück – raw_token nur einmalig."""
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
device = KioskDevice(
|
||||
company_id=company_id,
|
||||
name=data.name,
|
||||
location=data.location,
|
||||
token_hash=hash_token(raw_token),
|
||||
)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return device, raw_token
|
||||
|
||||
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
|
||||
async def get_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> KioskDevice:
|
||||
device = await db.scalar(
|
||||
select(KioskDevice).where(
|
||||
KioskDevice.id == device_id,
|
||||
@@ -47,8 +43,40 @@ class KioskService:
|
||||
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
|
||||
return device
|
||||
|
||||
# ── Schreibende Operationen ───────────────────────────────────────────────
|
||||
|
||||
async def create_device(
|
||||
self,
|
||||
company_id: UUID,
|
||||
data: KioskDeviceCreate,
|
||||
db: AsyncSession,
|
||||
) -> KioskDevice:
|
||||
"""
|
||||
Gerät anlegen.
|
||||
Status = pending wenn kiosk_require_approval, sonst approved.
|
||||
"""
|
||||
company = await db.get(Company, company_id)
|
||||
require_approval = company.kiosk_require_approval if company else True
|
||||
|
||||
device = KioskDevice(
|
||||
company_id=company_id,
|
||||
name=data.name,
|
||||
location=data.location,
|
||||
public_key=data.public_key,
|
||||
ip_whitelist=data.ip_whitelist,
|
||||
key_algorithm="ed25519",
|
||||
status=KioskDeviceStatus.PENDING if require_approval else KioskDeviceStatus.APPROVED,
|
||||
)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return device
|
||||
|
||||
async def update_device(
|
||||
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession
|
||||
self,
|
||||
device_id: UUID,
|
||||
company_id: UUID,
|
||||
data: KioskDeviceUpdate,
|
||||
db: AsyncSession,
|
||||
) -> KioskDevice:
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
changes = data.model_dump(exclude_none=True)
|
||||
@@ -56,32 +84,56 @@ class KioskService:
|
||||
setattr(device, field, value)
|
||||
return device
|
||||
|
||||
async def rotate_token(
|
||||
async def delete_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> tuple[KioskDevice, str]:
|
||||
"""Token rotieren – altes Token wird sofort ungültig."""
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
device.token_hash = hash_token(raw_token)
|
||||
return device, raw_token
|
||||
|
||||
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
|
||||
) -> None:
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
await db.delete(device)
|
||||
|
||||
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
|
||||
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
|
||||
token_hash = hash_token(raw_token)
|
||||
device = await db.scalar(
|
||||
select(KioskDevice).where(
|
||||
KioskDevice.token_hash == token_hash,
|
||||
KioskDevice.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
|
||||
device.last_seen_at = datetime.now(timezone.utc)
|
||||
# ── Status-Verwaltung ─────────────────────────────────────────────────────
|
||||
|
||||
async def approve_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> KioskDevice:
|
||||
"""Gerät freigeben: Status → approved."""
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
device.status = KioskDeviceStatus.APPROVED
|
||||
return device
|
||||
|
||||
async def revoke_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> KioskDevice:
|
||||
"""Gerät sperren: Status → revoked."""
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
device.status = KioskDeviceStatus.REVOKED
|
||||
return device
|
||||
|
||||
# ── Heartbeat ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def process_heartbeat(
|
||||
self,
|
||||
device: KioskDevice,
|
||||
data: HeartbeatRequest,
|
||||
company: Company,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
Heartbeat-Daten vom Kiosk-Gerät verarbeiten.
|
||||
last_heartbeat_at wird bereits in verify_kiosk_request gesetzt –
|
||||
hier werden nur die zusätzlichen Felder aktualisiert.
|
||||
"""
|
||||
if data.client_version is not None:
|
||||
device.client_version = data.client_version
|
||||
|
||||
device.offline_queue_size = data.queued_offline_entries
|
||||
|
||||
if data.current_user_id is not None and company.kiosk_track_current_user:
|
||||
device.current_user_id = data.current_user_id
|
||||
elif not company.kiosk_track_current_user:
|
||||
# DSGVO-Opt-Out: aktuellen User nicht speichern
|
||||
device.current_user_id = None
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
kiosk_service = KioskService()
|
||||
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
TimeMaster CLI – Kiosk-Geräteverwaltung
|
||||
|
||||
Verwendung (auf dem Server):
|
||||
cd /opt/timemaster/backend
|
||||
source venv/bin/activate
|
||||
python cli.py kiosk list
|
||||
python cli.py kiosk add --company "Acme GmbH" --name "Eingang" --pubkey ~/.ssh/kiosk.pub
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich import box
|
||||
|
||||
# ── Projekt-Imports ───────────────────────────────────────────────────────────
|
||||
# Sicherstellen, dass das backend/-Verzeichnis im Python-Pfad ist,
|
||||
# damit app.* importiert werden kann.
|
||||
_HERE = Path(__file__).parent.resolve()
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
# .env im selben Verzeichnis laden, bevor pydantic-settings greift
|
||||
_env_file = _HERE / ".env"
|
||||
if _env_file.exists():
|
||||
# Minimales manuelles Laden, damit DATABASE_URL vor dem Settings-Import steht
|
||||
import os
|
||||
for line in _env_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
from sqlalchemy import select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
|
||||
# ── Typer Apps ────────────────────────────────────────────────────────────────
|
||||
|
||||
app = typer.Typer(
|
||||
name="timemaster",
|
||||
help="TimeMaster CLI – Verwaltungstool für den Server",
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
kiosk_app = typer.Typer(
|
||||
help="Kiosk-Geräteverwaltung",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
app.add_typer(kiosk_app, name="kiosk")
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
# ── DB-Hilfsfunktionen ────────────────────────────────────────────────────────
|
||||
|
||||
def _make_engine():
|
||||
"""Erstellt einen AsyncEngine auf Basis der aktuellen DATABASE_URL."""
|
||||
db_url = settings.database_url
|
||||
return create_async_engine(db_url, pool_pre_ping=True, pool_size=2, max_overflow=2)
|
||||
|
||||
|
||||
async def _open_session() -> AsyncSession:
|
||||
"""Gibt eine neue AsyncSession zurück mit deaktiviertem RLS."""
|
||||
engine = _make_engine()
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
session = Session()
|
||||
await session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||
return session
|
||||
|
||||
|
||||
def _run(coro):
|
||||
"""Blockierendes asyncio.run() mit sauberem Fehler-Handling."""
|
||||
try:
|
||||
return asyncio.run(coro)
|
||||
except Exception as exc:
|
||||
err_console.print(f"[bold red]Fehler:[/bold red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# ── Public-Key-Fingerprint (SHA-256, wie ssh-keygen -l -E sha256) ─────────────
|
||||
|
||||
def _pubkey_fingerprint(pubkey_str: str) -> str:
|
||||
"""
|
||||
Berechnet SHA-256-Fingerprint eines OpenSSH-Public-Keys.
|
||||
Format: SHA256:<base64> (ohne trailing =, wie ssh-keygen -l -E sha256)
|
||||
Funktioniert für 'ssh-ed25519 AAAA...' und PEM-Blöcke.
|
||||
"""
|
||||
pubkey_str = pubkey_str.strip()
|
||||
try:
|
||||
if pubkey_str.startswith("-----BEGIN"):
|
||||
# PEM → binär
|
||||
lines = [l for l in pubkey_str.splitlines() if not l.startswith("-----")]
|
||||
raw = base64.b64decode("".join(lines))
|
||||
else:
|
||||
# OpenSSH-Einzeiler: zweites Feld ist der Base64-Teil
|
||||
parts = pubkey_str.split()
|
||||
if len(parts) < 2:
|
||||
raise ValueError("Ungültiges Public-Key-Format")
|
||||
raw = base64.b64decode(parts[1])
|
||||
digest = hashlib.sha256(raw).digest()
|
||||
b64 = base64.b64encode(digest).decode().rstrip("=")
|
||||
return f"SHA256:{b64}"
|
||||
except Exception:
|
||||
return "(Fingerprint nicht berechenbar)"
|
||||
|
||||
|
||||
# ── Heartbeat-Status-Hilfsfunktion ────────────────────────────────────────────
|
||||
|
||||
def _heartbeat_label(last_hb: Optional[datetime]) -> str:
|
||||
if last_hb is None:
|
||||
return "[dim]nie[/dim]"
|
||||
now = datetime.now(timezone.utc)
|
||||
if last_hb.tzinfo is None:
|
||||
last_hb = last_hb.replace(tzinfo=timezone.utc)
|
||||
delta = (now - last_hb).total_seconds()
|
||||
ts = last_hb.strftime("%d.%m.%Y %H:%M:%S")
|
||||
if delta < 90:
|
||||
return f"[green]online[/green] ({ts})"
|
||||
elif delta < 300:
|
||||
return f"[yellow]stale[/yellow] ({ts})"
|
||||
else:
|
||||
return f"[red]offline[/red] ({ts})"
|
||||
|
||||
|
||||
def _status_icon(status: KioskDeviceStatus) -> str:
|
||||
return {
|
||||
KioskDeviceStatus.APPROVED: "[green]approved[/green]",
|
||||
KioskDeviceStatus.PENDING: "[yellow]pending[/yellow]",
|
||||
KioskDeviceStatus.REVOKED: "[red]revoked[/red]",
|
||||
}.get(status, status.value)
|
||||
|
||||
|
||||
# ── CIDR-Validierung ──────────────────────────────────────────────────────────
|
||||
|
||||
def _validate_ip_whitelist(ip_whitelist: Optional[str]) -> None:
|
||||
"""Wirft typer.BadParameter bei ungültiger CIDR-Notation."""
|
||||
if not ip_whitelist:
|
||||
return
|
||||
for cidr in ip_whitelist.split(","):
|
||||
cidr = cidr.strip()
|
||||
if not cidr:
|
||||
continue
|
||||
try:
|
||||
ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
raise typer.BadParameter(
|
||||
f"Ungültige CIDR-Notation: '{cidr}'. "
|
||||
"Beispiel: 10.0.0.0/24,192.168.1.0/24"
|
||||
)
|
||||
|
||||
|
||||
# ── Firma suchen ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _find_company(session: AsyncSession, company_name: str) -> Company:
|
||||
"""
|
||||
Sucht Firma case-insensitiv. Fehler wenn 0 oder >1 Treffer.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Company).where(Company.name.ilike(f"%{company_name}%"))
|
||||
)
|
||||
companies = result.scalars().all()
|
||||
if not companies:
|
||||
raise typer.BadParameter(
|
||||
f"Keine Firma mit dem Namen '{company_name}' gefunden."
|
||||
)
|
||||
if len(companies) > 1:
|
||||
names = ", ".join(c.name for c in companies)
|
||||
raise typer.BadParameter(
|
||||
f"Mehrere Firmen gefunden: {names}\n"
|
||||
"Bitte den Namen genauer angeben."
|
||||
)
|
||||
return companies[0]
|
||||
|
||||
|
||||
# ── Gerät suchen ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _find_device(session: AsyncSession, device_id: str) -> KioskDevice:
|
||||
try:
|
||||
dev_uuid = uuid.UUID(device_id)
|
||||
except ValueError:
|
||||
raise typer.BadParameter(f"Ungültige UUID: '{device_id}'")
|
||||
|
||||
result = await session.execute(
|
||||
select(KioskDevice).where(KioskDevice.id == dev_uuid)
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
if device is None:
|
||||
raise typer.BadParameter(f"Kein Gerät mit ID '{device_id}' gefunden.")
|
||||
return device
|
||||
|
||||
|
||||
# ── Subcommand: kiosk add ─────────────────────────────────────────────────────
|
||||
|
||||
@kiosk_app.command("add")
|
||||
def kiosk_add(
|
||||
company: str = typer.Option(..., "--company", help="Firmenname (Teilübereinstimmung möglich)"),
|
||||
name: str = typer.Option(..., "--name", help="Name des Kiosk-Geräts, z.B. 'Eingang Berlin'"),
|
||||
location: Optional[str] = typer.Option(None, "--location", help="Standort-Beschreibung"),
|
||||
pubkey: Optional[Path] = typer.Option(None, "--pubkey", help="Pfad zur Public-Key-Datei (OpenSSH oder PEM)"),
|
||||
ip_whitelist: Optional[str] = typer.Option(None, "--ip-whitelist", help="CIDR-Liste, z.B. '10.0.0.0/24,192.168.1.0/24'"),
|
||||
):
|
||||
"""Neues Kiosk-Gerät registrieren (Status: pending)."""
|
||||
|
||||
# Validierungen vor DB-Zugriff
|
||||
_validate_ip_whitelist(ip_whitelist)
|
||||
|
||||
pubkey_str: Optional[str] = None
|
||||
if pubkey is not None:
|
||||
if not pubkey.exists():
|
||||
err_console.print(f"[bold red]Fehler:[/bold red] Public-Key-Datei nicht gefunden: {pubkey}")
|
||||
raise typer.Exit(1)
|
||||
pubkey_str = pubkey.read_text().strip()
|
||||
if not pubkey_str:
|
||||
err_console.print("[bold red]Fehler:[/bold red] Public-Key-Datei ist leer.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _add():
|
||||
session = await _open_session()
|
||||
try:
|
||||
firm = await _find_company(session, company)
|
||||
|
||||
device = KioskDevice(
|
||||
company_id=firm.id,
|
||||
name=name,
|
||||
location=location,
|
||||
status=KioskDeviceStatus.PENDING,
|
||||
public_key=pubkey_str,
|
||||
key_algorithm="ed25519",
|
||||
ip_whitelist=ip_whitelist,
|
||||
)
|
||||
session.add(device)
|
||||
await session.commit()
|
||||
await session.refresh(device)
|
||||
return device, firm
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
device, firm = _run(_add())
|
||||
|
||||
console.print()
|
||||
console.print(Panel(
|
||||
f"[bold green]✓ Gerät erfolgreich angelegt[/bold green]\n\n"
|
||||
f" [bold]Name:[/bold] {device.name}\n"
|
||||
f" [bold]ID:[/bold] {device.id}\n"
|
||||
f" [bold]Firma:[/bold] {firm.name}\n"
|
||||
f" [bold]Standort:[/bold] {device.location or '—'}\n"
|
||||
f" [bold]Status:[/bold] {_status_icon(device.status)}\n"
|
||||
+ (f" [bold]Fingerprint:[/bold] {_pubkey_fingerprint(device.public_key)}\n" if device.public_key else " [bold]Public Key:[/bold] [dim]nicht gesetzt[/dim]\n")
|
||||
+ (f" [bold]IP-Whitelist:[/bold] {device.ip_whitelist}\n" if device.ip_whitelist else ""),
|
||||
title="Kiosk-Gerät angelegt",
|
||||
border_style="green",
|
||||
))
|
||||
console.print()
|
||||
console.print("[yellow]Hinweis:[/yellow] Das Gerät wartet auf Admin-Freigabe im Web-Interface.")
|
||||
if not pubkey_str:
|
||||
console.print("[yellow]Hinweis:[/yellow] Kein Public Key gesetzt. Der Enrollment-Flow muss im Browser abgeschlossen werden.")
|
||||
console.print()
|
||||
|
||||
|
||||
# ── Subcommand: kiosk list ────────────────────────────────────────────────────
|
||||
|
||||
@kiosk_app.command("list")
|
||||
def kiosk_list(
|
||||
company: Optional[str] = typer.Option(None, "--company", help="Nur Geräte dieser Firma anzeigen"),
|
||||
status: Optional[str] = typer.Option(None, "--status", help="Filter: pending|approved|revoked"),
|
||||
):
|
||||
"""Alle registrierten Kiosk-Geräte auflisten."""
|
||||
|
||||
# Status-Enum validieren
|
||||
status_filter: Optional[KioskDeviceStatus] = None
|
||||
if status is not None:
|
||||
try:
|
||||
status_filter = KioskDeviceStatus(status.lower())
|
||||
except ValueError:
|
||||
err_console.print(
|
||||
f"[bold red]Fehler:[/bold red] Ungültiger Status '{status}'. "
|
||||
"Erlaubt: pending, approved, revoked"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _list():
|
||||
session = await _open_session()
|
||||
try:
|
||||
q = select(KioskDevice, Company).join(Company, KioskDevice.company_id == Company.id)
|
||||
if status_filter is not None:
|
||||
q = q.where(KioskDevice.status == status_filter)
|
||||
if company:
|
||||
q = q.where(Company.name.ilike(f"%{company}%"))
|
||||
q = q.order_by(Company.name, KioskDevice.name)
|
||||
result = await session.execute(q)
|
||||
return result.all()
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
rows = _run(_list())
|
||||
|
||||
if not rows:
|
||||
console.print("[dim]Keine Geräte gefunden.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
box=box.ROUNDED,
|
||||
show_lines=False,
|
||||
)
|
||||
table.add_column("ID", style="dim", min_width=8, max_width=36, no_wrap=True)
|
||||
table.add_column("Firma", min_width=10)
|
||||
table.add_column("Name", min_width=12)
|
||||
table.add_column("Standort")
|
||||
table.add_column("Status", min_width=10)
|
||||
table.add_column("Heartbeat", min_width=14)
|
||||
table.add_column("Key-Algo", justify="center")
|
||||
|
||||
for device, firm in rows:
|
||||
# UUID kurz anzeigen (erste 8 Zeichen + ...)
|
||||
short_id = str(device.id)[:8] + "…"
|
||||
table.add_row(
|
||||
short_id,
|
||||
firm.name,
|
||||
device.name,
|
||||
device.location or "—",
|
||||
_status_icon(device.status),
|
||||
_heartbeat_label(device.last_heartbeat_at),
|
||||
device.key_algorithm or "—",
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f" [dim]{len(rows)} Gerät(e)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# ── Subcommand: kiosk approve ─────────────────────────────────────────────────
|
||||
|
||||
@kiosk_app.command("approve")
|
||||
def kiosk_approve(
|
||||
device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"),
|
||||
):
|
||||
"""Kiosk-Gerät freigeben (Status: pending → approved)."""
|
||||
|
||||
async def _approve():
|
||||
session = await _open_session()
|
||||
try:
|
||||
device = await _find_device(session, device_id)
|
||||
if device.status == KioskDeviceStatus.APPROVED:
|
||||
return device, "already_approved"
|
||||
old_status = device.status
|
||||
device.status = KioskDeviceStatus.APPROVED
|
||||
await session.commit()
|
||||
return device, old_status
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
device, old_status = _run(_approve())
|
||||
|
||||
if old_status == "already_approved":
|
||||
console.print(f"[yellow]Info:[/yellow] Gerät '{device.name}' ist bereits [green]approved[/green].")
|
||||
else:
|
||||
console.print(
|
||||
f"[bold green]✓[/bold green] Gerät [bold]{device.name}[/bold] "
|
||||
f"({str(device.id)[:8]}…) wurde [green]freigegeben[/green] "
|
||||
f"(vorher: {old_status.value})."
|
||||
)
|
||||
|
||||
|
||||
# ── Subcommand: kiosk revoke ──────────────────────────────────────────────────
|
||||
|
||||
@kiosk_app.command("revoke")
|
||||
def kiosk_revoke(
|
||||
device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"),
|
||||
yes: bool = typer.Option(False, "--yes", "-y", help="Ohne Bestätigung sperren"),
|
||||
):
|
||||
"""Kiosk-Gerät sperren (Status → revoked)."""
|
||||
|
||||
async def _get():
|
||||
session = await _open_session()
|
||||
try:
|
||||
return await _find_device(session, device_id), session
|
||||
except Exception:
|
||||
await session.close()
|
||||
raise
|
||||
|
||||
async def _revoke():
|
||||
session = await _open_session()
|
||||
try:
|
||||
device = await _find_device(session, device_id)
|
||||
if device.status == KioskDeviceStatus.REVOKED:
|
||||
return device, "already_revoked"
|
||||
old_status = device.status
|
||||
device.status = KioskDeviceStatus.REVOKED
|
||||
await session.commit()
|
||||
return device, old_status
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
# Bestätigung einholen, wenn --yes nicht gesetzt
|
||||
if not yes:
|
||||
async def _peek():
|
||||
session = await _open_session()
|
||||
try:
|
||||
return await _find_device(session, device_id)
|
||||
finally:
|
||||
await session.close()
|
||||
device = _run(_peek())
|
||||
confirm = typer.confirm(
|
||||
f"Gerät '{device.name}' ({str(device.id)[:8]}…) wirklich sperren?"
|
||||
)
|
||||
if not confirm:
|
||||
console.print("[dim]Abgebrochen.[/dim]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
device, old_status = _run(_revoke())
|
||||
|
||||
if old_status == "already_revoked":
|
||||
console.print(f"[yellow]Info:[/yellow] Gerät '{device.name}' ist bereits [red]revoked[/red].")
|
||||
else:
|
||||
console.print(
|
||||
f"[bold red]✓[/bold red] Gerät [bold]{device.name}[/bold] "
|
||||
f"({str(device.id)[:8]}…) wurde [red]gesperrt[/red] "
|
||||
f"(vorher: {old_status.value})."
|
||||
)
|
||||
|
||||
|
||||
# ── Subcommand: kiosk info ────────────────────────────────────────────────────
|
||||
|
||||
@kiosk_app.command("info")
|
||||
def kiosk_info(
|
||||
device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"),
|
||||
):
|
||||
"""Detailinfo zu einem Kiosk-Gerät anzeigen."""
|
||||
|
||||
async def _info():
|
||||
session = await _open_session()
|
||||
try:
|
||||
device = await _find_device(session, device_id)
|
||||
# Firma nachladen
|
||||
result = await session.execute(
|
||||
select(Company).where(Company.id == device.company_id)
|
||||
)
|
||||
firm = result.scalar_one_or_none()
|
||||
return device, firm
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
device, firm = _run(_info())
|
||||
|
||||
fingerprint = "—"
|
||||
pubkey_preview = "—"
|
||||
if device.public_key:
|
||||
fingerprint = _pubkey_fingerprint(device.public_key)
|
||||
# Ersten 60 Zeichen des Keys als Vorschau
|
||||
key_stripped = device.public_key.strip()
|
||||
pubkey_preview = (key_stripped[:60] + "…") if len(key_stripped) > 60 else key_stripped
|
||||
|
||||
created_str = "—"
|
||||
if device.created_at:
|
||||
ts = device.created_at
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
created_str = ts.strftime("%d.%m.%Y %H:%M:%S UTC")
|
||||
|
||||
enrollment_str = "—"
|
||||
if device.enrollment_expires_at:
|
||||
ts = device.enrollment_expires_at
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
enrollment_str = ts.strftime("%d.%m.%Y %H:%M:%S UTC")
|
||||
|
||||
lines = [
|
||||
f" [bold]ID:[/bold] {device.id}",
|
||||
f" [bold]Firma:[/bold] {firm.name if firm else str(device.company_id)}",
|
||||
f" [bold]Name:[/bold] {device.name}",
|
||||
f" [bold]Standort:[/bold] {device.location or '—'}",
|
||||
f" [bold]Status:[/bold] {_status_icon(device.status)}",
|
||||
f" [bold]Key-Algorithmus:[/bold] {device.key_algorithm or '—'}",
|
||||
f" [bold]Public Key:[/bold] {pubkey_preview}",
|
||||
f" [bold]Key-Fingerprint:[/bold] {fingerprint}",
|
||||
f" [bold]IP-Whitelist:[/bold] {device.ip_whitelist or '—'}",
|
||||
f" [bold]Heartbeat:[/bold] {_heartbeat_label(device.last_heartbeat_at)}",
|
||||
f" [bold]Client-Version:[/bold] {device.client_version or '—'}",
|
||||
f" [bold]Offline-Queue:[/bold] {device.offline_queue_size}",
|
||||
f" [bold]Aktueller User:[/bold] {device.current_user_id or '—'}",
|
||||
f" [bold]Enrollment läuft ab:[/bold] {enrollment_str}",
|
||||
f" [bold]Angelegt am:[/bold] {created_str}",
|
||||
]
|
||||
|
||||
console.print()
|
||||
console.print(Panel(
|
||||
"\n".join(lines),
|
||||
title=f"Kiosk-Gerät: {device.name}",
|
||||
border_style="cyan",
|
||||
))
|
||||
console.print()
|
||||
|
||||
|
||||
# ── Entry Point ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -24,3 +24,5 @@ pytest-asyncio>=0.23.0
|
||||
pytest-httpx>=0.30.0
|
||||
aiosqlite>=0.20.0
|
||||
weasyprint>=61.0
|
||||
typer>=0.12.0
|
||||
rich>=13.7.0
|
||||
|
||||
Reference in New Issue
Block a user