#!/usr/bin/env bash set -euo pipefail here=$(dirname "${BASH_SOURCE[0]}") flake="" flakeAttr="" kexecUrl="" kexecExtraFlags="" sshStoreSettings="" enableDebug="" nixBuildFlags=() diskoAttr="" diskoScript="" diskoMode="disko" diskoDeps=y nixosSystem="" extraFiles="" vmTest="n" nixOptions=( --extra-experimental-features 'nix-command flakes' "--no-write-lock-file" ) SSH_PRIVATE_KEY=${SSH_PRIVATE_KEY-} declare -A phases phases[kexec]=1 phases[disko]=1 phases[install]=1 phases[reboot]=1 hardwareConfigBackend=none hardwareConfigPath= sshPrivateKeyFile= if [ -t 0 ]; then # stdin is a tty, we allow interactive input to ssh i.e. passwords sshTtyParam="-t" else sshTtyParam="-T" fi sshConnection= postKexecSshPort=22 buildOnRemote=n buildOn=auto envPassword=n # Facts set by get-facts.sh isOs= isArch= isKexec= isInstaller= isContainer= hasIpv6Only= hasTar= hasCpio= hasSudo= hasDoas= hasWget= hasCurl= hasSetsid= hasNixOSFacter= sshKeyDir=$(mktemp -d) trap 'rm -rf "$sshKeyDir"' EXIT mkdir -p "$sshKeyDir" declare -A diskEncryptionKeys=() declare -A extraFilesOwnership=() declare -a nixCopyOptions=() declare -a sshArgs=() showUsage() { cat <] Options: * -f, --flake set the flake to install the system from. i.e. nixos-anywhere --flake .#mymachine Also supports variants: nixos-anywhere --flake .#nixosConfigurations.mymachine.config.virtualisation.vmVariant * --target-host set the SSH target host to deploy onto. * -i selects which SSH private key file to use. * -p, --ssh-port set the ssh port to connect with * --ssh-option set an ssh option * -L, --print-build-logs print full build logs * --env-password set a password used by ssh-copy-id, the password should be set by the environment variable SSHPASS * -s, --store-paths set the store paths to the disko-script and nixos-system directly if this is given, flake is not needed * --kexec use another kexec tarball to bootstrap NixOS * --kexec-extra-flags extra flags to add into the call to kexec, e.g. "--no-sync" * --ssh-store-setting ssh store settings appended to the store URI, e.g. "compress true". needs to be URI encoded. * --post-kexec-ssh-port after kexec is executed, use a custom ssh port to connect. Defaults to 22 * --copy-host-keys copy over existing /etc/ssh/ssh_host_* host keys to the installation * --extra-files contents of local are recursively copied to the root (/) of the new NixOS installation. Existing files are overwritten Copied files will be owned by root unless specified by --chown option. See documentation for details. * --chown change ownership of recursively. Recommended to use uid:gid as opposed to username:groupname for ownership. Option can be specified more than once. * --disk-encryption-keys copy the contents of the file or pipe in local_path to remote_path in the installer environment, after kexec but before installation. Can be repeated. * --no-substitute-on-destination disable passing --substitute-on-destination to nix-copy * --debug enable debug output * --show-trace show nix build traces * --option nix option to pass to every nix related command * --from URL of the source Nix store to copy the nixos and disko closure from * --build-on-remote build the closure on the remote machine instead of locally and copy-closuring it * --vm-test build the system and test the disk configuration inside a VM without installing it to the target. * --generate-hardware-config nixos-facter|nixos-generate-config generate a hardware-configuration.nix file using the specified backend and write it to the specified path. The backend can be either 'nixos-facter' or 'nixos-generate-config'. * --phases comma separated list of phases to run. Default is: kexec,disko,install,reboot kexec: kexec into the nixos installer disko: first unmount and destroy all filesystems on the disks we want to format, then run the create and mount mode install: install the system reboot: unmount the filesystems, export any ZFS pools and reboot the machine * --disko-mode disko|mount|format set the disko mode to format, mount or destroy. Default is disko. disko: first unmount and destroy all filesystems on the disks we want to format, then run the create and mount mode * --no-disko-deps This will only upload the disko script and not the partitioning tools dependencies. Installers usually have dependencies available. Use this option if your target machine has not enough RAM to store the dependencies in memory. * --build-on auto|remote|local sets the build on settings to auto, remote or local. Default is auto. auto: tries to figure out, if the build is possible on the local host, if not falls back gracefully to remote build local: will build on the local host remote: will build on the remote host USAGE } abort() { echo "aborted: $*" >&2 exit 1 } step() { echo "### $* ###" } parseArgs() { local substituteOnDestination=y local printBuildLogs=n local buildOnRemote=n while [[ $# -gt 0 ]]; do case "$1" in -f | --flake) flake=$2 shift ;; --target-host) sshConnection=$2 shift ;; -i) sshPrivateKeyFile=$2 shift ;; -p | --ssh-port) sshArgs+=("-p" "$2") shift ;; --ssh-option) sshArgs+=("-o" "$2") shift ;; -L | --print-build-logs) printBuildLogs=y ;; -s | --store-paths) diskoScript=$(readlink -f "$2") nixosSystem=$(readlink -f "$3") shift shift ;; --generate-hardware-config) if [[ $# -lt 3 ]]; then abort "Missing arguments for --generate-hardware-config " fi case "$2" in nixos-facter | nixos-generate-config) hardwareConfigBackend=$2 ;; *) abort "Unknown hardware config backend: $2" ;; esac hardwareConfigPath=$3 shift shift ;; -t | --tty) echo "the '$1' flag is deprecated, a tty is now detected automatically" >&2 ;; --help) showUsage exit 0 ;; --kexec) kexecUrl=$2 shift ;; --kexec-extra-flags) kexecExtraFlags=$2 shift ;; --ssh-store-setting) key=$2 shift value=$2 shift sshStoreSettings+="$sshStoreSettings$key=$value&" shift ;; --post-kexec-ssh-port) postKexecSshPort=$2 shift ;; --copy-host-keys) copyHostKeys=y ;; --show-trace) nixBuildFlags+=("--show-trace") ;; --debug) enableDebug="-x" printBuildLogs=y set -x ;; --disko-mode) case "$2" in format | mount | disko) diskoMode=$2 ;; *) abort "Supported values for --disko-mode are disko, mount and format. Unknown mode : $2" ;; esac shift ;; --no-disko-deps) diskoDeps=n ;; --build-on) case "$2" in auto | local | remote) buildOn=$2 ;; *) abort "Supported values for --build-on are auto, local and remote. Unknown mode : $2" ;; esac shift ;; --extra-files) extraFiles=$2 shift ;; --chown) extraFilesOwnership["$2"]="$3" shift shift ;; --disk-encryption-keys) diskEncryptionKeys["$2"]="$3" shift shift ;; --phases) phases[kexec]=0 phases[disko]=0 phases[install]=0 phases[reboot]=0 IFS=, read -r -a phaseList <<<"$2" for phase in "${phaseList[@]}"; do if [[ ${phases[$phase]:-unset} == unset ]]; then abort "Unknown phase: $phase" fi phases[$phase]=1 done shift ;; --stop-after-disko) echo "WARNING: --stop-after-disko is deprecated, use --phases kexec,disko instead" 2>&1 phases[kexec]=1 phases[disko]=1 phases[install]=0 phases[reboot]=0 ;; --no-reboot) echo "WARNING: --no-reboot is deprecated, use --phases kexec,disko,install instead" 2>&1 phases[kexec]=1 phases[disko]=1 phases[install]=1 phases[reboot]=0 ;; --from) nixCopyOptions+=("--from" "$2") shift ;; --option) key=$2 shift value=$2 shift nixOptions+=("--option" "$key" "$value") ;; --no-substitute-on-destination) substituteOnDestination=n ;; --build-on-remote) echo "WARNING: --build-on-remote is deprecated, use --build-on remote instead" 2>&1 buildOnRemote=y buildOn="remote" ;; --env-password) envPassword=y ;; --vm-test) vmTest=y ;; *) if [[ -z ${sshConnection} ]]; then sshConnection="$1" else showUsage exit 1 fi ;; esac shift done diskoAttr="${diskoMode}Script" if [[ ${diskoDeps} == "n" ]]; then diskoAttr="${diskoAttr}NoDeps" fi if [[ ${printBuildLogs} == "y" ]]; then nixOptions+=("-L") fi if [[ $substituteOnDestination == "y" ]]; then nixCopyOptions+=("--substitute-on-destination") fi if [[ $vmTest == "n" ]] && [[ -z ${sshConnection} ]]; then abort "ssh-host must be set" fi if [[ $buildOn == "local" ]] && [[ $buildOnRemote == "y" ]]; then abort "Conflicting flags: --build-on local and --build-on-remote used." fi if [[ -n ${flake} ]]; then if [[ $flake =~ ^(.*)\#([^\#\"]*)$ ]]; then flake="${BASH_REMATCH[1]}" flakeAttr="${BASH_REMATCH[2]}" fi if [[ -z ${flakeAttr} ]]; then echo "Please specify the name of the NixOS configuration to be installed, as a URI fragment in the flake-uri." >&2 echo 'For example, to use the output nixosConfigurations.foo from the flake.nix, append "#foo" to the flake-uri.' >&2 exit 1 fi # Support .#foo shorthand if [[ $flakeAttr != nixosConfigurations.* ]]; then flakeAttr="nixosConfigurations.\"$flakeAttr\".config" fi fi } # ssh wrapper runSshNoTty() { ssh -i "$sshKeyDir"/nixos-anywhere -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${sshArgs[@]}" "$sshConnection" "$@" } runSshTimeout() { timeout 10 ssh -i "$sshKeyDir"/nixos-anywhere -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${sshArgs[@]}" "$sshConnection" "$@" } runSsh() { ssh "$sshTtyParam" -i "$sshKeyDir"/nixos-anywhere -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${sshArgs[@]}" "$sshConnection" "$@" } nixCopy() { NIX_SSHOPTS="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i $sshKeyDir/nixos-anywhere ${sshArgs[*]}" nix copy \ "${nixOptions[@]}" \ "${nixCopyOptions[@]}" \ "$@" } nixBuild() { NIX_SSHOPTS="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i $sshKeyDir/nixos-anywhere ${sshArgs[*]}" nix build \ --print-out-paths \ --no-link \ "${nixBuildFlags[@]}" \ "${nixOptions[@]}" \ "$@" } runVmTest() { if [[ -z ${flakeAttr} ]]; then echo "--vm-test is not supported with --store-paths" >&2 echo "Please use --flake instead or build config.system.build.installTest of your nixos configuration manually" >&2 exit 1 fi if [[ ${buildOn} == "remote" ]]; then echo "--vm-test is not supported with --build-on-remote" >&2 exit 1 fi if [[ -n ${extraFiles} ]]; then echo "--vm-test is not supported with --extra-files" >&2 exit 1 fi if [ ${#diskEncryptionKeys[@]} -gt 0 ]; then echo "--vm-test is not supported with --disk-encryption-keys" >&2 exit 1 fi nix build \ --print-out-paths \ --no-link \ -L \ "${nixBuildFlags[@]}" \ "${nixOptions[@]}" \ "${flake}#${flakeAttr}.system.build.installTest" } uploadSshKey() { # ssh-copy-id requires this directory mkdir -p "$HOME/.ssh/" if [[ -n ${sshPrivateKeyFile} ]]; then cp "$sshPrivateKeyFile" "$sshKeyDir/nixos-anywhere" ssh-keygen -y -f "$sshKeyDir/nixos-anywhere" >"$sshKeyDir/nixos-anywhere.pub" else # we generate a temporary ssh keypair that we can use during nixos-anywhere ssh-keygen -t ed25519 -f "$sshKeyDir"/nixos-anywhere -P "" -C "nixos-anywhere" >/dev/null fi declare -a sshCopyIdArgs if [[ -n ${sshPrivateKeyFile} ]]; then unset SSH_AUTH_SOCK # don't use system agent if key was supplied sshCopyIdArgs+=(-o "IdentityFile=${sshPrivateKeyFile}" -f) fi step Uploading install SSH keys until if [[ ${envPassword} == y ]]; then sshpass -e \ ssh-copy-id \ -i "$sshKeyDir"/nixos-anywhere.pub \ -o ConnectTimeout=10 \ -o UserKnownHostsFile=/dev/null \ -o IdentitiesOnly=yes \ -o StrictHostKeyChecking=no \ "${sshCopyIdArgs[@]}" \ "${sshArgs[@]}" \ "$sshConnection" else ssh-copy-id \ -i "$sshKeyDir"/nixos-anywhere.pub \ -o ConnectTimeout=10 \ -o UserKnownHostsFile=/dev/null \ -o StrictHostKeyChecking=no \ "${sshCopyIdArgs[@]}" \ "${sshArgs[@]}" \ "$sshConnection" fi do sleep 3 done } importFacts() { step Gathering machine facts local facts filteredFacts if ! facts=$(runSsh -o ConnectTimeout=10 enableDebug=$enableDebug sh -- <"$here"/get-facts.sh); then exit 1 fi filteredFacts=$(echo "$facts" | grep -E '^(has|is)[A-Za-z0-9_]+=\S+') if [[ -z $filteredFacts ]]; then abort "Retrieving host facts via ssh failed. Check with --debug for the root cause, unless you have done so already" fi # make facts available in script # shellcheck disable=SC2046 export $(echo "$filteredFacts" | xargs) for var in isOs isArch isKexec isInstaller isContainer hasIpv6Only hasTar hasCpio hasSudo hasDoas hasWget hasCurl hasSetsid; do if [[ -z ${!var} ]]; then abort "Failed to retrieve fact $var from host" fi done } checkBuildLocally() { local system extraPlatforms machineSystem system="$(nix --extra-experimental-features 'nix-command flakes' config show system)" extraPlatforms="$(nix --extra-experimental-features 'nix-command flakes' config show extra-platforms)" if [[ $# -gt 0 ]]; then machineSystem=$1 elif [[ -n ${nixosSystem} ]]; then machineSystem="$(cat "${nixosSystem}"/system)" else machineSystem="$(nix --extra-experimental-features 'nix-command flakes' eval --raw "${flake}"#"${flakeAttr}".pkgs.system 2>/dev/null || echo "unknown")" if [[ ${machineSystem} == "unknown" ]]; then buildOn=auto return fi fi if [[ ${system} == "${machineSystem}" ]]; then buildOn=local return fi if [[ ${extraPlatforms} == "*${machineSystem}*" ]]; then buildOn=local return fi local entropy entropy="$(date +'%Y%m%d%H%M%S')" if nix build \ -L \ "${nixOptions[@]}" \ --expr \ "derivation { system = \"$system\"; name = \"env-$entropy\"; builder = \"/bin/sh\"; args = [ \"-c\" \"echo > \$out\" ]; }"; then # The local build failed buildOn=local fi buildOn=remote } generateHardwareConfig() { local maybeSudo="$maybeSudo" mkdir -p "$(dirname "$hardwareConfigPath")" case "$hardwareConfigBackend" in nixos-facter) if [[ ${isInstaller} == "y" ]]; then if [[ ${hasNixOSFacter} == "n" ]]; then abort "nixos-facter is not available in booted installer, use nixos-generate-config. For nixos-facter, you may want to boot an installer image from here instead: https://github.com/nix-community/nixos-images" fi else maybeSudo="" fi step "Generating hardware-configuration.nix using nixos-facter" runSshNoTty -o ConnectTimeout=10 ${maybeSudo} "nixos-facter" >"$hardwareConfigPath" ;; nixos-generate-config) step "Generating hardware-configuration.nix using nixos-generate-config" runSshNoTty -o ConnectTimeout=10 nixos-generate-config --show-hardware-config --no-filesystems >"$hardwareConfigPath" ;; *) abort "Unknown hardware config backend: $hardwareConfigBackend" ;; esac # to make sure nix knows about the new file if command -v git >/dev/null; then # handle relative paths hardwareConfigPath="$(realpath "$hardwareConfigPath")" pushd "$(dirname "$hardwareConfigPath")" if git rev-parse --is-inside-work-tree >/dev/null; then git add --intent-to-add --force -- "$hardwareConfigPath" fi popd fi } runKexec() { if [[ ${isKexec} == "y" ]] || [[ ${isInstaller} == "y" ]]; then return fi if [[ ${isContainer} != "none" ]]; then echo "WARNING: This script does not support running from a '${isContainer}' container. kexec will likely not work" >&2 fi if [[ $kexecUrl == "" ]]; then case "${isArch}" in x86_64 | aarch64) kexecUrl="https://github.com/nix-community/nixos-images/releases/download/nixos-24.11/nixos-kexec-installer-noninteractive-${isArch}-linux.tar.gz" ;; *) abort "Unsupported architecture: ${isArch}. Our default kexec images only support x86_64 and aarch64 cpus. Checkout https://github.com/nix-community/nixos-anywhere/#using-your-own-kexec-image for more information." ;; esac fi step Switching system into kexec runSsh sh < $path" <"${diskEncryptionKeys[$path]}" done if [[ -n ${diskoScript} ]]; then nixCopy --to "ssh://$sshConnection?$sshStoreSettings" "$diskoScript" elif [[ ${buildOn} == "remote" ]]; then step Building disko script # We need to do a nix copy first because nix build doesn't have --no-check-sigs # Use ssh:// here to avoid https://github.com/NixOS/nix/issues/7359 nixCopy --to "ssh://$sshConnection?$sshStoreSettings" "${flake}#${flakeAttr}.system.build.${diskoMode}Script" \ --derivation --no-check-sigs # If we don't use ssh-ng here, we get `error: operation 'getFSAccessor' is not supported by store` diskoScript=$( nixBuild "${flake}#${flakeAttr}.system.build.${diskoAttr}" \ --eval-store auto --store "ssh-ng://$sshConnection?ssh-key=$sshKeyDir%2Fnixos-anywhere&$sshStoreSettings" ) fi step Formatting hard drive with disko runSsh "$diskoScript" } nixosInstall() { local nixosSystem=$1 if [[ -n ${nixosSystem} ]]; then step Uploading the system closure nixCopy --to "ssh://$sshConnection?remote-store=local%3Froot=%2Fmnt&$sshStoreSettings" "$nixosSystem" elif [[ ${buildOn} == "remote" ]]; then step Building the system closure # We need to do a nix copy first because nix build doesn't have --no-check-sigs # Use ssh:// here to avoid https://github.com/NixOS/nix/issues/7359 nixCopy --to "ssh://$sshConnection?remote-store=local%3Froot=%2Fmnt&$sshStoreSettings" "${flake}#${flakeAttr}.system.build.toplevel" \ --derivation --no-check-sigs # If we don't use ssh-ng here, we get `error: operation 'getFSAccessor' is not supported by store` nixosSystem=$( nixBuild "${flake}#${flakeAttr}.system.build.toplevel" \ --eval-store auto --store "ssh-ng://$sshConnection?ssh-key=$sshKeyDir%2Fnixos-anywhere&remote-store=local%3Froot=%2Fmnt&$sshStoreSettings" ) fi if [[ -n ${extraFiles} ]]; then step Copying extra files tar -C "$extraFiles" -cpf- . | runSsh "tar -C /mnt -xf- --no-same-owner" runSsh "chmod 755 /mnt" # tar also changes permissions of /mnt fi if [[ ${#extraFilesOwnership[@]} -gt 0 ]]; then # shellcheck disable=SC2016 printf "%s\n" "${!extraFilesOwnership[@]}" "${extraFilesOwnership[@]}" | pr -2t | runSsh 'while read file ownership; do chown -R "$ownership" "/mnt/$file"; done' fi step Installing NixOS runSsh sh </dev/null && [ "\$(zpool list)" != "no pools available" ]; then # we always want to export the zfs pools so people can boot from it without force import umount -Rv /mnt/ swapoff -a zpool export -a || true fi nohup sh -c 'sleep 6 && reboot' >/dev/null & fi SSH } main() { parseArgs "$@" if [[ ${vmTest} == y ]]; then if [[ ${hardwareConfigBackend} != "none" ]]; then abort "--vm-test is not supported with --generate-hardware-config. You need to generate the hardware configuration before you can run the VM test." >&2 fi runVmTest exit 0 fi if [[ ${buildOn} == "auto" ]]; then checkBuildLocally fi # parse flake nixos-install style syntax, get the system attr if [[ -n ${flake} ]]; then if [[ ${buildOn} == "local" ]] && [[ ${hardwareConfigBackend} == "none" ]]; then if [[ ${phases[disko]} == 1 ]]; then diskoScript=$(nixBuild "${flake}#${flakeAttr}.system.build.${diskoAttr}") fi if [[ ${phases[install]} == 1 ]]; then nixosSystem=$(nixBuild "${flake}#${flakeAttr}.system.build.toplevel") fi fi elif [[ -n ${diskoScript} ]] && [[ -n ${nixosSystem} ]]; then if [[ ! -e ${diskoScript} ]] || [[ ! -e ${nixosSystem} ]]; then abort "${diskoScript} and ${nixosSystem} must be existing store-paths" fi else abort "--flake or --store-paths must be set" fi if [[ -n ${SSH_PRIVATE_KEY} ]] && [[ -z ${sshPrivateKeyFile} ]]; then # $sshKeyDir is getting deleted on trap EXIT sshPrivateKeyFile="$sshKeyDir/from-env" ( umask 077 printf '%s\n' "$SSH_PRIVATE_KEY" >"$sshPrivateKeyFile" ) fi sshSettings=$(ssh "${sshArgs[@]}" -G "${sshConnection}") sshUser=$(echo "$sshSettings" | awk '/^user / { print $2 }') sshHost=$(echo "$sshSettings" | awk '/^hostname / { print $2 }') uploadSshKey importFacts if [[ ${hasTar-n} == "n" ]]; then abort "no tar command found, but required to unpack kexec tarball" fi if [[ ${hasCpio-n} == "n" ]]; then abort "no cpio command found, but required to build the new initrd" fi if [[ ${hasSetsid-n} == "n" ]]; then abort "no setsid command found, but required to run the kexec script under a new session" fi maybeSudo="" if [[ ${hasSudo-n} == "y" ]]; then maybeSudo="sudo" elif [[ ${hasDoas-n} == "y" ]]; then maybeSudo="doas" fi if [[ ${isOs} != "Linux" ]]; then abort "This script requires Linux as the operating system, but got $isOs" fi if [[ ${phases[kexec]} == 1 ]]; then runKexec fi if [[ ${hardwareConfigBackend} != "none" ]]; then generateHardwareConfig fi # Before we do not have a valid hardware configuration we don't know the machine system if [[ ${buildOn} == "auto" ]]; then local remoteSystem remoteSystem=$(runSshNoTty -o ConnectTimeout=10 nix --extra-experimental-features nix-command config show system) checkBuildLocally "${remoteSystem}" # if we cannot figure it out at this point, we will build on the remote host if [[ ${buildOn} == "auto" ]]; then buildOn=remote fi fi if [[ ${buildOn} != "remote" ]] && [[ -n ${flake} ]] && [[ -z ${diskoScript} ]]; then if [[ ${phases[disko]} == 1 ]]; then diskoScript=$(nixBuild "${flake}#${flakeAttr}.system.build.${diskoAttr}") fi if [[ ${phases[install]} == 1 ]]; then nixosSystem=$(nixBuild "${flake}#${flakeAttr}.system.build.toplevel") fi fi # Installation will fail if non-root user is used for installer. # Switch to root user by copying authorized_keys. if [[ ${isInstaller} == "y" ]] && [[ ${sshUser} != "root" ]]; then # Allow copy to fail if authorized_keys does not exist, like if using /etc/ssh/authorized_keys.d/ runSsh "${maybeSudo} mkdir -p /root/.ssh; ${maybeSudo} cp ~/.ssh/authorized_keys /root/.ssh || true" sshConnection="root@${sshHost}" fi if [[ ${phases[disko]} == 1 ]]; then runDisko "$diskoScript" fi if [[ ${phases[install]} == 1 ]]; then nixosInstall "$nixosSystem" fi if [[ ${phases[reboot]} == 1 ]]; then step Waiting for the machine to become unreachable due to reboot while runSshTimeout -- exit 0; do sleep 1; done fi step "Done!" } main "$@"