feat: add Proxmox provisioning service with CI and deployment tooling
CI / javascript-check (push) Successful in 51s
CI / container-policy (push) Successful in 2s
CI / container-verify (push) Canceled after 0s
CI / container-publish (push) Canceled after 0s
CI / python-tests (push) Canceled after 6m59s

This commit is contained in:
BartelLuis
2026-09-14 20:09:12 +02:00
commit 06c3474636
52 changed files with 9779 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Proxmox AIS: controlled installation and post-installation provisioning."""
__version__ = "0.1.0"
+766
View File
@@ -0,0 +1,766 @@
from contextlib import asynccontextmanager
import asyncio
import base64
from collections import defaultdict, deque
import hashlib
import hmac
import json
from pathlib import Path
import re
import shlex
import sqlite3
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import jsonschema
from pydantic import ValidationError
from .config import Settings
from .db import Database
from .models import (Approval, Completion, Enroll, EventBatch, GroupCreate, HostCreate, HostUpdate, IsoCreate, LeaseRequest, LogBatch, ModuleCreate, ProfileCreate, Publish, RunAction, RunReconcile, SecretCreate, UserCreate, normalize_identity)
from .security import Security, atomic_artifact, canonical, digest, token
from .service import Service, TERMINAL, audit, get_host, new_id, now_iso, public_run, require, unpack
ASSETS = Path(__file__).parent
class RequestGuards:
"""Bound bodies before FastAPI parses them; no capability URLs in access logs."""
def __init__(self, app, max_bytes):
self.app, self.max_bytes = app, max_bytes
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
consumed = 0
messages = []
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
consumed += len(message.get("body", b""))
if consumed > self.max_bytes:
return await JSONResponse({"detail":"Anfrage überschreitet das Größenlimit."},413)(scope,receive,send)
messages.append(message)
if not message.get("more_body", False):
break
async def replay():
if messages:
return messages.pop(0)
return await receive()
async def guarded_send(message):
if message["type"] == "http.response.start":
referrer_policy = b"no-referrer" if scope["path"].startswith(("/bootstrap/","/installer/","/agent/")) else b"same-origin"
message.setdefault("headers", []).extend([(b"cache-control", b"no-store"),(b"x-content-type-options",b"nosniff"),(b"x-frame-options",b"DENY"),(b"referrer-policy",referrer_policy),(b"content-security-policy",b"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")])
await send(message)
await self.app(scope,replay,guarded_send)
def create_app(settings: Settings | None = None):
settings = settings or Settings.from_env()
db = Database(settings)
security = Security(settings)
service = Service(settings, db, security)
rate_buckets = defaultdict(deque)
def rate_limit(key, limit, seconds=60):
timestamp = time.monotonic()
queue = rate_buckets[key]
while queue and queue[0] < timestamp - seconds:
queue.popleft()
require(len(queue) < limit, 429, "Zu viele Anfragen. Bitte später erneut versuchen.")
queue.append(timestamp)
if len(rate_buckets) > 10000:
for item in list(rate_buckets):
if not rate_buckets[item] or rate_buckets[item][-1] < timestamp - 3600:
rate_buckets.pop(item, None)
async def maintenance_loop():
while True:
await asyncio.sleep(30)
await asyncio.to_thread(service.maintain)
@asynccontextmanager
async def lifespan(app):
from .cli import ServiceLock
require(not (settings.data_dir / "RESTORE_FAILED").exists(), 503, "Unvollständige Wiederherstellung muss zuerst behoben werden.")
with ServiceLock(settings.data_dir):
db.initialize()
if settings.bootstrap_username and settings.bootstrap_password:
with db.connection(write=True) as connection:
if not connection.execute("SELECT 1 FROM users LIMIT 1").fetchone():
connection.execute("INSERT INTO users(id,username,password_hash,role,created_at) VALUES(?,?,?,?,?)", (new_id("user"),settings.bootstrap_username,security.hash_password(settings.bootstrap_password),"admin",now_iso()))
service.maintain()
task = asyncio.create_task(maintenance_loop())
try:
yield
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="Proxmox AIS", version="0.1.0", description="Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.db, app.state.settings, app.state.security, app.state.service = db, settings, security, service
app.add_middleware(RequestGuards, max_bytes=settings.max_request_bytes)
(ASSETS / "static").mkdir(exist_ok=True)
(ASSETS / "templates").mkdir(exist_ok=True)
app.mount("/static",StaticFiles(directory=ASSETS / "static"),name="static")
templates = Jinja2Templates(directory=ASSETS / "templates")
@app.exception_handler(sqlite3.IntegrityError)
async def conflict(request, error):
return JSONResponse({"detail":"Konflikt: Identität, IP, FQDN, Name oder aktiver Lauf ist bereits vergeben."},409)
@app.exception_handler(sqlite3.OperationalError)
async def db_unavailable(request,error):
return JSONResponse({"detail":"Datenbank vorübergehend nicht verfügbar."},503)
@app.exception_handler(RequestValidationError)
@app.exception_handler(ValidationError)
async def validation_failed(request,error):
errors = [{"loc":list(e["loc"]),"msg":e["msg"],"type":e["type"]} for e in error.errors()]
return JSONResponse({"detail":"Ungültige Eingaben.","errors":errors},422)
@app.exception_handler(ValueError)
async def bad_value(request,error):
return JSONResponse({"detail":"Ungültiger Wert oder nicht lesbare Konfiguration."},422)
def current_user(request: Request):
session_token = request.cookies.get("ais_session", "")
require(bool(session_token),401,"Anmeldung erforderlich.")
with db.connection() as connection:
row = connection.execute("SELECT u.id,u.username,u.role,s.csrf_token FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>? AND u.disabled=0",(digest(session_token),time.time())).fetchone()
require(row is not None,401,"Sitzung ist ungültig oder abgelaufen.")
user = dict(row)
if request.method not in {"GET","HEAD","OPTIONS"}:
csrf = request.headers.get("x-csrf-token", "")
require(hmac.compare_digest(csrf,user["csrf_token"]),403,"CSRF-Prüfung fehlgeschlagen. Seite neu laden.")
return user
def roles(*allowed):
def check(user=Depends(current_user)):
require(user["role"] in {*allowed,"admin","developer"},403,"Für diese Aktion fehlt die Berechtigung.")
return user
return check
@app.get("/health/live")
def health_live():
return {"status":"ok"}
@app.get("/health/ready")
def health_ready():
with db.connection() as connection:
connection.execute("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").fetchone()
require(settings.master_key_file.is_file(),503,"Notwendige Konfiguration fehlt.")
return {"status":"ready"}
@app.get("/login",response_class=HTMLResponse,include_in_schema=False)
def login_page(request:Request):
return templates.TemplateResponse(request=request,name="login.html",context={"error":None})
@app.post("/auth/login",include_in_schema=False)
def login(request:Request,username:str=Form(...),password:str=Form(...)):
rate_limit("login:" + (request.client.host if request.client else "unknown"),10,300)
origin = request.headers.get("origin")
require(not origin or origin == settings.public_url or (settings.testing and origin == str(request.base_url).rstrip("/")),403,"Anmeldeanfrage stammt von einer fremden Website.")
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM users WHERE username=? AND disabled=0",(username,)).fetchone()
if row is None or not security.verify_password(password,row["password_hash"]):
audit(connection,"anonymous","login.failed","",data={"username":username[:64]})
return templates.TemplateResponse(request=request,name="login.html",context={"error":"Benutzername oder Passwort ist falsch."},status_code=401)
session_token, csrf = token(),token()
connection.execute("INSERT INTO sessions VALUES(?,?,?,?)",(digest(session_token),row["id"],csrf,time.time()+settings.session_hours*3600))
audit(connection,row["id"],"login.succeeded",row["id"])
response = RedirectResponse("/",status_code=303)
response.set_cookie("ais_session",session_token,httponly=True,secure=settings.secure_cookies,samesite="strict",max_age=settings.session_hours*3600,path="/")
return response
@app.post("/auth/logout")
def logout(request:Request,user=Depends(current_user)):
with db.connection(write=True) as connection:
connection.execute("DELETE FROM sessions WHERE token_hash=?",(digest(request.cookies.get("ais_session","")),))
audit(connection,user["id"],"logout",user["id"])
response = JSONResponse({"status":"ok"})
response.delete_cookie("ais_session",path="/")
return response
@app.get("/",response_class=HTMLResponse,include_in_schema=False)
def index(request:Request):
try:
user = current_user(request)
except HTTPException:
return RedirectResponse("/login",303)
return templates.TemplateResponse(request=request,name="index.html",context={"user":user})
@app.get("/api/v1/me")
def me(user=Depends(current_user)):
return user
@app.get("/openapi.json",include_in_schema=False)
def openapi(user=Depends(current_user)):
return app.openapi()
@app.get("/api/v1/dashboard")
def dashboard(user=Depends(current_user)):
service.maintain()
with db.connection() as connection:
hosts = [get_host(connection,r[0]) for r in connection.execute("SELECT id FROM hosts ORDER BY created_at DESC")]
runs = [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 20")]
counts = {"hosts":len(hosts),"ready":sum(h["status"] == "prepared" and not h["blocked"] for h in hosts),"active":sum(h["status"] in {"answer_served","installed_reported","runner_ready","running","reboot_pending","waiting_retry"} for h in hosts),"succeeded":sum(h["status"]=="succeeded" for h in hosts),"needs_review":sum(h["status"] in {"needs_review","failed"} for h in hosts),"discovered":connection.execute("SELECT COUNT(*) FROM discoveries").fetchone()[0]}
events = [unpack(r) for r in connection.execute("SELECT * FROM audit ORDER BY created_at DESC LIMIT 12")]
return {"counts":counts,"recent_events":events,"hosts":hosts,"runs":runs,"maintenance":settings.maintenance}
@app.get("/api/v1/hosts")
def hosts(user=Depends(current_user)):
with db.connection() as connection:
return [get_host(connection,r[0]) for r in connection.execute("SELECT id FROM hosts ORDER BY fqdn")]
@app.post("/api/v1/hosts",status_code=201)
def create_host(payload:HostCreate,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
return service.create_host(connection,payload,user["id"])
@app.post("/api/v1/hosts/import",status_code=201)
def import_hosts(payload:list[HostCreate],user=Depends(roles("operator"))):
require(1 <= len(payload) <= 100,422,"Ein Import darf 1 bis 100 Hosts enthalten.")
with db.connection(write=True) as connection:
return [service.create_host(connection,item,user["id"]) for item in payload]
@app.get("/api/v1/discoveries")
def discoveries(user=Depends(current_user)):
with db.connection() as connection:
return [unpack(r) for r in connection.execute("SELECT * FROM discoveries ORDER BY last_seen DESC")]
@app.get("/api/v1/hosts/{host_id}")
def host_detail(host_id:str,user=Depends(current_user)):
with db.connection() as connection:
host = get_host(connection,host_id)
host["runs"] = [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs WHERE host_id=? ORDER BY created_at DESC",(host_id,))]
return host
@app.patch("/api/v1/hosts/{host_id}")
def update_host(host_id:str,payload:HostUpdate,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
return service.update_host(connection,host_id,payload,user["id"])
@app.get("/api/v1/hosts/{host_id}/preview")
def preview(host_id:str,user=Depends(roles("operator","author"))):
with db.connection() as connection:
return service.resolve(connection,host_id)[0]
@app.post("/api/v1/hosts/{host_id}/approve-install",status_code=201)
def approve(host_id:str,payload:Approval,user=Depends(roles("operator"))):
service.maintain()
with db.connection(write=True) as connection:
return service.approve(connection,host_id,payload,user["id"])
@app.get("/api/v1/profiles")
def profiles(user=Depends(current_user)):
with db.connection() as connection:
return [unpack(r) for r in connection.execute("SELECT * FROM profiles ORDER BY name,version DESC")]
@app.post("/api/v1/profiles",status_code=201)
def create_profile(payload:ProfileCreate,user=Depends(roles("author"))):
with db.connection(write=True) as connection:
return service.create_profile(connection,payload,user["id"])
@app.get("/api/v1/modules")
def modules(user=Depends(current_user)):
with db.connection() as connection:
result = [unpack(r) for r in connection.execute("SELECT * FROM modules ORDER BY name,version DESC")]
if user["role"] not in {"author","admin","developer"}:
for module in result:
module.pop("source",None)
return result
@app.post("/api/v1/modules",status_code=201)
def create_module(payload:ModuleCreate,user=Depends(roles("author"))):
try:
jsonschema.Draft202012Validator.check_schema(payload.parameters_schema)
except jsonschema.SchemaError:
raise HTTPException(422,"Parameterschema ist ungültig.")
require("$ref" not in canonical(payload.parameters_schema),422,"Externe und rekursive Schema-Referenzen werden nicht unterstützt.")
payload.source = payload.source.replace("\r\n","\n")
checksum = atomic_artifact(service.artifact_dir,payload.source)
with db.connection(write=True) as connection:
version = connection.execute("SELECT COALESCE(MAX(version),0)+1 FROM modules WHERE name=?",(payload.name,)).fetchone()[0]
module_id = new_id("module")
connection.execute("INSERT INTO modules VALUES(?,?,?,?,?,?,?,?)",(module_id,payload.name,version,"draft",checksum,canonical(payload.model_dump()),user["id"],now_iso()))
audit(connection,user["id"],"module.created",module_id,payload.reason,{"digest":checksum})
return unpack(connection.execute("SELECT * FROM modules WHERE id=?",(module_id,)).fetchone())
@app.get("/api/v1/modules/builtin")
def builtin_modules(user=Depends(roles("author"))):
from .builtin_modules import catalog
return catalog()
@app.get("/api/v1/modules/{object_id}")
def module_detail(object_id:str,user=Depends(current_user)):
with db.connection() as connection:
result = unpack(connection.execute("SELECT * FROM modules WHERE id=?",(object_id,)).fetchone())
if user["role"] not in {"admin","developer","author"}:
result.pop("source",None)
return result
@app.get("/api/v1/profiles/{object_id}")
def profile_detail(object_id:str,user=Depends(current_user)):
with db.connection() as connection:
return unpack(connection.execute("SELECT * FROM profiles WHERE id=?",(object_id,)).fetchone())
@app.post("/api/v1/modules/{object_id}/publish")
def publish_module(object_id:str,payload:Publish,user=Depends(roles())):
return publish_object("modules",object_id,payload,user)
@app.post("/api/v1/profiles/{object_id}/publish")
def publish_profile(object_id:str,payload:Publish,user=Depends(roles())):
return publish_object("profiles",object_id,payload,user)
def publish_object(table,object_id,payload,user):
with db.connection() as connection:
item = unpack(connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone())
require(item["status"] == "draft",409,"Veröffentlichte Versionen sind unveränderlich. Neue Version erstellen.")
require(not settings.four_eyes or item["created_by"] != user["id"],403,"Vieraugenprinzip: Eine andere Person muss diese Version veröffentlichen.")
require(item["target_builds"],422,"Mindestens ein getesteter Zielbuild ist erforderlich.")
if table == "modules":
service.module_syntax(item["source"])
else:
require(item["kind"] != "postinstall" or item["steps"],422,"Postinstallationsprofil benötigt Schritte.")
with db.connection(write=True) as connection:
row = connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone()
require(row["status"] == "draft",409,"Version wurde bereits veröffentlicht.")
data = json.loads(row["data"])
data.update({"test_evidence":payload.test_evidence,"published_by":user["id"],"published_at":now_iso()})
if table == "profiles":
data["digest"] = digest(canonical({k:v for k,v in data.items() if k != "digest"}))
connection.execute(f"UPDATE {table} SET status='published',data=? WHERE id=?",(canonical(data),object_id))
audit(connection,user["id"],table[:-1]+".published",object_id,payload.reason,{"test_evidence":payload.test_evidence})
return unpack(connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone())
@app.get("/api/v1/groups")
def groups(user=Depends(roles("operator","author"))):
with db.connection() as connection:
return [dict(r) for r in connection.execute("SELECT id,name,site,expires_at,revoked,created_at FROM groups ORDER BY name")]
@app.post("/api/v1/groups",status_code=201)
def create_group(payload:GroupCreate,user=Depends(roles())):
secret = token()
with db.connection(write=True) as connection:
group_id = new_id("group")
connection.execute("INSERT INTO groups VALUES(?,?,?,?,?,?,?)",(group_id,payload.name,payload.site,digest(secret),time.time()+payload.valid_hours*3600,0,now_iso()))
audit(connection,user["id"],"group.created",group_id)
result = dict(connection.execute("SELECT id,name,site,expires_at,revoked,created_at FROM groups WHERE id=?",(group_id,)).fetchone())
return {**result,"token":payload.name + ":" + secret}
@app.post("/api/v1/groups/{group_id}/revoke")
def revoke_group(group_id:str,user=Depends(roles())):
with db.connection(write=True) as connection:
changed = connection.execute("UPDATE groups SET revoked=1 WHERE id=?",(group_id,)).rowcount
require(changed,404,"Gruppe nicht gefunden.")
audit(connection,user["id"],"group.revoked",group_id)
return {"status":"revoked"}
@app.get("/api/v1/iso-records")
def iso_records(user=Depends(current_user)):
with db.connection() as connection:
return [iso_command(unpack(r),connection) for r in connection.execute("SELECT * FROM iso_records ORDER BY created_at DESC")]
def iso_command(iso,connection):
group = connection.execute("SELECT name FROM groups WHERE id=?",(iso["group_id"],)).fetchone()
args = ["proxmox-auto-install-assistant","prepare-iso","SOURCE.iso","--fetch-from","http","--url",settings.public_url + "/installer/v1/answer","--cert-fingerprint",iso["fingerprint"],"--answer-auth-token",(group[0] if group else "gruppe") + ":<SECRET>"]
iso["command"] = shlex.join(args)
return iso
@app.post("/api/v1/iso-records",status_code=201)
def create_iso(payload:IsoCreate,user=Depends(roles())):
require(payload.test_status != "passed" or (payload.native_token_support and len(payload.test_evidence)>=5),422,"Freigegebenes Medium benötigt nativen Token-Support und dokumentierten Testnachweis.")
with db.connection(write=True) as connection:
require(connection.execute("SELECT 1 FROM groups WHERE id=?",(payload.group_id,)).fetchone(),422,"Gruppe nicht gefunden.")
iso_id = new_id("iso")
connection.execute("INSERT INTO iso_records VALUES(?,?,?,?)",(iso_id,payload.name,canonical(payload.model_dump()),now_iso()))
audit(connection,user["id"],"iso.registered",iso_id,data={"build":payload.build,"test_status":payload.test_status})
return iso_command(unpack(connection.execute("SELECT * FROM iso_records WHERE id=?",(iso_id,)).fetchone()),connection)
@app.get("/api/v1/secrets")
def secrets_list(user=Depends(roles())):
with db.connection() as connection:
return [dict(r) for r in connection.execute("SELECT id,name,created_at FROM secrets ORDER BY name")]
@app.post("/api/v1/secrets",status_code=201)
def create_secret(payload:SecretCreate,user=Depends(roles())):
with db.connection(write=True) as connection:
secret_id = new_id("secret")
connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(payload.value),now_iso()))
audit(connection,user["id"],"secret.created",secret_id)
return {"id":secret_id,"name":payload.name}
@app.get("/api/v1/users")
def users(user=Depends(roles())):
with db.connection() as connection:
return [dict(r) for r in connection.execute("SELECT id,username,role,disabled,created_at FROM users ORDER BY username")]
@app.post("/api/v1/users",status_code=201)
def create_user(payload:UserCreate,user=Depends(roles())):
with db.connection(write=True) as connection:
user_id = new_id("user")
connection.execute("INSERT INTO users(id,username,password_hash,role,created_at) VALUES(?,?,?,?,?)",(user_id,payload.username,security.hash_password(payload.password),payload.role,now_iso()))
audit(connection,user["id"],"user.created",user_id,data={"role":payload.role})
return {"id":user_id,"username":payload.username,"role":payload.role}
@app.post("/api/v1/users/{user_id}/disable")
def disable_user(user_id:str,user=Depends(roles())):
require(user_id != user["id"],409,"Eigenes Konto kann nicht deaktiviert werden.")
with db.connection(write=True) as connection:
require(connection.execute("UPDATE users SET disabled=1 WHERE id=?",(user_id,)).rowcount,404,"Benutzer nicht gefunden.")
connection.execute("DELETE FROM sessions WHERE user_id=?",(user_id,))
audit(connection,user["id"],"user.disabled",user_id)
return {"status":"disabled"}
@app.get("/api/v1/audit")
def audit_list(user=Depends(current_user)):
with db.connection() as connection:
return [unpack(r) for r in connection.execute("SELECT * FROM audit ORDER BY created_at DESC LIMIT 500")]
@app.get("/api/v1/runs")
def runs(user=Depends(current_user)):
with db.connection() as connection:
return [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 500")]
def decorate_run(row,connection):
result = public_run(row)
result["contact_status"] = "unknown" if row["last_seen"] and row["status"] not in TERMINAL and time.time()-row["last_seen"]>settings.heartbeat_unknown_seconds else "current" if row["last_seen"] else "pending"
specs = {s["id"]:s for s in result["manifest"]["steps"]}
result["steps"] = [{**specs[r["step_id"]],**dict(r),"verification":json.loads(r["verification"])} for r in connection.execute("SELECT * FROM run_steps WHERE run_id=? ORDER BY position",(row["id"],))]
return result
@app.get("/api/v1/runs/{run_id}")
def run_detail(run_id:str,user=Depends(current_user)):
with db.connection() as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
require(row is not None,404,"Lauf nicht gefunden.")
result = decorate_run(row,connection)
result["events"] = [unpack(r) for r in connection.execute("SELECT * FROM events WHERE run_id=? ORDER BY sequence DESC LIMIT 200",(run_id,))][::-1]
result["logs"] = [unpack(r) for r in connection.execute("SELECT * FROM logs WHERE run_id=? ORDER BY sequence DESC LIMIT 100",(run_id,))][::-1]
return result
@app.post("/api/v1/runs/{run_id}/cancel")
def cancel_run(run_id:str,payload:RunAction,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
run = unpack(row)
require(run["version"] == payload.expected_version and run["status"] not in TERMINAL,409,"Laufzustand hat sich geändert oder ist bereits abgeschlossen.")
data = json.loads(row["data"])
data["cancel_requested"] = True
status = "cancelled" if run["status"] == "prepared" else run["status"]
connection.execute("UPDATE runs SET data=?,status=?,version=version+1 WHERE id=?",(canonical(data),status,run_id))
if status == "cancelled":
connection.execute("UPDATE approvals SET status='revoked' WHERE id=?",(run["approval_id"],))
connection.execute("UPDATE hosts SET status='cancelled' WHERE id=?",(run["host_id"],))
audit(connection,user["id"],"run.cancel_requested",run_id,payload.reason)
return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
@app.post("/api/v1/runs/{run_id}/resume")
def resume_run(run_id:str,payload:RunAction,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
run = unpack(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
require(run["version"] == payload.expected_version and run["status"] in {"needs_review","waiting_retry"},409,"Nur ein wartender, unverändert angezeigter Lauf kann fortgesetzt werden.")
require(run["device_key"] and not run.get("cancel_requested"),409,"Keine aktive Gerätebindung für sichere Wiederaufnahme vorhanden.")
require(not get_host(connection,run["host_id"])["blocked"],403,"Host ist gesperrt.")
connection.execute("UPDATE runs SET status='runner_ready',version=version+1 WHERE id=?",(run_id,))
connection.execute("UPDATE hosts SET status='runner_ready' WHERE id=?",(run["host_id"],))
audit(connection,user["id"],"run.resumed",run_id,payload.reason)
return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
@app.post("/api/v1/runs/{run_id}/reconcile")
def reconcile_run(run_id:str,payload:RunReconcile,user=Depends(roles("operator"))):
"""Close an externally checked abandoned run; never grants installation."""
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
run = unpack(row)
host = get_host(connection,run["host_id"])
require(run["version"] == payload.expected_version and run["status"] not in TERMINAL,409,"Lauf wurde geändert oder ist bereits beendet.")
require(payload.execution_stopped and payload.confirmation == host["fqdn"],422,"Vor dem Abschließen muss lokal geprüft sein, dass Installer und Runner gestoppt sind; Host-FQDN bestätigen.")
require(not row["lease_until"] or row["lease_until"]<=time.time(),409,"Aktuelle Laufberechtigung muss vor dem Abgleich ablaufen. Zuerst Abbruch anfordern.")
require(not row["answer_until"] or row["answer_until"]<=time.time(),409,"Auslieferungsfenster ist noch aktiv. Abgleich erst nach dessen Ablauf möglich.")
data = json.loads(row["data"])
data.update({"cancel_requested":True,"reconciled_by":user["id"],"reconciliation_reason":payload.reason})
connection.execute("UPDATE runs SET status='cancelled',version=version+1,data=?,completed_at=?,device_key=NULL,enrollment_hash=NULL,bootstrap_hash=NULL,report_hash=NULL,answer_ciphertext=NULL,bootstrap_ciphertext=NULL,lease_until=NULL WHERE id=?",(canonical(data),time.time(),run_id))
connection.execute("UPDATE approvals SET status='revoked' WHERE id=?",(run["approval_id"],))
connection.execute("UPDATE hosts SET status='cancelled',version=version+1 WHERE id=?",(host["id"],))
audit(connection,user["id"],"run.reconciled",run_id,payload.reason,{"execution_stopped_confirmed":True,"fqdn":host["fqdn"]})
return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
def installer_group(request):
authorization = request.headers.get("authorization", "")
require(authorization.startswith("Bearer ") and ":" in authorization,401,"Gültiger Installer-Gruppentoken erforderlich.")
name,secret = authorization[7:].split(":",1)
with db.connection() as connection:
row = connection.execute("SELECT * FROM groups WHERE name=?",(name,)).fetchone()
require(row is not None and hmac.compare_digest(digest(secret),row["token_hash"]),401,"Ungültiger Installer-Gruppentoken.")
require(not row["revoked"],403,"Installer-Gruppentoken ist gesperrt.")
require(row["expires_at"] > time.time(),410,"Installer-Gruppentoken ist abgelaufen.")
rate_limit("installer:" + row["id"],120)
return row
@app.post("/installer/v1/answer",response_class=Response)
async def installer_answer(request:Request):
rate_limit("answer-ip:" + (request.client.host if request.client else "unknown"),240)
group = installer_group(request)
try:
payload = await request.json()
except (ValueError,UnicodeDecodeError):
raise HTTPException(422,"Ungültige Installer-Systemdaten.")
service.maintain()
try:
with db.connection(write=True) as connection:
answer = service.serve_answer(connection,group,payload)
except HTTPException as error:
with db.connection(write=True) as connection:
audit(connection,"installer:"+group["name"],"installation.denied","",str(error.detail))
raise
require(answer is not None,403,"Unbekannter Host wurde als entdeckt gespeichert. Vor erneutem Start zuordnen und freigeben.")
return Response(answer,media_type="application/toml")
@app.get("/bootstrap/v1/{download_token}",response_class=Response)
def bootstrap(download_token:str):
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE bootstrap_hash=?",(digest(download_token),)).fetchone()
require(row is not None,401,"Ungültige Download-Berechtigung.")
require(row["status"] in {"answer_served","installed_reported"} and time.time()<row["answer_until"],410,"Starthelfer ist nicht mehr zum Download freigegeben.")
require(not get_host(connection,row["host_id"])["blocked"] and not json.loads(row["data"]).get("cancel_requested"),403,"Lauf ist gesperrt.")
rate_limit("bootstrap:"+row["id"],12,300)
audit(connection,"installer","bootstrap.downloaded",row["id"])
return Response(security.decrypt(row["bootstrap_ciphertext"]),media_type="text/x-shellscript")
@app.post("/installer/v1/report/{report_token}")
async def installer_report(report_token:str,request:Request):
try:
await request.json()
except ValueError:
raise HTTPException(422,"Ungültiger Installationsbericht.")
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE report_hash=?",(digest(report_token),)).fetchone()
require(row is not None,401,"Ungültige Report-Berechtigung.")
require(row["enroll_until"] and row["enroll_until"]>time.time(),410,"Report-Berechtigung ist abgelaufen.")
require(row["status"] in {"answer_served","installed_reported","runner_ready","running"},409,"Installationsbericht passt nicht zum Laufzustand.")
if row["status"] == "answer_served":
connection.execute("UPDATE runs SET status='installed_reported',version=version+1,last_seen=? WHERE id=?",(time.time(),row["id"]))
connection.execute("UPDATE hosts SET status='installed_reported' WHERE id=?",(row["host_id"],))
audit(connection,"installer","installation.reported",row["id"])
return {"status":"accepted"}
@app.post("/agent/v1/enroll")
def enroll(payload:Enroll,request:Request):
rate_limit("enroll:" + (request.client.host if request.client else "unknown"),60)
try:
key = base64.b64decode(payload.public_key,validate=True)
Ed25519PublicKey.from_public_bytes(key)
except (ValueError,TypeError):
raise HTTPException(422,"Ungültiger Ed25519-Geräteschlüssel.")
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(payload.run_id,)).fetchone()
require(row is not None and row["enrollment_hash"] and hmac.compare_digest(row["enrollment_hash"],digest(payload.enrollment_secret)),401,"Ungültige Enrollment-Berechtigung.")
require(row["enroll_until"] > time.time(),410,"Enrollment-Berechtigung ist abgelaufen.")
require(row["status"] in {"answer_served","installed_reported","runner_ready","running","reboot_pending","needs_review","waiting_retry"},403,"Lauf erlaubt keine Registrierung.")
run_data = json.loads(row["data"])
require(not run_data.get("cancel_requested"),403,"Lauf ist zum Abbruch markiert.")
identities = [{"kind":i.kind,"value":normalize_identity(i.kind,i.value)} for i in payload.identities]
host = service.match_host(connection,identities)
require(host and host["id"] == row["host_id"] and not host["blocked"],403,"Geräteidentität passt nicht zum freigegebenen Host.")
require(not row["device_key"] or row["device_key"] == payload.public_key,409,"Enrollment ist bereits an einen anderen Geräteschlüssel gebunden.")
if not row["device_key"]:
run_data["boot_id"] = payload.boot_id
connection.execute("UPDATE runs SET device_key=?,data=?,status='runner_ready',version=version+1,last_seen=? WHERE id=?",(payload.public_key,canonical(run_data),time.time(),row["id"]))
connection.execute("UPDATE hosts SET status='runner_ready' WHERE id=?",(row["host_id"],))
audit(connection,"device:"+row["id"],"runner.enrolled",row["id"])
return {"run_id":row["id"],"status":"enrolled","manifest_digest":run_data["manifest_digest"]}
async def device(request:Request):
run_id = request.headers.get("x-run-id", "")
key = request.headers.get("x-device-key", "")
timestamp = request.headers.get("x-timestamp", "")
nonce = request.headers.get("x-nonce", "")
try:
require(abs(time.time()-int(timestamp)) <= 300,401,"Signaturzeit liegt außerhalb des zulässigen Fensters.")
require(bool(re.fullmatch(r"[A-Za-z0-9_-]{16,128}",nonce)),401,"Ungültige Request-Nonce.")
body = await request.body()
message = f"{request.method}\n{request.url.path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode()
signature = base64.b64decode(request.headers.get("x-signature", ""),validate=True)
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
require(row is not None and row["device_key"] and hmac.compare_digest(row["device_key"],key),401,"Gerätebindung ungültig.")
Ed25519PublicKey.from_public_bytes(base64.b64decode(key,validate=True)).verify(signature,message)
require(not connection.execute("SELECT 1 FROM nonces WHERE run_id=? AND nonce=?",(run_id,nonce)).fetchone(),409,"Request-Nonce wurde bereits verwendet.")
if row["status"] in TERMINAL:
complete_retry = request.url.path == f"/agent/v1/runs/{run_id}/complete" and row["status"] == "succeeded" and row["completed_at"] and time.time()-row["completed_at"] < 3600
cancel_retry = request.url.path == f"/agent/v1/runs/{run_id}/events" and row["status"] == "cancelled" and row["completed_at"] and time.time()-row["completed_at"] < 3600
require(complete_retry or cancel_retry,403,"Laufberechtigung ist beendet.")
connection.execute("INSERT INTO nonces VALUES(?,?,?)",(run_id,nonce,time.time()+600))
rate_limit("device:"+run_id,600)
return run_id
except (ValueError,TypeError,InvalidSignature):
raise HTTPException(401,"Ungültige Gerätesignatur.")
def authorized_run(connection,run_id,device_id,lease=False):
require(run_id == device_id,403,"Kein Zugriff auf einen fremden Lauf.")
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
require(row is not None,404,"Lauf nicht gefunden.")
if lease:
require(row["status"] in {"runner_ready","running"} and row["lease_until"] and row["lease_until"]>time.time(),403,"Aktuelle Ausführungsberechtigung erforderlich.")
require(not json.loads(row["data"]).get("cancel_requested") and not get_host(connection,row["host_id"])["blocked"],403,"Lauf ist gesperrt.")
return row
@app.post("/agent/v1/lease")
def lease(payload:LeaseRequest,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,payload.run_id,device_id)
data = json.loads(row["data"])
host = get_host(connection,row["host_id"])
action = "stop" if data.get("cancel_requested") else "wait" if host["blocked"] or row["status"] in {"needs_review","waiting_retry"} else "run" if row["status"] in {"runner_ready","running","reboot_pending"} else "revoked"
expiry = time.time()+settings.lease_seconds if action == "run" else time.time()
# A lease already held by an offline device cannot be recalled.
# Keep its horizon for the operator reconciliation gate.
remembered_expiry = expiry if action == "run" else row["lease_until"]
connection.execute("UPDATE runs SET lease_until=?,last_seen=? WHERE id=?",(remembered_expiry,time.time(),row["id"]))
return {"action":action,"expires_at":expiry,"run_version":row["version"]}
@app.get("/agent/v1/runs/{run_id}/manifest")
def manifest(run_id:str,device_id=Depends(device)):
with db.connection() as connection:
row = authorized_run(connection,run_id,device_id,lease=True)
data = json.loads(row["data"])
return {**data["manifest"],"digest":data["manifest_digest"]}
@app.get("/agent/v1/artifacts/{checksum}",response_class=Response)
def artifact(checksum:str,device_id=Depends(device)):
require(bool(re.fullmatch(r"[a-f0-9]{64}",checksum)),404,"Artefakt nicht gefunden.")
with db.connection() as connection:
row = authorized_run(connection,device_id,device_id,lease=True)
require(checksum in {s["digest"] for s in json.loads(row["data"])["manifest"]["steps"]},403,"Artefakt gehört nicht zum Lauf.")
path = service.artifact_dir / checksum
require(path.is_file(),404,"Artefakt fehlt.")
content = path.read_bytes()
require(digest(content)==checksum,503,"Artefaktintegrität konnte nicht bestätigt werden.")
return Response(content,media_type="application/octet-stream")
@app.get("/agent/v1/runs/{run_id}/secrets/{step_id}")
def step_secrets(run_id:str,step_id:str,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id,lease=True)
step = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND step_id=?",(run_id,step_id)).fetchone()
require(step is not None and step["status"] != "succeeded",403,"Kein Geheimniszugriff für diesen Schritt.")
specs = {s["id"]:s for s in json.loads(row["data"])["manifest"]["steps"]}
remaining = [s for s in connection.execute("SELECT * FROM run_steps WHERE run_id=? ORDER BY position",(run_id,)) if s["status"] != "succeeded" and not (s["status"] == "failed" and not specs[s["step_id"]]["required"])]
require(remaining and remaining[0]["step_id"] == step_id,403,"Geheimnisse sind nur für den aktuellen Schritt verfügbar.")
audit(connection,"device:"+run_id,"step.secrets_read",run_id,data={"step_id":step_id})
return json.loads(security.decrypt(row["secrets_ciphertext"]))["steps"].get(step_id,{})
@app.post("/agent/v1/runs/{run_id}/events")
def events(run_id:str,payload:EventBatch,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id)
ack = connection.execute("SELECT COALESCE(MAX(sequence),0) FROM events WHERE run_id=?",(run_id,)).fetchone()[0]
for event in payload.events:
redacted = service.redact_payload(row,event.model_dump())
if event.sequence <= ack:
stored = connection.execute("SELECT data FROM events WHERE run_id=? AND sequence=?",(run_id,event.sequence)).fetchone()
require(stored and stored[0] == redacted,409,"Sequenznummer wurde mit anderem Ereignisinhalt wiederholt.")
continue
require(event.sequence == ack+1,409,"Ereignisse müssen lückenlos und aufsteigend eintreffen.")
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
apply_event(connection,row,event)
connection.execute("INSERT INTO events VALUES(?,?,?,?)",(run_id,event.sequence,redacted,now_iso()))
ack = event.sequence
connection.execute("UPDATE runs SET last_seen=? WHERE id=?",(time.time(),run_id))
return {"ack_sequence":ack}
def apply_event(connection,row,event):
data = json.loads(row["data"])
status = row["status"]
require(status not in TERMINAL,409,"Terminaler Lauf nimmt keine neuen Ereignisse an.")
if event.type == "heartbeat":
return
if event.type.startswith("step."):
require(status in {"runner_ready","running"},409,"Schrittereignis ist in diesem Laufzustand nicht zulässig.")
step = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND step_id=?",(row["id"],event.step_id)).fetchone()
require(step is not None,422,"Schritt gehört nicht zum fixierten Manifest.")
specs = {s["id"]:s for s in data["manifest"]["steps"]}
previous = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND position<? AND status!='succeeded'",(row["id"],step["position"])).fetchall()
require(all(s["status"] == "failed" and not specs[s["step_id"]]["required"] for s in previous),409,"Vorheriger Pflichtschritt ist nicht erfolgreich abgeschlossen.")
for dependency in specs[event.step_id]["dependencies"]:
dep = connection.execute("SELECT status FROM run_steps WHERE run_id=? AND step_id=?",(row["id"],dependency)).fetchone()
require(dep and dep[0] == "succeeded",409,"Schrittabhängigkeit ist nicht erfolgreich.")
if event.type == "step.started":
require(row["lease_until"] and row["lease_until"]>time.time() and not data.get("cancel_requested") and not get_host(connection,row["host_id"])["blocked"],403,"Keine aktuelle Erlaubnis für einen neuen Schritt.")
require(step["status"] in {"pending","applying","failed"},409,"Abgeschlossener Schritt darf nicht erneut gestartet werden.")
connection.execute("UPDATE run_steps SET status='applying',attempt=attempt+1 WHERE run_id=? AND step_id=?",(row["id"],event.step_id))
status = "running"
elif event.type == "step.succeeded":
require(step["status"] == "applying" and event.exit_code == 0 and bool(event.verification),409,"Erfolg benötigt einen laufenden Schritt und erfolgreiche Verifikation.")
require(event.verification.get("passed") is True or ("passed" not in event.verification and all(v is True for v in event.verification.values())),409,"Verifikation bestätigt keinen Erfolg.")
connection.execute("UPDATE run_steps SET status='succeeded',verification=? WHERE run_id=? AND step_id=?",(service.redact_payload(row,event.verification),row["id"],event.step_id))
else:
require(step["status"] == "applying",409,"Nur ein laufender Schritt kann fehlschlagen.")
connection.execute("UPDATE run_steps SET status='failed',verification=? WHERE run_id=? AND step_id=?",(service.redact_payload(row,event.verification),row["id"],event.step_id))
status = "needs_review" if specs[event.step_id]["required"] else "running"
elif event.type == "run.needs_review":
status = "needs_review"
elif event.type == "run.reboot_pending":
require(status in {"running","runner_ready"} and data.get("reboots",0)<data["manifest"]["reboot_budget"],409,"Neustart ist nicht erlaubt oder Budget erschöpft.")
data["reboots"] = data.get("reboots",0)+1
data["boot_id"] = event.boot_id
status = "reboot_pending"
elif event.type == "run.resumed":
require((status == "reboot_pending" and event.boot_id != data.get("boot_id")) or status == "runner_ready",409,"Wiederaufnahme benötigt einen neuen Boot oder eine Operatorfreigabe.")
data["boot_id"] = event.boot_id
status = "runner_ready"
elif event.type == "run.cancelled":
require(data.get("cancel_requested"),409,"Kein Abbruch angefordert.")
status = "cancelled"
connection.execute("UPDATE runs SET status=?,data=?,version=version+1 WHERE id=?",(status,canonical(data),row["id"]))
if status == "cancelled":
connection.execute("UPDATE runs SET completed_at=?,lease_until=NULL,enrollment_hash=NULL,bootstrap_hash=NULL,report_hash=NULL WHERE id=?",(time.time(),row["id"]))
connection.execute("UPDATE hosts SET status=? WHERE id=?",(status,row["host_id"]))
@app.post("/agent/v1/runs/{run_id}/logs")
def logs(run_id:str,payload:LogBatch,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id)
ack = connection.execute("SELECT COALESCE(MAX(sequence),0) FROM logs WHERE run_id=?",(run_id,)).fetchone()[0]
for chunk in payload.chunks:
data = service.redact_payload(row,chunk.model_dump())
if chunk.sequence <= ack:
existing = connection.execute("SELECT data FROM logs WHERE run_id=? AND sequence=?",(run_id,chunk.sequence)).fetchone()
require(existing and existing[0]==data,409,"Logsequenz wurde mit anderem Inhalt wiederholt.")
continue
require(chunk.sequence==ack+1,409,"Logsequenz ist nicht lückenlos.")
connection.execute("INSERT INTO logs VALUES(?,?,?,?)",(run_id,chunk.sequence,data,now_iso()))
ack = chunk.sequence
return {"ack_sequence":ack}
@app.post("/agent/v1/runs/{run_id}/complete")
def complete(run_id:str,payload:Completion,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id)
if row["status"] == "succeeded":
return {"status":"succeeded"}
data = json.loads(row["data"])
require(row["status"] in {"running","runner_ready"} and not data.get("cancel_requested"),409,"Lauf ist nicht abschließbar.")
steps = connection.execute("SELECT * FROM run_steps WHERE run_id=?",(run_id,)).fetchall()
specs = {s["id"]:s for s in data["manifest"]["steps"]}
require(steps and all(s["status"]=="succeeded" or (s["status"]=="failed" and not specs[s["step_id"]]["required"]) for s in steps) and bool(payload.verification),409,"Alle Pflichtschritte müssen verifiziert erfolgreich sein.")
require(all(isinstance(payload.verification.get(s["id"]),dict) and payload.verification[s["id"]].get("passed") is True for s in specs.values() if s["required"]),409,"Abschlussprüfung muss jeden Pflichtschritt ausdrücklich als erfolgreich bestätigen.")
data["verification"] = json.loads(service.redact_payload(row,payload.verification))
connection.execute("UPDATE runs SET status='succeeded',version=version+1,data=?,completed_at=?,lease_until=NULL,enrollment_hash=NULL,bootstrap_hash=NULL,report_hash=NULL,answer_ciphertext=NULL,bootstrap_ciphertext=NULL WHERE id=?",(canonical(data),time.time(),run_id))
connection.execute("UPDATE hosts SET status='succeeded',version=version+1 WHERE id=?",(row["host_id"],))
audit(connection,"device:"+run_id,"run.succeeded",run_id)
return {"status":"succeeded"}
return app
+88
View File
@@ -0,0 +1,88 @@
"""Build a self-contained first-boot executable without target-side downloads."""
import base64
import json
from pathlib import Path
import urllib.parse
def render_bootstrap(config: dict) -> str:
required = {"api_url", "run_id", "enrollment_secret", "identities", "manifest_digest"}
if not required.issubset(config):
raise ValueError(f"Missing bootstrap values: {', '.join(sorted(required - config.keys()))}")
if urllib.parse.urlsplit(config["api_url"]).scheme != "https":
raise ValueError("Bootstrap requires a certificate-validated HTTPS API URL")
runner = base64.b64encode(Path(__file__).with_name("runner.py").read_bytes()).decode()
settings = base64.b64encode(json.dumps(config, ensure_ascii=False, sort_keys=True).encode()).decode()
script = f'''#!/bin/bash
set -euo pipefail
umask 077
test "$(id -u)" = 0
command -v python3 >/dev/null
command -v openssl >/dev/null
command -v systemctl >/dev/null
# Persist every input before enabling the first network operation.
python3 - <<'PVE_BOOTSTRAP_PY'
import base64, json, os, pathlib, tempfile
etc = pathlib.Path('/etc/pve-provisioner')
state = pathlib.Path('/var/lib/pve-provisioner')
etc.mkdir(mode=0o700, parents=True, exist_ok=True)
state.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(etc, 0o700)
os.chmod(state, 0o700)
config = base64.b64decode('{settings}')
settings = json.loads(config)
existing = state / 'state.json'
if existing.exists() and json.loads(existing.read_text())['run_id'] != json.loads(config)['run_id']:
raise SystemExit('Another run owns this host; explicit local state archival is required')
def persist(path, content, mode=0o600):
fd, name = tempfile.mkstemp(prefix='.tmp-', dir=path.parent)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, 'wb') as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
directory = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(name):
os.unlink(name)
if settings.get('ca_pem'):
persist(etc / 'trusted-ca.pem', settings.pop('ca_pem').encode())
settings['ca_file'] = str(etc / 'trusted-ca.pem')
config = json.dumps(settings, sort_keys=True).encode()
persist(etc / 'config.json', config)
persist(etc / 'runner.py', base64.b64decode('{runner}'))
service = b"""[Unit]
Description=Proxmox one-run provisioner
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=3600
StartLimitBurst=120
[Service]
Type=simple
ExecStart=/usr/bin/python3 /etc/pve-provisioner/runner.py
Restart=on-failure
RestartSec=30
TimeoutStopSec=14500
KillMode=mixed
UMask=0077
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
"""
persist(pathlib.Path('/etc/systemd/system/pve-provisioner.service'), service, 0o644)
PVE_BOOTSTRAP_PY
systemctl daemon-reload
systemctl enable --now pve-provisioner.service
'''
if len(script.encode()) >= 1024 * 1024:
raise ValueError("Bootstrap exceeds installer size limit")
return script
+26
View File
@@ -0,0 +1,26 @@
# Mitgelieferte Modulentwürfe
Alle acht Module werden als **Entwurf ohne Hardware-Testnachweis und ohne freigegebene Zielbuilds** angeboten. Vor Veröffentlichung müssen Quelltext, konkrete Parameter und Verhalten auf einem passenden Testhost geprüft werden. Die Syntaxprüfung ersetzt diesen Nachweis nicht.
| Modul | Parameter und Umfang |
| --- | --- |
| Voraussetzungen | `allowed_versions`: exakte Versionsstrings aus `pveversion`; `dns_names`: aufzulösende Namen; `minimum_free_mb`: freier Platz auf `/`. Eine leere Versionsliste lässt die zusätzliche Versionsprüfung aus; die Buildfreigabe im Dienst bleibt erforderlich. |
| Paketquellen | `url`, `suite`, `components`, `keyring` sind zwingend. Verwaltet genau `/etc/apt/sources.list.d/pve-provisioner.sources` mit HTTPS und vorhandenem APT-Schlüsselbund. Andere Quellen, insbesondere Subscription-Konfigurationen, werden nicht automatisch entfernt. |
| Basispakete | `packages`: Debian-Paketnamen ohne Shell-Ausdrücke oder APT-Optionen. Installiert fehlende Pakete, wartet auf die Paketmanagersperre und prüft anschließend den Installationsstatus. Führt kein allgemeines Systemupgrade aus. |
| SSH-Zugang | `users`: Liste aus `name` und `authorized_keys`. Benutzer müssen bereits existieren und eine Login-Shell sowie ein sicher berechtigtes Home-Verzeichnis haben. OpenSSH validiert vollständige Schlüssel vor jeder Änderung. Schlüssel werden ergänzt und Dateirechte, effektive lokale SSH-Schlüsselrichtlinie, `sshd -t` sowie der aktive Dienst geprüft. Verbindung und gegebenenfalls abweichende `Match`-Regeln aus dem realen Managementnetz sind separat zu testen. |
| Zeitsynchronisation | `servers`: explizite NTP-Hostnamen oder IP-Adressen. `chrony` muss zuvor installiert sein und `sourcedir /etc/chrony/sources.d` verwenden. Verwaltet eine eigene Quelldatei und wartet begrenzt auf Synchronisation. |
| Monitoring | `enabled`: standardmäßig `false`. Bei Aktivierung muss `prometheus-node-exporter` bereits installiert sein. Aktiviert den Dienst und prüft dessen lokalen Metrics-Endpunkt. Netzwerkzugriff auf den Exporter muss im Standortnetz passend geregelt sein. |
| Zusätzlicher Storage | `id`, `path`, `content`: registriert ein bereits existierendes Verzeichnis unter `/mnt/` oder `/srv/` als PVE-Verzeichnisstorage. Kein Formatieren, kein Mounten, keine Änderung widersprüchlicher vorhandener Storage-Konfiguration. |
| Abschlussprüfung | `allowed_versions`, `dns_names`, `storage_ids`, `require_time_sync`: prüft PVE-Dienste, Version, DNS, Zeit und angegebenen aktiven Storage. |
Modulabhängigkeiten beziehen sich im Verwaltungsmodell auf **Modulnamen**. Beim Freigeben werden sie auf die konkreten Schritt-IDs des unveränderlichen Laufmanifests aufgelöst. Profilparameter werden gegen das jeweilige JSON-Schema geprüft. Beispielsweise muss das Paketprofil `chrony` enthalten, wenn der Zeitschritt auf einem Host ohne Chrony eingeplant wird.
## Modulvertrag
Der Runner startet `bash modul.sh check|apply|verify parameter.json`. `check` liefert `0`, wenn der Sollzustand erreicht ist, `1`, wenn eine Änderung nötig ist, und einen anderen Rückgabecode für einen Prüffehler. Ein Schritt gilt erst nach erfolgreichem `verify` als erfolgreich. `apply` darf mit `194` einen geplanten Neustart anfordern; der Runner schreibt zuerst seinen Checkpoint und kontrolliert das Neustartbudget. Ein Modul darf den Neustart nicht selbst auslösen.
Die Parameterdatei enthält die freigegebenen Parameter sowie ein Objekt `secrets` mit ausschließlich den Geheimnissen des aktuellen Schritts. Sie wird mit Modus `0600` angelegt und nach dem Schritt entfernt. Module sollen keine Geheimnisse ausgeben; zusätzlich redigiert der Runner bekannte Geheimniswerte vor der dauerhaften Logablage. Logausgabe ist pro Phase und in der lokalen Warteschlange begrenzt.
Das Schritt-Timeout gilt gemeinsam für `check`, `apply` und `verify`. Nach Unterbrechungen werden `check` und `verify` erneut ausgeführt; ein nicht bestätigter Zustand darf nur bei `retry_safe=true` erneut angewendet werden. Ein permanenter Fehler wartet auf eine explizite Wiederaufnahme im Webtool. Die standardmäßige Wartefrist beträgt 24 Stunden, die maximale automatische Wiederherstellung bei Netzausfall 30 Minuten.
Chrony-Kommandos orientieren sich an der offiziellen Dokumentation zu [chronyc](https://chrony-project.org/doc/4.7/chronyc.html) und [sourcedir](https://chrony-project.org/doc/4.7/chrony.conf.html). Die tatsächliche Paketversion und Distribution bleiben Bestandteil des Zielhost-Tests.
+53
View File
@@ -0,0 +1,53 @@
"""Conservative module drafts. Publication always requires target-host evidence."""
from pathlib import Path
def schema(properties, required=()):
return {"type": "object", "additionalProperties": False, "properties": properties, "required": list(required)}
STRING_LIST = {"type": "array", "items": {"type": "string"}, "maxItems": 100}
DEFINITIONS = [
("prerequisites", "Voraussetzungen", "PVE-Version, DNS, Uhrzeit und freien Speicher prüfen.",
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
"minimum_free_mb": {"type": "integer", "minimum": 512, "maximum": 1048576}}),
{"allowed_versions": [], "dns_names": [], "minimum_free_mb": 2048}, [], 120, True),
("repositories", "Paketquellen", "Eine signierte, explizit freigegebene HTTPS-Paketquelle verwalten.",
schema({"url": {"type": "string"}, "suite": {"type": "string"}, "components": STRING_LIST,
"keyring": {"type": "string"}}, ("url", "suite", "components", "keyring")),
{}, ["prerequisites"], 600, True),
("packages", "Basispakete", "Explizit genannte Pakete installieren; keine globale Aktualisierung.",
schema({"packages": STRING_LIST}), {"packages": []}, ["prerequisites"], 1800, True),
("ssh", "SSH-Zugang", "Freigegebene Schlüssel vorhandenen Benutzern hinzufügen; sshd validieren.",
schema({"users": {"type": "array", "maxItems": 50, "items": schema({
"name": {"type": "string", "pattern": "^[a-z_][a-z0-9_-]{0,31}$"},
"authorized_keys": STRING_LIST}, ("name", "authorized_keys"))}}),
{"users": []}, ["prerequisites"], 120, True),
("time", "Zeitsynchronisation", "Chrony mit expliziten Zeitservern konfigurieren und Synchronisation prüfen.",
schema({"servers": STRING_LIST}, ("servers",)), {"servers": []}, ["packages"], 600, True),
("monitoring", "Monitoring", "Optional den Debian prometheus-node-exporter aktivieren.",
schema({"enabled": {"type": "boolean"}}), {"enabled": False}, ["packages"], 600, False),
("storage", "Zusätzlicher Storage", "Vorhandenes Verzeichnis ohne Formatierung als PVE-Storage anbinden.",
schema({"id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"},
"path": {"type": "string"}, "content": STRING_LIST}, ("id", "path", "content")),
{}, ["prerequisites"], 120, True),
("final-verification", "Abschlussprüfung", "PVE-Dienste, Versionsstand, DNS, Zeit und aktiven Storage prüfen.",
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
"storage_ids": STRING_LIST, "require_time_sync": {"type": "boolean"}}),
{"allowed_versions": [], "dns_names": [], "storage_ids": [], "require_time_sync": True},
["prerequisites"], 180, True),
]
def catalog():
result = []
root = Path(__file__).parent
names = {definition[0]: definition[1] for definition in DEFINITIONS}
for module_id, name, description, parameters_schema, defaults, dependencies, timeout, required in DEFINITIONS:
result.append({"id": module_id, "name": name, "description": description, "version": "1.0.0",
"source": (root / f"{module_id}.sh").read_text(encoding="utf-8"),
"parameters_schema": parameters_schema, "default_parameters": defaults,
"dependencies": [names[dependency] for dependency in dependencies], "timeout_seconds": timeout,
"retry_safe": True, "required": required, "status": "draft",
"test_evidence": "", "target_builds": []})
return result
@@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, socket, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
try:
version = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=30).stdout
match = re.search(r'pve-manager/([^/\s]+)', version)
if not match or (p.get('allowed_versions') and match[1] not in p['allowed_versions']):
raise ValueError('PVE version verification failed')
for service in ('pve-cluster.service', 'pvedaemon.service', 'pveproxy.service', 'pvestatd.service'):
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
for name in p.get('dns_names', []):
socket.getaddrinfo(name, 443)
if p.get('require_time_sync', True):
sync = subprocess.run(['timedatectl', 'show', '-p', 'NTPSynchronized', '--value'], check=True, capture_output=True, text=True, timeout=30)
if sync.stdout.strip() != 'yes':
raise ValueError('System clock is not synchronized')
for storage_id in p.get('storage_ids', []):
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
raise ValueError('Invalid storage identifier')
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+\S+\s+active\s', line) for line in result.stdout.splitlines()):
raise ValueError('Required storage is not active')
print(json.dumps({'passed': True, 'pve_version': match[1], 'services': 'active', 'clock': 'checked', 'storage': 'checked'}))
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(2)
PY
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, subprocess, sys, urllib.request
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
if not isinstance(p.get('enabled', False), bool):
raise SystemExit('enabled must be a boolean')
if not p.get('enabled', False):
print('{"passed":true,"enabled":false}')
raise SystemExit(0)
service = 'prometheus-node-exporter.service'
active = subprocess.run(['systemctl', 'is-active', '--quiet', service], timeout=30).returncode == 0
if mode == 'check':
sys.exit(0 if active else 1)
if mode == 'apply':
package = subprocess.run(['dpkg-query', '-W', '-f=${Status}', 'prometheus-node-exporter'], capture_output=True, text=True, timeout=30)
if package.returncode != 0 or package.stdout != 'install ok installed':
raise SystemExit('Install prometheus-node-exporter with the package module first')
subprocess.run(['systemctl', 'enable', '--now', service], check=True, timeout=60)
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
with urllib.request.urlopen('http://127.0.0.1:9100/metrics', timeout=10) as response:
body = response.read(2 * 1024 * 1024)
if b'node_exporter_build_info' not in body:
raise SystemExit('Node exporter metrics validation failed')
print('{"passed":true,"metrics":"available"}')
PY
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
packages = p.get('packages', [])
if not isinstance(packages, list) or len(packages) > 100 or any(not isinstance(x, str) or not re.fullmatch(r'[a-z0-9][a-z0-9+.-]{0,100}', x) for x in packages):
raise SystemExit('Invalid package list; names only, no options or shell expressions')
def installed(name):
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}', name], capture_output=True, text=True, timeout=30)
return result.returncode == 0 and result.stdout == 'install ok installed'
missing = [name for name in packages if not installed(name)]
if mode == 'apply' and missing:
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=600)
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', '--no-install-recommends', '--', *missing], check=True, timeout=1200)
missing = [name for name in packages if not installed(name)]
print(json.dumps({'passed': not missing, 'missing': missing}))
sys.exit(1 if missing else 0)
PY
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, shutil, socket, subprocess, sys, time
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
try:
result = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=20)
match = re.search(r'pve-manager/([^/\s]+)', result.stdout)
if not match:
raise ValueError('Target does not report a Proxmox VE manager version')
if p.get('allowed_versions') and match[1] not in p['allowed_versions']:
raise ValueError('PVE version is outside the approved module target versions')
minimum = p.get('minimum_free_mb', 2048)
if not isinstance(minimum, int) or not 512 <= minimum <= 1048576:
raise ValueError('minimum_free_mb is invalid')
if shutil.disk_usage('/').free < minimum * 1024 * 1024:
raise ValueError('Insufficient free root filesystem space')
if time.time() < 1704067200:
raise ValueError('System clock is not plausible')
for name in p.get('dns_names', []):
if not isinstance(name, str) or not name or len(name) > 253:
raise ValueError('Invalid DNS target')
socket.getaddrinfo(name, 443)
print(json.dumps({'passed': True, 'pve_version': match[1], 'dns': 'resolved', 'free_space': 'sufficient'}))
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(2)
PY
@@ -0,0 +1,48 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, os, pathlib, re, subprocess, sys, tempfile, urllib.parse
def has_repository_indexes(policy, url, suite, components):
indexes = [line.split() for line in policy.splitlines()]
return all(any(any(field.rstrip('/') == url.rstrip('/') for field in fields) and f'{suite}/{component}' in fields for fields in indexes) for component in components)
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
url, suite, components, keyring = (p.get(k) for k in ('url', 'suite', 'components', 'keyring'))
if not isinstance(url, str) or any(c.isspace() for c in url):
raise SystemExit('An explicit HTTPS repository URL is required')
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise SystemExit('Repository URL must use HTTPS without credentials or query parameters')
if not isinstance(suite, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,40}', suite):
raise SystemExit('Invalid repository suite')
if not isinstance(components, list) or not components or any(not isinstance(c, str) or not re.fullmatch(r'[a-z][a-z0-9/-]{0,50}', c) for c in components):
raise SystemExit('Invalid repository components')
if not isinstance(keyring, str) or not re.fullmatch(r'/(usr/share|etc/apt)/keyrings/[A-Za-z0-9_.-]+\.(gpg|asc)', keyring) or not pathlib.Path(keyring).is_file():
raise SystemExit('An existing administrator-provisioned APT keyring is required')
expected = f'Types: deb\nURIs: {url}\nSuites: {suite}\nComponents: {" ".join(components)}\nSigned-By: {keyring}\n'
target = pathlib.Path('/etc/apt/sources.list.d/pve-provisioner.sources')
matches = target.is_file() and target.read_text() == expected
if mode == 'check':
sys.exit(0 if matches else 1)
if mode == 'apply' and not matches:
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, 'w') as stream:
stream.write(expected)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
if mode == 'apply':
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=500)
policy = subprocess.run(['apt-cache', 'policy'], check=True, capture_output=True, text=True, timeout=30).stdout
indexed = has_repository_indexes(policy, url, suite, components)
passed = target.is_file() and target.read_text() == expected and indexed
print(json.dumps({'passed': passed, 'repository': url, 'suite': suite}))
sys.exit(0 if passed else 1)
PY
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import base64, json, os, pathlib, pwd, re, subprocess, sys, tempfile
def validate_public_key(key):
if not isinstance(key, str) or '\n' in key or '\r' in key or len(key) > 16384:
raise SystemExit('Invalid SSH public key')
parts = key.split()
if len(parts) < 2 or parts[0] not in ('ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'):
raise SystemExit('Unsupported SSH public key format')
try:
blob = base64.b64decode(parts[1], validate=True)
size = int.from_bytes(blob[:4], 'big')
if blob[4:4 + size].decode() != parts[0] or len(blob) <= 4 + size:
raise ValueError()
except (ValueError, UnicodeError):
raise SystemExit('Invalid SSH public key encoding')
# sshd -t does not parse authorized_keys. Validate the complete public key.
fd, key_file = tempfile.mkstemp(prefix='pve-public-key-')
try:
with os.fdopen(fd, 'w') as stream:
stream.write(key + '\n')
parsed = subprocess.run(['ssh-keygen', '-l', '-f', key_file], capture_output=True, text=True, timeout=15)
if parsed.returncode != 0:
raise SystemExit('OpenSSH rejected the configured public key')
finally:
os.unlink(key_file)
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
users = p.get('users', [])
if not isinstance(users, list) or len(users) > 50:
raise SystemExit('Invalid SSH users')
changes = []
for entry in users:
name = entry.get('name', '')
if not isinstance(name, str) or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', name):
raise SystemExit('Invalid SSH account name')
try:
user = pwd.getpwnam(name)
except KeyError:
raise SystemExit('SSH module requires an existing user account')
keys = entry.get('authorized_keys', [])
if not isinstance(keys, list) or not keys or len(keys) > 100:
raise SystemExit('At least one authorized key per configured user is required')
for key in keys:
validate_public_key(key)
home = pathlib.Path(user.pw_dir)
if not home.is_dir() or home.stat().st_uid not in (0, user.pw_uid) or home.stat().st_mode & 0o022:
raise SystemExit('Account home must have safe ownership and must not be writable by group or others')
if user.pw_shell in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'):
raise SystemExit('SSH account requires an interactive login shell')
effective = subprocess.run(['/usr/sbin/sshd', '-T', '-C', f'user={name},host=localhost,addr=127.0.0.1'], check=True, capture_output=True, text=True, timeout=30)
settings = dict(line.split(None, 1) for line in effective.stdout.splitlines() if ' ' in line)
if settings.get('pubkeyauthentication') != 'yes' or (name == 'root' and settings.get('permitrootlogin') not in ('yes', 'prohibit-password', 'without-password')):
raise SystemExit('Effective SSH policy does not permit public-key login for this account')
key_paths = settings.get('authorizedkeysfile', '').split()
accepted = {'.ssh/authorized_keys', '%h/.ssh/authorized_keys', str(home / '.ssh/authorized_keys')}
if not accepted.intersection(key_paths):
raise SystemExit('Effective SSH policy does not use the managed authorized_keys file')
directory = pathlib.Path(user.pw_dir) / '.ssh'
target = directory / 'authorized_keys'
if directory.is_symlink() or target.is_symlink():
raise SystemExit('Refusing symlinked SSH paths')
existing = target.read_text() if target.exists() else ''
missing = [key for key in keys if key not in existing.splitlines()]
correct_permissions = directory.exists() and target.exists() and directory.stat().st_mode & 0o777 == 0o700 and target.stat().st_mode & 0o777 == 0o600 and target.stat().st_uid == user.pw_uid and directory.stat().st_uid == user.pw_uid
if missing or not correct_permissions:
changes.append((user, directory, target, existing, missing))
if mode == 'check':
sys.exit(1 if changes else 0)
if mode == 'apply':
for user, directory, target, existing, missing in changes:
directory.mkdir(mode=0o700, exist_ok=True)
os.chmod(directory, 0o700)
os.chown(directory, user.pw_uid, user.pw_gid)
content = existing.rstrip('\n') + ('\n' if existing else '') + '\n'.join(missing) + ('\n' if missing else '')
fd, name = tempfile.mkstemp(prefix='.authorized-', dir=directory)
try:
os.fchmod(fd, 0o600)
os.fchown(fd, user.pw_uid, user.pw_gid)
with os.fdopen(fd, 'w') as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
subprocess.run(['/usr/sbin/sshd', '-t'], check=True, timeout=30)
subprocess.run(['systemctl', 'is-active', '--quiet', 'ssh.service'], check=True, timeout=30)
if mode == 'verify' and changes:
raise SystemExit(1)
print(json.dumps({'passed': True, 'accounts': len(users), 'sshd': 'validated'}))
PY
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
storage_id, location, content = (p.get(k) for k in ('id', 'path', 'content'))
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
raise SystemExit('Invalid storage identifier')
if not isinstance(location, str) or any(c.isspace() for c in location) or not location.startswith(('/mnt/', '/srv/')):
raise SystemExit('Storage must be an existing absolute directory under /mnt or /srv')
directory = pathlib.Path(location)
if not directory.is_dir() or directory.is_symlink() or str(directory.resolve()) != location.rstrip('/'):
raise SystemExit('Storage directory must already exist without symbolic links or traversal')
if not isinstance(content, list) or not content or not set(content).issubset({'images', 'rootdir', 'vztmpl', 'iso', 'backup', 'snippets'}):
raise SystemExit('Invalid storage content types')
def configuration():
result = subprocess.run(['pvesh', 'get', '/storage', '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
if not any(entry.get('storage') == storage_id for entry in json.loads(result.stdout)):
return None
detail = subprocess.run(['pvesh', 'get', '/storage/' + storage_id, '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
return json.loads(detail.stdout)
existing = configuration()
if existing and (existing.get('type') != 'dir' or existing.get('path', '').rstrip('/') != location.rstrip('/') or set(existing.get('content', '').split(',')) != set(content)):
raise SystemExit('Existing storage has conflicting settings; automatic changes are refused')
if mode == 'check':
sys.exit(0 if existing else 1)
if mode == 'apply' and not existing:
subprocess.run(['pvesm', 'add', 'dir', storage_id, '--path', location, '--content', ','.join(content)], check=True, timeout=60)
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+dir\s+active\s', line) for line in result.stdout.splitlines()):
raise SystemExit('Storage is not active')
print(json.dumps({'passed': True, 'storage': storage_id, 'destructive_operations': False}))
PY
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import ipaddress, json, os, pathlib, re, subprocess, sys, tempfile
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
servers = p.get('servers')
if not isinstance(servers, list) or not 1 <= len(servers) <= 16:
raise SystemExit('Between one and sixteen explicit NTP servers are required')
for server in servers:
if not isinstance(server, str) or len(server) > 253:
raise SystemExit('Invalid NTP server')
try:
ipaddress.ip_address(server)
except ValueError:
if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?', server):
raise SystemExit('NTP server must be a hostname or IP address')
target = pathlib.Path('/etc/chrony/sources.d/pve-provisioner.sources')
expected = ''.join(f'server {server} iburst\n' for server in servers)
active = subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], timeout=30).returncode == 0
matches = target.is_file() and target.read_text() == expected
if mode == 'check':
sys.exit(0 if matches and active else 1)
if mode == 'apply':
# The package module owns installation; this module never swaps NTP daemons.
config = pathlib.Path('/etc/chrony/chrony.conf')
if not config.is_file() or not any(line.strip() == 'sourcedir /etc/chrony/sources.d' for line in config.read_text().splitlines()):
raise SystemExit('Install chrony first and enable its standard sources.d directory')
target.parent.mkdir(mode=0o755, exist_ok=True)
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, 'w') as stream:
stream.write(expected)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
subprocess.run(['systemctl', 'enable', '--now', 'chrony.service'], check=True, timeout=60)
subprocess.run(['chronyc', 'reload', 'sources'], check=True, timeout=30)
subprocess.run(['chronyc', 'waitsync', '30', '0.5', '0', '2'], check=True, timeout=90)
subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], check=True, timeout=30)
if not target.is_file() or target.read_text() != expected:
raise SystemExit(1)
print(json.dumps({'passed': True, 'clock': 'synchronized'}))
PY
+287
View File
@@ -0,0 +1,287 @@
"""Operational commands; no administrative password is saved in clear text."""
from __future__ import annotations
import argparse
from contextlib import closing, suppress
from dataclasses import asdict
import getpass
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import sqlite3
import sys
import tempfile
from datetime import datetime, timezone
from uuid import uuid4
class ServiceLock:
"""A process-lifetime lock shared by the web service and offline operations."""
def __init__(self, data_dir: Path):
self.path = Path(data_dir) / ".service.lock"
self.handle = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self.handle = self.path.open("a+b")
try:
if self.path.stat().st_size == 0:
self.handle.write(b"0")
self.handle.flush()
self.handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, BlockingIOError) as exc:
self.handle.close()
self.handle = None
raise RuntimeError("Data directory is in use. Stop the service before this operation.") from exc
return self
def __exit__(self, *_):
if self.handle is not None:
if os.name == "nt":
import msvcrt
self.handle.seek(0)
msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
self.handle.close()
self.handle = None
def load_settings():
"""Read optional TOML, then environment overrides, with safe defaults."""
from provisioner.config import Settings
return Settings.from_env()
def initialize(settings, username: str | None = None) -> None:
from cryptography.fernet import Fernet
from provisioner.db import Database
from provisioner.security import Security
data_dir = Path(settings.data_dir).resolve()
key_path = Path(settings.master_key_file).resolve()
if key_path == data_dir or data_dir in key_path.parents:
raise ValueError("MASTER_KEY_FILE must be outside DATA_DIR")
with ServiceLock(data_dir):
database = Database(settings)
database.initialize()
with database.connection() as connection:
if connection.execute("SELECT 1 FROM users LIMIT 1").fetchone():
raise ValueError("Already initialized; use the administrator interface to manage users")
username = username or input("Administrator username: ").strip()
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}", username):
raise ValueError("Administrator username requires 2 to 64 letters, digits, dots, underscores or hyphens")
password = getpass.getpass("Administrator password (at least 12 characters): ")
if len(password) < 12:
raise ValueError("Use a password with at least 12 characters")
if password != getpass.getpass("Repeat administrator password: "):
raise ValueError("Passwords do not match")
if not key_path.exists():
key_path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(Fernet.generate_key())
security = Security(settings)
with database.connection() as connection:
connection.execute(
"INSERT INTO users(id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)",
(str(uuid4()), username, security.hash_password(password), "admin", datetime.now(timezone.utc).isoformat()),
)
print(f"Initialized {data_dir}. Administrator: {username}")
print(f"Back up the encryption key separately: {key_path}")
def _digest(path: Path) -> str:
with path.open("rb") as handle:
return hashlib.file_digest(handle, "sha256").hexdigest()
def backup(settings, destination: Path) -> None:
"""Snapshot SQLite, then immutable referenced artifacts; never include keys."""
from provisioner.db import Database
destination = destination.resolve()
data_dir = Path(settings.data_dir).resolve()
if destination == data_dir or data_dir in destination.parents:
raise ValueError("Backup destination must be outside DATA_DIR")
if destination.exists():
raise ValueError("Backup destination already exists; use a new directory")
database = Database(settings)
if not database.path.exists():
raise ValueError("No initialized database found")
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = Path(tempfile.mkdtemp(prefix=".ais-backup-", dir=destination.parent))
try:
database.backup(temporary / "database.sqlite3")
artifact_dir = data_dir / "artifacts"
if artifact_dir.exists():
if artifact_dir.is_symlink():
raise ValueError("Refusing symlink in artifact storage")
# Artifacts are immutable; copying after the database snapshot includes
# every artifact referenced by that snapshot, plus possibly newer ones.
for source in artifact_dir.rglob("*"):
if source.is_symlink():
raise ValueError("Refusing symlink in artifact storage")
if source.is_file():
target = temporary / "artifacts" / source.relative_to(artifact_dir)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
config = {
"public_url": settings.public_url,
"secure_cookies": settings.secure_cookies,
"key_included": False,
"master_key_sha256": _digest(Path(settings.master_key_file)),
"format": 1,
"created_at": datetime.now(timezone.utc).isoformat(),
"configuration": {
name: value for name, value in asdict(settings).items()
if name not in {"data_dir", "master_key_file", "bootstrap_username", "bootstrap_password", "testing"}
},
}
(temporary / "settings.json").write_text(json.dumps(config, indent=2), encoding="utf-8")
hashes = {path.relative_to(temporary).as_posix(): _digest(path) for path in temporary.rglob("*") if path.is_file()}
(temporary / "manifest.json").write_text(json.dumps(hashes, indent=2), encoding="utf-8")
temporary.rename(destination)
except BaseException:
if temporary.resolve().parent == destination.parent and temporary.name.startswith(".ais-backup-"):
with suppress(OSError):
shutil.rmtree(temporary)
raise
print(f"Backup complete: {destination}")
print("Encryption key is excluded. Save it separately and test restoration.")
def _invalidate_restored_state(connection: sqlite3.Connection) -> None:
"""Prevent rollback of the database from resurrecting machine credentials."""
connection.execute("DELETE FROM sessions")
connection.execute("DELETE FROM nonces")
connection.execute("UPDATE approvals SET status='revoked' WHERE status='approved'")
connection.execute("UPDATE groups SET revoked=1")
connection.execute("UPDATE hosts SET status='needs_review' WHERE id IN (SELECT host_id FROM runs WHERE status NOT IN ('succeeded','failed','cancelled','expired'))")
connection.execute("UPDATE runs SET status='needs_review' WHERE status NOT IN ('succeeded','failed','cancelled','expired')")
connection.execute(
"UPDATE runs SET device_key=NULL, bootstrap_hash=NULL, enrollment_hash=NULL, report_hash=NULL, "
"answer_until=0, enroll_until=0, lease_until=0, answer_ciphertext=NULL, bootstrap_ciphertext=NULL"
)
connection.execute(
"INSERT INTO audit(id,actor,action,object_id,reason,data,created_at) VALUES(?,?,?,?,?,?,?)",
(str(uuid4()), "system", "backup.restore", "database", "Offline restore; credentials revoked and active runs require review", "{}", datetime.now(timezone.utc).isoformat()),
)
def restore(settings, source: Path) -> None:
"""Restore into a new data directory; the original data is never overwritten."""
from cryptography.fernet import Fernet
from provisioner.db import Database
source = source.resolve()
data_dir = Path(settings.data_dir).resolve()
if not Path(settings.master_key_file).is_file():
raise ValueError("Restore the separately protected MASTER_KEY_FILE first")
Fernet(Path(settings.master_key_file).read_bytes().strip())
if not (source / "manifest.json").is_file():
raise ValueError("Missing backup manifest")
hashes = json.loads((source / "manifest.json").read_text(encoding="utf-8"))
if "database.sqlite3" not in hashes or "settings.json" not in hashes:
raise ValueError("Incomplete backup manifest")
for name, expected in hashes.items():
original = source / name
candidate = original.resolve()
if source not in candidate.parents or original.is_symlink() or any(parent.is_symlink() for parent in original.parents if parent != source.parent) or not candidate.is_file():
raise ValueError("Unsafe or missing backup file")
if _digest(candidate) != expected:
raise ValueError(f"Backup checksum mismatch: {name}")
config = json.loads((source / "settings.json").read_text(encoding="utf-8"))
if config.get("format") != 1:
raise ValueError("Unsupported backup format")
if _digest(Path(settings.master_key_file)) != config.get("master_key_sha256"):
raise ValueError("MASTER_KEY_FILE does not match this backup")
with ServiceLock(data_dir):
existing = [path for path in data_dir.iterdir() if path.name != ".service.lock"]
if existing:
raise ValueError("Restore requires an empty DATA_DIR. Keep the old directory until validation completes.")
database = Database(settings)
shutil.copy2(source / "database.sqlite3", database.path)
try:
with closing(sqlite3.connect(database.path)) as connection, connection:
connection.execute("PRAGMA foreign_keys=ON")
if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("Backup database failed integrity check")
if connection.execute("PRAGMA user_version").fetchone()[0] != 1:
raise ValueError("Unsupported backup database schema")
_invalidate_restored_state(connection)
for name in hashes:
if name.startswith("artifacts/"):
target = data_dir / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source / name, target)
except BaseException:
# Leave failed restoration for inspection; never start it silently.
(data_dir / "RESTORE_FAILED").write_text("Restore failed. Do not start this data directory.\n", encoding="utf-8")
raise
print(f"Restored into {data_dir}; active runs need review and previous machine credentials are invalid.")
print("Review settings.json, revoke/reissue installation media, and reconcile hosts before new approvals.")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="proxmox-ais")
commands = parser.add_subparsers(dest="command", required=True)
init_parser = commands.add_parser("init", help="Create external encryption key and initial administrator")
init_parser.add_argument("--username")
serve_parser = commands.add_parser("serve", help="Start the HTTP service behind a trusted TLS proxy")
serve_parser.add_argument("--host", default="127.0.0.1")
serve_parser.add_argument("--port", type=int, default=8080)
serve_parser.add_argument("--tls-cert", type=Path)
serve_parser.add_argument("--tls-key", type=Path)
backup_parser = commands.add_parser("backup", help="Create a consistent database and artifact snapshot")
backup_parser.add_argument("destination", type=Path)
restore_parser = commands.add_parser("restore", help="Restore offline into an empty DATA_DIR")
restore_parser.add_argument("source", type=Path)
args = parser.parse_args(argv)
try:
settings = load_settings()
if args.command == "init":
initialize(settings, args.username)
elif args.command == "backup":
backup(settings, args.destination)
elif args.command == "restore":
restore(settings, args.source)
elif args.command == "serve":
import uvicorn
from provisioner.app import create_app
if bool(args.tls_cert) != bool(args.tls_key):
raise ValueError("Provide both --tls-cert and --tls-key")
if (Path(settings.data_dir) / "RESTORE_FAILED").exists():
raise ValueError("This data directory contains a failed restore")
uvicorn.run(
create_app(settings), host=args.host, port=args.port,
proxy_headers=False, access_log=False,
ssl_certfile=str(args.tls_cert) if args.tls_cert else None,
ssl_keyfile=str(args.tls_key) if args.tls_key else None,
)
except (OSError, ValueError, RuntimeError, sqlite3.Error) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
from dataclasses import dataclass, field, fields
import os
from pathlib import Path
import tomllib
from urllib.parse import urlsplit
@dataclass
class Settings:
data_dir: Path = Path("data")
master_key_file: Path = Path("secrets/master.key")
public_url: str = "https://localhost:8080"
secure_cookies: bool = True
bootstrap_username: str | None = None
bootstrap_password: str | None = None
testing: bool = False
session_hours: int = 8
answer_window_seconds: int = 300
enrollment_hours: int = 4
lease_seconds: int = 900
heartbeat_unknown_seconds: int = 180
log_retention_days: int = 30
audit_retention_days: int = 180
max_request_bytes: int = 1048576
four_eyes: bool = True
maintenance: bool = False
trusted_proxy_ips: str = "127.0.0.1"
runner_ca_file: str | None = None
defaults: dict = field(default_factory=dict)
sites: dict = field(default_factory=dict)
def __post_init__(self):
self.data_dir = Path(self.data_dir)
self.master_key_file = Path(self.master_key_file)
self.public_url = self.public_url.rstrip("/")
url = urlsplit(self.public_url)
if not url.hostname or url.username or url.password or url.query or url.fragment or url.path:
raise ValueError("PUBLIC_URL muss eine absolute Basis-URL ohne Pfad sein.")
if url.scheme != "https" and not (url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1", "testserver"}):
raise ValueError("PUBLIC_URL benötigt HTTPS; HTTP ist nur für lokale Entwicklung erlaubt.")
if self.master_key_file.resolve().is_relative_to(self.data_dir.resolve()):
raise ValueError("Der Master-Key muss außerhalb des Datenverzeichnisses liegen.")
for name in ("session_hours", "answer_window_seconds", "enrollment_hours", "lease_seconds", "max_request_bytes"):
if getattr(self, name) <= 0:
raise ValueError(f"{name} muss positiv sein.")
@classmethod
def from_env(cls):
values = {}
if os.environ.get("APP_CONFIG"):
with open(os.environ["APP_CONFIG"], "rb") as stream:
loaded = tomllib.load(stream)
values.update(loaded.get("app", loaded))
bools = {"secure_cookies", "testing", "four_eyes", "maintenance"}
ints = {f.name for f in fields(cls) if f.type is int}
allowed = {f.name for f in fields(cls)}
unknown = set(values) - allowed
if unknown:
raise ValueError(f"Unbekannte Konfiguration: {', '.join(sorted(unknown))}")
for key in allowed:
value = os.environ.get(key.upper())
if value is not None:
values[key] = value.lower() in {"true", "1", "yes"} if key in bools else int(value) if key in ints else value
return cls(**values)
+66
View File
@@ -0,0 +1,66 @@
from contextlib import closing, contextmanager
from pathlib import Path
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL, created_at TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), csrf_token TEXT NOT NULL, expires_at REAL NOT NULL);
CREATE TABLE IF NOT EXISTS hosts(id TEXT PRIMARY KEY, fqdn TEXT NOT NULL UNIQUE COLLATE NOCASE, management_ip TEXT UNIQUE, site TEXT NOT NULL, status TEXT NOT NULL, blocked INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 1, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS host_identities(host_id TEXT NOT NULL REFERENCES hosts(id), kind TEXT NOT NULL, value TEXT NOT NULL, UNIQUE(kind,value));
CREATE TABLE IF NOT EXISTS profiles(id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(name,kind,version));
CREATE TABLE IF NOT EXISTS modules(id TEXT PRIMARY KEY, name TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL, digest TEXT NOT NULL, data TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(name,version));
CREATE TABLE IF NOT EXISTS groups(id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, site TEXT NOT NULL, token_hash TEXT NOT NULL, expires_at REAL NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS iso_records(id TEXT PRIMARY KEY, name TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS secrets(id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, ciphertext TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS approvals(id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES hosts(id), status TEXT NOT NULL, expires_at REAL NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS one_open_approval ON approvals(host_id) WHERE status='approved';
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES hosts(id), approval_id TEXT NOT NULL UNIQUE REFERENCES approvals(id), status TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, data TEXT NOT NULL, answer_ciphertext TEXT, bootstrap_ciphertext TEXT, secrets_ciphertext TEXT, bootstrap_hash TEXT UNIQUE, enrollment_hash TEXT, report_hash TEXT UNIQUE, device_key TEXT, answer_until REAL, enroll_until REAL, lease_until REAL, last_seen REAL, completed_at REAL, created_at TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS one_active_run ON runs(host_id) WHERE status NOT IN ('succeeded','failed','cancelled','expired');
CREATE TABLE IF NOT EXISTS run_steps(run_id TEXT NOT NULL REFERENCES runs(id), step_id TEXT NOT NULL, position INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempt INTEGER NOT NULL DEFAULT 0, verification TEXT NOT NULL DEFAULT '{}', PRIMARY KEY(run_id,step_id));
CREATE TABLE IF NOT EXISTS events(run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(run_id,sequence));
CREATE TABLE IF NOT EXISTS logs(run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(run_id,sequence));
CREATE TABLE IF NOT EXISTS nonces(run_id TEXT NOT NULL REFERENCES runs(id), nonce TEXT NOT NULL, expires_at REAL NOT NULL, PRIMARY KEY(run_id,nonce));
CREATE TABLE IF NOT EXISTS audit(id TEXT PRIMARY KEY, actor TEXT NOT NULL, action TEXT NOT NULL, object_id TEXT NOT NULL, reason TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS discoveries(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL UNIQUE, site TEXT NOT NULL, data TEXT NOT NULL, reason TEXT NOT NULL, last_seen REAL NOT NULL);
INSERT OR IGNORE INTO schema_migrations(version) VALUES(1);
"""
class Database:
def __init__(self, settings):
self.path = settings.data_dir / "provisioner.sqlite3"
def initialize(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.connection() as connection:
connection.execute("PRAGMA journal_mode=WAL")
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version > 1:
raise RuntimeError("Datenbankschema ist neuer als diese Anwendung.")
connection.executescript(SCHEMA)
connection.execute("PRAGMA user_version=1")
@contextmanager
def connection(self, write=False):
connection = sqlite3.connect(self.path, timeout=15, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=15000")
try:
if write:
connection.execute("BEGIN IMMEDIATE")
yield connection
if connection.in_transaction:
connection.commit()
except BaseException:
if connection.in_transaction:
connection.rollback()
raise
finally:
connection.close()
def backup(self, destination: Path):
destination.parent.mkdir(parents=True, exist_ok=True)
with self.connection() as source, closing(sqlite3.connect(destination)) as target:
source.backup(target)
+199
View File
@@ -0,0 +1,199 @@
from typing import Any, Literal
from ipaddress import ip_interface
import re
import uuid
from pydantic import BaseModel, ConfigDict, Field, field_validator
class Model(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class Identity(Model):
kind: Literal["uuid", "serial", "mac"]
value: str = Field(min_length=1, max_length=200)
@field_validator("value")
@classmethod
def meaningful(cls, value):
if value.lower() in {"unknown", "none", "not specified", "default string", "to be filled by o.e.m."}:
raise ValueError("Identität enthält einen Hersteller-Platzhalter.")
return value
def normalize_identity(kind, value):
value = value.strip().lower()
if kind == "mac":
value = value.replace("-", ":")
if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", value) or value in {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"}:
raise ValueError("Ungültige MAC-Adresse.")
if kind == "uuid":
parsed = uuid.UUID(value)
if parsed.int in {0, 2**128 - 1}:
raise ValueError("Ungültige System-UUID.")
value = str(parsed)
return value
class HostCreate(Model):
fqdn: str = Field(min_length=3, max_length=253)
site: str = Field(min_length=1, max_length=80)
management_ip: str | None = None
tags: list[str] = Field(default_factory=list, max_length=30)
identities: list[Identity] = Field(min_length=1, max_length=32)
installation_profile_id: str | None = None
postinstall_profile_id: str | None = None
iso_id: str | None = None
overrides: dict[str, Any] = Field(default_factory=dict)
blocked: bool = False
@field_validator("fqdn")
@classmethod
def hostname(cls, value):
value = value.lower().rstrip(".")
if "." not in value or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", part) for part in value.split(".")):
raise ValueError("Ein gültiger FQDN ist erforderlich.")
return value
@field_validator("management_ip")
@classmethod
def address(cls, value):
return str(ip_interface(value)) if value else None
class HostUpdate(Model):
expected_version: int = Field(ge=1)
fqdn: str | None = None
site: str | None = None
management_ip: str | None = None
tags: list[str] | None = None
identities: list[Identity] | None = None
installation_profile_id: str | None = None
postinstall_profile_id: str | None = None
iso_id: str | None = None
overrides: dict | None = None
blocked: bool | None = None
class StepSpec(Model):
id: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$")
module_id: str
parameters: dict = Field(default_factory=dict)
secret_refs: dict[str, str] = Field(default_factory=dict)
required: bool = True
class ProfileCreate(Model):
name: str = Field(min_length=1, max_length=120)
kind: Literal["installation", "postinstall"]
values: dict = Field(default_factory=dict)
steps: list[StepSpec] = Field(default_factory=list, max_length=50)
target_builds: list[str] = Field(default_factory=list, max_length=30)
locked_fields: list[str] = Field(default_factory=lambda: ["disk_setup", "global.root-password", "global.root-password-hashed"])
reboot_budget: int = Field(default=1, ge=0, le=5)
reason: str = Field(default="", max_length=1000)
class ModuleCreate(Model):
name: str = Field(min_length=1, max_length=120)
source: str = Field(min_length=10, max_length=262144)
parameters_schema: dict = Field(default_factory=lambda: {"type": "object", "additionalProperties": False})
dependencies: list[str] = Field(default_factory=list, max_length=20)
target_builds: list[str] = Field(default_factory=list, max_length=30)
timeout_seconds: int = Field(default=600, ge=1, le=7200)
retry_safe: bool = False
test_evidence: str = Field(default="", max_length=2000)
reason: str = Field(default="", max_length=1000)
class Publish(Model):
test_evidence: str = Field(min_length=5, max_length=2000)
reason: str = Field(min_length=3, max_length=1000)
class Approval(Model):
expected_version: int = Field(ge=1)
valid_minutes: int = Field(default=30, ge=1, le=1440)
confirmation: str
disks_confirmed: bool
reason: str = Field(min_length=5, max_length=1000)
class RunAction(Model):
expected_version: int = Field(ge=1)
reason: str = Field(min_length=5, max_length=1000)
class RunReconcile(RunAction):
confirmation: str
execution_stopped: bool
class GroupCreate(Model):
name: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
site: str = Field(min_length=1, max_length=80)
valid_hours: int = Field(default=720, ge=1, le=8760)
class IsoCreate(Model):
name: str = Field(min_length=1, max_length=120)
build: str = Field(pattern=r"^[0-9]+\.[0-9]+(?:\.[0-9]+)?-[0-9]+$")
sha256: str = Field(pattern=r"^[a-fA-F0-9]{64}$")
assistant_version: str = Field(min_length=1, max_length=80)
fingerprint: str = Field(pattern=r"^(?:[a-fA-F0-9]{64}|(?:[a-fA-F0-9]{2}:){31}[a-fA-F0-9]{2})$")
group_id: str
test_status: Literal["draft", "passed"] = "draft"
test_evidence: str = Field(default="", max_length=3000)
native_token_support: bool = False
class UserCreate(Model):
username: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}$")
password: str = Field(min_length=12, max_length=1024)
role: Literal["reader", "operator", "author", "admin", "developer"]
class SecretCreate(Model):
name: str = Field(min_length=1, max_length=120)
value: str = Field(min_length=1, max_length=16384)
class Enroll(Model):
run_id: str
enrollment_secret: str = Field(min_length=20, max_length=200)
public_key: str = Field(min_length=40, max_length=100)
identities: list[Identity] = Field(min_length=1, max_length=32)
boot_id: str = Field(min_length=1, max_length=80)
class Event(Model):
sequence: int = Field(ge=1)
boot_id: str = Field(min_length=1, max_length=80)
step_id: str | None = None
type: Literal["step.started", "step.succeeded", "step.failed", "run.needs_review", "run.reboot_pending", "run.resumed", "run.cancelled", "heartbeat"]
occurred_at: str = Field(max_length=80)
exit_code: int | None = None
verification: dict = Field(default_factory=dict)
class EventBatch(Model):
events: list[Event] = Field(min_length=1, max_length=100)
class LogChunk(Model):
sequence: int = Field(ge=1)
step_id: str | None = None
text: str = Field(max_length=16384)
class LogBatch(Model):
chunks: list[LogChunk] = Field(min_length=1, max_length=32)
class Completion(Model):
verification: dict
class LeaseRequest(Model):
run_id: str
+668
View File
@@ -0,0 +1,668 @@
#!/usr/bin/env python3
"""Short-lived Linux provisioning runner. Target dependencies: Python 3, Bash, OpenSSL.
Module contract: check=0 means converged, check=1 means apply is needed; all other
check exits fail. Every successful apply is followed by verify. Exit 194 from
apply requests a checkpointed reboot. Module scripts must not reboot themselves.
"""
from __future__ import annotations
import argparse
import base64
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import random
import re
import selectors
import signal
import ssl
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
MAX_ARTIFACT = 2 * 1024 * 1024
MAX_RESPONSE = 4 * 1024 * 1024
MAX_LOG_BYTES = 1024 * 1024
MAX_PHASE_OUTPUT = 128 * 1024
MAX_EVENTS = 4096
DIGEST = re.compile(r"[a-f0-9]{64}\Z")
STEP_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
def canonical_json(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
def atomic_write(path, content, mode=0o600):
path = Path(path)
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
try:
os.fchmod(descriptor, mode) if hasattr(os, "fchmod") else None
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
if os.name == "posix":
parent_fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(parent_fd)
finally:
os.close(parent_fd)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def verified_digest(content, expected):
if not isinstance(expected, str) or not DIGEST.fullmatch(expected):
raise Halt("Invalid artifact digest")
if hashlib.sha256(content).hexdigest() != expected:
raise Halt("Artifact digest mismatch; execution refused")
return content
def discover_identities(expected, sys_root=Path("/sys")):
"""Check bootstrap host binding against identities actually observed on target."""
def normalize(kind, value):
value = value.strip().lower()
if kind == "uuid":
parsed = uuid.UUID(value)
if parsed.int in (0, 2 ** 128 - 1):
raise ValueError("Empty UUID")
return str(parsed)
if kind == "mac":
value = value.replace("-", ":")
if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", value) or value in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"):
raise ValueError("Invalid MAC")
if not value or value in ("unknown", "none", "not specified", "default string", "to be filled by o.e.m."):
raise ValueError("Placeholder identity")
return value
observed = set()
candidates = [("uuid", sys_root / "class/dmi/id/product_uuid"),
("serial", sys_root / "class/dmi/id/product_serial")]
candidates.extend(("mac", path) for path in (sys_root / "class/net").glob("*/address"))
for kind, path in candidates:
try:
observed.add((kind, normalize(kind, path.read_text())))
except (OSError, ValueError):
continue
try:
required = {(item["kind"], normalize(item["kind"], item["value"])) for item in expected}
except (KeyError, ValueError) as exc:
raise Halt("Invalid expected host identity") from exc
if not required or not required.issubset(observed):
raise Halt("Observed target identities do not match the bootstrap host binding")
return [{"kind": kind, "value": value} for kind, value in sorted(required)]
class TransportError(Exception):
pass
class Rejected(Exception):
pass
class Halt(Exception):
pass
class Deferred(Exception):
pass
class RebootRequested(Exception):
pass
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise Rejected("API redirects are not permitted")
class DeviceKey:
def __init__(self, directory):
self.directory = Path(directory)
self.path = self.directory / "device-key.pem"
def ensure(self):
if not self.path.exists():
result = subprocess.run(["openssl", "genpkey", "-algorithm", "ED25519"],
capture_output=True, check=True, timeout=15)
atomic_write(self.path, result.stdout)
os.chmod(self.path, 0o600)
@property
def public_key(self):
result = subprocess.run(["openssl", "pkey", "-in", str(self.path), "-pubout",
"-outform", "DER"], capture_output=True, check=True, timeout=15)
# RFC 8410 Ed25519 SubjectPublicKeyInfo: fixed 12-byte prefix + raw key.
if not result.stdout.startswith(bytes.fromhex("302a300506032b6570032100")) or len(result.stdout) != 44:
raise Halt("Device key is not Ed25519")
return base64.b64encode(result.stdout[12:]).decode()
def sign(self, content):
fd, path = tempfile.mkstemp(prefix=".signature-", dir=self.directory)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(content)
result = subprocess.run(["openssl", "pkeyutl", "-sign", "-rawin", "-inkey",
str(self.path), "-in", path], capture_output=True,
check=True, timeout=15)
return base64.b64encode(result.stdout).decode()
finally:
os.unlink(path)
class API:
def __init__(self, config, key):
self.config, self.key = config, key
self.base = config["api_url"].rstrip("/")
parsed = urllib.parse.urlsplit(self.base)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise Halt("api_url must be an HTTPS origin with normal certificate validation")
context = ssl.create_default_context(cafile=config.get("ca_file"))
self.opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context), NoRedirect())
def request(self, method, path, payload=None, *, signed=True, raw=False, attempts=5):
body = b"" if payload is None else canonical_json(payload)
if len(body) > MAX_RESPONSE:
raise Halt("Outgoing request exceeds size limit")
url = self.base + path
for attempt in range(attempts):
headers = {"Accept": "application/octet-stream" if raw else "application/json"}
if payload is not None:
headers["Content-Type"] = "application/json"
if signed:
timestamp, nonce = str(int(time.time())), os.urandom(24).hex()
request_path = urllib.parse.urlsplit(url).path
message = f"{method}\n{request_path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode()
headers.update({"X-Run-ID": self.config["run_id"], "X-Device-Key": self.key.public_key,
"X-Timestamp": timestamp, "X-Nonce": nonce,
"X-Signature": self.key.sign(message)})
request = urllib.request.Request(url, data=body if payload is not None else None,
method=method, headers=headers)
try:
with self.opener.open(request, timeout=10) as response:
result = response.read((MAX_ARTIFACT if raw else MAX_RESPONSE) + 1)
if len(result) > (MAX_ARTIFACT if raw else MAX_RESPONSE):
raise Halt("Response exceeds size limit")
if raw:
return result
try:
return json.loads(result)
except (ValueError, UnicodeError) as exc:
raise Halt("Invalid API response") from exc
except urllib.error.HTTPError as exc:
if exc.code not in (408, 425, 429, 500, 502, 503, 504):
raise Rejected(f"API rejected {method} {path}: HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError, OSError):
pass
if attempt + 1 < attempts:
time.sleep(min(20, 2 ** attempt) + random.random())
raise TransportError("API unavailable after bounded retries")
class Runner:
def __init__(self, config, directory="/var/lib/pve-provisioner", api=None):
self.config = config
self.directory = Path(directory)
self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
self.state_path = self.directory / "state.json"
self.key = DeviceKey(self.directory)
self.api = api
self.boot_id = Path("/proc/sys/kernel/random/boot_id").read_text().strip() if os.name == "posix" else "test-boot"
self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else {
"format": 1, "run_id": config["run_id"], "status": "pending", "steps": {},
"events": [], "logs": [], "event_sequence": 0, "log_sequence": 0,
"reboot_count": 0, "boot_id": self.boot_id,
}
if self.state["run_id"] != config["run_id"]:
raise Halt("Existing local state belongs to another run")
self.lease = {"action": "wait", "expires_at": 0, "run_version": 0}
self.last_heartbeat = 0
self.secret_values = []
self.stop_requested = False
self.step_deadline = None
self.save()
def save(self):
atomic_write(self.state_path, canonical_json(self.state))
def event(self, event_type, step_id=None, **fields):
if len(self.state["events"]) >= MAX_EVENTS:
# Reserve a durable halt locally; never evict unacknowledged events.
self.state.update(status="needs_review", reason="Event queue limit reached")
self.save()
raise Halt("Event queue limit reached")
self.state["event_sequence"] += 1
self.state["events"].append({"sequence": self.state["event_sequence"], "boot_id": self.boot_id,
"step_id": step_id, "type": event_type,
"occurred_at": datetime.now(timezone.utc).isoformat(), **fields})
self.save()
def log(self, step_id, content):
for value in sorted(self.secret_values, key=len, reverse=True):
if value:
content = content.replace(value, "[REDACTED]")
content = content[:MAX_PHASE_OUTPUT]
remaining = MAX_LOG_BYTES - sum(len(chunk["text"].encode()) for chunk in self.state["logs"])
encoded = content.encode()
if len(encoded) > remaining:
content = encoded[:max(0, remaining)].decode(errors="ignore")
self.state["logs_truncated"] = True
# Drop newly arriving overflow, never evict a sequenced/unacknowledged
# chunk: server acknowledgements require a contiguous sequence.
for position in range(0, len(content), 16000):
self.state["log_sequence"] += 1
self.state["logs"].append({"sequence": self.state["log_sequence"], "step_id": step_id,
"text": content[position:position + 16000]})
self.save()
def flush(self):
base = f"/agent/v1/runs/{self.config['run_id']}"
for queue, endpoint, key in (("logs", "logs", "chunks"), ("events", "events", "events")):
while self.state[queue]:
batch = self.state[queue][:100 if queue == "events" else 8]
response = self.api.request("POST", f"{base}/{endpoint}", {key: batch}, attempts=1)
ack = response.get("ack_sequence")
if not isinstance(ack, int) or ack < batch[0]["sequence"] or ack > self.state["event_sequence" if queue == "events" else "log_sequence"]:
raise Halt("Invalid event/log acknowledgement")
self.state[queue] = [event for event in self.state[queue] if event["sequence"] > ack]
self.save()
def network_failure(self):
self.state.setdefault("network_failed_since", time.time())
self.save()
if time.time() - self.state["network_failed_since"] > int(self.config.get("network_deadline_seconds", 1800)):
raise Halt("Network recovery deadline exceeded")
def renew_lease(self):
self.lease = self.api.request("POST", "/agent/v1/lease", {"run_id": self.config["run_id"]}, attempts=1)
if self.lease.get("action") not in ("run", "wait", "stop", "revoked"):
raise Halt("Invalid lease action")
self.state["run_version"] = self.lease.get("run_version", 0)
self.state.pop("network_failed_since", None)
self.save()
def authorize(self):
if self.stop_requested:
raise Deferred("Runner service is stopping")
self.renew_lease()
if self.lease["action"] == "stop":
self.cancel()
raise Deferred("Run cancelled")
if self.lease["action"] == "revoked":
raise Halt("Execution permission revoked or stopped")
if self.lease["action"] != "run" or self.lease.get("expires_at", 0) <= time.time():
raise Deferred("Waiting for execution permission")
def heartbeat(self, step_id):
if time.monotonic() - self.last_heartbeat < 30:
return
self.last_heartbeat = time.monotonic()
try:
self.renew_lease()
self.event("heartbeat", step_id)
self.flush()
except TransportError:
self.state.setdefault("network_failed_since", time.time())
self.save()
except Rejected:
# Finish the running safe phase; authorize() prevents another apply.
self.lease = {"action": "revoked", "expires_at": 0}
def manifest(self):
path = self.directory / "manifest.json"
if path.exists():
manifest = json.loads(path.read_text())
else:
manifest = self.api.request("GET", f"/agent/v1/runs/{self.config['run_id']}/manifest")
claimed = manifest.get("digest")
unsigned = {key: value for key, value in manifest.items() if key != "digest"}
digest = hashlib.sha256(canonical_json(unsigned)).hexdigest()
expected = self.config.get("manifest_digest") or self.state.get("manifest_digest") or claimed
if not expected or digest != expected or (claimed and claimed != digest):
raise Halt("Manifest digest mismatch")
if manifest.get("run_id") != self.config["run_id"]:
raise Halt("Manifest belongs to another run")
steps = manifest.get("steps")
if not isinstance(steps, list) or not 1 <= len(steps) <= 100:
raise Halt("Manifest must contain 1 to 100 steps")
seen = set()
for step in steps:
if not STEP_ID.fullmatch(step.get("id", "")) or step["id"] in seen:
raise Halt("Invalid or duplicate step identifier")
if not DIGEST.fullmatch(step.get("digest", "")) or not isinstance(step.get("parameters", {}), dict):
raise Halt("Invalid module digest or parameters")
if not isinstance(step.get("timeout_seconds", 600), int) or not 1 <= step.get("timeout_seconds", 600) <= 14400:
raise Halt("Invalid module timeout")
if not set(step.get("dependencies", [])).issubset(seen):
raise Halt("Module dependencies must precede their dependants")
seen.add(step["id"])
self.state["manifest_digest"] = digest
atomic_write(path, canonical_json(manifest))
self.save()
return manifest
def artifact(self, step):
digest = step["digest"]
if not DIGEST.fullmatch(digest):
raise Halt("Invalid artifact digest")
path = self.directory / "artifacts" / digest
content = path.read_bytes() if path.exists() else self.api.request("GET", f"/agent/v1/artifacts/{digest}", raw=True)
if len(content) > MAX_ARTIFACT:
raise Halt("Artifact exceeds size limit")
verified_digest(content, digest)
if not path.exists():
atomic_write(path, content)
return path
def execute(self, step, phase, artifact, parameters):
# Verify again immediately before *every* execution, including check/verify.
verified_digest(Path(artifact).read_bytes(), step["digest"])
deadline = self.step_deadline or (time.monotonic() + step.get("timeout_seconds", 600))
if time.monotonic() >= deadline:
self.log(step["id"], f"[{phase}] Step timeout reached before phase start")
return 124
process = subprocess.Popen(["/bin/bash", str(artifact), phase, str(parameters)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
start_new_session=True, cwd=self.directory,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8",
"DEBIAN_FRONTEND": "noninteractive", "PVE_RUN_ID": self.config["run_id"]})
output = bytearray()
capture_limit = MAX_PHASE_OUTPUT + max((len(value.encode()) for value in self.secret_values), default=0)
timed_out = False
poller = selectors.DefaultSelector()
poller.register(process.stdout, selectors.EVENT_READ)
try:
while process.poll() is None or poller.get_map():
if time.monotonic() >= deadline:
timed_out = True
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
for key, _ in poller.select(timeout=0.25):
chunk = os.read(key.fileobj.fileno(), 8192)
if not chunk:
poller.unregister(key.fileobj)
elif len(output) < capture_limit:
output.extend(chunk[:capture_limit - len(output)])
# Continued lease renewal never interrupts a package operation.
self.heartbeat(step["id"])
process.wait(timeout=5)
except BaseException:
if process.poll() is None:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
raise
finally:
poller.close()
process.stdout.close()
text = output.decode("utf-8", errors="replace")
if len(output) >= MAX_PHASE_OUTPUT:
text += "\n[output truncated]"
self.log(step["id"], f"[{phase}]\n{text}")
return 124 if timed_out else process.returncode
def run_step(self, step, reboot_budget):
step_id = step["id"]
checkpoint = self.state["steps"].get(step_id, {})
if checkpoint.get("status") == "succeeded":
return
if checkpoint.get("status") == "failed":
if step.get("required", True):
raise Halt(f"Step {step_id} previously failed; explicit review is required")
return
self.authorize()
self.flush()
artifact = self.artifact(step)
secrets = self.api.request("GET", f"/agent/v1/runs/{self.config['run_id']}/secrets/{step_id}")
if not isinstance(secrets, dict):
raise Halt("Invalid step secret response")
def secret_strings(value):
if isinstance(value, dict):
return [item for child in value.values() for item in secret_strings(child)]
if isinstance(value, list):
return [item for child in value for item in secret_strings(child)]
return [str(value)] if value is not None else []
self.secret_values = secret_strings(secrets)
if any(len(value.encode()) > 16384 for value in self.secret_values):
raise Halt("Step secret exceeds the supported redaction limit")
parameters = self.directory / "step-parameters.json"
atomic_write(parameters, canonical_json({**step.get("parameters", {}), "secrets": secrets}))
self.step_deadline = time.monotonic() + step.get("timeout_seconds", 600)
try:
self.event("step.started", step_id)
self.flush() # Server knows the attempt before any module phase.
check = self.execute(step, "check", artifact, parameters)
recovering = checkpoint.get("status") in ("applying", "reboot_pending")
if recovering:
verified = self.execute(step, "verify", artifact, parameters)
if check == 0 and verified == 0:
self.succeed(step_id, recovered=True)
return
if not step.get("retry_safe", False):
raise Halt(f"Interrupted step {step_id} cannot be repeated safely")
if check == 0 and verified != 0:
check = 1
if check not in (0, 1):
self.fail(step, check, "check failed")
return
if check == 0:
verified = self.execute(step, "verify", artifact, parameters)
if verified == 0:
self.succeed(step_id, unchanged=True)
return
# A converged check with a failing verify is an inconsistent module.
self.fail(step, verified, "verification failed")
return
self.authorize()
self.state["steps"][step_id] = {"status": "applying", "attempt": checkpoint.get("attempt", 0) + 1}
self.state["status"] = "running"
self.save() # Durable checkpoint before any mutation.
code = self.execute(step, "apply", artifact, parameters)
if code == 194:
if self.state["reboot_count"] >= reboot_budget:
raise Halt("Reboot budget exhausted")
self.state["reboot_count"] += 1
self.state["status"] = "reboot_pending"
self.state["reboot_boot_id"] = self.boot_id
self.state["steps"][step_id]["status"] = "reboot_pending"
self.save()
self.event("run.reboot_pending", step_id)
try:
self.flush()
except (TransportError, Rejected):
pass
self.authorize()
raise RebootRequested()
if code != 0:
self.fail(step, code, "apply failed")
return
verified = self.execute(step, "verify", artifact, parameters)
if verified != 0:
self.fail(step, verified, "verification failed")
return
self.succeed(step_id)
finally:
parameters.unlink(missing_ok=True)
self.secret_values = []
self.step_deadline = None
def succeed(self, step_id, **verification):
self.state["steps"].setdefault(step_id, {})["status"] = "succeeded"
self.state["steps"][step_id]["verification"] = {"passed": True, **verification}
self.event("step.succeeded", step_id, exit_code=0, verification={"passed": True, **verification})
def fail(self, step, code, reason):
self.state["steps"].setdefault(step["id"], {}).update(status="failed", exit_code=code)
self.event("step.failed", step["id"], exit_code=code, verification={"passed": False, "reason": reason})
if step.get("required", True):
raise Halt(f"Required step {step['id']}: {reason} (exit {code})")
def halt(self, reason):
self.state.update(status="needs_review", reason=reason, halted_version=self.state.get("run_version", 0))
self.state.setdefault("review_started_at", time.time())
self.save()
if len(self.state["events"]) < MAX_EVENTS:
self.event("run.needs_review", verification={"reason": reason})
if self.api is None:
return
try:
self.flush()
except (TransportError, Rejected, Halt):
pass
def cancel(self):
self.state["status"] = "cancellation_pending"
self.event("run.cancelled", verification={"reason": "Operator cancelled at a safe transition"})
self.flush()
self.state["status"] = "cancelled"
self.save()
def review(self):
if time.time() - self.state.get("review_started_at", time.time()) >= self.config.get("review_deadline_seconds", 86400):
return False
self.flush()
self.renew_lease()
if self.lease["action"] == "stop":
self.cancel()
return False
if self.lease["action"] == "run" and self.lease.get("run_version", 0) > self.state.get("halted_version", 0):
# Only an explicit server-side resume permits recovery. Failed steps
# become interrupted steps and still pass check/verify + retry_safe.
for checkpoint in self.state["steps"].values():
if checkpoint.get("status") == "failed":
checkpoint["status"] = "applying"
self.state.update(status="running")
self.state.pop("review_started_at", None)
self.state.pop("reason", None)
self.event("run.resumed")
return True
raise Deferred("Awaiting explicit operator resume")
def finish(self):
self.flush()
verification = {key: value.get("verification", {"passed": False}) for key, value in self.state["steps"].items()}
response = self.api.request("POST", f"/agent/v1/runs/{self.config['run_id']}/complete", {"verification": verification})
if response.get("status") != "succeeded":
raise Halt("Completion was not acknowledged")
self.state["status"] = "succeeded"
self.save()
def run(self):
try:
if self.state["status"] in ("succeeded", "cancelled"):
return 0
if self.api is None:
self.key.ensure()
self.api = API(self.config, self.key)
if self.state["status"] == "cancellation_pending":
self.flush()
self.state["status"] = "cancelled"
self.save()
return 0
if self.state["status"] == "needs_review":
if not self.review():
return 0
if self.state["status"] == "completion_pending":
self.finish()
return 0
if not self.state.get("enrolled"):
self.api.request("POST", "/agent/v1/enroll", {
"run_id": self.config["run_id"], "enrollment_secret": self.config["enrollment_secret"],
"public_key": self.key.public_key, "identities": discover_identities(self.config["identities"]),
"boot_id": self.boot_id}, signed=False)
self.state["enrolled"] = True
self.save()
if self.state["status"] == "reboot_pending":
if self.state.get("reboot_boot_id") == self.boot_id:
self.authorize()
raise RebootRequested()
self.state.update(status="running", boot_id=self.boot_id)
self.save()
self.event("run.resumed")
self.flush()
self.authorize()
manifest = self.manifest()
self.flush()
for step in manifest["steps"]:
for dependency in step.get("dependencies", []):
if self.state["steps"].get(dependency, {}).get("status") != "succeeded":
raise Halt(f"Dependency {dependency} has not succeeded")
self.run_step(step, int(manifest.get("reboot_budget", 1)))
try:
self.flush()
except TransportError:
self.network_failure()
self.state["status"] = "completion_pending"
self.save()
self.finish()
return 0
except RebootRequested:
return 194
except (Halt, Rejected) as exc:
self.halt(str(exc))
return 75
except Deferred:
return 0 if self.state["status"] == "cancelled" else 75
except TransportError:
try:
self.network_failure()
except Halt as exc:
self.halt(str(exc))
return 75
return 75
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", default="/etc/pve-provisioner/config.json")
parser.add_argument("--state-dir", default="/var/lib/pve-provisioner")
args = parser.parse_args()
if os.name != "posix" or os.geteuid() != 0:
parser.error("The runner requires a Linux target and root privileges")
import fcntl
os.umask(0o077)
directory = Path(args.state_dir)
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
with (directory / "runner.lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return 0
# Secrets from a power failure are removed before any recovery operation.
(directory / "step-parameters.json").unlink(missing_ok=True)
config = json.loads(Path(args.config).read_text())
runner = Runner(config, directory)
def request_stop(signum, frame):
runner.stop_requested = True
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
result = runner.run()
if runner.state.get("enrolled") and "enrollment_secret" in config:
config.pop("enrollment_secret")
atomic_write(args.config, canonical_json(config))
if result == 194:
subprocess.run(["systemctl", "reboot"], check=True, timeout=15)
return 0
if runner.state["status"] in ("succeeded", "cancelled"):
subprocess.run(["systemctl", "disable", "pve-provisioner.service"], check=True, timeout=15)
return result
if __name__ == "__main__":
raise SystemExit(main())
+86
View File
@@ -0,0 +1,86 @@
import base64
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import secrets
from cryptography.fernet import Fernet
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def digest(value):
if not isinstance(value, bytes):
value = value.encode()
return hashlib.sha256(value).hexdigest()
def token():
return secrets.token_urlsafe(32)
class Security:
def __init__(self, settings):
path = settings.master_key_file
if not path.exists():
if not settings.testing:
raise RuntimeError("Master-Key fehlt. Zuerst 'provisioner init' ausführen.")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(Fernet.generate_key())
path.chmod(0o600)
self.fernet = Fernet(path.read_bytes().strip())
@staticmethod
def hash_password(password):
if len(password) < 12 or len(password) > 1024:
raise ValueError("Passwörter benötigen 12 bis 1024 Zeichen.")
salt = os.urandom(16)
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(result).decode()
@staticmethod
def verify_password(password, stored):
try:
algorithm, salt, expected = stored.split("$")
if algorithm != "scrypt" or len(password) > 1024:
return False
result = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt), n=16384, r=8, p=1)
return hmac.compare_digest(result, base64.b64decode(expected))
except (ValueError, TypeError):
return False
def encrypt(self, value):
return self.fernet.encrypt(value.encode()).decode()
def decrypt(self, value):
return self.fernet.decrypt(value.encode()).decode()
def redact(text, values=()):
text = str(text)
for value in sorted({str(v) for v in values if v}, key=len, reverse=True):
text = text.replace(value, "[REDACTED]")
text = re.sub(r"(?i)(bearer\s+)[^\s\"']+", r"\1[REDACTED]", text)
text = re.sub(r"(/(?:bootstrap/v1|installer/v1/report)/)[A-Za-z0-9_-]+", r"\1[REDACTED]", text)
text = re.sub(r"(?i)((?:password|secret|token|authorization)\s*[=:]\s*)[^\s,;]+", r"\1[REDACTED]", text)
return text
def atomic_artifact(directory: Path, source: str):
content = source.encode("utf-8")
checksum = digest(content)
directory.mkdir(parents=True, exist_ok=True)
target = directory / checksum
if not target.exists():
temporary = directory / (".tmp-" + token())
with temporary.open("xb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
return checksum
+433
View File
@@ -0,0 +1,433 @@
"""Transactional domain rules shared by browser and machine APIs."""
from copy import deepcopy
from datetime import datetime, timezone
from fnmatch import fnmatchcase
from ipaddress import ip_address, ip_interface
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
import uuid
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException
import jsonschema
import tomli_w
from .models import HostCreate, normalize_identity
from .security import atomic_artifact, canonical, digest, redact, token
TERMINAL = {"succeeded", "failed", "cancelled", "expired"}
def now_iso():
return datetime.now(timezone.utc).isoformat()
def new_id(prefix):
return prefix + "-" + uuid.uuid4().hex
def require(condition, status, detail):
if not condition:
raise HTTPException(status, detail)
def unpack(row):
if row is None:
raise HTTPException(404, "Objekt nicht gefunden.")
result = dict(row)
data = json.loads(result.pop("data", "{}"))
return {**data, **result}
def audit(connection, actor, action, object_id, reason="", data=None):
connection.execute("INSERT INTO audit VALUES(?,?,?,?,?,?,?)", (new_id("audit"), actor, action, object_id, reason, canonical(data or {}), now_iso()))
def get_host(connection, host_id):
host = unpack(connection.execute("SELECT * FROM hosts WHERE id=?", (host_id,)).fetchone())
host["identities"] = [dict(r) for r in connection.execute("SELECT kind,value FROM host_identities WHERE host_id=? ORDER BY kind,value", (host_id,))]
host["blocked"] = bool(host["blocked"])
stored = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0])
host["management_ip"] = stored.get("management_ip")
latest = connection.execute("SELECT last_seen FROM runs WHERE host_id=? ORDER BY created_at DESC LIMIT 1",(host_id,)).fetchone()
host["last_seen"] = latest[0] if latest else None
for field,table,label in (("installation_profile_id","profiles","installation_profile_name"),("postinstall_profile_id","profiles","postinstall_profile_name"),("iso_id","iso_records","iso_name")):
name = connection.execute(f"SELECT name FROM {table} WHERE id=?",(host.get(field),)).fetchone()
host[label] = name[0] if name else None
return host
def public_run(row):
result = unpack(row)
for field in ("answer_ciphertext", "bootstrap_ciphertext", "secrets_ciphertext", "bootstrap_hash", "enrollment_hash", "report_hash", "device_key"):
result.pop(field, None)
return result
def deep_merge(base, patch, provenance=None, source="", prefix=""):
for key, value in patch.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
if not isinstance(base.get(key), dict):
base[key] = {}
deep_merge(base[key], value, provenance, source, path)
else:
base[key] = deepcopy(value)
if provenance is not None:
provenance[path] = source
return base
def leaf_paths(value, prefix=""):
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(item, dict):
yield from leaf_paths(item, path)
else:
yield path
class Service:
def __init__(self, settings, db, security):
self.settings, self.db, self.security = settings, db, security
self.artifact_dir = settings.data_dir / "artifacts"
def create_host(self, connection, payload, actor):
host_id = new_id("host")
data = payload.model_dump()
normalized = [(item.kind, normalize_identity(item.kind, item.value)) for item in payload.identities]
require(len(set(normalized)) == len(normalized), 422, "Identitäten sind doppelt angegeben.")
address = str(ip_interface(payload.management_ip).ip) if payload.management_ip else None
connection.execute("INSERT INTO hosts(id,fqdn,management_ip,site,status,data,blocked,created_at) VALUES(?,?,?,?,?,?,?,?)", (host_id, payload.fqdn, address, payload.site, "ready", canonical(data), payload.blocked, now_iso()))
connection.executemany("INSERT INTO host_identities VALUES(?,?,?)", [(host_id, *item) for item in normalized])
audit(connection, actor, "host.created", host_id)
return get_host(connection, host_id)
def update_host(self, connection, host_id, patch, actor):
previous = get_host(connection, host_id)
require(previous["version"] == patch.expected_version, 409, "Host wurde zwischenzeitlich geändert. Ansicht neu laden.")
values = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0])
values.update(patch.model_dump(exclude_unset=True, exclude={"expected_version"}))
payload = HostCreate.model_validate(values)
identities = [(i.kind, normalize_identity(i.kind, i.value)) for i in payload.identities]
require(len(set(identities)) == len(identities), 422, "Identitäten sind doppelt angegeben.")
active = connection.execute("SELECT id FROM runs WHERE host_id=? AND status NOT IN ('succeeded','failed','cancelled','expired')", (host_id,)).fetchone()
changed_keys = set(patch.model_fields_set) - {"expected_version", "blocked", "tags"}
require(not active or not changed_keys, 409, "Während eines aktiven Laufs sind nur Sperre und Tags änderbar.")
address = str(ip_interface(payload.management_ip).ip) if payload.management_ip else None
connection.execute("UPDATE hosts SET fqdn=?,management_ip=?,site=?,blocked=?,data=?,version=version+1 WHERE id=?", (payload.fqdn,address,payload.site,payload.blocked,canonical(payload.model_dump()),host_id))
connection.execute("DELETE FROM host_identities WHERE host_id=?", (host_id,))
connection.executemany("INSERT INTO host_identities VALUES(?,?,?)", [(host_id, *item) for item in identities])
audit(connection, actor, "host.updated", host_id, data={"fields": sorted(patch.model_fields_set)})
return get_host(connection, host_id)
def create_profile(self, connection, payload, actor):
data = payload.model_dump()
require(payload.kind == "postinstall" or not payload.steps, 422, "Installationsprofile enthalten keine Skriptschritte.")
self.reject_inline_secrets(data["values"])
for step in data["steps"]:
self.reject_inline_secrets(step["parameters"])
version = connection.execute("SELECT COALESCE(MAX(version),0)+1 FROM profiles WHERE name=? AND kind=?", (payload.name,payload.kind)).fetchone()[0]
profile_id = new_id("profile")
data["digest"] = digest(canonical(data))
connection.execute("INSERT INTO profiles VALUES(?,?,?,?,?,?,?,?)", (profile_id,payload.name,payload.kind,version,"draft",canonical(data),actor,now_iso()))
audit(connection, actor, "profile.created", profile_id, payload.reason)
return unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (profile_id,)).fetchone())
@staticmethod
def reject_inline_secrets(value):
for path in leaf_paths(value):
name = path.rsplit(".", 1)[-1].lower().replace("-", "_")
require(name not in {"password", "root_password", "root_password_hashed", "secret", "token", "private_key"}, 422, "Geheimnisse müssen über Secret-Referenzen eingebunden werden.")
def resolve(self, connection, host_id):
host = get_host(connection, host_id)
require(not host["blocked"], 403, "Host ist gesperrt.")
install = unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (host.get("installation_profile_id"),)).fetchone())
post = unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (host.get("postinstall_profile_id"),)).fetchone())
require(install["kind"] == "installation" and post["kind"] == "postinstall", 422, "Profiltypen passen nicht zur Zuordnung.")
require(install["status"] == post["status"] == "published", 403, "Beide Profile müssen veröffentlicht sein.")
iso = unpack(connection.execute("SELECT * FROM iso_records WHERE id=?", (host.get("iso_id"),)).fetchone())
require(iso["test_status"] == "passed" and iso["native_token_support"] and len(iso["test_evidence"]) >= 5, 403, "ISO benötigt Testnachweis und native Token-Unterstützung.")
group = connection.execute("SELECT * FROM groups WHERE id=?", (iso["group_id"],)).fetchone()
require(group and not group["revoked"] and group["expires_at"] > time.time() and group["site"] == host["site"], 403, "ISO-Gruppe ist ungültig oder gehört zu einem anderen Standort.")
require(iso["build"] in install["target_builds"] and iso["build"] in post["target_builds"], 422, "Zielbuild ist nicht in beiden Profilen freigegeben.")
resolved, provenance = {}, {}
deep_merge(resolved, self.settings.defaults, provenance, "Globale Vorgaben")
deep_merge(resolved, self.settings.sites.get(host["site"], {}), provenance, f"Standort {host['site']}")
deep_merge(resolved, install["values"], provenance, f"Profil {install['name']} v{install['version']}")
overrides = host.get("overrides", {})
paths = list(leaf_paths(overrides))
for locked in install.get("locked_fields", []):
require(not any(path == locked or path.startswith(locked + ".") for path in paths), 422, f"Host darf gesperrtes Feld {locked} nicht überschreiben.")
require(set(overrides) <= {"global", "network", "root_secret_id", "disk_setup"}, 422, "Unzulässige Hostparameter.")
deep_merge(resolved, overrides, provenance, "Host")
stored_host = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0])
deep_merge(resolved, {"global": {"fqdn": host["fqdn"]}, "network": {"cidr": stored_host.get("management_ip")}}, provenance, "Host")
self.validate_installation(resolved)
secret_row = connection.execute("SELECT * FROM secrets WHERE id=?", (resolved["root_secret_id"],)).fetchone()
require(secret_row is not None, 422, "Root-Passwort-Hash als Secret fehlt.")
root_hash = self.security.decrypt(secret_row["ciphertext"])
require(root_hash.startswith(("$6$", "$5$", "$y$")) and len(root_hash) > 30, 422, "Root-Secret muss ein unterstützter crypt-Passwort-Hash sein.")
steps, seen, seen_names = [], set(), {}
secrets_snapshot = {"root": root_hash, "steps": {}}
for step in post["steps"]:
require(step["id"] not in seen, 422, "Schritt-IDs müssen eindeutig sein.")
module = unpack(connection.execute("SELECT * FROM modules WHERE id=?", (step["module_id"],)).fetchone())
require(module["status"] == "published" and iso["build"] in module["target_builds"], 403, f"Modul {module['name']} ist für den Build nicht freigegeben.")
require(set(module["dependencies"]) <= set(seen_names), 422, f"Abhängigkeiten von {module['name']} sind nicht vorher eingeplant.")
try:
jsonschema.Draft202012Validator(module["parameters_schema"]).validate(step["parameters"])
except jsonschema.ValidationError:
raise HTTPException(422, f"Parameter von {module['name']} passen nicht zum Schema.")
checksum = module["digest"]
path = self.artifact_dir / checksum
require(path.is_file() and digest(path.read_bytes()) == checksum, 422, "Modulartefakt fehlt oder ist beschädigt.")
secrets_snapshot["steps"][step["id"]] = {}
for name, secret_id in step.get("secret_refs", {}).items():
require(bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", name)), 422, "Ungültiger Secret-Parametername.")
secret = connection.execute("SELECT ciphertext FROM secrets WHERE id=?", (secret_id,)).fetchone()
require(secret is not None, 422, "Ein Schritt-Secret fehlt.")
secrets_snapshot["steps"][step["id"]][name] = self.security.decrypt(secret[0])
steps.append({**step, "name": module["name"], "module_version": module["version"], "digest": checksum, "timeout_seconds": module["timeout_seconds"], "retry_safe": module["retry_safe"], "dependencies": [seen_names[name] for name in module["dependencies"]]})
seen.add(step["id"])
seen_names[module["name"]] = step["id"]
require(steps and any(step["required"] for step in steps), 422, "Mindestens eine verpflichtende Abschlussprüfung ist erforderlich.")
snapshot = {"resolved": resolved, "provenance": provenance, "profiles": [{"id": p["id"], "name": p["name"], "version": p["version"], "digest": p["digest"]} for p in (install,post)], "steps": steps, "disks": resolved["disk_setup"], "iso": iso, "identities": host["identities"], "reboot_budget": post.get("reboot_budget",1), "warnings": ["Die Hardwarekennung dient der Zuordnung im kontrollierten Provisionierungsnetz."]}
snapshot["digest"] = digest(canonical(snapshot))
return snapshot, secrets_snapshot
@staticmethod
def validate_installation(values):
require(isinstance(values,dict),422,"Installationsparameter müssen ein Objekt sein.")
require(set(values) <= {"global", "network", "disk_setup", "root_secret_id"}, 422, "Unbekannte Installationsparameter.")
glob = values.get("global", {})
require(isinstance(glob,dict) and all(isinstance(glob.get(k),str) for k in ("keyboard","country","timezone","mailto","fqdn")),422,"Globale Pflichtfelder müssen Zeichenketten sein.")
require(set(glob) <= {"keyboard", "country", "timezone", "mailto", "fqdn", "root-ssh-keys", "reboot-on-error"}, 422, "Nicht freigegebene globale Antwortoption.")
require(all(glob.get(k) for k in ("keyboard", "country", "timezone", "mailto", "fqdn")), 422, "Globale Pflichtfelder fehlen.")
require(bool(re.fullmatch(r"[a-z]{2}", glob["country"])) and "@" in glob["mailto"], 422, "Land oder E-Mail ungültig.")
require(glob["keyboard"] in {"de","de-ch","dk","en-gb","en-us","es","fi","fr","fr-be","fr-ca","fr-ch","hu","is","it","jp","lt","mk","nl","no","pl","pt","pt-br","se","si","tr"},422,"Tastaturlayout wird vom Installer nicht unterstützt.")
require(isinstance(glob.get("reboot-on-error",False),bool),422,"reboot-on-error muss ein Wahrheitswert sein.")
require(isinstance(glob.get("root-ssh-keys",[]),list) and all(isinstance(k,str) and k.startswith(("ssh-ed25519 ","ssh-rsa ","ecdsa-sha2-")) for k in glob.get("root-ssh-keys",[])),422,"Root-SSH-Schlüssel müssen als Liste öffentlicher Schlüssel angegeben werden.")
try:
ZoneInfo(glob["timezone"])
except (ZoneInfoNotFoundError, TypeError):
raise HTTPException(422, "Ungültige Zeitzone.")
network = values.get("network", {})
require(isinstance(network,dict),422,"Netzwerkparameter müssen ein Objekt sein.")
require(set(network) <= {"source", "cidr", "gateway", "dns", "filter"}, 422, "Nicht freigegebene Netzwerkoption.")
require(network.get("source") == "from-answer" and network.get("filter"), 422, "Explizites Managementnetz und Interface-Filter erforderlich.")
try:
address = ip_interface(network["cidr"])
gateway, dns = ip_address(network["gateway"]), ip_address(network["dns"])
require(gateway.version == address.version and gateway in address.network, 422, "Gateway liegt außerhalb des Managementnetzes.")
require(address.ip != gateway, 422, "Hostadresse darf nicht der Gatewayadresse entsprechen.")
if address.version == 4:
require(address.ip not in {address.network.network_address,address.network.broadcast_address}, 422, "Hostadresse ist Netz- oder Broadcastadresse.")
except (ValueError, KeyError, TypeError):
raise HTTPException(422, "Management-IP mit CIDR, Gateway und DNS müssen gültig sein.")
require(isinstance(network["filter"],dict) and all(isinstance(k,str) and isinstance(v,str) and v and v != "*" for k,v in network["filter"].items()), 422, "Expliziter Interface-Filter erforderlich.")
disks = values.get("disk_setup", {})
require(isinstance(disks,dict),422,"Datenträgerparameter müssen ein Objekt sein.")
require(set(disks) <= {"filesystem", "filter", "filter_match", "expected_count", "expected_serials", "inventory_evidence", "zfs", "lvm"}, 422, "Nicht freigegebene Datenträgeroption.")
require(isinstance(disks.get("filesystem"),str) and disks["filesystem"] in {"ext4", "xfs", "zfs"}, 422, "Unterstützte Dateisysteme: ext4, xfs, zfs.")
filters = disks.get("filter", {})
require(isinstance(filters,dict) and bool(filters) and set(filters) <= {"ID_SERIAL", "ID_SERIAL_SHORT", "ID_WWN"}, 422, "Datenträger benötigen stabile Seriennummer- oder WWN-Filter.")
serials = disks.get("expected_serials", [])
require(isinstance(serials,list) and all(isinstance(s,str) for s in serials) and 1 <= len(serials) <= 16 and len(set(serials)) == len(serials) and type(disks.get("expected_count")) is int and disks["expected_count"] == len(serials), 422, "Erwartete Datenträger und Anzahl müssen explizit übereinstimmen.")
require(all(isinstance(s,str) and s and not any(c in s for c in "*?[]") for s in serials), 422, "Erwartete Seriennummern müssen konkret sein.")
require(all(isinstance(v,str) and v and v not in {"*", "?"} for v in filters.values()), 422, "Pauschale Datenträgerfilter sind unzulässig.")
require(len(filters) == 1 and all(fnmatchcase(s, next(iter(filters.values()))) for s in serials), 422, "Ein stabiler Filter muss alle bestätigten Systemdatenträger auswählen.")
require(isinstance(disks.get("inventory_evidence"),str) and len(disks["inventory_evidence"]) >= 5, 422, "Datenträger benötigen einen Inventarisierungsnachweis.")
require(isinstance(disks.get("filter_match","all"),str) and disks.get("filter_match","all") in {"all","any"},422,"Ungültiger Datenträger-Filtermodus.")
if disks["filesystem"] in {"ext4", "xfs"}:
require(len(serials) == 1, 422, "LVM-Dateisysteme benötigen genau einen Systemdatenträger.")
require("zfs" not in disks,422,"ZFS-Optionen sind mit einem LVM-Dateisystem nicht kombinierbar.")
lvm = disks.get("lvm",{})
require(isinstance(lvm,dict) and set(lvm) <= {"hdsize","swapsize","maxroot","maxvz","minfree"},422,"Nicht unterstützte LVM-Option.")
require(all(isinstance(v,(int,float)) and not isinstance(v,bool) and v >= (2 if k in {"hdsize","maxroot"} else 0) and v < 1000000 for k,v in lvm.items()),422,"Ungültige LVM-Größenangabe.")
else:
require("lvm" not in disks,422,"LVM-Optionen sind mit ZFS nicht kombinierbar.")
zfs = disks.get("zfs",{})
require(isinstance(zfs,dict) and set(zfs) <= {"raid","ashift","arc-max","checksum","compress","copies","hdsize"},422,"Nicht unterstützte ZFS-Option.")
raid = zfs.get("raid")
minimum = {"raid0":1,"raid1":2,"raid10":4,"raidz-1":3,"raidz-2":4,"raidz-3":5}
require(isinstance(raid,str) and raid in minimum, 422, "ZFS benötigt einen expliziten RAID-Modus.")
require(len(serials)>=minimum[raid] and (raid!="raid10" or len(serials)%2==0),422,"Datenträgeranzahl passt nicht zum ZFS-RAID-Modus.")
for key,lower,upper in (("ashift",9,16),("arc-max",64,1048576),("copies",1,3),("hdsize",2,1000000)):
if key in zfs:
require(isinstance(zfs[key],(int,float)) and not isinstance(zfs[key],bool) and lower<=zfs[key]<=upper and (key=="hdsize" or isinstance(zfs[key],int)),422,f"Ungültige ZFS-Option {key}.")
require(isinstance(zfs.get("checksum","on"),str) and isinstance(zfs.get("compress","on"),str) and zfs.get("checksum","on") in {"on","fletcher4","sha256"} and zfs.get("compress","on") in {"on","off","lzjb","lz4","zle","gzip","zstd"},422,"Nicht unterstützte ZFS-Kompression oder Prüfsumme.")
require(isinstance(values.get("root_secret_id"),str) and bool(values["root_secret_id"]), 422, "Root-Secret-Referenz fehlt.")
def approve(self, connection, host_id, payload, actor):
require(not self.settings.maintenance, 503, "Wartungsmodus: Neue Freigaben sind gesperrt.")
host = get_host(connection, host_id)
require(host["version"] == payload.expected_version, 409, "Host wurde geändert. Vorschau erneut prüfen.")
require(payload.confirmation == host["fqdn"] and payload.disks_confirmed, 422, "FQDN und Überschreiben der aufgeführten Systemdatenträger müssen bestätigt werden.")
active = connection.execute("SELECT id FROM runs WHERE host_id=? AND status NOT IN ('succeeded','failed','cancelled','expired')", (host_id,)).fetchone()
require(not active, 409, "Für diesen Host existiert bereits ein aktiver Lauf.")
snapshot, secret_values = self.resolve(connection, host_id)
run_id, approval_id = new_id("run"), new_id("approval")
manifest = {"run_id": run_id, "steps": snapshot["steps"], "reboot_budget": snapshot["reboot_budget"]}
manifest_digest = digest(canonical(manifest))
run_data = {"snapshot": snapshot, "manifest": manifest, "manifest_digest": manifest_digest, "fqdn": host["fqdn"], "site": host["site"], "cancel_requested": False, "reboots": 0}
expiry = time.time() + payload.valid_minutes * 60
connection.execute("INSERT INTO approvals VALUES(?,?,?,?,?,?)", (approval_id,host_id,"approved",expiry,canonical({"actor":actor,"reason":payload.reason,"snapshot_digest":snapshot["digest"]}),now_iso()))
connection.execute("INSERT INTO runs(id,host_id,approval_id,status,data,secrets_ciphertext,created_at) VALUES(?,?,?,?,?,?,?)", (run_id,host_id,approval_id,"prepared",canonical(run_data),self.security.encrypt(canonical(secret_values)),now_iso()))
connection.executemany("INSERT INTO run_steps(run_id,step_id,position) VALUES(?,?,?)", [(run_id,s["id"],i) for i,s in enumerate(snapshot["steps"])])
connection.execute("UPDATE hosts SET status='prepared',version=version+1 WHERE id=?", (host_id,))
audit(connection, actor, "installation.approved", host_id, payload.reason, {"run_id":run_id,"disks":snapshot["disks"],"expires_at":expiry})
return public_run(connection.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone())
@staticmethod
def installer_identity(payload):
require(isinstance(payload,dict), 422, "Installer-Payload muss ein Objekt sein.")
meta = payload.get("$schema", payload.get("$fetchinfo", {}))
dmi = payload.get("dmi",{})
require(isinstance(meta,dict) and isinstance(dmi,dict),422,"Native Metadaten oder DMI-Daten sind ungültig.")
schema = meta.get("version", "legacy")
require(isinstance(schema,str) and schema in {"1.0","legacy"},422,"Nicht freigegebenes Installer-Payload-Schema.")
system = dmi.get("system", {})
interfaces = payload.get("network-interfaces",payload.get("network_interfaces",[]))
require(isinstance(system,dict) and isinstance(interfaces,list) and len(interfaces)<=64,422,"Native Hardwarekennungen sind ungültig.")
raw = []
for field,kind in (("uuid","uuid"),("serial","serial")):
if system.get(field):
raw.append((kind,system[field]))
for item in interfaces:
if isinstance(item,dict) and item.get("mac"):
raw.append(("mac",item["mac"]))
identities = []
for kind,value in raw:
if not isinstance(value,str) or value.lower() in {"unknown","none","not specified","default string","to be filled by o.e.m."}:
continue
try:
identities.append({"kind":kind,"value":normalize_identity(kind,value)})
except ValueError:
continue
require(identities, 422, "Installer übermittelt keine verwendbare Hardwarekennung.")
product = payload.get("product", {})
iso = payload.get("iso", {})
require(isinstance(product,dict) and isinstance(iso,dict),422,"Native Produkt- und ISO-Angaben fehlen.")
require(product.get("product") == "pve", 422, "Nur Proxmox VE Installer werden unterstützt.")
release, build = iso.get("release"), iso.get("build")
require(release and build, 422, "Native ISO Release- und Build-Informationen fehlen.")
return identities, f"{release}-{build}", schema
@staticmethod
def match_host(connection, identities, site=None):
candidates = set()
for identity in identities:
for row in connection.execute("SELECT host_id FROM host_identities WHERE kind=? AND value=?", (identity["kind"],identity["value"])):
candidates.add(row[0])
require(len(candidates) <= 1, 409, "Widersprüchliche Hardwarekennungen gehören zu mehreren Hosts.")
if not candidates:
return None
host = get_host(connection, candidates.pop())
if site is not None:
require(host["site"] == site, 403, "Host gehört nicht zum Standort des Gruppentokens.")
for kind in ("uuid", "serial"):
expected = {x["value"] for x in host["identities"] if x["kind"] == kind}
supplied = {x["value"] for x in identities if x["kind"] == kind}
require(not expected or not supplied or supplied <= expected, 409, "UUID und Seriennummer widersprechen der gespeicherten Hostidentität.")
return host
def serve_answer(self, connection, group, payload):
identities, build, schema = self.installer_identity(payload)
host = self.match_host(connection, identities, group["site"])
if host is None:
fingerprint = digest(canonical(sorted(identities,key=lambda i:(i["kind"],i["value"]))))
connection.execute("INSERT INTO discoveries VALUES(?,?,?,?,?,?) ON CONFLICT(fingerprint) DO UPDATE SET last_seen=excluded.last_seen", (new_id("discovery"),fingerprint,group["site"],canonical({"identities":identities,"build":build,"schema":schema}),"Host ist nicht zugeordnet.",time.time()))
# The caller commits discovery before returning the denial.
return None
require(not host["blocked"], 403, "Host ist gesperrt.")
row = connection.execute("SELECT * FROM runs WHERE host_id=? ORDER BY created_at DESC LIMIT 1", (host["id"],)).fetchone()
require(row is not None, 403, "Keine ausdrückliche Installationsfreigabe vorhanden.")
run = unpack(row)
iso = run["snapshot"]["iso"]
require(iso["group_id"] == group["id"] and iso["build"] == build, 403, "Installergruppe oder Zielbuild stimmt nicht mit der Freigabe überein.")
require(not run.get("cancel_requested"), 403, "Lauf ist zum Abbruch markiert.")
require(run["status"] != "expired", 410, "Installationsfreigabe ist abgelaufen.")
if run["status"] == "answer_served":
require(time.time() < run["answer_until"], 410, "Auslieferungsfenster ist abgelaufen.")
audit(connection, "installer:" + group["name"], "answer.repeated", run["id"])
return self.security.decrypt(run["answer_ciphertext"])
require(run["status"] == "prepared", 403, "Dieser Lauf erlaubt keine weitere Installation.")
approval = connection.execute("SELECT * FROM approvals WHERE id=?", (run["approval_id"],)).fetchone()
require(approval["status"] == "approved" and approval["expires_at"] > time.time(), 410, "Installationsfreigabe ist abgelaufen.")
require(self.settings.testing or self.settings.public_url.startswith("https://"), 503, "Maschinenendpunkte benötigen eine konfigurierte HTTPS-Adresse.")
bootstrap_token, enrollment_secret, report_token = token(), token(), token()
from .bootstrap import render_bootstrap
bootstrap_config = {"api_url":self.settings.public_url,"run_id":run["id"],"enrollment_secret":enrollment_secret,"identities":run["snapshot"]["identities"],"manifest_digest":run["manifest_digest"]}
if self.settings.runner_ca_file:
from pathlib import Path
bootstrap_config["ca_pem"] = Path(self.settings.runner_ca_file).read_text()
bootstrap = render_bootstrap(bootstrap_config)
require(len(bootstrap.encode()) <= 1024 * 1024, 422, "Starthelfer überschreitet das Größenlimit.")
resolved = deepcopy(run["snapshot"]["resolved"])
resolved["global"]["root-password-hashed"] = json.loads(self.security.decrypt(run["secrets_ciphertext"]))["root"]
disks = resolved["disk_setup"]
native_disks = {k:v for k,v in disks.items() if k not in {"expected_count","expected_serials","inventory_evidence","filter_match"}}
native_disks["filter-match"] = disks.get("filter_match","all")
answer_data = {"global":resolved["global"],"network":resolved["network"],"disk-setup":native_disks,"first-boot":{"source":"from-url","ordering":"network-online","url":self.settings.public_url + "/bootstrap/v1/" + bootstrap_token,"cert-fingerprint":iso["fingerprint"]},"post-installation-webhook":{"url":self.settings.public_url + "/installer/v1/report/" + report_token,"cert-fingerprint":iso["fingerprint"]}}
answer = tomli_w.dumps(answer_data)
run_data = json.loads(row["data"])
run_data.update({"answer_digest":digest(answer),"installer_schema":schema})
connection.execute("UPDATE runs SET status='answer_served',version=version+1,data=?,answer_ciphertext=?,bootstrap_ciphertext=?,bootstrap_hash=?,enrollment_hash=?,report_hash=?,answer_until=?,enroll_until=?,last_seen=? WHERE id=?", (canonical(run_data),self.security.encrypt(answer),self.security.encrypt(bootstrap),digest(bootstrap_token),digest(enrollment_secret),digest(report_token),time.time()+self.settings.answer_window_seconds,time.time()+self.settings.enrollment_hours*3600,time.time(),run["id"]))
connection.execute("UPDATE approvals SET status='consumed' WHERE id=?", (run["approval_id"],))
connection.execute("UPDATE hosts SET status='answer_served' WHERE id=?", (host["id"],))
audit(connection, "installer:" + group["name"], "answer.served", run["id"])
return answer
def redact_run(self, row, text):
values = []
if row["secrets_ciphertext"]:
secret_values = json.loads(self.security.decrypt(row["secrets_ciphertext"]))
values.append(secret_values.get("root", ""))
for step in secret_values.get("steps", {}).values():
values.extend(step.values())
return redact(text, values)
def redact_payload(self, row, value):
def walk(item):
if isinstance(item, str):
return self.redact_run(row, item)
if isinstance(item, dict):
return {key:walk(child) for key,child in item.items()}
if isinstance(item, list):
return [walk(child) for child in item]
return item
return canonical(walk(value))
def maintain(self):
with self.db.connection(write=True) as connection:
timestamp = time.time()
connection.execute("DELETE FROM sessions WHERE expires_at<?", (timestamp,))
connection.execute("DELETE FROM nonces WHERE expires_at<?", (timestamp,))
expired = connection.execute("SELECT r.id,r.host_id,r.approval_id FROM runs r JOIN approvals a ON a.id=r.approval_id WHERE r.status='prepared' AND a.expires_at<?", (timestamp,)).fetchall()
for run in expired:
connection.execute("UPDATE runs SET status='expired',version=version+1,completed_at=? WHERE id=?", (timestamp,run["id"]))
connection.execute("UPDATE approvals SET status='expired' WHERE id=?", (run["approval_id"],))
connection.execute("UPDATE hosts SET status='expired' WHERE id=?", (run["host_id"],))
audit(connection,"system","approval.expired",run["id"])
for table,days in (("logs",self.settings.log_retention_days),("audit",self.settings.audit_retention_days)):
cutoff = datetime.fromtimestamp(timestamp-days*86400,timezone.utc).isoformat()
connection.execute(f"DELETE FROM {table} WHERE created_at<?", (cutoff,))
def module_syntax(self, source):
executable = shutil.which("bash")
if os.name == "nt":
git = shutil.which("git")
candidate = Path(git).parent.parent / "bin" / "bash.exe" if git else None
if candidate and candidate.is_file():
executable = str(candidate)
require(executable is not None, 503, "Bash-Syntaxprüfung nicht verfügbar. Modul im Linux-Container veröffentlichen.")
result = subprocess.run([executable,"-n"],input=source.replace("\r\n","\n").encode(),capture_output=True,timeout=15)
require(result.returncode == 0, 422, "Bash-Syntaxprüfung fehlgeschlagen.")
require(all(re.search(r"\b" + phase + r"\b",source) for phase in ("check","apply","verify")), 422, "Modul muss check/apply/verify implementieren.")
+424
View File
@@ -0,0 +1,424 @@
/* Proxmox AIS administration console. No client-side dependencies. */
'use strict';
const icons = {
dashboard: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
server: '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M15 6.5h3M15 17.5h3"/>',
activity: '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
layers: '<path d="m12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5"/>',
workflow: '<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M6 9v7a2 2 0 0 0 2 2h7M15 6h6m-3-3v6"/>',
code: '<path d="m8 6-6 6 6 6m8-12 6 6-6 6m-3-15-2 18"/>',
disc: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2"/><path d="m7 7 2 2m6 6 2 2"/>',
shield: '<path d="M12 3 3 7v5c0 5 9 9 9 9s9-4 9-9V7l-9-4Z"/><path d="m8 12 3 3 5-6"/>',
settings: '<path d="m9 3-1 3-3 1-2 4 2 2v3l3 2 1 3h5l1-3 3-1 2-4-2-2V8l-3-2-1-3Z"/><circle cx="11.5" cy="12" r="3"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
arrow: '<path d="M4 12h15m-6-6 6 6-6 6"/>',
refresh: '<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.5 8a7 7 0 0 1 11.8-3L20 8M4 16l2.7 3A7 7 0 0 0 18.5 16"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
check: '<path d="m5 12 4 4L19 6"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
alert: '<path d="m12 3 10 18H2L12 3Z"/><path d="M12 9v5m0 3h.01"/>',
edit: '<path d="m16 3 5 5-12 12-6 1 1-6L16 3Zm-3 3 5 5"/>',
key: '<circle cx="8" cy="8" r="5"/><path d="m12 12 9 9m-4-4 3-3m-6 0 3-3"/>',
users: '<circle cx="9" cy="8" r="4"/><path d="M2 21v-3a7 7 0 0 1 14 0v3M17 4a4 4 0 0 1 0 8m3 9v-3a6 6 0 0 0-3-5"/>',
lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3m-4 5v2"/>',
copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V3H3v13h5"/>',
back: '<path d="M20 12H5m6-6-6 6 6 6"/>',
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
play: '<path d="m7 4 14 8-14 8V4Z"/>',
file: '<path d="M14 3H5v18h14V8l-5-5Zm0 0v5h5M8 13h8M8 17h5"/>',
};
const svg = (name) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.file}</svg>`;
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const json = value => JSON.stringify(value ?? {}, null, 2);
const arr = value => Array.isArray(value) ? value : (value?.items || []);
const toDate = value => new Date(typeof value==='number' && value<1e12 ? value*1000 : value);
const fmtDate = value => value && !Number.isNaN(toDate(value).getTime()) ? new Intl.DateTimeFormat('de-DE', {dateStyle:'short',timeStyle:'short'}).format(toDate(value)) : 'Noch kein Kontakt';
const shortId = value => String(value || '').slice(0, 10);
const roleNames = {reader:'Leser',operator:'Operator',author:'Skriptautor',admin:'Administrator',developer:'Entwickler'};
const statusNames = {draft:'Entwurf',published:'Veröffentlicht',discovered:'Entdeckt',ready:'Bereit',prepared:'Freigegeben',approved:'Freigegeben',answer_served:'Antwort ausgeliefert',answer_delivered:'Antwort ausgeliefert',installing:'Installation',installed:'Installiert',installed_reported:'Basisinstallation gemeldet',bootstrapping:'Erster Start',enrolled:'Registriert',runner_ready:'Runner bereit',running:'In Ausführung',postinstall_running:'Nachkonfiguration',postinstalling:'Nachkonfiguration',waiting_retry:'Wiederaufnahme erwartet',reboot_pending:'Neustart erwartet',waiting_reboot:'Neustart erwartet',rebooting:'Neustart',succeeded:'Erfolgreich',failed:'Fehlgeschlagen',needs_review:'Prüfung nötig',unknown:'Kontakt unbekannt',cancelled:'Abgebrochen',canceled:'Abgebrochen',cancel_requested:'Abbruch angefordert',blocked:'Gesperrt',pending:'Ausstehend',checking:'Prüfen',applying:'Anwenden',verifying:'Validieren',skipped:'Übersprungen',active:'Aktiv',passed:'Geprüft',expired:'Abgelaufen',revoked:'Widerrufen'};
const greenStatuses = new Set(['succeeded','passed','published','active','ready']);
const redStatuses = new Set(['failed','needs_review','blocked','revoked']);
const orangeStatuses = new Set(['draft','pending','discovered','unknown','expired','waiting_reboot','waiting_retry','reboot_pending','cancel_requested']);
const blueStatuses = new Set(['approved','prepared','answer_served','answer_delivered','installing','installed','bootstrapping','enrolled','runner_ready','running','postinstall_running','postinstalling','checking','applying','verifying','rebooting']);
const badge = status => `<span class="badge ${greenStatuses.has(status)?'green':redStatuses.has(status)?'red':orangeStatuses.has(status)?'orange':blueStatuses.has(status)?'blue':''}">${esc(statusNames[status] || status || 'Unbekannt')}</span>`;
const state = {me:null,page:'dashboard',id:null,data:null,routeVersion:0,settingsTab:'users',runTab:'steps',refreshing:false};
const canOperate = () => ['admin','developer','operator'].includes(state.me?.role);
const canAuthor = () => ['admin','developer','author'].includes(state.me?.role);
const canAdmin = () => ['admin','developer'].includes(state.me?.role);
const main = document.getElementById('main');
const modal = document.getElementById('modal');
let modalSubmit = null;
function errorMessage(detail) {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) return detail.map(e => `${(e.loc || []).filter(v=>v!=='body').join('.')}: ${e.msg || json(e)}`).join('\n');
return detail?.message || json(detail);
}
async function api(path, options = {}) {
const headers = {'Accept':'application/json', ...(options.headers || {})};
if (options.body !== undefined && typeof options.body !== 'string') {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.body);
}
if (options.method && options.method !== 'GET') headers['X-CSRF-Token'] = state.me?.csrf_token || '';
const response = await fetch(path.startsWith('/auth/') ? path : `/api/v1${path}`, {...options, headers, credentials:'same-origin'});
if (response.status === 401) { window.location.href = '/login'; throw new Error('Ihre Sitzung ist abgelaufen. Bitte erneut anmelden.'); }
const contentType = response.headers.get('content-type') || '';
const result = response.status === 204 ? null : contentType.includes('json') ? await response.json() : await response.text();
if (!response.ok) throw new Error(errorMessage(result?.errors || result?.detail || result?.message || result || `Anfrage fehlgeschlagen (${response.status})`));
return result;
}
function toast(message, isError=false) {
const el=document.createElement('div'); el.className=`toast${isError?' error':''}`; el.textContent=message;
const region=document.getElementById('toast-region');while(region.children.length>=3)region.firstElementChild.remove();region.append(el); setTimeout(()=>el.remove(), isError?9000:4500);
}
function actionButton(label, action, icon='plus', attrs='', primary=false) {
if(action==='preview-host' && !canOperate() && !canAuthor())return '';
return `<button type="button" class="button${primary?' primary':''}" data-action="${action}" ${attrs}>${svg(icon)}${esc(label)}</button>`;
}
function header(title, description, actions='', eyebrow='PROVISIONING CONSOLE') {
return `<div class="page-header"><div><span class="eyebrow">${esc(eyebrow)}</span><h1>${esc(title)}</h1><p>${esc(description)}</p></div><div class="header-actions">${actions}</div></div>`;
}
function empty(title, description, icon='server', action='') {
return `<div class="empty-state"><div class="empty-icon">${svg(icon)}</div><h3>${esc(title)}</h3><p>${esc(description)}</p>${action}</div>`;
}
function table(headings, rows) {
return `<div class="table-wrap"><table><thead><tr>${headings.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`;
}
function toolbar(placeholder='Suchen …', sites=[], statuses=[]) {
return `<div class="toolbar"><div class="filter-group"><label class="search-field">${svg('search')}<input id="list-search" type="search" aria-label="Liste durchsuchen" placeholder="${esc(placeholder)}"></label>${sites.length?`<select id="site-filter" class="filter-select" aria-label="Nach Standort filtern"><option value="">Alle Standorte</option>${sites.map(s=>`<option>${esc(s)}</option>`).join('')}</select>`:''}${statuses.length?`<select id="status-filter" class="filter-select" aria-label="Nach Status filtern"><option value="">Alle Status</option>${statuses.map(s=>`<option value="${esc(s)}">${esc(statusNames[s]||s)}</option>`).join('')}</select>`:''}</div><span class="toolbar-info" id="filter-count"></span></div>`;
}
const searchAttrs = (value, site='', status='') => `data-search="${esc(String(value).toLowerCase())}" data-site="${esc(site)}" data-status="${esc(status)}"`;
function applyFilters() {
const query=(document.getElementById('list-search')?.value || '').toLowerCase();
const site=document.getElementById('site-filter')?.value || '';
const status=document.getElementById('status-filter')?.value || '';
let count=0, total=0;
main.querySelectorAll('[data-search]').forEach(row=>{
total++; const visible=row.dataset.search.includes(query) && (!site || row.dataset.site===site) && (!status || row.dataset.status===status);
row.hidden=!visible; if(visible) count++;
});
const countEl=document.getElementById('filter-count'); if(countEl) countEl.textContent=`${count} ${count===1?'Eintrag':'Einträge'}${count!==total?` von ${total}`:''}`;
let noResults=document.getElementById('no-filter-results');
if(!noResults && total) {noResults=document.createElement('div');noResults.id='no-filter-results';noResults.className='empty-state';noResults.textContent='Keine Einträge für diese Auswahl.';main.querySelector('.filterable')?.append(noResults);}
if(noResults) noResults.hidden=count!==0;
}
function hostRows(hosts, compact=false) {
return hosts.map(h=>`<tr ${searchAttrs(`${h.fqdn} ${h.management_ip} ${h.site} ${(h.tags||[]).join(' ')}`,h.site,h.blocked?'blocked':h.status)}><td><div class="table-title"><span class="row-icon">${svg('server')}</span><div><a href="#/hosts/${encodeURIComponent(h.id)}"><strong>${esc(h.fqdn || h.name || `Entdeckter Host ${shortId(h.id)}`)}</strong></a><small>${esc(h.management_ip || 'Keine Management-IP')}</small></div></div></td><td>${esc(h.site || '')}</td><td>${badge(h.blocked?'blocked':h.status)}</td>${compact?'':`<td>${(h.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('') || '<span class="muted"></span>'}</td><td>${esc(fmtDate(h.last_contact || h.last_seen))}</td>`}<td><a class="button-link" href="#/hosts/${encodeURIComponent(h.id)}">Details ${svg('arrow')}</a></td></tr>`);
}
function runRows(runs) {
return runs.map(r=>{
const steps=arr(r.steps),finished=steps.filter(s=>['succeeded','failed','skipped'].includes(s.status)).length;
const waiting={prepared:'Freigabe aktiv · wartet auf ISO',answer_served:'Antwort ausgeliefert',answer_delivered:'Antwort ausgeliefert',installed_reported:'Wartet auf den ersten Start',installing:'Basisinstallation',installed:'Wartet auf den Runner'}[r.status];
const progress=steps.length?Math.round(finished/steps.length*100):0;
const progressCell=waiting?esc(waiting):steps.length?`${finished} / ${steps.length} geprüft<progress class="run-progress" max="100" value="${progress}" aria-label="Geprüfte Konfigurationsschritte">${progress}%</progress>`:esc(fmtDate(r.started_at || r.created_at));
return `<tr ${searchAttrs(`${r.host_fqdn||r.fqdn||r.host_id} ${r.id}`,r.site,r.status)}><td><div class="table-title"><span class="row-icon">${svg('activity')}</span><div><a href="#/runs/${encodeURIComponent(r.id)}"><strong>${esc(r.host_fqdn || r.fqdn || shortId(r.host_id))}</strong></a><small>Lauf ${esc(shortId(r.id))}</small></div></div></td><td>${badge(r.cancel_requested && !['cancelled','succeeded','failed','expired'].includes(r.status)?'cancel_requested':r.status)}${r.contact_status==='unknown'?` ${badge('unknown')}`:''}</td><td>${progressCell}</td><td><a class="button-link" href="#/runs/${encodeURIComponent(r.id)}">Ansehen ${svg('arrow')}</a></td></tr>`;
});
}
function eventList(events, blank='Noch keine Ereignisse') {
if(!events.length) return empty(blank,'Ereignisse erscheinen hier, sobald Sie Hosts und Konfigurationen verwalten.','activity');
return `<div class="activity-list">${events.map(e=>`<div class="activity-item"><span class="event-dot">${svg(e.status==='failed'?'alert':'activity')}</span><div class="activity-copy"><strong>${esc(e.message || e.action || e.type || e.event_type || 'Statusänderung')}</strong><p>${esc(e.host_fqdn || e.actor || e.username || e.object_id || shortId(e.run_id))} · ${esc(fmtDate(e.created_at || e.timestamp))}</p></div></div>`).join('')}</div>`;
}
function dashboard(data) {
const c=data.counts||{}, hosts=arr(data.hosts), runs=arr(data.runs), events=arr(data.recent_events);
const metrics=[['Server gesamt',c.hosts||0,'Inventarisierte Hosts','server',''],['Bereit zur Installation',c.ready||0,'Freigegebene Server','shield','orange'],['Aktive Läufe',c.active||0,'Installation & Nachkonfiguration','activity','green'],['Prüfung erforderlich',c.needs_review||0,'Vorgänge mit Handlungsbedarf','alert','red']];
return header('Infrastruktur im Überblick','Installationen steuern. Konfigurationen prüfen. Den Überblick behalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('Server hinzufügen','create-host','plus','',true):''),'WORKSPACE / ÜBERSICHT')+
`<div class="metrics-grid">${metrics.map(m=>`<div class="metric"><div class="metric-label">${m[0]}<span class="metric-icon ${m[4]}">${svg(m[3])}</span></div><div class="metric-value">${Number(m[1])}</div><div class="metric-note">${m[2]}</div></div>`).join('')}</div><div class="dashboard-grid"><div class="stack"><section class="card"><div class="card-header"><div><h2>Installationsläufe <span class="count-label">${runs.length}</span></h2><p>Die letzten Provisionierungen und ihr aktueller Stand</p></div><a class="button-link" href="#/runs">Alle Läufe ${svg('arrow')}</a></div>${runs.length?table(['SERVER','STATUS','FORTSCHRITT',''],runRows(runs.slice(0,6))):empty('Bereit für den ersten Lauf','Erfassen Sie einen Server, weisen Sie geprüfte Profile zu und erteilen Sie die Installationsfreigabe.','activity',canOperate()?actionButton('Server erfassen','create-host','plus'):'')}<div class="card-footer"><span><span class="status-dot"></span> Status aus persistenten Laufereignissen</span><span>${Number(c.succeeded||0)} erfolgreich abgeschlossen</span></div></section><section class="card"><div class="card-header"><div><h2>Serverinventar <span class="count-label">${Number(c.hosts||0)}</span></h2><p>Ihre zuletzt erfassten Server</p></div><a class="button-link" href="#/hosts">Zum Inventar ${svg('arrow')}</a></div>${hosts.length?table(['SERVER','STANDORT','STATUS',''],hostRows(hosts.slice(0,5),true)):empty('Ihr Inventar beginnt hier','Identitäten, Managementnetz und Profilzuordnungen an einem Ort.','server')}<div class="card-footer"><span>${Number(c.discovered||0)} neu entdeckte Hosts</span><a href="#/hosts">Inventar verwalten →</a></div></section></div><div class="stack"><section class="card intro-card"><div class="card-header"><span class="eyebrow">${c.hosts?'IHR PROVISIONIERUNGSABLAUF':'ERSTE SCHRITTE'}</span>${svg('workflow')}</div><div class="card-content"><h3>Von der ISO zum<br>fertigen Server.</h3><p>Ein klarer Ablauf für jede Installation.</p><div class="setup-steps"><a class="setup-step" href="#/media"><span class="setup-number">01</span><span><strong>Installationsmedium vorbereiten</strong><small>ISO registrieren und Build prüfen</small></span></a><a class="setup-step" href="#/installation"><span class="setup-number">02</span><span><strong>Konfiguration festlegen</strong><small>Profile und Skriptversionen veröffentlichen</small></span></a><a class="setup-step${c.hosts?' complete':''}" href="#/hosts"><span class="setup-number">${c.hosts?'✓':'03'}</span><span><strong>Server erfassen & freigeben</strong><small>Vorschau prüfen, Installation bestätigen</small></span></a></div></div></section><section class="card"><div class="card-header"><div><h2>Letzte Ereignisse</h2><p>Was sich in Ihrem Workspace verändert</p></div></div>${eventList(events.slice(0,5))}<div class="card-footer"><a class="button-link" href="#/audit">Auditprotokoll öffnen ${svg('arrow')}</a></div></section></div></div>`;
}
function hostsPage(hosts, discoveries=[]) {
const unassigned=discoveries.filter(d=>!hosts.some(h=>arr(h.identities).some(i=>arr(d.identities).some(v=>v.kind===i.kind && v.value.toLowerCase()===i.value.toLowerCase()))));
return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('JSON importieren','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`<section class="card filterable">${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}</section>`+(unassigned.length?`<section class="card space-top"><div class="card-header"><div><h2>Entdeckte Hardware <span class="count-label">${unassigned.length}</span></h2><p>Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.</p></div>${badge('discovered')}</div>${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`<tr><td>${arr(d.identities).map(i=>`<div class="small-text mono">${esc(i.kind)}: ${esc(i.value)}</div>`).join('')}</td><td>${esc(d.site)}<br><span class="small-text muted">${esc(d.build)}</span></td><td>${esc(d.reason)}</td><td>${canOperate()?`<button class="button small" data-action="assign-discovery" data-id="${esc(d.id)}">Als Server erfassen</button>`:''}</td></tr>`))}</section>`:'');
}
function hostPage(host) {
const id=esc(host.id), runs=arr(host.runs), identities=arr(host.identities);
return header(host.fqdn||'Entdeckter Server',`${host.site||'Kein Standort'} · ${host.management_ip||'Keine Management-IP'}`,`<a class="button" href="#/hosts">${svg('back')}Inventar</a>${canOperate()?actionButton('Bearbeiten','edit-host','edit',`data-id="${id}"`)+actionButton('Installation freigeben','approve-host','shield',`data-id="${id}"`,true):''}`,'SERVERDETAIL / '+shortId(host.id))+
`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Serverkonfiguration</h2>${badge(host.blocked?'blocked':host.status)}</div><div class="card-content"><dl class="detail-list"><dt>FQDN</dt><dd>${esc(host.fqdn||'')}</dd><dt>Management-IP</dt><dd class="mono">${esc(host.management_ip||'')}</dd><dt>Standort</dt><dd>${esc(host.site||'')}</dd><dt>Tags</dt><dd>${(host.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')||''}</dd><dt>Letzter Kontakt</dt><dd>${esc(fmtDate(host.last_contact||host.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(host.version)}</dd></dl><h3 class="section-label">Hardware-Identitäten</h3>${identities.length?`<dl class="detail-list">${identities.map(i=>`<dt>${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}</dt><dd class="mono">${esc(i.value)}</dd>`).join('')}</dl>`:'<p class="muted small-text">Noch keine Identitäten hinterlegt.</p>'}</div><div class="card-footer"><span>Erfasst am ${esc(fmtDate(host.created_at))}</span>${canOperate()?`<button class="button-link" data-action="toggle-host" data-id="${id}">${host.blocked?'Host entsperren':'Host sperren'}</button>`:''}</div></section><section class="card"><div class="card-header"><h2>Installationshistorie</h2><span class="count-label">${runs.length}</span></div>${runs.length?table(['LAUF','STATUS','FORTSCHRITT',''],runRows(runs)):empty('Noch keine Installationsläufe','Nach einer Freigabe und dem ersten ISO-Kontakt erscheint hier der zugehörige Lauf.','activity')}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Profilzuordnung</h2>${svg('layers')}</div><div class="card-content"><dl class="detail-list"><dt>Installation</dt><dd>${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}</dd><dt>Postinstallation</dt><dd>${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}</dd><dt>ISO-Medium</dt><dd>${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}</dd></dl><p class="small-text muted space-top">Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.</p>${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}</div></section>${host.discovered_data?`<section class="card"><div class="card-header"><h2>Erkannte Systemdaten</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.discovered_data))}</pre></div></section>`:''}<section class="card"><div class="card-header"><h2>Hostüberschreibungen</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.overrides))}</pre></div></section></div></div>`;
}
function profilesPage(profiles, kind) {
const installation=kind==='installation'; const list=profiles.filter(p=>p.kind===kind);
return header(installation?'Installationsprofile':'Postinstallationsprofile',installation?'Sprache, Managementnetz und explizite Systemdatenträger versioniert definieren.':'Geprüfte Module in einen nachvollziehbaren Ablauf mit festen Versionen bringen.',canAuthor()?actionButton('Profil erstellen','create-profile','plus',`data-kind="${kind}"`,true):'',`KONFIGURATION / ${installation?'INSTALLATION':'POSTINSTALLATION'}`)+
(list.length?`<div class="card space-bottom">${toolbar('Profilname oder Zielbuild suchen …')}</div><div class="cards-grid filterable">${list.map(p=>`<section class="card profile-card" ${searchAttrs(`${p.name} ${(p.target_builds||[]).join(' ')}`)}><div class="profile-top"><span class="profile-symbol${installation?'':' blue'}">${svg(installation?'layers':'workflow')}</span>${badge(p.status)}</div><h2>${esc(p.name)}</h2><p>Version ${esc(p.version)} · ${installation?'Installationskonfiguration':`${arr(p.steps).length} Konfigurationsschritte`}</p><div class="profile-meta">${(p.target_builds||[]).map(b=>`<span class="tag">PVE ${esc(b)}</span>`).join('')||'<span class="muted small-text">Keine Zielbuilds</span>'}</div><code title="${esc(p.digest||'')}">${p.digest?`SHA256 ${esc(p.digest.slice(0,25))}`:'Digest nach Veröffentlichung'}</code><div class="profile-actions"><button class="button small" data-action="view-profile" data-id="${esc(p.id)}">${svg('file')}Ansehen</button>${canAuthor()?`<button class="button small" data-action="version-profile" data-id="${esc(p.id)}">${svg('plus')}Neue Version</button>`:''}${canAdmin()&&p.status==='draft'?`<button class="button small primary" data-action="publish-profile" data-id="${esc(p.id)}">Freigeben</button>`:''}</div></section>`).join('')}</div>`:`<section class="card">${empty(installation?'Noch keine Installationsprofile':'Noch keine Postinstallationsprofile',installation?'Legen Sie zuerst ein Root-Geheimnis und die Netzwerk- und Datenträgerkonfiguration Ihres Hardwaretyps an.':'Erstellen und veröffentlichen Sie zunächst Skriptmodule. Fassen Sie diese anschließend zu einem Ablauf zusammen.',installation?'layers':'workflow',canAuthor()?actionButton('Erstes Profil erstellen','create-profile','plus',`data-kind="${kind}"`,true):'')}</section>`);
}
function modulesPage(modules) {
return header('Skriptmodule','Check, Apply und Verify: versionierte Bausteine für die Nachkonfiguration.',canAuthor()?actionButton('Aus Vorlage','module-catalog','layers')+actionButton('Modul erstellen','create-module','plus','',true):'','KONFIGURATION / SKRIPTE')+`<section class="card filterable">${toolbar('Name oder Zielbuild suchen …',[],['draft','published'])}${modules.length?table(['MODUL','VERSION','STATUS','ZIELBUILDS','WIEDERHOLUNG',''],modules.map(m=>`<tr ${searchAttrs(`${m.name} ${(m.target_builds||[]).join(' ')}`,'',m.status)}><td><div class="table-title"><span class="row-icon">${svg('code')}</span><div><strong>${esc(m.name)}</strong><small>${Number(m.timeout_seconds||300)} s Timeout</small></div></div></td><td>v${esc(m.version)}</td><td>${badge(m.status)}</td><td>${(m.target_builds||[]).map(b=>`<span class="tag">${esc(b)}</span>`).join('')}</td><td>${m.retry_safe?'Explizit erlaubt':'Manuelle Prüfung'}</td><td><div class="table-actions"><button class="button small" data-action="view-module" data-id="${esc(m.id)}">Ansehen</button>${canAuthor()?`<button class="button small" data-action="version-module" data-id="${esc(m.id)}">Neue Version</button>`:''}${canAdmin()&&m.status==='draft'?`<button class="button small primary" data-action="publish-module" data-id="${esc(m.id)}">Freigeben</button>`:''}</div></td></tr>`)):empty('Ihre Konfiguration als Bausteine','Jedes Modul definiert Zustandsprüfung, Änderung und Erfolgskontrolle. Eine Veröffentlichung erfordert einen Testnachweis.','code',canAuthor()?actionButton('Erstes Modul erstellen','create-module','plus','',true):'')}</section>`;
}
function runsPage(runs) {
return header('Installationsläufe','Installationen und Nachkonfigurationen vom ersten Kontakt bis zur Abschlussprüfung.',actionButton('Aktualisieren','refresh','refresh'),'VERWALTUNG / LÄUFE')+`<section class="card filterable">${toolbar('Host oder Lauf-ID suchen …',[],[...new Set(runs.map(r=>r.status))])}${runs.length?table(['SERVER / LAUF','STATUS','FORTSCHRITT / START',''],runRows(runs)):empty('Noch keine Installationsläufe','Starten Sie einen freigegebenen Server mit dem registrierten Installationsmedium. Der Lauf wird beim Antwortabruf automatisch angelegt.','activity','<a class="button" href="#/hosts">Zum Serverinventar →</a>')}</section>`;
}
function runPage(run) {
const steps=arr(run.steps), events=arr(run.events), logs=arr(run.logs), terminal=['succeeded','failed','cancelled','canceled','expired'].includes(run.status);
const resumeAllowed=['needs_review','waiting_retry'].includes(run.status) && !run.cancel_requested;
const actions=`<a class="button" href="#/runs">${svg('back')}Alle Läufe</a>${canOperate()&&resumeAllowed?actionButton('Wiederaufnehmen','resume-run','play',`data-id="${esc(run.id)}"`,true):''}${canOperate()&&!terminal?actionButton('Abbrechen','cancel-run','stop',`data-id="${esc(run.id)}"`)+actionButton('Lauf abgleichen','reconcile-run','shield',`data-id="${esc(run.id)}"`):''}`;
let content='';
if(state.runTab==='steps') content=steps.length?`<ol class="run-steps">${steps.map((s,i)=>`<li class="run-step"><span class="step-number">${s.status==='succeeded'?'✓':i+1}</span><div class="run-step-copy"><strong>${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}</strong><p>Modul ${esc(s.module_name || s.module_id || '')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}</p>${s.required===false?'<p>Optionaler Schritt</p>':''}${s.error?`<p>${esc(s.error)}</p>`:''}${s.verification&&Object.keys(s.verification).length?`<details><summary>Pr?fergebnis${s.status==='failed'?' / Fehlerursache':''}</summary><pre class="code-block light">${esc(json(s.verification))}</pre></details>`:''}${s.checkpoint?`<details><summary>Checkpoint</summary><pre class="code-block light">${esc(json(s.checkpoint))}</pre></details>`:''}</div>${badge(s.status)}</li>`).join('')}</ol>`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow');
if(state.runTab==='events') content=eventList(events,'Noch keine Laufereignisse');
if(state.runTab==='logs') content=logs.length?`<div class="card-content"><pre class="code-block">${esc(logs.map(l=>typeof l==='string'?l:`${l.created_at?fmtDate(l.created_at)+' ':''}${l.step_id?'['+l.step_id+'] ':''}${l.content||l.text||l.message||json(l)}`).join('\n'))}</pre></div>`:empty('Noch keine Protokolldaten','Der Runner übermittelt redigierte Protokolle während der Ausführung.','code');
return header(run.host_fqdn||run.fqdn||`Lauf ${shortId(run.id)}`,`Lauf ${run.id}`,actions,'INSTALLATIONSLAUF')+`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Ausführungsstatus</h2>${badge(run.status)}</div><div class="card-content"><div class="tabs" role="tablist" aria-label="Laufdetails">${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>`<button role="tab" type="button" aria-selected="${state.runTab===id}" class="tab${state.runTab===id?' active':''}" data-action="run-tab" data-tab="${id}">${label}${id==='steps'?` (${steps.length})`:''}</button>`).join('')}</div>${run.error||run.error_reason?`<div class="alert alert-danger">${esc(run.error||run.error_reason)}</div>`:''}</div>${content}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Laufdaten</h2></div><div class="card-content"><dl class="detail-list"><dt>Server</dt><dd><a href="#/hosts/${encodeURIComponent(run.host_id)}">${esc(run.host_fqdn||shortId(run.host_id))} ↗</a></dd><dt>Gestartet</dt><dd>${esc(fmtDate(run.started_at||run.created_at))}</dd><dt>Abgeschlossen</dt><dd>${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):''}</dd><dt>Letzter Heartbeat</dt><dd>${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(run.version)}</dd><dt>Manifest-Digest</dt><dd class="mono">${esc(run.manifest_digest||'Noch nicht erstellt')}</dd><dt>Antwort-Digest</dt><dd class="mono">${esc(run.answer_digest||run.answer_sha256||'')}</dd></dl></div></section><section class="card"><div class="card-header"><h2>Fixierte Konfiguration</h2>${svg('lock')}</div><div class="card-content"><p class="small-text muted">Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.</p><pre class="code-block light">${esc(json(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id}))}</pre></div></section></div></div>`;
}
function mediaPage(records, groups=[]) {
return header('Installationsmedien','Gemeinsame ISO-Medien registrieren, Zugriffe begrenzen und Kompatibilität belegen.',(canAdmin()?actionButton('Gruppentoken erstellen','create-group','key')+actionButton('ISO registrieren','create-iso','plus','',true):''),'KONFIGURATION / MEDIEN')+
`<div class="alert alert-info">Die ISO wird auf einer Build-Maschine mit dem Proxmox Auto Install Assistant vorbereitet. Registrieren Sie anschließend den konkreten Build mit Prüfsumme und Testnachweis.</div><div class="stack"><section class="card"><div class="card-header"><div><h2>Registrierte ISO-Medien <span class="count-label">${records.length}</span></h2><p>Freigabe ausschließlich für die dokumentierte Build-Kombination</p></div></div>${records.length?table(['MEDIUM','ZIELBUILD','ASSISTANT','TESTSTATUS',''],records.map(r=>`<tr><td><div class="table-title"><span class="row-icon">${svg('disc')}</span><div><strong>${esc(r.name)}</strong><small>${esc(shortId(r.sha256))}…</small></div></div></td><td>${esc(r.build)}</td><td>${esc(r.assistant_version)}</td><td>${badge(r.test_status)}</td><td><button class="button small" data-action="view-iso" data-id="${esc(r.id)}">Details & Buildbefehl</button></td></tr>`)):empty('Noch keine Installationsmedien','Erstellen Sie zuerst eine Bereitstellungsgruppe. Registrieren Sie danach Ihr geprüftes ISO-Medium.','disc',canAdmin()?actionButton('ISO registrieren','create-iso','plus','',true):'')}</section><section class="card groups-card"><div class="card-header"><div><h2>Bereitstellungsgruppen</h2><p>Zeitlich begrenzte Gruppentoken für den Antwortabruf</p></div></div>${groups.length?table(['GRUPPE','STANDORT','GÜLTIG BIS','STATUS'],groups.map(g=>`<tr><td><strong>${esc(g.name)}</strong><br><small class="muted mono">${esc(g.id)}</small></td><td>${esc(g.site||'')}</td><td>${esc(fmtDate(g.expires_at))}</td><td>${badge(g.revoked?'revoked':toDate(g.expires_at)<new Date()?'expired':'active')}</td></tr>`)):empty('Keine Gruppen vorhanden','Ein Gruppentoken berechtigt zum Antwortabruf für zugeordnete und freigegebene Hosts.','key',canAdmin()?actionButton('Gruppe erstellen','create-group','plus'):'')}</section></div>`;
}
function auditPage(events) {
return header('Auditprotokoll','Änderungen, Freigaben und privilegierte Zugriffe nachvollziehen.',actionButton('Aktualisieren','refresh','refresh'),'SYSTEM / AUDIT')+`<section class="card filterable">${toolbar('Aktion, Benutzer oder Objekt suchen …')}${events.length?table(['ZEITPUNKT','AKTEUR','AKTION','OBJEKT',''],events.map(e=>`<tr ${searchAttrs(`${e.action} ${e.actor||e.username||e.actor_id} ${e.object_id||e.target_id} ${e.reason||''}`)}><td>${esc(fmtDate(e.created_at||e.timestamp))}</td><td><strong>${esc(e.actor||e.username||e.actor_id||'System')}</strong></td><td>${esc(e.action)}</td><td>${esc(e.object_type||e.target_type||'')} <span class="mono">${esc(shortId(e.object_id||e.target_id))}</span></td><td><button class="button small" data-action="view-audit" data-id="${esc(e.id)}">Details</button></td></tr>`)):empty('Noch keine Auditereignisse','Änderungen und Freigaben werden mit Akteur, Zeitpunkt und Änderungsgrund aufgezeichnet.','shield')}</section>`;
}
function settingsPage(users=[], secrets=[]) {
if(!canAdmin()) return header('Einstellungen','Ihre Zugriffsrechte im Workspace.','','SYSTEM / EINSTELLUNGEN')+`<section class="card"><div class="card-header"><h2>Ihr Konto</h2></div><div class="card-content"><dl class="detail-list"><dt>Benutzername</dt><dd>${esc(state.me.username)}</dd><dt>Rolle</dt><dd>${esc(roleNames[state.me.role]||state.me.role)}</dd></dl><p class="small-text muted space-top">Benutzer und Geheimnisse werden durch Administratoren verwaltet.</p></div></section>`;
const tab=state.settingsTab;
return header('Einstellungen','Benutzerkonten und verschlüsselte Betriebsgeheimnisse verwalten.',tab==='users'?actionButton('Benutzer anlegen','create-user','plus','',true):actionButton('Geheimnis hinterlegen','create-secret','key','',true),'SYSTEM / EINSTELLUNGEN')+`<div class="tabs" role="tablist" aria-label="Einstellungen"><button type="button" role="tab" aria-selected="${tab==='users'}" class="tab${tab==='users'?' active':''}" data-action="settings-tab" data-tab="users">Benutzer & Rollen</button><button type="button" role="tab" aria-selected="${tab==='secrets'}" class="tab${tab==='secrets'?' active':''}" data-action="settings-tab" data-tab="secrets">Geheimnisse</button></div>`+(tab==='users'?`<section class="card"><div class="card-header"><h2>Benutzerkonten <span class="count-label">${users.length}</span></h2></div>${users.length?table(['BENUTZER','ROLLE','ERSTELLT'],users.map(u=>`<tr><td><div class="table-title"><span class="row-icon">${svg('users')}</span><strong>${esc(u.username)}</strong>${u.id===state.me.id?'<span class="tag">Sie</span>':''}</div></td><td>${esc(roleNames[u.role]||u.role)}</td><td>${esc(fmtDate(u.created_at))}</td></tr>`)):empty('Keine Benutzer gefunden','Legen Sie ein Benutzerkonto mit der passenden Rolle an.','users')}</section><div class="alert alert-info space-top">Leser sehen redigierte Daten. Operatoren verwalten Hosts und Läufe. Skriptautoren erstellen Entwürfe. Administratoren verwalten Freigaben, Benutzer und Geheimnisse. Entwickler haben alle Berechtigungen.</div>`:`<div class="alert alert-info">Geheimnisse werden verschlüsselt gespeichert und über ihre ID referenziert. Der gespeicherte Wert wird in der Konsole nicht erneut angezeigt.</div><section class="card"><div class="card-header"><h2>Geheimnisreferenzen <span class="count-label">${secrets.length}</span></h2></div>${secrets.length?table(['NAME','REFERENZ-ID','ERSTELLT'],secrets.map(s=>`<tr><td><div class="table-title"><span class="row-icon">${svg('key')}</span><strong>${esc(s.name)}</strong></div></td><td class="mono">${esc(s.id)}</td><td>${esc(fmtDate(s.created_at))}</td></tr>`)):empty('Noch keine Geheimnisse hinterlegt','Hinterlegen Sie beispielsweise den Root-Passwort-Hash für ein Installationsprofil.','key',actionButton('Geheimnis hinterlegen','create-secret','key','',true))}</section>`);
}
function showModal(title, content, submit=null, eyebrow='PROXMOX AIS') {
document.getElementById('modal-title').textContent=title;
document.getElementById('modal-eyebrow').textContent=eyebrow;
document.getElementById('modal-body').innerHTML=content;
modalSubmit=submit;
if(!modal.open) modal.showModal();
}
function closeModal() {modal.close();modalSubmit=null;document.getElementById('modal-body').replaceChildren();}
function field(name,label,value='',options={}) {
const attrs=`name="${esc(name)}"${options.required?' required':''}${options.placeholder?` placeholder="${esc(options.placeholder)}"`:''}${options.min!==undefined?` min="${esc(options.min)}"`:''}${options.max!==undefined?` max="${esc(options.max)}"`:''}${options.autocomplete?` autocomplete="${esc(options.autocomplete)}"`:''}`;
let input;
if(options.type==='textarea'||options.type==='json') input=`<textarea ${attrs} class="${options.type==='json'?'code':''}"${options.rows?` rows="${Number(options.rows)}"`:''}>${esc(typeof value==='object'?json(value):value)}</textarea>`;
else if(options.type==='select') input=`<select ${attrs}>${(options.options||[]).map(o=>`<option value="${esc(o.value)}"${String(o.value)===String(value)?' selected':''}>${esc(o.label)}</option>`).join('')}</select>`;
else if(options.type==='checkbox') return `<label class="checkbox${options.full?' full':''}"><input type="checkbox" ${attrs}${value?' checked':''}><span>${esc(label)}${options.hint?`<br><small>${esc(options.hint)}</small>`:''}</span></label>`;
else input=`<input type="${esc(options.type||'text')}" ${attrs} value="${esc(value)}">`;
return `<label class="${options.full?'full':''}">${esc(label)}${input}${options.hint?`<small>${esc(options.hint)}</small>`:''}</label>`;
}
function form(fields,label='Speichern',intro='') {
return `${intro}<form id="modal-form"><div class="form-grid">${fields}</div><div class="form-error alert alert-danger" role="alert"></div><div class="form-actions"><button type="button" class="button" data-action="close-modal">Abbrechen</button><button type="submit" class="button primary">${esc(label)}</button></div></form>`;
}
function parseJSON(data, name, fallback={}) {try{return JSON.parse(data.get(name)||json(fallback));}catch{throw new Error(`Das Feld „${name}“ enthält kein gültiges JSON.`);}}
const split = value => String(value||'').split(/[,\n]/).map(s=>s.trim()).filter(Boolean);
const selectObjects = (objects, emptyLabel='Bitte auswählen') => [{value:'',label:emptyLabel},...objects.map(o=>({value:o.id,label:`${o.name || o.fqdn}${o.version?` · v${o.version}`:''}${o.build?` · ${o.build}`:''}`}))];
async function hostForm(existing=null, discovery=null) {
const [profiles, isos]=await Promise.all([api('/profiles'),api('/iso-records')]);
const h=existing||discovery||{};
const fields=field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})+field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})+field('management_ip','Management-IP mit Präfix',h.management_ip,{required:true,placeholder:'192.0.2.10/24'})+field('tags','Tags',arr(h.tags).join(', '),{placeholder:'produktion, rack-a',hint:'Mehrere Tags mit Komma trennen.'})+field('identities','Hardware-Identitäten',arr(h.identities).map(i=>`${i.kind}:${i.value}`).join('\n'),{type:'textarea',required:true,full:true,placeholder:'serial:SERVER-SERIAL\nuuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\nmac:00:11:22:33:44:55',hint:'Eine Identität pro Zeile. Erlaubte Typen: serial, uuid, mac.'})+field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+field('overrides','Hostüberschreibungen (JSON)',h.overrides||{},{type:'json',full:true,hint:'Spezifische Werte dieses Hosts. Geheimnisse ausschließlich per Referenz zuweisen.'});
showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{
const identities=String(data.get('identities')).split('\n').filter(l=>l.trim()).map(line=>{const colon=line.indexOf(':');if(colon<1)throw new Error('Jede Identität benötigt das Format typ:wert.');return {kind:line.slice(0,colon).trim(),value:line.slice(colon+1).trim()};});
const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip'),tags:split(data.get('tags')),identities,installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:parseJSON(data,'overrides')};
if(existing){for(const key of Object.keys(body)){if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];}if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;}
await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh();
},'INVENTAR');
}
function hostImportForm() {
const example=[{fqdn:'pve-01.example.net',site:'Berlin',management_ip:'192.0.2.10/24',identities:[{kind:'serial',value:'SERVER-SERIAL'}],tags:[]}];
showModal('Server aus JSON importieren',form(field('hosts','Serverliste (JSON)',example,{type:'json',full:true,required:true,rows:16,hint:'Liste von Hostobjekten. Die gesamte Liste wird zusammen validiert und gespeichert.'}),'Server importieren'),async data=>{const hosts=parseJSON(data,'hosts',[]);if(!Array.isArray(hosts)||!hosts.length)throw new Error('Eine nicht leere JSON-Liste von Servern ist erforderlich.');await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server importiert.`);await refresh();},'INVENTARIMPORT');
}
async function moduleCatalog() {
const catalog=arr(await api('/modules/builtin'));
showModal('Basismodul als Entwurf übernehmen',`<div class="alert alert-info">Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.</div>${catalog.map(m=>`<div class="run-step"><span class="step-number">${svg('code')}</span><div class="run-step-copy"><strong>${esc(m.name)}</strong><p>${esc(m.description)}</p></div><button type="button" class="button small" data-action="use-module-template" data-id="${esc(m.id)}">Übernehmen</button></div>`).join('')}<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULVORLAGEN');
state.catalog=catalog;
}
function previewContent(preview) {
const p=preview||{}, warnings=arr(p.warnings);
const network=p.resolved?.network||{};
return `${warnings.length?`<div class="alert">${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('<br>')}</div>`:''}<div class="modal-summary"><dl class="detail-list"><dt>ISO / Zielbuild</dt><dd>${esc(p.iso?.name||'')} · ${esc(p.iso?.build||'')}</dd><dt>Managementnetz</dt><dd>${esc(network.cidr||'')}<br>Gateway ${esc(network.gateway||'')} · DNS ${esc(network.dns||'')}<br><span class="mono">${esc(JSON.stringify(network.filter||{}))}</span></dd><dt>Zieldatenträger</dt><dd><pre class="code-block light">${esc(json(p.disks || p.resolved?.disk_setup || p.resolved?.['disk-setup'] || {}))}</pre></dd><dt>Profilversionen</dt><dd>${arr(p.profiles).map(profile=>`${esc(profile.name)} · v${esc(profile.version)}`).join('<br>')||''}</dd><dt>Skriptversionen</dt><dd>${arr(p.steps).map(step=>`${esc(step.name||step.id)} · v${esc(step.module_version||'')}`).join('<br>')||''}</dd><dt>Konfigurationsdigest</dt><dd class="mono break">${esc(p.digest||'')}</dd></dl></div><details><summary>Aufgelöste Konfiguration & Herkunft</summary><pre class="code-block light">${esc(json({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps}))}</pre></details>`;
}
async function approveHost(id, previewOnly=false) {
const [host, preview]=await Promise.all([api(`/hosts/${encodeURIComponent(id)}`),api(`/hosts/${encodeURIComponent(id)}/preview`)]);
if(previewOnly){showModal('Aufgelöste Konfiguration',previewContent(preview)+`<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,host.fqdn);return;}
const intro=`<div class="alert alert-danger">Die Installation überschreibt die ausgewählten Systemdatenträger. Prüfen Sie Host, Netzwerk, Zielbuild und Datenträger vor der Freigabe.</div><div class="modal-summary"><dl class="detail-list"><dt>Server</dt><dd><strong>${esc(host.fqdn)}</strong></dd><dt>Management-IP</dt><dd>${esc(host.management_ip)}</dd><dt>Standort</dt><dd>${esc(host.site)}</dd></dl></div>${previewContent(preview)}<hr class="form-divider">`;
const fields=field('confirmation','Hostnamen zur Bestätigung eingeben','',{required:true,full:true,placeholder:host.fqdn})+field('valid_minutes','Freigabefenster in Minuten',30,{type:'number',required:true,min:5,max:240})+field('reason','Freigabegrund','',{required:true,placeholder:'Geplante Erstinstallation'})+field('disks_confirmed','Ich habe die Zieldatenträger geprüft und bestätige, dass diese überschrieben werden dürfen.',false,{type:'checkbox',required:true,full:true});
showModal('Installation freigeben',form(fields,'Verbindlich freigeben',intro),async data=>{
if(data.get('confirmation')!==host.fqdn)throw new Error('Der eingegebene Hostname stimmt nicht mit dem Server überein.');
await api(`/hosts/${encodeURIComponent(id)}/approve-install`,{method:'POST',body:{expected_version:host.version,valid_minutes:Number(data.get('valid_minutes')),confirmation:data.get('confirmation'),disks_confirmed:data.get('disks_confirmed')==='on',reason:data.get('reason')}});
closeModal();toast('Installationsfreigabe erteilt. Der Server kann mit der zugewiesenen ISO gestartet werden.');await refresh();
},'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE');
}
const installationExample = {
global:{keyboard:'de',country:'de',timezone:'Europe/Berlin',mailto:'admin@example.net'},
network:{source:'from-answer',gateway:'192.0.2.1',dns:'192.0.2.53',filter:{ID_NET_NAME_MAC:'enx001122334455'}},
disk_setup:{filesystem:'ext4',filter:{ID_SERIAL:'EXPLICIT_DISK_SERIAL'},expected_count:1,expected_serials:['EXPLICIT_DISK_SERIAL'],inventory_evidence:'Referenz zur geprüften Hardwareinventarisierung'},
root_secret_id:'ID_DES_ROOT_PASSWORT_HASHES'
};
async function profileForm(kind, existing=null) {
const p=existing||{}, install=kind==='installation';
let info='';
if(!install){const modules=arr(await api('/modules')).filter(m=>m.status==='published');info=`<div class="alert alert-info">Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: <code>${esc(m.id)}</code>`).join('<br>'):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}</div>`;}
const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Standardserver':'PVE · Basiskonfiguration',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(p.target_builds).join(', '),{required:true,full:true,placeholder:'z. B. 9.1-1',hint:'Nur tatsächlich geprüfte Builds eintragen. Mehrere Werte mit Komma trennen.'})+field('values',install?'Installationskonfiguration (JSON)':'Profilparameter (JSON)',p.values||(install?installationExample:{}),{type:'json',full:true,rows:install?17:6,hint:install?'Beispielwerte an Ihr Netz und Ihre geprüfte Hardware anpassen. FQDN und Management-CIDR kommen vom Host.':'Parameter werden mit den Einstellungen der einzelnen Schritte aufgelöst.'})+(!install?field('steps','Geordnete Schritte (JSON)',p.steps||[{id:'final-check',module_id:'VEROEFFENTLICHTE_MODUL_ID',parameters:{},secret_refs:{},required:true}],{type:'json',full:true,rows:10,hint:'Jeder Schritt verweist auf eine veröffentlichte Modulversion. Die Listenreihenfolge ist die Ausführungsreihenfolge.'})+field('reboot_budget','Maximale geplante Neustarts',p.reboot_budget??1,{type:'number',min:0,max:5,full:true}):'')+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'});
showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern',info),async data=>{
await api('/profiles',{method:'POST',body:{name:data.get('name'),kind,target_builds:split(data.get('target_builds')),values:parseJSON(data,'values'),steps:install?[]:parseJSON(data,'steps',[]),reason:data.get('reason')||'',...(!install?{reboot_budget:Number(data.get('reboot_budget'))}:{})}});
closeModal();toast('Profilentwurf gespeichert. Eine Veröffentlichung benötigt einen Testnachweis.');await refresh();
},install?'INSTALLATIONSPROFIL':'POSTINSTALLATIONSPROFIL');
}
const moduleExample = '#!/usr/bin/env bash\nset -euo pipefail\n\ncheck() {\n systemctl is-active --quiet pveproxy\n}\n\napply() {\n # Nur erforderliche, geprüfte Änderungen ausführen.\n return 0\n}\n\nverify() {\n systemctl is-active --quiet pveproxy\n}\n\ncase "${1:-}" in\n check) check ;;\n apply) apply ;;\n verify) verify ;;\n *) echo "Usage: $0 {check|apply|verify}" >&2; exit 2 ;;\nesac\n';
function moduleForm(existing=null) {
const m=existing||{};
const fields=field('name','Modulname',m.name,{required:true,full:true,placeholder:'PVE-Dienste prüfen',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(m.target_builds).join(', '),{required:true,placeholder:'z. B. 9.1-1'})+field('timeout_seconds','Timeout in Sekunden',m.timeout_seconds||300,{type:'number',required:true,min:1,max:7200})+field('source','Bash-Quelltext',m.source||moduleExample,{type:'json',required:true,full:true,rows:17,hint:'Aufruf: bash modul.sh check|apply|verify parameter.json. Parameter werden als JSON-Datei übergeben.'})+field('parameters_schema','Parameterschema (JSON Schema)',m.parameters_schema||{type:'object',properties:{},additionalProperties:false},{type:'json',full:true,rows:6})+field('dependencies','Abhängige Modulnamen',arr(m.dependencies).join(', '),{full:true,placeholder:'Optional: exakte Modulnamen, durch Komma getrennt'})+field('retry_safe','Apply darf nach Zustandsprüfung wiederholt werden.',m.retry_safe||false,{type:'checkbox',full:true,hint:'Nur aktivieren, wenn die Wiederholbarkeit im Test nachgewiesen wurde.'})+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Modulstand'});
showModal(existing?'Neue Modulversion':'Skriptmodul erstellen',form(fields,'Entwurf speichern'),async data=>{
await api('/modules',{method:'POST',body:{name:data.get('name'),source:data.get('source'),parameters_schema:parseJSON(data,'parameters_schema'),dependencies:split(data.get('dependencies')),target_builds:split(data.get('target_builds')),timeout_seconds:Number(data.get('timeout_seconds')),retry_safe:data.get('retry_safe')==='on',reason:data.get('reason')||''}});closeModal();toast('Modulentwurf gespeichert.');await refresh();
},'VERSIONIERTE SKRIPTMODULE');
}
async function publishObject(type, id) {
const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`);
const intro=`<div class="alert alert-info">${esc(item.name)} · Version ${esc(item.version)}<br>Veröffentlichten Inhalt können Sie nicht mehr ändern. Dokumentieren Sie den praktischen Test auf einem passenden Testhost. Im Vieraugenmodus muss eine andere Person den Entwurf veröffentlichen.</div>`;
showModal(type==='profiles'?'Profil veröffentlichen':'Modul veröffentlichen',form(field('test_evidence','Praktischer Testnachweis','',{type:'textarea',required:true,full:true,placeholder:'Testhost, Build, Datum, Ergebnisse und Referenz zum Prüfprotokoll'})+field('reason','Änderungsgrund','',{type:'textarea',required:true,full:true}),'Veröffentlichen',intro),async data=>{
await api(`/${type}/${encodeURIComponent(id)}/publish`,{method:'POST',body:{test_evidence:data.get('test_evidence'),reason:data.get('reason')}});closeModal();toast('Version veröffentlicht.');await refresh();
},'VERÖFFENTLICHUNG');
}
function inspectProfile(p) {
showModal(p.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version</dt><dd>v${esc(p.version)} ${badge(p.status)}</dd><dt>Zielbuilds</dt><dd>${esc((p.target_builds||[]).join(', ')||'')}</dd><dt>Profil-ID</dt><dd class="mono">${esc(p.id)}</dd><dt>Digest</dt><dd class="mono">${esc(p.digest||'Noch nicht veröffentlicht')}</dd></dl></div><pre class="code-block light">${esc(json({values:p.values,steps:p.steps}))}</pre><div class="form-actions">${canAuthor()?`<button class="button" data-action="version-profile" data-id="${esc(p.id)}">Neue Version</button>`:''}<button class="button" data-action="close-modal">Schließen</button></div>`,null,'PROFILDETAILS');
}
function inspectModule(m) {
showModal(m.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version / Status</dt><dd>v${esc(m.version)} ${badge(m.status)}</dd><dt>Modul-ID</dt><dd class="mono">${esc(m.id)}</dd><dt>Zielbuilds</dt><dd>${esc((m.target_builds||[]).join(', '))}</dd><dt>Digest</dt><dd class="mono">${esc(m.digest||'Noch nicht veröffentlicht')}</dd><dt>Testnachweis</dt><dd>${esc(m.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div>${m.source?`<pre class="code-block">${esc(m.source)}</pre>`:'<div class="alert alert-info">Der Quelltext ist für Skriptautoren und Administratoren sichtbar.</div>'}<details><summary>Parameterschema & Abhängigkeiten</summary><pre class="code-block light">${esc(json({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds}))}</pre></details><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULDETAILS');
}
async function isoForm() {
const groups=arr(await api('/groups'));
const fields=field('name','Medienname','',{required:true,placeholder:'Berlin · Proxmox VE'})+field('build','Exakter Proxmox ISO-Build','',{required:true,placeholder:'z. B. 9.1-1'})+field('sha256','SHA256-Prüfsumme des ISO-Mediums','',{required:true,full:true,placeholder:'64 hexadezimale Zeichen'})+field('assistant_version','Version des Auto Install Assistant','',{required:true,placeholder:'Exakte Paketversion'})+field('group_id','Bereitstellungsgruppe','',{required:true,type:'select',options:selectObjects(groups.filter(g=>!g.revoked))})+field('fingerprint','SHA256-Zertifikatsfingerprint','',{required:true,full:true,placeholder:'Fingerprint des HTTPS-Zertifikats des Antwortdienstes'})+field('test_status','Prüfstatus','draft',{type:'select',options:[{value:'draft',label:'Entwurf noch nicht freigegeben'},{value:'passed',label:'Geprüft Nachweis liegt vor'}]})+field('native_token_support','Native Unterstützung von --answer-auth-token ist nachgewiesen.',false,{type:'checkbox',required:true,full:true})+field('test_evidence','Kompatibilitätsnachweis','',{type:'textarea',full:true,placeholder:'Antwortschema, Token-Header, First Boot, Startnetz und Bootverfahren: Testhost, Datum und Prüfprotokoll.'});
showModal('ISO-Medium registrieren',form(fields,'Medium registrieren',groups.length?'':'<div class="alert">Erstellen Sie zuerst eine Bereitstellungsgruppe im Bereich Installationsmedien.</div>'),async data=>{
await api('/iso-records',{method:'POST',body:{name:data.get('name'),build:data.get('build'),sha256:data.get('sha256'),assistant_version:data.get('assistant_version'),group_id:data.get('group_id'),fingerprint:data.get('fingerprint'),test_status:data.get('test_status'),native_token_support:data.get('native_token_support')==='on',test_evidence:data.get('test_evidence')}});closeModal();toast('ISO-Medium registriert.');await refresh();
},'INSTALLATIONSMEDIUM');
}
function buildCommand(iso, token='<gruppenname>:<secret>') {
if(iso.command)return iso.command;
const shellQuote = value => "'"+String(value).replace(/'/g,"'\\''")+"'";
const base=(iso.answer_url||`${window.location.origin}/installer/v1/answer`);
return `proxmox-auto-install-assistant prepare-iso SOURCE.iso \\\n --fetch-from http \\\n --url ${shellQuote(base)} \\\n --cert-fingerprint ${shellQuote(iso.fingerprint||'<SHA256-FINGERPRINT>')} \\\n --answer-auth-token ${shellQuote(token)}`;
}
function inspectISO(iso) {
showModal(iso.name,`<div class="modal-summary"><dl class="detail-list"><dt>ISO-ID</dt><dd class="mono">${esc(iso.id)}</dd><dt>Zielbuild / Status</dt><dd>${esc(iso.build)} ${badge(iso.test_status)}</dd><dt>Assistant-Version</dt><dd>${esc(iso.assistant_version)}</dd><dt>SHA256</dt><dd class="mono">${esc(iso.sha256)}</dd><dt>Fingerprint</dt><dd class="mono">${esc(iso.fingerprint)}</dd><dt>Gruppe</dt><dd class="mono">${esc(iso.group_id)}</dd><dt>Testnachweis</dt><dd>${esc(iso.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div><h3 class="section-label">Vorbereiteter Buildbefehl</h3><p class="small-text muted">SOURCE.iso und den Token-Platzhalter durch Ihre Eingaben ersetzen. Die URL muss aus dem Provisionierungsnetz per HTTPS erreichbar sein.</p><textarea class="code" id="copy-value" readonly aria-label="Buildbefehl">${esc(buildCommand(iso))}</textarea><div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}Befehl kopieren</button><button class="button" data-action="close-modal">Schließen</button></div>`,null,'ISO-DETAILS');
}
function groupForm() {
showModal('Bereitstellungsgruppe erstellen',form(field('name','Gruppenname','',{required:true,placeholder:'berlin-rack-a'})+field('site','Standort','',{required:true,placeholder:'Rechenzentrum Berlin'})+field('valid_hours','Gültigkeit in Stunden',24,{type:'number',required:true,min:1,max:8760,full:true}),'Token erstellen'),async data=>{
const result=await api('/groups',{method:'POST',body:{name:data.get('name'),site:data.get('site'),valid_hours:Number(data.get('valid_hours'))}});
showModal('Gruppentoken erstellt',`<div class="alert">Der vollständige Token wird nur jetzt angezeigt. Speichern Sie ihn für die ISO-Vorbereitung. Er ist auf die Bereitstellungsgruppe und deren Gültigkeitsfenster beschränkt.</div><dl class="detail-list"><dt>Gruppe</dt><dd>${esc(result.name)}</dd><dt>Referenz-ID</dt><dd class="mono">${esc(result.id)}</dd><dt>Gültig bis</dt><dd>${esc(fmtDate(result.expires_at))}</dd></dl><h3 class="section-label">Token für --answer-auth-token</h3><textarea id="copy-value" class="code token-value" readonly aria-label="Einmalig angezeigter Gruppentoken">${esc(result.token)}</textarea>${result.command?`<h3 class="section-label">Buildbefehl</h3><pre class="code-block light">${esc(result.command)}</pre>`:''}<div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}Token kopieren</button><button class="button primary" data-action="close-modal">Token gesichert</button></div>`,null,'EINMALIGE TOKENAUSGABE');
await refresh();
},'ISO-ZUGRIFF');
}
function userForm() {
showModal('Benutzer anlegen',form(field('username','Benutzername','',{required:true,autocomplete:'off'})+field('role','Rolle','reader',{type:'select',options:Object.entries(roleNames).map(([value,label])=>({value,label}))})+field('password','Initiales Passwort','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Mindestens 12 Zeichen verwenden.'}),'Benutzer anlegen'),async data=>{
await api('/users',{method:'POST',body:{username:data.get('username'),password:data.get('password'),role:data.get('role')}});closeModal();toast('Benutzerkonto angelegt.');await refresh();
},'BENUTZER & ROLLEN');
}
function secretForm() {
showModal('Geheimnis hinterlegen',form(field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Hash · PVE Berlin'})+field('value','Geheimniswert','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für den Root-Zugang einen von Ihrem Zielbuild unterstützten Passwort-Hash verwenden.'}),'Verschlüsselt speichern','<div class="alert alert-info">Verwenden Sie die nach dem Speichern angezeigte Referenz-ID im Profil. Der Geheimniswert wird nicht erneut ausgegeben.</div>'),async data=>{
const result=await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value')}});closeModal();toast('Geheimnis verschlüsselt gespeichert.');state.settingsTab='secrets';await refresh();
if(result?.id)showModal('Geheimnis gespeichert',`<p class="small-text muted">Referenz für <code>root_secret_id</code> oder einen Schritt:</p><input id="copy-value" value="${esc(result.id)}" readonly aria-label="Geheimnisreferenz"><div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}ID kopieren</button><button class="button primary" data-action="close-modal">Fertig</button></div>`);
},'BETRIEBSGEHEIMNIS');
}
async function runAction(id, action) {
const run=await api(`/runs/${encodeURIComponent(id)}`), resume=action==='resume';
showModal(resume?'Lauf wiederaufnehmen':'Lauf abbrechen',form(field('reason',resume?'Begründung und durchgeführte Prüfung':'Abbruchgrund','',{type:'textarea',full:true,required:true}),resume?'Wiederaufnahme anfordern':'Abbruch anfordern',`<div class="alert${resume?' alert-info':''}">${resume?'Der Runner prüft gespeicherte Checkpoints und Modulzustände vor weiteren Änderungen. Unklare, nicht wiederholbare Schritte benötigen eine manuelle Klärung.':'Der Runner stoppt am nächsten sicheren Übergang. Eine bereits gestartete Datenträgeroperation oder ein Paketmanager wird dadurch nicht rückgängig gemacht.'}</div>`),async data=>{
await api(`/runs/${encodeURIComponent(id)}/${action}`,{method:'POST',body:{reason:data.get('reason'),expected_version:run.version}});closeModal();toast(resume?'Wiederaufnahme angefordert.':'Abbruch angefordert.');await refresh();
},`LAUF ${shortId(id)}`);
}
async function reconcileRun(id) {
const run=await api(`/runs/${encodeURIComponent(id)}`);
const host=await api(`/hosts/${encodeURIComponent(run.host_id)}`);
const fields=field('reason','Durchgeführte Prüfung und Abgleichgrund','',{type:'textarea',full:true,required:true})+field('confirmation','Hostnamen zur Bestätigung eingeben','',{required:true,full:true,placeholder:host.fqdn})+field('execution_stopped','Ich habe am Host geprüft, dass Installer und Runner gestoppt sind.',false,{type:'checkbox',full:true,required:true});
showModal('Lauf manuell abgleichen',form(fields,'Lauf als abgebrochen schließen',`<div class="alert">Dieser Abgleich schließt einen aufgegebenen oder nach Wiederherstellung unklaren Lauf. Prüfen Sie zuvor direkt am Host, dass keine Ausführung mehr stattfindet. Bereits erteilte Laufberechtigungen und das Antwortfenster müssen abgelaufen sein. Für eine neue Installation ist eine neue Freigabe erforderlich.</div>`),async data=>{
if(data.get('confirmation')!==host.fqdn)throw new Error('Der eingegebene Hostname stimmt nicht mit dem Server überein.');
await api(`/runs/${encodeURIComponent(id)}/reconcile`,{method:'POST',body:{expected_version:run.version,reason:data.get('reason'),confirmation:data.get('confirmation'),execution_stopped:data.get('execution_stopped')==='on'}});closeModal();toast('Lauf abgeglichen und als abgebrochen geschlossen.');await refresh();
},host.fqdn);
}
async function renderRoute(showLoading=true) {
const route=window.location.hash.replace(/^#\/?/,'').split('/');
const pages=['dashboard','hosts','installation','postinstall','modules','runs','media','audit','settings'];
state.page=pages.includes(route[0])?route[0]:'dashboard';state.id=route[1]?decodeURIComponent(route[1]):null;
const version=++state.routeVersion;
const labels={dashboard:'Übersicht',hosts:'Serverinventar',installation:'Installationsprofile',postinstall:'Postinstallation',modules:'Skriptmodule',runs:'Installationsläufe',media:'Installationsmedien',audit:'Auditprotokoll',settings:'Einstellungen'};
document.getElementById('breadcrumb').textContent=labels[state.page];document.title=`${labels[state.page]} · Proxmox AIS`;
document.querySelectorAll('[data-nav]').forEach(a=>{a.classList.toggle('active',a.dataset.nav===state.page);if(a.dataset.nav===state.page)a.setAttribute('aria-current','page');else a.removeAttribute('aria-current');});
if(showLoading)main.innerHTML='<div class="loading-screen"><span class="spinner"></span> Daten werden geladen …</div>';
try {
let output, data;
if(state.page==='dashboard'){data=await api('/dashboard');output=dashboard(data);}
if(state.page==='hosts'){if(state.id){data=await api(`/hosts/${encodeURIComponent(state.id)}`);output=hostPage(data);}else{const results=await Promise.all([api('/hosts'),api('/discoveries')]);data={hosts:arr(results[0]),discoveries:arr(results[1])};output=hostsPage(data.hosts,data.discoveries);}}
if(['installation','postinstall'].includes(state.page)){data=arr(await api('/profiles'));output=profilesPage(data,state.page);}
if(state.page==='modules'){data=arr(await api('/modules'));output=modulesPage(data);}
if(state.page==='runs'){data=await api(state.id?`/runs/${encodeURIComponent(state.id)}`:'/runs');output=state.id?runPage(data):runsPage(arr(data));}
if(state.page==='media'){const result=await Promise.all([api('/iso-records'),(canOperate()||canAuthor())?api('/groups'):Promise.resolve([])]);data={records:arr(result[0]),groups:arr(result[1])};output=mediaPage(data.records,data.groups);}
if(state.page==='audit'){data=arr(await api('/audit'));output=auditPage(data);}
if(state.page==='settings'){data=canAdmin()?await Promise.all([api('/users'),api('/secrets')]):[[],[]];output=settingsPage(arr(data[0]),arr(data[1]));}
if(version!==state.routeVersion)return;
state.data=data; main.innerHTML=output;
if(state.page==='media'&&!canOperate()&&!canAuthor())main.querySelector('.groups-card')?.remove();
applyFilters();
document.getElementById('connection').className='connection';document.getElementById('connection').innerHTML='<span class="status-dot"></span> Verbunden';
}catch(error){
if(version!==state.routeVersion)return;
document.getElementById('connection').className='connection disconnected';document.getElementById('connection').innerHTML='<span class="status-dot"></span> Abruf fehlgeschlagen';
if(showLoading)main.innerHTML=header('Daten konnten nicht geladen werden','Prüfen Sie Ihre Verbindung und Zugriffsrechte.',actionButton('Erneut versuchen','refresh','refresh'))+`<div class="alert alert-danger" role="alert">${esc(error.message)}</div>`;
else throw error;
}
}
async function refresh() {
if(state.refreshing)return;
state.refreshing=true;
const filters=['list-search','site-filter','status-filter'].map(id=>[id,document.getElementById(id)?.value]);
try{await renderRoute(false);for(const [id,value]of filters){const el=document.getElementById(id);if(el&&value!==undefined)el.value=value;}applyFilters();}finally{state.refreshing=false;}
}
async function handleAction(button) {
const action=button.dataset.action,id=button.dataset.id;
if(action==='close-modal'){closeModal();return;}
if(action==='menu'){const open=document.getElementById('sidebar').classList.toggle('open');button.setAttribute('aria-expanded',String(open));return;}
if(action==='logout'){await api('/auth/logout',{method:'POST'});window.location.href='/login';return;}
if(action==='refresh'){await refresh();return;}
if(action==='create-host'){await hostForm();return;}
if(action==='import-hosts'){hostImportForm();return;}
if(action==='assign-discovery'){await hostForm(null,state.data.discoveries.find(d=>d.id===id));return;}
if(action==='edit-host'){await hostForm(await api(`/hosts/${encodeURIComponent(id)}`));return;}
if(action==='approve-host'||action==='preview-host'){await approveHost(id,action==='preview-host');return;}
if(action==='toggle-host'){
const host=await api(`/hosts/${encodeURIComponent(id)}`);
await api(`/hosts/${encodeURIComponent(id)}`,{method:'PATCH',body:{expected_version:host.version,blocked:!host.blocked}});toast(host.blocked?'Host entsperrt.':'Host gesperrt.');await refresh();return;
}
if(action==='create-profile'){await profileForm(button.dataset.kind);return;}
if(action==='version-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);await profileForm(p.kind,p);return;}
if(action==='view-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);inspectProfile(p);return;}
if(action==='publish-profile'){await publishObject('profiles',id);return;}
if(action==='create-module'){moduleForm();return;}
if(action==='module-catalog'){await moduleCatalog();return;}
if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);moduleForm({...selected,dependencies:selected.dependencies.map(name=>state.catalog.find(m=>m.id===name)?.name||name),version:0});return;}
if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')moduleForm(m);else inspectModule(m);return;}
if(action==='publish-module'){await publishObject('modules',id);return;}
if(action==='create-iso'){await isoForm();return;}
if(action==='view-iso'){inspectISO(state.data.records.find(x=>x.id===id));return;}
if(action==='create-group'){groupForm();return;}
if(action==='create-user'){userForm();return;}
if(action==='create-secret'){secretForm();return;}
if(action==='settings-tab'){state.settingsTab=button.dataset.tab;await renderRoute(false);return;}
if(action==='run-tab'){state.runTab=button.dataset.tab;main.innerHTML=runPage(state.data);return;}
if(action==='resume-run'||action==='cancel-run'){await runAction(id,action==='resume-run'?'resume':'cancel');return;}
if(action==='reconcile-run'){await reconcileRun(id);return;}
if(action==='copy-value'){
const el=document.getElementById('copy-value');
try{await navigator.clipboard.writeText(el.value);toast('In die Zwischenablage kopiert.');}catch{el.focus();el.select();toast('Text markiert. Mit Strg+C kopieren.');}return;
}
if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`<pre class="code-block light">${esc(json(e))}</pre><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,e?.action||'AUDIT');}
}
document.addEventListener('click',async event=>{
const button=event.target.closest('[data-action]');
if(button){event.preventDefault();if(button.disabled)return;button.disabled=true;try{await handleAction(button);}catch(error){toast(error.message,true);}finally{button.disabled=false;}}
if(event.target.closest('[data-nav]')){document.getElementById('sidebar').classList.remove('open');document.querySelector('[data-action="menu"]').setAttribute('aria-expanded','false');}
});
document.addEventListener('input',event=>{if(event.target.id==='list-search')applyFilters();});
document.addEventListener('change',event=>{if(['site-filter','status-filter'].includes(event.target.id))applyFilters();});
document.addEventListener('submit',async event=>{
if(event.target.id!=='modal-form')return;
event.preventDefault();if(!modalSubmit)return;
const formEl=event.target, submit=formEl.querySelector('[type="submit"]'),errorEl=formEl.querySelector('.form-error');
submit.disabled=true;errorEl.textContent='';
try{await modalSubmit(new FormData(formEl));}catch(error){errorEl.textContent=error.message;errorEl.scrollIntoView({block:'nearest'});}finally{submit.disabled=false;}
});
modal.addEventListener('cancel',()=>{modalSubmit=null;document.getElementById('modal-body').replaceChildren();});
window.addEventListener('hashchange',()=>{renderRoute();});
async function init() {
document.querySelectorAll('[data-icon]').forEach(el=>el.insertAdjacentHTML('afterbegin',svg(el.dataset.icon)));
try{
state.me=await api('/me');
document.getElementById('username').textContent=state.me.username;
document.getElementById('user-role').textContent=roleNames[state.me.role]||state.me.role;
document.getElementById('avatar').textContent=state.me.username.slice(0,2).toUpperCase();
await renderRoute();
setInterval(async()=>{
if(document.hidden||modal.open||state.refreshing||['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)||!['dashboard','runs','hosts'].includes(state.page))return;
try{await refresh();}catch{/* The connection indicator reports refresh failures. */}
},15000);
}catch(error){main.innerHTML=`<div class="alert alert-danger" role="alert">${esc(error.message)}</div>`;}
}
init();
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<p class="affiliation-notice">Proxmox AIS ist ein unabhängiges Projekt und steht in keiner Verbindung zur Proxmox Server Solutions GmbH oder den Entwicklern von Proxmox Virtual Environment.</p>
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Proxmox AIS · Provisionierung</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="Hauptnavigation">
<a class="brand" href="#/dashboard"><span class="brand-mark">a<span>i</span>s</span><span>PROXMOX<span class="brand-sub">PROVISIONING CONSOLE</span></span></a>
<div class="workspace-label"><span class="workspace-dot"></span> Infrastruktur <span class="workspace-version">v1</span></div>
<nav class="nav-list">
<span class="nav-caption">VERWALTUNG</span>
<a href="#/dashboard" data-nav="dashboard" data-icon="dashboard">Übersicht</a>
<a href="#/hosts" data-nav="hosts" data-icon="server">Serverinventar</a>
<a href="#/runs" data-nav="runs" data-icon="activity">Installationsläufe</a>
<span class="nav-caption">KONFIGURATION</span>
<a href="#/installation" data-nav="installation" data-icon="layers">Installationsprofile</a>
<a href="#/postinstall" data-nav="postinstall" data-icon="workflow">Postinstallation</a>
<a href="#/modules" data-nav="modules" data-icon="code">Skriptmodule</a>
<a href="#/media" data-nav="media" data-icon="disc">Installationsmedien</a>
<span class="nav-caption">SYSTEM</span>
<a href="#/audit" data-nav="audit" data-icon="shield">Auditprotokoll</a>
<a href="#/settings" data-nav="settings" data-icon="settings">Einstellungen</a>
</nav>
<div class="sidebar-bottom"><div class="sidebar-status"><span class="status-dot"></span> Zentrale Provisionierung</div><p>Automatisch installieren.<br>Kontrolliert konfigurieren.</p><a href="/openapi.json" target="_blank" rel="noopener">OpenAPI-Spezifikation <span aria-hidden="true"></span></a></div>
</aside>
<div class="main-shell">
<header class="topbar">
<button class="icon-button mobile-menu" type="button" data-action="menu" aria-label="Navigation öffnen" aria-controls="sidebar" aria-expanded="false"></button>
<div class="breadcrumb">Workspace <span>/</span> <strong id="breadcrumb">Übersicht</strong></div>
<div class="topbar-right"><span class="connection" id="connection"><span class="status-dot"></span> Verbinden …</span><span class="topbar-divider"></span><div class="avatar" id="avatar"></div><div class="user-info"><strong id="username">{{ user.username }}</strong><span id="user-role"></span></div><button class="icon-button" type="button" data-action="logout" aria-label="Abmelden" title="Abmelden"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 4H4v16h5M14 8l4 4-4 4M8 12h10"/></svg></button></div>
</header>
<main id="main" tabindex="-1"><div class="loading-screen"><span class="spinner"></span> Konsole wird geladen …</div></main>
<footer class="main-footer"><span>Proxmox AIS <span class="muted">/</span> Automated Installation Service</span><span>Versionierte Konfiguration. Nachvollziehbare Ausführung.</span>{% include "affiliation_notice.html" %}</footer>
</div>
</div>
<dialog id="modal" aria-labelledby="modal-title"><div class="modal-header"><div><span class="eyebrow" id="modal-eyebrow">PROXMOX AIS</span><h2 id="modal-title"></h2></div><button class="icon-button" type="button" data-action="close-modal" aria-label="Dialog schließen">×</button></div><div id="modal-body"></div></dialog>
<div id="toast-region" class="toast-region" role="status" aria-live="polite"></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Anmelden · Proxmox AIS</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-page">
<main class="login-layout">
<section class="login-story"><a class="brand" href="/"><span class="brand-mark">a<span>i</span>s</span><span>PROXMOX<span class="brand-sub">PROVISIONING CONSOLE</span></span></a><div class="login-intro"><span class="eyebrow">AUTOMATED INSTALLATION SERVICE</span><h1>Infrastruktur.<br>Mit System.</h1><p>Von der ersten Installation bis zur geprüften Konfiguration. Ihre Proxmox-Server, zentral verwaltet.</p><div class="login-flow"><span>01 <strong>Vorbereiten</strong></span><span>02 <strong>Freigeben</strong></span><span>03 <strong>Provisionieren</strong></span></div></div><div class="login-footnote"><span class="status-dot"></span> Kontrollierte Abläufe · Fixierte Versionen · Klare Zustände</div></section>
<section class="login-form-panel"><form class="login-form" method="post" action="/auth/login"><div class="login-emblem"></div><span class="eyebrow">WILLKOMMEN ZURÜCK</span><h2>An der Konsole anmelden</h2><p class="muted">Verwenden Sie Ihr lokales Benutzerkonto.</p>{% if error %}<div class="alert alert-danger" role="alert">{{ error }}</div>{% endif %}<label>Benutzername<input name="username" autocomplete="username" required autofocus placeholder="Benutzername"></label><label>Passwort<input name="password" type="password" autocomplete="current-password" required placeholder="Ihr Passwort"></label><button class="button primary login-submit" type="submit">Anmelden <span aria-hidden="true"></span></button><p class="login-help">Konten und Zugriffsrechte werden durch Ihren Administrator verwaltet.</p></form>{% include "affiliation_notice.html" %}<div class="login-copyright">Proxmox AIS <span>Automated Installation Service</span></div></section>
</main>
</body>
</html>