improve installation, add support for:

ZFS, encryption (keys, keystore, LUKS), bootFS, ephemeral root (tmpfs, ZFS, F2FS, ...), testing in qemu, options & debugging, ... and many small things
This commit is contained in:
Niklas Gollenstede
2022-05-31 03:41:28 +02:00
parent 9ef807877e
commit f56db19b5e
28 changed files with 1765 additions and 222 deletions

View File

@ -3,15 +3,17 @@
This is a library of bash functions, mostly for NixOS system installation.
The (paths to these) scripts are meant to me passed in the `scripts` argument to [`mkSystemsFlake`](../flakes.nix#mkSystemsFlake), which makes their functions available in the per-host `devShells`/`apps`.
The (paths to these) scripts are meant to me passed in the `scripts` argument to [`mkSystemsFlake`](../flakes.nix#mkSystemsFlake) (see [`flake.nix`](../../flake.nix) for an example), which makes their functions available in the per-host `devShells`/`apps`.
Host-specific nix variables are available to the bash functions as `@{...}` through [`substituteImplicit`](../scripts.nix#substituteImplicit) with the respective host as root context.
Any script passed later in `scripts` can overwrite the functions of these (earlier) default scripts.
With the functions from here, adding a simple three-liner can be enough to do a completely automated NixOS installation:
With the functions from here, [a simple four-liner](../install.sh) is enough to do a completely automated NixOS installation:
```bash
function install-system {( set -eu # 1: blockDev
function install-system {( set -eu # 1: diskPaths
prepare-installer "$@"
do-disk-setup "$1"
install-system-to $mnt prompt=true
do-disk-setup "${argv[0]}"
init-or-restore-system
install-system-to $mnt
)}
```

View File

@ -0,0 +1,110 @@
##
# Key Generation Methods
# See »../../modules/fs/keystore.nix.md« for more documentation.
##
## Puts an empty key in the keystore, causing that ZFS dataset to be unencrypted, even if it's parent is encrypted.
function add-key-unencrypted {( set -eu # 1: usage
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1
: | write-secret "$keystore"/"$usage".key
)}
## Adds a key by copying the hostname to the keystore.
function add-key-hostname {( set -eu # 1: usage
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1
if [[ ! "$usage" =~ ^(luks/keystore-@{config.networking.hostName!hashString.sha256:0:8}/.*)$ ]] ; then printf '»trivial« key mode is only available for the keystore itself.\n' ; exit 1 ; fi
printf %s "@{config.networking.hostName}" | write-secret "$keystore"/"$usage".key
)}
## Adds a key by copying it from a bootkey partition (see »add-bootkey-to-keydev«) to the keystore.
function add-key-usb-part {( set -eu # 1: usage
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1
if [[ ! "$usage" =~ ^(luks/keystore/.*)$ ]] ; then printf '»usb-part« key mode is only available for the keystore itself.\n' ; exit 1 ; fi
bootkeyPartlabel=bootkey-"@{config.networking.hostName!hashString.sha256:0:8}"
cat /dev/disk/by-partlabel/"$bootkeyPartlabel" | write-secret "$keystore"/"$usage".key
)}
## Adds a key by copying a different key from the keystore to the keystore.
function add-key-copy {( set -eu # 1: usage, 2: source
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1 ; source=$2
cat "$keystore"/"$source".key | write-secret "$keystore"/"$usage".key
)}
## Adds a key by writing a constant value to the keystore.
function add-key-constant {( set -eu # 1: usage, 2: value
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1 ; value=$2
printf %s "$value" | write-secret "$keystore"/"$usage".key
)}
## Adds a key by prompting for a password and saving it to the keystore.
function add-key-password {( set -eu # 1: usage
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1
(prompt-new-password "as key for @{config.networking.hostName}/$usage" || exit 1) \
| write-secret "$keystore"/"$usage".key
)}
## Generates a key by prompting for a password, combining it with »$keystore/home/$user.key«, and saving it to the keystore.
function add-key-home-pw {( set -eu # 1: usage, 2: user
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1 ; user=$2
if [[ ${!userPasswords[@]} && ${userPasswords[$user]:-} ]] ; then
password=${userPasswords[$user]}
else
password=$(prompt-new-password "that will be used as component of the key for @{config.networking.hostName}/$usage")
fi
( cat "$keystore"/home/"$user".key && cat <<<"$password" ) | sha256sum | head -c 64 \
| write-secret "$keystore"/"$usage".key
)}
## Generates a reproducible secret for a certain »$use«case by prompting for a pin/password and then challenging slot »$slot« of YubiKey »$serial«, and saves it to the »$keystore«.
function add-key-yubikey-pin {( set -eu # 1: usage, 2: serialAndSlot(as »serial:slot«)
usage=$1 ; serialAndSlot=$2
password=$(prompt-new-password "/ pin as challenge to YubiKey »$serialAndSlot« as key for @{config.networking.hostName}/$usage")
add-key-yubikey-challenge "$usage" "$serialAndSlot:$password" true "pin for ${usage}"
)}
## Generates a reproducible secret for a certain »$use«case on a »$host« by challenging slot »$slot« of YubiKey »$serial«, and saves it to the »$keystore«.
function add-key-yubikey {( set -eu # 1: usage, 2: serialAndSlotAndSalt(as »serial:slot:salt«)
usage=$1 ; IFS=':' read -ra serialAndSlotAndSalt <<< "$2"
usage_="$usage" ; if [[ "$usage" =~ ^(luks/.*/[0-8])$ ]] ; then usage_="${usage:0:(-2)}" ; fi # produce the same secret, regardless of the target luks slot
challenge="@{config.networking.hostName}:$usage_${serialAndSlotAndSalt[2]:+:${serialAndSlotAndSalt[2]:-}}"
add-key-yubikey-challenge "$usage" "${serialAndSlotAndSalt[0]}:${serialAndSlotAndSalt[1]}:$challenge"
)}
## Generates a reproducible secret for a certain »$use«case by challenging slot »$slot« of YubiKey »$serial« with the fixed »$challenge«, and saves it to the »$keystore«.
# If »$sshArgs« is set as (env) var, generate the secret locally, then use »ssh $sshArgs« to write the secret on the other end.
# E.g.: # sshArgs='installerIP' add-key-yubikey /run/keystore/ zfs/rpool/remote 1234567:2:customChallenge
function add-key-yubikey-challenge {( set -eu # 1: usage, 2: serialAndSlotAndChallenge(as »$serial:$slot:$challenge«), 3?: onlyOnce, 4?: message
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1 ; args=$2 ; message=${4:-}
serial=$(<<<"$args" cut -d: -f1)
slot=$(<<<"$args" cut -d: -f2)
challenge=${args/$serial:$slot:/}
if [[ "$serial" != "$(@{native.yubikey-personalization}/bin/ykinfo -sq)" ]] ; then printf 'Please insert / change to YubiKey with serial %s!\n' "$serial" ; fi
if [[ ! "${3:-}" ]] ; then
read -p 'Challenging YubiKey '"$serial"' slot '"$slot"' twice with '"${message:-challenge »"$challenge":1/2«}"'. Enter to continue, or Ctrl+C to abort:'
else
read -p 'Challenging YubiKey '"$serial"' slot '"$slot"' once with '"${message:-challenge »"$challenge"«}"'. Enter to continue, or Ctrl+C to abort:'
fi
if [[ "$serial" != "$(@{native.yubikey-personalization}/bin/ykinfo -sq)" ]] ; then printf 'YubiKey with serial %s not present, aborting.\n' "$serial" ; exit 1 ; fi
if [[ ! "${3:-}" ]] ; then
secret="$(@{native.yubikey-personalization}/bin/ykchalresp -"$slot" "$challenge":1)""$(@{native.yubikey-personalization}/bin/ykchalresp -2 "$challenge":2)"
if [[ ${#secret} != 80 ]] ; then printf 'YubiKey challenge failed, aborting.\n' "$serial" ; exit 1 ; fi
else
secret="$(@{native.yubikey-personalization}/bin/ykchalresp -"$slot" "$challenge")"
if [[ ${#secret} != 40 ]] ; then printf 'YubiKey challenge failed, aborting.\n' "$serial" ; exit 1 ; fi
fi
if [[ ! "${sshArgs:-}" ]] ; then
printf %s "$secret" | ( head -c 64 | write-secret "$keystore"/"$usage".key )
else
read -p 'Uploading secret with »ssh '"$sshArgs"'«. Enter to continue, or Ctrl+C to abort:'
printf %s "$secret" | ( head -c 64 | ssh $sshArgs /etc/nixos/utils/functions.sh write-secret "$keystore"/"$usage".key )
fi
)}
## Generates a random secret key and saves it to the keystore.
function add-key-random {( set -eu # 1: usage
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8} ; usage=$1
</dev/urandom tr -dc 0-9a-f | head -c 64 | write-secret "$keystore"/"$usage".key
)}

View File

@ -6,34 +6,49 @@
## Prepares the disks of the target system for the copying of files.
function do-disk-setup { # 1: diskPaths
mnt=/tmp/nixos-install-@{config.networking.hostName} ; mkdir -p "$mnt" ; prepend_trap "rmdir $mnt" EXIT # »mnt=/run/user/0/...« would be more appropriate, but »nixos-install« does not like the »700« permissions on »/run/user/0«
prompt-for-user-passwords &&
populate-keystore &&
partition-disks "$1"
# ... block layers would go here ...
source @{config.wip.installer.postPartitionCommands!writeText.postPartitionCommands}
format-partitions
source @{config.wip.installer.postFormatCommands!writeText.postFormatCommands}
prepend_trap "unmount-system $mnt" EXIT ; mount-system $mnt
source @{config.wip.installer.postMountCommands!writeText.postMountCommands}
mnt=/tmp/nixos-install-@{config.networking.hostName} && mkdir -p "$mnt" && prepend_trap "rmdir $mnt" EXIT && # »mnt=/run/user/0/...« would be more appropriate, but »nixos-install« does not like the »700« permissions on »/run/user/0«
partition-disks "$1" &&
create-luks-layers && open-luks-layers && # other block layers would go here too (but figuring out their dependencies would be difficult)
run-hook-script 'Post Partitioning' @{config.wip.fs.disks.postPartitionCommands!writeText.postPartitionCommands} &&
format-partitions &&
{ [[ $(LC_ALL=C type -t create-zpools) != function ]] || create-zpools $mnt ; } &&
run-hook-script 'Post Formatting' @{config.wip.fs.disks.postFormatCommands!writeText.postFormatCommands} &&
prepend_trap "unmount-system $mnt" EXIT && mount-system $mnt &&
run-hook-script 'Post Mounting' @{config.wip.fs.disks.postMountCommands!writeText.postMountCommands} &&
:
}
## Partitions all »config.installer.disks« to ensure that all (correctly) specified »{config.installer.partitions« exist.
# Notes segmentation and alignment:
# * Both fdisk and gdisk report start and end in 0-indexed sectors from the start of the block device.
# * (fdisk and gdisk have slightly different interfaces, but seem to otherwise be mostly equivalent, (fdisk used to not understand GPT).)
# * The MBR sits only in the first sector, a GPT additionally requires next 33 (34 total) and the (absolute) last 33 sectors. At least fdisk won't put partitions in the first 2048 sectors on MBRs.
# * Crappy flash storage (esp. micro SD cards) requires alignment to pretty big sectors for optimal (esp. write) performance. For reasons of inconvenience, vendors don't document the size of those. Not too extensive test with 4 (in 2022 considered to be among the more decent) micro SD cards indicates the magic number to be somewhere between 1 and 4MiB, but it may very well be higher for others.
# * (source: https://lwn.net/Articles/428584/)
# * So alignment at the default »align=8MiB« actually seems a decent choice.
## Partitions all »config.wip.fs.disks.devices« to ensure that all (correctly) specified »config.wip.fs.disks.partitions« exist.
function partition-disks { { # 1: diskPaths
beQuiet=/dev/null ; if [[ ${debug:=} ]] ; then beQuiet=/dev/stdout ; fi
beQuiet=/dev/null ; if [[ ${args[debug]:-} ]] ; then beQuiet=/dev/stdout ; fi
declare -g -A blockDevs=( ) # this ends up in the caller's scope
local path ; for path in ${1/:/ } ; do
name=${path/=*/} ; if [[ $name != "$path" ]] ; then path=${path/$name=/} ; else name=primary ; fi
local path ; for path in ${1//:/ } ; do
local name=${path/=*/} ; if [[ $name != "$path" ]] ; then path=${path/$name=/} ; else name=primary ; fi
if [[ ${blockDevs[$name]:-} ]] ; then echo "Path for block device $name specified more than once. Duplicate definition: $path" ; exit 1 ; fi
blockDevs[$name]=$path
done
local name ; for name in "@{!config.wip.installer.disks!attrsAsBashEvalSets[@]}" ; do
local name ; for name in "@{!config.wip.fs.disks.devices[@]}" ; do
if [[ ! ${blockDevs[$name]:-} ]] ; then echo "Path for block device $name not provided" ; exit 1 ; fi
if [[ ! ${blockDevs[$name]} =~ ^(/dev/.*)$ ]] ; then
local outFile=${blockDevs[$name]} ; ( set -eu
eval "@{config.wip.installer.disks!attrsAsBashEvalSets[$name]}" # _size
install -o root -g root -m 640 -T /dev/null "$outFile" && fallocate -l "$_size" "$outFile"
eval 'declare -A disk='"@{config.wip.fs.disks.devices[$name]}"
install -o root -g root -m 640 -T /dev/null "$outFile" && fallocate -l "${disk[size]}" "$outFile"
) && blockDevs[$name]=$(losetup --show -f "$outFile") && prepend_trap "losetup -d ${blockDevs[$name]}" EXIT # NOTE: this must not be inside a sub-shell!
else
if [[ ! "$(blockdev --getsize64 "${blockDevs[$name]}")" ]] ; then echo "Block device $name does not exist at ${blockDevs[$name]}" ; exit 1 ; fi
@ -41,108 +56,138 @@ function partition-disks { { # 1: diskPaths
fi
done
} ; ( set -eu
} && ( set -eu
for name in "@{!config.wip.installer.disks!attrsAsBashEvalSets[@]}" ; do (
eval "@{config.wip.installer.disks!attrsAsBashEvalSets[$name]}" # _name ; _size ; _serial ; _alignment ; _mbrParts ; _extraFDiskCommands
if [[ $_serial ]] ; then
actual=$(udevadm info --query=property --name="${blockDevs[$name]}" | grep -oP 'ID_SERIAL_SHORT=\K.*')
if [[ $_serial != "$actual" ]] ; then echo "Block device ${blockDevs[$name]} does not match the serial declared for $name" ; exit 1 ; fi
for partDecl in "@{config.wip.fs.disks.partitionList[@]}" ; do
eval 'declare -A part='"$partDecl"
if [[ -e /dev/disk/by-partlabel/"${part[name]}" ]] && ! is-partition-on-disks /dev/disk/by-partlabel/"${part[name]}" "${blockDevs[@]}" ; then echo "Partition /dev/disk/by-partlabel/${part[name]} exists but does not reside on one of the target disks ${blockDevs[@]}" ; exit 1 ; fi
done
for name in "@{!config.wip.fs.disks.devices[@]}" ; do (
eval 'declare -A disk='"@{config.wip.fs.disks.devices[$name]}"
if [[ ${disk[serial]:-} ]] ; then
actual=$(udevadm info --query=property --name="${blockDevs[${disk[name]}]}" | @{native.gnugrep}/bin/grep -oP 'ID_SERIAL_SHORT=\K.*')
if [[ ${disk[serial]} != "$actual" ]] ; then echo "Block device ${blockDevs[${disk[name]}]} does not match the serial declared for ${disk[name]}" ; exit 1 ; fi
fi
sgdisk=( --zap-all ) # delete existing part tables
for partDecl in "@{config.wip.installer.partitionList!listAsBashEvalSets[@]}" ; do
eval "$partDecl" # _name ; _disk ; _type ; _size ; _index
if [[ $_disk != "$name" ]] ; then exit ; fi # i.e. continue
if [[ $_position =~ ^[0-9]+$ ]] ; then alignment=1 ; else alignment=$_alignment ; fi # if position is an absolute number, start precisely there
sgdisk+=( -a "$alignment" -n "${_index:-0}":"$_position":+"$_size" -t 0:"$_type" -c 0:"$_name" )
declare -a sgdisk=( --zap-all ) # delete existing part tables
for partDecl in "@{config.wip.fs.disks.partitionList[@]}" ; do
eval 'declare -A part='"$partDecl"
if [[ ${part[disk]} != "${disk[name]}" ]] ; then continue ; fi
if [[ ${part[size]:-} =~ ^[0-9]+%$ ]] ; then
devSize=$(blockdev --getsize64 "${blockDevs[${disk[name]}]}")
part[size]=$(( $devSize / 1024 * ${part[size]:0:(-1)} / 100 ))K
fi
sgdisk+=( -a "${part[alignment]:-${disk[alignment]}}" -n "${part[index]:-0}":"${part[position]}":+"${part[size]:-}" -t 0:"${part[type]}" -c 0:"${part[name]}" )
done
if [[ $_mbrParts ]] ; then
sgdisk+=( --hybrid "$_mbrParts" ) # --hybrid: create MBR in addition to GPT; $_mbrParts: make these GPT part 1 MBR parts 2[3[4]]
if [[ ${disk[mbrParts]:-} ]] ; then
sgdisk+=( --hybrid "${disk[mbrParts]}" ) # --hybrid: create MBR in addition to GPT; ${disk[mbrParts]}: make these GPT part 1 MBR parts 2[3[4]]
fi
sgdisk "${sgdisk[@]}" "${blockDevs[$name]}" >$beQuiet # running all at once is much faster
( PATH=@{native.gptfdisk}/bin ; set -x ; sgdisk "${sgdisk[@]}" "${blockDevs[${disk[name]}]}" >$beQuiet ) # running all at once is much faster
if [[ $_mbrParts ]] ; then
if [[ ${disk[mbrParts]:-} ]] ; then
printf "
M # edit hybrid MBR
d;1 # delete parts 1 (GPT)
# move the selected »mbrParts« to slots 1[2[3]] instead of 2[3[4]] (by re-creating part1 in the last sector, then sorting)
n;p;1 # new ; primary ; part1
$(( $(blockSectorCount "${blockDevs[$name]}") - 1)) # start (size 1sec)
$(( $(blockSectorCount "${blockDevs[${disk[name]}]}") - 1)) # start (size 1sec)
x;f;r # expert mode ; fix order ; return
d;$(( (${#_mbrParts} + 1) / 2 + 1 )) # delete ; part(last)
d;$(( (${#disk[mbrParts]} + 1) / 2 + 1 )) # delete ; part(last)
# create GPT part (spanning primary GPT area) as last part
n;p;4 # new ; primary ; part4
1;33 # start ; end
t;4;ee # type ; part4 ; GPT
${_extraFDiskCommands}
${disk[extraFDiskCommands]}
p;w;q # print ; write ; quit
" | perl -pe 's/^ *| *(#.*)?$//g' | perl -pe 's/\n\n+| *; */\n/g' | fdisk "${blockDevs[$name]}" &>$beQuiet
" | @{native.gnused}/bin/sed -E 's/^ *| *(#.*)?$//g' | @{native.gnused}/bin/sed -E 's/\n\n+| *; */\n/g' | tee >((echo -n '++ ' ; tr $'\n' '|' ; echo) 1>&2) | ( set -x ; fdisk "${blockDevs[${disk[name]}]}" &>$beQuiet )
fi
partprobe "${blockDevs[$name]}"
) ; done
sleep 1 # sometimes partitions aren't quite made available yet (TODO: wait "for udev to settle" instead?)
@{native.parted}/bin/partprobe "${blockDevs[@]}"
@{native.systemd}/bin/udevadm settle -t 15 || true # sometimes partitions aren't quite made available yet
# ensure that filesystem creation does not complain about the devices already being occupied by a previous filesystem
wipefs --all "@{config.wip.fs.disks.partitions!attrNames[@]/#/'/dev/disk/by-partlabel/'}" >$beQuiet
)}
## Checks whether a »partition« resides on one of the provided »blockDevs«.
function is-partition-on-disks {( set -eu # 1: partition, ...: blockDevs
partition=$1 ; shift ; declare -a blockDevs=( "$@" )
blockDev=$(realpath "$partition") ; if [[ $blockDev == /dev/sd* ]] ; then
blockDev=$( shopt -s extglob ; echo "${blockDev%%+([0-9])}" )
else
blockDev=$( shopt -s extglob ; echo "${blockDev%%p+([0-9])}" )
fi
[[ ' '"${blockDevs[@]}"' ' == *' '"$blockDev"' '* ]]
)}
## For each filesystem in »config.fileSystems« whose ».device« is in »/dev/disk/by-partlabel/«, this creates the specified file system on that partition.
function format-partitions {( set -eu
beQuiet=/dev/null ; if [[ ${debug:=} ]] ; then beQuiet=/dev/stdout ; fi
for fsDecl in "@{config.fileSystems!attrsAsBashEvalSets[@]}" ; do (
eval "$fsDecl" # _name ; _device ; _fsType ; _formatOptions ; ...
if [[ $_device != /dev/disk/by-partlabel/* ]] ; then exit ; fi # i.e. continue
blockDev=$(realpath "$_device") ; if [[ $blockDev == /dev/sd* ]] ; then
blockDev=$( shopt -s extglob ; echo "${blockDev%%+([0-9])}" )
else
blockDev=$( shopt -s extglob ; echo "${blockDev%%p+([0-9])}" )
fi
if [[ ' '"${blockDevs[@]}"' ' != *' '"$blockDev"' '* ]] ; then echo "Partition alias $_device does not point at one of the target disks ${blockDevs[@]}" ; exit 1 ; fi
mkfs.${_fsType} ${_formatOptions} "${_device}" >$beQuiet
partprobe "${_device}"
) ; done
beQuiet=/dev/null ; if [[ ${args[debug]:-} ]] ; then beQuiet=/dev/stdout ; fi
for fsDecl in "@{config.fileSystems[@]}" ; do
eval 'declare -A fs='"$fsDecl"
if [[ ${fs[device]} == /dev/disk/by-partlabel/* ]] ; then
if ! is-partition-on-disks "${fs[device]}" "${blockDevs[@]}" ; then echo "Partition alias ${fs[device]} used by mount ${fs[mountPoint]} does not point at one of the target disks ${blockDevs[@]}" ; exit 1 ; fi
elif [[ ${fs[device]} == /dev/mapper/* ]] ; then
if [[ ! @{config.boot.initrd.luks.devices!catAttrSets.device[${fs[device]/'/dev/mapper/'/}]:-} ]] ; then echo "LUKS device ${fs[device]} used by mount ${fs[mountPoint]} does not point at one of the device mappings ${!config.boot.initrd.luks.devices!catAttrSets.device[@]}" ; exit 1 ; fi
else continue ; fi
( PATH=@{native.e2fsprogs}/bin:@{native.f2fs-tools}/bin:@{native.xfsprogs}/bin:@{native.dosfstools}/bin:$PATH ; set -x ; mkfs.${fs[fsType]} ${fs[formatOptions]} "${fs[device]}" >$beQuiet )
@{native.parted}/bin/partprobe "${fs[device]}" || true
done
for swapDev in "@{config.swapDevices!catAttrs.device[@]}" ; do
if [[ $swapDev == /dev/disk/by-partlabel/* ]] ; then
if ! is-partition-on-disks "$swapDev" "${blockDevs[@]}" ; then echo "Partition alias $swapDev used for SWAP does not point at one of the target disks ${blockDevs[@]}" ; exit 1 ; fi
elif [[ $swapDev == /dev/mapper/* ]] ; then
if [[ ! @{config.boot.initrd.luks.devices!catAttrSets.device[${swapDev/'/dev/mapper/'/}]:-} ]] ; then echo "LUKS device $swapDev used for SWAP does not point at one of the device mappings @{!config.boot.initrd.luks.devices!catAttrSets.device[@]}" ; exit 1 ; fi
else continue ; fi
( set -x ; mkswap "$swapDev" >$beQuiet )
done
)}
## Mounts all file systems as it would happen during boot, but at path prefix »$mnt«.
## Mounts all file systems as it would happen during boot, but at path prefix »$mnt« (instead of »/«).
function mount-system {( set -eu # 1: mnt, 2?: fstabPath
# mount --all --fstab @{config.system.build.toplevel.outPath}/etc/fstab --target-prefix "$1" -o X-mount.mkdir # (»--target-prefix« is not supported on Ubuntu 20.04)
mnt=$1 ; fstabPath=${2:-"@{config.system.build.toplevel.outPath}/etc/fstab"}
<$fstabPath grep -v '^#' | LC_ALL=C sort -k2 | while read source target type options numbers ; do
# mount --all --fstab @{config.system.build.toplevel}/etc/fstab --target-prefix "$1" -o X-mount.mkdir # (»--target-prefix« is not supported on Ubuntu 20.04)
mnt=$1 ; fstabPath=${2:-"@{config.system.build.toplevel}/etc/fstab"}
PATH=@{native.e2fsprogs}/bin:@{native.f2fs-tools}/bin:@{native.xfsprogs}/bin:@{native.dosfstools}/bin:$PATH
<$fstabPath @{native.gnugrep}/bin/grep -v '^#' | LC_ALL=C sort -k2 | while read source target type options numbers ; do
if [[ ! $target || $target == none ]] ; then continue ; fi
options=,$options, ; options=${options//,ro,/,}
if [[ $options =~ ,r?bind, ]] || [[ $type == overlay ]] ; then continue ; fi
if ! mountpoint -q "$mnt"/"$target" ; then
if ! mountpoint -q "$mnt"/"$target" ; then (
mkdir -p "$mnt"/"$target"
[[ $type == tmpfs ]] || @{native.kmod}/bin/modprobe --quiet $type || true # (this does help sometimes)
mount -t $type -o "${options:1:(-1)}" "$source" "$mnt"/"$target"
fi
) || [[ $options == *,nofail,* ]] ; fi # (actually, nofail already makes mount fail silently)
done
# Since bind mounts may depend on other mounts not only for the target (which the sort takes care of) but also for the source, do all bind mounts last. This would break if there was a different bind mountpoint within a bind-mounted target.
<$fstabPath grep -v '^#' | LC_ALL=C sort -k2 | while read source target type options numbers ; do
<$fstabPath @{native.gnugrep}/bin/grep -v '^#' | LC_ALL=C sort -k2 | while read source target type options numbers ; do
if [[ ! $target || $target == none ]] ; then continue ; fi
options=,$options, ; options=${options//,ro,/,}
if [[ $options =~ ,r?bind, ]] || [[ $type == overlay ]] ; then : ; else continue ; fi
if ! mountpoint -q "$mnt"/"$target" ; then
if ! mountpoint -q "$mnt"/"$target" ; then (
mkdir -p "$mnt"/"$target"
if [[ $type == overlay ]] ; then
options=${options//,workdir=/,workdir=$mnt\/} ; options=${options//,upperdir=/,upperdir=$mnt\/} # work and upper dirs must be in target, lower dirs are probably store paths
workdir=$(<<<"$options" grep -o -P ',workdir=\K[^,]+' || true) ; if [[ $workdir ]] ; then mkdir -p "$workdir" ; fi
upperdir=$(<<<"$options" grep -o -P ',upperdir=\K[^,]+' || true) ; if [[ $upperdir ]] ; then mkdir -p "$upperdir" ; fi
workdir=$(<<<"$options" @{native.gnugrep}/bin/grep -o -P ',workdir=\K[^,]+' || true) ; if [[ $workdir ]] ; then mkdir -p "$workdir" ; fi
upperdir=$(<<<"$options" @{native.gnugrep}/bin/grep -o -P ',upperdir=\K[^,]+' || true) ; if [[ $upperdir ]] ; then mkdir -p "$upperdir" ; fi
else
source=$mnt/$source ; if [[ ! -e $source ]] ; then mkdir -p "$source" ; fi
fi
mount -t $type -o "${options:1:(-1)}" "$source" "$mnt"/"$target"
fi
) || [[ $options == *,nofail,* ]] ; fi
done
)}
## Unmounts all file systems (that would be mounted during boot / by »mount-system«).
function unmount-system {( set -eu # 1: mnt, 2?: fstabPath
mnt=$1 ; fstabPath=${2:-"@{config.system.build.toplevel.outPath}/etc/fstab"}
<$fstabPath grep -v '^#' | LC_ALL=C sort -k2 -r | while read source target rest ; do
mnt=$1 ; fstabPath=${2:-"@{config.system.build.toplevel}/etc/fstab"}
<$fstabPath @{native.gnugrep}/bin/grep -v '^#' | LC_ALL=C sort -k2 -r | while read source target rest ; do
if [[ ! $target || $target == none ]] ; then continue ; fi
if mountpoint -q "$mnt"/"$target" ; then
umount "$mnt"/"$target"

View File

@ -3,55 +3,108 @@
# NixOS Installation
##
## Ensures that the installer gets called by root and with an argument, includes a hack to make installation work when nix isn't installed for root, and enables debugging (if requested).
## Entry point to the installation, see »./README.md«.
function install-system {( set -eu # 1: blockDev
prepare-installer "$@"
do-disk-setup "${argv[0]}"
init-or-restore-system
install-system-to $mnt
)}
## Does very simple argument passing and validation, performs some sanity checks, includes a hack to make installation work when nix isn't installed for root, and enables debugging (if requested).
function prepare-installer { # ...
beQuiet=/dev/null ; if [[ ${debug:=} ]] ; then set -x ; beQuiet=/dev/stdout ; fi
generic-arg-parse "$@"
if [[ "$(id -u)" != '0' ]] ; then echo 'Script must be run in a root (e.g. in a »sudo --preserve-env=SSH_AUTH_SOCK -i«) shell.' ; exit ; fi
if [[ ${SUDO_USER:-} ]] ; then function nix {( args=("$@") ; su - "$SUDO_USER" -c "$(declare -p args)"' ; nix "${args[@]}"' )} ; fi
beQuiet=/dev/null ; if [[ ${args[debug]:-} ]] ; then set -x ; beQuiet=/dev/stdout ; fi
: ${1:?"Required: Target disk or image paths."}
: ${argv[0]:?"Required: Target disk or image paths."}
if [[ $debug ]] ; then set +e ; set -E ; trap 'code= ; bash -l || code=$? ; if [[ $code ]] ; then exit $code ; fi' ERR ; fi # On error, instead of exiting straight away, open a shell to allow diagnosing/fixing the issue. Only exit if that shell reports failure (e.g. CtrlC + CtrlD). Unfortunately, the exiting has to be repeated for level of each nested sub-shells.
if [[ "$(id -u)" != '0' ]] ; then echo 'Script must be run as root.' ; exit 1 ; fi
umask 0022 # Ensure consistent umask (default permissions for new files).
if [[ -e "/run/keystore-@{config.networking.hostName!hashString.sha256:0:8}" ]] ; then echo "Keystore »/run/keystore-@{config.networking.hostName!hashString.sha256:0:8}/« is already open. Close it and remove the mountpoint before running the installer." ; exit 1 ; fi
# (partitions are checked in »partition-disks« once the target devices are known)
local luksName ; for luksName in "@{!config.boot.initrd.luks.devices!catAttrSets.device[@]}" ; do
if [[ -e "/dev/mapper/$luksName" ]] ; then echo "LUKS device mapping »$luksName« is already open. Close it before running the installer." ; exit 1 ; fi
done
local poolName ; for poolName in "@{!config.wip.fs.zfs.pools[@]}" ; do
if zfs get -o value -H name "$poolName" &>/dev/null ; then echo "ZFS pool »$poolName« is already imported. Export the pool before running the installer." ; exit 1 ; fi
done
if [[ ${SUDO_USER:-} ]] ; then function nix {( set +x ; declare -a args=("$@") ; PATH=/bin:/usr/bin su - "$SUDO_USER" -c "$(declare -p args)"' ; nix "${args[@]}"' )} ; fi
if [[ ${args[debug]:-} ]] ; then set +e ; set -E ; trap 'code= ; @{native.bashInteractive}/bin/bash --init-file @{config.environment.etc.bashrc.source} || code=$? ; if [[ $code ]] ; then exit $code ; fi' ERR ; fi # On error, instead of exiting straight away, open a shell to allow diagnosing/fixing the issue. Only exit if that shell reports failure (e.g. CtrlC + CtrlD). Unfortunately, the exiting has to be repeated for level of each nested sub-shells.
}
## Depending on the presence or absence of the »--restore« CLI flag, either runs the system's initialization or restore commands.
# The initialization commands are expected to create files that can't be stored in the (host's or target system's) nix store (i.e. secrets).
# The restore commands are expected to pull in a backup of the systems secrets and state from somewhere, and need to acknowledge that something happened by running »restore-supported-callback«.
function init-or-restore-system {( set -eu # (void)
if [[ ! ${args[restore]:-} ]] ; then
run-hook-script 'System Initialization' @{config.wip.fs.disks.initSystemCommands!writeText.postPartitionCommands} # TODO: Do this later inside the chroot?
return # usually, this would be it ...
fi
requiresRestoration=$(mktemp) ; trap "rm -f '$requiresRestoration'" EXIT ; function restore-supported-callback {( rm -f "$requiresRestoration" )}
run-hook-script 'System Restoration' @{config.wip.fs.disks.restoreSystemCommands!writeText.postPartitionCommands}
if [[ -e $requiresRestoration ]] ; then echo 'The »restoreSystemCommands« did not call »restore-supported-callback« to mark backup restoration as supported for this system. Assuming incomplete configuration.' 1>&2 ; exit 1 ; fi
)}
## The default command that will activate the system and install the bootloader. In a separate function to make it easy to replace.
function nixos-install-cmd {( set -eu # 1: mnt, 2: topLevel
# »nixos-install« by default does some stateful things (see the »--no« options below), builds and copies the system config (but that's already done), and then calls »NIXOS_INSTALL_BOOTLOADER=1 nixos-enter -- $topLevel/bin/switch-to-configuration boot«, which is essentially the same as »NIXOS_INSTALL_BOOTLOADER=1 nixos-enter -- @{config.system.build.installBootLoader} $targetSystem«, i.e. the side effects of »nixos-enter« and then calling the bootloader-installer.
PATH=@{config.systemd.package}/bin:$PATH TMPDIR=/tmp LC_ALL=C nixos-install --system "$2" --no-root-passwd --no-channel-copy --root "$1" #--debug
)}
## Copies the system's dependencies to the disks mounted at »$mnt« and installs the bootloader. If »$inspect« is set, a root shell will be opened in »$mnt« afterwards.
# »$topLevel« may point to an alternative top-level dependency to install.
function install-system-to {( # 1: mnt, 2?: inspect, 3?: topLevel
mnt=$1 ; inspect=${2:-} ; topLevel=${3:-}
targetSystem=@{config.system.build.toplevel}
trap - EXIT # start with empty traps for sub-shell
function install-system-to {( set -eu # 1: mnt, 2?: topLevel
mnt=$1 ; topLevel=${2:-}
targetSystem=@{config.system.build.toplevel}
trap - EXIT # start with empty traps for sub-shell
for dir in dev/ sys/ run/ ; do mkdir -p $mnt/$dir ; mount tmpfs -t tmpfs $mnt/$dir ; prepend_trap "while umount -l $mnt/$dir 2>$beQuiet ; do : ; done" EXIT ; done # proc/ run/
mkdir -p -m 755 $mnt/nix/var ; mkdir -p -m 1775 $mnt/nix/store
if [[ ${SUDO_USER:-} ]] ; then chown $SUDO_USER: $mnt/nix/store $mnt/nix/var ; fi
# Copy system closure to new nix store:
mkdir -p -m 755 $mnt/nix/var/nix ; mkdir -p -m 1775 $mnt/nix/store
if [[ ${SUDO_USER:-} ]] ; then chown -R $SUDO_USER: $mnt/nix/store $mnt/nix/var ; fi
( set -x ; time nix copy --no-check-sigs --to $mnt ${topLevel:-$targetSystem} ) ; rm -rf $mnt/nix/var/nix/gcroots
if [[ ${SUDO_USER:-} ]] ; then chown -R root:root $mnt/nix $mnt/nix/var ; chown :30000 $mnt/nix/store ; fi
( set -x ; time nix copy --no-check-sigs --to $mnt ${topLevel:-$targetSystem} )
ln -sT $(realpath $targetSystem) $mnt/run/current-system
mkdir -p -m 755 $mnt/nix/var/nix/profiles ; ln -sT $(realpath $targetSystem) $mnt/nix/var/nix/profiles/system
mkdir -p $mnt/etc/ ; [[ -e $mnt/etc/NIXOS ]] || touch $mnt/etc/NIXOS
# Link/create files that some tooling expects:
mkdir -p $mnt/etc $mnt/run ; mkdir -p -m 1777 $mnt/tmp
mount tmpfs -t tmpfs $mnt/run ; prepend_trap "umount -l $mnt/run" EXIT # If there isn't anything mounted here, »activate« will mount a tmpfs (inside »nixos-enter«'s private mount namespace). That would hide the additions below.
[[ -e $mnt/etc/NIXOS ]] || touch $mnt/etc/NIXOS # for »switch-to-configuration«
[[ -e $mnt/etc/mtab ]] || ln -sfn /proc/mounts $mnt/etc/mtab
ln -sT $(realpath $targetSystem) $mnt/run/current-system
#mkdir -p /nix/var/nix/db # »nixos-containers« requires this but nothing creates it before nix is used. BUT »nixos-enter« screams: »/nix/var/nix/db exists and is not a regular file.«
if [[ $(cat /run/current-system/system 2>/dev/null || echo "x86_64-linux") != "@{config.preface.hardware}"-linux ]] ; then # cross architecture installation
mkdir -p $mnt/run/binfmt ; cp -a {,$mnt}/run/binfmt/"@{config.preface.hardware}"-linux || true
# Ubuntu (by default) expects the "interpreter" at »/usr/bin/qemu-@{config.preface.hardware}-static«.
fi
# Set this as the initial system generation:
mkdir -p -m 755 $mnt/nix/var/nix/profiles ; ln -sT $(realpath $targetSystem) $mnt/nix/var/nix/profiles/system-1-link ; ln -sT system-1-link $mnt/nix/var/nix/profiles/system
if [[ ${SUDO_USER:-} ]] ; then chown -R root:root $mnt/nix ; chown :30000 $mnt/nix/store ; fi
# Support cross architecture installation (not sure if this is actually required)
if [[ $(cat /run/current-system/system 2>/dev/null || echo "x86_64-linux") != "@{config.preface.hardware}"-linux ]] ; then
mkdir -p $mnt/run/binfmt ; cp -a {,$mnt}/run/binfmt/"@{config.preface.hardware}"-linux || true
# Ubuntu (by default) expects the "interpreter" at »/usr/bin/qemu-@{config.preface.hardware}-static«.
fi
mount -o bind /nix/store $mnt/nix/store # all the things required to _run_ the system are copied, but (may) need some more things to initially install it
code=0 ; TMPDIR=/tmp LC_ALL=C nixos-install --system ${topLevel:-$targetSystem} --no-root-passwd --no-channel-copy --root $mnt || code=$? #--debug
umount -l $mnt/nix/store
# Run the main install command (primarily for the bootloader):
mount -o bind,ro /nix/store $mnt/nix/store ; prepend_trap '! mountpoint -q $mnt/nix/store || umount -l $mnt/nix/store' EXIT # all the things required to _run_ the system are copied, but (may) need some more things to initially install it
code=0 ; nixos-install-cmd $mnt "${topLevel:-$targetSystem}" || code=$?
#umount -l $mnt/nix/store # »nixos-enter« below still needs the bind mount, if installing cross-arch
if [[ $inspect ]] ; then
if (( code != 0 )) ; then
( set +x ; echo "Something went wrong in the last step of the installation. Inspect the output above and the system mounted in CWD to decide whether it is critical. Exit the shell with 0 to proceed, or non-zero to abort." )
else
( set +x ; echo "Installation done, but the system is still mounted in CWD for inspection. Exit the shell to unmount it." )
fi
( cd $mnt ; mnt=$mnt bash -l )
fi
# Done!
if [[ ! ${args[no-inspect]:-} ]] ; then
if (( code != 0 )) ; then
( set +x ; echo "Something went wrong in the last step of the installation. Inspect the output above and the mounted system in this chroot shell to decide whether it is critical. Exit the shell with 0 to proceed, or non-zero to abort." )
else
( set +x ; echo "Installation done! This shell is in a chroot in the mounted system for inspection. Exiting the shell will unmount the system." )
fi
PATH=@{config.systemd.package}/bin:$PATH nixos-enter --root $mnt
#( cd $mnt ; mnt=$mnt @{native.bashInteractive}/bin/bash --init-file @{config.environment.etc.bashrc.source} )
elif (( code != 0 )) ; then
exit $code
fi
( mkdir -p $mnt/var/lib/systemd/timesync ; touch $mnt/var/lib/systemd/timesync/clock ) || true # save current time
( mkdir -p $mnt/var/lib/systemd/timesync ; touch $mnt/var/lib/systemd/timesync/clock ) || true # save current time
)}

79
lib/setup-scripts/keys.sh Normal file
View File

@ -0,0 +1,79 @@
## Prompts for the password of every user that uses a »passwordFile«, to later use that password for home encryption and/or save it in the »passwordFile«.
function prompt-for-user-passwords { # (void)
declare -g -A userPasswords=( ) # (this ends up in the caller's scope)
for user in "@{!config.users.users!catAttrSets.password[@]}" ; do # Also grab any plaintext passwords for testing setups.
userPasswords[$user]=@{config.users.users!catAttrSets.password[$user]}
done
for user in "@{!config.users.users!catAttrSets.passwordFile[@]}" ; do
if ! userPasswords[$user]=$(prompt-new-password "for the user account »$user«") ; then exit 1 ; fi
done
}
## Mounts a ramfs as the host's keystore and populates it with keys as requested by »config.wip.fs.keystore.keys«.
# Depending on the specified key types/sources, this may prompt for user input.
function populate-keystore { { # (void)
local keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8}
mkdir -p $keystore && chmod 750 $keystore && prepend_trap "rmdir $keystore" EXIT
mount ramfs -t ramfs $keystore && prepend_trap "umount $keystore" EXIT
} && ( set -eu
declare -A methods=( ) ; declare -A options=( )
for usage in "@{!config.wip.fs.keystore.keys[@]}" ; do
methodAndOptions="@{config.wip.fs.keystore.keys[$usage]}"
method=$(<<<"$methodAndOptions" cut -d= -f1)
methods[$usage]=$method ; options[$usage]=${methodAndOptions/$method=/} # TODO: if no options are provided, this passes the method string as options (use something like ${methodAndOptions:(- $(( ${#method} + 1 ))})
done
for usage in "${!methods[@]}" ; do
if [[ "${methods[$usage]}" != inherit ]] ; then continue ; fi
from=${options[$usage]}
methods[$usage]=${methods[$from]} ; options[$usage]=${options[$from]}
done
for usage in "${!methods[@]}" ; do
if [[ "${methods[$usage]}" == home-pw || "${methods[$usage]}" == copy ]] ; then continue ; fi
add-key-"${methods[$usage]}" "$usage" "${options[$usage]}"
done
for usage in "${!methods[@]}" ; do
if [[ "${methods[$usage]}" != home-pw ]] ; then continue ; fi
add-key-"${methods[$usage]}" "$usage" "${options[$usage]}"
done
for usage in "${!methods[@]}" ; do
if [[ "${methods[$usage]}" != copy ]] ; then continue ; fi
add-key-"${methods[$usage]}" "$usage" "${options[$usage]}"
done
)}
## Creates the LUKS devices specified by the host using the keys created by »populate-keystore«.
function create-luks-layers {( set -eu # (void)
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8}
for luksName in "@{!config.boot.initrd.luks.devices!catAttrSets.device[@]}" ; do
rawDev=@{config.boot.initrd.luks.devices!catAttrSets.device[$luksName]}
if ! is-partition-on-disks "$rawDev" "${blockDevs[@]}" ; then echo "Partition alias $rawDev used by LUKS device $luksName does not point at one of the target disks ${blockDevs[@]}" ; exit 1 ; fi
primaryKey="$keystore"/luks/"$luksName"/0.key
keyOptions=( --pbkdf=pbkdf2 --pbkdf-force-iterations=1000 )
( PATH=@{native.cryptsetup}/bin ; set -x ; cryptsetup --batch-mode luksFormat --key-file="$primaryKey" "${keyOptions[@]}" -c aes-xts-plain64 -s 512 -h sha256 "$rawDev" )
for index in 1 2 3 4 5 6 7 ; do
if [[ -e "$keystore"/luks/"$luksName"/"$index".key ]] ; then
( PATH=@{native.cryptsetup}/bin ; set -x ; cryptsetup luksAddKey --key-file="$primaryKey" "${keyOptions[@]}" "$rawDev" "$keystore"/luks/"$luksName"/"$index".key )
fi
done
done
)}
## Opens the LUKS devices specified by the host, using the opened host's keystore.
function open-luks-layers { # (void)
keystore=/run/keystore-@{config.networking.hostName!hashString.sha256:0:8}
for luksName in "@{!config.boot.initrd.luks.devices!catAttrSets.device[@]}" ; do
rawDev=@{config.boot.initrd.luks.devices!catAttrSets.device[$luksName]}
primaryKey="$keystore"/luks/"$luksName"/0.key
( PATH=@{native.cryptsetup}/bin ; set -x ; cryptsetup --batch-mode luksOpen --key-file="$primaryKey" "$rawDev" "$luksName" ) &&
prepend_trap "@{native.cryptsetup}/bin/cryptsetup close $luksName" EXIT
done
}

View File

@ -4,25 +4,30 @@
##
## On the host and for the user it is called by, creates/registers a VirtualBox VM meant to run the shells target host. Requires the path to the target host's »diskImage« as the result of running the install script. The image file may not be deleted or moved. If »bridgeTo« is set (to a host interface name, e.g. as »eth0«), it is added as bridged network "Adapter 2" (which some hosts need).
function register-vbox {( set -eu # 1: diskImage, 2?: bridgeTo
diskImage=$1 ; bridgeTo=${2:-}
function register-vbox {( set -eu # 1: diskImages, 2?: bridgeTo
diskImages=$1 ; bridgeTo=${2:-}
vmName="nixos-@{config.networking.hostName}"
VBoxManage=$( PATH=$hostPath which VBoxManage ) # The host is supposed to run these anyway, and »pkgs.virtualbox« is marked broken on »aarch64«.
if [[ ! -e $diskImage.vmdk ]] ; then
VBoxManage internalcommands createrawvmdk -filename $diskImage.vmdk -rawdisk $diskImage # pass-through
fi
$VBoxManage createvm --name "$vmName" --register --ostype Linux26_64
$VBoxManage modifyvm "$vmName" --memory 2048 --pae off --firmware efi
VBoxManage createvm --name "$vmName" --register --ostype Linux26_64
VBoxManage modifyvm "$vmName" --memory 2048 --pae off --firmware efi
$VBoxManage storagectl "$vmName" --name SATA --add sata --portcount 4 --bootable on --hostiocache on
VBoxManage storagectl "$vmName" --name SATA --add sata --portcount 4 --bootable on --hostiocache on
VBoxManage storageattach "$vmName" --storagectl SATA --port 0 --device 0 --type hdd --medium $diskImage.vmdk
index=0 ; for decl in ${diskImages//:/ } ; do
diskImage=${decl/*=/}
if [[ ! -e $diskImage.vmdk ]] ; then
$VBoxManage internalcommands createrawvmdk -filename $diskImage.vmdk -rawdisk $diskImage # pass-through
fi
$VBoxManage storageattach "$vmName" --storagectl SATA --port $(( index++ )) --device 0 --type hdd --medium $diskImage.vmdk
done
if [[ $bridgeTo ]] ; then # VBoxManage list bridgedifs
VBoxManage modifyvm "$vmName" --nic2 bridged --bridgeadapter2 $bridgeTo
$VBoxManage modifyvm "$vmName" --nic2 bridged --bridgeadapter2 $bridgeTo
fi
VBoxManage modifyvm "$vmName" --uart1 0x3F8 4 --uartmode1 server /run/user/$(id -u)/$vmName.socket # (guest sets speed)
# TODO: The serial settings between qemu and vBox seem incompatible. With a simple »console=ttyS0«, vBox hangs on start. So just disable this for now an use qemu for headless setups. The UX here is awful anyway.
#$VBoxManage modifyvm "$vmName" --uart1 0x3F8 4 --uartmode1 server /run/user/$(id -u)/$vmName.socket # (guest sets speed)
set +x # avoid double-echoing
echo '# VM info:'
@ -31,10 +36,73 @@ function register-vbox {( set -eu # 1: diskImage, 2?: bridgeTo
echo " VBoxManage startvm $vmName --type headless"
echo '# kill VM:'
echo " VBoxManage controlvm $vmName poweroff"
echo '# create TTY:'
echo " socat UNIX-CONNECT:/run/user/$(id -u)/$vmName.socket PTY,link=/run/user/$(id -u)/$vmName.pty"
echo '# connect TTY:'
echo " screen /run/user/$(id -u)/$vmName.pty"
#echo '# create TTY:'
#echo " socat UNIX-CONNECT:/run/user/$(id -u)/$vmName.socket PTY,link=/run/user/$(id -u)/$vmName.pty"
#echo '# connect TTY:'
#echo " screen /run/user/$(id -u)/$vmName.pty"
echo '# screenshot:'
echo " ssh $(hostname) VBoxManage controlvm $vmName screenshotpng /dev/stdout | display"
echo " ssh $(@{native.inetutils}/bin/hostname) VBoxManage controlvm $vmName screenshotpng /dev/stdout | display"
)}
## Runs a host in QEMU, taking the same disk specification as the installer. It infers a number of options from he target system's configuration.
# Currently, this only works for x64 (on x64) ...
function run-qemu {( set -eu # 1: diskImages
generic-arg-parse "$@"
diskImages=${argv[0]}
if [[ ${args[debug]:-} ]] ; then set -x ; fi
qemu=( @{native.qemu_full}/bin/qemu-system-@{config.preface.hardware} )
qemu+=( -m ${args[mem]:-2048} -smp ${args[smp]:-4} )
if [[ @{config.preface.hardware}-linux == "@{native.system}" && ! ${args[no-kvm]:-} ]] ; then
qemu+=( -cpu host -enable-kvm ) # For KVM to work vBox may not be running anything at the same time (and vBox hangs on start if qemu runs). Pass »--no-kvm« and accept ~10x slowdown, or stop vBox.
elif [[ @{config.preface.hardware} == aarch64 ]] ; then # assume it's a raspberry PI (or compatible)
# TODO: this does not work yet:
qemu+=( -machine type=raspi3b -m 1024 ) ; args[no-nat]=1
# ... and neither does this:
#qemu+=( -M virt -m 1024 -smp 4 -cpu cortex-a53 ) ; args[no-nat]=1
fi # else things are going to be quite slow
for decl in ${diskImages//:/ } ; do
qemu+=( -drive format=raw,file="${decl/*=/}" ) #,if=none,index=0,media=disk,id=disk0 -device "virtio-blk-pci,drive=disk0,disable-modern=on,disable-legacy=off" )
done
if [[ @{config.boot.loader.systemd-boot.enable} || ${args[efi]:-} ]] ; then
qemu+=( -bios @{pkgs.OVMF.fd}/FV/OVMF.fd ) # UEFI. Otherwise it boots something much like a classic BIOS?
fi
if [[ @{config.preface.hardware} == aarch64 ]] ; then
qemu+=( -kernel @{config.system.build.kernel}/Image -initrd @{config.system.build.initialRamdisk}/initrd -append "$(echo -n "@{config.boot.kernelParams[@]}")" )
fi
for param in "@{config.boot.kernelParams[@]}" ; do if [[ $param == 'console=ttyS0' || $param == 'console=ttyS0',* ]] ; then
qemu+=( -nographic ) # »-nographic« by default only shows output once th system reaches the login prompt. Add »config.boot.kernelParams = [ "console=tty1" "console=ttyS0" ]« to log to serial (»-nographic«) and the display (if there is one), preferring the last »console« option for the initrd shell (if enabled and requested).
fi ; done
if [[ ! ${args[no-nat]:-} ]] ; then
qemu+=( -nic user,model=virtio-net-pci ) # NATed, IPs: 10.0.2.15+/32, gateway: 10.0.2.2
fi
# TODO: network bridging:
#[[ @{config.networking.hostId} =~ ^(.)(.)(.)(.)(.)(.)(.)(.)$ ]] ; mac=$( printf "52:54:%s%s:%s%s:%s%s:%s%s" "${BASH_REMATCH[@]:1}" )
#qemu+=( -netdev bridge,id=enp0s3,macaddr=$mac -device virtio-net-pci,netdev=hn0,id=nic1 )
# To pass a USB device (e.g. a YubiKey for unlocking), add pass »--usb-port=${bus}-${port}«, where bus and port refer to the physical USB port »/sys/bus/usb/devices/${bus}-${port}« (see »lsusb -tvv«). E.g.: »--usb-port=3-1.1.1.4«
if [[ ${args[usb-port]:-} ]] ; then for decl in ${args[usb-port]//:/ } ; do
qemu+=( -usb -device usb-host,hostbus="${decl/-*/}",hostport="${decl/*-/}" )
done ; fi
( set -x ; "${qemu[@]}" )
# https://askubuntu.com/questions/54814/how-can-i-ctrl-alt-f-to-get-to-a-tty-in-a-qemu-session
)}
## Creates a random static key on a new key partition on the GPT partitioned »$blockDev«. The drive can then be used as headless but removable disk unlock method.
# To create/clear the GPT: $ sgdisk --zap-all "$blockDev"
function add-bootkey-to-keydev {( set -eu # 1: blockDev, 2?: hostHash
blockDev=$1 ; hostHash=${2:-@{config.networking.hostName!hashString.sha256}}
bootkeyPartlabel=bootkey-${hostHash:0:8}
@{native.gptfdisk}/bin/sgdisk --new=0:0:+1 --change-name=0:"$bootkeyPartlabel" --typecode=0:0000 "$blockDev" # create new 1 sector (512b) partition
@{native.parted}/bin/partprobe "$blockDev" ; @{native.systemd}/bin/udevadm settle -t 15 # wait for partitions to update
</dev/urandom tr -dc 0-9a-f | head -c 512 >/dev/disk/by-partlabel/"$bootkeyPartlabel"
)}

View File

@ -3,6 +3,22 @@
# Utilities
##
## Performs a simple and generic parsing of CLI arguments. Creates a global associative array »args« and a global normal array »argv«.
# Named options may be passed as »--name[=value]«, where »value« defaults to »1«, and are assigned to »args«.
# Everything else, or everything following the »--« argument, ends up as positional arguments in »argv«.
# Checking the validity of the parsed arguments is up to the caller.
function generic-arg-parse { # ...
declare -g -A args=( ) ; declare -g -a argv=( ) # this ends up in the caller's scope
while (( "$#" )) ; do
if [[ $1 == -- ]] ; then shift ; argv+=( "$@" ) ; return ; fi
if [[ $1 == --* ]] ; then
if [[ $1 == *=* ]] ; then
local key=${1/=*/} ; args[${key/--/}]=${1/$key=/}
else args[${1/--/}]=1 ; fi
else argv+=( "$1" ) ; fi
shift ; done
}
## Prepends a command to a trap. Especially useful fo define »finally« commands via »prepend_trap '<command>' EXIT«.
# NOTE: When calling this in a sub-shell whose parents already has traps installed, make sure to do »trap - trapName« first. On a new shell, this should be a no-op, but without it, the parent shell's traps will be added to the sub-shell as well (due to strange behavior of »trap -p« (in bash ~5.1.8)).
prepend_trap() { # 1: command, ...: trapNames
@ -15,3 +31,34 @@ prepend_trap() { # 1: command, ...: trapNames
)" "${name}" || fatal "unable to add to trap ${name}"
done
} ; declare -f -t prepend_trap # required to modify DEBUG or RETURN traps
## Writes a »$name«d secret from stdin to »$targetDir«, ensuring proper file permissions.
function write-secret {( set -eu # 1: path, 2?: owner[:[group]], 3?: mode
mkdir -p -- "$(dirname "$1")"/
install -o root -g root -m 000 -T /dev/null "$1"
secret=$(tee "$1") # copy stdin to path without removing or adding anything
if [[ "${#secret}" == 0 ]] ; then echo "write-secret to $1 was empty!" 1>&2 ; exit 1 ; fi # could also stat the file ...
chown "${2:-root:root}" -- "$1"
chmod "${3:-400}" -- "$1"
)}
## Interactively prompts for a password to be entered and confirmed.
function prompt-new-password {( set -eu # 1: usage
usage=$1
read -s -p "Please enter the new password $usage: " password1 ; echo 1>&2
read -s -p "Please enter the same password again: " password2 ; echo 1>&2
if (( ${#password1} == 0 )) || [[ "$password1" != "$password2" ]] ; then printf 'Passwords empty or mismatch, aborting.\n' 1>&2 ; exit 1 ; fi
printf %s "$password1"
)}
## Runs an installer hook script, optionally stepping through the script.
function run-hook-script {( set -eu # 1: title, 2: scriptPath
trap - EXIT # start with empty traps for sub-shell
if [[ ${args[inspectScripts]:-} && "$(cat "$2")" != $'' ]] ; then
echo "Running $1 commands. For each command printed, press Enter to continue or Ctrl+C to abort the installation:"
# (this does not help against intentionally malicious scripts, it's quite easy to trick this)
BASH_PREV_COMMAND= ; set -o functrace ; trap 'if [[ $BASH_COMMAND != "$BASH_PREV_COMMAND" ]] ; then echo -n "> $BASH_COMMAND" >&2 ; read ; fi ; BASH_PREV_COMMAND=$BASH_COMMAND' debug
fi
source "$2"
)}

137
lib/setup-scripts/zfs.sh Normal file
View File

@ -0,0 +1,137 @@
# These functions have »pkgs.zfs« as undeclared dependency (so that they can alternatively use initramfs' »extraUtils«).
## Creates the system's ZFS pools and their datasets.
function create-zpools { # 1: mnt
local mnt=$1 ; local poolName ; for poolName in "@{!config.wip.fs.zfs.pools[@]}" ; do ( set -eu
eval 'declare -A pool='"@{config.wip.fs.zfs.pools[$poolName]}"
eval 'declare -a vdevs='"${pool[vdevArgs]}"
eval 'declare -A poolProps='"${pool[props]}"
eval 'declare -A dataset='"@{config.wip.fs.zfs.datasets[${pool[name]}]}"
eval 'declare -A dataProps='"${dataset[props]}"
get-zfs-crypt-props "${dataset[name]}" dataProps
declare -a args=( )
for name in "${!poolProps[@]}" ; do args+=( -o "${name}=${poolProps[$name]}" ) ; done
for name in "${!dataProps[@]}" ; do args+=( -O "${name}=${dataProps[$name]}" ) ; done
for index in "${!vdevs[@]}" ; do
part=${vdevs[$index]} ; if [[ $part =~ ^(mirror|raidz[123]?|draid[123]?.*|spare|log|dedup|special|cache)$ ]] ; then continue ; fi
if [[ @{config.boot.initrd.luks.devices!catAttrSets.device[$part]:-} ]] ; then
vdevs[$index]=/dev/mapper/$part
else
part=/dev/disk/by-partlabel/$part ; vdevs[$index]=$part
if ! is-partition-on-disks "$part" "${blockDevs[@]}" ; then echo "Partition alias $part used by zpool ${pool[name]} does not point at one of the target disks ${blockDevs[@]}" ; exit 1 ; fi
fi
done
( set -x ; zpool create "${args[@]}" -R "$mnt" "${pool[name]}" "${vdevs[@]}" )
) && {
prepend_trap "zpool export '$poolName'" EXIT
} ; done &&
ensure-datasets $mnt
}
## Ensures that the system's datasets exist and have the defined properties (but not that they don't have properties that aren't defined).
# The pool(s) must exist, be imported with root prefix »$mnt«, and (if datasets are to be created or encryption roots to be inherited) the system's keystore must be open (see »mount-keystore-luks«).
# »keystatus« and »mounted« of existing datasets should remain unchained, newly crated datasets will not be mounted but have their keys loaded.
function ensure-datasets {( set -eu # 1: mnt, 2?: filterExp
if (( @{#config.wip.fs.zfs.datasets[@]} == 0 )) ; then return ; fi
mnt=$1 ; while [[ "$mnt" == */ ]] ; do mnt=${mnt:0:(-1)} ; done # (remove any tailing slashes)
filterExp=${2:-'^'}
tmpMnt=$(mktemp -d) ; trap "rmdir $tmpMnt" EXIT
: 'Step-through is very verbose and breaks the loop, disabling it for this function' ; trap - debug
printf '%s\0' "@{!config.wip.fs.zfs.datasets[@]}" | LC_ALL=C sort -z | while IFS= read -r -d $'\0' name ; do
if [[ ! $name =~ $filterExp ]] ; then printf 'Skipping dataset »%s« since it does not match »%s«\n' "$name" "$filterExp" >&2 ; continue ; fi
eval 'declare -A dataset='"@{config.wip.fs.zfs.datasets[$name]}"
eval 'declare -A props='"${dataset[props]}"
explicitKeylocation=${props[keylocation]:-}
get-zfs-crypt-props "${dataset[name]}" props cryptKey cryptRoot
if zfs get -o value -H name "${dataset[name]}" &>/dev/null ; then # dataset exists: check its properties
if [[ ${props[mountpoint]:-} ]] ; then # don't set the current mount point again (no-op), cuz that fails if the dataset is mounted
current=$(zfs get -o value -H mountpoint "${dataset[name]}") ; current=${current/$mnt/}
if [[ ${props[mountpoint]} == "${current:-/}" ]] ; then unset props[mountpoint] ; fi
fi
if [[ ${props[keyformat]:-} == ephemeral ]] ; then
cryptRoot=${dataset[name]} ; unset props[keyformat] ; props[keylocation]=file:///dev/null
fi
unset props[encryption] ; unset props[keyformat] # can't change these anyway
names=$(IFS=, ; echo "${!props[*]}") ; values=$(IFS=$'\n' ; echo "${props[*]}")
if [[ $values != "$(zfs get -o value -H "$names" "${dataset[name]}")" ]] ; then (
declare -a args=( ) ; for name in "${!props[@]}" ; do args+=( "${name}=${props[$name]}" ) ; done
( set -x ; zfs set "${args[@]}" "${dataset[name]}" )
) ; fi
if [[ $cryptRoot && $(zfs get -o value -H encryptionroot "${dataset[name]}") != "$cryptRoot" ]] ; then ( # inherit key from parent (which the parent would also already have done if necessary)
parent=$(dirname "${dataset[name]}")
if [[ $(zfs get -o value -H keystatus "$parent") != available ]] ; then
zfs load-key -L file://"$cryptKey" "$parent" ; trap "zfs unload-key $parent || true" EXIT
fi
if [[ $(zfs get -o value -H keystatus "${dataset[name]}") != available ]] ; then
zfs load-key -L file://"$cryptKey" "${dataset[name]}" # will unload with parent
fi
( set -x ; zfs change-key -i "${dataset[name]}" )
) ; fi
else # create dataset
if [[ ${props[keyformat]:-} == ephemeral ]] ; then
props[encryption]=aes-256-gcm ; props[keyformat]=hex ; props[keylocation]=file:///dev/stdin ; explicitKeylocation=file:///dev/null
declare -a args=( ) ; for name in "${!props[@]}" ; do args+=( -o "${name}=${props[$name]}" ) ; done
</dev/urandom tr -dc 0-9a-f | head -c 64 | ( set -x ; zfs create "${args[@]}" "${dataset[name]}" )
zfs unload-key "${dataset[name]}"
else (
# TODO: if [[ $cryptRoot && $(zfs get -o value -H keystatus "$cryptRoot") != available ]] ; then ... load key while in this block ...
declare -a args=( ) ; for name in "${!props[@]}" ; do args+=( -o "${name}=${props[$name]}" ) ; done
( set -x ; zfs create "${args[@]}" "${dataset[name]}" )
) ; fi
if [[ ${props[canmount]} != off ]] ; then (
mount -t zfs -o zfsutil "${dataset[name]}" $tmpMnt ; trap "umount '${dataset[name]}'" EXIT
chmod 000 "$tmpMnt" ; ( chown "${dataset[uid]}:${dataset[gid]}" -- "$tmpMnt" ; chmod "${dataset[mode]}" -- "$tmpMnt" )
) ; fi
if [[ $explicitKeylocation && $explicitKeylocation != "${props[keylocation]:-}" ]] ; then
( set -x ; zfs set keylocation="$explicitKeylocation" "${dataset[name]}" )
fi
zfs snapshot -r "${dataset[name]}"@empty
fi
eval 'declare -A allows='"${dataset[permissions]}"
for who in "${!allows[@]}" ; do
# »zfs allow $dataset« seems to be the only way to view permissions, and that is not very parsable -.-
( set -x ; zfs allow -$who "${allows[$who]}" "${dataset[name]}" >&2 )
done
done
)}
## TODO
function get-zfs-crypt-props { # 1: datasetPath, 2: name_cryptProps, 3: name_cryptKey, 4: name_cryptRoot
local hash=@{config.networking.hostName!hashString.sha256:0:8}
local keystore=/run/keystore-$hash
local -n __cryptProps=${2:-props} ; local -n __cryptKey=${3:-cryptKey} ; local -n __cryptRoot=${4:-cryptRoot}
local name=$1 ; {
if [[ $name == */* ]] ; then local pool=${name/\/*/}/ ; local path=/${name/$pool/} ; else local pool=$name/ ; local path= ; fi
} ; local key=${pool/-$hash'/'/}$path # strip hash from pool name
__cryptKey='' ; __cryptRoot=''
if [[ @{config.wip.fs.keystore.keys[zfs/$name]:-} ]] ; then
if [[ @{config.wip.fs.keystore.keys[zfs/$name]} == unencrypted ]] ; then
__cryptProps[encryption]=off # empty key to disable encryption
else
__cryptProps[encryption]=aes-256-gcm ; __cryptProps[keyformat]=hex ; __cryptProps[keylocation]=file://"$keystore"/zfs/"$name".key
__cryptKey=$keystore/zfs/$name.key ; __cryptRoot=$name
fi
else
while true ; do
name=$(dirname $name) ; if [[ $name == . ]] ; then break ; fi
if [[ @{config.wip.fs.keystore.keys[zfs/$name]:-} ]] ; then
if [[ @{config.wip.fs.keystore.keys[zfs/$name]} != unencrypted ]] ; then
__cryptKey=$keystore/zfs/$name.key ; __cryptRoot=$name
fi ; break
fi
done
fi
}