#!/usr/bin/env bash # Reproducible bootstrap for a fresh Debian 12/13 AI host. set -Eeuo pipefail umask 077 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="" STACK_DIR=/opt/mike-ai/stack SECRETS_DIR=/etc/mike-ai STATE_DIR=/srv/mike-ai log() { printf '\n==> %s\n' "$*"; } die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' Aufruf: sudo ./install.sh --config /root/mike-ai-install.env Das Skript ist idempotent und darf nach einem Treiber-Reboot erneut ausgeführt werden. Es formatiert keine Datenträger und löscht keine Modelle oder Volumes. EOF } while [[ $# -gt 0 ]]; do case "$1" in --config) CONFIG="${2:-}"; shift 2 ;; -h|--help) usage; exit 0 ;; *) die "Unbekanntes Argument: $1" ;; esac done [[ $EUID -eq 0 ]] || die "Bitte als root ausführen." [[ -n "$CONFIG" && -f "$CONFIG" ]] || die "Konfigurationsdatei fehlt (--config)." config_mode=$(stat -c '%a' "$CONFIG") if (( (8#$config_mode & 077) != 0 )); then die "Konfigurationsdatei darf nicht für Gruppe/Andere schreib- oder lesbar sein (chmod 600)." fi # shellcheck disable=SC1090 source "$CONFIG" required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL MEDIUM_MODEL_SHA256 LARGE_MODEL_FILE LARGE_MODEL_URL LARGE_MODEL_SHA256 ULTRA_MODEL_FILE ULTRA_MODEL_URL ULTRA_MODEL_SHA256 UNCENSORED_MODEL_FILE UNCENSORED_MODEL_URL UNCENSORED_MODEL_SHA256 UNCENSORED_PROJECTOR_FILE UNCENSORED_PROJECTOR_URL UNCENSORED_PROJECTOR_SHA256 VISION_PROJECTOR_FILE VISION_PROJECTOR_URL VISION_PROJECTOR_SHA256) for name in "${required[@]}"; do [[ -n "${!name:-}" ]] || die "Pflichtwert $name fehlt." done if [[ ${WIREGUARD_MODE:-container} != container ]]; then [[ -n ${AI_BIND_ADDRESS:-} ]] || die "Pflichtwert AI_BIND_ADDRESS fehlt." fi source /etc/os-release [[ ${ID:-} == debian ]] || die "Unterstützt wird Debian, gefunden: ${ID:-unbekannt}." [[ ${VERSION_ID%%.*} == 12 || ${VERSION_ID%%.*} == 13 ]] || \ die "Unterstützt werden Debian 12 und 13." [[ $(dpkg --print-architecture) == amd64 ]] || die "Dieser Stack erwartet amd64." id "$ADMIN_USER" >/dev/null 2>&1 || die "ADMIN_USER $ADMIN_USER existiert nicht." install_base_packages() { log "Basispakete installieren" # Heal keyrings created by an older installer run under this script's 0077 # umask before apt attempts to refresh any already configured repositories. [[ ! -e /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg ]] || \ chmod 0644 /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates curl git gnupg jq openssl wireguard-tools iptables \ iproute2 pciutils rsync unattended-upgrades ethtool age } setup_stable_network_name() { local mac=${PRIMARY_NETWORK_MAC:-} local desired=${PERSISTENT_NETWORK_NAME:-} [[ -n $mac && -n $desired ]] || return 0 [[ $mac =~ ^([[:xdigit:]]{2}:){5}[[:xdigit:]]{2}$ ]] || \ die "PRIMARY_NETWORK_MAC ist ungültig: $mac" [[ $desired =~ ^[a-zA-Z0-9_.-]+$ ]] || \ die "PERSISTENT_NETWORK_NAME ist ungültig: $desired" mac=${mac,,} local current="" path for path in /sys/class/net/*; do [[ -f $path/address ]] || continue if [[ $(<"$path/address") == "$mac" ]]; then current=${path##*/} break fi done [[ -n $current ]] || die "Keine Netzwerkkarte mit permanenter MAC $mac gefunden." log "Stabilen Netzwerknamen $desired für $mac konfigurieren" install -d -m 0755 /etc/systemd/network cat >/etc/systemd/network/10-athena-lan.link </$desired/g" /etc/network/interfaces grep -q "^iface $desired inet " /etc/network/interfaces || \ die "Netzwerkprofil wurde nicht auf $desired umgestellt." log "Netzwerkname wird beim nächsten Boot von $current auf $desired geändert." log "Bitte neu starten und denselben Installer danach erneut ausführen." exit 21 fi } setup_remote_recovery() { log "Remote-Recovery (Hardware-Watchdog und Wake-on-LAN) konfigurieren" if [[ -n ${PRIMARY_NETWORK_INTERFACE:-} ]]; then ip link show "$PRIMARY_NETWORK_INTERFACE" >/dev/null 2>&1 || \ die "Primäres Netzwerk-Interface existiert nicht: $PRIMARY_NETWORK_INTERFACE" if [[ -f /etc/network/interfaces ]] && \ grep -q "^allow-hotplug $PRIMARY_NETWORK_INTERFACE\$" /etc/network/interfaces && \ ! grep -q "^auto $PRIMARY_NETWORK_INTERFACE\$" /etc/network/interfaces; then sed -i "/^allow-hotplug $PRIMARY_NETWORK_INTERFACE\$/i auto $PRIMARY_NETWORK_INTERFACE" \ /etc/network/interfaces fi fi if [[ ${ENABLE_HARDWARE_WATCHDOG:-true} == true ]]; then [[ -e /dev/watchdog0 || -e /dev/watchdog ]] || \ die "Hardware-Watchdog angefordert, aber kein Watchdog-Gerät vorhanden." install -d -m 0755 /etc/systemd/system.conf.d cat >/etc/systemd/system.conf.d/90-mike-ai-watchdog.conf <<'EOF' [Manager] # PID 1 feeds the hardware watchdog. If the kernel or userspace freezes long # enough that it cannot be fed, the firmware resets the machine. RuntimeWatchdogSec=60s RebootWatchdogSec=10min KExecWatchdogSec=10min EOF systemctl daemon-reexec fi if [[ -n ${WAKE_ON_LAN_INTERFACE:-} ]]; then ip link show "$WAKE_ON_LAN_INTERFACE" >/dev/null 2>&1 || \ die "Wake-on-LAN-Interface existiert nicht: $WAKE_ON_LAN_INTERFACE" ethtool "$WAKE_ON_LAN_INTERFACE" | grep -q 'Supports Wake-on:.*g' || \ die "Das Interface unterstützt kein Wake-on-LAN per Magic Packet." cat >/etc/network/if-up.d/mike-ai-wol </etc/ssh/sshd_config.d/20-athena-key-only.conf <<'EOF' PasswordAuthentication no KbdInteractiveAuthentication no PubkeyAuthentication yes EOF sshd -t systemctl reload ssh } install_docker() { log "Docker CE aus dem offiziellen Repository installieren" install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg \ -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc cat >/etc/apt/sources.list.d/docker.sources </dev/null 2>&1; then [[ ${INSTALL_NVIDIA_DRIVER:-false} == true ]] || \ die "NVIDIA-Treiber fehlt. Installiere ihn oder setze INSTALL_NVIDIA_DRIVER=true." log "Aktuellen NVIDIA-Treiber aus dem offiziellen NVIDIA-Repository installieren" DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ "linux-headers-$(uname -r)" local distro="debian${VERSION_ID%%.*}" local keyring_url="https://developer.download.nvidia.com/compute/cuda/repos/${distro}/x86_64/cuda-keyring_1.1-1_all.deb" local keyring_deb keyring_deb=$(mktemp /tmp/cuda-keyring.XXXXXX.deb) curl --fail --location --retry 5 --output "$keyring_deb" "$keyring_url" dpkg -i "$keyring_deb" rm -f "$keyring_deb" apt-get update if [[ -n ${NVIDIA_DRIVER_BRANCH:-} ]]; then local pinning="nvidia-driver-pinning-${NVIDIA_DRIVER_BRANCH}" apt-cache show "$pinning" >/dev/null 2>&1 || \ die "Gewuenschter NVIDIA-Treiberzweig fehlt im offiziellen Repository: $pinning" DEBIAN_FRONTEND=noninteractive apt-get install -y "$pinning" fi # RTX 30/50 werden beide von NVIDIAs offenen Kernelmodulen unterstuetzt. # Compute-only vermeidet X11-, Desktop- und Vulkan-Pakete auf dem Headless-Host. DEBIAN_FRONTEND=noninteractive apt-get install -y \ nvidia-driver-cuda nvidia-kernel-open-dkms printf '\nNVIDIA-Treiber installiert. Jetzt neu starten und danach denselben Installer erneut ausführen.\n' exit 20 fi nvidia-smi >/dev/null || die "nvidia-smi ist vorhanden, aber der Treiber funktioniert nicht." local driver_major min_driver_major driver_major=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -n1 | cut -d. -f1) min_driver_major=${NVIDIA_MIN_DRIVER_MAJOR:-570} [[ $driver_major =~ ^[0-9]+$ ]] || die "NVIDIA-Treiberversion konnte nicht gelesen werden." (( driver_major >= min_driver_major )) || \ die "NVIDIA-Treiber $driver_major ist zu alt; mindestens $min_driver_major wird verlangt." log "NVIDIA Container Toolkit installieren" curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \ gpg --dearmor --yes -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg # apt verifies repository metadata as the unprivileged _apt user. The # installer's restrictive umask would otherwise leave this keyring at 0600. chmod 0644 /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ >/etc/apt/sources.list.d/nvidia-container-toolkit.list apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y nvidia-container-toolkit nvidia-ctk runtime configure --runtime=docker systemctl restart docker docker run --rm --gpus all nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi >/dev/null } setup_wireguard() { [[ ${WIREGUARD_MODE:-container} != container ]] || return 0 [[ ${WIREGUARD_ENABLE:-false} == true ]] || return 0 for name in WG_INTERFACE WG_ADDRESS WG_HOME_SUBNET WG_PEER_PUBLIC_KEY WG_PEER_ENDPOINT; do [[ -n "${!name:-}" && ${!name} != REPLACE_* ]] || die "WireGuard-Wert $name fehlt." done log "WireGuard als ausgehenden Heimnetz-Tunnel konfigurieren" install -d -m 0700 /etc/wireguard local key_file="/etc/wireguard/${WG_INTERFACE}.key" [[ -s $key_file ]] || wg genkey >"$key_file" chmod 0600 "$key_file" local private_key private_key=$(<"$key_file") local allowed_ips="$WG_HOME_SUBNET" [[ ${WG_ROUTE_AI_INTERNET:-true} == true ]] && allowed_ips="0.0.0.0/0, ::/0" { printf '[Interface]\nAddress = %s\nPrivateKey = %s\nTable = off\n' "$WG_ADDRESS" "$private_key" printf '\n[Peer]\nPublicKey = %s\nEndpoint = %s\nAllowedIPs = %s\nPersistentKeepalive = 25\n' \ "$WG_PEER_PUBLIC_KEY" "$WG_PEER_ENDPOINT" "$allowed_ips" if [[ -n ${WG_PEER_PRESHARED_KEY_FILE:-} ]]; then [[ -s $WG_PEER_PRESHARED_KEY_FILE ]] || die "WireGuard-Preshared-Key-Datei fehlt." printf 'PresharedKey = %s\n' "$(<"$WG_PEER_PRESHARED_KEY_FILE")" fi } >"/etc/wireguard/${WG_INTERFACE}.conf" chmod 0600 "/etc/wireguard/${WG_INTERFACE}.conf" systemctl enable --now "wg-quick@${WG_INTERFACE}" ip address show "$WG_INTERFACE" >/dev/null local wg_host=${WG_ADDRESS%/*} [[ $AI_BIND_ADDRESS == "$wg_host" ]] || \ die "AI_BIND_ADDRESS muss der WireGuard-Adresse ohne Präfix entsprechen ($wg_host)." printf 'WireGuard-Public-Key des KI-Hosts: %s\n' "$(wg pubkey <"$key_file")" } install_stack_files() { log "Stackdateien installieren" XTTS_CACHE_DIR=${XTTS_CACHE_DIR:-$MODEL_DIR/xtts-v2-cache} install -d -m 0755 "$STACK_DIR" "$MODEL_DIR" "$XTTS_CACHE_DIR" "$STATE_DIR/backups" # /opt/mike-ai/stack is both the live stack and the one canonical Git # checkout. Copying everything except .git created two competing source # trees and made agents reconstruct deployment state on every change. if [[ $(realpath "$ROOT_DIR") != $(realpath "$STACK_DIR") ]]; then rsync -a --delete --exclude '*.local.*' \ --exclude config/install.env "$ROOT_DIR/" "$STACK_DIR/" fi if git -C "$ROOT_DIR" rev-parse HEAD >/dev/null 2>&1; then local source_head source_head=$(git -C "$ROOT_DIR" rev-parse HEAD) git -C "$STACK_DIR" switch -C main "$source_head" >/dev/null printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit" fi install -d -m 0700 "$SECRETS_DIR" [[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key [[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token chmod 0600 "$SECRETS_DIR"/* cat >$SECRETS_DIR/stack.env </dev/null 2>&1; then # The installer itself runs with umask 077, but inference runs deliberately # unprivileged. Models are immutable inputs: globally readable, never # writable by the runtime container. chmod 0444 "$target" printf 'Vorhanden und geprüft: %s\n' "$relative" return fi log "Modell laden: $relative" curl --fail --location --continue-at - --retry 5 --retry-all-errors \ --output "$target.partial" "$url" printf '%s %s\n' "$expected" "$target.partial" | sha256sum -c - mv "$target.partial" "$target" chmod 0444 "$target" } download_models() { [[ ${SKIP_MODEL_DOWNLOADS:-false} == true ]] && return 0 declare -A seen=() local row relative url hash while IFS='|' read -r relative url hash; do [[ -n ${seen[$relative]:-} ]] && continue seen[$relative]=1 download_one "$relative" "$url" "$hash" done </usr/local/sbin/mike-ai-network-guard </dev/null || true done ip route replace \"\$HOME_NET\" dev \"\$WG\" ip route replace \"\$HOME_NET\" dev \"\$WG\" table \"\$TABLE\" ip route replace blackhole default metric 32767 table \"\$TABLE\" EOF if [[ ${WG_ROUTE_AI_INTERNET:-true} == true ]]; then printf 'ip route replace default dev "$WG" metric 10 table "$TABLE"\n' >>/usr/local/sbin/mike-ai-network-guard fi cat >>/usr/local/sbin/mike-ai-network-guard <<'EOF' # Explicit forwarding policy: AI containers may use wg0; WireGuard clients may # reach the two published UI/API ports. Neither side may use this host as a # general university<->home router. The blackhole route above prevents a # fail-open to the university gateway when wg0 loses its peer/default route. add_rule() { iptables -C DOCKER-USER "$@" 2>/dev/null || iptables -I DOCKER-USER 1 "$@"; } add_rule -o "$WG" -j DROP add_rule -i "$WG" -j DROP add_rule -s 172.30.0.0/16 -o "$WG" -j ACCEPT add_rule -i "$WG" -d 172.30.10.0/24 -p tcp -m multiport --dports 8080,8081 -j ACCEPT add_rule -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT EOF chmod 0755 /usr/local/sbin/mike-ai-network-guard cat >/etc/systemd/system/mike-ai-network-guard.service </dev/null || true)" = "healthy" ]; do if (( SECONDS >= deadline )); then docker logs --tail 80 mike-ai-router >&2 || true die "Router wurde nicht rechtzeitig gesund" fi sleep 2 done log "Medium-Profil als Standard aktivieren und Readiness prüfen" # Use docker exec directly here. Some Compose/Docker combinations return a # transient HTTP 409 while upgrading the exec stream immediately after a # freshly built service has been recreated. local activation_output activation_output=$(docker exec -i mike-ai-router python - <<'PY' import json, os, time, urllib.request key = os.environ["ROUTER_API_KEY"] request = urllib.request.Request( "http://127.0.0.1:8081/medium", method="POST", headers={"Authorization": f"Bearer {key}"}) with urllib.request.urlopen(request, timeout=700) as response: print(json.dumps(json.load(response), indent=2)) deadline = time.monotonic() + 60 while time.monotonic() < deadline: try: with urllib.request.urlopen("http://127.0.0.1:8081/ready", timeout=5) as response: if response.status == 200: print("Router und Medium-Standardprofil sind bereit.") print("INSTALL_READINESS_OK") break except Exception: pass time.sleep(2) else: raise SystemExit("Readiness-Check fehlgeschlagen") PY ) || die "Medium-Standardprofil konnte nicht aktiviert werden" printf '%s\n' "$activation_output" grep -Fxq 'INSTALL_READINESS_OK' <<<"$activation_output" || \ die "Medium-Standardprofil lieferte keinen bestätigten Readiness-Marker" } hostnamectl set-hostname "$AI_HOSTNAME" install_base_packages setup_stable_network_name setup_remote_recovery setup_ssh_hardening install_docker install_nvidia setup_wireguard install_stack_files download_models install_routing_guard build_and_start log "Installation abgeschlossen" if [[ ${WIREGUARD_MODE:-container} == container ]]; then vpn_address=$(awk -F= ' /^[[:space:]]*Address[[:space:]]*=/ { value=$2; gsub(/[[:space:]]/, "", value); split(value, addresses, ",") for (i in addresses) if (addresses[i] !~ /:/) { sub(/\/.*/, "", addresses[i]); print addresses[i]; exit } } ' "${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}") else vpn_address=$AI_BIND_ADDRESS fi cat <