diff --git a/app.py b/app.py index e0fdba2..29744b9 100644 --- a/app.py +++ b/app.py @@ -1,18 +1,23 @@ from pathlib import Path from datetime import datetime, timezone +import hashlib +import hmac import json import os +import secrets from uuid import uuid4 import psycopg -from fastapi import FastAPI, Request +from fastapi import FastAPI, Header, Request from pydantic import BaseModel from fastapi.responses import FileResponse LOG_PATH = Path("activation.log") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") +AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300")) +ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") app = FastAPI(title="Provisioning Activation Server", version="0.1.0") @@ -29,6 +34,14 @@ class ResolveRequest(BaseModel): device_id: str +class AgentBootstrapRequest(BaseModel): + device_id: str + + +class AgentCheckinRequest(BaseModel): + device_id: str + + def write_log(entry: dict) -> None: with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") @@ -302,6 +315,57 @@ def fetch_templates(organization_id: str) -> list[dict]: for row in rows ] +def hash_agent_secret(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + +def verify_agent_secret(device_id: str, provided_secret: str) -> bool: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT agent_secret_hash FROM devices WHERE id = %s", + (device_id,), + ) + row = cur.fetchone() + + if row is None or row[0] is None: + return False + + return hmac.compare_digest(row[0], hash_agent_secret(provided_secret)) + +def fetch_assigned_blueprints(device_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT bv.workspace_id, bv.backend_id + FROM assignments a + JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id + WHERE a.device_id = %s + """, + (device_id,), + ) + assignment_row = cur.fetchone() + + if assignment_row is None: + return [] + + workspace_id, backend_id = assignment_row + + cur.execute( + """ + SELECT m.key, bp.ansible_role + FROM workspace_merkmale wm + JOIN merkmale m ON m.id = wm.merkmal_id + JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s + WHERE wm.workspace_id = %s + """, + (backend_id, workspace_id), + ) + return [ + {"merkmal": key, "ansible_role": ansible_role} + for key, ansible_role in cur.fetchall() + ] + @app.get("/health") def health(): return { @@ -450,3 +514,70 @@ def get_fedora_kickstart(): "installers/fedora-workstation/ks.cfg", media_type="text/plain" ) + + +@app.post("/api/v1/agent/bootstrap") +def agent_bootstrap(payload: AgentBootstrapRequest): + agent_secret = secrets.token_urlsafe(32) + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE devices + SET agent_secret_hash = %s, agent_secret_issued_at = %s + WHERE id = %s + RETURNING id + """, + ( + hash_agent_secret(agent_secret), + datetime.now(timezone.utc), + payload.device_id, + ), + ) + updated = cur.fetchone() + + if updated is None: + return { + "success": False, + "error": "device_not_found", + "message": "Das angegebene Gerät wurde nicht gefunden.", + } + + return {"success": True, "agent_secret": agent_secret} + + +@app.post("/api/v1/agent/checkin") +def agent_checkin( + payload: AgentCheckinRequest, + authorization: str | None = Header(default=None), +): + if authorization is None or not authorization.startswith("Bearer "): + return { + "success": False, + "error": "unauthorized", + "message": "Fehlendes oder ungültiges Authorization-Header.", + } + + provided_secret = authorization.removeprefix("Bearer ") + + if not verify_agent_secret(payload.device_id, provided_secret): + return { + "success": False, + "error": "unauthorized", + "message": "Ungültiges Agent-Secret.", + } + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE devices SET agent_last_checkin = %s WHERE id = %s", + (datetime.now(timezone.utc), payload.device_id), + ) + + return { + "success": True, + "blueprints": fetch_assigned_blueprints(payload.device_id), + "ansible_repo": ANSIBLE_CONTENT_REPO, + "poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS, + } diff --git a/migrations/0006_agent_credentials.sql b/migrations/0006_agent_credentials.sql new file mode 100644 index 0000000..8b8eb24 --- /dev/null +++ b/migrations/0006_agent_credentials.sql @@ -0,0 +1,7 @@ +BEGIN; + +ALTER TABLE devices ADD COLUMN agent_secret_hash TEXT; +ALTER TABLE devices ADD COLUMN agent_secret_issued_at TIMESTAMPTZ; +ALTER TABLE devices ADD COLUMN agent_last_checkin TIMESTAMPTZ; + +COMMIT;