Files
Numbus/script/deploy.sh
T
Raphaël Numbus d4af5bbdb1 Bugfixes.
2026-05-25 22:06:50 +02:00

839 lines
29 KiB
Bash
Executable File

#!/usr/bin/env nix-shell
#!nix-shell -i bash -p bash coreutils gnused gum 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 "${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" "${COMMAND}"
}
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() {
if [[ "${DEBUG}" -eq 1 ]]; then
echo -e "\n ✅ Exiting..."
echo -e "\n ✅ Debug mode is enabled. Clean up manually files located at ${INSTALL_DIR} or reboot."
else
echo -e "\n ✅ Cleaning up..."
rm -${DIR_RM_FLAGS} "${INSTALL_DIR}"
fi
if [[ -n "${BRIDGE_PID:-}" ]] && ps -p ${BRIDGE_PID} >> "${STDOUT}" 2>> "${STDERR}"; then
kill ${BRIDGE_PID}
fi
echo -e "\n 🌟 Thanks for using Numbus, consider supporting the project !"
}
hierarchy_preparation() {
echod "\n 🔄 Preparing the folder hierarchy for the final configuration..."
# Extra files folders
mkdir -${MKDIR_FLAGS} ${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/
mkdir -${MKDIR_FLAGS} ${TMP_EXTRA_PATH}/var/lib/sops-nix/
mkdir -${MKDIR_FLAGS} ${TMP_EXTRA_PATH}/etc/nixos/secrets/disks
mkdir -${MKDIR_FLAGS} ${TMP_EXTRA_PATH}/etc/nixos/secrets/system
mkdir -${MKDIR_FLAGS} ${TMP_EXTRA_PATH}/etc/nixos/secrets/podman
echod "\n ✅ Folder hierarchy ready"
}
hardware_detection() {
local TMPFILE="/run/user/1000/numbus-installer/hw_detection.json"
ssh_to_host "nix-shell -p jq pciutils usbutils smartmontools iproute2 --run 'bash -s'" << SSHEND >> "${STDOUT}" 2>> "${STDERR}"
set -euo pipefail
if [[ ${DEBUG} -eq 1 ]]; then
mkdir -${MKDIR_FLAGS} "${REMOTE_STDOUT%std.log}"
mkdir -${MKDIR_FLAGS} "${REMOTE_STDERR%err.log}"
fi
# --- Initialize Global JSON Output ---
HW_REPORT=\$(jq -n '{}')
# --- Helper: Add JSON array to the main report ---
append_to_report() {
local key="\$1"
local json_array="\$2"
HW_REPORT=\$(echo "\$HW_REPORT" | jq --argjson arr "\$json_array" --arg k "\$key" '.[\$k] = \$arr')
}
# --- 1. Detect Graphics ---
detect_graphics() {
local gpus="[]"
# Process each GPU found by lspci
while read -r line; do
[[ -z "\$line" ]] && continue
local brand="unknown"
local renderer="none"
local product="unknown"
local integrated="false"
local pci_addr="none"
# Extract PCI address
pci_addr="\$(echo "\$line" | cut -d' ' -f1)"
# Brand
for b in Intel AMD NVIDIA; do
if echo "\$line" | grep -iq "\$b"; then
brand="\${b}"
break
fi
done
# Renderer
if [[ -d "/dev/dri/by-path" ]]; then
local render_node
render_node=\$(ls /dev/dri/by-path | grep "\$pci_addr" | grep "render" | head -n1 || true)
if [[ -n "\$render_node" ]]; then
renderer=\$(basename "\$(readlink -f "/dev/dri/by-path/\$render_node")")
fi
fi
# Product name
product="\${line#*:}"
product="\${product#*: }"
# Form factor
if [[ "\${brand}" == "NVIDIA" ]]; then
integrated="false"
fi
if [[ "\${brand}" == "Intel" ]]; then
if echo "\${line}" | grep -Ei "HD Graphics|Xe"; then
integrated="true"
else
integrated="false"
fi
fi
if [[ "\${brand}" == "AMD" ]]; then
if echo "\$line" | grep -i "Mobile"; then
integrated="true"
else
integrated="false"
fi
fi
local obj=\$(jq -n \
--arg b "\${brand}" \
--arg r "\${renderer}" \
--arg p "\${product}" \
--argjson i "\${integrated}" \
'{brand: \$b, renderer: \$r, product: \$p, integrated: \$i}')
gpus=\$(echo "\$gpus" | jq --argjson obj "\$obj" '. += [\$obj]')
done < <(lspci | grep -Ei "VGA|3D")
append_to_report "graphics" "\$gpus"
}
# --- 2. Detect Coral TPUs ---
detect_corals() {
local corals="[]"
# Check PCIe Coral (Google ID 1ac1:089a)
if lspci -nn | grep -iq "1ac1:089a" >> "${REMOTE_STDOUT}" 2>> "${REMOTE_STDERR}"; then
local pcie_count
pcie_count=\$(lspci -nn | grep -ic "1ac1:089a")
for ((i=1; i<=pcie_count; i++)); do
local obj=\$(jq -n --arg i "PCIe" '{interface: \$i, type: "Edge TPU"}')
corals=\$(echo "\$corals" | jq --argjson obj "\$obj" '. += [\$obj]')
done
fi
# Check USB Coral (Google ID 18d1:9302)
if lsusb | grep -iq "18d1:9302" >> "${REMOTE_STDOUT}" 2>> "${REMOTE_STDERR}"; then
local usb_count
usb_count=\$(lsusb | grep -ic "18d1:9302")
for ((i=1; i<=usb_count; i++)); do
local obj=\$(jq -n --arg i "USB" '{interface: \$i, type: "Edge TPU"}')
corals=\$(echo "\$corals" | jq --argjson obj "\$obj" '. += [\$obj]')
done
fi
append_to_report "coral_devices" "\$corals"
}
# --- 3. Detect Zigbee Coordinators ---
detect_zigbee() {
local zigbees="[]"
local serial_dir="/dev/serial/by-id"
if [[ -d "\$serial_dir" ]]; then
for dev in "\$serial_dir"/*; do
[[ -e "\$dev" ]] || continue # skip if empty directory pattern matched
# Match common Zigbee adapter names (Sonoff, ConBee, ITead, CC2531, etc.)
if echo "\$dev" | grep -iE 'zigbee|conbee|sonoff|cc2531|efr32|itead'; then
local obj=\$(jq -n --arg p "\$dev" '{device_path: \$p}')
zigbees=\$(echo "\$zigbees" | jq --argjson obj "\$obj" '. += [\$obj]')
fi
done
fi
append_to_report "zigbee_devices" "\$zigbees"
}
# --- 4. Detect Network Interfaces ---
detect_network() {
local networks="[]"
local default_iface
default_iface=\$(ip -4 route show default >> "${REMOTE_STDOUT}" 2>> "${REMOTE_STDERR}" | awk '{print \$5}' | head -n1)
for iface_path in /sys/class/net/*; do
[[ -e "\$iface_path" ]] || continue
local iface
iface=\$(basename "\$iface_path")
# Skip loopback and virtual interfaces
[[ "\$iface" == "lo" ]] && continue
[[ -L "\$iface_path" && \$(readlink "\$iface_path") == *"virtual"* ]] && continue
local type="wired"
[[ -d "\$iface_path/wireless" ]] && type="wireless"
local is_default="false"
[[ "\$iface" == "\$default_iface" ]] && is_default="true"
local obj=\$(jq -n --arg n "\$iface" --arg t "\$type" --argjson d "\$is_default" \
'{name: \$n, type: \$t, default: \$d}')
networks=\$(echo "\$networks" | jq --argjson obj "\$obj" '. += [\$obj]')
done
append_to_report "network_interfaces" "\$networks"
}
# --- 5. Detect TPM ---
detect_tpm() {
local tpms="[]"
for tpm_dir in /sys/class/tpm/tpm*; do
[[ -e "\$tpm_dir" ]] || continue
local name
name=\$(basename "\$tpm_dir")
local version="Unknown"
if [[ -f "\$tpm_dir/tpm_version_major" ]]; then
version=\$(cat "\$tpm_dir/tpm_version_major")
fi
local obj=\$(jq -n --arg n "\$name" --arg v "\$version" '{name: \$n, version: \$v}')
tpms=\$(echo "\$tpms" | jq --argjson obj "\$obj" '. += [\$obj]')
done
append_to_report "tpm" "\$tpms"
}
# --- 6. Detect Disks ---
detect_disks() {
local disks="[]"
while read -r disk_name; do
# Disk Type Mapping
local disk_type="unknown"
local disk_transport=\$(lsblk -d -n -o TRAN "/dev/\$disk_name" || echo "unknown")
local rotational=\$(lsblk -d -n -o ROTA "/dev/\$disk_name" || echo "1")
if [[ "\$disk_name" == nvme* ]]; then
disk_type="NVMe"
elif [[ "\$rotational" == "1" ]]; then
disk_type="HDD"
elif [[ "\$rotational" == "0" ]]; then
disk_type="SSD"
fi
# Size in GB
local disk_size=\$(lsblk -d -n -o SIZE "/dev/\$disk_name" || echo "unknown")
# ID via by-id
local disk_id="unknown"
if [[ -d "/dev/disk/by-id" ]]; then
local id_match=\$(find /dev/disk/by-id/ -type l -not -name "wwn-*" -not -name "nvme-eui*" -printf "%p %l\n" | grep -m1 "/\$disk_name\$" | awk '{print \$1}')
[[ -n "\$id_match" ]] && disk_id="\$id_match"
fi
# Health
local health="unknown"
if echo "${LIVE_TARGET_PASSWORD}" | sudo -S smartctl -H /dev/\$disk_name 2>/dev/null | grep -i "PASSED"; then
disk_health="Passed"
elif echo "${LIVE_TARGET_PASSWORD}" | sudo -S smartctl -H /dev/\$disk_name 2>/dev/null | grep -i "FAILED"; then
disk_health="Failed"
fi
local obj=\$(jq -n \
--arg n "\${disk_name}" \
--arg t "\${disk_type}" \
--arg tr "\${disk_transport}" \
--arg h "\${disk_health}" \
--arg s "\${disk_size}" \
--arg i "\${disk_id}" \
'{name: \$n, type: \$t, transport: \$tr, health: \$h, size: \$s, id: \$i}')
disks=\$(echo "\$disks" | jq --argjson obj "\$obj" '. += [\$obj]')
done < <(lsblk -d -n -o NAME -e 7,11,252)
append_to_report "disks" "\$disks"
}
# --- Execution ---
detect_graphics
detect_corals
detect_zigbee
detect_network
detect_tpm
detect_disks
# --- Output ---
echo "\$HW_REPORT" | jq '.' > "$TMPFILE"
SSHEND
scp -i "${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}":"${TMPFILE}" "${HW_DATA_FILE}" >> "${STDOUT}" 2>> "${STDERR}"
[[ ${DEBUG} -eq 1 ]] && scp -i "${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}":"${REMOTE_STDOUT}" "${INSTALL_DIR}/web/logs/hw_std.log" >> "${STDOUT}" 2>> "${STDERR}"
[[ ${DEBUG} -eq 1 ]] && scp -i "${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}":"${REMOTE_STDERR}" "${INSTALL_DIR}/web/logs/hw_err.log" >> "${STDOUT}" 2>> "${STDERR}"
if ssh_to_host "echo \"${LIVE_TARGET_PASSWORD}\" | sudo -S nixos-generate-config --no-filesystems --show-hardware-config &> /dev/null" > ${TMP_EXTRA_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_gui() {
echo -e "\n 🚀 Launching Numbus Configurator..."
echo -e " ➡️ You will now proceed to the configuration of your device through your browser"
python3 "${BRIDGE_SCRIPT}" >> "${STDOUT}" 2>> "${STDERR}" &
export BRIDGE_PID=$!
local START_URL="http://localhost:${WEBSERVER_PORT}/pages/index.html"
xdg-open "${START_URL}" >> "${STDOUT}" 2>> "${STDERR}" || open "${START_URL}" >> "${STDOUT}" 2>> "${STDERR}" || true
sleep 5
echo -e "\n ⚠️ If it doesn't automatically, open your browser at: $(gum style --foreground 212 "${START_URL}")"
}
# --- MAIN WEB FUNCTIONS ---<
# --- MAIN SCRIPT FUNCTIONS --->
setup_ssh() {
echod "\n ✅ Generating new SSH key for numbus-admin..."
chmod 700 "${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/"
ssh-keygen -t "ed25519" -C "numbus-admin@numbus-${DEVICE_TYPE}" -f "${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/id_ed25519" -N "" -q
echod "\n ➡️ Copying SSH key to target host '${TARGET_USER}@${LIVE_TARGET_IP}'..."
if sshpass -p "${LIVE_TARGET_PASSWORD}" ssh-copy-id -o StrictHostKeyChecking=no -i "${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" >> "${STDOUT}" 2>> "${STDERR}"; then
echod "\n ✅ SSH key copied successfully"
else
echo -e "\n ❌ Failed to copy SSH key. Please check the host IP and password."
exit 1
fi
}
server_config_generation() {
echod "\n 📝 Generating structured settings.json..."
# Create a temporary JSON file with all the collected variables
# This file will be read by the Nix configuration using builtins.fromJSON
jq -n \
--arg tz "$INTERNATIONALIZATION_TIMEZONE" \
--arg lang "$INTERNATIONALIZATION_LANGUAGE" \
--arg owner "$SERVER_OWNER_NAME" \
--arg ip "$HOME_SERVER_IP" \
--arg iface "$TARGET_INTERFACE" \
--arg router "$NETWORK_ROUTER_IP" \
--arg domain "$DOMAIN_NAME" \
--argjson cockpit_enabled "true" \
--arg dns "${SELECTED_DNS_SERVICE[0]}" \
--argjson apps "$(printf '%s\n' "${SELECTED_WEB_APPLICATIONS[@]}" | jq -R . | jq -s .)" \
'{
system: {
timeZone: $tz,
language: $lang,
owner: $owner
},
network: {
ipAddress: $ip,
interface: $iface,
routerIp: $router
},
services: {
domain: $domain,
dnsProvider: $dns,
enabledApps: $apps,
managementConsole: $cockpit_enabled
}
}' > "${TMP_EXTRA_PATH}/etc/nixos/settings.json"
echo -e "{\n numbus.settings = builtins.fromJSON (builtins.readFile ./settings.json);\n}" > "${CONFIGURATION_PATH}"
# Ensure the settings file is writable by the management service
# and that the directory is prepared for local git tracking
chmod 664 "${TMP_EXTRA_PATH}/etc/nixos/settings.json"
}
# The existing network_config_generation and services_config_generation functions
# are now redundant as the logic is centralized in the JSON export.
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" > "${TMP_EXTRA_PATH}/etc/secrets/disks/boot-${i}"
chmod 600 "${TMP_EXTRA_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" > "${TMP_EXTRA_PATH}/etc/secrets/disks/content-${i}"
chmod 600 "${TMP_EXTRA_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" > "${TMP_EXTRA_PATH}/etc/secrets/disks/parity-${i}"
chmod 600 "${TMP_EXTRA_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)" =~ "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 ${TMP_EXTRA_PATH}/home/numbus-admin/.ssh/id_ed25519 > ${TMP_EXTRA_PATH}/var/lib/sops-nix/key.txt
export SOPS_PUBLIC_KEY=$(age-keygen -y ${TMP_EXTRA_PATH}/var/lib/sops-nix/key.txt)
echo -e "\n ✅ Generating sops-nix configuration files..."
envsubst < templates/nix-config/sops-nix/.sops.yaml > ${TMP_EXTRA_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 ${TMP_EXTRA_PATH}/etc/nixos/secrets/secrets.yaml
}
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" >> "${STDOUT}" 2>> "${STDERR}"
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
}
deploy() {
git -C . add -f "${TMP_EXTRA_PATH}/"
git -C . add -f "templates/"
git -C . add -f "deploy.conf"
# Initialize a git repo in the configuration to be deployed
# This allows the Management UI on the appliance to commit changes
# and provide a local history/rollback UI to the user.
if [ ! -d "${TMP_EXTRA_PATH}/etc/nixos/.git" ]; then
git -C "${TMP_EXTRA_PATH}/etc/nixos" init -q
git -C "${TMP_EXTRA_PATH}/etc/nixos" add .
git -C "${TMP_EXTRA_PATH}/etc/nixos" commit -m "Initial bootstrap via Numbus Deploy" -q
fi
echo -e "\n\n🔄 Deploying to the remote server..."
nix flake update --flake ./${TMP_EXTRA_PATH}/etc/nixos
nix run github:nix-community/nixos-anywhere -- \
--flake ${TMP_EXTRA_PATH}/etc/nixos#numbus-server \
--extra-files ${TMP_EXTRA_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 >> "${STDOUT}" 2>> "${STDERR}"; 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 "${TMP_EXTRA_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
}
# --- MAIN SCRIPT FUNCTIONS ---<
# --- DEFAULT VARIABLES --->
INSTALL_DIR="/run/user/$(id -u)/numbus-installer"
WEBSERVER_PORT=${WEBSERVER_PORT:-8088}
LIVE_DATA_FILE="web/config/live.yaml"
HW_DATA_FILE="web/config/hardware.yaml"
CONFIG_FILE="web/config/numbus.yaml"
BRIDGE_SCRIPT="web/logic/interactive.py"
# default is nixos
TARGET_USER="numbus-admin"
TMP_EXTRA_PATH="extra"
if [[ ${DEBUG-0} -eq 1 ]]; then
FILES_CP_FLAGS="vau"
FILES_RM_FLAGS="vf"
DIR_RM_FLAGS="rvf"
MKDIR_FLAGS="pv"
MV_FLAGS="vu"
STDOUT="${INSTALL_DIR}/web/logs/std.log"
STDERR="${INSTALL_DIR}/web/logs/err.log"
REMOTE_STDOUT="/run/user/1000/numbus-installer/std.log"
REMOTE_STDERR="/run/user/1000/numbus-installer/err.log"
else
DEBUG=0
FILES_CP_FLAGS="au"
FILES_RM_FLAGS="f"
DIR_RM_FLAGS="rf"
MKDIR_FLAGS="p"
MV_FLAGS="u"
STDOUT="/dev/null"
STDERR="/dev/null"
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
# --- PRE MAIN LOGIC ---<
# --- MAIN LOGIC --->
echo """
_ ____ ____ ______ __ ______
/ |/ / / / / |/ / _ )/ / / / __/
/ / /_/ / /|_/ / _ / /_/ /\ \
/_/|_/\____/_/ /_/____/\____/___/
"""
launch_gui
until [[ -e "${LIVE_DATA_FILE}" ]]; do
sleep 5
done
INTERNATIONALIZATION_LANGUAGE="$(yq -r '.internationalization.language' ${LIVE_DATA_FILE})"
INTERNATIONALIZATION_COUNTRY="$(yq -r '.internationalization.country' ${LIVE_DATA_FILE})"
INTERNATIONALIZATION_TIMEZONE="$(yq -r '.internationalization.timeZone' ${LIVE_DATA_FILE})"
DEVICE_TYPE="$(yq -r '.device.type' ${LIVE_DATA_FILE})"
DEPLOYMENT_MODE="$(yq -r '.deployment.mode' ${LIVE_DATA_FILE})"
DEPLOYMENT_GIT_URL="$(yq -r '.deployment.git_url' ${LIVE_DATA_FILE})"
DEPLOYMENT_GIT_USERNAME="$(yq -r '.deployment.git_username' ${LIVE_DATA_FILE})"
DEPLOYMENT_GIT_PASSWORD="$(yq -r '.deployment.git_password' ${LIVE_DATA_FILE})"
LIVE_TARGET_IP="$(yq -r '.live_target.ip' ${LIVE_DATA_FILE})"
LIVE_TARGET_PASSWORD="$(yq -r '.live_target.password' ${LIVE_DATA_FILE})"
hierarchy_preparation
setup_ssh
hardware_detection
until [[ -e web/signals/configuration_ready ]]; do
sleep 5
done
until [[ -e web/signals/deployment_ready ]]; do
sleep 5
done
# --- MAIN LOGIC ---<