#!/usr/bin/env nix-shell #!nix-shell -i bash -p bash nano coreutils gnused gum fastfetch xkcdpass sops ssh-to-age age sshpass envsubst pciutils usbutils mosquitto curl jq yq python3 # --- UTILITY FUNCTIONS ---> echod() { MESSAGE=${1} if [[ ${DEBUG} -eq 1 ]]; then echo -e ${MESSAGE} fi } ssh_to_host() { local COMMAND="${1}" ssh -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" "${COMMAND}" }"Invalid IP address format." get_valid_input() { local VAR_NAME="${1}" local HEADER="${2}" local PLACEHOLDER="${3}" local REGEX="${4}" local MANDATORY="${5:-true}" local SENSITIVE="${6:-false}" if [[ "${MANDATORY}" == "true" ]]; then local PROMPT="(Required) > " elif [[ "${MANDATORY}" == "false" ]]; then local PROMPT="(Optional) > " fi while true; do local INPUT=$(gum input --header "${HEADER}" --prompt "${PROMPT}" --placeholder "${PLACEHOLDER}") # Handle empty input if [[ -z "${INPUT}" ]]; then if [[ "${MANDATORY}" == true ]]; then gum style --foreground "#ff0000" -- "✖ This field is mandatory." continue else INPUT="" break fi fi # Handle Regex Validation if [[ -n "${REGEX}" ]]; then if [[ "${INPUT}" =~ ${REGEX} ]]; then export "${VAR_NAME}"="${INPUT}" break else gum style --foreground "#ff0000" -- "✖ Invalid format. Please try again." fi else export "${VAR_NAME}"="${INPUT}" break fi done } # --- UTILITY FUNCTIONS ---< # --- GLOBAL FUNCTIONS ---> cleanup() { echo -e "\n ✅ Cleaning up..." rm -${DIR_RM_FLAGS} ${TMP_FILES_PATH}/ if [[ ${WEB_MODE} -eq 1 && -n "${BRIDGE_PID:-}" ]]; then kill ${BRIDGE_PID} fi } compatibility_check() { TEST_FAIL=0 if [[ -r /etc/os-release ]] && grep -qi '^ID=nixos\b' /etc/os-release; then echod "\n ✅ NixOS system detected." else TEST_FAIL=$((TEST_FAIL + 1)) echo -e "\n ❌ You are not on a NixOS based system. This is required to continue." fi if [[ "$(uname -m)" == "x86_64" ]]; then echod "\n ✅ x86_64 system detected." else TEST_FAIL=$((TEST_FAIL + 1)) echo -e "\n ❌ You are not on a x86_64 based system. This is required to continue." fi if [[ ${TEST_FAIL} -gt 0 ]]; then COMPATIBILITY_OVERRIDE=$(gum choose --header "Some compatibility checks failed. The installation will very likely fail. Continue ?" \ "No" \ "Yes, I know what I am doing") [[ "${COMPATIBILITY_OVERRIDE}" == "No" ]] && exit 1 [[ "${COMPATIBILITY_OVERRIDE}" != "No" ]] && echo -e "\n ⚠️ Continuing anyways, this is not supported by Numbus." fi return 0 } hierarchy_preparation() { echod "\n 🔄 Preparing the folder hierarchy for the final configuration..." if [[ -e config/* ]]; then echo " ⚠️ It seems you have already run this script. Previously generated files need to be cleaned up." OLD_CONFIG_PATH="trash/$(date +"%Y-%m-%d-%Hh%M")/" mkdir -${MKDIR_FLAGS} ${OLD_CONFIG_PATH} mv -${MV_FLAGS} config/ ${OLD_CONFIG_PATH} echo " ✅ Your files have been moved to the ${OLD_CONFIG_PATH} directory. You can retrieve them there if needed." fi # Script folders mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/config mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/logs mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/tmp [[ ${WEB_MODE} -eq 1 ]] && mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/web # Secrets mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/ mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/var/lib/sops-nix/ mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/disks mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/system if [[ "${DEVICE_TYPE}" == "server" || "${DEVICE_TYPE}" == "backup" ]]; then mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/podman fi echod "\n ✅ Folder hierarchy ready" } hardware_detection() { local TMPFILE="/tmp/nixos-installation-hw-detection" ssh_to_host 'bash -s' << SSHEND TARGET_GRAPHICS_BRAND=() 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 [[ "\$TRANSPORT_PROTOCOL" == "usb" ]]; then DISK_TYPE+=("USB"); elif [[ "\$HDD" -eq 1 ]]; then DISK_TYPE+=("HDD"); elif [[ "\$HDD" -eq 0 ]]; then DISK_TYPE+=("SSD"); else DISK_TYPE+=("Other") fi # Disk health if [[ \$(echo "${LIVE_TARGET_PASSWORD}" | 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_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 \ TARGET_GRAPHICS_BRAND \ DISK_DEVPATH \ DISK_NAME \ DISK_TYPE \ DISK_HEALTH \ DISK_ID \ DISK_SIZE; do declare -p \${var} | sed 's/^declare /declare -g /' >> "${TMPFILE}" done SSHEND scp -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}":"${TMPFILE}" "${TMPFILE}" &> /dev/null source "${TMPFILE}" local DISK_FLAT_ARRAY=() for i in "${!DISK_NAME[@]}"; do DISK_FLAT_ARRAY+=("${DISK_NAME[$i]}" "${DISK_DEVPATH[$i]}" "${DISK_TYPE[$i]}" "${DISK_HEALTH[$i]}" "${DISK_ID[$i]}" "${DISK_SIZE[$i]}") done jq -n \ --argjson graphics_enabled "${TARGET_GRAPHICS:-false}" \ --argjson graphics_renderer "${TARGET_GRAPHICS_RENDERER:-false}" \ --argjson tpu_usb "${TARGET_USB_CORAL:-false}" \ --argjson tpu_pcie "${TARGET_PCIE_CORAL:-false}" \ --argjson tpm_enabled "${TARGET_TPM:-false}" \ --arg tpm_version "${TARGET_TPM_VERSION:-N/A}" \ --arg zigbee_device "${TARGET_ZIGBEE_DEVICE:-}" \ --arg interface "${TARGET_INTERFACE:-}" \ --argjson brands "$(jq -n '$ARGS.positional' --args ${TARGET_GRAPHICS_BRAND[@]:-})" \ ' { graphics: { enabled: $graphics_enabled, brands: $brands, renderer: $graphics_renderer }, tpu: { usb: $tpu_usb, pcie: $tpu_pcie }, tpm: { enabled: $tpm_enabled, version: $tpm_version }, zigbee: { device: $zigbee_device }, network: { interface: $interface }, disks: [ $ARGS.positional | range(0; length; 6) as $i | { name: .[$i], path: .[$i+1], type: .[$i+2], health: .[$i+3], id: .[$i+4], size: .[$i+5] } ] }' --args "${DISK_FLAT_ARRAY[@]:-}" > ${HARDWARE_DATA_PATH} if ssh_to_host "sudo nixos-generate-config --no-filesystems --show-hardware-config" > ${EXTRA_FILES_PATH}/etc/nixos/hardware-configuration.nix; then echo -e "\n✅ Hardware configuration generated" else echo -e "\n❌ Failed to generate hardware configuration" exit 1 fi } # --- GLOBAL FUNCTIONS ---< # --- MAIN WEB FUNCTIONS ---> launch_configurator() { echo -e "\n 🚀 Launching Numbus Configurator..." python3 "${BRIDGE_SCRIPT}" > /dev/null 2>&1 & export BRIDGE_PID=$! echo -e "\n ➡️ Open your browser at: $(gum style --foreground 212 "http://localhost:${WEBSERVER_PORT}")" xdg-open "http://localhost:${WEBSERVER_PORT}" 2>/dev/null || open "http://localhost:${WEBSERVER_PORT}" 2>/dev/null || true } # --- MAIN WEB FUNCTIONS ---< # --- MAIN TUI FUNCTIONS ---> preparation() { echo -e "\n ➡️ This script will now guide you through the configuration and gather the necessary information." echo "" RAW_DEVICE_TYPE=$(gum choose --header "Choose the device you want to deploy :" \ "Numbus Server : Professional-grade hosting, strictly kept under your roof." \ "Numbus Backup Server : Automated, high-efficiency protection for your entire ecosystem." \ "Numbus Computer : A modern, privacy-respecting machine built for work, creation, and play — without the corporate bloat." \ "Numbus TV : A premium cinematic experience free from trackers and forced subscriptions.") case "${RAW_DEVICE_TYPE}" in "Numbus Server : "* ) DEVICE_TYPE="server" ;; "Numbus Backup Server : "* ) DEVICE_TYPE="backup" ;; "Numbus Computer : "* ) DEVICE_TYPE="computer" ;; "Numbus TV : "* ) DEVICE_TYPE="tv" ;; esac RAW_DEPLOYMENT_MODE=$(gum choose --header "Choose your preferred deployment mode :" \ "Interactive : You don't already have a configuration." \ "Non-interactive : You have a valid configuration hosted on a Git platform.") case "${RAW_DEPLOYMENT_MODE}" in "Interactive : "* ) DEPLOYMENT_MODE="interactive" ;; "Non-interactive : "* ) DEPLOYMENT_MODE="non-interactive" ;; esac if [[ "${DEPLOYMENT_MODE}" == "non-interactive" ]]; then git_url() { IMPORTED_CONFIG_URL=$(gum input --placeholder "https://yourgitplatform.tld/your-user/repo-containing-the-configuration" --header "Please provide the URL to the git repository containing your configuration :") } git_url until git clone "${IMPORTED_CONFIG_URL}" imported_configuration; do echo -e "\n ⚠️ This did not work correctly." echo -e "\n Is this URL correct [y/n] ? ${IMPORTED_CONFIG_URL}" read URL if [[ "${URL^^}" == "N" ]]; then git_url fi echo -e "\n You will be prompted for your credentials again. Make sure that they are correct." done fi echo "" gum format -- \ "➡️ To continue, you need to start the target device in a NixOS live environment : 1. Download the NixOS iso from the **[official website](https://nixos.org/download/)**. 2. Flash it to a USB stick. (use a flashing tool like **[Rufus](https://rufus.ie/en/#download)**, **[BalenaEtcher](https://etcher.balena.io/#download-etcher)**, **[Impression](https://flathub.org/en/apps/io.gitlab.adhami3310.Impression)**, ...) 3. Make sure your computer allows booting from USB drives and is in UEFI mode. 4. Boot into the NixOS live environment. 5. Launch a terminal. Set a password using \`passwd\` and find the IP address using \`ip a\`" echo "" gum confirm "Is the device ready ?" || { echo "❌ You need to prepare the device. The script cannot continue."; exit 1; } # 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}" user_input "LIVE_TARGET_PASSWORD" " Please enter the password for '${TARGET_USER}@${LIVE_TARGET_IP}' :" "${LIVE_TARGET_IP}'s password" "" "" "true" # INTERNATIONALIZATION SETTINGS user_input "INTERNATIONALIZATION_TIMEZONE" " Please provide the wanted timezone :" "For example : Europe/Paris, Europe/Berlin, Europe/London, etc" user_input "INTERNATIONALIZATION_LANGUAGE" " Please provide the wanted language :" "For example : French, Deutsch, English, etc" user_input "INTERNATIONALIZATION_COUNTRY" " Please provide your country :" "For example : France, Germany, Great-Britain, etc" } configuration() { if [[ "${DEVICE_TYPE}" == "server" ]]; then # Users & Groups user_input "SERVER_OWNER_NAME" " Please provide the name of the owner of this server :" "For example : Steve" user_input "SERVER_ADMIN_EMAIL" " Please provide a valid ADMIN email address (ACME, system failures notifications, etc) :" "For example : myemail@mydomain.mytld" "${EMAIL_REGEX}" user_input "AUTHORIZED_SSH_PUBLIC_KEY" " Please provide the SSH public key of an authorized device (or a comma-separated list) :" "For example : ssh-ed25519 AAAAC3Nzam0uYewNAbxL8Fci8 user@your-pc or ssh-* * *, ssh-* * *, etc" "${SSH_KEY_REGEX}" "Invalid SSH key format (must start with ssh-...)." 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" # TRAEFIK SETTINGS user_input "DOMAIN_NAME" " Please provide the domain name (FQDN) your home server will use :" "For example : yourdomain.com" "${DOMAIN_REGEX}" 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)" # SMTP SETTINGS user_input "SMTP_SERVER_USERNAME" " Please provide a valid sender email address :" "For example : myemail@gmail.com" "${EMAIL_REGEX}" 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." elif [[ "${DEVICE_TYPE}" == "backup" ]]; then : elif [[ "${DEVICE_TYPE}" == "computer" ]]; then : elif [[ "${DEVICE_TYPE}" == "tv" ]]; then : fi } setup_ssh() { echod "\n ✅ Generating new SSH key for numbus-admin..." chmod 700 ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/ ssh-keygen -t "ed25519" -C "numbus-admin@numbus-server" -f "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" -N "" -q if [[ ${DEBUG} -eq 1 ]]; then echo -e "\n ➡️ Copying SSH key to target host '${TARGET_USER}@${LIVE_TARGET_IP}'..." fi if sshpass -p "${LIVE_TARGET_PASSWORD}" ssh-copy-id -o StrictHostKeyChecking=no -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}"; then if [[ ${DEBUG} -eq 1 ]]; then echo -e "\n ✅ SSH key copied successfully" fi else echo -e "\n ❌ Failed to copy SSH key. Please check the host IP and password." exit 1 fi } services_selection() { services_choice() { local SERVICES_LIST=( "${1[@]}" ) local SERVICES_DESCRIPTION=( "${2[@]}" ) local FINAL_VARIABLE="${3}" local HEADER="${4}" local LIMIT="${5:---no-limit}" local SELECTED_SERVICES=() local SELECTED_SERVICES_DESCRIPTION=() local SELECTED_SERVICES_DESCRIPTION=$(gum choose ${LIMIT} --header "${HEADER}" "${SERVICES_DESCRIPTION[@]}") for i in ${!SERVICES_LIST[@]}; do if printf '%s' "${SELECTED_SERVICES_DESCRIPTION}" | grep -iq "${SERVICES_LIST[${i}]}"; then SELECTED_SERVICES+=("${SERVICES_LIST[${i}]}") fi done export "${FINAL_VARIABLE}=(${SELECTED_SERVICES[@]})" } echo -e "\n\n ➡️ You will now select the services you want installed on your server:" services_choice "${DNS_SERVICES_LIST[@]}" "${DNS_SERVICES_DESCRIPTION[@]}" "SELECTED_DNS_SERVICE" "Choose your preferred DNS service :" "--limit=1" services_choice "${WEB_APPLICATIONS_LIST[@]}" "${WEB_APPLICATIONS_DESCRIPTION[@]}" "SELECTED_WEB_APPLICATIONS" "Choose the web applications you want to install :" services_choice "${SYSTEM_SERVICES_LIST[@]}" "${SYSTEM_SERVICES_DESCRIPTION[@]}" "SELECTED_SYSTEM_SERVICES" "Choose the system services you want to install :" gum confirm "Do you want to edit the default subdomain of your services ?" || { echo -e "\n\n✅ Continuing..."; return 0; } for service in ${SELECTED_WEB_APPLICATIONS[@]} ${SELECTED_DNS_SERVICE[@]}; do if gum confirm "Change the subdomain of ${service} ?"; then SELECTED_WEB_APPLICATIONS_SUBDOMAIN+=( "$(gum input --placeholder "${service}" --header "Please provide the desired subdomain for ${service}:")" ) fi done return 0 } users_and_groups() { declare -A ACL_GROUPS declare -A ACL_USERS compute_acl_services() { EXCLUDED_SERVICES=( "clamav" ) # Those are the services that don't have a web page or don't support SSO local ALL_SERVICES=("${SELECTED_DNS_SERVICE[@]}" "${SELECTED_WEB_APPLICATIONS[@]}" "${SELECTED_SYSTEM_SERVICES[@]}") for i in "${!ALL_SERVICES[@]}"; do for excluded in "${EXCLUDED_SERVICES[@]}"; do if [[ "${ALL_SERVICES[${i}]}" == "${excluded}" ]]; then unset "ALL_SERVICES[${i}]" fi done done } show_groups_table() { if [[ ${#ACL_GROUPS[@]} -eq 0 ]]; then gum style --italic --foreground "#6272a4" -- "No groups configured." return fi # We use CSV format with quotes to handle comma-separated services correctly local csv="Group Name,Allowed Services\n" for g in "${!ACL_GROUPS[@]}"; do csv+="\"$g\",\"${ACL_GROUPS[$g]}\"\n" done printf "%b" "$csv" | gum table } add_group() { if [[ ${#ACL_GROUPS[@]} -ge 10 ]]; then gum style --foreground "#ffb86c" -- "⚠ Maximum of 10 groups reached." sleep 2; return fi local group_name get_valid_input group_name "Group Name" "^[a-zA-Z0-9_-]+$" true "" if [[ -n "${ACL_GROUPS[$group_name]}" ]]; then gum style --foreground "#ff0000" -- "✖ Group already exists." sleep 2; return fi gum style --foreground "#50fa7b" -- "Select services for $group_name (Space to select, Enter to confirm):" local chosen_services chosen_services=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -) ACL_GROUPS["$group_name"]="$chosen_services" gum style --foreground "#50fa7b" -- "✔ Group '$group_name' created." sleep 1 } edit_group() { if [[ ${#ACL_GROUPS[@]} -eq 0 ]]; then return; fi local group_keys=("${!ACL_GROUPS[@]}") gum style -- "Select a group to edit:" local group_name=$(gum choose "${group_keys[@]}") if [[ -z "$group_name" ]]; then return; fi if [[ "$group_name" == "admin" ]]; then gum style --foreground "#ff0000" -- "✖ The admin group cannot be modified." sleep 2; return fi gum style --foreground "#50fa7b" -- "Select NEW services for $group_name:" local chosen_services=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -) ACL_GROUPS["$group_name"]="$chosen_services" gum style --foreground "#50fa7b" -- "✔ Group '$group_name' updated." sleep 1 } remove_group() { if [[ ${#ACL_GROUPS[@]} -eq 0 ]]; then return; fi local group_keys=("${!ACL_GROUPS[@]}") gum style -- "Select a group to REMOVE:" local group_name=$(gum choose "${group_keys[@]}") if [[ -z "$group_name" ]]; then return; fi if [[ "$group_name" == "admin" ]]; then gum style --foreground "#ff0000" -- "✖ The admin group cannot be removed." sleep 2; return fi gum style --foreground "#ff5555" --bold -- "Are you sure you want to delete '$group_name'?" if gum confirm; then unset ACL_GROUPS["$group_name"] gum style --foreground "#50fa7b" -- "✔ Group deleted." sleep 1 fi } manage_groups_menu() { while true; do clear gum style --border double --margin "1" --padding "0 1" --border-foreground "#8be9fd" -- "Group Management (${#ACL_GROUPS[@]}/10)" show_groups_table local action=$(gum choose "Add Group" "Edit Group" "Remove Group" "Back") case "$action" in "Add Group") add_group ;; "Edit Group") edit_group ;; "Remove Group") remove_group ;; "Back"|"") break ;; esac done } show_users_table() { if [[ ${#ACL_USERS[@]} -eq 0 ]]; then gum style --italic --foreground "#6272a4" -- "No users configured." return fi local csv="Username,Name,Email,Health Alerts,ACL Type,ACL Value\n" for u in "${!ACL_USERS[@]}"; do IFS='|' read -r name email phone health type input <<< "${ACL_USERS[$u]}" csv+="\"$u\",\"$name\",\"$email\",\"$health\",\"$type\",\"$input\"\n" done printf "%b" "$csv" | gum table } add_user() { if [[ ${#ACL_USERS[@]} -ge 20 ]]; then gum style --foreground "#ffb86c" -- "⚠ Maximum of 20 users reached." sleep 2; return fi local name username email phone health_alert acl_type acl_value get_valid_input username "Username" "^[a-z0-9_-]+$" true "" if [[ -n "${ACL_USERS[$username]}" ]]; then gum style --foreground "#ff0000" -- "✖ Username already exists." sleep 2; return fi get_valid_input name "Full Name" "" true "" get_valid_input email "Email Address" "$EMAIL_REGEX" true "" get_valid_input phone "Phone Number (E.164, optional)" "$PHONE_REGEX" false "" gum style -- "Inform about server health?" if gum confirm; then health_alert="true"; else health_alert="false"; fi gum style -- "How should ACL be managed for $username?" acl_type=$(gum choose "Assign to Group" "Manual Service Selection") if [[ "$acl_type" == "Assign to Group" ]]; then acl_type="group" local group_keys=("${!ACL_GROUPS[@]}") acl_value=$(gum choose "${group_keys[@]}") else acl_type="manual" gum style --foreground "#50fa7b" -- "Select services for $username:" acl_value=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -) fi ACL_USERS["$username"]="$name|$email|$phone|$health_alert|$acl_type|$acl_value" gum style --foreground "#50fa7b" -- "✔ User '$username' created." sleep 1 } edit_user() { if [[ ${#ACL_USERS[@]} -eq 0 ]]; then return; fi local user_keys=("${!ACL_USERS[@]}") gum style -- "Select a user to edit:" local username=$(gum choose "${user_keys[@]}") if [[ -z "$username" ]]; then return; fi # Extract current values IFS='|' read -r curr_name curr_email curr_phone curr_health curr_type curr_val <<< "${ACL_USERS[$username]}" local name email phone health_alert acl_type acl_value gum style --foreground "#f1fa8c" -- "Editing User: $username" get_valid_input name "Full Name" "" true "$curr_name" get_valid_input email "Email Address" "$EMAIL_REGEX" true "$curr_email" get_valid_input phone "Phone Number" "$PHONE_REGEX" false "$curr_phone" if [[ "$username" == "admin" ]]; then gum style --foreground "#ffb86c" -- "Admin health alerts and ACL settings cannot be changed." health_alert="true" acl_type="group" acl_value="admin" sleep 2 else gum style -- "Inform about server health? (Currently: $curr_health)" if gum confirm; then health_alert="true"; else health_alert="false"; fi gum style -- "How should ACL be managed? (Currently: $curr_type)" acl_type=$(gum choose "Assign to Group" "Manual Service Selection") if [[ "$acl_type" == "Assign to Group" ]]; then acl_type="group" local group_keys=("${!ACL_GROUPS[@]}") acl_value=$(gum choose "${group_keys[@]}") else acl_type="manual" gum style --foreground "#50fa7b" -- "Select services for $username:" acl_value=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -) fi fi ACL_USERS["$username"]="$name|$email|$phone|$health_alert|$acl_type|$acl_value" gum style --foreground "#50fa7b" -- "✔ User '$username' updated." sleep 1 } remove_user() { if [[ ${#ACL_USERS[@]} -eq 0 ]]; then return; fi local user_keys=("${!ACL_USERS[@]}") gum style -- "Select a user to REMOVE:" local username=$(gum choose "${user_keys[@]}") if [[ -z "$username" ]]; then return; fi if [[ "$username" == "admin" ]]; then gum style --foreground "#ff0000" -- "✖ The admin user cannot be removed." sleep 2; return fi gum style --foreground "#ff5555" --bold -- "Are you sure you want to delete '$username'?" if gum confirm; then unset ACL_USERS["$username"] gum style --foreground "#50fa7b" -- "✔ User deleted." sleep 1 fi } manage_users_menu() { while true; do clear gum style --border double --margin "1" --padding "0 1" --border-foreground "#ff79c6" -- "User Management (${#ACL_USERS[@]}/20)" show_users_table local action=$(gum choose "Add User" "Edit User" "Remove User" "Back") case "$action" in "Add User") add_user ;; "Edit User") edit_user ;; "Remove User") remove_user ;; "Back"|"") break ;; esac done } setup_admin_user() { if [[ -n "${ACL_USERS["admin"]}" ]]; then return; fi gum style --border rounded --padding "1 2" --margin "1" --border-foreground "#ff79c6" -- "Initial Setup: Administrator User" local name email phone get_valid_input name "Admin Full Name" "" true "" get_valid_input email "Admin Email Address" "$EMAIL_REGEX" true "" get_valid_input phone "Admin Phone Number (optional)" "$PHONE_REGEX" false "" ACL_USERS["admin"]="$name|$email|$phone|true|group|admin" gum style --foreground "#50fa7b" -- "✔ Administrator configured." sleep 1 } export_data() { clear gum style --foreground "#50fa7b" --bold -- "--- Provisioning Data Payload ---" echo "GROUPS:" for group in "${!ACL_GROUPS[@]}"; do echo " $group -> Allowed: ${ACL_GROUPS[$group]}" done echo "" echo "USERS (Name|Email|Phone|Alert|AclType|AclValue):" for user in "${!ACL_USERS[@]}"; do echo " $user -> ${ACL_USERS[$user]}" done } compute_acl_services ACL_GROUPS["admin"]=$(printf "%s," "${ACL_SERVICES[@]}" | sed 's/,$//') setup_admin_user while true; do clear gum style --border double --margin "1" --padding "1 2" --border-foreground "#bd93f9" -- "Numbus Deployment - Access Management" gum style -- "Current state: ${#ACL_GROUPS[@]}/10 Groups | ${#ACL_USERS[@]}/20 Users" echo "" local choice=$(gum choose "1. Manage Groups" "2. Manage Users" "3. Finish & Apply Configuration") case "$choice" in "1. Manage Groups") manage_groups_menu ;; "2. Manage Users") manage_users_menu ;; "3. Finish & Apply Configuration") export_data break ;; esac done } 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 = \"${INTERNATIONALIZATION_TIMEZONE}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.locale = \"${LOCALE}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.language = \"${INTERNATIONALIZATION_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[0]}\";" >> ${CONFIGURATION_PATH} echo -e " numbus.services.${SELECTED_DNS_SERVICE[0]}.enable = true;" >> ${CONFIGURATION_PATH} echo -e "\n # Services settings" >> ${CONFIGURATION_PATH} echo -e " numbus.services.domain = \"${DOMAIN_NAME}\";" >> ${CONFIGURATION_PATH} i=0 for service in "${SELECTED_WEB_APPLICATIONS[@]}"; do if [[ -v SELECTED_WEB_APPLICATIONS_SUBDOMAIN && -n "${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}" ]]; then echo -e " numbus.services.${service}.subdomain = \"${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}\";" >> ${CONFIGURATION_PATH} fi echo -e " numbus.services.${service}.enable = true;" >> ${CONFIGURATION_PATH} i=$((i + 1)) done if [[ -v SELECTED_DNS_SERVICE_SUBDOMAIN && -n "${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}" ]]; then echo -e " numbus.services.${SELECTED_DNS_SERVICE[0]}.subdomain = \"${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}\";" >> ${CONFIGURATION_PATH} fi if [[ "${TARGET_GRAPHICS_RENDERER}" == "true" ]]; then FRIGATE_DEVICES+=" \"/dev/dri/D128\"" fi if [[ "${TARGET_USB_CORAL}" == "true" ]]; then FRIGATE_DEVICES+=" \"/dev/bus/usb\"" elif [[ "${TARGET_PCIE_CORAL}" == "true" ]]; then FRIGATE_DEVICES+=" \"/dev/apex_0\"" fi if [[ -n "${TARGET_ZIGBEE_DEVICE}" ]]; then HOME_ASSISTANT_DEVICES+=" \"${TARGET_ZIGBEE_DEVICE}\"" fi if [[ -n "${FRIGATE_DEVICES:-}" ]]; then echo -e " numbus.services.frigate.devices = [${FRIGATE_DEVICES} ];" >> ${CONFIGURATION_PATH} fi if [[ -n "${HOME_ASSISTANT_DEVICES:-}" ]]; then echo -e " numbus.services.home-assistant.devices = [${HOME_ASSISTANT_DEVICES} ];" >> ${CONFIGURATION_PATH} fi } 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} if [[ "${TARGET_PCIE_CORAL}" == "true" ]]; then echo " numbus.hardware.pcie-coral.enable = true;" >> ${CONFIGURATION_PATH} fi 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" > "${EXTRA_FILES_PATH}/etc/secrets/disks/boot-${i}" chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/boot-${i}" ssh_to_host 'bash -s' << EOF echo "$LIVE_TARGET_PASSWORD" | sudo -S mkdir -p /etc/secrets/disks/ echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/boot-${i}" echo "$LIVE_TARGET_PASSWORD" | 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" > "${EXTRA_FILES_PATH}/etc/secrets/disks/content-${i}" chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/content-${i}" ssh_to_host 'bash -s' << EOF echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/content-${i}" echo "$LIVE_TARGET_PASSWORD" | 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" > "${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${i}" chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${i}" ssh_to_host 'bash -s' << EOF echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/parity-${i}" echo "$LIVE_TARGET_PASSWORD" | 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 SSH_KEYS_FORMATTED echo -e "\n ✅ Generating sops-nix keys..." ssh-to-age -private-key -i ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519 > ${EXTRA_FILES_PATH}/var/lib/sops-nix/key.txt export SOPS_PUBLIC_KEY=$(age-keygen -y ${EXTRA_FILES_PATH}/var/lib/sops-nix/key.txt) echo -e "\n ✅ Generating sops-nix configuration files..." envsubst < templates/nix-config/sops-nix/.sops.yaml > ${EXTRA_FILES_PATH}/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 ${EXTRA_FILES_PATH}/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]}\`" ) **Data Disks ($CONTENT_DISK_NUMBER) :** $( [[ $CONTENT_DISK_NUMBER -gt 0 ]] && 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; } SERVICES_RECAP_CONTENT=$(cat << EOF ### Services Configuration Summary Please review the selected services before proceeding. **DNS Service (${#SELECTED_DNS_SERVICE[@]}) :** $(echo "* \`${SELECTED_DNS_SERVICE[0]^}\`") **Web Applications (${#SELECTED_WEB_APPLICATIONS[@]}) :** $(for app in "${SELECTED_WEB_APPLICATIONS[@]}"; do echo "* \`${app^}\`"; done) **System Services (${#SELECTED_SYSTEM_SERVICES[@]}) :** $(for service in "${SELECTED_SYSTEM_SERVICES[@]}"; do echo "* \`${service^}\`"; done) EOF ) gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "$(gum format <<< "${SERVICES_RECAP_CONTENT}")" gum confirm "➡️ Proceed with this services configuration?" || { echo -e "\n\n❌ Aborting as requested."; exit 1; } DISK_RECAP_CONTENT=$(cat << EOF ### Secrets Summary Please save the following secrets to a secure place (i.e. your local password manager, or a hidden sheet of paper). **Boot Disks (${#BOOT_DISKS_ID_LIST[@]}) :** * **Disk 1 Secret Key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/boot-1 )\` $( [[ -n "${BOOT_DISKS_ID_LIST[1]:-}" ]] && echo "* **Disk 2 secret key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/boot-2 )\`" ) **Data Disks ($CONTENT_DISK_NUMBER):** $( [[ $CONTENT_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" ) $( [[ $CONTENT_DISK_NUMBER -gt 0 ]] && j=1 && for i in $(seq 0 $(($CONTENT_DISK_NUMBER - 1))); do echo "* **Disk ${j} Secret Key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/content-${j} )\`" && j=$((j + 1)); done ) **Parity Disks ($PARITY_DISK_NUMBER):** $( [[ $PARITY_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" ) $( [[ $PARITY_DISK_NUMBER -gt 0 ]] && j=1 && for i in $(seq $CONTENT_DISK_NUMBER $((${#DATA_DISKS_ID[@]} - 1))); do echo "* **Disk ${j} Secret Key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${j} )\`" && j=$((j + 1)); done ) EOF ) gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "$(gum format <<< "${DISK_RECAP_CONTENT}")" gum confirm "✅ I have stored these credentials in a safe place" || { echo -e "\n\n❌ Please store these credentials in a safe place as you will need them later."; exit 1; } gum confirm "➡️ Would you like to manually edit the configuration (⚠️ advanced users only)" || { echo -e "\n\n✅ continuing with the installation..."; return 0; } nano ${EXTRA_FILES_PATH}/etc/nixos/configuration.nix } cloudflare_dns_setup() { gum confirm "➡️ This script can automatically create DNS records for your services. Proceed? (recommended)" || { echo -e "\n\n ⚠️ skipping the DNS records creation step..."; return 0; } 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..." i=0 for service in "${SELECTED_WEB_APPLICATIONS[@]}"; do if [[ -n "${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]:-}" ]]; then SELECTED_SERVICES_DNS+=( "${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}.${DOMAIN_NAME}" ) else SELECTED_SERVICES_DNS+=( "${service}.${DOMAIN_NAME}" ) fi i=$((i + 1)) [[ "${service}" == "nextcloud" ]] && SELECTED_SERVICES_DNS+=( "onlyoffice.${DOMAIN_NAME}" "whiteboard.${DOMAIN_NAME}" ) done if [[ -n "${SELECTED_DNS_SERVICE_SUBDOMAIN[0]:-}" ]]; then SELECTED_SERVICES_DNS+=( "${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}.${DOMAIN_NAME}" ) else SELECTED_SERVICES_DNS+=( "${SELECTED_DNS_SERVICE}.${DOMAIN_NAME}" ) fi # 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 -${FILES_CP_FLAGS} deploy.conf ${EXTRA_FILES_PATH}/var/lib/numbus-server/numbus-server.conf local CONFIG_EXPORT_DIR="${EXTRA_FILES_PATH}/var/lib/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 . add -f "${EXTRA_FILES_PATH}/" git -C . add -f "templates/" git -C . add -f "deploy.conf" echo -e "\n\n🔄 Deploying to the remote server..." nix flake update --flake ./${EXTRA_FILES_PATH}/etc/nixos nix run github:nix-community/nixos-anywhere -- \ --flake ${EXTRA_FILES_PATH}/etc/nixos#numbus-server \ --extra-files ${EXTRA_FILES_PATH} \ --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_PASSWORD="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_PASSWORD}" ssh -i "${EXTRA_FILES_PATH}/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_PASSWORD} | 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_PASSWORD} | sudo -S systemd-analyze pcrs 15 --json=short) echo ${LIVE_TARGET_PASSWORD} | 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_PASSWORD | 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 ${EXTRA_FILES_PATH}/etc/nixos#numbus-server } # --- MAIN FUNCTIONS ---< # --- DEFAULT VARIABLES ---> WEBSERVER_PORT=${WEBSERVER_PORT:-8088} LIVE_DATA_PATH="/run/numbus/web/live_settings.json" HARDWARE_DATA_PATH="/run/numbus/web/hardware.json" BRIDGE_SCRIPT="web/logic/bridge.py" CONFIG_FILE="config/numbus.yaml" TARGET_USER="nixos" TMP_FILES_PATH="/run/user/$(id -u)/numbus-$(date +"%Y-%m-%d-%Hh%M")" EXTRA_FILES_PATH="${TMP_FILES_PATH}/config" if [[ ${DEBUG-0} -eq 1 ]]; then FILES_CP_FLAGS="vau" FILES_RM_FLAGS="vf" DIR_RM_FLAGS="rvf" MKDIR_FLAGS="pv" MV_FLAGS="vu" else DEBUG=0 FILES_CP_FLAGS="au" FILES_RM_FLAGS="f" DIR_RM_FLAGS="rf" MKDIR_FLAGS="p" MV_FLAGS="u" fi IP_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}$' SUBNET_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$' DOMAIN_REGEX='^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$' EMAIL_REGEX='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' PORT_REGEX='^[0-9]{1,5}$' SSH_KEY_REGEX='^ssh-[a-z0-9]+ [A-Za-z0-9+/]+.*' PHONE_REGEX='^\+[1-9][0-9]{7,14}$' GUM_INPUT_PADDING="1 1" GUM_INPUT_HEADER_FOREGROUND="212" GUM_INPUT_CURSOR_FOREGROUND="212" GUM_INPUT_TIMEOUT="3600" # --- DEFAULTS VARIABLES ---< # --- PRE MAIN LOGIC ---> set -euo pipefail clear trap cleanup EXIT compatibility_check # --- PRE MAIN LOGIC ---< # --- MAIN LOGIC ---> echo """ _ ____ ____ ______ __ ______ / |/ / / / / |/ / _ )/ / / / __/ / / /_/ / /|_/ / _ / /_/ /\ \ /_/|_/\____/_/ /_/____/\____/___/ """ DEPLOY_MODE=$(gum choose --header "Choose your preferred configuration interface :" "Through my browser (Recommended for beginners)" "Through my terminal (TUI)") if [[ "$DEPLOY_MODE" == "Through my terminal (TUI)" ]]; then WEB_MODE=0 preparation configuration else WEB_MODE=1 launch_configurator hierarchy_preparation echod "\n ⏳ Waiting for device credentials from web UI..." while [ ! -f configurator/.discovery_ready ]; do sleep 5 done echod "\n ✅ Credentials received." INTERNATIONALIZATION_LANGUAGE=$(jq -r '.language' ${LIVE_DATA_PATH}) COUNTRY=$(jq -r '.country' ${LIVE_DATA_PATH}) INTERNATIONALIZATION_TIMEZONE=$(jq -r '.timeZone' ${LIVE_DATA_PATH}) DEVICE_TYPE=$(jq -r '.device' ${LIVE_DATA_PATH}) DEPLOYMENT_MODE=$(jq -r '.deploymentMode' ${LIVE_DATA_PATH}) if [[ "${DEPLOYMENT_MODE}" == "non-interactive" ]]; then REPLICATION_HARDWARE=$(jq -r '.replicationHardware' ${LIVE_DATA_PATH}) REPLICATION_STRATEGY=$(jq -r '.replicationStrategy' ${LIVE_DATA_PATH}) REPLICATION_SECRETS=$(jq -r '.replicationSecrets' ${LIVE_DATA_PATH}) fi LIVE_IP=$(jq -r '.liveIp' ${LIVE_DATA_PATH}) LIVE_PASSWORD=$(jq -r '.livePassword' ${LIVE_DATA_PATH}) fi # --- MAIN LOGIC ---< # 3. Load Credentials and run Discovery setup_ssh hardware_detection if [[ ${DEBUG} -eq 1 ]]; then echo -e "\n ✅ Discovery complete. Hardware data sent to Configurator." fi # 4. Wait for Final Configuration Submission if [[ ${DEBUG} -eq 1 ]]; then echo -e "\n ⏳ Waiting for final configuration deployment signal..." fi while [ ! -f configurator/.deploy_signal ]; do sleep 1 done # 5. Execute Deployment echo -e "\n🚀 Starting deployment sequence..." deploy > deploy-out.log 2> deploy-err.log