#!/usr/bin/env nix-shell #!nix-shell -i bash -p bash coreutils gnused gum fastfetch xkcdpass sops ssh-to-age age sshpass envsubst pciutils usbutils mosquitto curl jq ### --> Default settings export GUM_SPIN_SPINNER="minidot" export GUM_SPIN_SPINNER_BOLD=true export GUM_SPIN_SHOW_ERROR=true export GUM_SPIN_TITLE_BOLD=true NECESSARY_VARIABLES_LIST=( LIVE_TARGET_IP LIVE_TARGET_PASSWD \ TIMEZONE LANGUAGE LOCALE SERVER_OWNER_NAME SERVER_USER_EMAIL SERVER_ADMIN_EMAIL AUTHORIZED_SSH_PUBLIC_KEY \ DOMAIN_NAME CLOUDFLARE_DNS_API_TOKEN \ SMTP_SERVER_USERNAME SMTP_SERVER_PASSWORD SMTP_SERVER_HOST SMTP_SERVER_PORT \ NETWORK_SUBNET NETWORK_ROUTER_IP HOME_SERVER_IP) ### Default settings <-- user_input() { local VAR_NAME="${1}" local HEADER="${2}" local PLACEHOLDER="${3}" local REGEX="${4}" local ERROR_MSG="${5}" local SENSITIVE="${6:-false}" while true; do [[ "$SENSITIVE" == "false" ]] && INPUT_VALUE=$(gum input --placeholder "${PLACEHOLDER}" --header "${HEADER}") [[ "$SENSITIVE" == "true" ]] && INPUT_VALUE=$(gum input --password --placeholder "${PLACEHOLDER}" --header "${HEADER}") if [[ -z "${INPUT_VALUE}" ]]; then echo "❌ Error: Input cannot be empty. Please provide the necessary information." continue fi if [[ -n "${REGEX}" ]]; then if [[ ! "${INPUT_VALUE}" =~ ${REGEX} ]]; then echo "❌ Error: ${ERROR_MSG}" continue fi fi export "${VAR_NAME}"="${INPUT_VALUE}" break done } strictly_necessary_information() { export IP_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}$' user_input "LIVE_TARGET_IP" " Please provide the IP address of the target host :" "For example : 192.168.1.100" "${IP_REGEX}" "Invalid IP address format." user_input "LIVE_TARGET_PASSWD" " Please enter the password for '${TARGET_USER}@${LIVE_TARGET_IP}' :" "${LIVE_TARGET_IP}'s password" "" "" "true" } necessary_information() { # Regex Definitions local SUBNET_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$' local DOMAIN_REGEX='^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$' local EMAIL_REGEX='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' local PORT_REGEX='^[0-9]{1,5}$' local SSH_KEY_REGEX='^ssh-[a-z0-9]+ [A-Za-z0-9+/]+.*' echo -e "\n\n➡️ This script needs information about the target you want to install NixOS on\n" # LIVE TARGET SETTINGS user_input "LIVE_TARGET_IP" " Please provide the IP address of the target host :" "For example : 192.168.1.100" "${IP_REGEX}" "Invalid IP address format." user_input "LIVE_TARGET_PASSWD" " Please enter the password for '${TARGET_USER}@${LIVE_TARGET_IP}' :" "${LIVE_TARGET_IP}'s password" "" "" "true" echo -e "\n\n➡️ Now provide some information about the server you are deploying\n" # SERVER SETTINGS user_input "TIMEZONE" " Please provide the wanted timezone :" "For example : Europe/Paris, Europe/Berlin" "" "" user_input "LANGUAGE" " Please provide the wanted language :" "For example : FR (for french), EN (for english), DE, IT, etc" "" "" user_input "LOCALE" " Please provide your locale :" "For example : fr_FR for France, de_DE for Germany, en_US for USA or en_GB for Great-Britain, etc" "" "" user_input "SERVER_OWNER_NAME" " Please provide the name of the owner of this server :" "For example : Steve" "" "" user_input "SERVER_USER_EMAIL" " Please provide a valid user email address (to stay informed about your server's health) :" "For example : myemail@gmail.com" "${EMAIL_REGEX}" "Invalid email address format." user_input "SERVER_ADMIN_EMAIL" " Please provide a valid admin email address (will be used for ACME, and system failures notifications) :" "For example : myemail@gmail.com" "${EMAIL_REGEX}" "Invalid email address format." user_input "AUTHORIZED_SSH_PUBLIC_KEY" " Please provide a list of SSH public keys of authorized devices :" "For example : ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGhcYDmjMo5YApLkk/3P3HZCnOSzm0uYewNAbxL8Fci8 user@your-pc" "${SSH_KEY_REGEX}" "Invalid SSH key format (must start with ssh-...)." "true" echo -e "\n\n➡️ You will access your services via a domain name (e.g. cloud.mydomain.com) and containers need credentials to create those subdomains\n" # TRAEFIK SETTINGS user_input "DOMAIN_NAME" " Please provide the domain name (FQDN) your home server will use :" "For example : yourdomain.com" "${DOMAIN_REGEX}" "Invalid domain name format." user_input "CLOUDFLARE_DNS_API_TOKEN" " Please provide a cloudflare API token with DNS zone permission :" "For example : bA7hdvCOuXGytlNKohi3ZGtlVpf5CHpLuCMiJrE" "" "" "true" echo -e "\n\n➡️ Some services will be able to send you emails. For that you need an email that supports sending emails (like Gmail for example)\n" # SMTP SETTINGS user_input "SMTP_SERVER_USERNAME" " Please provide a valid sender email address :" "For example : myemail@gmail.com" "${EMAIL_REGEX}" "Invalid email address format." user_input "SMTP_SERVER_PASSWORD" " Please provide the password of this email address :" "abcd efgh ijkl mnop" "" "" "true" user_input "SMTP_SERVER_HOST" " Please provide the SMTP server endpoint :" "For Gmail : smtp.gmail.com" "${DOMAIN_REGEX}" "Invalid domain name format." user_input "SMTP_SERVER_PORT" " Please provide the smtp TLS port :" "For Gmail : 587" "${PORT_REGEX}" "Invalid port number." echo -e "\n\n➡️ This server will connect to your local network and you will configure its IP address\n" # NETWORK SETTINGS user_input "NETWORK_SUBNET" " Please provide your network subnet :" "For example 192.168.1.0/24" "${SUBNET_REGEX}" "Invalid subnet format (e.g. 192.168.1.1/24)." user_input "NETWORK_ROUTER_IP" " Please provide the ip address of your router :" "Most likely 192.168.1.1 or 192.168.1.254" "${IP_REGEX}" "Invalid IP address format." user_input "HOME_SERVER_IP" " 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.) :" "For example 192.168.1.5" "${IP_REGEX}" "Invalid IP address format." } necessary_information_config() { echo -e "\n\n➡️ Please choose your configuration file :" local CONFIG_PATH="$(gum file)" source "${CONFIG_PATH}" local MISSING=0 for VAR in "${NECESSARY_VARIABLES_LIST[@]}"; do if [[ -v "${VAR}" && -n "${!VAR}" ]]; then gum style "✅ "${VAR}" imported successfully from the config file" else gum style "❌ "${VAR}" is missing or empty" MISSING=1 fi done if [[ "${MISSING}" -eq 1 ]]; then echo -e "\n❌ Please check your configuration file to include all necessary variables" exit 1 fi if [[ "${DEBUG:-false}" == "true" ]]; then echo -e "\n✅ Debugging enabled." export DIR_COPY_FLAGS="ravu" export FILES_COPY_FLAGS="avu" else export DIR_COPY_FLAGS="rau" export FILES_COPY_FLAGS="au" fi } setup_ssh() { mkdir -p final-nix-config/ mkdir -p final-nix-config/etc/ mkdir -p final-nix-config/etc/nixos/ mkdir -p final-nix-config/home/ mkdir -p final-nix-config/home/numbus-admin/ mkdir -p final-nix-config/home/numbus-admin/.ssh/ echo -e "\n\n✅ Generating new SSH key for numbus-admin..." chmod 700 final-nix-config/home/numbus-admin/.ssh/ ssh-keygen -t "ed25519" -C "numbus-admin@numbus-server" -f "final-nix-config/home/numbus-admin/.ssh/id_ed25519" -N "" -q echo -e "\n\n➡️ Copying SSH key to target host '${TARGET_USER}@${LIVE_TARGET_IP}'..." if sshpass -p "${LIVE_TARGET_PASSWD}" ssh-copy-id -o StrictHostKeyChecking=no -i "final-nix-config/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}"; then echo -e "\n✅ SSH key copied successfully" else echo -e "\n❌ Failed to copy SSH key. Please check the host IP and password." exit 1 fi } ssh_to_host() { local COMMAND="${1}" ssh -i "final-nix-config/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" "${COMMAND}" } hierarchy_preparation() { mkdir -p final-nix-config/etc/numbus-server mkdir -p final-nix-config/etc/secrets mkdir -p final-nix-config/etc/secrets/disks mkdir -p final-nix-config/etc/nixos/secrets mkdir -p final-nix-config/var mkdir -p final-nix-config/var/lib mkdir -p final-nix-config/var/lib/sops-nix echo -e "\n✅ Writing configuration..." cp -${FILES_COPY_FLAGS} templates/nix-config/configuration.nix final-nix-config/etc/nixos/configuration.nix cp -${FILES_COPY_FLAGS} templates/nix-config/flake.nix final-nix-config/etc/nixos/flake.nix export CONFIGURATION_PATH="final-nix-config/etc/nixos/configuration.nix" } hardware_detection() { ### --> Get hardware information local TMPFILE="/tmp/nixos-installation-hardware-detection-temp-file" ssh_to_host 'bash -s' << SSHEND for brand in Intel AMD NVIDIA; do if lspci -nn 2>/dev/null | grep -i "vga" | grep -iq "\${brand}"; then TARGET_GRAPHICS="true" TARGET_GRAPHICS_BRAND+=("\${brand}") else TARGET_GRAPHICS="false" fi done ls /dev/dri/ > /dev/null 2>&1 | grep -iq "renderD128" && TARGET_GRAPHICS_RENDERER="true" || TARGET_GRAPHICS_RENDERER="false" lsusb > /dev/null 2>&1 | grep -iq "google" && TARGET_USB_CORAL="true" || TARGET_USB_CORAL="false" lspci -nn > /dev/null 2>&1 | grep -iq "089a" && TARGET_PCIE_CORAL="true" || TARGET_PCIE_CORAL="false" ls /dev/serial/by-id/ > /dev/null 2>&1 | grep -i "zigbee" && TARGET_ZIGBEE_DEVICE=\$(ls /dev/serial/by-id/ > /dev/null 2>&1 | grep -i "zigbee" | head -n 1) || TARGET_ZIGBEE_DEVICE="" TARGET_INTERFACE=\$(ip -4 route show default | awk '{print \$5}' | head -n1) if ls -l /sys/class/tpm/tpm0/ > /dev/null 2>&1; then TARGET_TPM="true" TARGET_TPM_VERSION=\$(cat /sys/class/tpm/tpm0/tpm_version_major) else TARGET_TPM="false" TARGET_TPM_VERSION="N/A" fi HDD=1 DISK_DEVPATH=() DISK_NAME=() DISK_TYPE=() DISK_HEALTH=() DISK_ID=() for DISK in \$(lsblk -x SIZE -d -n -e 7,11 -o NAME); do # Disk name and simple path DISK_DEVPATH+=("/dev/\$DISK") DISK_NAME+=("\$DISK") # Disk type HDD=\$(cat /sys/block/\$DISK/queue/rotational) TRANSPORT_PROTOCOL=\$(lsblk -x SIZE -d -n -e 7,11 -o TRAN /dev/\$DISK) if [[ "\$DISK" == "nvme*" ]]; then DISK_TYPE+=("NVMe"); elif [[ "\$HDD" -eq 1 ]]; then DISK_TYPE+=("HDD"); elif [[ "\$TRANSPORT_PROTOCOL" == "usb" ]]; then DISK_TYPE+=("USB"); elif [[ "\$HDD" -eq 0 ]]; then DISK_TYPE+=("SSD"); else DISK_TYPE+=("Other") fi # Disk health if [[ \$(echo "$LIVE_TARGET_PASSWD" | sudo -S smartctl -H /dev/\$DISK 2>/dev/null | grep 'self-assessment' | awk '{print \$6}') == "PASSED" ]]; then DISK_HEALTH+=("PASSED") else DISK_HEALTH+=("N/A") fi # Disk ID DISK_ID+=("\$(ls -l /dev/disk/by-id | grep -m1 "../../\$DISK" | awk '{print "/dev/disk/by-id/" \$9}')") DISK_SIZE+=("\$(lsblk -x SIZE -d -n -e 7,11 -o SIZE /dev/\$DISK)") done echo "# Hardware detection results on \$(date)" > "${TMPFILE}" for var in \ TARGET_GRAPHICS \ TARGET_GRAPHICS_BRAND \ TARGET_GRAPHICS_RENDERER \ TARGET_USB_CORAL \ TARGET_PCIE_CORAL \ TARGET_ZIGBEE_DEVICE \ TARGET_INTERFACE \ TARGET_TPM \ TARGET_TPM_VERSION; do echo "export \${var}=\${!var}" >> "${TMPFILE}" done for var in \ DISK_DEVPATH \ DISK_NAME \ DISK_TYPE \ DISK_HEALTH \ DISK_ID \ DISK_SIZE; do declare -p \${var} | sed 's/^declare /declare -g /' >> "${TMPFILE}" done SSHEND ### Get hardware information <-- scp -i "final-nix-config/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}":"${TMPFILE}" "${TMPFILE}" &> /dev/null source "${TMPFILE}" && rm -rf "${TMPFILE}" ### --> Generate hardware-configuration.nix if ssh_to_host "sudo nixos-generate-config --no-filesystems --show-hardware-config" > final-nix-config/etc/nixos/hardware-configuration.nix; then echo -e "\n✅ Hardware configuration generated" else echo -e "\n❌ Failed to generate hardware configuration" exit 1 fi ### Generate hardware-configuration.nix <-- } services_selection() { echo -e "\n\n ➡️ You will now select the services you want installed on your server:" local AVAILABLE_DNS_SERVICES=( "pi-hole" "adguard" ) local AVAILABLE_SERVICES=( "frigate" "gitea" "home-assistant" "immich" "it-tools" \ "nextcloud" "passbolt" "virtualization" ) local DNS_SERVICES_DESCRIPTION=( "Pi-Hole : Simple open-source DNS black hole" \ "AdGuard " : Feature rich DNS service ) local SERVICES_DESCRIPTION=( "Immich : Pictures and videos backup with local machine-learning" \ "Nextcloud : No fuss Office 365 replacement" \ "Passbolt: Security-first password manager with collaboration features" \ "Home-Assistant : Manage your smart home and security cameras" \ "Frigate [Home Assistant required] : Secure your house with security cameras" \ "Gitea : Your own git platform" \ "IT-tools : A set of useful tools when doing IT" \ "Virtualization : Run Virtual Machines (KVM/QEMU) with Libvirt" ) SELECTED_SERVICES=() local SELECTED_SERVICES_DESCRIPTION=$(gum choose --no-limit --header "Homelab services:" "${SERVICES_DESCRIPTION[@]}") SELECTED_DNS_SERVICE="" local SELECTED_DNS_SERVICE_DESCRIPTION=$(gum choose --no-limit --header "Homelab services:" "${DNS_SERVICES_DESCRIPTION[@]}") for i in ${!AVAILABLE_SERVICES[@]}; do if printf '%s' "${SELECTED_SERVICES_DESCRIPTION}" | grep -iq "${AVAILABLE_SERVICES[${i}]}"; then SELECTED_SERVICES+=("${AVAILABLE_SERVICES[${i}]}") fi done for i in ${!AVAILABLE_DNS_SERVICES[@]}; do if printf '%s' "${SELECTED_DNS_SERVICE_DESCRIPTION}" | grep -iq "${AVAILABLE_DNS_SERVICES[${i}]}"; then SELECTED_DNS_SERVICE="${AVAILABLE_DNS_SERVICES[${i}]}" fi done export SELECTED_SERVICES export SELECTED_DNS_SERVICE } disks_selection() { 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 -e "\n\n ❌ Aborting as requested."; exit 1; } echo -e "\n\n 🔎 Fetching and analyzing disks from target host... (This may take a moment)" if [[ "${#DISK_NAME[@]}" -eq 0 ]]; then echo -e "\n ❌ No disks found on the target host. Aborting." exit 1 fi local HEADER=$(printf " %-12s %-12s %-12s %-12s %s" "Device" "Type" "Size" "SMART" "Path") for i in ${!DISK_NAME[@]}; do local GUM_PRINTED_ELEMENT=$(printf "%-12s %-12s %-12s %-12s %s" \ "${DISK_NAME[${i}]}" "${DISK_TYPE[${i}]}" "${DISK_SIZE[${i}]}" \ "${DISK_HEALTH[${i}]}" "${DISK_DEVPATH[${i}]}") local GUM_PRINTED_ELEMENTS+=("${GUM_PRINTED_ELEMENT}") done echo "" gum style --foreground 212 "➡️ Please choose one (stripe) or two (mirror) disks for your NixOS boot installation :" local SELECTED_BOOT_DISK=$(gum choose --limit 2 --header "${HEADER}" "${GUM_PRINTED_ELEMENTS[@]}") for i in ${!DISK_NAME[@]}; do if printf '%s' "$SELECTED_BOOT_DISK" | grep -iqw "${DISK_NAME[${i}]}"; then BOOT_DISKS_ID_LIST+=("\"${DISK_ID[${i}]:-${DISK_DEVPATH[${i}]}}\"") BOOT_DISKS_NAME+=("${DISK_NAME[${i}]}") unset "GUM_PRINTED_ELEMENTS[${i}]" fi done echo "" gum style --foreground 212 "➡️ Please choose data and parity disks (up to 9 total) :" local SELECTED_DATA_DISK=$(gum choose --limit 9 --header "$HEADER" "${GUM_PRINTED_ELEMENTS[@]}") for i in ${!DISK_NAME[@]}; do if printf '%s' "$SELECTED_DATA_DISK" | grep -iq "${DISK_NAME[${i}]}"; then DATA_DISKS_ID+=("${DISK_ID[${i}]:-${DISK_DEVPATH[${i}]}}") DATA_DISKS_TYPE+=("${DISK_TYPE[${i}]}") fi done if [[ "${#DATA_DISKS_ID[@]}" -eq 1 ]]; then export PARITY_DISK_NUMBER=0 export CONTENT_DISK_NUMBER=1 export PARITY_DISK_LIST=() export CONTENT_DISK_LIST=("\"${DATA_DISKS_ID[0]}\"") else export PARITY_DISK_NUMBER=$(((${#DATA_DISKS_ID[@]} + 2) / 3)) export CONTENT_DISK_NUMBER=$((${#DATA_DISKS_ID[@]} - PARITY_DISK_NUMBER)) for i in $(seq 0 $(($CONTENT_DISK_NUMBER - 1))); do CONTENT_DISK_LIST+=("\"${DATA_DISKS_ID[${i}]}\"") done for i in $(seq $CONTENT_DISK_NUMBER $((${#DATA_DISKS_ID[@]} - 1))); do PARITY_DISK_LIST+=("\"${DATA_DISKS_ID[${i}]}\"") done fi if [[ "${#DATA_DISKS_ID[@]}" -gt 0 ]]; then for i in ${!DATA_DISKS_ID[@]}; do if [[ "${DATA_DISKS_TYPE[${i}]}" == "HDD" ]]; then SPINDOWN_DISKS_LIST+=("\"${DATA_DISKS_ID[${i}]}\"") fi done fi export SPINDOWN_DISKS_LIST export BOOT_DISKS_ID_LIST export PARITY_DISK_LIST export CONTENT_DISK_LIST } server_config_generation() { echo -e "\n # Server settings" >> ${CONFIGURATION_PATH} echo -e " time.timeZone = \"${TIMEZONE}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.locale = \"${LOCALE}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.language = \"${LANGUAGE}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.owner = \"${SERVER_OWNER_NAME}\";" >> ${CONFIGURATION_PATH} } network_config_generation() { echo -e "\n # Network settings" >> ${CONFIGURATION_PATH} echo -e " numbus.networking.ipAddress = \"${HOME_SERVER_IP}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.networking.interface = \"${TARGET_INTERFACE}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.networking.routerIpAddress = \"${NETWORK_ROUTER_IP}\";" >> ${CONFIGURATION_PATH} } services_config_generation() { echo -e "\n # DNS settings" >> ${CONFIGURATION_PATH} echo -e " numbus.services.dns = \"${SELECTED_DNS_SERVICE}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.services.${SELECTED_DNS_SERVICE}.enable = true;" >> ${CONFIGURATION_PATH} echo -e "\n # Services settings" >> ${CONFIGURATION_PATH} echo -e " numbus.services.domain = \"${DOMAIN_NAME}\";" >> ${CONFIGURATION_PATH} for service in "${SELECTED_SERVICES[@]}"; do echo -e " numbus.services.${service}.enable = true;" >> ${CONFIGURATION_PATH} done } mail_config_generation() { echo -e "\n # Mail settings" >> ${CONFIGURATION_PATH} echo -e " numbus.mail.enable = true;" >> ${CONFIGURATION_PATH} echo -e " numbus.mail.userAddress = \"${SERVER_USER_EMAIL}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.mail.adminAddress = \"${SERVER_ADMIN_EMAIL}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.mail.smtpUsername = \"${SMTP_SERVER_USERNAME}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.mail.smtpPasswordPath = config.sops.secrets.smtpPassword.path;" >> ${CONFIGURATION_PATH} if [[ "${SMTP_SERVER_HOST}" != "smtp.gmail.com" ]]; then echo -e " numbus.mail.smtpServer = \"${SMTP_SERVER_HOST}\";" >> ${CONFIGURATION_PATH} fi if [[ "${SMTP_SERVER_PORT}" != "587" ]]; then echo -e " numbus.mail.smtpPort = ${SMTP_SERVER_PORT};" >> ${CONFIGURATION_PATH} fi } disk_config_generation() { echo -e "\n # Hardware settings" >> ${CONFIGURATION_PATH} echo -e " numbus.hardware.bootDisksList = [ ${BOOT_DISKS_ID_LIST[@]} ];" >> ${CONFIGURATION_PATH} echo -e " numbus.hardware.dataDisksList = [ ${CONTENT_DISK_LIST[@]} ];" >> ${CONFIGURATION_PATH} echo -e " numbus.hardware.parityDisksList = [ ${PARITY_DISK_LIST[@]} ];" >> ${CONFIGURATION_PATH} echo -e " numbus.hardware.spindownDisksList = [ ${SPINDOWN_DISKS_LIST[@]} ];" >> ${CONFIGURATION_PATH} echo "}" >> ${CONFIGURATION_PATH} } keys_generation() { for i in $(seq 1 "${#BOOT_DISKS_ID_LIST[@]}"); do PASS="$(xkcdpass)" echo -n "$PASS" > "final-nix-config/etc/secrets/disks/boot-${i}" chmod 600 "final-nix-config/etc/secrets/disks/boot-${i}" ssh_to_host 'bash -s' << EOF echo "$LIVE_TARGET_PASSWD" | sudo -S mkdir -p /etc/secrets/disks/ echo "$LIVE_TARGET_PASSWD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/boot-${i}" echo "$LIVE_TARGET_PASSWD" | sudo -S chmod 600 /etc/secrets/disks/boot-${i} EOF done for i in $(seq 1 "$CONTENT_DISK_NUMBER"); do PASS="$(xkcdpass)" echo -n "$PASS" > "final-nix-config/etc/secrets/disks/content-${i}" chmod 600 "final-nix-config/etc/secrets/disks/content-${i}" ssh_to_host 'bash -s' << EOF echo "$LIVE_TARGET_PASSWD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/content-${i}" echo "$LIVE_TARGET_PASSWD" | sudo -S chmod 600 /etc/secrets/disks/content-${i} EOF done for i in $(seq 1 "$PARITY_DISK_NUMBER"); do PASS="$(xkcdpass)" echo -n "$PASS" > "final-nix-config/etc/secrets/disks/parity-${i}" chmod 600 "final-nix-config/etc/secrets/disks/parity-${i}" ssh_to_host 'bash -s' << EOF echo "$LIVE_TARGET_PASSWD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/parity-${i}" echo "$LIVE_TARGET_PASSWD" | sudo -S chmod 600 /etc/secrets/disks/parity-${i} EOF done local SSH_KEYS_FORMATTED="" if [[ "$(declare -p AUTHORIZED_SSH_PUBLIC_KEY 2>/dev/null)" =~ "declare -a" ]]; then for key in "${AUTHORIZED_SSH_PUBLIC_KEY[@]}"; do SSH_KEYS_FORMATTED+=" \"$key\""$'\n' done else SSH_KEYS_FORMATTED=" \"$AUTHORIZED_SSH_PUBLIC_KEY\""$'\n' fi export AUTHORIZED_SSH_PUBLIC_KEY="$SSH_KEYS_FORMATTED" echo -e "\n ✅ Generating sops-nix keys..." ssh-to-age -private-key -i final-nix-config/home/numbus-admin/.ssh/id_ed25519 > final-nix-config/var/lib/sops-nix/key.txt export SOPS_PUBLIC_KEY=$(age-keygen -y final-nix-config/var/lib/sops-nix/key.txt) echo -e "\n ✅ Generating sops-nix configuration files..." envsubst < templates/nix-config/sops-nix/.sops.yaml > final-nix-config/etc/nixos/.sops.yaml echo -e "\n ✅ Encrypting secrets in the correct file..." envsubst < "templates/nix-config/sops-nix/secrets.yaml" \ | sops encrypt --filename-override secrets.yaml \ --input-type yaml --output-type yaml \ --age $SOPS_PUBLIC_KEY \ --output final-nix-config/etc/nixos/secrets/secrets.yaml } sum_up() { DISK_RECAP_CONTENT=$(cat << EOF ### Disk Configuration Summary Please review the selected disk layout before proceeding. **Boot Disks (${#BOOT_DISKS_ID_LIST[@]}):** * **Boot 1:** \`${BOOT_DISKS_ID_LIST[0]}\` $( [[ -n "${BOOT_DISKS_ID_LIST[1]:-}" ]] && echo "* **Boot 2:** \`${BOOT_DISKS_ID_LIST[1]}\`" || echo "* **Boot 2:** *Not configured*" ) **Data Disks ($CONTENT_DISK_NUMBER):** $( j=1 && for i in $(seq 0 $(($CONTENT_DISK_NUMBER - 1))); do echo "* **Data ${j}:** \`${DATA_DISKS_ID[${i}]}\`" && j=$((j + 1)); done ) $( [[ $CONTENT_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" ) **Parity Disks ($PARITY_DISK_NUMBER):** $( [[ $PARITY_DISK_NUMBER -gt 0 ]] && j=1 && for i in $(seq $CONTENT_DISK_NUMBER $((${#DATA_DISKS_ID[@]} - 1))); do echo "* **Parity ${j}:** \`${DATA_DISKS_ID[${i}]}\`" && j=$((j + 1)); done ) $( [[ $PARITY_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" ) EOF ) gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "$(gum format <<< "${DISK_RECAP_CONTENT}")" gum confirm "Proceed with this disk configuration?" || { echo -e "\n\n ❌ Aborting as requested."; exit 1; } } cloudflare_dns_setup() { local ZONE_ID local RECORD_COUNT local IS_MATCHING local DNS_RECORDS create_records() { local SUBDOMAIN="${1}" local CREATION_STATUS CREATION_STATUS=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \ -H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \ -H "Content-Type: application/json" \ --data "{\"type\":\"A\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${HOME_SERVER_IP}\",\"ttl\":1,\"proxied\":false}" | jq -r '.success') if [[ "${CREATION_STATUS}" == "true" ]]; then echo " ✅ Successfully created a DNS record for ${SUBDOMAIN}" else echo -e " ❌ Failed to create a DNS record for ${SUBDOMAIN}. Check documentation to \n learn how you can create them manually." fi } erase_records() { local SUBDOMAIN="${1}" gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 " ⚠️ $(gum style --foreground 212 'WARNING:') One or more existing type A DNS records found for \`${SUBDOMAIN}\`. This script can clear those DNS records for you and create the correct ones needed for the server. If you are unsure that these records are actually in use, please select \"no\"." gum confirm "Select \"yes\" to clear ALL EXISTING type A DNS records for this subdomain and automatically create the correct ones." \ || { echo -e "\n ⚠️ DNS records for ${SUBDOMAIN} will not be updated"; return 0; } RECORD_IDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}&type=A" \ -H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \ -H "Content-Type: application/json" | jq -r '.result[].id') for id in ${RECORD_IDS}; do curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${id}" \ -H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \ -H "Content-Type: application/json" > /dev/null 2>&1 done create_records "${SUBDOMAIN}" } echo -e "\n\n ☁️ Configuring Cloudflare DNS records..." SELECTED_SERVICES_DNS=() for service in "${SELECTED_SERVICES[@]}"; do [[ "${service}" == "virtualization" ]] && continue SELECTED_SERVICES_DNS+=("${service}.${DOMAIN_NAME}") done SELECTED_SERVICES_DNS+=("${SELECTED_DNS_SERVICE}.${DOMAIN_NAME}") # Get Zone ID ZONE_ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN_NAME}" \ -H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \ -H "Content-Type: application/json" | jq -r '.result[0].id') if [[ "${ZONE_ID}" == "null" || -z "${ZONE_ID}" ]]; then echo -e "\n\n ⚠️ Could not fetch Zone ID for ${DOMAIN_NAME}. Please check your Cloudflare \"DNS ZONE\" API token" echo "Check the Numbus-Server documentation to learn how to get one." fi # Check for existing records and create them if non-existent for service_domain in "${SELECTED_SERVICES_DNS[@]}"; do DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${service_domain}&type=A" \ -H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \ -H "Content-Type: application/json") RECORD_COUNT=$(echo "${DNS_RECORDS}" | jq '.result | length') if [[ "${RECORD_COUNT}" -eq 0 ]]; then echo -e "\n ⚠️ No DNS record found for ${service_domain}" create_records "${service_domain}" elif [[ "${RECORD_COUNT}" -eq 1 ]]; then if [[ $(echo "${DNS_RECORDS}" | jq ".result[0].content == \"${HOME_SERVER_IP}\"") == "true" ]]; then echo -e "\n ✅ DNS record already configured for ${service_domain}" else echo -e "\n ⚠️ No DNS record found for ${service_domain}" erase_records "${service_domain}" fi elif [[ "${RECORD_COUNT}" -gt 1 ]]; then erase_records "${service_domain}" fi done } export_configuration() { cp deploy.conf final-nix-config/etc/numbus-server/numbus-server.conf local CONFIG_EXPORT_DIR="final-nix-config/etc/numbus-server/" local CONFIG_EXPORT_FILE="${CONFIG_EXPORT_DIR}/numbus-server.conf" echo "export TARGET_INTERFACE=\"${TARGET_INTERFACE}\"" >> $CONFIG_EXPORT_FILE echo -e "\n# SERVER SETTINGS" >> $CONFIG_EXPORT_FILE echo "export SERVER_OWNER_NAME=\"${SERVER_OWNER_NAME:-User}\"" >> $CONFIG_EXPORT_FILE echo -e "\n# DISK SETTINGS" >> $CONFIG_EXPORT_FILE echo "export BOOT_DISKS_ID_LIST=\"(${BOOT_DISKS_ID_LIST[@]})\"" >> $CONFIG_EXPORT_FILE echo "export DATA_DISKS_ID=\"(${DATA_DISKS_ID[@]})\"" >> $CONFIG_EXPORT_FILE echo "export DATA_DISKS_TYPE=\"(${DATA_DISKS_TYPE[@]})\"" >> $CONFIG_EXPORT_FILE echo "export SPINDOWN_DISKS_LIST=\"(${SPINDOWN_DISKS_LIST[@]})\"" >> $CONFIG_EXPORT_FILE echo "export CONTENT_DISK_NUMBER=\"${CONTENT_DISK_NUMBER}\"" >> $CONFIG_EXPORT_FILE echo "export PARITY_DISK_NUMBER=\"${PARITY_DISK_NUMBER}\"" >> $CONFIG_EXPORT_FILE echo -e "\n# TPM SETTINGS" >> $CONFIG_EXPORT_FILE echo "export TARGET_TPM=\"${TARGET_TPM}\"" >> $CONFIG_EXPORT_FILE echo "export TARGET_TPM_VERSION=\"${TARGET_TPM_VERSION:-}\"" >> $CONFIG_EXPORT_FILE } deploy() { git -C "/home/nixosd/numbus-server" add -f "final-nix-config" echo -e "\n\n🔄 Deploying to the remote server..." nix flake update --flake ./final-nix-config/etc/nixos nix run github:nix-community/nixos-anywhere -- \ --flake ./final-nix-config/etc/nixos#numbus-server \ --extra-files final-nix-config \ --chown "/home/numbus-admin/" 1000:1000 \ --target-host ${TARGET_USER}@${LIVE_TARGET_IP} echo -e "\n\n✅ Installation successfull !" sleep 1 } postrun_action() { TARGET_USER="numbus-admin" LIVE_TARGET_IP="${HOME_SERVER_IP}" LIVE_TARGET_PASSWD="changeMe!" echo -e "\n\n Now the remote machine will reboot. You will need to input the boot disk(s) passphrase. This will be the only time you will have to do so, it will be automatic in the future." gum spin --title "Rebooting the remote..." -- sleep 120 gum confirm "➡️ Select \"yes\" once the machine rebooted and you unlocked the disks." || { echo -e "\n\n❌ Aborting as requested."; exit 1; } FOUND="false" i="0" while [[ "${FOUND}" == "false" ]]; do if ping -c1 -W1 $HOME_SERVER_IP >/dev/null 2>&1; then FOUND="true" echo -e "\n✅ Ping ${HOME_SERVER_IP} successful ! Continuing..." else i=$((i + 1)) if [[ "${i}" -gt 150 ]]; then echo -e "\n\n❌ Could not connect to the server after 150 retries. \ This is most likely due to a networking issue. Please double check your network settings. Aborting." exit 1 fi fi done if [[ "${TARGET_TPM}" == "true" && ${TARGET_TPM_VERSION} -eq 2 ]]; then gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 " A TPM version 2 has been detected on the system. You can choose to enable automatic disk decryption on boot. Enabling automatic disk decryption on boot means that you won't have to enter your disk password everytime you start your server. This comes in very handy if you don't plan to leave your server accessible with a keyboard or if you don't have an IP KVM. Note : This feature is currently vulnerable to on-site attacks. This means that an attacker with physical access to your machine could steal the password from the TPM, and therefore have access to all your date. Do you want to enable automatic disk decryption on boot ?" if gum confirm "➡️ I understand, 'yes' to proceed."; then sshpass -p "${LIVE_TARGET_PASSWD}" ssh -i "final-nix-config/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" 'bash -s' << EOF echo "Enrolling boot disk key to TPM..." BOOT_DISKS_NAME=(${BOOT_DISKS_NAME[@]}) DEBUG=${DEBUG} DISK_PATH="" j=1 for i in \${!BOOT_DISKS_NAME[@]}; do if echo "\${BOOT_DISKS_NAME[\${i}]}" | grep -iq "nvme"; then [[ "\${DEBUG}" == "true" ]] && echo "NVMe detected..." DISK_PATH="/dev/\${BOOT_DISKS_NAME[\${i}]}p2" else [[ "\${DEBUG}" == "true" ]] && echo "Non-NVMe drive detected..." DISK_PATH="/dev/\${BOOT_DISKS_NAME[\${i}]}2" fi [[ "\${DEBUG}" == "true" ]] && echo "Issuing enroll command for disk \${DISK_PATH}..." echo ${LIVE_TARGET_PASSWD} | sudo -S systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+7 --unlock-key-file=/etc/secrets/disks/boot-\${j} \${DISK_PATH} j=\$((j + 1)) done echo "Getting PCRS 15 hash..." PCR_HASH=\$(echo ${LIVE_TARGET_PASSWD} | sudo -S systemd-analyze pcrs 15 --json=short) echo ${LIVE_TARGET_PASSWD} | sudo -S sed -i "s|PCR_HASH|\${PCR_HASH}|" /etc/nixos/configuration.nix EOF else echo "Skipping TPM configuration." fi else echo "No supported TPM detected (TPM version 2 required). Skipping TPM configuration." fi gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 " ⚠️ $(gum style --foreground 212 'WARNING:') You will now set the password of the numbus-admin user. You will almost never user it. Consider using a very strong password : you can write it down securely on a hidden sheet of paper or add it to your password manager (locally with Passbolt with any other online password manager provider)." gum confirm "➡️ I understand, 'yes' to proceed." || { echo -e "\n\n❌ Aborting as requested."; exit 1; } echo $LIVE_TARGET_PASSWD | sudo -S passwd numbus-admin } nix_update() { echo -e "\n\n🔄 Updating NixOS on the remote server..." nixos-rebuild --target-host numbus-admin@${LIVE_TARGET_IP} \ --use-remote-sudo switch --flake final-nix-config/etc/nixos#numbus-server } congrats() { gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 " ⚠️ $(gum style --foreground 212 'CONGRATULATIONS !!:') You now have a working home server. \ Data stored on there will be fully yours and protected. Keep in my mind this comes with the \ responsability of managing it and keeping it secure. Now, you have to log in the webpages of \ the services you installed. Create an admin account for all of them and configure them (or keep \ it simple and use defaults) and take care to note down all the passwords. Change all default passwords \ and create user accounts for your family or friends that will use the server. Cheers !!" } set -euo pipefail fastfetch --logo nixos --structure ' ' cat << EOF ██████ █████ █████ ▒▒██████ ▒▒███ ▒▒███ ▒███▒███ ▒███ █████ ████ █████████████ ▒███████ █████ ████ █████ ▒███▒▒███▒███ ▒▒███ ▒███ ▒▒███▒▒███▒▒███ ▒███▒▒███▒▒███ ▒███ ███▒▒ ▒███ ▒▒██████ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒▒█████ ▒███ ▒▒█████ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒▒▒▒███ █████ ▒▒█████ ▒▒████████ █████▒███ █████ ████████ ▒▒████████ ██████ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒ █████████ ███▒▒▒▒▒███ ▒███ ▒▒▒ ██████ ████████ █████ █████ ██████ ████████ ▒▒█████████ ███▒▒███▒▒███▒▒███▒▒███ ▒▒███ ███▒▒███▒▒███▒▒███ ▒▒▒▒▒▒▒▒███▒███████ ▒███ ▒▒▒ ▒███ ▒███ ▒███████ ▒███ ▒▒▒ ███ ▒███▒███▒▒▒ ▒███ ▒▒███ ███ ▒███▒▒▒ ▒███ ▒▒█████████ ▒▒██████ █████ ▒▒█████ ▒▒██████ █████ ▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒ EOF sleep 1 # Choose the action ACTION_ANSWER=$(gum choose "[1] 🌐 Deploy NixOS on a remote machine" "[2] 💽 Deploy NixOS on a remote machine with a file configuration" "[3] 🛠️ Update a NixOS remote machine") if [[ "$ACTION_ANSWER" == "[1] 🌐 Deploy NixOS on a remote machine" ]]; then TARGET_USER="nixos" echo -e "\n➡️ Proceeding with deployment…" gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "➡️ On the target host : start the computer and boot into the NixOS iso. Launch a console and set up a new user password" gum confirm "Do you understand and wish to proceed?" || { echo "❌ Aborting as requested"; exit 1; } strictly_necessary_information necessary_information setup_ssh hierarchy_preparation hardware_detection services_selection disks_selection server_config_generation network_config_generation services_config_generation mail_config_generation disk_config_generation keys_generation sum_up cloudflare_dns_setup export_configuration deploy postrun_action elif [[ "$ACTION_ANSWER" == "[2] 💽 Deploy NixOS on a remote machine with a file configuration" ]]; then TARGET_USER="nixos" echo -e "\n➡️ Proceeding with deployment using a config file…" gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "➡️ On the target host : start the computer and boot into the NixOS iso. Launch a console and set up a new user password" gum confirm "Do you understand and wish to proceed?" || { echo "❌ Aborting as requested"; exit 1; } necessary_information_config setup_ssh hierarchy_preparation hardware_detection disks_selection server_config_generation network_config_generation services_config_generation mail_config_generation disk_config_generation keys_generation sum_up cloudflare_dns_setup export_configuration deploy postrun_action elif [[ "$ACTION_ANSWER" == "[3] 🛠️ Update a NixOS remote machine" ]]; then TARGET_USER="numbus-admin" echo -e "\n➡️ Proceeding with update…" gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "➡️ On the target host : make sure the NixOS installation you want to update is up-and-running, accessible with SSH" gum confirm "Do you understand and wish to proceed?" || { echo "❌ Aborting as requested."; exit 1; } strictly_necessary_information setup_ssh more_information_config folder_tree_generation nix_generation nix_update congrats else echo "Aborting - you did not type 1, 2 or 3" exit 1 fi