diff --git a/README.md b/README.md index 593c35a..fa0cd1c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Wichtige Env-Variablen (`/etc/tuxflotte/provisioning-server.env` im Betrieb): produktiven De-Root-Setup stattdessen explizit auf einen für den `provisioning-server`-User lesbaren Pfad setzen, z.B. `/opt/tuxflotte-installer`) -- `TUXFLOTTE_MINT_ISO_PATH`, `TUXFLOTTE_ISO_BUILD_OUTPUT_DIR` - relativ zum +- `TUXFLOTTE_BOOT_MEDIUM_PATH` (früher `TUXFLOTTE_MINT_ISO_PATH`), `TUXFLOTTE_ISO_BUILD_OUTPUT_DIR` - relativ zum Arbeitsverzeichnis des Dienstes (`data/upstream/...`, `data/iso-builds`) Start: `.venv/bin/uvicorn app:app --port 8080` diff --git a/app.py b/app.py index a2d99b0..061f74e 100644 --- a/app.py +++ b/app.py @@ -46,15 +46,22 @@ STANDARD_OE_NAME = "Neue Geräte" # Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/ # build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode -# geklontes Repo (nicht Teil von provisioning-server selbst), MINT_ISO_PATH -# das einmalig von der offiziellen Downloadseite geholte Quellabbild. +# geklontes Repo (nicht Teil von provisioning-server selbst), BOOT_MEDIUM_PATH +# das eigene, distributionsunabhaengige Boot-Medium (build_boot_medium.sh + +# publish_boot_medium.sh), aus dem JEDE Kunden-ISO gebaut wird - egal ob Mint, +# Debian oder Ubuntu. Hiess frueher MINT_ISO_PATH (aus der Zeit, als die +# Mint-Live-ISO gepatcht wurde); die alte Variable gilt als Rueckfallebene. TUXFLOTTE_INSTALLER_DIR = os.environ.get("TUXFLOTTE_INSTALLER_DIR", "/root/tuxflotte-installer") # Absolut aufloesen: run_iso_build() startet build_customer_iso.sh mit # cwd=TUXFLOTTE_INSTALLER_DIR, ein relativer Pfad wuerde also dort statt # relativ zum eigenen Arbeitsverzeichnis (WorkingDirectory des Service) # gesucht - genau das hat den ersten Live-Test auf anode fehlschlagen lassen. -MINT_ISO_PATH = str(Path(os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstream/linuxmint-22.3-cinnamon-64bit.iso")).resolve()) -# Aus demselben Grund wie MINT_ISO_PATH absolut aufloesen: der als $2 an +BOOT_MEDIUM_PATH = str(Path( + os.environ.get("TUXFLOTTE_BOOT_MEDIUM_PATH") + or os.environ.get("TUXFLOTTE_MINT_ISO_PATH") + or "data/upstream/tuxflotte-boot-medium.iso" +).resolve()) +# Aus demselben Grund wie BOOT_MEDIUM_PATH absolut aufloesen: der als $2 an # build_customer_iso.sh uebergebene Ausgabepfad wird von xorriso relativ # zum Subprocess-cwd (TUXFLOTTE_INSTALLER_DIR) interpretiert, nicht relativ # zum eigenen Arbeitsverzeichnis. @@ -275,6 +282,7 @@ class CreateEnrollmentSessionRequest(BaseModel): bereitstellungsvorlage_id: str max_devices: int expires_at: str + neuinstallation_ohne_rueckfrage: bool = False class RechargeEnrollmentSessionRequest(BaseModel): @@ -289,6 +297,8 @@ class CreateIsoBuildRequest(BaseModel): # Diagnosezugang (02.10.2026): nur der SHA-512-crypt-Hash, nie das # Passwort selbst - das kennt allein die Kundenplattform (Root-Admin). root_password_hash: str | None = None + # Offline-Medium (Roadmap V5): Golden Image auf die ISO legen. + offline_medium: bool = False class CreateOrganisationseinheitRequest(BaseModel): @@ -609,7 +619,8 @@ def find_enrollment_session(code: str): cur.execute( """ SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id, - es.max_devices, es.devices_used, es.expires_at, es.revoked_at + es.max_devices, es.devices_used, es.expires_at, es.revoked_at, + es.neuinstallation_ohne_rueckfrage FROM enrollment_sessions es JOIN organizations o ON o.id = es.organization_id WHERE es.id = %s @@ -623,7 +634,7 @@ def find_enrollment_session(code: str): ( session_id, organization_id, organization_name, bereitstellungsvorlage_id, - max_devices, devices_used, expires_at, revoked_at, + max_devices, devices_used, expires_at, revoked_at, neuinstallation_ohne_rueckfrage, ) = row if revoked_at is not None: @@ -638,6 +649,7 @@ def find_enrollment_session(code: str): "organization_id": str(organization_id), "organization_name": organization_name, "bereitstellungsvorlage_id": str(bereitstellungsvorlage_id), + "neuinstallation_ohne_rueckfrage": bool(neuinstallation_ohne_rueckfrage), } def consume_enrollment_session(session_id: str) -> None: @@ -3481,7 +3493,10 @@ def fetch_enrollment_sessions(organization_id: str): ) in cur.fetchall() ] -def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str): +def create_enrollment_session( + organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str, + neuinstallation_ohne_rueckfrage: bool = False, +): session_id = uuid4() with get_database_connection() as conn: @@ -3489,10 +3504,12 @@ def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: s cur.execute( """ INSERT INTO enrollment_sessions - (id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at) - VALUES (%s, %s, %s, %s, %s) + (id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at, + neuinstallation_ohne_rueckfrage) + VALUES (%s, %s, %s, %s, %s, %s) """, - (session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at), + (session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at, + neuinstallation_ohne_rueckfrage), ) return {"id": str(session_id)} @@ -3748,10 +3765,11 @@ def compute_file_sha256(path) -> str: return hasher.hexdigest() -def fetch_vorlage_label_for_session(session_id: str) -> str | None: +def fetch_vorlage_for_session(session_id: str) -> tuple[str | None, str | None]: """ - Name der Bereitstellungsvorlage einer Enrollment Session - fuer den - Boot-Menue-Eintrag der Kunden-ISO (build_customer_iso.sh, VORLAGE_LABEL). + (Name der Bereitstellungsvorlage, Backend-Key) einer Enrollment Session - + fuer den Boot-Menue-Eintrag (VORLAGE_LABEL) und das Offline-Medium + (passendes Golden Image) in build_customer_iso.sh. """ try: @@ -3759,19 +3777,40 @@ def fetch_vorlage_label_for_session(session_id: str) -> str | None: with conn.cursor() as cur: cur.execute( """ - SELECT bv.label + SELECT bv.label, b.key FROM enrollment_sessions es JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id + JOIN backends b ON b.id = bv.backend_id WHERE es.id = %s """, (session_id,), ) row = cur.fetchone() except Exception: - # Rein kosmetisch - ein Fehler hier darf den ISO-Bau nie verhindern. - return None + # Rein unterstuetzend - ein Fehler hier darf den ISO-Bau nie verhindern. + return None, None - return row[0] if row else None + return (row[0], row[1]) if row else (None, None) + + +def golden_image_path_for_backend(backend_key: str | None) -> Path | None: + """ + Pfad des Golden Images eines Image-Backends. Der Dateiname steht nur an + einer Stelle - GOLDEN_IMAGE_FILENAME im jeweiligen backend.sh des + Installers -, deshalb wird er dort gelesen statt hier dupliziert. + """ + + if not backend_key or not re.fullmatch(r"[a-z0-9-]+", backend_key): + return None + backend_file = Path(TUXFLOTTE_INSTALLER_DIR) / "backends" / backend_key / "backend.sh" + try: + match = re.search(r'^GOLDEN_IMAGE_FILENAME="([^"/]+)"', backend_file.read_text(), re.MULTILINE) + except OSError: + return None + if not match: + return None + path = GOLDEN_IMAGES_DIR / match.group(1) + return path if path.is_file() else None ROOT_PASSWORD_HASH_PATTERN = re.compile(r"^\$6\$[./A-Za-z0-9]{1,16}\$[./A-Za-z0-9]{86}$") @@ -3780,6 +3819,7 @@ ROOT_PASSWORD_HASH_PATTERN = re.compile(r"^\$6\$[./A-Za-z0-9]{1,16}\$[./A-Za-z0- def run_iso_build( build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None, root_password_hash: str | None = None, + offline_medium: bool = False, ) -> None: output_path = ISO_BUILD_OUTPUT_DIR / f"{build_id}.iso" @@ -3787,7 +3827,18 @@ def run_iso_build( organization = fetch_organization(organization_id) volid = compute_iso_volid(organization["name"] if organization else "TUXFLOTTE") - vorlage_label = (fetch_vorlage_label_for_session(activation_code) or "").replace("\n", " ") + vorlage_label, backend_key = fetch_vorlage_for_session(activation_code) + vorlage_label = (vorlage_label or "").replace("\n", " ") + golden_image_file = "" + if offline_medium: + golden_image = golden_image_path_for_backend(backend_key) + if golden_image is None: + update_iso_build( + build_id, status="failed", mark_finished=True, + error_message=f"Offline-Medium: kein Golden Image für Backend '{backend_key}' gefunden.", + ) + return + golden_image_file = str(golden_image) try: # De-Root-Umzug (07.09.2026): der Dienst selbst laeuft nicht mehr als @@ -3801,7 +3852,7 @@ def run_iso_build( "sudo", "bash", f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh", - MINT_ISO_PATH, + BOOT_MEDIUM_PATH, str(output_path), activation_code, wifi_ssid or "", @@ -3815,6 +3866,7 @@ def run_iso_build( f"WIFI_PSK={wifi_psk or ''}\n" f"ROOT_PASSWORD_HASH={root_password_hash or ''}\n" f"VORLAGE_LABEL={vorlage_label}\n" + f"GOLDEN_IMAGE_FILE={golden_image_file}\n" ), cwd=TUXFLOTTE_INSTALLER_DIR, capture_output=True, @@ -3983,6 +4035,13 @@ def activate(payload: ActivationRequest, request: Request): "previously_installed": ( False if device_created else has_completed_installation(device["id"]) ), + # Kunden-ISO-Test 03.10.2026: pro Installationsmedium abschaltbare + # Rueckfrage bei bereits installierten Geraeten (siehe + # 25_installation_confirm.sh) - fuer das bewusste Neuaufsetzen + # ganzer Raeume. + "reinstall_without_confirmation": bool( + enrollment_session and enrollment_session.get("neuinstallation_ohne_rueckfrage") + ), }, "customer": { @@ -5022,7 +5081,8 @@ def create_enrollment_session_endpoint( return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} session = create_enrollment_session( - organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at + organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at, + payload.neuinstallation_ohne_rueckfrage, ) return {"success": True, "enrollment_session": session} @@ -5095,7 +5155,7 @@ def create_iso_build_endpoint( target=run_iso_build, args=( UUID(iso_build["id"]), organization_id, payload.activation_code, payload.wifi_ssid, payload.wifi_psk, - payload.root_password_hash, + payload.root_password_hash, payload.offline_medium, ), daemon=True, ) diff --git a/migrations/0047_enrollment_neuinstallation_ohne_rueckfrage.sql b/migrations/0047_enrollment_neuinstallation_ohne_rueckfrage.sql new file mode 100644 index 0000000..7db6141 --- /dev/null +++ b/migrations/0047_enrollment_neuinstallation_ohne_rueckfrage.sql @@ -0,0 +1,11 @@ +BEGIN; + +-- Kunden-ISO-Test 03.10.2026: ein bereits fertig installiertes Geraet fragt +-- beim erneuten Booten des Installationsmediums ohne Zeitlimit nach +-- ("Installation jetzt starten? [j/N]") - richtig als Schutz, aber +-- hinderlich, wenn ein ganzer Raum bewusst neu aufgesetzt werden soll. Pro +-- Installationsmedium (Enrollment Session) abschaltbar. +ALTER TABLE enrollment_sessions + ADD COLUMN neuinstallation_ohne_rueckfrage BOOLEAN NOT NULL DEFAULT FALSE; + +COMMIT;