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:
parent
e02b646564
commit
341ef92a7a
133
app.py
133
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,
|
||||
}
|
||||
|
||||
7
migrations/0006_agent_credentials.sql
Normal file
7
migrations/0006_agent_credentials.sql
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user