feat: add provisioning agent bootstrap and check-in endpoints

Adds POST /api/v1/agent/bootstrap (issues a hashed agent secret for a
device) and POST /api/v1/agent/checkin (Bearer-authenticated, resolves
the device's assigned blueprints via its Bereitstellungsvorlage and
returns them alongside the ansible-content repo URL and poll
interval). Migration 0006 adds the backing columns on devices
(agent_secret_hash, agent_secret_issued_at, agent_last_checkin).

This was implemented and verified end to end (activate -> resolve ->
bootstrap -> checkin, plus a full QEMU agent test) in an earlier
session but never committed to this repo, even though it has been
running in production on anode since. Committing now to close that
gap before building on top of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-04 09:16:58 +02:00
parent e02b646564
commit 341ef92a7a
2 changed files with 139 additions and 1 deletions

133
app.py
View File

@ -1,18 +1,23 @@
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
import hashlib
import hmac
import json import json
import os import os
import secrets
from uuid import uuid4 from uuid import uuid4
import psycopg import psycopg
from fastapi import FastAPI, Request from fastapi import FastAPI, Header, Request
from pydantic import BaseModel from pydantic import BaseModel
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
LOG_PATH = Path("activation.log") LOG_PATH = Path("activation.log")
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") 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") app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
@ -29,6 +34,14 @@ class ResolveRequest(BaseModel):
device_id: str device_id: str
class AgentBootstrapRequest(BaseModel):
device_id: str
class AgentCheckinRequest(BaseModel):
device_id: str
def write_log(entry: dict) -> None: def write_log(entry: dict) -> None:
with LOG_PATH.open("a", encoding="utf-8") as f: with LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n") 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 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") @app.get("/health")
def health(): def health():
return { return {
@ -450,3 +514,70 @@ def get_fedora_kickstart():
"installers/fedora-workstation/ks.cfg", "installers/fedora-workstation/ks.cfg",
media_type="text/plain" 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,
}

View File

@ -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;