385 lines
11 KiB
Python
385 lines
11 KiB
Python
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import os
|
|
from uuid import uuid4
|
|
|
|
import psycopg
|
|
|
|
from fastapi import FastAPI, Request
|
|
from pydantic import BaseModel
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
|
CONFIG_PATH = Path("config.json")
|
|
LOG_PATH = Path("activation.log")
|
|
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
|
|
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
|
|
|
|
|
|
class ActivationRequest(BaseModel):
|
|
activation_code: str
|
|
device_fingerprint: str
|
|
hostname: str | None = None
|
|
machine_id: str | None = None
|
|
client_version: str | None = None
|
|
hardware: dict
|
|
|
|
|
|
def load_config() -> dict:
|
|
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
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")
|
|
|
|
def get_database_connection():
|
|
if not DATABASE_URL:
|
|
raise RuntimeError("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.")
|
|
|
|
return psycopg.connect(DATABASE_URL)
|
|
|
|
def load_or_create_device(
|
|
organization_id,
|
|
device_fingerprint: str,
|
|
hostname: str | None,
|
|
) -> tuple[dict, bool]:
|
|
"""
|
|
Lädt ein bekanntes Gerät oder legt es neu an.
|
|
|
|
Rückgabe:
|
|
(device, created)
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
created_at,
|
|
last_seen
|
|
FROM devices
|
|
WHERE device_fingerprint = %s
|
|
""",
|
|
(device_fingerprint,),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if row is not None:
|
|
if str(row[1]) != str(organization_id):
|
|
raise RuntimeError(
|
|
"Das Gerät ist bereits einer anderen Organization zugeordnet."
|
|
)
|
|
cur.execute(
|
|
"""
|
|
UPDATE devices
|
|
SET
|
|
hostname = %s,
|
|
last_seen = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING
|
|
id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
created_at,
|
|
last_seen
|
|
""",
|
|
(hostname, row[0]),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
created = False
|
|
else:
|
|
device_id = uuid4()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO devices (
|
|
id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname
|
|
)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING
|
|
id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
created_at,
|
|
last_seen
|
|
""",
|
|
(
|
|
device_id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
created = True
|
|
|
|
device = {
|
|
"id": str(row[0]),
|
|
"organization_id": str(row[1]),
|
|
"device_fingerprint": row[2],
|
|
"hostname": row[3],
|
|
"created_at": row[4].isoformat(),
|
|
"last_seen": row[5].isoformat(),
|
|
}
|
|
|
|
return device, created
|
|
|
|
def store_hardware_snapshot(
|
|
device_id: str,
|
|
hardware: dict,
|
|
) -> str:
|
|
snapshot_id = uuid4()
|
|
|
|
identity = hardware.get("identity", {})
|
|
system = hardware.get("system", {})
|
|
mainboard = hardware.get("mainboard", {})
|
|
firmware = hardware.get("firmware", {})
|
|
security = hardware.get("security", {})
|
|
cpu = system.get("cpu", {})
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO hardware_snapshots (
|
|
id,
|
|
device_id,
|
|
architecture,
|
|
manufacturer,
|
|
product_name,
|
|
product_version,
|
|
system_uuid,
|
|
system_serial,
|
|
board_vendor,
|
|
board_name,
|
|
board_serial,
|
|
bios_vendor,
|
|
bios_version,
|
|
boot_mode,
|
|
secure_boot,
|
|
tpm_version,
|
|
cpu_model,
|
|
cpu_logical_count,
|
|
memory_bytes
|
|
)
|
|
VALUES (
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, %s, %s
|
|
)
|
|
""",
|
|
(
|
|
snapshot_id,
|
|
device_id,
|
|
system["architecture"],
|
|
system.get("manufacturer"),
|
|
system.get("product_name"),
|
|
system.get("product_version"),
|
|
identity.get("system_uuid"),
|
|
identity.get("system_serial"),
|
|
mainboard.get("vendor"),
|
|
mainboard.get("name"),
|
|
identity.get("board_serial"),
|
|
firmware.get("bios_vendor"),
|
|
firmware.get("bios_version"),
|
|
firmware.get("boot_mode"),
|
|
firmware.get("secure_boot"),
|
|
security.get("tpm_version"),
|
|
cpu.get("model"),
|
|
cpu.get("logical_count"),
|
|
system.get("memory_bytes"),
|
|
),
|
|
)
|
|
|
|
for interface in hardware.get("network_interfaces", []):
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO network_interfaces (
|
|
id,
|
|
hardware_snapshot_id,
|
|
name,
|
|
type,
|
|
mac_address
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
uuid4(),
|
|
snapshot_id,
|
|
interface["name"],
|
|
interface["type"],
|
|
interface["mac"],
|
|
),
|
|
)
|
|
|
|
for storage_device in hardware.get("storage_devices", []):
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO storage_devices (
|
|
id,
|
|
hardware_snapshot_id,
|
|
name,
|
|
model,
|
|
serial,
|
|
size_bytes,
|
|
transport
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
uuid4(),
|
|
snapshot_id,
|
|
storage_device["name"],
|
|
storage_device.get("model"),
|
|
storage_device.get("serial"),
|
|
storage_device["size_bytes"],
|
|
storage_device.get("transport"),
|
|
),
|
|
)
|
|
|
|
return str(snapshot_id)
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {
|
|
"status": "ok",
|
|
"service": "provisioning-server",
|
|
"version": "0.1.0"
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/activate")
|
|
def activate(payload: ActivationRequest, request: Request):
|
|
config = load_config()
|
|
activation_entry = config.get("activation_codes", {}).get(payload.activation_code)
|
|
|
|
success = activation_entry is not None
|
|
device = None
|
|
device_created = False
|
|
|
|
hardware_snapshot_id = None
|
|
|
|
if success:
|
|
organization_id = activation_entry["customer"]["organization_id"]
|
|
|
|
device, device_created = load_or_create_device(
|
|
organization_id,
|
|
payload.device_fingerprint,
|
|
payload.hostname,
|
|
)
|
|
hardware_snapshot_id = store_hardware_snapshot(
|
|
device["id"],
|
|
payload.hardware,
|
|
)
|
|
|
|
write_log({
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"event": "activate",
|
|
"success": success,
|
|
"remote_ip": request.client.host if request.client else None,
|
|
"activation_code": payload.activation_code,
|
|
"hostname": payload.hostname,
|
|
"machine_id": payload.machine_id,
|
|
"client_version": payload.client_version
|
|
})
|
|
|
|
if not success:
|
|
return {
|
|
"success": False,
|
|
"error": "invalid_activation_code",
|
|
"message": "Der Aktivierungscode ist ungültig."
|
|
}
|
|
|
|
profiles = config.get("profiles", {})
|
|
available_profiles = [
|
|
profiles[profile_id]
|
|
for profile_id in activation_entry.get("profile_ids", [])
|
|
if profile_id in profiles
|
|
]
|
|
|
|
return {
|
|
"success": True,
|
|
|
|
"device": {
|
|
"id": device["id"],
|
|
"fingerprint": device["device_fingerprint"],
|
|
"hostname": device["hostname"],
|
|
"created": device_created,
|
|
"hardware_snapshot_id": hardware_snapshot_id,
|
|
},
|
|
|
|
"customer": activation_entry["customer"],
|
|
"profiles": available_profiles
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/profiles/{profile_id}")
|
|
def get_profile(profile_id: str):
|
|
config = load_config()
|
|
profile = config.get("profiles", {}).get(profile_id)
|
|
|
|
if not profile:
|
|
return {
|
|
"success": False,
|
|
"error": "profile_not_found",
|
|
"message": "Das angeforderte Profil wurde nicht gefunden."
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"profile": profile
|
|
}
|
|
|
|
@app.get("/api/v1/profiles/{profile_id}/bootstrap")
|
|
def get_profile_bootstrap(profile_id: str):
|
|
config = load_config()
|
|
profile = config.get("profiles", {}).get(profile_id)
|
|
|
|
if not profile:
|
|
return {
|
|
"success": False,
|
|
"error": "profile_not_found",
|
|
"message": "Das angeforderte Profil wurde nicht gefunden."
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"profile": {
|
|
"id": profile["id"],
|
|
"label": profile["label"],
|
|
"distribution": profile["distribution"],
|
|
"version": profile["version"]
|
|
},
|
|
"installer": profile.get("installer"),
|
|
"ansible": {
|
|
"repo": profile.get("ansible_repo"),
|
|
"mode": "pull"
|
|
},
|
|
"ssh": {
|
|
"authorized_keys_url": "https://anode.tuxflotte.de/keys/default.pub"
|
|
}
|
|
}
|
|
|
|
@app.get("/installers/fedora-workstation/ks.cfg")
|
|
def get_fedora_kickstart():
|
|
return FileResponse(
|
|
"installers/fedora-workstation/ks.cfg",
|
|
media_type="text/plain"
|
|
)
|