From 2190d6f40317f79c25f7edef0e3fa17795c25703 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Mon, 31 Aug 2026 13:52:38 +0200 Subject: [PATCH] feat(boot-medium): Tuxflotte-Nutzlast ins Boot-Medium einbauen (Phase 1) scripts/ (installer.sh, lib/*, modules/00-99) und backends/mint-image/ (inkl. echtem postinstall.sh statt Symlink auf backends/mint/) werden ueber config/includes.chroot/opt/tuxflotte/ ins Image kopiert - bewusst nur die zur Laufzeit benoetigten Dateien, nicht die Build-Host-Werkzeuge (build_customer_iso.sh, build_golden_image.sh, package_golden_image.sh, lib/initrd.sh etc. bleiben aussen vor). 00_preflight.sh und backend_init() (backends/mint-image/backend.sh) verlieren ihre Laufzeit-apt-get-Nachinstallation (jq, parted, dosfstools, e2fsprogs, zstd, btrfs-progs, gettext-base, curl) - alles bereits in Phase 0 vorinstalliert. Werden durch reine Assertions ersetzt, die frueh und klar melden, falls die Paketliste doch mal luecken sollte. backends/mint/postinstall.sh nach backends/mint-image/postinstall.sh als echte Datei verschoben (war Symlink) - noetig, weil backends/mint/ in Phase 4 komplett entfernt wird. Live verifiziert in der enterprise-QEMU-VM: installer.sh von Hand gestartet, Module 00/05/10/12/15/17 liefen sauber durch, inkl. echtem Server-Kontakt zu anode und echter Geraeteregistrierung (Liebherr-Org), Commit-Gate erschien interaktiv wie erwartet, kontrollierter Abbruch ohne jede destruktive Aktion. --- .gitignore | 4 + backends/mint-image/backend.sh | 38 +- backends/mint-image/postinstall.sh | 73 ++- boot-medium/config/bootstrap | 2 +- .../tuxflotte/backends/mint-image/backend.sh | 286 +++++++++ .../backends/mint-image/postinstall.sh | 72 +++ .../opt/tuxflotte/config/installer.conf | 0 .../opt/tuxflotte/scripts/installer.sh | 84 +++ .../opt/tuxflotte/scripts/lib/checks.sh | 17 + .../opt/tuxflotte/scripts/lib/errors.sh | 8 + .../scripts/lib/hardware_collectors.sh | 61 ++ .../opt/tuxflotte/scripts/lib/image_deploy.sh | 256 ++++++++ .../opt/tuxflotte/scripts/lib/logging.sh | 17 + .../opt/tuxflotte/scripts/lib/utils.sh | 40 ++ .../tuxflotte/scripts/modules/00_preflight.sh | 37 ++ .../tuxflotte/scripts/modules/05_network.sh | 544 +++++++++++++++++ .../tuxflotte/scripts/modules/10_hardware.sh | 550 ++++++++++++++++++ .../scripts/modules/12_enrollment_auth.sh | 79 +++ .../scripts/modules/15_server_handshake.sh | 156 +++++ .../scripts/modules/17_device_status.sh | 133 +++++ .../scripts/modules/20_profile_selection.sh | 159 +++++ .../tuxflotte/scripts/modules/20_storage.sh | 37 ++ .../modules/25_installation_confirm.sh | 119 ++++ .../scripts/modules/30_runtime_blueprint.sh | 119 ++++ .../tuxflotte/scripts/modules/40_backend.sh | 70 +++ .../tuxflotte/scripts/modules/99_finish.sh | 0 scripts/modules/00_preflight.sh | 45 +- 27 files changed, 2961 insertions(+), 45 deletions(-) mode change 120000 => 100644 backends/mint-image/postinstall.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/backend.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/postinstall.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/config/installer.conf create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/installer.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/checks.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/errors.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/hardware_collectors.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/image_deploy.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/logging.sh create mode 100644 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/utils.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/00_preflight.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/05_network.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/10_hardware.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/12_enrollment_auth.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/15_server_handshake.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/17_device_status.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_profile_selection.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_storage.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/25_installation_confirm.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/30_runtime_blueprint.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/40_backend.sh create mode 100755 boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/99_finish.sh diff --git a/.gitignore b/.gitignore index 8c1a35e..ce14514 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ boot-medium/*.img boot-medium/*.contents boot-medium/*.files boot-medium/*.packages +boot-medium/*.zsync +boot-medium/binary.modified_timestamps +boot-medium/chroot.packages.install +boot-medium/chroot.packages.live # Editor/system files *~ diff --git a/backends/mint-image/backend.sh b/backends/mint-image/backend.sh index 7191216..b4e9aa2 100644 --- a/backends/mint-image/backend.sh +++ b/backends/mint-image/backend.sh @@ -64,27 +64,27 @@ _mint_image_detect_target_disk() { } backend_init() { - local live_packages_needed=() + # Historisch (bis zum Umstieg auf das eigenstaendige, per live-build + # gebaute Boot-Medium) wurden diese Werkzeuge hier noch zur Laufzeit per + # apt-get nachinstalliert, weil das damalige Boot-Medium (eine gepatchte + # Linux-Mint-Live-ISO) sie nicht immer mitbrachte. Das eigenstaendige + # Boot-Medium bringt sie bereits im Paketsatz mit (siehe + # boot-medium/config/package-lists/tuxflotte.list.chroot) - hier bleibt + # nur noch eine reine Assertion, damit ein kuenftiger Paketlisten-Fehler + # fruh und klar auffaellt. + local missing=() - command -v jq >/dev/null 2>&1 || live_packages_needed+=(jq) - command -v envsubst >/dev/null 2>&1 || live_packages_needed+=(gettext-base) - command -v parted >/dev/null 2>&1 || live_packages_needed+=(parted) - command -v mkfs.vfat >/dev/null 2>&1 || live_packages_needed+=(dosfstools) - command -v mkfs.ext4 >/dev/null 2>&1 || live_packages_needed+=(e2fsprogs) - command -v zstd >/dev/null 2>&1 || live_packages_needed+=(zstd) - command -v curl >/dev/null 2>&1 || live_packages_needed+=(curl) + command -v jq >/dev/null 2>&1 || missing+=(jq) + command -v envsubst >/dev/null 2>&1 || missing+=(gettext-base) + command -v parted >/dev/null 2>&1 || missing+=(parted) + command -v mkfs.vfat >/dev/null 2>&1 || missing+=(dosfstools) + command -v mkfs.ext4 >/dev/null 2>&1 || missing+=(e2fsprogs) + command -v mkfs.btrfs >/dev/null 2>&1 || missing+=(btrfs-progs) + command -v zstd >/dev/null 2>&1 || missing+=(zstd) + command -v curl >/dev/null 2>&1 || missing+=(curl) - if [[ "${#live_packages_needed[@]}" -gt 0 ]]; then - backend_log "Werkzeuge fehlen auf dem Live-Medium, installiere nach: ${live_packages_needed[*]}" - - sed -i '/^deb cdrom/d' /etc/apt/sources.list 2>/dev/null || true - rm -f /etc/apt/sources.list.d/*cdrom* 2>/dev/null || true - - apt-get update -qq || - { backend_fatal "apt-get update fehlgeschlagen."; return 1; } - DEBIAN_FRONTEND=noninteractive apt-get install -y "${live_packages_needed[@]}" || - { backend_fatal "Installation fehlender Werkzeuge fehlgeschlagen."; return 1; } - fi + [[ "${#missing[@]}" -eq 0 ]] || + { backend_fatal "Werkzeuge fehlen auf dem Boot-Medium (Paketliste pruefen): ${missing[*]}"; return 1; } [[ -r "${IMAGE_DEPLOY_LIB}" ]] || { backend_fatal "Deployment-Bibliothek nicht gefunden: ${IMAGE_DEPLOY_LIB}"; return 1; } diff --git a/backends/mint-image/postinstall.sh b/backends/mint-image/postinstall.sh deleted file mode 120000 index 21b6b5b..0000000 --- a/backends/mint-image/postinstall.sh +++ /dev/null @@ -1 +0,0 @@ -../mint/postinstall.sh \ No newline at end of file diff --git a/backends/mint-image/postinstall.sh b/backends/mint-image/postinstall.sh new file mode 100644 index 0000000..f20f032 --- /dev/null +++ b/backends/mint-image/postinstall.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Lesbare Referenzfassung des Agent-Bootstraps, den backend_generate_config() +# in backend.sh zur Laufzeit envsubst-auflöst und anschließend base64-kodiert +# in preseed.tpls ubiquity/success_command einsetzt (siehe backend.sh). Diese +# Datei selbst wird nie direkt ausgeführt - sie existiert, damit der Code +# lesbar bleibt statt nur als Base64-Blob im Preseed zu existieren. +# +# Inhaltlich das Bash-Pendant zu backends/fedora/kickstart.tpl %post: gleiche +# curl/jq-Aufrufe, nur eingebettet über ubiquity/success_command (in-target, +# chrooted) statt Kickstart %post. +tuxflotte_agent_fatal() { + echo "tuxflotte: Provisioning-Agent-Einrichtung fehlgeschlagen: $*" >> /var/log/tuxflotte-postinstall.log + exit 1 +} + +ANODE_URL="https://anode.tuxflotte.de" +AGENT_REPO_RAW="https://git.tuxflotte.de/admin/provisioning-agent/raw/branch/main" + +install -d -m 0700 /etc/tuxflotte || + tuxflotte_agent_fatal "Verzeichnis /etc/tuxflotte konnte nicht angelegt werden." + +# Identifikation soll in beide Richtungen moeglich sein: die Geraeteliste +# zeigt den Fingerprint bereits an (siehe geraete_liste.html), aber bislang +# gab es auf dem installierten Geraet selbst keine Datei, um ihn mit einem +# einfachen "cat" gegenzupruefen - build_device_fingerprint() (10_hardware.sh) +# berechnet ihn nur einmalig waehrend des Live-Boots und haelt ihn sonst +# nirgends fest. Absichtlich Klartext, kein Secret - reiner Hardware-Hash, +# kein chmod 0600 noetig wie bei agent.credentials. +echo "${TUXFLOTTE_DEVICE_FINGERPRINT}" > /etc/tuxflotte/device_fingerprint || + tuxflotte_agent_fatal "device_fingerprint konnte nicht abgelegt werden." + +cat > /etc/tuxflotte/runtime_blueprint.json <<'RUNTIME_BLUEPRINT_EOF' +${TUXFLOTTE_BLUEPRINTS_JSON} +RUNTIME_BLUEPRINT_EOF + +install -d /opt/tuxflotte/agent || + tuxflotte_agent_fatal "Verzeichnis /opt/tuxflotte/agent konnte nicht angelegt werden." + +curl --silent --show-error --fail --location \ + --output /opt/tuxflotte/agent/agent.py \ + "${AGENT_REPO_RAW}/agent.py" || + tuxflotte_agent_fatal "agent.py konnte nicht von ${AGENT_REPO_RAW} geladen werden." + +curl --silent --show-error --fail --location \ + --output /etc/systemd/system/tuxflotte-agent.service \ + "${AGENT_REPO_RAW}/tuxflotte-agent.service" || + tuxflotte_agent_fatal "tuxflotte-agent.service konnte nicht von ${AGENT_REPO_RAW} geladen werden." + +AGENT_BOOTSTRAP_RESPONSE="$( + curl --silent --show-error --fail --location \ + --header 'Content-Type: application/json' \ + --data-binary "{\"device_id\": \"${TUXFLOTTE_DEVICE_ID}\"}" \ + "${ANODE_URL}/api/v1/agent/bootstrap" +)" || + tuxflotte_agent_fatal "Bootstrap-Aufruf gegen ${ANODE_URL} ist fehlgeschlagen." + +jq --exit-status '.success == true' <<<"${AGENT_BOOTSTRAP_RESPONSE}" >/dev/null || + tuxflotte_agent_fatal "Server hat den Bootstrap abgelehnt: ${AGENT_BOOTSTRAP_RESPONSE}" + +jq --null-input \ + --arg device_id "${TUXFLOTTE_DEVICE_ID}" \ + --argjson response "${AGENT_BOOTSTRAP_RESPONSE}" \ + '{device_id: $device_id, agent_secret: $response.agent_secret}' \ + > /etc/tuxflotte/agent.credentials || + tuxflotte_agent_fatal "Credentials-Datei konnte nicht erzeugt werden." +chmod 0600 /etc/tuxflotte/agent.credentials + +systemctl enable tuxflotte-agent.service || + tuxflotte_agent_fatal "systemd-Dienst tuxflotte-agent konnte nicht aktiviert werden." + +echo "tuxflotte: Runtime Blueprint unter /etc/tuxflotte/runtime_blueprint.json hinterlegt." >> /var/log/tuxflotte-postinstall.log +echo "tuxflotte: Provisioning-Agent installiert, registriert und für den ersten Boot aktiviert." >> /var/log/tuxflotte-postinstall.log diff --git a/boot-medium/config/bootstrap b/boot-medium/config/bootstrap index 0f541d3..39698d7 100644 --- a/boot-medium/config/bootstrap +++ b/boot-medium/config/bootstrap @@ -22,7 +22,7 @@ LB_DISTRIBUTION_BINARY="trixie" LB_PARENT_DISTRIBUTION_BINARY="trixie" # Select parent distribution for debian-installer to use -LB_PARENT_DEBIAN_INSTALLER_DISTRIBUTION="" +LB_PARENT_DEBIAN_INSTALLER_DISTRIBUTION="trixie" # Select archive areas to use LB_ARCHIVE_AREAS="main contrib non-free non-free-firmware" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/backend.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/backend.sh new file mode 100644 index 0000000..b4e9aa2 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/backend.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Dieses Skript wird von einem Orchestrator-Modul (40_backend.sh) per +# `source` in dessen Shell geladen. Variablen bleiben deshalb bewusst nicht +# readonly, um Namenskollisionen mit dem ladenden Modul zu vermeiden. +# +# Golden-Image-Deployment-Backend (siehe ADR-0024) - ersetzt die +# Ubiquity-Automatisierung von backends/mint/ durch das curtin/FAI-Muster: +# Zieldatentraeger direkt partitionieren, ein fertiges Root-Filesystem- +# Image entpacken, per chroot nacharbeiten. Kein GUI-Installer, kein +# Preseed/Kickstart mehr - die eigentliche Mechanik steckt in +# scripts/lib/image_deploy.sh (Phase 1, isoliert live verifiziert). +# +# backends/mint/ bleibt unveraendert als Referenz bestehen - dieses +# Backend ist ein bewusst NEUER backend_id ("mint-image"), nichts wird +# live umgeschaltet. +BACKEND_KEY="mint-image" + +BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${BACKEND_DIR}/../.." && pwd)" +POSTINSTALL_SCRIPT="${BACKEND_DIR}/postinstall.sh" +IMAGE_DEPLOY_LIB="${REPO_DIR}/scripts/lib/image_deploy.sh" + +RUNTIME_BLUEPRINT_FILE="/run/tuxflotte/runtime/runtime_blueprint.json" +SERVER_RESPONSE_FILE="/run/tuxflotte/server/response.json" +HARDWARE_FILE="/run/tuxflotte/hardware/hardware.json" + +RUNTIME_DIR="/run/tuxflotte/backend" +CONFIG_FILE="${RUNTIME_DIR}/config.json" +GOLDEN_IMAGE_FILE="${RUNTIME_DIR}/golden-image.tar.zst" + +# Ziel-Mountpunkt fuer die Deployment-Mechanik - global, da backend_launch() +# und backend_postinstall() (separate Funktionsaufrufe, aber dieselbe +# Shell/derselbe Prozess, siehe 40_backend.sh) sich denselben Baum teilen. +TARGET_DIR="/target" +declare -a MOUNT_STACK=() + +# Aus einer manuell in Proxmox installierten Referenz-VM gezogen (nicht +# debootstrap - siehe ADR-0024-Nachtrag "Referenz-VM statt debootstrap", +# 31.08.2026), bereinigt via scripts/package_golden_image.sh, gehostet +# ueber die unauthentifizierte /golden-images/-Route in +# provisioning-server (analog ks.cfg) - live verifiziert per Public-HTTPS- +# Download (200, byte-exakte Groesse) am 31.08.2026. +GOLDEN_IMAGE_URL="https://anode.tuxflotte.de/golden-images/linux-mint-22.3-cinnamon.tar.zst" + +backend_log() { + printf '[backend:%s] %s\n' "${BACKEND_KEY}" "$*" >&2 +} + +backend_fatal() { + printf '[backend:%s] FEHLER: %s\n' "${BACKEND_KEY}" "$*" >&2 + return 1 +} + +# Analog zu _tuxflotte_detect_target_disk() in backends/mint/backend.sh - +# bewusst hier dupliziert statt geteilt, um dieses Backend unabhaengig vom +# Mint-Referenzbackend zu halten (siehe Modul-Kommentar oben). Ein Umzug in +# eine gemeinsame lib waere ein sinnvolles spaeteres Aufraeumen, sobald +# mehr als zwei Backends dieselbe Logik brauchen. +_mint_image_detect_target_disk() { + lsblk --nodeps --noheadings --bytes --output NAME,TYPE,SIZE --paths | + awk '$2 == "disk" && $3 > 0 && $1 !~ /(nbd|zram|loop)[0-9]*$/ { print $1; exit }' +} + +backend_init() { + # Historisch (bis zum Umstieg auf das eigenstaendige, per live-build + # gebaute Boot-Medium) wurden diese Werkzeuge hier noch zur Laufzeit per + # apt-get nachinstalliert, weil das damalige Boot-Medium (eine gepatchte + # Linux-Mint-Live-ISO) sie nicht immer mitbrachte. Das eigenstaendige + # Boot-Medium bringt sie bereits im Paketsatz mit (siehe + # boot-medium/config/package-lists/tuxflotte.list.chroot) - hier bleibt + # nur noch eine reine Assertion, damit ein kuenftiger Paketlisten-Fehler + # fruh und klar auffaellt. + local missing=() + + command -v jq >/dev/null 2>&1 || missing+=(jq) + command -v envsubst >/dev/null 2>&1 || missing+=(gettext-base) + command -v parted >/dev/null 2>&1 || missing+=(parted) + command -v mkfs.vfat >/dev/null 2>&1 || missing+=(dosfstools) + command -v mkfs.ext4 >/dev/null 2>&1 || missing+=(e2fsprogs) + command -v mkfs.btrfs >/dev/null 2>&1 || missing+=(btrfs-progs) + command -v zstd >/dev/null 2>&1 || missing+=(zstd) + command -v curl >/dev/null 2>&1 || missing+=(curl) + + [[ "${#missing[@]}" -eq 0 ]] || + { backend_fatal "Werkzeuge fehlen auf dem Boot-Medium (Paketliste pruefen): ${missing[*]}"; return 1; } + + [[ -r "${IMAGE_DEPLOY_LIB}" ]] || + { backend_fatal "Deployment-Bibliothek nicht gefunden: ${IMAGE_DEPLOY_LIB}"; return 1; } + # shellcheck source=../../scripts/lib/image_deploy.sh + source "${IMAGE_DEPLOY_LIB}" + + [[ -r "${POSTINSTALL_SCRIPT}" ]] || + { backend_fatal "Postinstall-Skript nicht gefunden: ${POSTINSTALL_SCRIPT}"; return 1; } + + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${CONFIG_FILE}" "${GOLDEN_IMAGE_FILE}" + + backend_log "Initialisiert." +} + +backend_validate() { + [[ -r "${RUNTIME_BLUEPRINT_FILE}" ]] || + { backend_fatal "Runtime Blueprint nicht gefunden: ${RUNTIME_BLUEPRINT_FILE}"; return 1; } + + jq --exit-status \ + --arg backend_key "${BACKEND_KEY}" \ + '.runtime_blueprint.backend_id == $backend_key' \ + "${RUNTIME_BLUEPRINT_FILE}" >/dev/null || + { backend_fatal "Runtime Blueprint ist nicht für Backend '${BACKEND_KEY}' aufgelöst."; return 1; } + + jq --exit-status ' + .runtime_blueprint.installation_directives + | (.disk_encryption | type == "boolean") + and (.partitioning | type == "object") + and (.secure_boot_required | type == "boolean") + ' "${RUNTIME_BLUEPRINT_FILE}" >/dev/null || + { backend_fatal "Installationszeitliche Vorgaben fehlen oder sind ungültig."; return 1; } + + # Phase 2 deckt bewusst nur das einfache Schema ab (ESP/biosgrub + + # eine Root-Partition, siehe image_deploy_partition()) - "custom" mit + # extra_partitions (/home, /var) ist noch nicht auf die neue + # parted-basierte Mechanik uebertragen. Klarer Fehler statt stiller + # Fehlinterpretation. + local scheme + scheme="$(jq --raw-output '.runtime_blueprint.installation_directives.partitioning.scheme // "single"' "${RUNTIME_BLUEPRINT_FILE}")" + [[ "${scheme}" == "single" ]] || + { backend_fatal "Partitionierungsschema '${scheme}' wird von diesem Backend noch nicht unterstützt (nur 'single')."; return 1; } + + if [[ "$(jq --raw-output '.runtime_blueprint.installation_directives.disk_encryption' "${RUNTIME_BLUEPRINT_FILE}")" == "true" ]]; then + backend_fatal "disk_encryption=true wird von diesem Backend derzeit nicht unterstützt." + return 1 + fi + + backend_log "Runtime Blueprint ist gültig für Backend '${BACKEND_KEY}'." +} + +backend_generate_config() { + local hostname device_id device_fingerprint + local root_filesystem partitioning_json blueprints_json + + [[ -r "${SERVER_RESPONSE_FILE}" ]] || + { backend_fatal "Serverantwort nicht gefunden: ${SERVER_RESPONSE_FILE}"; return 1; } + + hostname="$(jq --raw-output '.device.hostname // empty' "${SERVER_RESPONSE_FILE}")" + [[ -n "${hostname}" ]] || + { backend_fatal "Kein Hostname in der Serverantwort gefunden."; return 1; } + + device_id="$(jq --raw-output '.device.id // empty' "${SERVER_RESPONSE_FILE}")" + [[ -n "${device_id}" ]] || + { backend_fatal "Keine Geräte-ID in der Serverantwort gefunden."; return 1; } + + [[ -r "${HARDWARE_FILE}" ]] || + { backend_fatal "Hardware-Erfassung nicht gefunden: ${HARDWARE_FILE}"; return 1; } + device_fingerprint="$(jq --raw-output '.identity.device_fingerprint // empty' "${HARDWARE_FILE}")" + [[ -n "${device_fingerprint}" ]] || + { backend_fatal "Kein device_fingerprint in ${HARDWARE_FILE} gefunden."; return 1; } + + partitioning_json="$(jq --compact-output '.runtime_blueprint.installation_directives.partitioning' "${RUNTIME_BLUEPRINT_FILE}")" + root_filesystem="$(jq --raw-output '.root_filesystem // "ext4"' <<<"${partitioning_json}")" + case "${root_filesystem}" in + ext4|btrfs) ;; + *) backend_fatal "Nicht unterstütztes Root-Dateisystem: ${root_filesystem}"; return 1 ;; + esac + + blueprints_json="$(jq --compact-output '.runtime_blueprint.blueprints' "${RUNTIME_BLUEPRINT_FILE}")" + + jq --null-input \ + --arg hostname "${hostname}" \ + --arg device_id "${device_id}" \ + --arg device_fingerprint "${device_fingerprint}" \ + --arg root_filesystem "${root_filesystem}" \ + --argjson blueprints "${blueprints_json}" \ + '{ + hostname: $hostname, + device_id: $device_id, + device_fingerprint: $device_fingerprint, + root_filesystem: $root_filesystem, + blueprints: $blueprints + }' > "${CONFIG_FILE}" || + { backend_fatal "Konfigurationsdatei konnte nicht erzeugt werden."; return 1; } + + chmod 0600 "${CONFIG_FILE}" + + backend_log "Konfiguration erzeugt: ${CONFIG_FILE}" +} + +backend_launch() { + local disk is_efi root_fs boot_part root_part + + disk="$(_mint_image_detect_target_disk)" + [[ -n "${disk}" ]] || + { backend_fatal "Zieldatenträger konnte nicht ermittelt werden."; return 1; } + + [[ -d /sys/firmware/efi ]] && is_efi="true" || is_efi="false" + backend_log "Zieldatenträger: ${disk} (Firmware: $([ "${is_efi}" = true ] && echo UEFI || echo BIOS))" + + root_fs="$(jq --raw-output '.root_filesystem' "${CONFIG_FILE}")" + + backend_log "Lade Golden Image von ${GOLDEN_IMAGE_URL}" + curl --silent --show-error --fail --location \ + --output "${GOLDEN_IMAGE_FILE}" "${GOLDEN_IMAGE_URL}" || + { backend_fatal "Golden Image konnte nicht geladen werden: ${GOLDEN_IMAGE_URL}"; return 1; } + + backend_log "Partitioniere ${disk}" + read -r boot_part root_part <<<"$(image_deploy_partition "${disk}" "${is_efi}")" || + return 1 + + backend_log "Formatiere Partitionen" + image_deploy_format "${boot_part}" "${root_part}" "${root_fs}" || return 1 + + backend_log "Mounte unter ${TARGET_DIR}" + image_deploy_mount "${TARGET_DIR}" "${boot_part}" "${root_part}" || return 1 + + backend_log "Entpacke Golden Image" + image_deploy_extract_image "${GOLDEN_IMAGE_FILE}" "${TARGET_DIR}" || return 1 + rm -f "${GOLDEN_IMAGE_FILE}" + + backend_log "Schreibe fstab" + image_deploy_write_fstab "${TARGET_DIR}" "${boot_part}" "${root_part}" "${root_fs}" || return 1 + + backend_log "Binde /dev, /proc, /sys ein" + image_deploy_bind_mounts "${TARGET_DIR}" MOUNT_STACK || return 1 + + backend_log "chroot-Fixup (machine-id, SSH-Hostkeys, initramfs)" + image_deploy_chroot_fixup "${TARGET_DIR}" || return 1 + + backend_log "Installiere Bootloader" + image_deploy_install_bootloader "${TARGET_DIR}" "${disk}" "${is_efi}" || return 1 + + local hostname + hostname="$(jq --raw-output '.hostname' "${CONFIG_FILE}")" + backend_log "Setze Hostname (${hostname})" + image_deploy_set_hostname "${TARGET_DIR}" "${hostname}" || return 1 + + backend_log "Deployment abgeschlossen." +} + +backend_postinstall() { + local device_id device_fingerprint blueprints_json + local postinstall_rendered + + device_id="$(jq --raw-output '.device_id' "${CONFIG_FILE}")" + device_fingerprint="$(jq --raw-output '.device_fingerprint' "${CONFIG_FILE}")" + blueprints_json="$(jq --compact-output '.blueprints' "${CONFIG_FILE}")" + + # Dasselbe Template wie backends/mint/postinstall.sh (per Symlink + # geteilt, siehe Verzeichnis) - rein distributionsunabhaengiges + # Bash-Skript (curl/jq gegen anode), hier per chroot statt per + # ubiquity/success_command ausgefuehrt. + postinstall_rendered="$( + TUXFLOTTE_DEVICE_ID="${device_id}" \ + TUXFLOTTE_BLUEPRINTS_JSON="${blueprints_json}" \ + TUXFLOTTE_DEVICE_FINGERPRINT="${device_fingerprint}" \ + envsubst '${TUXFLOTTE_DEVICE_ID} ${TUXFLOTTE_BLUEPRINTS_JSON} ${TUXFLOTTE_DEVICE_FINGERPRINT}' \ + <"${POSTINSTALL_SCRIPT}" + )" + + if grep -q '\${TUXFLOTTE_' <<<"${postinstall_rendered}"; then + backend_fatal "postinstall.sh enthält nach envsubst nicht aufgelöste Platzhalter." + return 1 + fi + + printf '%s' "${postinstall_rendered}" > "${TARGET_DIR}/tmp/postinstall.sh" + chmod 0700 "${TARGET_DIR}/tmp/postinstall.sh" + + backend_log "Führe Postinstall-Skript im chroot aus." + chroot "${TARGET_DIR}" /bin/bash /tmp/postinstall.sh || + { backend_fatal "Postinstall-Skript ist im chroot fehlgeschlagen."; return 1; } + + rm -f "${TARGET_DIR}/tmp/postinstall.sh" + + backend_log "Hänge Ziel-Dateisystem aus." + image_deploy_unbind_mounts MOUNT_STACK + umount --recursive "${TARGET_DIR}" || + { backend_fatal "${TARGET_DIR} konnte nicht ausgehängt werden."; return 1; } + + backend_log "Starte neu - kein Rücksprung erwartet, ab hier läuft das frisch installierte System." + reboot +} diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/postinstall.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/postinstall.sh new file mode 100644 index 0000000..f20f032 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/backends/mint-image/postinstall.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Lesbare Referenzfassung des Agent-Bootstraps, den backend_generate_config() +# in backend.sh zur Laufzeit envsubst-auflöst und anschließend base64-kodiert +# in preseed.tpls ubiquity/success_command einsetzt (siehe backend.sh). Diese +# Datei selbst wird nie direkt ausgeführt - sie existiert, damit der Code +# lesbar bleibt statt nur als Base64-Blob im Preseed zu existieren. +# +# Inhaltlich das Bash-Pendant zu backends/fedora/kickstart.tpl %post: gleiche +# curl/jq-Aufrufe, nur eingebettet über ubiquity/success_command (in-target, +# chrooted) statt Kickstart %post. +tuxflotte_agent_fatal() { + echo "tuxflotte: Provisioning-Agent-Einrichtung fehlgeschlagen: $*" >> /var/log/tuxflotte-postinstall.log + exit 1 +} + +ANODE_URL="https://anode.tuxflotte.de" +AGENT_REPO_RAW="https://git.tuxflotte.de/admin/provisioning-agent/raw/branch/main" + +install -d -m 0700 /etc/tuxflotte || + tuxflotte_agent_fatal "Verzeichnis /etc/tuxflotte konnte nicht angelegt werden." + +# Identifikation soll in beide Richtungen moeglich sein: die Geraeteliste +# zeigt den Fingerprint bereits an (siehe geraete_liste.html), aber bislang +# gab es auf dem installierten Geraet selbst keine Datei, um ihn mit einem +# einfachen "cat" gegenzupruefen - build_device_fingerprint() (10_hardware.sh) +# berechnet ihn nur einmalig waehrend des Live-Boots und haelt ihn sonst +# nirgends fest. Absichtlich Klartext, kein Secret - reiner Hardware-Hash, +# kein chmod 0600 noetig wie bei agent.credentials. +echo "${TUXFLOTTE_DEVICE_FINGERPRINT}" > /etc/tuxflotte/device_fingerprint || + tuxflotte_agent_fatal "device_fingerprint konnte nicht abgelegt werden." + +cat > /etc/tuxflotte/runtime_blueprint.json <<'RUNTIME_BLUEPRINT_EOF' +${TUXFLOTTE_BLUEPRINTS_JSON} +RUNTIME_BLUEPRINT_EOF + +install -d /opt/tuxflotte/agent || + tuxflotte_agent_fatal "Verzeichnis /opt/tuxflotte/agent konnte nicht angelegt werden." + +curl --silent --show-error --fail --location \ + --output /opt/tuxflotte/agent/agent.py \ + "${AGENT_REPO_RAW}/agent.py" || + tuxflotte_agent_fatal "agent.py konnte nicht von ${AGENT_REPO_RAW} geladen werden." + +curl --silent --show-error --fail --location \ + --output /etc/systemd/system/tuxflotte-agent.service \ + "${AGENT_REPO_RAW}/tuxflotte-agent.service" || + tuxflotte_agent_fatal "tuxflotte-agent.service konnte nicht von ${AGENT_REPO_RAW} geladen werden." + +AGENT_BOOTSTRAP_RESPONSE="$( + curl --silent --show-error --fail --location \ + --header 'Content-Type: application/json' \ + --data-binary "{\"device_id\": \"${TUXFLOTTE_DEVICE_ID}\"}" \ + "${ANODE_URL}/api/v1/agent/bootstrap" +)" || + tuxflotte_agent_fatal "Bootstrap-Aufruf gegen ${ANODE_URL} ist fehlgeschlagen." + +jq --exit-status '.success == true' <<<"${AGENT_BOOTSTRAP_RESPONSE}" >/dev/null || + tuxflotte_agent_fatal "Server hat den Bootstrap abgelehnt: ${AGENT_BOOTSTRAP_RESPONSE}" + +jq --null-input \ + --arg device_id "${TUXFLOTTE_DEVICE_ID}" \ + --argjson response "${AGENT_BOOTSTRAP_RESPONSE}" \ + '{device_id: $device_id, agent_secret: $response.agent_secret}' \ + > /etc/tuxflotte/agent.credentials || + tuxflotte_agent_fatal "Credentials-Datei konnte nicht erzeugt werden." +chmod 0600 /etc/tuxflotte/agent.credentials + +systemctl enable tuxflotte-agent.service || + tuxflotte_agent_fatal "systemd-Dienst tuxflotte-agent konnte nicht aktiviert werden." + +echo "tuxflotte: Runtime Blueprint unter /etc/tuxflotte/runtime_blueprint.json hinterlegt." >> /var/log/tuxflotte-postinstall.log +echo "tuxflotte: Provisioning-Agent installiert, registriert und für den ersten Boot aktiviert." >> /var/log/tuxflotte-postinstall.log diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/config/installer.conf b/boot-medium/config/includes.chroot/opt/tuxflotte/config/installer.conf new file mode 100644 index 0000000..e69de29 diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/installer.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/installer.sh new file mode 100755 index 0000000..e4ed7d7 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/installer.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +source "$SCRIPT_DIR/lib/logging.sh" +source "$SCRIPT_DIR/lib/errors.sh" +source "$SCRIPT_DIR/lib/utils.sh" +source "$SCRIPT_DIR/lib/checks.sh" + +DRY_RUN=false + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + *) error_exit "Unbekannter Parameter: $arg" ;; + esac +done + +load_config "$INSTALLER_ROOT/config/installer.conf" + +log_info "Tuxflotte Installer gestartet" +log_info "Installer Root: $INSTALLER_ROOT" + +[[ "$DRY_RUN" == true ]] && log_warn "Dry-Run aktiv" + +run_module "$SCRIPT_DIR/modules/00_preflight.sh" "always" +run_module "$SCRIPT_DIR/modules/05_network.sh" "always" +run_module "$SCRIPT_DIR/modules/10_hardware.sh" "always" +run_module "$SCRIPT_DIR/modules/12_enrollment_auth.sh" "always" +run_module "$SCRIPT_DIR/modules/15_server_handshake.sh" "always" +run_module "$SCRIPT_DIR/modules/17_device_status.sh" "always" +PROVISIONING_STATE_FILE="/run/tuxflotte/provisioning/state.env" + +[[ -r "$PROVISIONING_STATE_FILE" ]] || + error_exit "Provisionierungszustand fehlt: $PROVISIONING_STATE_FILE" + +# shellcheck disable=SC1090 +source "$PROVISIONING_STATE_FILE" + +case "${TUXFLOTTE_PROVISIONING_CONTINUE:-}" in + true) + log_info "Provisionierung wird fortgesetzt." + ;; + false) + log_info "Provisionierung wurde durch den Benutzer kontrolliert beendet." + exit 0 + ;; + *) + error_exit "Ungültiger Provisionierungszustand." + ;; +esac +run_module "$SCRIPT_DIR/modules/20_profile_selection.sh" "always" +run_module "$SCRIPT_DIR/modules/25_installation_confirm.sh" "always" + +INSTALLATION_STATE_FILE="/run/tuxflotte/installation/state.env" + +[[ -r "$INSTALLATION_STATE_FILE" ]] || + error_exit "Installationsbestätigung fehlt: $INSTALLATION_STATE_FILE" + +# shellcheck disable=SC1090 +source "$INSTALLATION_STATE_FILE" + +case "${TUXFLOTTE_INSTALLATION_CONFIRMED:-}" in + true) + log_info "Commit Point bestätigt. Installationsphase wird fortgesetzt." + ;; + false) + log_info "Provisionierung wurde durch den Benutzer kontrolliert beendet." + log_info "Es wurden keine destruktiven Installationsaktionen gestartet." + exit 0 + ;; + *) + error_exit "Ungültiger Installationsbestätigungszustand." + ;; +esac + +run_module "$SCRIPT_DIR/modules/30_runtime_blueprint.sh" "always" +run_module "$SCRIPT_DIR/modules/40_backend.sh" "always" +run_module "$SCRIPT_DIR/modules/20_storage.sh" "dry-run-safe" +run_module "$SCRIPT_DIR/modules/99_finish.sh" "always" + +log_success "Tuxflotte Installer abgeschlossen" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/checks.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/checks.sh new file mode 100644 index 0000000..19fe7c7 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/checks.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +detect_uefi() { + if [[ -d /sys/firmware/efi ]]; then + echo "uefi" + else + echo "bios" + fi +} + +check_network() { + ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 +} + +list_install_disks() { + lsblk -dpno NAME,SIZE,MODEL,TRAN,TYPE | awk '$5 == "disk" && $4 != "usb" {print}' +} diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/errors.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/errors.sh new file mode 100644 index 0000000..1c42e26 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/errors.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +error_exit() { + log_error "$1" + exit "${2:-1}" +} + +trap 'error_exit "Unerwarteter Fehler in Zeile $LINENO."' ERR diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/hardware_collectors.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/hardware_collectors.sh new file mode 100644 index 0000000..c1a8705 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/hardware_collectors.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +get_cpu_model() { + awk -F: ' + $1 ~ /^model name[[:space:]]*$/ { + value = $2 + sub(/^[[:space:]]*/, "", value) + print value + exit + } + ' /proc/cpuinfo +} + +get_cpu_count() { + getconf _NPROCESSORS_ONLN +} + +get_memory_bytes() { + awk ' + $1 == "MemTotal:" { + print $2 * 1024 + exit + } + ' /proc/meminfo +} + +build_storage_devices_json() { + lsblk \ + --bytes \ + --json \ + --nodeps \ + --output NAME,TYPE,MODEL,SERIAL,SIZE,TRAN | + jq ' + [ + .blockdevices[] + | select(.type == "disk") + | { + name: .name, + model: ( + if .model == null or .model == "" + then null + else (.model | gsub("^[[:space:]]+|[[:space:]]+$"; "")) + end + ), + serial: ( + if .serial == null or .serial == "" + then null + else (.serial | gsub("^[[:space:]]+|[[:space:]]+$"; "")) + end + ), + size_bytes: .size, + transport: ( + if .tran == null or .tran == "" + then null + else .tran + end + ) + } + ] + ' +} diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/image_deploy.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/image_deploy.sh new file mode 100644 index 0000000..7d3098a --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/image_deploy.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Kernmechanik des Golden-Image-Deployments (siehe ADR-0024 und +# platform-docs-Plan "Golden-Image-Deployment statt Ubiquity- +# Automatisierung"). Wird von source eingebunden - nicht eigenstaendig +# ausfuehrbar. Ersetzt den bisherigen Ansatz "nativen Distributions- +# Installer automatisieren" (Ubiquity/Anaconda) durch das curtin/FAI- +# Muster: Zieldatentraeger direkt partitionieren, ein fertiges +# Root-Filesystem-Image entpacken, per chroot nacharbeiten +# (fstab/initramfs/machine-id/SSH-Hostkeys/Bootloader). +# +# Bewusst als eigenstaendige Funktionsbibliothek (wie lib/initrd.sh, +# lib/checks.sh) statt einer einzigen "mach alles"-Funktion - sowohl der +# isolierte Phase-1-Testtreiber als auch das spaetere +# backends/mint-image/backend.sh rufen dieselben Bausteine in derselben +# Reihenfolge auf. + +image_deploy_log() { + printf '[image_deploy] %s\n' "$*" >&2 +} + +image_deploy_fatal() { + printf '[image_deploy] FEHLER: %s\n' "$*" >&2 + return 1 +} + +# Nvme/mmcblk-Geraete brauchen ein "p" vor der Partitionsnummer +# (/dev/nvme0n1p1), sd/vd-Geraete nicht (/dev/sda1) - Heuristik: Geraete- +# name endet auf eine Ziffer -> braucht "p". +_image_deploy_part_suffix() { + local disk="$1" + if [[ "${disk}" =~ [0-9]$ ]]; then + echo "p" + else + echo "" + fi +} + +image_deploy_partition_path() { + local disk="$1" + local num="$2" + echo "${disk}$(_image_deploy_part_suffix "${disk}")${num}" +} + +# Partitioniert den Zieldatentraeger komplett neu (GPT, wie schon in +# _tuxflotte_render_partman_recipe() begruendet - auch ohne EFI legt GPT +# an, siehe dortiger Kommentar zu bios_grub). Gibt "BOOT_PART ROOT_PART" +# auf stdout aus - BOOT_PART ist bei BIOS leer (die biosgrub-Partition +# braucht kein Dateisystem/keinen Mountpunkt). +image_deploy_partition() { + local disk="$1" + local is_efi="$2" + + parted --script "${disk}" mklabel gpt || + { image_deploy_fatal "mklabel gpt fehlgeschlagen auf ${disk}"; return 1; } + + if [[ "${is_efi}" == "true" ]]; then + parted --script "${disk}" mkpart ESP fat32 1MiB 513MiB && + parted --script "${disk}" set 1 esp on && + parted --script "${disk}" mkpart root ext4 513MiB 100% || + { image_deploy_fatal "Partitionierung (UEFI) fehlgeschlagen auf ${disk}"; return 1; } + else + parted --script "${disk}" mkpart biosgrub 1MiB 2MiB && + parted --script "${disk}" set 1 bios_grub on && + parted --script "${disk}" mkpart root ext4 2MiB 100% || + { image_deploy_fatal "Partitionierung (BIOS) fehlgeschlagen auf ${disk}"; return 1; } + fi + + partprobe "${disk}" 2>/dev/null || true + udevadm settle + + local part1 part2 + part1="$(image_deploy_partition_path "${disk}" 1)" + part2="$(image_deploy_partition_path "${disk}" 2)" + + [[ -b "${part1}" && -b "${part2}" ]] || + { image_deploy_fatal "Partitionen nach partprobe nicht gefunden (${part1}, ${part2})"; return 1; } + + if [[ "${is_efi}" == "true" ]]; then + echo "${part1} ${part2}" + else + echo " ${part2}" + fi +} + +image_deploy_format() { + local boot_part="$1" + local root_part="$2" + local root_fs="$3" + + if [[ -n "${boot_part}" ]]; then + mkfs.vfat -F32 -n ESP "${boot_part}" || + { image_deploy_fatal "mkfs.vfat auf ${boot_part} fehlgeschlagen"; return 1; } + fi + + case "${root_fs}" in + ext4) mkfs.ext4 -F -L root "${root_part}" ;; + btrfs) mkfs.btrfs -f -L root "${root_part}" ;; + *) image_deploy_fatal "Nicht unterstuetztes Root-Dateisystem: ${root_fs}"; return 1 ;; + esac || { image_deploy_fatal "mkfs auf ${root_part} fehlgeschlagen"; return 1; } +} + +image_deploy_mount() { + local target="$1" + local boot_part="$2" + local root_part="$3" + + install -d "${target}" + mount "${root_part}" "${target}" || + { image_deploy_fatal "Root-Partition ${root_part} konnte nicht auf ${target} gemountet werden"; return 1; } + + if [[ -n "${boot_part}" ]]; then + install -d "${target}/boot/efi" + mount "${boot_part}" "${target}/boot/efi" || + { image_deploy_fatal "ESP ${boot_part} konnte nicht gemountet werden"; return 1; } + fi +} + +# Entpackt das per build_golden_image.sh gebaute Golden Image direkt in +# den gemounteten Zielbaum. -p erhaelt Rechte/Eigentuemer, --zstd deckt +# sowohl .tar.zst als auch (falls spaeter gewechselt) andere +# zstd-komprimierte Varianten ab. +image_deploy_extract_image() { + local image_path="$1" + local target="$2" + + [[ -r "${image_path}" ]] || + { image_deploy_fatal "Golden Image nicht lesbar: ${image_path}"; return 1; } + + tar --zstd -xpf "${image_path}" -C "${target}" || + { image_deploy_fatal "Golden Image konnte nicht nach ${target} entpackt werden"; return 1; } +} + +# /etc/fstab aus den tatsaechlichen Partitions-UUIDs neu erzeugen - kein +# genfstab auf Debian/Mint verfuegbar (das ist ein Arch-Linux-Werkzeug), +# blkid reicht fuer unseren einfachen Fall (Root + optional ESP) locker. +image_deploy_write_fstab() { + local target="$1" + local boot_part="$2" + local root_part="$3" + local root_fs="$4" + + local root_uuid + root_uuid="$(blkid -s UUID -o value "${root_part}")" + [[ -n "${root_uuid}" ]] || + { image_deploy_fatal "Keine UUID fuer ${root_part} gefunden"; return 1; } + + { + echo "# Von image_deploy.sh generiert - siehe ADR-0024." + echo "UUID=${root_uuid} / ${root_fs} defaults 0 1" + if [[ -n "${boot_part}" ]]; then + local boot_uuid + boot_uuid="$(blkid -s UUID -o value "${boot_part}")" + [[ -n "${boot_uuid}" ]] || + { image_deploy_fatal "Keine UUID fuer ${boot_part} gefunden"; return 1; } + echo "UUID=${boot_uuid} /boot/efi vfat umask=0077 0 1" + fi + } > "${target}/etc/fstab" +} + +# /dev, /proc, /sys, /dev/pts in den Zielbaum einbinden (fuer chroot- +# Operationen, die Geraeteknoten/Kernel-Interfaces brauchen - initramfs, +# grub-install) sowie eine funktionierende DNS-Aufloesung fuers +# apt-get-basierte Bootloader-Nachinstallieren. MOUNTED_STACK wird vom +# aufrufenden Skript vorgehalten (siehe image_deploy_unbind_mounts()). +image_deploy_bind_mounts() { + local target="$1" + local -n stack_ref="$2" + + mount --bind /dev "${target}/dev" && stack_ref+=("${target}/dev") + mount -t proc proc "${target}/proc" && stack_ref+=("${target}/proc") + mount -t sysfs sysfs "${target}/sys" && stack_ref+=("${target}/sys") + mount -t devpts devpts "${target}/dev/pts" && stack_ref+=("${target}/dev/pts") + cp /etc/resolv.conf "${target}/etc/resolv.conf" +} + +image_deploy_unbind_mounts() { + local -n stack_ref="$1" + local i mnt + + for ((i = ${#stack_ref[@]} - 1; i >= 0; i--)); do + mnt="${stack_ref[i]}" + umount --recursive "${mnt}" 2>/dev/null || umount --lazy "${mnt}" 2>/dev/null || true + done + stack_ref=() +} + +# machine-id + SSH-Hostkeys werden im Golden Image bewusst NICHT +# mitgeliefert (siehe build_golden_image.sh) - hier, pro tatsaechlich +# ausgerolltem Geraet, frisch erzeugt. update-initramfs regeneriert das +# initrd fuer die tatsaechliche Zielhardware (Golden Image wurde auf +# einem anderen System gebaut). +image_deploy_chroot_fixup() { + local target="$1" + + chroot "${target}" systemd-machine-id-setup || + { image_deploy_fatal "machine-id konnte nicht erzeugt werden"; return 1; } + chroot "${target}" ssh-keygen -A || + { image_deploy_fatal "SSH-Hostkeys konnten nicht erzeugt werden"; return 1; } + chroot "${target}" update-initramfs -u -k all || + { image_deploy_fatal "initramfs-Regenerierung fehlgeschlagen"; return 1; } +} + +# Grub wird bewusst NICHT ins Golden Image eingebaut (spart Platz, das +# Image kennt beim Bauen das Zielfirmware-Schema noch nicht) - stattdessen +# hier zur Deployment-Zeit nachinstalliert, wo bereits Netzwerk verfuegbar +# ist (Stufe-1-Umgebung hat sich schon fuer die Aktivierung verbunden). +image_deploy_install_bootloader() { + local target="$1" + local disk="$2" + local is_efi="$3" + + if [[ "${is_efi}" == "true" ]]; then + chroot "${target}" /bin/bash -c ' + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y grub-efi-amd64 + ' || { image_deploy_fatal "grub-efi-amd64-Installation fehlgeschlagen"; return 1; } + chroot "${target}" grub-install --target=x86_64-efi \ + --efi-directory=/boot/efi --bootloader-id=tuxflotte --recheck || + { image_deploy_fatal "grub-install (UEFI) fehlgeschlagen"; return 1; } + # Zusaetzlich auf den Standard-Fallback-Pfad (EFI/BOOT/BOOTX64.EFI) + # installieren - real beim Testen entdeckt: grub-install kann in + # verschachtelten/eingeschraenkten Umgebungen keinen NVRAM- + # Booteintrag setzen ("EFI variables are not supported on this + # system"), ohne --removable bleibt dann NUR der benannte + # /EFI/tuxflotte/-Pfad uebrig, den die Firmware ohne NVRAM-Eintrag + # nie findet ("No bootable option or device was found"). Der + # Fallback-Pfad wird von JEDER UEFI-Firmware ohne NVRAM-Eintrag + # automatisch versucht - robuster fuer heterogene Zielhardware + # generell, nicht nur fuer dieses Testszenario. + chroot "${target}" grub-install --target=x86_64-efi \ + --efi-directory=/boot/efi --removable --recheck || + { image_deploy_fatal "grub-install (UEFI, removable-Fallback) fehlgeschlagen"; return 1; } + else + chroot "${target}" /bin/bash -c ' + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y grub-pc + ' || { image_deploy_fatal "grub-pc-Installation fehlgeschlagen"; return 1; } + chroot "${target}" grub-install --target=i386-pc --recheck "${disk}" || + { image_deploy_fatal "grub-install (BIOS) fehlgeschlagen"; return 1; } + fi + + chroot "${target}" update-grub || + { image_deploy_fatal "update-grub fehlgeschlagen"; return 1; } +} + +image_deploy_set_hostname() { + local target="$1" + local hostname="$2" + + echo "${hostname}" > "${target}/etc/hostname" + printf '127.0.1.1\t%s\n' "${hostname}" >> "${target}/etc/hosts" +} diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/logging.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/logging.sh new file mode 100644 index 0000000..971cc60 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/logging.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +log_info() { + echo "[INFO ] $*" +} + +log_warn() { + echo "[WARN ] $*" >&2 +} + +log_error() { + echo "[ERROR] $*" >&2 +} + +log_success() { + echo "[ OK ] $*" +} diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/utils.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/utils.sh new file mode 100644 index 0000000..d2ff045 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/lib/utils.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +load_config() { + local config_file="$1" + + if [[ -f "$config_file" ]]; then + # shellcheck disable=SC1090 + source "$config_file" + log_info "Konfiguration geladen: $config_file" + else + log_warn "Keine Konfiguration gefunden: $config_file" + fi +} + +run_module() { + local module="$1" + local mode="${2:-normal}" + + [[ -f "$module" ]] || error_exit "Modul nicht gefunden: $module" + + log_info "Starte Modul: $(basename "$module")" + + if [[ "${DRY_RUN:-false}" == true && "$mode" != "always" ]]; then + log_warn "Dry-Run: Modul übersprungen: $module" + return 0 + fi + + # shellcheck disable=SC1090 + "$module" + + log_success "Modul abgeschlossen: $(basename "$module")" +} + +require_root() { + [[ "$EUID" -eq 0 ]] || error_exit "Installer muss als root ausgeführt werden." +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/00_preflight.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/00_preflight.sh new file mode 100755 index 0000000..d8592e8 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/00_preflight.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +# Tuxflotte Installer +# Phase 0 – Preflight +# +# Prueft, dass Werkzeuge vorhanden sind, die spaetere Module (ab +# 10_hardware.sh) brauchen. jq ist der einzige hier betroffene Fall: +# 10_hardware.sh, 12_enrollment_auth.sh, 15_server_handshake.sh, +# 17_device_status.sh, 20_profile_selection.sh, 25_installation_confirm.sh +# und 30_runtime_blueprint.sh nutzen es alle. +# +# Historisch (bis zum Umstieg auf das eigenstaendige, per live-build gebaute +# Boot-Medium) wurde jq hier noch zur Laufzeit per apt-get nachinstalliert, +# weil das damalige Boot-Medium (eine gepatchte Linux-Mint-Live-ISO) es nicht +# mitbrachte - real entdeckt: beim automatisierten Start ueber +# start-kiosk.sh (kein Terminal, keine sichtbare Fehlermeldung) blieb der +# Installer in 10_hardware.sh mit "Benoetigtes Programm nicht gefunden: jq" +# haengen, sichtbar nur in ~/.xsession-errors. Das eigenstaendige Boot-Medium +# bringt jq bereits im Paketsatz mit (siehe boot-medium/config/package-lists/ +# tuxflotte.list.chroot) - diese Pruefung bleibt trotzdem als reine Assertion +# bestehen, damit ein kuenftiger Paketlisten-Fehler hier fruh und klar auffaellt, +# statt erst kryptisch in 10_hardware.sh. +set -Eeuo pipefail + +readonly SCRIPT_NAME="${0##*/}" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +command -v jq >/dev/null 2>&1 || + fatal "jq fehlt auf dem Boot-Medium - Paketliste (tuxflotte.list.chroot) pruefen." diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/05_network.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/05_network.sh new file mode 100755 index 0000000..59ef7e1 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/05_network.sh @@ -0,0 +1,544 @@ +#!/usr/bin/env bash + +# Tuxflotte Installer +# Phase 2 – Netzwerkinitialisierung +# +# Unterstützt zunächst: +# - Ethernet über DHCP +# - bereits aktive NetworkManager-Verbindungen +# - WPA2/WPA3 Personal +# - interaktive WLAN-Auswahl +# - Prüfung des Tuxflotte-Servers +# - geschützte Runtime-Ablage des aktiven Netzwerkprofils + +set -Eeuo pipefail + +readonly SCRIPT_NAME="${0##*/}" + +readonly RUNTIME_DIR="/run/tuxflotte/network" +readonly STATE_FILE="${RUNTIME_DIR}/state.env" +readonly CONNECTION_EXPORT="${RUNTIME_DIR}/connection.nmconnection" + +readonly SERVER_URL="${TUXFLOTTE_SERVER_URL:-https://anode.tuxflotte.de/health}" +readonly SERVER_TIMEOUT="${TUXFLOTTE_SERVER_TIMEOUT:-10}" + +readonly NMCLI="${NMCLI:-nmcli}" +readonly CURL="${CURL:-curl}" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +warn() { + printf '[%s] WARNUNG: %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Netzwerkmodul muss als root ausgeführt werden." + fi +} + +require_command() { + local command_name="$1" + + command -v "${command_name}" >/dev/null 2>&1 || + fatal "Benötigtes Programm nicht gefunden: ${command_name}" +} + +prepare_runtime_directory() { + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${STATE_FILE}" "${CONNECTION_EXPORT}" +} + +networkmanager_is_running() { + "${NMCLI}" -t -f RUNNING general 2>/dev/null | + grep -qx 'running' +} + +start_networkmanager_if_possible() { + if networkmanager_is_running; then + return 0 + fi + + log "NetworkManager ist noch nicht aktiv." + + if command -v systemctl >/dev/null 2>&1; then + log "Versuche NetworkManager zu starten." + systemctl start NetworkManager.service 2>/dev/null || true + fi + + networkmanager_is_running || + fatal "NetworkManager konnte nicht verwendet werden." +} + +enable_networking() { + "${NMCLI}" networking on >/dev/null 2>&1 || true + "${NMCLI}" radio wifi on >/dev/null 2>&1 || true +} + +device_has_ipv4() { + local device="$1" + + "${NMCLI}" -g IP4.ADDRESS device show "${device}" 2>/dev/null | + grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/' +} + +get_active_device() { + local device + local type + + while IFS=: read -r device type _; do + case "${type}" in + ethernet|wifi) + ;; + *) + continue + ;; + esac + + [[ -e "/sys/class/net/${device}/device" ]] || continue + + if device_has_ipv4 "${device}"; then + printf '%s\n' "${device}" + return 0 + fi + done < <( + "${NMCLI}" \ + --terse \ + --fields DEVICE,TYPE,STATE \ + device status + ) + + return 1 +} + + +get_active_connection_name() { + local device="$1" + + "${NMCLI}" \ + --get-values GENERAL.CONNECTION \ + device show "${device}" 2>/dev/null | + head -n 1 +} + +get_device_type() { + local device="$1" + + "${NMCLI}" \ + --get-values GENERAL.TYPE \ + device show "${device}" 2>/dev/null | + head -n 1 +} + +server_is_reachable() { + "${CURL}" \ + --silent \ + --show-error \ + --fail \ + --location \ + --connect-timeout "${SERVER_TIMEOUT}" \ + --max-time "${SERVER_TIMEOUT}" \ + --output /dev/null \ + "${SERVER_URL}" +} + +check_existing_connection() { + local device + + device="$(get_active_device || true)" + + if [[ -z "${device}" ]]; then + return 1 + fi + + if ! device_has_ipv4 "${device}"; then + warn "Interface ${device} ist verbunden, besitzt aber keine IPv4-Adresse." + return 1 + fi + + log "Aktive Netzwerkverbindung über ${device} gefunden." + + if server_is_reachable; then + log "Tuxflotte-Server ist erreichbar." + return 0 + fi + + warn "Netzwerk ist aktiv, aber der Tuxflotte-Server ist nicht erreichbar." + return 1 +} + +get_ethernet_devices() { + local device + + while IFS=: read -r device type state; do + [[ "${type}" == "ethernet" ]] || continue + [[ "${state}" != "unavailable" ]] || continue + [[ -e "/sys/class/net/${device}/device" ]] || continue + + printf '%s\n' "${device}" + done < <( + "${NMCLI}" \ + --terse \ + --fields DEVICE,TYPE,STATE \ + device status + ) +} + +try_ethernet() { + local device + + while IFS= read -r device; do + [[ -n "${device}" ]] || continue + + log "Prüfe Ethernet-Interface ${device}." + + "${NMCLI}" device connect "${device}" >/dev/null 2>&1 || true + + if device_has_ipv4 "${device}"; then + log "Ethernet-Verbindung über ${device} hergestellt." + + if server_is_reachable; then + log "Tuxflotte-Server ist über Ethernet erreichbar." + return 0 + fi + + warn "Ethernet besitzt eine IP-Adresse, aber der Server ist nicht erreichbar." + fi + done < <(get_ethernet_devices) + + return 1 +} + +get_wifi_device() { + "${NMCLI}" \ + --terse \ + --fields DEVICE,TYPE,STATE \ + device status | + awk -F: '$2 == "wifi" && $3 != "unavailable" { print $1; exit }' +} + +scan_wifi_networks() { + local wifi_device="$1" + + "${NMCLI}" device wifi rescan ifname "${wifi_device}" >/dev/null 2>&1 || + true + + sleep 2 + + "${NMCLI}" \ + --terse \ + --escape yes \ + --fields SSID,SIGNAL,SECURITY \ + device wifi list \ + ifname "${wifi_device}" | + awk -F: ' + $1 != "" && !seen[$1]++ { + printf "%s\t%s\t%s\n", $1, $2, $3 + } + ' | + sort -t $'\t' -k2,2nr +} + +choose_wifi_ssid() { + local wifi_device="$1" + local -a networks=() + local entry + local choice + local index=1 + + while IFS= read -r entry; do + [[ -n "${entry}" ]] && networks+=("${entry}") + done < <(scan_wifi_networks "${wifi_device}") + + if [[ "${#networks[@]}" -eq 0 ]]; then + warn "Keine sichtbaren WLAN-Netze gefunden." + + read -r -p "Versteckte SSID manuell eingeben oder leer abbrechen: " WIFI_SSID + + [[ -n "${WIFI_SSID}" ]] + return + fi + + printf '\nVerfügbare WLAN-Netze:\n\n' >&2 + + for entry in "${networks[@]}"; do + IFS=$'\t' read -r ssid signal security <<<"${entry}" + + printf ' %2d) %-32s Signal: %-3s Sicherheit: %s\n' \ + "${index}" \ + "${ssid}" \ + "${signal}" \ + "${security:-offen}" >&2 + + ((index += 1)) + done + + printf '\n' >&2 + read -r -p "WLAN auswählen [1-${#networks[@]}], m = manuell, q = abbrechen: " choice + + case "${choice}" in + q|Q) + return 1 + ;; + m|M) + read -r -p "SSID: " WIFI_SSID + [[ -n "${WIFI_SSID}" ]] + ;; + *) + if [[ ! "${choice}" =~ ^[0-9]+$ ]] || + (( choice < 1 || choice > ${#networks[@]} )); then + warn "Ungültige Auswahl." + return 1 + fi + + IFS=$'\t' read -r WIFI_SSID _ _ <<<"${networks[choice - 1]}" + ;; + esac +} + +wifi_network_is_open() { + local wifi_device="$1" + local ssid="$2" + local security + + security="$( + "${NMCLI}" \ + --terse \ + --escape no \ + --fields SSID,SECURITY \ + device wifi list \ + ifname "${wifi_device}" | + awk -F: -v wanted="${ssid}" ' + $1 == wanted { + print $2 + exit + } + ' + )" + + [[ -z "${security}" || "${security}" == "--" ]] +} + +connect_wifi() { + local wifi_device="$1" + + choose_wifi_ssid "${wifi_device}" || + return 1 + + log "Verbinde mit WLAN '${WIFI_SSID}'." + + if wifi_network_is_open "${wifi_device}" "${WIFI_SSID}"; then + if ! "${NMCLI}" \ + device wifi connect "${WIFI_SSID}" \ + ifname "${wifi_device}" \ + >/dev/null; then + + warn "Verbindung mit dem offenen WLAN konnte nicht hergestellt werden." + return 1 + fi + else + log "NetworkManager fragt die WLAN-Zugangsdaten geschützt ab." + + if ! "${NMCLI}" \ + --ask \ + device wifi connect "${WIFI_SSID}" \ + ifname "${wifi_device}" \ + >/dev/null; then + + warn "WLAN-Anmeldung ist fehlgeschlagen." + return 1 + fi + fi + + if ! device_has_ipv4 "${wifi_device}"; then + warn "WLAN-Verbindung besitzt keine IPv4-Adresse." + return 1 + fi + + log "WLAN-Verbindung wurde hergestellt." + + if ! server_is_reachable; then + warn "WLAN ist verbunden, aber der Tuxflotte-Server ist nicht erreichbar." + return 1 + fi + + log "Tuxflotte-Server ist über WLAN erreichbar." +} + +connect_wifi_noninteractive() { + local wifi_device="$1" + local ssid="${TUXFLOTTE_WIFI_SSID}" + + log "Verbinde mit vorkonfiguriertem WLAN '${ssid}' (nicht-interaktiv)." + + if [[ -n "${TUXFLOTTE_WIFI_PSK:-}" ]]; then + if ! "${NMCLI}" \ + device wifi connect "${ssid}" \ + password "${TUXFLOTTE_WIFI_PSK}" \ + ifname "${wifi_device}" \ + >/dev/null; then + + warn "WLAN-Anmeldung mit vorkonfigurierten Zugangsdaten ist fehlgeschlagen." + return 1 + fi + else + if ! "${NMCLI}" \ + device wifi connect "${ssid}" \ + ifname "${wifi_device}" \ + >/dev/null; then + + warn "Verbindung mit dem offenen, vorkonfigurierten WLAN konnte nicht hergestellt werden." + return 1 + fi + fi + + if ! device_has_ipv4 "${wifi_device}"; then + warn "WLAN-Verbindung besitzt keine IPv4-Adresse." + return 1 + fi + + log "WLAN-Verbindung wurde hergestellt." + + if ! server_is_reachable; then + warn "WLAN ist verbunden, aber der Tuxflotte-Server ist nicht erreichbar." + return 1 + fi + + log "Tuxflotte-Server ist über WLAN erreichbar." +} + +store_network_state() { + local device + local connection_name + local device_type + local connection_uuid="" + + device="$(get_active_device)" || + fatal "Kein aktives Provisionierungsinterface gefunden." + + connection_name="$( + get_active_connection_name "${device}" || true + )" + + device_type="$(get_device_type "${device}")" + + if [[ -n "${connection_name}" && "${connection_name}" != "--" ]]; then + connection_uuid="$( + "${NMCLI}" \ + --get-values connection.uuid \ + connection show "${connection_name}" 2>/dev/null | + head -n 1 + )" + else + connection_name="" + warn "Interface ${device} wird nicht durch ein aktives NetworkManager-Profil verwaltet." + fi + + umask 077 + + { + printf 'TUXFLOTTE_NETWORK_DEVICE=%q\n' "${device}" + printf 'TUXFLOTTE_NETWORK_TYPE=%q\n' "${device_type}" + printf 'TUXFLOTTE_CONNECTION_NAME=%q\n' "${connection_name}" + printf 'TUXFLOTTE_CONNECTION_UUID=%q\n' "${connection_uuid}" + printf 'TUXFLOTTE_SERVER_URL=%q\n' "${SERVER_URL}" + } >"${STATE_FILE}" + + chmod 0600 "${STATE_FILE}" + + log "Netzwerkstatus wurde unter ${STATE_FILE} gespeichert." +} + +export_connection_profile() { + local device + local connection_name + local source_file + + device="$(get_active_device)" || + fatal "Kein aktives Provisionierungsinterface gefunden." + + connection_name="$( + get_active_connection_name "${device}" || true + )" + + if [[ -z "${connection_name}" || "${connection_name}" == "--" ]]; then + warn "Für Interface ${device} existiert kein aktives NetworkManager-Profil." + return 0 + fi + + source_file="$( + "${NMCLI}" \ + --get-values connection.filename \ + connection show "${connection_name}" 2>/dev/null | + head -n 1 || true + )" + + if [[ -z "${source_file}" || ! -f "${source_file}" ]]; then + warn "NetworkManager-Profil konnte nicht exportiert werden." + return 0 + fi + + install \ + --mode=0600 \ + --owner=root \ + --group=root \ + "${source_file}" \ + "${CONNECTION_EXPORT}" + + log "Aktives Verbindungsprofil wurde geschützt vorgemerkt." +} + +main() { + require_root + require_command "${NMCLI}" + require_command "${CURL}" + + prepare_runtime_directory + start_networkmanager_if_possible + enable_networking + + log "Prüfe vorhandene Netzwerkverbindungen." + + if check_existing_connection; then + : + elif try_ethernet; then + : + else + local wifi_device + + wifi_device="$(get_wifi_device || true)" + + if [[ -z "${wifi_device}" ]]; then + fatal "Keine funktionierende Ethernet-Verbindung und keine WLAN-Hardware gefunden." + fi + + log "Ethernet ist nicht verfügbar. WLAN-Initialisierung wird gestartet." + + if [[ -n "${TUXFLOTTE_WIFI_SSID:-}" ]]; then + connect_wifi_noninteractive "${wifi_device}" || + fatal "Es konnte keine Verbindung zum Tuxflotte-Server hergestellt werden." + else + connect_wifi "${wifi_device}" || + fatal "Es konnte keine Verbindung zum Tuxflotte-Server hergestellt werden." + fi + fi + + store_network_state + export_connection_profile + + log "Netzwerkinitialisierung erfolgreich abgeschlossen." +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/10_hardware.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/10_hardware.sh new file mode 100755 index 0000000..56e9183 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/10_hardware.sh @@ -0,0 +1,550 @@ +#!/usr/bin/env bash + +# Tuxflotte Installer +# Phase 2 – Hardware- und Geräteidentität +# +# Ermittelt: +# - DMI-/SMBIOS-Daten +# - System-UUID und Seriennummer +# - CPU-Architektur +# - physische Netzwerkinterfaces und MAC-Adressen +# - TPM-Verfügbarkeit +# - UEFI- und Secure-Boot-Status +# +# Ausgabe: +# /run/tuxflotte/hardware/hardware.json + +set -Eeuo pipefail + +SCRIPT_DIR="$( + cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && + pwd +)" +readonly SCRIPT_DIR + +readonly COLLECTORS_FILE="${SCRIPT_DIR}/../lib/hardware_collectors.sh" + +if [[ ! -r "${COLLECTORS_FILE}" ]]; then + printf '[%s] FEHLER: Collector-Library nicht gefunden: %s\n' \ + "${0##*/}" \ + "${COLLECTORS_FILE}" >&2 + exit 1 +fi + +# shellcheck source=../lib/hardware_collectors.sh +source "${COLLECTORS_FILE}" + +readonly SCRIPT_NAME="${0##*/}" + +readonly RUNTIME_DIR="/run/tuxflotte/hardware" +readonly HARDWARE_FILE="${RUNTIME_DIR}/hardware.json" + +readonly SYS_DMI_DIR="/sys/class/dmi/id" +readonly SYS_NET_DIR="/sys/class/net" +readonly EFI_VARS_DIR="/sys/firmware/efi/efivars" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +warn() { + printf '[%s] WARNUNG: %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Hardwaremodul muss als root ausgeführt werden." + fi +} + +require_command() { + local command_name="$1" + + command -v "${command_name}" >/dev/null 2>&1 || + fatal "Benötigtes Programm nicht gefunden: ${command_name}" +} + +prepare_runtime_directory() { + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${HARDWARE_FILE}" +} + +read_trimmed_file() { + local file="$1" + local value + + if [[ ! -r "${file}" ]]; then + return 0 + fi + + value="$(tr -d '\000' <"${file}")" + + value="$( + printf '%s' "${value}" | + sed \ + -e 's/^[[:space:]]*//' \ + -e 's/[[:space:]]*$//' + )" + + printf '%s' "${value}" +} + +read_dmi_value() { + local name="$1" + + read_trimmed_file "${SYS_DMI_DIR}/${name}" +} + +normalize_uuid() { + local value="$1" + + value="${value,,}" + + case "${value}" in + ""|\ + "none"|\ + "not specified"|\ + "to be filled by o.e.m."|\ + "00000000-0000-0000-0000-000000000000"|\ + "ffffffff-ffff-ffff-ffff-ffffffffffff") + return 0 + ;; + esac + + printf '%s' "${value}" +} + +normalize_serial() { + local value="$1" + local normalized + + normalized="${value,,}" + + case "${normalized}" in + ""|\ + "none"|\ + "unknown"|\ + "not specified"|\ + "default string"|\ + "system serial number"|\ + "to be filled by o.e.m.") + return 0 + ;; + esac + + printf '%s' "${value}" +} + +get_machine_id() { + local candidate + + for candidate in \ + /etc/machine-id \ + /var/lib/dbus/machine-id + do + if [[ -r "${candidate}" ]]; then + read_trimmed_file "${candidate}" + return 0 + fi + done +} + +get_architecture() { + uname -m +} + +get_boot_mode() { + if [[ -d /sys/firmware/efi ]]; then + printf 'uefi' + else + printf 'bios' + fi +} + +get_secure_boot_state() { + local secure_boot_file + local value + + if [[ ! -d /sys/firmware/efi ]]; then + printf 'unsupported' + return 0 + fi + + secure_boot_file="$( + find "${EFI_VARS_DIR}" \ + -maxdepth 1 \ + -type f \ + -name 'SecureBoot-*' \ + -print \ + -quit 2>/dev/null || true + )" + + if [[ -z "${secure_boot_file}" || ! -r "${secure_boot_file}" ]]; then + printf 'unknown' + return 0 + fi + + value="$( + od \ + --address-radix=n \ + --format=u1 \ + --skip-bytes=4 \ + --read-bytes=1 \ + "${secure_boot_file}" 2>/dev/null | + tr -d '[:space:]' + )" + + case "${value}" in + 1) + printf 'enabled' + ;; + 0) + printf 'disabled' + ;; + *) + printf 'unknown' + ;; + esac +} + +get_tpm_version() { + if [[ ! -e /dev/tpm0 && ! -e /dev/tpmrm0 ]]; then + printf 'none' + return 0 + fi + + if [[ -r /sys/class/tpm/tpm0/tpm_version_major ]]; then + read_trimmed_file /sys/class/tpm/tpm0/tpm_version_major + return 0 + fi + + if [[ -r /sys/class/tpm/tpm0/device/description ]]; then + local description + + description="$( + read_trimmed_file /sys/class/tpm/tpm0/device/description + )" + + case "${description}" in + *2.0*) + printf '2' + ;; + *1.2*) + printf '1.2' + ;; + *) + printf 'unknown' + ;; + esac + + return 0 + fi + + printf 'unknown' +} + +interface_is_physical() { + local interface="$1" + + [[ "${interface}" != "lo" ]] || return 1 + [[ -e "${SYS_NET_DIR}/${interface}/device" ]] || return 1 + [[ -r "${SYS_NET_DIR}/${interface}/address" ]] || return 1 +} + +get_interface_type() { + local interface="$1" + + if [[ -d "${SYS_NET_DIR}/${interface}/wireless" ]]; then + printf 'wifi' + else + printf 'ethernet' + fi +} + +build_network_interfaces_json() { + local interface + local mac + local type + local -a interfaces=() + + for interface_path in "${SYS_NET_DIR}"/*; do + [[ -e "${interface_path}" ]] || continue + + interface="${interface_path##*/}" + + interface_is_physical "${interface}" || continue + + mac="$(read_trimmed_file "${interface_path}/address")" + type="$(get_interface_type "${interface}")" + + [[ -n "${mac}" ]] || continue + + interfaces+=("$( + jq \ + --null-input \ + --arg name "${interface}" \ + --arg type "${type}" \ + --arg mac "${mac,,}" \ + '{ + name: $name, + type: $type, + mac: $mac + }' + )") + done + + if [[ "${#interfaces[@]}" -eq 0 ]]; then + printf '[]' + return 0 + fi + + printf '%s\n' "${interfaces[@]}" | + jq --slurp 'sort_by(.type, .name)' +} + +build_device_fingerprint() { + local system_uuid="$1" + local system_serial="$2" + local board_serial="$3" + local interfaces_json="$4" + local identity_material + local mac_addresses + + mac_addresses="$( + jq \ + --raw-output \ + '.[].mac // empty' \ + <<<"${interfaces_json}" | + tr '[:upper:]' '[:lower:]' | + sort -u | + paste -sd ',' - + )" + + identity_material="$( + printf 'system_uuid=%s\n' "${system_uuid,,}" + printf 'system_serial=%s\n' "${system_serial,,}" + printf 'board_serial=%s\n' "${board_serial,,}" + printf 'mac_addresses=%s\n' "${mac_addresses}" + )" + + printf '%s' "${identity_material}" | + sha256sum | + awk '{ print $1 }' +} + +build_hardware_json() { + local system_uuid + local system_serial + local machine_id + local manufacturer + local product_name + local product_version + local board_vendor + local board_name + local board_serial + local bios_vendor + local bios_version + local architecture + local boot_mode + local secure_boot + local tpm_version + local interfaces_json + local device_fingerprint + local cpu_model + local cpu_count + local memory_bytes + local storage_devices_json + + system_uuid="$(normalize_uuid "$(read_dmi_value product_uuid)")" + system_serial="$(normalize_serial "$(read_dmi_value product_serial)")" + machine_id="$(get_machine_id)" + + manufacturer="$(read_dmi_value sys_vendor)" + product_name="$(read_dmi_value product_name)" + product_version="$(read_dmi_value product_version)" + + board_vendor="$(read_dmi_value board_vendor)" + board_name="$(read_dmi_value board_name)" + board_serial="$(normalize_serial "$(read_dmi_value board_serial)")" + + bios_vendor="$(read_dmi_value bios_vendor)" + bios_version="$(read_dmi_value bios_version)" + + architecture="$(get_architecture)" + boot_mode="$(get_boot_mode)" + secure_boot="$(get_secure_boot_state)" + tpm_version="$(get_tpm_version)" + cpu_model="$(get_cpu_model)" + cpu_count="$(get_cpu_count)" + memory_bytes="$(get_memory_bytes)" + + interfaces_json="$(build_network_interfaces_json)" + storage_devices_json="$(build_storage_devices_json)" + device_fingerprint="$( + build_device_fingerprint \ + "${system_uuid}" \ + "${system_serial}" \ + "${board_serial}" \ + "${interfaces_json}" + )" + + jq \ + --null-input \ + --arg schema_version "1" \ + --arg device_fingerprint "${device_fingerprint}" \ + --arg system_uuid "${system_uuid}" \ + --arg system_serial "${system_serial}" \ + --arg machine_id "${machine_id}" \ + --arg manufacturer "${manufacturer}" \ + --arg product_name "${product_name}" \ + --arg product_version "${product_version}" \ + --arg board_vendor "${board_vendor}" \ + --arg board_name "${board_name}" \ + --arg board_serial "${board_serial}" \ + --arg bios_vendor "${bios_vendor}" \ + --arg bios_version "${bios_version}" \ + --arg architecture "${architecture}" \ + --arg boot_mode "${boot_mode}" \ + --arg secure_boot "${secure_boot}" \ + --arg tpm_version "${tpm_version}" \ + --arg cpu_model "${cpu_model}" \ + --argjson cpu_count "${cpu_count}" \ + --argjson memory_bytes "${memory_bytes}" \ + --argjson network_interfaces "${interfaces_json}" \ + --argjson storage_devices "${storage_devices_json}" \ + '{ + schema_version: ($schema_version | tonumber), + + identity: { + device_fingerprint: $device_fingerprint, + system_uuid: ( + if $system_uuid == "" then null else $system_uuid end + ), + system_serial: ( + if $system_serial == "" then null else $system_serial end + ), + board_serial: ( + if $board_serial == "" then null else $board_serial end + ), + machine_id: ( + if $machine_id == "" then null else $machine_id end + ) + }, + + system: { + manufacturer: ( + if $manufacturer == "" then null else $manufacturer end + ), + product_name: ( + if $product_name == "" then null else $product_name end + ), + product_version: ( + if $product_version == "" then null else $product_version end + ), + architecture: $architecture, + cpu: { + model: ( + if $cpu_model == "" then null else $cpu_model end + ), + logical_count: $cpu_count + }, + memory_bytes: $memory_bytes, + }, + + mainboard: { + vendor: ( + if $board_vendor == "" then null else $board_vendor end + ), + name: ( + if $board_name == "" then null else $board_name end + ) + }, + + firmware: { + bios_vendor: ( + if $bios_vendor == "" then null else $bios_vendor end + ), + bios_version: ( + if $bios_version == "" then null else $bios_version end + ), + boot_mode: $boot_mode, + secure_boot: $secure_boot + }, + + security: { + tpm_version: $tpm_version + }, + + network_interfaces: $network_interfaces, + storage_devices: $storage_devices + }' +} + +validate_hardware_identity() { + local uuid + local serial + local board_serial + local mac_count + + uuid="$(jq -r '.identity.system_uuid // empty' "${HARDWARE_FILE}")" + serial="$(jq -r '.identity.system_serial // empty' "${HARDWARE_FILE}")" + board_serial="$(jq -r '.identity.board_serial // empty' "${HARDWARE_FILE}")" + mac_count="$(jq '.network_interfaces | length' "${HARDWARE_FILE}")" + + if [[ -z "${uuid}" && + -z "${serial}" && + -z "${board_serial}" && + "${mac_count}" -eq 0 ]]; then + fatal "Es konnte kein stabiles Hardwaremerkmal ermittelt werden." + fi + + if [[ -z "${uuid}" ]]; then + warn "Das Gerät stellt keine verwertbare System-UUID bereit." + fi + + if [[ -z "${serial}" ]]; then + warn "Das Gerät stellt keine verwertbare Systemseriennummer bereit." + fi +} + +main() { + require_root + require_command jq + require_command uname + require_command sed + require_command find + require_command od + require_command sha256sum + require_command sort + require_command paste + require_command tr + require_command awk + + prepare_runtime_directory + + log "Ermittle Hardware- und Geräteidentität." + + umask 077 + build_hardware_json >"${HARDWARE_FILE}" + + chmod 0600 "${HARDWARE_FILE}" + + jq --exit-status . "${HARDWARE_FILE}" >/dev/null || + fatal "Die erzeugte Hardwaredatei enthält kein gültiges JSON." + + validate_hardware_identity + + log "Hardwareinformationen wurden unter ${HARDWARE_FILE} gespeichert." +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/12_enrollment_auth.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/12_enrollment_auth.sh new file mode 100755 index 0000000..824cce9 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/12_enrollment_auth.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly SCRIPT_NAME="${0##*/}" + +readonly RUNTIME_DIR="/run/tuxflotte/enrollment" +readonly AUTHORIZATION_FILE="${RUNTIME_DIR}/authorization.json" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Enrollment-Autorisierungsmodul muss als root ausgeführt werden." + fi +} + +prepare_runtime_directory() { + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${AUTHORIZATION_FILE}" +} + +store_bootstrap_authorization() { + local activation_code + + if [[ -n "${TUXFLOTTE_ACTIVATION_CODE:-}" ]]; then + activation_code="${TUXFLOTTE_ACTIVATION_CODE}" + else + printf '\n' + read -r -p "Temporären Aktivierungscode eingeben: " activation_code + fi + + [[ -n "${activation_code}" ]] || + fatal "Es wurde kein Aktivierungscode angegeben." + + jq \ + --null-input \ + --arg activation_code "${activation_code}" \ + '{ + schema_version: 1, + authorization_type: "bootstrap_activation_code", + activation_code: $activation_code + }' >"${AUTHORIZATION_FILE}" + + chmod 0600 "${AUTHORIZATION_FILE}" +} + +validate_authorization() { + jq --exit-status ' + .schema_version == 1 + and .authorization_type == "bootstrap_activation_code" + and (.activation_code | type == "string") + and (.activation_code | length > 0) + ' "${AUTHORIZATION_FILE}" >/dev/null || + fatal "Enrollment-Autorisierung ist ungültig." +} + +main() { + require_root + prepare_runtime_directory + store_bootstrap_authorization + validate_authorization + + log "Temporäre Bootstrap-Autorisierung wurde vorbereitet." +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/15_server_handshake.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/15_server_handshake.sh new file mode 100755 index 0000000..ed8bbd0 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/15_server_handshake.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly SCRIPT_NAME="${0##*/}" + +readonly NETWORK_STATE="/run/tuxflotte/network/state.env" +readonly HARDWARE_FILE="/run/tuxflotte/hardware/hardware.json" + +readonly RUNTIME_DIR="/run/tuxflotte/server" +readonly ACTIVATION_FILE="${RUNTIME_DIR}/activation.json" +readonly AUTHORIZATION_FILE="/run/tuxflotte/enrollment/authorization.json" +readonly RESPONSE_FILE="${RUNTIME_DIR}/response.json" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Server-Handshake-Modul muss als root ausgeführt werden." + fi +} + +prepare_runtime_directory() { + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${ACTIVATION_FILE}" "${RESPONSE_FILE}" +} + +validate_inputs() { + [[ -r "${NETWORK_STATE}" ]] || + fatal "Netzwerkstatus nicht gefunden: ${NETWORK_STATE}" + + [[ -r "${HARDWARE_FILE}" ]] || + fatal "Hardwareinformationen nicht gefunden: ${HARDWARE_FILE}" + + jq --exit-status . "${HARDWARE_FILE}" >/dev/null || + fatal "Hardwaredatei enthält kein gültiges JSON." + + [[ -r "${AUTHORIZATION_FILE}" ]] || + fatal "Enrollment-Autorisierung nicht gefunden: ${AUTHORIZATION_FILE}" + + jq --exit-status ' + .schema_version == 1 + and .authorization_type == "bootstrap_activation_code" + and (.activation_code | type == "string") + and (.activation_code | length > 0) + ' "${AUTHORIZATION_FILE}" >/dev/null || + fatal "Enrollment-Autorisierung ist ungültig." +} + +build_activation_request() { + local activation_code="$1" + local hostname + local machine_id + + hostname="$(hostname)" + machine_id="$( + jq --raw-output \ + '.identity.machine_id // empty' \ + "${HARDWARE_FILE}" + )" + + jq \ + --null-input \ + --arg activation_code "${activation_code}" \ + --arg hostname "${hostname}" \ + --arg machine_id "${machine_id}" \ + --arg client_version "0.1.0" \ + --slurpfile hardware "${HARDWARE_FILE}" \ + '{ + activation_code: $activation_code, + device_fingerprint: $hardware[0].identity.device_fingerprint, + hostname: $hostname, + machine_id: ( + if $machine_id == "" + then null + else $machine_id + end + ), + client_version: $client_version, + hardware: $hardware[0] + }' +} + +send_activation_request() { + local server_url + + # shellcheck disable=SC1090 + source "${NETWORK_STATE}" + + server_url="${TUXFLOTTE_SERVER_URL%/health}" + + curl \ + --silent \ + --show-error \ + --fail \ + --location \ + --header 'Content-Type: application/json' \ + --data-binary "@${ACTIVATION_FILE}" \ + --output "${RESPONSE_FILE}" \ + "${server_url}/api/v1/activate" || + fatal "Provisioning-Server konnte nicht erfolgreich kontaktiert werden." + + chmod 0600 "${RESPONSE_FILE}" + + jq --exit-status . "${RESPONSE_FILE}" >/dev/null || + fatal "Serverantwort enthält kein gültiges JSON." + + jq --exit-status '.success == true' "${RESPONSE_FILE}" >/dev/null || + fatal "Provisioning-Server hat die Aktivierung abgelehnt." + + log "Provisioning-Handshake erfolgreich abgeschlossen." +} + +main() { + require_root + prepare_runtime_directory + validate_inputs + + log "Eingabedaten für den Provisioning-Handshake sind gültig." + + local activation_code + + local activation_code + + activation_code="$( + jq --raw-output \ + '.activation_code' \ + "${AUTHORIZATION_FILE}" + )" + + build_activation_request "${activation_code}" >"${ACTIVATION_FILE}" + + chmod 0600 "${ACTIVATION_FILE}" + + jq --exit-status . "${ACTIVATION_FILE}" >/dev/null || + fatal "Aktivierungsrequest enthält kein gültiges JSON." + + log "Aktivierungsrequest wurde unter ${ACTIVATION_FILE} gespeichert." + + send_activation_request + +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/17_device_status.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/17_device_status.sh new file mode 100755 index 0000000..80fe5f1 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/17_device_status.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +readonly SCRIPT_NAME + +readonly SERVER_RESPONSE_FILE="/run/tuxflotte/server/response.json" +readonly RUNTIME_DIR="/run/tuxflotte/provisioning" +readonly STATE_FILE="${RUNTIME_DIR}/state.env" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Gerätestatusmodul muss als root ausgeführt werden." + fi +} + +prepare_runtime() { + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${STATE_FILE}" +} + +store_provisioning_state() { + local continue_provisioning="$1" + + umask 077 + + printf 'TUXFLOTTE_PROVISIONING_CONTINUE=%s\n' \ + "${continue_provisioning}" >"${STATE_FILE}" + + chmod 0600 "${STATE_FILE}" +} + +validate_server_response() { + [[ -r "${SERVER_RESPONSE_FILE}" ]] || + fatal "Serverantwort nicht gefunden: ${SERVER_RESPONSE_FILE}" + + jq --exit-status ' + .success == true + and (.device | type == "object") + and (.device.registration_status == "existing" + or .device.registration_status == "registered") + and (.customer | type == "object") + ' "${SERVER_RESPONSE_FILE}" >/dev/null || + fatal "Serverantwort enthält keinen gültigen Gerätestatus." +} + +show_device_status() { + local registration_status + local hostname + local organization_name + + registration_status="$( + jq -r '.device.registration_status' "${SERVER_RESPONSE_FILE}" + )" + + hostname="$( + jq -r '.device.hostname // "unbekannt"' "${SERVER_RESPONSE_FILE}" + )" + + organization_name="$( + jq -r '.customer.name // "unbekannt"' "${SERVER_RESPONSE_FILE}" + )" + + printf '\n' + + case "${registration_status}" in + existing) + printf 'Bekanntes Gerät erkannt\n' + printf '========================\n\n' + printf 'Gerät: %s\n' "${hostname}" + printf 'Organisation: %s\n' "${organization_name}" + printf '\n' + printf 'Das Gerät ist bereits registriert.\n' + ;; + registered) + printf 'Neues Gerät registriert\n' + printf '=======================\n\n' + printf 'Gerät: %s\n' "${hostname}" + printf 'Organisation: %s\n' "${organization_name}" + printf '\n' + printf 'Hinweis:\n' + printf 'Die Registrierung erfolgte über den temporären Bootstrap-Aktivierungsmechanismus.\n' + ;; + esac +} + +confirm_provisioning() { + local answer + + if [[ "${TUXFLOTTE_AUTO_MODE:-false}" == "true" ]]; then + store_provisioning_state true + log "Auto-Modus: Provisionierung wird ohne Rückfrage fortgesetzt." + return 0 + fi + + printf '\n' + read -r -p "Provisionierung fortsetzen? [j/N]: " answer + + case "${answer}" in + j|J|ja|JA|Ja) + store_provisioning_state true + log "Provisionierung wird fortgesetzt." + ;; + *) + store_provisioning_state false + log "Provisionierung wurde durch den Benutzer beendet." + ;; + esac +} + +main() { + require_root + prepare_runtime + validate_server_response + show_device_status + confirm_provisioning +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_profile_selection.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_profile_selection.sh new file mode 100755 index 0000000..20b3dc2 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_profile_selection.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +readonly SCRIPT_NAME + +readonly SERVER_RESPONSE_FILE="/run/tuxflotte/server/response.json" +readonly RUNTIME_DIR="/run/tuxflotte/assignment" +readonly TEMPLATE_FILE="${RUNTIME_DIR}/template.json" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Profilauswahlmodul muss als root ausgeführt werden." + fi +} + +prepare_runtime() { + install -d -m 0700 "${RUNTIME_DIR}" + rm -f -- "${TEMPLATE_FILE}" +} + +validate_server_response() { + [[ -r "${SERVER_RESPONSE_FILE}" ]] || + fatal "Serverantwort nicht gefunden: ${SERVER_RESPONSE_FILE}" + + jq --exit-status . "${SERVER_RESPONSE_FILE}" >/dev/null || + fatal "Serverantwort enthält kein gültiges JSON." + + jq --exit-status ' + .success == true + and (.templates | type == "array") + and (.templates | length > 0) + ' "${SERVER_RESPONSE_FILE}" >/dev/null || + fatal "Serverantwort enthält keine auswählbaren Bereitstellungsvorlagen." +} + +show_templates() { + local index=1 + local template + + log "Verfügbare Bereitstellungsvorlagen:" + + while IFS= read -r template; do + printf '\n' + printf ' %d) %s' \ + "${index}" \ + "$(jq -r '.label' <<<"${template}")" + + if [[ "$(jq -r '.is_default' <<<"${template}")" == "true" ]]; then + printf ' (Standard)' + fi + + printf '\n' + + printf ' Workspace: %s\n' \ + "$(jq -r '.workspace.name' <<<"${template}")" + + printf ' Backend: %s %s\n' \ + "$(jq -r '.backend.name' <<<"${template}")" \ + "$(jq -r '.backend.version' <<<"${template}")" + + ((index += 1)) + done < <(jq -c '.templates[]' "${SERVER_RESPONSE_FILE}") +} + +select_template_auto() { + local default_index + + # Kein spezifischer Server-Hinweis vorhanden (Enrollment Sessions legen + # serverseitig zwar schon eine Bereitstellungsvorlage fest, die + # Verbrauchslogik dafür existiert aber noch nicht - siehe Phase 3 im + # Plan) - bis dahin wird im Auto-Modus die als is_default markierte + # Vorlage gewählt. + default_index="$( + jq '[.templates[] | .is_default] | index(true)' "${SERVER_RESPONSE_FILE}" + )" + + [[ "${default_index}" != "null" ]] || + fatal "Auto-Modus: keine Standard-Bereitstellungsvorlage in der Serverantwort markiert." + + jq \ + --argjson index "${default_index}" \ + '{ + schema_version: 1, + template: .templates[$index] + }' \ + "${SERVER_RESPONSE_FILE}" >"${TEMPLATE_FILE}" + + log "Auto-Modus: Standard-Bereitstellungsvorlage automatisch gewählt." +} + +select_template_interactive() { + local template_count + local selection + + template_count="$(jq '.templates | length' "${SERVER_RESPONSE_FILE}")" + + while true; do + printf '\n' + read -r -p "Bereitstellungsvorlage auswählen [1-${template_count}]: " selection + + if [[ "${selection}" =~ ^[0-9]+$ ]] && + ((selection >= 1 && selection <= template_count)); then + break + fi + + log "Ungültige Auswahl." + done + + jq \ + --argjson index "$((selection - 1))" \ + '{ + schema_version: 1, + template: .templates[$index] + }' \ + "${SERVER_RESPONSE_FILE}" >"${TEMPLATE_FILE}" +} + +select_template() { + if [[ "${TUXFLOTTE_AUTO_MODE:-false}" == "true" ]]; then + select_template_auto + else + select_template_interactive + fi + + chmod 0600 "${TEMPLATE_FILE}" + + jq --exit-status ' + .schema_version == 1 + and (.template | type == "object") + and (.template.id | type == "string") + and (.template.id | length > 0) + and (.template.workspace | type == "object") + and (.template.backend | type == "object") + ' "${TEMPLATE_FILE}" >/dev/null || + fatal "Ausgewählte Bereitstellungsvorlage konnte nicht gültig gespeichert werden." + + log "Bereitstellungsvorlage $(jq -r '.template.id' "${TEMPLATE_FILE}") wurde ausgewählt." + log "Auswahl wurde unter ${TEMPLATE_FILE} gespeichert." +} + +main() { + require_root + prepare_runtime + validate_server_response + show_templates + select_template +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_storage.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_storage.sh new file mode 100755 index 0000000..5bdbfdb --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/20_storage.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +readonly SCRIPT_NAME + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR + +# shellcheck source=../lib/checks.sh +source "${SCRIPT_DIR}/../lib/checks.sh" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +main() { + log "Datenträger werden erkannt..." + + local disks + disks="$(list_install_disks || true)" + + [[ -n "${disks}" ]] || + fatal "Keine geeigneten Datenträger erkannt." + + printf '%s\n' "${disks}" + + log "Phase 1: Datenträger werden nur angezeigt, nicht verändert." + log "Partitionierung ist noch deaktiviert." +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/25_installation_confirm.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/25_installation_confirm.sh new file mode 100755 index 0000000..e5d70aa --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/25_installation_confirm.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +readonly SCRIPT_NAME + +readonly TEMPLATE_FILE="/run/tuxflotte/assignment/template.json" + +readonly RUNTIME_DIR="/run/tuxflotte/installation" +readonly STATE_FILE="${RUNTIME_DIR}/state.env" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Installationsbestätigungsmodul muss als root ausgeführt werden." + fi +} + +prepare_runtime() { + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${STATE_FILE}" +} + +validate_template() { + [[ -r "${TEMPLATE_FILE}" ]] || + fatal "Bereitstellungsvorlage nicht gefunden: ${TEMPLATE_FILE}" + + jq --exit-status ' + .schema_version == 1 + and (.template | type == "object") + and (.template.id | type == "string") + and (.template.label | type == "string") + and (.template.workspace.name | type == "string") + and (.template.backend.name | type == "string") + and (.template.backend.version | type == "string") + ' "${TEMPLATE_FILE}" >/dev/null || + fatal "Bereitstellungsvorlage enthält keine gültige Auswahl." +} + +show_installation_plan() { + local label + local workspace + local backend_name + local backend_version + + label="$(jq -r '.template.label' "${TEMPLATE_FILE}")" + workspace="$(jq -r '.template.workspace.name' "${TEMPLATE_FILE}")" + backend_name="$(jq -r '.template.backend.name' "${TEMPLATE_FILE}")" + backend_version="$(jq -r '.template.backend.version' "${TEMPLATE_FILE}")" + + printf '\n' + printf 'Geplanter Installationsvorgang\n' + printf '==============================\n\n' + printf 'Bereitstellungsvorlage: %s\n' "${label}" + printf 'Workspace: %s\n' "${workspace}" + printf 'Backend: %s %s\n' "${backend_name}" "${backend_version}" + printf '\n' + printf 'Die eigentliche Installation kann lokale Datenträger verändern.\n' + printf 'Bis zu dieser Bestätigung wurden keine destruktiven Installationsaktionen gestartet.\n' + printf '\n' +} + +store_confirmation_state() { + local confirmed="$1" + + umask 077 + + printf 'TUXFLOTTE_INSTALLATION_CONFIRMED=%s\n' \ + "${confirmed}" >"${STATE_FILE}" + + chmod 0600 "${STATE_FILE}" +} + +confirm_installation() { + local answer + + if [[ "${TUXFLOTTE_AUTO_MODE:-false}" == "true" ]]; then + store_confirmation_state true + log "Auto-Modus: Installation wird ohne Rückfrage gestartet." + return 0 + fi + + read -r -p "Installation jetzt starten? [j/N]: " answer + + case "${answer}" in + j|J|ja|JA|Ja) + store_confirmation_state true + log "Installation wurde durch den Benutzer bestätigt." + ;; + *) + store_confirmation_state false + log "Installation wurde durch den Benutzer abgebrochen." + log "Es wurden keine destruktiven Installationsaktionen gestartet." + ;; + esac +} + +main() { + require_root + prepare_runtime + validate_template + show_installation_plan + confirm_installation +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/30_runtime_blueprint.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/30_runtime_blueprint.sh new file mode 100755 index 0000000..c206eae --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/30_runtime_blueprint.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +readonly SCRIPT_NAME + +readonly NETWORK_STATE="/run/tuxflotte/network/state.env" +readonly SERVER_RESPONSE_FILE="/run/tuxflotte/server/response.json" +readonly TEMPLATE_FILE="/run/tuxflotte/assignment/template.json" + +readonly RUNTIME_DIR="/run/tuxflotte/runtime" +readonly RESOLVE_REQUEST_FILE="${RUNTIME_DIR}/resolve_request.json" +readonly BLUEPRINT_FILE="${RUNTIME_DIR}/runtime_blueprint.json" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Runtime-Blueprint-Modul muss als root ausgeführt werden." + fi +} + +prepare_runtime_directory() { + install -d \ + --mode=0700 \ + --owner=root \ + --group=root \ + "${RUNTIME_DIR}" + + rm -f -- "${RESOLVE_REQUEST_FILE}" "${BLUEPRINT_FILE}" +} + +validate_inputs() { + [[ -r "${NETWORK_STATE}" ]] || + fatal "Netzwerkstatus nicht gefunden: ${NETWORK_STATE}" + + [[ -r "${SERVER_RESPONSE_FILE}" ]] || + fatal "Serverantwort nicht gefunden: ${SERVER_RESPONSE_FILE}" + + jq --exit-status '.device.id | type == "string" and length > 0' \ + "${SERVER_RESPONSE_FILE}" >/dev/null || + fatal "Serverantwort enthält keine gültige Geräte-ID." + + [[ -r "${TEMPLATE_FILE}" ]] || + fatal "Bereitstellungsvorlage nicht gefunden: ${TEMPLATE_FILE}" + + jq --exit-status '.template.id | type == "string" and length > 0' \ + "${TEMPLATE_FILE}" >/dev/null || + fatal "Bereitstellungsvorlage enthält keine gültige ID." +} + +build_resolve_request() { + jq \ + --null-input \ + --slurpfile response "${SERVER_RESPONSE_FILE}" \ + '{ device_id: $response[0].device.id }' \ + >"${RESOLVE_REQUEST_FILE}" + + chmod 0600 "${RESOLVE_REQUEST_FILE}" +} + +send_resolve_request() { + local server_url + local template_id + + # shellcheck disable=SC1090 + source "${NETWORK_STATE}" + + server_url="${TUXFLOTTE_SERVER_URL%/health}" + template_id="$(jq --raw-output '.template.id' "${TEMPLATE_FILE}")" + + curl \ + --silent \ + --show-error \ + --fail \ + --location \ + --header 'Content-Type: application/json' \ + --data-binary "@${RESOLVE_REQUEST_FILE}" \ + --output "${BLUEPRINT_FILE}" \ + "${server_url}/api/v1/templates/${template_id}/resolve" || + fatal "Runtime Blueprint konnte nicht aufgelöst werden." + + chmod 0600 "${BLUEPRINT_FILE}" + + jq --exit-status . "${BLUEPRINT_FILE}" >/dev/null || + fatal "Antwort auf die Runtime-Blueprint-Anfrage enthält kein gültiges JSON." + + jq --exit-status '.success == true' "${BLUEPRINT_FILE}" >/dev/null || + fatal "$(jq -r '.message // "Provisioning-Server hat die Auflösung abgelehnt."' "${BLUEPRINT_FILE}")" + + jq --exit-status ' + .runtime_blueprint + | (.workspace_id | type == "string") + and (.backend_id | type == "string") + and (.blueprints | type == "array") + and (.installation_directives | type == "object") + ' "${BLUEPRINT_FILE}" >/dev/null || + fatal "Runtime Blueprint enthält keine gültige Zielbeschreibung." + + log "Runtime Blueprint für Backend $(jq -r '.runtime_blueprint.backend_id' "${BLUEPRINT_FILE}") erzeugt." + log "Runtime Blueprint wurde unter ${BLUEPRINT_FILE} gespeichert." +} + +main() { + require_root + prepare_runtime_directory + validate_inputs + build_resolve_request + send_resolve_request +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/40_backend.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/40_backend.sh new file mode 100755 index 0000000..9bb7821 --- /dev/null +++ b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/40_backend.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +readonly SCRIPT_NAME + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +BACKENDS_DIR="$(cd "${SCRIPT_DIR}/../../backends" && pwd)" +readonly BACKENDS_DIR + +readonly ORCHESTRATOR_BLUEPRINT_FILE="/run/tuxflotte/runtime/runtime_blueprint.json" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 +} + +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fatal "Das Backend-Modul muss als root ausgeführt werden." + fi +} + +load_backend() { + local backend_id + local backend_script + + [[ -r "${ORCHESTRATOR_BLUEPRINT_FILE}" ]] || + fatal "Runtime Blueprint nicht gefunden: ${ORCHESTRATOR_BLUEPRINT_FILE}" + + backend_id="$(jq --raw-output '.runtime_blueprint.backend_id // empty' "${ORCHESTRATOR_BLUEPRINT_FILE}")" + [[ -n "${backend_id}" ]] || + fatal "Runtime Blueprint enthält keine gültige Backend-ID." + + backend_script="${BACKENDS_DIR}/${backend_id}/backend.sh" + [[ -r "${backend_script}" ]] || + fatal "Kein Backend für '${backend_id}' gefunden: ${backend_script}" + + log "Lade Backend '${backend_id}' aus ${backend_script}" + + # shellcheck disable=SC1090 + source "${backend_script}" +} + +run_lifecycle() { + local step + + for step in backend_init backend_validate backend_generate_config backend_launch backend_postinstall; do + declare -f "${step}" >/dev/null || + fatal "Backend implementiert erforderliche Funktion nicht: ${step}" + + log "Führe ${step}() aus." + + "${step}" || + fatal "${step}() ist fehlgeschlagen." + done +} + +main() { + require_root + load_backend + run_lifecycle +} + +main "$@" diff --git a/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/99_finish.sh b/boot-medium/config/includes.chroot/opt/tuxflotte/scripts/modules/99_finish.sh new file mode 100755 index 0000000..e69de29 diff --git a/scripts/modules/00_preflight.sh b/scripts/modules/00_preflight.sh index 33ce623..d8592e8 100755 --- a/scripts/modules/00_preflight.sh +++ b/scripts/modules/00_preflight.sh @@ -3,22 +3,23 @@ # Tuxflotte Installer # Phase 0 – Preflight # -# Stellt sicher, dass Werkzeuge vorhanden sind, die spaetere Module (ab -# 10_hardware.sh) brauchen, aber auf dem Live-Medium selbst (anders als im -# Zielsystem, siehe pkgsel/include in preseed.tpl) nicht vorinstalliert sind. +# Prueft, dass Werkzeuge vorhanden sind, die spaetere Module (ab +# 10_hardware.sh) brauchen. jq ist der einzige hier betroffene Fall: +# 10_hardware.sh, 12_enrollment_auth.sh, 15_server_handshake.sh, +# 17_device_status.sh, 20_profile_selection.sh, 25_installation_confirm.sh +# und 30_runtime_blueprint.sh nutzen es alle. # -# jq ist der einzige hier betroffene Fall: 10_hardware.sh, 12_enrollment_auth.sh, -# 15_server_handshake.sh, 17_device_status.sh, 20_profile_selection.sh, -# 25_installation_confirm.sh und 30_runtime_blueprint.sh nutzen es alle, das -# erste davon (10_hardware.sh) bereits deutlich vor 40_backend.sh, wo -# backend_init() denselben Nachinstallations-Mechanismus fuer den Kexec-Pfad -# schon kennt (siehe backends/mint/backend.sh) - hier zu spaet fuer die -# frueheren Module. Real entdeckt: beim automatisierten Start ueber +# Historisch (bis zum Umstieg auf das eigenstaendige, per live-build gebaute +# Boot-Medium) wurde jq hier noch zur Laufzeit per apt-get nachinstalliert, +# weil das damalige Boot-Medium (eine gepatchte Linux-Mint-Live-ISO) es nicht +# mitbrachte - real entdeckt: beim automatisierten Start ueber # start-kiosk.sh (kein Terminal, keine sichtbare Fehlermeldung) blieb der -# Installer bereits in 10_hardware.sh mit "Benoetigtes Programm nicht -# gefunden: jq" haengen, sichtbar nur in ~/.xsession-errors - manuelle Testlaeufe -# in dieser Session sind daran nie gescheitert, weil jq dabei stets vorab von -# Hand nachinstalliert wurde, bevor installer.sh gestartet wurde. +# Installer in 10_hardware.sh mit "Benoetigtes Programm nicht gefunden: jq" +# haengen, sichtbar nur in ~/.xsession-errors. Das eigenstaendige Boot-Medium +# bringt jq bereits im Paketsatz mit (siehe boot-medium/config/package-lists/ +# tuxflotte.list.chroot) - diese Pruefung bleibt trotzdem als reine Assertion +# bestehen, damit ein kuenftiger Paketlisten-Fehler hier fruh und klar auffaellt, +# statt erst kryptisch in 10_hardware.sh. set -Eeuo pipefail readonly SCRIPT_NAME="${0##*/}" @@ -27,14 +28,10 @@ log() { printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" >&2 } -if ! command -v jq >/dev/null 2>&1; then - log "jq fehlt auf dem Live-Medium, installiere nach." +fatal() { + printf '[%s] FEHLER: %s\n' "${SCRIPT_NAME}" "$*" >&2 + exit 1 +} - sed -i '/^deb cdrom/d' /etc/apt/sources.list 2>/dev/null || true - rm -f /etc/apt/sources.list.d/*cdrom* 2>/dev/null || true - - apt-get update -qq || - { log "FEHLER: apt-get update fehlgeschlagen."; exit 1; } - DEBIAN_FRONTEND=noninteractive apt-get install -y jq || - { log "FEHLER: Installation von jq fehlgeschlagen."; exit 1; } -fi +command -v jq >/dev/null 2>&1 || + fatal "jq fehlt auf dem Boot-Medium - Paketliste (tuxflotte.list.chroot) pruefen."