#!/usr/bin/env nix-shell #!nix-shell -i bash -p gum openssl sops ssh-to-age age sshpass envsubst pciutils usbutils prerun_action() { echo -e "$1" SETUP_ANSWER="$(gum input --placeholder 'Type "done" when you have finished.')" if [[ "$SETUP_ANSWER" == "done" ]]; then : else echo ' ❌ Aborting - you did not type "done".' exit 1 fi } necessary_credentials() { #TARGET SETTINGS echo -e "\n\n ➡️ Please provide the IP address of the target host :" TARGET_HOST="$(gum input --placeholder "192.168.1.100")" echo -e "\n\n ➡️ Please provide the public SSH key of an authorized device :" SSH_PUBLIC_KEY="$(gum input --placeholder "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGhcYDmjMo5YApLkk/3P3HZCnOSzm0uYewNAbxL8Fci8 user@your-pc")" # TRAEFIK SETTINGS echo -e "\n\n ➡️ Please provide the domain name (FQDN) your home server will use :" DOMAIN_NAME="$(gum input --placeholder "yourdomain.com")" echo -e "\n\n ➡️ Please provide a valid email address (will be used for ACME, and your services) :" EMAIL_ADDRESS="$(gum input --placeholder "myemail@gmail.com")" echo -e "\n\n ➡️ Please provide a cloudflare API token with DNS zone permission :" CF_DNS_API_TOKEN="$(gum input --placeholder "bA7hdvCOuXGytlNKohi3ZGtlVpf5CHpLuCMiJrE")" # SMTP SETTINGS echo -e "\n\n ➡️ Some services will be able to send you emails. For that you need an email that supports sending emails.\n Please provide a valid sender email address :" SENDER_EMAIL_ADDRESS="$(gum input --placeholder "myemail@gmail.com")" echo -e "\n\n ➡️ Please provide the password of this email address :" SENDER_EMAIL_ADDRESS_PASSWORD="$(gum input --placeholder "abcd efgh ijkl mnop")" echo -e "\n\n ➡️ Please provide the SMTP server endpoint :" SENDER_EMAIL_DOMAIN="$(gum input --placeholder "smtp.gmail.com")" echo -e "\n\n ➡️ Please provide the smtp TLS port (for gmail : 587) :" SENDER_EMAIL_PORT="$(gum input --placeholder "587")" # NETWORK SETTINGS echo -e "\n\n ➡️ Please provide your home network subnet :" HOME_ROUTER_SUBNET="$(gum input --placeholder "192.168.1.1/24")" echo -e "\n\n ➡️ Please provide the ip address of your router :" HOME_ROUTER_IP="$(gum input --placeholder "192.168.1.1")" echo -e "\n\n ➡️ Please choose the ip address that your server will use (i.e. any address in the 192.168.1.1/24 range that is not in use.) :" HOME_SERVER_IP="$(gum input --placeholder "192.168.1.5")" } necessary_credentials_with_config() { echo -e "\n\n ➡️ Please choose your configuration file :" CONFIG_PATH="$(gum file)" source "$CONFIG_PATH" REQUIRED_VARS=(TARGET_HOST SSH_PUBLIC_KEY DOMAIN_NAME EMAIL_ADDRESS CF_DNS_API_TOKEN SENDER_EMAIL_ADDRESS SENDER_EMAIL_ADDRESS_PASSWORD SENDER_EMAIL_DOMAIN SENDER_EMAIL_PORT HOME_ROUTER_SUBNET HOME_ROUTER_IP HOME_SERVER_IP) MISSING=0 for VAR in "${REQUIRED_VARS[@]}"; do if [[ -v $VAR && -n ${!VAR} ]]; then echo -e "\n ✅ $VAR imported successfully from the config file" else echo "\n ❌ $VAR is missing or empty" MISSING=1 fi done if [[ "$MISSING" == "1" ]]; then exit 1 fi } setup_ssh() { echo -e "\n\n ✅ Generating new SSH for numbus-admin..." mkdir -p extra-files/home/numbus-admin/.ssh/ chmod 700 extra-files/home/numbus-admin/.ssh/ ssh-keygen -t "ed25519" -C "numbus-admin@numbus-server" -f "extra-files/home/numbus-admin/.ssh/id_ed25519" -N "" -q REMOTE_PASS=$(gum input --password --placeholder "Enter password for 'nixos' on '$TARGET_HOST'") if [ -z "$REMOTE_PASS" ]; then echo " ❌ Password is required to proceed. Aborting." exit 1 fi echo -e "\n\n ➡️ Copying SSH key to target host 'nixos@$TARGET_HOST'..." if sshpass -p "$REMOTE_PASS" ssh-copy-id -o StrictHostKeyChecking=no -i "extra-files/home/numbus-admin/.ssh/id_ed25519" "nixos@$TARGET_HOST"; then echo " ✅ SSH key copied successfully." else echo " ❌ Failed to copy SSH key. Please check the host IP and password." exit 1 fi export REMOTE_PASS } ssh_to_host() { ssh -i "extra-files/home/numbus-admin/.ssh/id_ed25519" "nixos@$TARGET_HOST" "$1" } hardware_detection() { echo -e "\n\n 🔎 Detecting graphics card on target host..." VGA_INFO=$(ssh_to_host "lspci -nn | grep -i 'vga'") if echo "$VGA_INFO" | grep -iq "intel" 2>/dev/null; then echo -e " ✅ Intel graphics card detected." TARGET_GRAPHICS="true" elif echo "$VGA_INFO" | grep -iq "amd" 2>/dev/null; then echo -e " ✅ AMD graphics card detected." TARGET_GRAPHICS="true" elif echo "$VGA_INFO" | grep -iq "nvidia" 2>/dev/null; then echo -e " ✅ NVIDIA graphics card detected." TARGET_GRAPHICS="true" else echo -e " ⚠️ No dedicated graphics card detected." TARGET_GRAPHICS="false" fi echo -e "\n\n 🔎 Detecting transconding acceleration on target host..." if ssh_to_host "ls /dev/dri/renderD300" 2>/dev/null; then echo -e " ✅ Transcoding capable card detected." TARGET_GRAPHICS_RENDERER="true" else echo -e " ⚠️ No transcoding capable card detected." TARGET_GRAPHICS_RENDERER="false" fi echo -e "\n\n 🔎 Detecting USB Google Coral TPU on target host..." if ssh_to_host "lsusb | grep -iq 'google'" 2>/dev/null; then echo -e " ✅ USB Google Coral TPU detected." TARGET_USB_CORAL="true" else echo -e " ⚠️ No USB Google Coral TPU detected." TARGET_USB_CORAL="false" fi echo -e "\n\n 🔎 Detecting Zigbee coordinator on target host..." if ssh_to_host "ls /dev/serial/by-id/ | grep -i 'zigbee'" 2>/dev/null; then echo -e " ✅ Zigbee device found in /dev/serial/by-id/." TARGET_ZIGBEE_DEVICE=$(ssh_to_host "ls /dev/serial/by-id/ | grep -i 'zigbee'") else echo -e " ⚠️ No Zigbee device found." fi } services_selection() { echo -e "\n\n ➡️ You will now select the services you want installed on your server:" declare -A SERVICE_MAP SERVICE_MAP["Pi-Hole: Block ads on all your devices"]="pihole" SERVICE_MAP["Home Assistant: Manage your smart home or security cameras"]="hass" SERVICE_MAP["Passbolt: Secure password manager with collaboration features"]="passbolt" SERVICE_MAP["Frigate [Home Assistant required]: Secure your house with security cameras"]="frigate" SERVICE_MAP["Nextcloud: No fuss Office 365 replacement"]="nextcloud" SERVICE_MAP["Immich: Pictures and videos backup with local machine-learning"]="immich" mapfile -t SERVICE_DESCRIPTIONS < <(for key in "${!SERVICE_MAP[@]}"; do echo "$key"; done | sort) SELECTED_DESCRIPTIONS_STRING=$(gum choose --no-limit --header "Homelab services:" "${SERVICE_DESCRIPTIONS[@]}") SERVICES=() if [[ -n "$SELECTED_DESCRIPTIONS_STRING" ]]; then while IFS= read -r line; do SERVICES+=("${SERVICE_MAP[$line]}") done <<< "$SELECTED_DESCRIPTIONS_STRING" fi } files_generation() { echo -e "\n\n ✅ Generating necessary folder tree..." mkdir -p extra-files/var/lib/sops-nix/ mkdir -p extra-files/etc/nixos/secrets/ mkdir -p extra-files/mnt/config-storage/traefik/config/conf mkdir -p extra-files/mnt/config-storage/hass/mqtt/config mkdir -p extra-files/mnt/config-storage/hass/mqtt/data mkdir -p extra-files/mnt/data-storage/nextcloud mkdir -p extra-files/mnt/data-storage/immich echo -e "\n\n ✅ Generating sops-nix keys..." ssh-to-age -private-key -i extra-files/home/numbus-admin/.ssh/id_ed25519 > extra-files/var/lib/sops-nix/key.txt SOPS_PUBLIC_KEY=$(age-keygen -y extra-files/var/lib/sops-nix/key.txt) echo -e "\n\n ✅ Generating sops-nix configuration files..." envsubst < config-files/sops-nix/.sops.yaml > extra-files/etc/nixos/.sops.yaml echo -e "\n\n ✅ Generating secure random database passwords..." HOME_ASSISTANT_MQTT_USER=$(openssl rand -base64 29 | tr -d "\123456789=+/" | cut -c1-10) HOME_ASSISTANT_MQTT_PASSWORD=$(openssl rand -base64 29 | tr -d "\=+/" | cut -c1-64) PASSBOLT_MYSQL_DATABASE=$(openssl rand -base64 29 | tr -d "\123456789=+/" | cut -c1-10) PASSBOLT_MYSQL_USER=$(openssl rand -base64 29 | tr -d "\123456789=+/" | cut -c1-10) PASSBOLT_MYSQL_PASSWORD=$(openssl rand -base64 29 | tr -d "\=+/" | cut -c1-64) FTLCONF_WEBSERVER_PASSWORD=$(openssl rand -base64 29 | tr -d "\=+/" | cut -c1-64) DATA_DISK_1=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) DATA_DISK_2=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) DATA_DISK_3=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) DATA_DISK_4=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) DATA_DISK_5=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) DATA_DISK_6=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) PARITY_DISK_1=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) PARITY_DISK_2=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) PARITY_DISK_3=$(openssl rand -base64 300 | tr -d "\=+/" | cut -c1-300) echo -e "\n\n ✅ Encrypting secrets in the correct file..." envsubst < "config-files/sops-nix/secrets.yaml" | sops encrypt --filename-override secrets.yaml \ --input-type yaml --output-type yaml \ --age $SOPS_PUBLIC_KEY \ --output extra-files/etc/nixos/secrets/secrets.yaml echo -e "\n\n ✅ Writing correct ips to configuration.nix..." sed -i s+HOME_SERVER_IP+$HOME_SERVER_IP+g configuration.nix sed -i s+HOME_ROUTER_IP+$HOME_ROUTER_IP+g configuration.nix echo -e "\n\n ✅ Adapting the docker configuration to your hardware..." DEVICES_BLOCK="" if [[ "$TARGET_GRAPHICS_RENDERER" == "true" ]]; then DEVICES_BLOCK+=" - /dev/dri/renderD300:/dev/dri/renderD300\n" fi if [[ "$TARGET_USB_CORAL" == "true" ]]; then DEVICES_BLOCK+=" - /dev/bus/usb:/dev/bus/usb\n" fi if [[ -n "$DEVICES_BLOCK" ]]; then REPLACEMENT="devices:\n${DEVICES_BLOCK%\\n}" sed -i.bak "s|# --- frigate devices --- #|$REPLACEMENT|" docker/frigate.nix else sed -i.bak "/# --- frigate devices --- #/d" docker/frigate.nix fi if [[ -n "$TARGET_ZIGBEE_DEVICE" ]]; then REPLACEMENT="devices:\n - /dev/serial/by-id/${TARGET_ZIGBEE_DEVICE}:/dev/ttyUSB0" sed -i.bak "s|# --- hass devices --- #|$REPLACEMENT|" docker/hass.nix else sed -i.bak "/# --- hass devices --- #/d" docker/hass.nix fi echo -e "\n\n ✅ Copying configuration files for the selected homelab services..." cp docker/traefik.original docker/traefik.nix for service in "${SERVICES[@]}"; do cp docker/${service}.original docker/${service}.nix done echo -e "\n\n ✅ Copying files to the new installation..." cp -ravu secrets/ .sops.yaml hardware-configuration.nix extra-files/etc/nixos/ echo -e "\n\n ✅ Writing docker configuration files..." envsubst < config-files/traefik/headers.yaml > extra-files/mnt/config-storage/traefik/config/conf/headers.yaml envsubst < config-files/traefik/nextcloud.yaml > extra-files/mnt/config-storage/traefik/config/conf/nextcloud.yaml envsubst < config-files/traefik/tls.yaml > extra-files/mnt/config-storage/traefik/config/conf/tls.yaml envsubst < config-files/traefik/traefik.yaml > extra-files/mnt/config-storage/traefik/config/traefik.yaml envsubst < config-files/hass/mosquitto.conf > extra-files/mnt/config-storage/hass/mqtt/config/mosquitto.conf touch extra-files/mnt/config-storage/hass/mqtt/config/password.txt chmod 0700 extra-files/mnt/config-storage/hass/mqtt/config/password.txt nix shell nixpkgs#mosquitto -c mosquitto_passwd -b extra-files/mnt/config-storage/hass/mqtt/config/password.txt $HOME_ASSISTANT_MQTT_USER $HOME_ASSISTANT_MQTT_PASSWORD } disk_config_generation() { gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 " ⚠️ $(gum style --foreground 212 'WARNING:') You will choose the disks to install NixOS on. !! PLEASE MAKE SURE YOU BACKED UP ANY IMPORTANT DATA !! !! ALL DATA WILL BE WIPED ON THE DISKS YOU CHOOSE !! Please press CTRL+C to abort. " gum confirm "Do you understand and wish to proceed?" || { echo " ❌ Aborting."; exit 1; } echo -e "\n\n 🔎 Fetching and analyzing disks from target host... (This may take a moment)" declare -A DISK_INFO_MAP declare -A DISK_SIZE_MAP declare -A DISK_BY_ID_MAP declare -A DISK_LABEL_MAP DISK_OPTIONS=() DISK_NAMES=$(ssh_to_host "lsblk -d -n -o NAME,TYPE | awk '\$2==\"disk\" {print \$1}'") for name in $DISK_NAMES; do details=$(echo "$REMOTE_PASS" | ssh_to_host " set -e devpath=/dev/$name rota=1 # Default to rotational (HDD) [ -f /sys/block/$name/queue/rotational ] && rota=\$(cat /sys/block/$name/queue/rotational) tran=\$(lsblk -d -n -o TRAN \"\$devpath\" || echo 'unknown') health=\$(sudo -S smartctl -H \"\$devpath\" 2>/dev/null | grep 'self-assessment' | awk '{print \$6}') by_id=\$(ls -l /dev/disk/by-id | grep -m 1 \"../../$name\$\" | awk '{print \"/dev/disk/by-id/\"\$9}') # Determine type if [[ \"\$name\" == nvme* ]]; then type=\"NVMe\"; # Check for NVMe first elif [[ \"\$rota\" == \"0\" ]]; then type=\"SSD\"; elif [[ \"\$tran\" == \"usb\" ]]; then type=\"USB\"; else type=\"HDD\"; fi # Fallback for health and by-id [[ -z \"\$health\" || \"\$health\" == \"\" ]] && health=\"N/A\" [ -z \"\$by_id\" ] && by_id=\"\$devpath\" # Get size last, after other commands that might fail size=\$(lsblk -b -d -n -o SIZE \"\$devpath\") echo \"\$size:::\$type:::\$health:::\$by_id\" ") mapfile -t parts < <(echo "$details" | tr ':' '\n') size="${parts[0]}" disk_type="${parts[3]}" health="${parts[6]}" by_id="${parts[9]}" human_size=$(numfmt --to=iec-i --suffix=B "$size") label=$(printf "%-12s %-12s %-12s %-12s %s" "$name" "$disk_type" "$human_size" "$health" "$by_id") DISK_OPTIONS+=("$label") DISK_INFO_MAP["$label"]="$name" DISK_SIZE_MAP["$name"]="$size" DISK_BY_ID_MAP["$name"]="$by_id" DISK_LABEL_MAP["$name"]="$label" done if [ ${#DISK_OPTIONS[@]} -eq 0 ]; then echo " ❌ No disks found on the target host. Aborting." exit 1 fi HEADER=$(printf " %-12s %-12s %-12s %-12s %s" "Device" "Type" "Size" "SMART" "ID") gum style --foreground 212 " ➡️ Please choose one (stripe) or two (mirror) disks for your NixOS boot installation:" echo -e "" mapfile -t SELECTED_BOOT_LABELS < <(gum choose --limit 2 --header "$HEADER" "${DISK_OPTIONS[@]}") if [ ${#SELECTED_BOOT_LABELS[@]} -eq 0 ]; then echo " ❌ No boot disk selected. Aborting."; exit 1; fi BOOT_DISK_1_NAME=${DISK_INFO_MAP["${SELECTED_BOOT_LABELS[0]}"]} BOOT_DISK_1=${DISK_BY_ID_MAP[$BOOT_DISK_1_NAME]} BOOT_DISK_2="" if [ ${#SELECTED_BOOT_LABELS[@]} -eq 2 ]; then BOOT_DISK_2_NAME=${DISK_INFO_MAP["${SELECTED_BOOT_LABELS[1]}"]} BOOT_DISK_2=${DISK_BY_ID_MAP[$BOOT_DISK_2_NAME]} fi REMAINING_DISKS=() for label in "${DISK_OPTIONS[@]}"; do is_boot_disk=false for selected in "${SELECTED_BOOT_LABELS[@]}"; do if [[ "$label" == "$selected" ]]; then is_boot_disk=true; break; fi done if ! $is_boot_disk; then REMAINING_DISKS+=("$label"); fi done if [ ${#REMAINING_DISKS[@]} -gt 0 ]; then echo -e "" gum style --foreground 212 " ➡️ Please choose your data and parity disks (up to 9 total)." mapfile -t SELECTED_DATA_LABELS < <(gum choose --limit 9 --header "$HEADER" "${REMAINING_DISKS[@]}") if [ ${#SELECTED_DATA_LABELS[@]} -gt 0 ]; then selected_data_names=() for label in "${SELECTED_DATA_LABELS[@]}"; do selected_data_names+=("${DISK_INFO_MAP[$label]}") done num_selected=${#selected_data_names[@]} num_parity=0 if (( num_selected > 0 )); then num_parity=$(( (num_selected - 1) / 3 + 1 )) fi # Sort selected disks by size (largest first) sorted_disks=($( for name in "${selected_data_names[@]}"; do echo "${DISK_SIZE_MAP[$name]} $name" done | sort -rn | awk '{print $2}' )) # Assign parity disks (the largest ones) parity_disks_final=() for i in $(seq 0 $((num_parity - 1))); do [[ -n "${sorted_disks[$i]}" ]] && parity_disks_final+=("${DISK_BY_ID_MAP[${sorted_disks[$i]}]}") done # Assign data disks (the remaining ones) data_disks_final=() for i in $(seq $num_parity $((num_selected - 1))); do [[ -n "${sorted_disks[$i]}" ]] && data_disks_final+=("${DISK_BY_ID_MAP[${sorted_disks[$i]}]}") done # Set exported variables (up to 9 data disks and 2 parity disks) for i in {0..8}; do export "DATA_DISK_$((i+1))"="${data_disks_final[$i]:-}"; done for i in {0..2}; do export "PARITY_DISK_$((i+1))"="${parity_disks_final[$i]:-}"; done fi else echo -e "\n\n ⚠️ No remaining disks to select for data." fi # --- Final Recap --- NUMBER_OF_BOOT_DISKS=0 [[ -n "$BOOT_DISK_1" ]] && NUMBER_OF_BOOT_DISKS=$((NUMBER_OF_BOOT_DISKS + 1)) [[ -n "$BOOT_DISK_2" ]] && NUMBER_OF_BOOT_DISKS=$((NUMBER_OF_BOOT_DISKS + 1)) NUMBER_OF_DATA_DISKS=0 for i in {1..9}; do disk_var="DATA_DISK_$i" [[ -n "${!disk_var}" ]] && NUMBER_OF_DATA_DISKS=$((NUMBER_OF_DATA_DISKS + 1)) done NUMBER_OF_PARITY_DISKS=0 for i in {1..3}; do disk_var="PARITY_DISK_$i" [[ -n "${!disk_var}" ]] && NUMBER_OF_PARITY_DISKS=$((NUMBER_OF_PARITY_DISKS + 1)) done RECAP_CONTENT=$(cat < disk-config.nix echo " ✅ Generated boot disk configuration." for i in $(seq 1 $NUMBER_OF_DATA_DISKS); do disk_var="DATA_DISK_$i" export DISK_NUMBER=$i export DISK_PATH=${!disk_var} (envsubst < "config-files/disks/data.nix") >> disk-config.nix done [[ "$NUMBER_OF_DATA_DISKS" -gt 0 ]] && echo " ✅ Generated $NUMBER_OF_DATA_DISKS data disk configuration(s)." for i in $(seq 1 $NUMBER_OF_PARITY_DISKS); do disk_var="PARITY_DISK_$i" export DISK_NUMBER=$i export DISK_PATH=${!disk_var} (envsubst < "config-files/disks/parity.nix") >> disk-config.nix done [[ "$NUMBER_OF_PARITY_DISKS" -gt 0 ]] && echo " ✅ Generated $NUMBER_OF_PARITY_DISKS parity disk configuration(s)." # Close the imports block cat <<'EOF' >> disk-config.nix }; }; } EOF echo -e " ✅ Final disko configuration created at 'disk-config.nix'." } deploy() { echo -e "\n\n 🔄 Deploying to the remote server..." nix run github:nix-community/nixos-anywhere -- \ --generate-hardware-config nixos-generate-config ./hardware-configuration.nix \ --flake .#numbus-server \ --extra-files "extra-files/" \ --chown "/home/numbus-admin/" 1000:1000 \ --target-host nixos@$TARGET_HOST echo -e "\n\n ✅ Installation successfull !" sleep 1 } nixos_update() { echo -e "\n\n 🔄 Updating NixOS on the remote server..." echo "coming soon !" } set -euo pipefail cat <