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 1760 additions and 218 deletions

78
.vscode/settings.json vendored
View File

@ -2,34 +2,53 @@
"cSpell.diagnosticLevel": "Information", // to find spelling mistakes
"cSpell.words": [
"aarch64", // processor architecture
"acltype", // zfs
"ashift", // zfs
"askpass", // program
"attrset", "attrsets", // nix/abbr (attribute set)
"autologin", // agetty
"binfmt", // abbr "binary format"
"blkid", // program / function
"blockdev", // program / function
"bootable", // word (~= able to boot)
"bootkey", // partition name
"bootloader", // word
"bridgeadapter", // virtual box
"bridgedifs", // virtual box
"builtins", // nix
"cachefile", // zfs
"canmount", // zfs
"checksumming", // word
"cmake", // program
"cmds", // abbr commands
"cmds", // abbr (commands)
"compress_chksum", // f2fs
"concat", // abbr
"controlvm", // virtual box
"convertfromraw", // virtual box
"coreutils", // package
"createrawvmdk", // virtual box
"createvm", // virtual box
"cryptsetup", // program
"ctxify", // abbr (~= add context)
"CWD", // abbr
"dedup", // zfs
"deps", // abbr dependencies
"devs", // abbr (devices)
"dnodesize", // zfs
"dontUnpack", // nixos
"dosfstools", // package
"draid", // zfs
"dropbear", // program
"e2fsprogs", // package
"elif", // abbr (else if)
"encryptionroot", // zfs
"extglob", // cli arg
"fallocate", // program / function
"fdisk", // program
"fetchpatch", // nix
"fetchurl", // nix function
"filesystems", // plural
"findutils", // package
"foldl", // nix (fold left)
"foldr", // nix (fold right)
"FUNCNAME", // bash var
@ -39,38 +58,76 @@
"getsize64", // cli arg
"getty", // serice
"gids", // abbr/plural (group IDs)
"gnugrep", // package
"gnused", // package
"gollenstede", // name
"gptfdisk", // package
"headlessly", // word
"hostbus", // cli arg
"hostiocache", // virtual box
"hostport", // cli arg
"inetutils", // package
"inodes", // plural
"internalcommands", // virtual box
"ints", // plural
"keydev", // cli arg
"keyformat", // zfs
"keylocation", // zfs
"keystatus", // zfs
"lazytime", // f2fs
"libubootenv", // package
"logbias", // zfs
"losetup", // program / function
"lowerdir", // mount overlay option
"lsusb", // program / function
"luks", // linux
"macaddr", // cli arg
"mkdir", // program / function
"mktemp", // program / function
"modifyvm", // virtual box
"mountpoint", // program / function
"namespacing", // word
"netbootxyz", // option
"netdev", // cli arg
"niklas", // name
"nixos", // (duh)
"nixpkgs", // nix
"noatime", // mount option
"noauto", // mount option
"nodev", // mount option
"nodiratime", // mount option
"nodiscard", // cli arg
"noexec", // mount option
"nofail", // cli arg
"nographic", // cli arg
"nosuid", // mount option
"oneshot", // systemd
"ostype", // virtual box
"OVMF", // package
"partlabel", // linux
"partprobe", // program / function
"pbkdf", // cli arg
"pkgs", // nix
"pname", // nix/abbr (package name)
"portcount", // virtual box
"posix", // word
"posixacl", // zfs
"poweroff", // program / function
"raidz", // zfs
"ramdisk", // word
"ramfs", // linux
"raspberrypi", // abbr
"raspberrypifw", // package
"raspi3b", // cli arg
"rawdisk", // virtual box
"realpath", // program / function
"refreservation", // zfs
"relatime", // mount option
"rpool", // zfs
"sata", // storage protocol
"sbabic", // name
"screenshotpng", // virtual box
"setuid", // cli arg
"sgdisk", // program
"showvminfo", // virtual box
"sigs", // cli arg
@ -78,21 +135,40 @@
"startvm", // virtual box
"stdenv", // nix
"storageattach", // virtual box
"stty", // program / function
"syncoid", // program
"temproot", // abbr (temporary root (FS))
"timesync", // systemd
"TMPDIR", // env var
"tmpfiles", // nixos option
"tmpfs", // linux
"toplevel", // nix
"typecode", // cli arg
"uart", "uarts", // serial protocol
"uartmode", // virtual box
"udev", // program
"udevadm", // program
"udptunnel", // program
"uids", // abbr/plural (group IDs)
"unencrypted", // ~= not encrypted / decrypted
"upperdir", // mount overlay option
"urandom", // linux
"vboxusers", // virtual box
"vdev", "vdevs", // zfs
"vfat", // linux
"virt", // abbr (virtualization)
"virtio", // cli arg
"vmdk", // file type (virtual disk format)
"wipefs", // program
"wiplib", // name / abbr (WIP library)
"workdir", // mount overlay option
"xattr", // zfs
"xfsprogs", // package
"ykchalresp", // program
"ykinfo", // program
"yubikey", // program
"yubikey", // program
"YubiKeys", // plural
"zfsutil", // program / function
]
}

View File

@ -1,7 +1,7 @@
# Work In Progress Nix(OS) Library
Whenever I have a Nix function, NixOS Module, nixpkgs package/overlay, related bash script, or combination of those that I need in more than one project, I first put it here so that it can be shared between them.
The idea of this repo / flake is that whenever I have a Nix function, NixOS Module, nixpkgs package/overlay, related bash script, or combination of those that I need in more than one project, I first put it here so that it can be shared between them.
Eventually I may decide to move parts of this into their own flake repository, but as long as they live here, APIs are not necessarily stable.
@ -13,7 +13,7 @@ The more interesting of the components currently in this repository are largely
This is a nix flake repository, so [`flake.nix`](./flake.nix) is the entry point and export mechanism for almost everything.
[`lib/`](./lib/) adds additional library functions as `.wip` to the default `nixpkgs.lib` and exports the whole thing as `lib`. Other folders in this repo may thus use them as `inputs.self.lib.wip.*`. \
[`lib/setup-scripts/`](./lib/setup-scripts/) contains some bash scripts that integrate with the options defined in [modules/installer.nix.md](./modules/installer.nix.md) and some default options to help installing NixOS hosts.
[`lib/setup-scripts/`](./lib/setup-scripts/) contains bash scripts that integrate with the options defined in [`modules/fs/`](./modules/fs/) (esp. [`modules/fs/disks.nix.md`](./modules/fs/disks.nix.md)) and some default options to do flexible and fully automated installations of configured NixOS hosts.
[`modules/`](./modules/) contains NixOS configuration modules. Added options' names start with `wip.` (or a custom prefix, see [Namespacing](#namespacing-in-nixos)).
The modules are inactive by default, and are, where possible, designed to be independent from each other and the other things in this repo. Some though do have dependencies on added or modified packages, or other modules in the same directory.
@ -28,7 +28,7 @@ The modules are inactive by default, and are, where possible, designed to be ind
Any `preface.*` options have to be set in the first sub-module in these files (`## Hardware` section). \
This flake only defines a single [`example`](./hosts/example.nix.md) host meant to demonstrate how other flakes can use the (NixOS) flake library framework.
[`utils/`](./utils/) contains the [installation](./utils/install.sh.md) script for the hosts (which is three lines bash, plus a lot of documentation) and this flake's [default config](./utils/defaultConfig/) (see [Namespacing](#namespacing-in-nixos)).
[`example/`](./example/) contains an example of adjusting the [installation](./example/install.sh.md) script for the hosts and this flake's [default config](./example/defaultConfig/) (see [Namespacing](#namespacing-in-nixos)).
## Namespacing in NixOS
@ -49,7 +49,7 @@ The only workaround (that I could come up with) is to have a flake-level option
Since flakes are purely functional, the only way to provide configuration to a flake as a whole (as opposed to exporting parts of the flake as functions, which would break the convention on flake exports) is via the flakes `inputs`, and those inputs must be flakes themselves.
The inputs have defaults defined by the flake itself, but can be overridden by the importing flake.
A flake using the modules exported by this flake may thus accept the default that all options are defined under the prefix `wip.`, or it may override its `config` input by a flake of the same shape as [`utils/defaultConfig/`](./utils/defaultConfig/) but with a different `prefix`.
A flake using the modules exported by this flake may thus accept the default that all options are defined under the prefix `wip.`, or it may override its `config` input by a flake of the same shape as [`example/defaultConfig/`](./example/defaultConfig/) but with a different `prefix`.
As a local experiment, the result of running this in a `nix repl` is sufficient:
```nix
:b (import <nixpkgs> { }).writeTextDir "flake.nix" ''

View File

@ -3,14 +3,20 @@
# System Installer Script
This is a minimal example for a NixOS system installation function using the functions defined in [`../lib/setup-scripts/`](../lib/setup-scripts/). See its [README](../lib/setup-scripts/README.md) for more documentation.
This is a minimal example for an adjusted NixOS system installation using the functions defined in [`../lib/setup-scripts/`](../lib/setup-scripts/).
See its [README](../lib/setup-scripts/README.md) for more documentation.
## Implementation
## (Example) Implementation
```bash
function install-system {( set -eu # 1: blockDev
# Replace the entry point with the same function:
function install-system {( set -eu # 1: blockDevs
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
)}
# ... could also replace any other function(s) ...

Binary file not shown.

View File

@ -7,8 +7,8 @@
); inputs = {
# To update »./flake.lock«: $ nix flake update
nixpkgs = { url = "github:NixOS/nixpkgs/nixos-unstable"; };
config = { type = "github"; owner = "NiklasGollenstede"; repo = "nix-wiplib"; dir = "example/defaultConfig"; };
nixpkgs = { url = "github:NixOS/nixpkgs/nixos-22.05"; };
config = { type = "github"; owner = "NiklasGollenstede"; repo = "nix-wiplib"; dir = "example/defaultConfig"; rev = "5e9cc7ce3440be9ce6aeeaedcc70db9c80489c5f"; }; # Use some previous commit's »./example/defaultConfig/flake.nix« as the default config for this flake.
}; outputs = inputs: let patches = {
@ -20,9 +20,9 @@
}; in (import "${./.}/lib/flakes.nix" "${./.}/lib" inputs).patchFlakeInputsAndImportRepo inputs patches ./. (inputs@ { self, nixpkgs, ... }: repo@{ overlays, lib, ... }: let
systemsFlake = lib.wip.mkSystemsFlake (rec {
#systems = { dir = "${./.}/hosts"; exclude = [ ]; };
#systems = { dir = "${inputs.self.outPath}/hosts"; exclude = [ ]; };
inherit inputs;
scripts = [ ./example/install.sh.md ] ++ (lib.attrValues lib.wip.setup-scripts);
scripts = (lib.attrValues lib.wip.setup-scripts) ++ [ ./example/install.sh.md ];
});
in [ # Run »nix flake show --allow-import-from-derivation« to see what this merges to:
@ -32,5 +32,5 @@ in [ # Run »nix flake show --allow-import-from-derivation« to see what this me
packages = lib.wip.getModifiedPackages (lib.wip.importPkgs inputs { system = localSystem; }) overlays;
defaultPackage = systemsFlake.packages.${localSystem}.all-systems;
}))
{ patches = import "${./.}/patches" "${./.}/patches" inputs; }
{ patches = import "${inputs.self.outPath}/patches" "${inputs.self.outPath}/patches" inputs; }
]); }

View File

@ -11,11 +11,15 @@ To prepare a virtual machine disk, as `sudo` user with `nix` installed, run in `
```bash
nix run '.#example' -- sudo install-system /home/$(id -un)/vm/disks/example.img && sudo chown $(id -un): /home/$(id -un)/vm/disks/example.img
```
Then as the user that is supposed to run the VM(s):
Then to run in a qemu VM with KVM:
```bash
nix run '.#example' -- sudo run-qemu /home/$(id -un)/vm/disks/example.img
```
Or as user with vBox access run this and use the UI or the printed commands:
```bash
nix run '.#example' -- register-vbox /home/$(id -un)/vm/disks/example.img
```
And manage the VM(s) using the UI or the commands printed.
Alternative to running directly as `root` (esp. if `nix` is not installed for root), the above commands can also be run with `sudo` as additional argument before the `--`.
## Implementation
@ -26,7 +30,7 @@ dirname: inputs: { config, pkgs, lib, name, ... }: let inherit (inputs.self) lib
#suffix = builtins.head (builtins.match ''example-(.*)'' name); # make differences in config based on this when using »preface.instances«
hash = builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName);
in { imports = [ ({ ## Hardware
#preface.instances = [ "example-a" "example-b" "example-c" ];
preface.instances = [ "example" "example-raidz" ];
preface.hardware = "x86_64"; system.stateVersion = "22.05";
@ -34,10 +38,13 @@ in { imports = [ ({ ## Hardware
boot.loader.systemd-boot.enable = true; boot.loader.grub.enable = false;
}) (lib.mkIf false { ## Minimal explicit FS setup
# Declare a boot and system partition. Though not required for EFI, make the boot part visible to boot loaders supporting only MBR.
wip.installer.partitions."boot-${hash}" = { type = "ef00"; size = "64M"; index = 1; order = 1500; };
wip.installer.partitions."system-${hash}" = { type = "8300"; size = null; order = 500; };
wip.installer.disks = { primary = { mbrParts = "1"; extraFDiskCommands = ''
wip.fs.disks.partitions."boot-${hash}" = { type = "ef00"; size = "64M"; index = 1; order = 1500; };
wip.fs.disks.partitions."system-${hash}" = { type = "8300"; size = null; order = 500; };
wip.fs.disks.devices = { primary = { mbrParts = "1"; extraFDiskCommands = ''
t;1;c # type ; part1 ; W95 FAT32 (LBA)
a;1 # active/boot ; part1
''; }; };
@ -48,15 +55,56 @@ in { imports = [ ({ ## Hardware
fileSystems."/system" = { fsType = "ext4"; device = "/dev/disk/by-partlabel/system-${hash}"; neededForBoot = true; options = [ "noatime" ]; formatOptions = "-O inline_data -E nodiscard -F"; };
fileSystems."/nix/store" = { options = ["bind,ro"]; device = "/system/nix/store"; neededForBoot = true; };
}) (lib.mkIf (name == "example") { ## More complex but automatic FS setup
#wip.fs.disks.devices.primary.size = "16G"; # (default)
wip.fs.boot.enable = true; wip.fs.boot.size = "512M";
wip.fs.keystore.enable = true;
wip.fs.temproot.enable = true;
wip.fs.temproot.temp.type = "tmpfs";
wip.fs.temproot.local.type = "bind";
wip.fs.temproot.local.bind.base = "f2fs-encrypted"; # creates partition and FS
#wip.fs.keystore.keys."luks/local-${hash}/0" = "random"; # (implied by the »-encrypted« suffix above)
#wip.fs.disks.partitions."local-${hash}".size = "50%"; # (default)
wip.fs.temproot.remote.type = "zfs";
wip.fs.keystore.keys."luks/rpool-${hash}/0" = "random";
#wip.fs.keystore.keys."zfs/rpool-${hash}/remote" = "random"; # (default)
#wip.fs.zfs.pools."rpool-${hash}".vdevArgs = [ "rpool-${hash}" ]; # (default)
#wip.fs.disks.partitions."rpool-${hash}" = { type = "bf00"; size = null; order = 500; }; # (default)
wip.fs.temproot.local.mounts."/var/log" = lib.mkForce null; # example: don't keep logs
}) (lib.mkIf (name == "example-raidz") { ## Multi-disk ZFS setup
#wip.fs.disks.devices.primary.size = "16G"; # (default)
wip.fs.boot.enable = true; wip.fs.boot.size = "512M";
wip.fs.keystore.enable = true;
wip.fs.temproot.enable = true;
wip.fs.temproot.temp.type = "zfs";
wip.fs.temproot.local.type = "zfs";
wip.fs.temproot.remote.type = "zfs";
wip.fs.zfs.pools."rpool-${hash}".vdevArgs = [ "raidz1" "rpool-rz1-${hash}" "rpool-rz2-${hash}" "rpool-rz3-${hash}" "log" "rpool-zil-${hash}" "cache" "rpool-arc-${hash}" ];
wip.fs.disks.partitions."rpool-rz1-${hash}" = { type = "bf00"; disk = "raidz1"; };
wip.fs.disks.partitions."rpool-rz2-${hash}" = { type = "bf00"; disk = "raidz2"; };
wip.fs.disks.partitions."rpool-rz3-${hash}" = { type = "bf00"; disk = "raidz3"; };
wip.fs.disks.partitions."rpool-zil-${hash}" = { type = "bf00"; size = "2G"; };
wip.fs.disks.partitions."rpool-arc-${hash}" = { type = "bf00"; };
}) ({ ## Actual Config
# Some base config:
wip.base.enable = true; wip.base.includeNixpkgs = inputs.nixpkgs;
# Static config for VBox Adapter 1 set to NAT (the default):
networking.interfaces.enp0s3.ipv4.addresses = [ {
address = "10.0.2.15"; prefixLength = 24;
} ];
networking.defaultGateway = "10.0.2.2";
networking.nameservers = [ "1.1.1.1" ]; # [ "10.0.2.3" ];
documentation.enable = false; # sometimes takes quite long to build
}) ({ ## Actual Config
@ -67,7 +115,7 @@ in { imports = [ ({ ## Hardware
services.getty.autologinUser = "root"; users.users.root.password = "root";
boot.kernelParams = [ "boot.shell_on_fail" ];
boot.kernelParams = lib.mkForce [ "console=tty1" "console=ttyS0" "boot.shell_on_fail" ];
wip.services.dropbear.enable = true;
#wip.services.dropbear.rootKeys = [ ''${lib.readFile "${dirname}/....pub"}'' ];

View File

@ -27,7 +27,9 @@ in rec {
)) else input) inputs);
# Generates implicit flake outputs by importing conventional paths in the local repo.
importRepo = inputs: repoPath: outputs: let list = (outputs inputs ((if builtins.pathExists "${repoPath}/lib/default.nix" then {
importRepo = inputs: repoPath': outputs: let
repoPath = builtins.path { path = repoPath'; name = "source"; }; # referring to the current flake directory as »./.« is quite intuitive (and »inputs.self.outPath« causes infinite recursion), but without this it adds another hash to the path (because it copies it)
in let list = (outputs inputs ((if builtins.pathExists "${repoPath}/lib/default.nix" then {
lib = import "${repoPath}/lib" "${repoPath}/lib" inputs;
} else { }) // (if builtins.pathExists "${repoPath}/overlays/default.nix" then rec {
overlays = import "${repoPath}/overlays" "${repoPath}/overlays" inputs;
@ -151,7 +153,8 @@ in rec {
inherit nixosConfigurations;
} // (forEachSystem [ "aarch64-linux" "x86_64-linux" ] (localSystem: let
pkgs = (import inputs.nixpkgs { inherit overlays; system = localSystem; });
nix_wrapped = pkgs.writeShellScriptBin "nix" ''exec ${pkgs.nix}/bin/nix --extra-experimental-features nix-command "$@"'';
nix = if lib.versionOlder pkgs.nix.version "2.4" then pkgs.nix_2_4 else pkgs.nix;
nix_wrapped = pkgs.writeShellScriptBin "nix" ''exec ${nix}/bin/nix --extra-experimental-features nix-command "$@"'';
in (if scripts == [ ] then { } else {
# E.g.: $ nix run .#$target -- install-system /tmp/system-$target.img
@ -159,16 +162,15 @@ in rec {
# If the first argument (after »--«) is »sudo«, then the program will re-execute itself with sudo as root (minus that »sudo« argument).
# If the first/next argument is »bash«, it will execute an interactive shell with the variables and functions sourced (largely equivalent to »nix develop .#$host«).
apps = lib.mapAttrs (name: system: let
appliedScripts = substituteImplicit { inherit pkgs scripts; context = system; };
appliedScripts = substituteImplicit { inherit pkgs scripts; context = system // { native = pkgs; }; };
in { type = "app"; program = "${pkgs.writeShellScript "scripts-${name}" ''
# if first arg is »sudo«, re-execute this script with sudo (as root)
if [[ $1 == sudo ]] ; then shift ; exec sudo --preserve-env=SSH_AUTH_SOCK,debug -- "$0" "$@" ; fi
if [[ $1 == sudo ]] ; then shift ; exec sudo --preserve-env=SSH_AUTH_SOCK -- "$0" "$@" ; fi
# if the (now) first arg is »bash« or there are no args, re-execute this script as bash »--init-file«, starting an interactive bash in the context of the script
if [[ $1 == bash ]] || [[ $# == 0 && $0 == *-scripts-${name} ]] ; then
set -x
exec ${pkgs.bashInteractive}/bin/bash --init-file <(cat << "EOS"${"\n"+''
# prefix the script to also include the default init files
! [[ -e /etc/profile ]] || . /etc/profile
@ -182,7 +184,8 @@ in rec {
fi
# provide installer tools (native to localSystem, not targetSystem)
PATH=${pkgs.nixos-install-tools}/bin:${nix_wrapped}/bin:${pkgs.nix}/bin:$PATH
hostPath=$PATH
PATH=${pkgs.nixos-install-tools}/bin:${nix_wrapped}/bin:${nix}/bin:${pkgs.util-linux}/bin:${pkgs.coreutils}/bin:${pkgs.gnused}/bin:${pkgs.findutils}/bin:${pkgs.tree}/bin:${pkgs.zfs}/bin
${appliedScripts}
@ -196,7 +199,7 @@ in rec {
# ... and then call any of the functions in ./utils/functions.sh (in the context of »$(hostname)«, where applicable).
# To get an equivalent root shell: $ nix run /etc/nixos/#functions-$(hostname) -- sudo bash
devShells = lib.mapAttrs (name: system: pkgs.mkShell (let
appliedScripts = substituteImplicit { inherit pkgs scripts; context = system; };
appliedScripts = substituteImplicit { inherit pkgs scripts; context = system // { native = pkgs; }; };
in {
nativeBuildInputs = [ pkgs.nixos-install-tools nix_wrapped pkgs.nix ];
shellHook = ''

View File

@ -101,7 +101,7 @@ in rec {
moduleArgs = { utils = import "${specialArgs.inputs.nixpkgs.outPath}/nixos/lib/utils.nix" { inherit (specialArgs) lib config pkgs; }; } // specialArgs;
module = import fullPath moduleArgs;
in { _file = fullPath; imports = [
(mergeAttrsRecursive [ { imports = module.imports or [ ]; options = module.options or { }; config = module.config or { }; } (override module) ])
(mergeAttrsRecursive (([ { imports = module.imports or [ ]; options = module.options or { }; config = module.config or { }; } ]) ++ (lib.toList (override module))))
{ disabledModules = [ modulePath ]; }
]; };
}

View File

@ -3,64 +3,61 @@ dirname: { self, nixpkgs, ...}: let
in rec {
# Turns an attr set into a bash dictionary (associative array) declaration, e.g.:
# bashSnippet = "declare -A dict=(\n${asBashDict { } { foo = "42"; bar = "baz"; }}\n)"
# bashSnippet = "declare -A dict=(\n${asBashDict { } { foo = "42"; bar = "baz"; }})"
asBashDict = { mkName ? (n: v: n), mkValue ? (n: v: v), indent ? " ", ... }: attrs: (
builtins.concatStringsSep "\n" (lib.mapAttrsToList (name: value: (
"${indent}[${lib.escapeShellArg (mkName name value)}]=${lib.escapeShellArg (mkValue name value)}"
builtins.concatStringsSep "" (lib.mapAttrsToList (name: value: (
let key = mkName name value; in if key == null then "" else
"${indent}[${lib.escapeShellArg key}]=${lib.escapeShellArg (mkValue name value)}\n"
)) attrs)
);
# Turns an attrset into a string that can (safely) be bash-»eval«ed to declare the attributes (prefixed with a »_«) as variables into the current scope.
asBashEvalSet = recursive: attrs: builtins.concatStringsSep " ; " (lib.mapAttrsToList (name: value: (
"_${name}=${lib.escapeShellArg (if recursive && builtins.isAttrs value then asBashEvalSet true value else toString value)}"
)) attrs);
# Makes an attrset of attrsets eligible to be passed to »asBashDict«. The bash script can (safely) call »eval« on each first-level attribute value to get the second-level attributes (prefixed with a »_«) into the current variable scope.
# Meant primarily as a helper for »substituteLazy«.
attrsAsBashEvalSets = attrs: builtins.mapAttrs (name: asBashEvalSet true) attrs;
# Makes a list of attrsets eligible to be passed to »asBashDict«. The bash script can (safely) call »eval« on each list item to get the contained attributes (prefixed with a »_«) into the current variable scope.
# Meant primarily as a helper for »substituteLazy«.
listAsBashEvalSets = list: map (asBashEvalSet true) list;
# This function allows using nix values in bash scripts without passing an explicit and manually curated list of values to the script.
# Given a path list of bash script »sources« and an attrset »context«, the function parses the scripts for the literal sequence »@{« followed by a lookup path of period-joined words, resolves that attribute path against »context«, declares a variable with that value and swaps out the »@{« plus path for a »${« use of the declared variable. The returned script sources the variable definitions and all translated »sources« in order.
# The lookup path may end in »!« plus the name of a (single argument) »builtins.*« function,in which case the resolved value will be passed to that function and its return value is used instead (e.g. for »attrNames«, »attrValues«, »toJSON«, »catAttrs«, ...).
# Given a path list of bash script »sources« and an attrset »context«, this function parses the scripts for the literal sequence »@{« followed by a lookup path of period-joined words, resolves that attribute path against »context«, declares a variable with that value, and swaps out the »@{« plus path for a »${« use of the declared variable. The returned script sources the variable definitions and all translated »sources« in order.
# The lookup path may end in »!« plus the name of a function and optionally string arguments separated by ».«s, in which case the function is taken from »helpers//self.lib.wip//(pkgs.lib or lib)//pkgs//builtins« and called with the string args and the resolved value as last arg; the return value then replaces the resolved value. Examples: »!attrNames«, »!toJSON«, »!catAttrs«, »!hashString.sha256«.
# The names of the declared values are the lookup paths, with ».« and »!« replaced by »_« and »__«.
# The symbol immediately following the lookup path (/builtin name) can be »}« or any other symbol that bash variable substitutions allow after the variable name (like »:«, »/«), eliminating the need to assign to a local variable to do things like replacements, fallbacks or substrings.
# If the lookup path does not exist in »context«, then the value will be considered the same as »null«, and a value of »null« will result in a bash variable that is not defined (which can then be handled in the bash script).
# Other scalars (bool, float, int, path) will be passed to »builtins.toString«, Lists will be mapped with »toString« and declared as bash arrays, attribute sets will be declared using »asBashDict« with their values »toString«ed as well.
# Other scalars (bool, float, int, path) will be passed to »builtins.toString«. Anything that has an ».outPath« that is a string will be passed as that ».outPath«.
# Lists will be declared as bash arrays, attribute sets will be declared as associative arrays using »asBashDict«.
# Bash does not support any nested data structures. Lists or attrsets in within lists or attrsets are therefore (recursively) encoded and escaped as strings, such that calling »eval« on them is safe if (but only if) they are known to be encoded from nested lists/attrsets. Example: »eval 'declare -A fs='"@{config.fileSystems['/']}" ; root=${fs[device]}«.
# Any other value (functions), and things that »builtins.toString« doesn't like, will throw here.
substituteImplicit = args@{ pkgs, scripts, context, helpers ? { }, }: let
substituteImplicit = args@{ pkgs, scripts, context, helpers ? { }, trace ? (m: v: v), }: let
scripts = map (source: rec {
text = builtins.readFile source; inherit source;
parsed = builtins.split ''@\{([#!]?)([a-zA-Z][a-zA-Z0-9_.-]*[a-zA-Z0-9](![a-zA-Z][a-zA-Z0-9_.-]*[a-zA-Z0-9])?)([:*@\[#%/^,\}])'' text; # (first part of a bash parameter expansion, with »@« instead of »$«)
}) args.scripts;
}) (if builtins.isAttrs args.scripts then builtins.attrValues args.scripts else args.scripts);
decls = lib.unique (map (match: builtins.elemAt match 1) (builtins.filter builtins.isList (builtins.concatMap (script: script.parsed) scripts)));
vars = pkgs.writeText "vars" (lib.concatMapStringsSep "\n" (decl: let
vars = pkgs.writeText "vars" ("#!/usr/bin/env bash\n" + (lib.concatMapStringsSep "\n" (decl: let
call = let split = builtins.split "!" decl; in if (builtins.length split) == 1 then null else builtins.elemAt split 2;
path = (builtins.filter builtins.isString (builtins.split "[.]" (if call == null then decl else builtins.substring 0 ((builtins.stringLength decl) - (builtins.stringLength call) - 1) decl)));
resolved = lib.attrByPath path null context;
applied = if call == null || resolved == null then resolved else (let
split = builtins.filter builtins.isString (builtins.split "[.]" call); name = builtins.head split; args = builtins.tail split;
func = builtins.foldl' (func: arg: func arg) (helpers.${name} or self.lib.wip.${name} or (pkgs.lib or lib).${name} or pkgs.${name}) args;
func = builtins.foldl' (func: arg: func arg) (helpers.${name} or self.lib.wip.${name} or (pkgs.lib or lib).${name} or pkgs.${name} or builtins.${name}) args;
in func resolved);
value = if builtins.isString (applied.outPath or null) then applied.outPath else if (
(builtins.isBool applied) || (builtins.isFloat applied) || (builtins.isInt applied) || (builtins.isPath applied)
) then builtins.toString applied else applied;
name = builtins.replaceStrings [ "." "!" ] [ "_" "__" ] decl; #(builtins.trace decl decl);
in (
if (value == null) then ""
name = trace "substituteImplicit »${decl}« =>" (builtins.replaceStrings [ "." "!" "-" ] [ "_" "1" "0" ] decl);
toStringRecursive = value: if builtins.isString (value.outPath or null) then (
value.outPath
) else if builtins.isAttrs value then (
"(\n${asBashDict { mkName = name: value: if value == null then null else name; mkValue = name: toStringRecursive; } value})"
) else if (builtins.isList value) then (
"( ${lib.escapeShellArgs (map toStringRecursive value)} )"
) else (toString value);
in (let final = (
if (value == null) then "#${name}=null"
else if (builtins.isString value) then "${name}=${lib.escapeShellArg value}"
else if (builtins.isList value) then "${name}=(${lib.escapeShellArgs (map builtins.toString value)})"
else if (builtins.isAttrs value) then "declare -A ${name}=${"(\n${asBashDict { mkValue = name: builtins.toString; } value}\n)"}"
else if (builtins.isList value) then "${name}=${toStringRecursive value}"
else if (builtins.isAttrs value) then "declare -A ${name}=${toStringRecursive value}"
else throw "Can't use value of unsupported type ${builtins.typeOf} as substitution for ${decl}" # builtins.isFunction
)) decls);
); in trace final final)) decls));
in ''
source ${vars}
${lib.concatMapStringsSep "\n" (script: "source ${pkgs.writeScript (builtins.baseNameOf script.source) (
lib.concatMapStringsSep "" (seg: if builtins.isString seg then seg else (
"$"+"{"+(builtins.head seg)+(builtins.replaceStrings [ "." "!" ] [ "_" "__" ] (builtins.elemAt seg 1))+(toString (builtins.elemAt seg 3))
"$"+"{"+(builtins.head seg)+(builtins.replaceStrings [ "." "!" "-" ] [ "_" "1" "0" ] (builtins.elemAt seg 1))+(toString (builtins.elemAt seg 3))
)) script.parsed
)}") scripts}
'';
@ -94,7 +91,10 @@ in rec {
escapeUnitName = name: lib.concatMapStringsSep "" (s: if builtins.isList s then "-" else s) (builtins.split "[^a-zA-Z0-9_.\\-]+" name); # from nixos/modules/services/backup/syncoid.nix
pathToName = path: (builtins.replaceStrings [ "/" ":" ] [ "-" "-" ] path);
# (If »path« ends with »/«, then »path[0:-1]« is the closest "parent".)
parentPaths = path: let parent = builtins.dirOf path; in if parent == "." || parent == "/" then [ ] else (parentPaths parent) ++ [ parent ];
# Given a »path«, returns the list of all its parents, starting with »path« itself and ending with only its first segment.
# Examples: "/a/b/c" -> [ "/a/b/c" "/a/b" "/a" ] ; "x/y" -> [ "x/y" "x" ]
parentPaths = path: let
absolute = if lib.hasPrefix "/" path then 1 else 0; prefix = if absolute == 1 then "/" else "";
split = builtins.filter builtins.isString (builtins.split ''/'' (builtins.substring (absolute) ((builtins.stringLength path) - absolute - (if lib.hasSuffix "/" path then 1 else 0)) path));
in map (length: prefix + (builtins.concatStringsSep "/" (lib.take length split))) (lib.reverseList ((lib.range 1 (builtins.length split))));
}

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
}

View File

@ -7,7 +7,8 @@ in rec {
# Given a function and a list, calls the function for each list element, and returns the merge of all attr sets returned by the function
# attrs = mapMerge (value: { "${newKey}" = newValue; }) list
# attrs = mapMerge (key: value: { "${newKey}" = newValue; }) attrs
mapMerge = toAttr: listOrAttrs: mergeAttrs (if builtins.isAttrs listOrAttrs then lib.mapAttrsToList toAttr listOrAttrs else map toAttr listOrAttrs);
mapMerge = toAttr: listOrAttrs: mergeAttrs (if builtins.isAttrs listOrAttrs then lib.mapAttrsToList toAttr listOrAttrs else map toAttr listOrAttrs);
mapMergeUnique = toAttr: listOrAttrs: mergeAttrsUnique (if builtins.isAttrs listOrAttrs then lib.mapAttrsToList toAttr listOrAttrs else map toAttr listOrAttrs);
# Given a list of attribute sets, returns the merged set of all contained attributes, with those in elements with higher indices taking precedence.
mergeAttrs = attrsList: builtins.foldl' (a: b: a // b) { } attrsList;
@ -46,6 +47,9 @@ in rec {
${l2name} = mapMerge (l1name: if attrs.${l1name}?${l2name} then { ${l1name} = attrs.${l1name}.${l2name}; } else { }) l1names;
}) l2names;
# Like »builtins.catAttrs«, just for attribute sets instead of lists: Given an attribute set of attribute sets (»{ ${l1name}.${l2name} = value; }«) and the »name« of a second-level attribute, this returns the attribute set mapping directly from the first level's names to the second-level's values (»{ ${l1name} = value; }«), omitting any first-level attributes that lack the requested second-level attribute.
catAttrSets = name: attrs: (builtins.mapAttrs (_: value: value.${name}) (lib.filterAttrs (_: value: value?${name}) attrs));
## String Manipulation
@ -53,6 +57,7 @@ in rec {
mapMatching = exp: strings: (builtins.filter (v: v != null) (builtins.concatLists (builtins.filter (v: v != null) (map (string: (builtins.match exp string)) strings))));
# Given a regular expression and a list of strings, returns the list of all the strings matched in their entirety by the regular expression.
filterMatching = exp: strings: (builtins.filter (matches exp) strings);
filterMismatching = exp: strings: (builtins.filter (string: !(matches exp string)) strings);
matches = exp: string: builtins.match exp string != null;
extractChars = exp: string: let match = (builtins.match "^.*(${exp}).*$" string); in if match == null then null else builtins.head match;
@ -75,4 +80,11 @@ in rec {
toBinString = int: builtins.concatStringsSep "" (map builtins.toString (lib.toBaseDigits 2 int));
parseSizeSuffix = decl: let
match = builtins.match ''^([0-9]+)(K|M|G|T|P)?(i)?(B)?$'' decl;
num = lib.toInt (builtins.head match); unit = builtins.elemAt match 1;
exponent = if unit == null then 0 else { K = 1; M = 2; G = 3; t = 4; P = 5; }.${unit};
base = if (builtins.elemAt match 3) == null || (builtins.elemAt match 2) != null then 1024 else 1000;
in if builtins.isInt decl then decl else if match != null then num * (pow base exponent) else throw "${decl} is not a number followed by a size suffix";
}

View File

@ -27,7 +27,8 @@ in {
users.mutableUsers = false; users.allowNoPasswordLogin = true; # Don't babysit. Can roll back or redeploy.
networking.hostId = lib.mkDefault (builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName));
environment.etc."machine-id".text = (builtins.substring 0 32 (builtins.hashString "sha256" "${config.networking.hostName}:machine-id")); # this works, but it "should be considered "confidential", and must not be exposed in untrusted environments" (not sure _why_ though)
environment.etc."machine-id".text = lib.mkDefault (builtins.substring 0 32 (builtins.hashString "sha256" "${config.networking.hostName}:machine-id")); # this works, but it "should be considered "confidential", and must not be exposed in untrusted environments" (not sure _why_ though)
documentation.man.enable = lib.mkDefault config.documentation.enable;
}) ({
@ -48,27 +49,33 @@ in {
system.extraSystemBuilderCmds = (if !specialArgs?inputs && !specialArgs.inputs?self then "" else ''
ln -sT ${specialArgs.inputs.self.outPath} $out/config # (build input for reference)
'');
environment.systemPackages = [ pkgs.git ]; # necessary as external dependency when working with flakes
}) ({
# Free convenience:
# The non-interactive version of bash does not remove »\[« and »\]« from PS1. Seems to work just fine without those. So make the prompt pretty (and informative):
programs.bash.promptInit = ''
# Provide a nice prompt if the terminal supports it.
if [ "''${TERM:-}" != "dumb" ] ; then
if [[ "$UID" == '0' ]] ; then if [[ ! "''${SUDO_USER:-}" ]] ; then # direct root: red username + green hostname
PS1='\e[0m\e[48;5;234m\e[96m$(printf "%-+ 4d" $?)\e[93m[\D{%Y-%m-%d %H:%M:%S}] \e[91m\u\e[97m@\e[92m\h\e[97m:\e[96m\w'"''${TERM_RECURSION_DEPTH:+\e[91m["$TERM_RECURSION_DEPTH"]}"'\e[24;97m\$ \e[0m'
else # sudo root: red username + red hostname
PS1='\e[0m\e[48;5;234m\e[96m$(printf "%-+ 4d" $?)\e[93m[\D{%Y-%m-%d %H:%M:%S}] \e[91m\u\e[97m@\e[91m\h\e[97m:\e[96m\w'"''${TERM_RECURSION_DEPTH:+\e[91m["$TERM_RECURSION_DEPTH"]}"'\e[24;97m\$ \e[0m'
fi ; else # other user: green username + green hostname
PS1='\e[0m\e[48;5;234m\e[96m$(printf "%-+ 4d" $?)\e[93m[\D{%Y-%m-%d %H:%M:%S}] \e[92m\u\e[97m@\e[92m\h\e[97m:\e[96m\w'"''${TERM_RECURSION_DEPTH:+\e[91m["$TERM_RECURSION_DEPTH"]}"'\e[24;97m\$ \e[0m'
fi
if test "$TERM" = "xterm" ; then
PS1="\033]2;\h:\u:\w\007$PS1"
fi
programs.bash.promptInit = lib.mkDefault ''
# Provide a nice prompt if the terminal supports it.
if [ "''${TERM:-}" != "dumb" ] ; then
if [[ "$UID" == '0' ]] ; then if [[ ! "''${SUDO_USER:-}" ]] ; then # direct root: red username + green hostname
PS1='\[\e[0m\]\[\e[48;5;234m\]\[\e[96m\]$(printf "%-+ 4d" $?)\[\e[93m\][\D{%Y-%m-%d %H:%M:%S}] \[\e[91m\]\u\[\e[97m\]@\[\e[92m\]\h\[\e[97m\]:\[\e[96m\]\w'"''${TERM_RECURSION_DEPTH:+\[\e[91m\]["$TERM_RECURSION_DEPTH"]}"'\[\e[24;97m\]\$ \[\e[0m\]'
else # sudo root: red username + red hostname
PS1='\[\e[0m\]\[\e[48;5;234m\]\[\e[96m\]$(printf "%-+ 4d" $?)\[\e[93m\][\D{%Y-%m-%d %H:%M:%S}] \[\e[91m\]\u\[\e[97m\]@\[\e[91m\]\h\[\e[97m\]:\[\e[96m\]\w'"''${TERM_RECURSION_DEPTH:+\[\e[91m\]["$TERM_RECURSION_DEPTH"]}"'\[\e[24;97m\]\$ \[\e[0m\]'
fi ; else # other user: green username + green hostname
PS1='\[\e[0m\]\[\e[48;5;234m\]\[\e[96m\]$(printf "%-+ 4d" $?)\[\e[93m\][\D{%Y-%m-%d %H:%M:%S}] \[\e[92m\]\u\[\e[97m\]@\[\e[92m\]\h\[\e[97m\]:\[\e[96m\]\w'"''${TERM_RECURSION_DEPTH:+\[\e[91m\]["$TERM_RECURSION_DEPTH"]}"'\[\e[24;97m\]\$ \[\e[0m\]'
fi
if test "$TERM" = "xterm" ; then
PS1="\[\033]2;\h:\u:\w\007\]$PS1"
fi
fi
export TERM_RECURSION_DEPTH=$(( 1 + ''${TERM_RECURSION_DEPTH:-0} ))
''; # The non-interactive version of bash does not remove »\[« and »\]« from PS1, but without those the terminal gets confused about the cursor position after the prompt once one types more than a bit of text there, at least via serial or SSH.
environment.interactiveShellInit = lib.mkDefault ''
if [[ "$(realpath /dev/stdin)" == /dev/ttyS* && $LINES == 24 && $COLUMNS == 80 ]] ; then
stty rows 34 cols 145 # Fairly large font on 1080p. Definitely a better default than 24x80.
fi
export TERM_RECURSION_DEPTH=$(( 1 + ''${TERM_RECURSION_DEPTH:-0} ))
'';
}) ]);

15
modules/fs/README.md Normal file
View File

@ -0,0 +1,15 @@
# Automated File System Setup
NixOS usually gets set up by booting a live system (e.g. from USB), manually setting up file systems on the target disks, mounting them and then using `nixos-generate-config` to deduct the `fileSystems` from the mounted state.
This runs completely contrary to how NixOS usually does (and should) work (the system state should be derived from the config, not the config from the system state), and it does not at all work for hermetic flakes (where all config needs to be committed in the flake repo) or for automated installations.
[`disks`](./disks.nix.md), [`keystore`](./keystore.nix.md), and [`zfs`](./zfs.nix.md), allow to not only specify how file systems are to be mounted, but also which devices, partitions, and LUKS layers they live on, and for ZFS the complete pool and dataset layout.
The bash functions in [`lib/setup-scripts`](../../lib/setup-scripts/README.md) take these definitions and perform arbitrarily complex partitioning and file systems creation, as long as:
* Actual device instances or image files (by path) are passed to the installer for each device specified in the configuration.
* Disks are may be partitioned with GPT, but may optionally have an MBR for boot partitions if the loader requires that.
* `fileSystems.*.device`/`boot.initrd.luks.devices.*.device`/`wip.fs.zfs.pools.*.vdevArgs` refer to partitions by GPT partition label (`/dev/disk/by-partlabel/*`) or (LUKS) mapped name (`dev/mapper/*`).
* `boot.initrd.luks.devices.${name}` have a `wip.fs.keystore.keys."luks/${name}/0"` key specified.
[`boot`](./boot.nix.md) and [`temproot`](./temproot.nix.md) help with a concise (if somewhat opinionated) file system setup that places `/` on a file system that gets cleared during reboot, to achieve a system that is truly stateless (or at least explicitly acknowledges where it has state).

39
modules/fs/boot.nix.md Normal file
View File

@ -0,0 +1,39 @@
/*
# Boot(-loader) File Systems
This is a simple shortcut to define and mount a boot/firmware/EFI partition and file system, such that they can get created automatically.
## Implementation
```nix
#*/# end of MarkDown, beginning of NixOS module:
dirname: inputs: { config, pkgs, lib, ... }: let inherit (inputs.self) lib; in let
prefix = inputs.config.prefix;
cfg = config.${prefix}.fs.boot;
hash = builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName);
in {
options.${prefix} = { fs.boot = {
enable = lib.mkEnableOption "configuration of a boot partition as GPT partition 1 on the »primary« disk and a FAT32 filesystem on it";
mountpoint = lib.mkOption { description = "Path at which to mount a vfat boot partition."; type = lib.types.str; default = "/boot"; };
createMbrPart = lib.mkOption { description = "Whether to create a hybrid MBR with (only) the boot partition listed as partition 1."; type = lib.types.bool; default = true; };
size = lib.mkOption { description = "Size of the boot partition, should be *more* than 32M(iB)."; type = lib.types.str; default = "2G"; };
}; };
config = let
in lib.mkIf cfg.enable (lib.mkMerge [ ({
${prefix} = {
fs.disks.partitions."boot-${hash}" = { type = "ef00"; size = cfg.size; index = 1; order = 1500; disk = "primary"; }; # require it to be part1, and create it early
fs.disks.devices = lib.mkIf cfg.createMbrPart { primary = { mbrParts = "1"; extraFDiskCommands = ''
t;1;c # type ; part1 ; W95 FAT32 (LBA)
a;1 # active/boot ; part1
''; }; };
};
fileSystems.${cfg.mountpoint} = { fsType = "vfat"; device = "/dev/disk/by-partlabel/boot-${hash}"; neededForBoot = true; options = [ "noatime" "umask=0022" ]; formatOptions = "-F 32"; };
}) ]);
}

1
modules/fs/default.nix Normal file
View File

@ -0,0 +1 @@
dirname: inputs@{ self, nixpkgs, ...}: self.lib.wip.importModules inputs dirname { }

View File

@ -1,8 +1,8 @@
/*
# Installer Declarations
# Disk Declarations
Options to declare Disks and Partitions to be picked up by the installer scripts.
Options to declare devices and partitions to be picked up by the installer scripts.
## Implementation
@ -11,19 +11,19 @@ Options to declare Disks and Partitions to be picked up by the installer scripts
#*/# end of MarkDown, beginning of NixOS module:
dirname: inputs: { config, pkgs, lib, ... }: let inherit (inputs.self) lib; in let
prefix = inputs.config.prefix;
cfg = config.${prefix}.installer;
cfg = config.${prefix}.fs.disks;
in {
options.${prefix} = { installer = {
disks = lib.mkOption {
description = "Set of disks that this host will be installed on.";
options.${prefix} = { fs.disks = {
devices = lib.mkOption {
description = "Set of disk devices that this host will be installed on.";
type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: { options = {
name = lib.mkOption { description = "Name that this disk is being referred to as in other places."; type = lib.types.str; default = name; readOnly = true; };
size = lib.mkOption { description = "The size of the image to create, when using an image for this disk, as argument to »fallocate -l«."; type = lib.types.str; default = "8G"; };
serial = lib.mkOption { description = "Serial number of the specific hardware disk to use. If set the disk path passed to the installer must point to the device with this serial. Use »udevadm info --query=property --name=$DISK | grep -oP 'ID_SERIAL_SHORT=\K.*'« to get the serial."; type = lib.types.nullOr lib.types.str; default = null; };
alignment = lib.mkOption { description = "Partition alignment quantifier. Should be at least the optimal physical write size of the disk, but going larger at worst wastes this many times the number of partitions disk sectors."; type = lib.types.int; default = 16384; };
name = lib.mkOption { description = "Name that this device is being referred to as in other places."; type = lib.types.str; default = name; readOnly = true; };
size = lib.mkOption { description = "The size of the image to create, when using an image for this device, as argument to »fallocate -l«."; type = lib.types.str; default = "16G"; };
serial = lib.mkOption { description = "Serial number of the specific hardware device to use. If set the device path passed to the installer must point to the device with this serial. Use »udevadm info --query=property --name=$DISK | grep -oP 'ID_SERIAL_SHORT=\K.*'« to get the serial."; type = lib.types.nullOr lib.types.str; default = null; };
alignment = lib.mkOption { description = "Default alignment quantifier for partitions on this device. Should be at least the optimal physical write size of the device, but going larger at worst wastes this many times the number of partitions disk sectors."; type = lib.types.int; default = 16384; };
mbrParts = lib.mkOption { description = "Up to three colon-separated (GPT) partition numbers that will be made available in a hybrid MBR."; type = lib.types.nullOr lib.types.str; default = null; };
extraFDiskCommands = lib.mkOption { description = "»fdisk« menu commands to run against the hybrid MBR. ».mbrParts« 1[2[3]] exist as transfers from the GPT table, and part4 is the protective GPT part. Can do things like marking partitions as bootable or changing their type. Spaces and end-of-line »#«-prefixed comments are removed, new lines and »;« also mean return."; type = lib.types.lines; default = null; example = ''
extraFDiskCommands = lib.mkOption { description = "»fdisk« menu commands to run against the hybrid MBR. ».mbrParts« 1[2[3]] exist as transfers from the GPT table, and part4 is the protective GPT part. Can do things like marking partitions as bootable or changing their type. Spaces and end-of-line »#«-prefixed comments are removed, new lines and »;« also mean return."; type = lib.types.lines; default = ""; example = ''
t;1;b # type ; part1 ; W95 FAT32
a;1 # active/boot ; part1
''; };
@ -34,19 +34,28 @@ in {
description = "Set of disks disk partitions that the system will need/use. Partitions will be created on their respective ».disk«s in ».order« using »sgdisk -n X:+0+$size«.";
type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: { options = {
name = lib.mkOption { description = "Name/partlabel that this partition can be referred to as once created."; type = lib.types.str; default = name; readOnly = true; };
disk = lib.mkOption { description = "Name of the disk that this partition resides on."; type = lib.types.str; default = "primary"; };
disk = lib.mkOption { description = "Name of the disk that this partition resides on, which will automatically be declared with default options."; type = lib.types.str; default = "primary"; };
type = lib.mkOption { description = "»gdisk« partition type of this partition."; type = lib.types.str; };
size = lib.mkOption { description = "Partition size, as number suffixed with »K«, »M«, »G«, etc. Or »null« to fill the remaining disk space."; type = lib.types.nullOr lib.types.str; default = null; };
size = lib.mkOption { description = "Partition size, either as integer suffixed with »K«, »M«, »G«, etc for sizes in XiB, or an integer suffixed with »%« for that portion of the size of the actual disk the partition gets created on. Or »null« to fill the remaining disk space."; type = lib.types.nullOr lib.types.str; default = null; };
position = lib.mkOption { description = "Position at which to create the partition. The default »+0« means the beginning of the largest free block."; type = lib.types.str; default = "+0"; };
alignment = lib.mkOption { description = "Adjusted alignment quantifier for this partition only."; type = lib.types.nullOr lib.types.int; default = null; example = 1; };
index = lib.mkOption { description = "Optionally explicit partition table index to place this partition in. Use ».order« to make sure that this index hasn't been used yet.."; type = lib.types.nullOr lib.types.int; default = null; };
order = lib.mkOption { description = "Creation order ranking of this partition. Higher orders will be created first, and will thus be placed earlier in the partition table (if ».index« isn't explicitly set) and also further to the front of the disk space."; type = lib.types.int; default = 1000; };
}; }));
default = { };
};
partitionList = lib.mkOption { description = "Partitions as a sorted list"; type = lib.types.listOf (lib.types.attrsOf lib.types.anything); default = lib.sort (before: after: before.order >= after.order) (lib.attrValues cfg.partitions); readOnly = true; internal = true; };
# These are less disk-state-describing and more installation-imperative ...
# Also, these are run as root and thee are no security or safety checks ...
postPartitionCommands = lib.mkOption { description = ""; type = lib.types.lines; default = ""; };
postFormatCommands = lib.mkOption { description = ""; type = lib.types.lines; default = ""; };
postMountCommands = lib.mkOption { description = ""; type = lib.types.lines; default = ""; };
initSystemCommands = lib.mkOption { description = ""; type = lib.types.lines; default = ""; };
restoreSystemCommands = lib.mkOption { description = ""; type = lib.types.lines; default = ""; };
}; };
# Create all devices referenced by partitions:
config.${prefix}.fs.disks.devices = lib.wip.mapMerge (name: { ${name} = { }; }) (lib.catAttrs "disk" config.${prefix}.fs.disks.partitionList);
}

255
modules/fs/keystore.nix.md Normal file
View File

@ -0,0 +1,255 @@
/*
# Boot Key Store
This module does two related things:
* it provides the specification for encryption keys to be generated during system installation, which are then (automatically) used by the [setup scripts](../../lib/setup-scripts/README.md) for various pieces of file system encryption,
* and it configures a `keystore` LUKS device to be opened (according to the keys specified for it) in the initramfs boot stage to use those keys to unlock other encrypted file systems.
Keys can always be specified, and the installer may decide to use the setup script functions populating the keystore or not.
The default functions in [`lib/setup-scripts`](../../lib/setup-scripts/README.md) do populating the keystore, and then use the keys according to the description below.
What keys are used for is derived from the attribute name in the `.keys` specification, which (plus a `.key` suffix) also becomes their storage path in the keystore:
* Keys in `luks/` are used for LUKS devices, where the second path label is both the target device name and source device GPT partition label, and the third and final label is the LUKS key slot (`0` is required to be specified, `1` to `7` are optional).
* Keys in `zfs/` are used for ZFS datasets, where the further path is that of the dataset. Datasets implicitly inherit their parent's encryption by default. An empty key (created by method `unencrypted`) explicitly disables encryption on a dataset. Other keys are by default used with `keyformat=hex` and must thus be exactly 64 (lowercase) hex digits.
* Keys in `home/` are used as composites for home directory encryption, where the second and only other path label us the user name. TODO: this is not completely implemented yet.
The attribute value in the `.keys` keys specification dictates how the key is acquired, primarily initially during installation, but (depending on the keys usage) also during boot unlocking, etc.
The format of the key specification is `method[=args]`, where `method` is the suffix of a bash function call `add-key-<method>` (the default functions are in [`add-key.sh`](../../lib/setup-scripts/add-key.sh), but others could be added to the installer), and `args` is the second argument to the respective function (often a `:` separated list of arguments, but some methods don't need any arguments at all).
Most key generation methods only make sense in some key usage contexts. A `random` key is impossible to provide to unlock the keystore (which it is stored in), but is well suited to unlock other devices (if the keystore has backups (TODO!)); conversely a USB-partition can be used to headlessly unlock the keystore, but would be redundant for any further devices, as it would also be copied in the keystore.
If the module is `enable`d, a partition and LUKS device `keystore-...` gets configured and the contents of the installation time keystore is copied to it (in its entirety, including intermediate or derived keys and those unlocking the keystore itself (TODO: this could be optimized)).
This LUKS device is then configured to be unlocked (using any ot the key methods specified for it) before anything else during boot, and closed before leaving the initramfs phase.
Any number of other devices may thus specify paths in the keystore as keylocation to be unlocked during boot without needing to prompt for further secrets, and without exposing the keys to the running system.
## Implementation
```nix
#*/# end of MarkDown, beginning of NixOS module:
dirname: inputs: { config, pkgs, lib, ... }: let inherit (inputs.self) lib; in let
prefix = inputs.config.prefix;
cfg = config.${prefix}.fs.keystore;
hash = builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName);
keystore = "/run/keystore-${hash}";
keystoreKeys = lib.attrValues (lib.filterAttrs (n: v: lib.wip.startsWith "luks/keystore-${hash}/" n) cfg.keys);
in let module = {
options.${prefix} = { fs.keystore = {
enable = lib.mkEnableOption "the use of a keystore partition to unlock various things during early boot";
enableLuksGeneration = (lib.mkEnableOption "the generation of a LUKS mapper configuration for each »luks/*/0« entry in ».keys«") // { default = true; example = false; };
keys = lib.mkOption { description = "Keys declared to be generated during installation and then exist in the keystore for unlocking disks and such. See »${dirname}/keystore.nix.md« for much more information."; type = lib.types.attrsOf (lib.types.either (lib.types.nullOr lib.types.str) (lib.types.attrsOf lib.types.str)); default = { }; apply = keys: (
lib.wip.mapMergeUnique (usage: methods: if methods == null then { } else if builtins.isString methods then { "${usage}" = methods; } else lib.wip.mapMerge (slot: method: if method == null then { } else { "${usage}/${slot}" = method; }) methods) keys
); };
unlockMethods = {
trivialHostname = lib.mkOption { description = "For headless auto boot, use »hostname« (in a file w/o newline) as trivial password/key for the keystore."; type = lib.types.bool; default = lib.elem "hostname" keystoreKeys; };
usbPartition = lib.mkOption { type = lib.types.bool; default = (lib.elem "usb-part" keystoreKeys); };
pinThroughYubikey = lib.mkOption { type = lib.types.bool; default = (lib.any (type: lib.wip.matches "^yubikey-pin=.*$" type) keystoreKeys); };
};
}; };
config = let
in lib.mkIf cfg.enable (lib.mkMerge [ ({
assertions = [ {
assertion = cfg.keys?"luks/keystore-${hash}/0";
message = ''At least one key (»0«) for »luks/keystore-${hash}« must be specified!'';
} ];
boot.initrd.luks.devices = lib.mkIf cfg.enableLuksGeneration (lib.wip.mapMerge (key: let
label = builtins.substring 5 ((builtins.stringLength key) - 7) key;
in { ${label} = {
device = lib.mkDefault "/dev/disk/by-partlabel/${label}";
keyFile = lib.mkIf (label != "keystore-${hash}") (lib.mkDefault "/run/keystore-${hash}/luks/${label}/0.key");
allowDiscards = lib.mkDefault true; # If attackers can observe trimmed blocks, then they can probably do much worse ...
}; }) (lib.wip.filterMatching ''^luks/.*/0$'' (lib.attrNames cfg.keys)));
${prefix}.fs.keystore.keys."luks/keystore-${hash}/0" = lib.mkOptionDefault "hostname";
}) ({
boot.initrd.supportedFilesystems = [ "vfat" ];
#boot.supportedFilesystems = [ "vfat" ]; # TODO: this should not be necessary
boot.initrd.luks.devices."keystore-${hash}" = {
device = "/dev/disk/by-partlabel/keystore-${hash}";
postOpenCommands = ''
echo "Mounting ${keystore}"
mkdir -p ${keystore}
mount -o nodev,umask=0277,ro /dev/mapper/keystore-${hash} ${keystore}
'';
preLVM = true; # ensure the keystore is opened early (»preLVM« also seems to be pre zpool import, and it is the only option that affects the opening order)
keyFile = lib.mkMerge [
(lib.mkIf cfg.unlockMethods.trivialHostname "${pkgs.writeText "hostname" config.networking.hostName}")
(lib.mkIf cfg.unlockMethods.usbPartition "/dev/disk/by-partlabel/bootkey-${hash}")
];
fallbackToPassword = true; # (might as well)
preOpenCommands = lib.mkIf cfg.unlockMethods.pinThroughYubikey verbose.doOpenWithYubikey;
};
# Create and populate keystore during installation:
fileSystems.${keystore} = { fsType = "vfat"; device = "/dev/mapper/keystore-${hash}"; options = [ "ro" "noatime" "umask=0277" "noauto" ]; formatOptions = ""; };
${prefix} = {
fs.disks.partitions."keystore-${hash}" = { type = "8309"; order = 1375; disk = "primary"; size = "32M"; };
fs.disks.postFormatCommands = ''
( : 'Copy the live keystore to its primary persistent location:'
tmp=$(mktemp -d) ; mount "/dev/mapper/keystore-${hash}" $tmp ; trap "umount $tmp ; rmdir $tmp" EXIT
${pkgs.rsync}/bin/rsync -a ${keystore}/ $tmp/
)
'';
};
boot.initrd.postMountCommands = ''
${if (lib.any (lib.wip.matches "^home/.*$") (lib.attrNames cfg.keys)) then ''
echo "Transferring home key composites"
# needs to be available later to unlock the home on demand
mkdir -p /run/keys/home-composite/ ; chmod 551 /run/keys/home-composite/ ; cp -a ${keystore}/home/*.key /run/keys/home-composite/
for name in "$(ls /run/keys/home-composite/)" ; do chown "''${name:0:(-4)}": /run/keys/home-composite/"$name" ; done
'' else ""}
echo "Closing ${keystore}"
umount ${keystore} ; rmdir ${keystore}
cryptsetup close /dev/mapper/keystore-${hash}
'';
boot.initrd.luks.yubikeySupport = lib.mkIf cfg.unlockMethods.pinThroughYubikey true;
boot.initrd.extraUtilsCommands = lib.mkIf cfg.unlockMethods.pinThroughYubikey (lib.mkAfter ''
copy_bin_and_libs ${verbose.askPassWithYubikey}/bin/cryptsetup-askpass
sed -i "s|/bin/sh|$out/bin/sh|" "$out/bin/cryptsetup-askpass"
'');
}) ]);
}; verbose = rec {
tryYubikey = ''tryYubikey () { # 1: key
local key="$1" ; local slot
if [ "$(ykinfo -q -2 2>/dev/null)" = '1' ] ; then slot=2 ;
elif [ "$(ykinfo -q -1 2>/dev/null)" = '1' ] ; then slot=1 ; fi
if [ "$slot" ] ; then
echo >&2 ; echo "Using slot $slot of detected Yubikey ..." >&2
key="$(ykchalresp -$slot "$key" 2>/dev/null || true)"
if [ "$key" ] ; then echo "Got response from Yubikey" >&2 ; fi
fi
printf '%s' "$key"
}'';
# The next tree strings are copied from https://github.com/NixOS/nixpkgs/blob/1c9b2f18ced655b19bf01ad7d5ef9497d48a32cf/nixos/modules/system/boot/luksroot.nix
# The only modification is the addition and invocation of »tryYubikey«
commonFunctions = ''
die() {
echo "$@" >&2
exit 1
}
dev_exist() {
local target="$1"
if [ -e $target ]; then
return 0
else
local uuid=$(echo -n $target | sed -e 's,UUID=\(.*\),\1,g')
blkid --uuid $uuid >/dev/null
return $?
fi
}
wait_target() {
local name="$1"
local target="$2"
local secs="''${3:-10}"
local desc="''${4:-$name $target to appear}"
if ! dev_exist $target; then
echo -n "Waiting $secs seconds for $desc..."
local success=false;
for try in $(seq $secs); do
echo -n "."
sleep 1
if dev_exist $target; then
success=true
break
fi
done
if [ $success == true ]; then
echo " - success";
return 0
else
echo " - failure";
return 1
fi
fi
return 0
}
'';
doOpenWithYubikey = (let
inherit (lib) optionalString;
inherit (config.boot.initrd) luks;
inherit (config.boot.initrd.luks.devices."keystore-${hash}") name device header keyFile keyFileSize keyFileOffset allowDiscards yubikey gpgCard fido2 fallbackToPassword;
cs-open = "cryptsetup luksOpen ${device} ${name} ${optionalString allowDiscards "--allow-discards"} ${optionalString (header != null) "--header=${header}"}";
in ''
${tryYubikey}
do_open_passphrase() {
local passphrase
while true; do
echo -n "Passphrase for ${device}: "
passphrase=
while true; do
if [ -e /crypt-ramfs/passphrase ]; then
echo "reused"
passphrase=$(cat /crypt-ramfs/passphrase)
break
else
# ask cryptsetup-askpass
echo -n "${device}" > /crypt-ramfs/device
# and try reading it from /dev/console with a timeout
IFS= read -t 1 -r passphrase
if [ -n "$passphrase" ]; then
passphrase="$(tryYubikey "$passphrase")"
${if luks.reusePassphrases then ''
# remember it for the next device
echo -n "$passphrase" > /crypt-ramfs/passphrase
'' else ''
# Don't save it to ramfs. We are very paranoid
''}
echo
break
fi
fi
done
echo -n "Verifying passphrase for ${device}..."
echo -n "$passphrase" | ${cs-open} --key-file=-
if [ $? == 0 ]; then
echo " - success"
${if luks.reusePassphrases then ''
# we don't rm here because we might reuse it for the next device
'' else ''
rm -f /crypt-ramfs/passphrase
''}
break
else
echo " - failure"
# ask for a different one
rm -f /crypt-ramfs/passphrase
fi
done
}
'');
askPassWithYubikey = pkgs.writeScriptBin "cryptsetup-askpass" ''
#!/bin/sh
${commonFunctions}
${tryYubikey}
while true; do
wait_target "luks" /crypt-ramfs/device 10 "LUKS to request a passphrase" || die "Passphrase is not requested now"
device=$(cat /crypt-ramfs/device)
echo -n "Passphrase for $device: "
IFS= read -rs passphrase
echo
rm /crypt-ramfs/device
echo -n "$(tryYubikey "$passphrase")" > /crypt-ramfs/passphrase
done
'';
}; in module

42
modules/fs/patches.nix.md Normal file
View File

@ -0,0 +1,42 @@
/*
# FS Nixpkgs "Patches"
Filesystem related "patches" of options in nixpkgs, i.e. additions of options that are *not* prefixed.
## Implementation
```nix
#*/# end of MarkDown, beginning of NixOS module:
dirname: inputs: specialArgs@{ config, pkgs, lib, ... }: let inherit (inputs.self) lib; in let
utils = import "${inputs.nixpkgs.outPath}/nixos/lib/utils.nix" { inherit (specialArgs) lib config pkgs; };
in {
options = {
fileSystems = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule [ { options = {
preMountCommands = lib.mkOption { description = ""; type = lib.types.nullOr lib.types.str; default = null; };
}; } ]);
}; };
config = let
in ({
systemd.services = lib.wip.mapMerge (target: { device, preMountCommands, ... }: if (preMountCommands != null) then let
isDevice = lib.wip.startsWith "/dev/" device;
target' = utils.escapeSystemdPath target;
device' = utils.escapeSystemdPath device;
mountPoint = "${target'}.mount";
in { "pre-mount-${target'}" = {
description = "Prepare mounting (to) ${target}";
wantedBy = [ mountPoint ]; before = [ mountPoint ] ++ (lib.optional isDevice "systemd-fsck@${device'}.service");
requires = lib.optional isDevice "${device'}.device"; after = lib.optional isDevice "${device'}.device";
unitConfig.RequiresMountsFor = [ (builtins.dirOf device) (builtins.dirOf target) ];
unitConfig.DefaultDependencies = false;
serviceConfig.Type = "oneshot"; script = preMountCommands;
}; } else { }) config.fileSystems;
});
}

322
modules/fs/temproot.nix.md Normal file
View File

@ -0,0 +1,322 @@
/*
# TempRoot Layout Options
The core concept of NixOS is to maintain the hosts' programs and configuration in a stateless manner.
Hand in hand with that goes the need to (but also the advantages of) identifying anything stateful in the system.
Such state generally falls into one of four categories:
1) It is secret and/or can not be re-generated (without other information of the same kind).
2) It may or may not be secret, but re-generating it takes time or is impractical.
3) It may or may not be secret and can be re-generated quickly.
Category 1 data can not be included in the nix store and thus can not (directly) be derived from the system configuration.
It needs to be stored in a way that the host (and only the host) can access it an that it can't be lost.
It is therefore referred to as `remote` data (even though it would usually also be stored locally).
Category 2 data is required for the system to boot (in reasonable time), and should thus, as `local` data, be stored persistently on the host.
Parts that are not secret should be included in or generated from the system configuration.
Anything that is secret likely shouldn't be lost (i.e. is actually category 1) or can be re-generated from randomness or category 1 (and is thus category 3).
Category 3 data is expendable, and, as `temp` data, can thus be cleared on reboot or at other times.
Temporary data is the cheapest to (not) maintain, esp. in terms of administrative overhead, and should be used wherever possible.
Though incorrectly assigning data to `temp` that should be `local` or `remote` may break the system or cause data loss.
TempRoot is the concept of defaulting everything to `temp` and selectively whitelisting things as `local` or `remote`, by mounting an ephemeral root (`/`) file system and mounting/binding/linking various, sometimes many, nested paths to persistent storage.
This module implements the concept with different filesystem options for `remote`, `local` and `temp` data, while maintaining the same general mount structure regardless, which should make the choice of backing storage largely transparent for anything running on the system.
ZFS is capable of serving all three roles quite well.
Its pooling and datasets allow seamless allocation of storage between the categories, file-system level encryption and sending of encrypted, incremental dataset snapshots make it excellent for the `remote` role.
As backed for `local`, which primarily holds the nix store, it benefits from transparent compression, and checksumming.
With `fsync` disabled, and the ability to roll back snapshots, it also works to create very large storage areas for `temp` data.
ZFS though struggles on lower-end systems. BtrFS could probably be configured to serve the roles with similar capability.
F2FS also supports checksumming and compression, though it currently does not automatically reclaim space gained by the latter (but TODO: Nix could be tuned to do this explicitly).
This and its design optimized for flash storage should make it an optimal backend for the `local` data, esp. on lower-end hardware.
EXT4 supports checksumming only for metadata, and does not support compression. Block device layers could in principle be used for this.
Using a sime filesystem with external backup tools is possible yet suboptimal for `remote` data, unless the system doesn't actually have any/much of it.
As long as the amount of `temp` data can be expected to stay within reasonable bounds, `tmpfs`es and swap can also be used to back the `temp` data.
The disk/partition declaration and the installer tooling refer to disks by GPT `partlabel`. They require the labels to be unique not only within a single target host, but also between the host that does the installation and the one being installed. It is therefore highly advisable (and in some places maybe implicitly expected) that the labels contain a unique host identifier, for example:
```nix
let hash = builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName); in # ...
```
## Examples
This completely configures the disks, partitions, pool, datasets, and mounts for a ZFS `rpool` on a three-disk `raidz1` with read and write cache on an additional SSD, which also holds the boot partition and swap:
```nix
{ wip.fs.disks.devices.primary.size = "16G"; # The default, and only relevant when installing to disk images. The »primary« disk will hold all implicitly created partitions and those not stating a »disk«.
wip.fs.boot.enable = true; wip.fs.boot.size = "512M"; # See »./boot.nix.md«. Creates a FAT boot partition.
wip.fs.keystore.enable = true; # See »./keystore.nix.md«. With this enabled, »remote« will automatically be encrypted, with a random key by default.
wip.fs.temproot.enable = true; # Use ZFS for all categories.
wip.fs.temproot.temp.type = "zfs";
wip.fs.temproot.local.type = "zfs";
wip.fs.temproot.remote.type = "zfs";
# Change/set the pools storage layout (see above), then adjust the partitions disks/sizes. Declaring disks requires them to be passed to the system installer.
wip.fs.zfs.pools."rpool-${hash}".vdevArgs = [ "raidz1" "rpool-rz1-${hash}" "rpool-rz2-${hash}" "rpool-rz3-${hash}" "log" "rpool-zil-${hash}" "cache" "rpool-arc-${hash}" ];
wip.fs.disks.partitions."rpool-rz1-${hash}" = { disk = "raidz1"; };
wip.fs.disks.partitions."rpool-rz2-${hash}" = { disk = "raidz2"; };
wip.fs.disks.partitions."rpool-rz3-${hash}" = { disk = "raidz3"; };
wip.fs.disks.partitions."rpool-zil-${hash}" = { size = "2G"; };
wip.fs.disks.partitions."rpool-arc-${hash}" = { }; } # (this is actually already implicitly declared)
```
On a less beefy system, but also with less data to manage, `tmpfs` works fine for `tmp`, and `f2fs` promises to get more performance out of the flash/ram/cpu:
```nix
{ # See above for these:
#wip.fs.disks.devices.primary.size = "16G"; # (default)
wip.fs.boot.enable = true; wip.fs.boot.size = "512M";
wip.fs.keystore.enable = true;
wip.fs.temproot.enable = true;
wip.fs.temproot.temp.type = "tmpfs"; # Put `/` on a `tmpfs`.
wip.fs.temproot.local.type = "bind"; # `bind`-mount all `local` locations to `/.local/*`, ...
wip.fs.temproot.local.bind.base = "f2fs-encrypted"; # ... where a LUKS-encrypted F2FS is mounted.
#wip.fs.keystore.keys."luks/local-${hash}/0" = "random"; # (implied by the »-encrypted« suffix above)
#wip.fs.disks.partitions."local-${hash}".size = "50%"; # (default, fixed after installation)
wip.fs.temproot.remote.type = "zfs";
wip.fs.keystore.keys."luks/rpool-${hash}/0" = "random";
#wip.fs.keystore.keys."zfs/rpool-${hash}/remote" = "random"; # (default)
#wip.fs.zfs.pools."rpool-${hash}".vdevArgs = [ "rpool-${hash}" ]; # (default)
#wip.fs.disks.partitions."rpool-${hash}" = { type = "bf00"; size = null; order = 500; }; # (default)
# Default mounts/binds can also be removed, in this case causing logs to be removed on reboot:
wip.fs.temproot.local.mounts."/var/log" = lib.mkForce null; }
```
## Implementation
```nix
#*/# end of MarkDown, beginning of NixOS module:
dirname: inputs: specialArgs@{ config, pkgs, lib, ... }: let inherit (inputs.self) lib; in let
prefix = inputs.config.prefix;
cfg = config.${prefix}.fs.temproot;
utils = import "${inputs.nixpkgs.outPath}/nixos/lib/utils.nix" { inherit (specialArgs) lib config pkgs; };
hash = builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName);
optionsFor = type: desc: {
bind.source = lib.mkOption { description = "Prefix for bind-mount targets."; type = lib.types.str; default = "/.${type}"; };
bind.base = lib.mkOption { description = "Filesystem to automatically create as the ».source« for the bind mounts."; type = lib.types.enum ([ null ] ++ (lib.optionals (type == "local") [ "f2fs" "f2fs-encrypted" ])); default = null; };
zfs.dataset = lib.mkOption { description = "Dataset path under which to create the ${desc} »${type}« datasets."; type = lib.types.str; default = "rpool-${hash}/${type}"; };
mounts = lib.mkOption {
description = "Locations (for »temp« in addition to »/«) where a ${desc} filesystem should be mounted. Some are declared by default but may be removed by setting them to »null«.";
type = lib.types.attrsOf (lib.types.nullOr (lib.types.submodule ({ name, ... }: { options = {
target = lib.mkOption { description = "Attribute name as the mount target path."; type = lib.types.addCheck lib.types.str (name: (builtins.match ''^/.*[^/]$'' name) != null); default = name; readOnly = true; };
source = lib.mkOption { description = "Relative source path of the mount. (Irrelevant for »tmpfs«.)"; type = lib.types.str; default = builtins.substring 1 (builtins.stringLength name - 1) name; };
uid = lib.mkOption { description = "UID owning the mounted target."; type = lib.types.ints.unsigned; default = 0; };
gid = lib.mkOption { description = "GID owning the mounted target."; type = lib.types.ints.unsigned; default = 0; };
mode = lib.mkOption { description = "Permission mode of the mounted target."; type = lib.types.str; default = "750"; };
options = lib.mkOption { description = "Additional mount options to set. Note that not all mount types support all options, they may be silently ignored or cause errors. »bind« supports setting »nosuid«, »nodev«, »noexec«, »noatime«, »nodiratime«, and »relatime«. »zfs« will only heed »noauto« and otherwise use the ».zfsProps«."; type = lib.types.listOf lib.types.str; default = [ ]; };
extraFsOptions = lib.mkOption { description = "Extra options to set on the generated »fileSystems.*« entry (unless this mount is forced to »null«)."; type = lib.types.attrsOf lib.types.anything; default = { }; };
zfsProps = lib.mkOption { description = "ZFS properties to set on the dataset, if mode type is »zfs«."; type = lib.types.attrsOf (lib.types.nullOr lib.types.str); default = { }; };
}; })));
default = { };
apply = lib.filterAttrs (k: v: v != null);
};
mountOptions = lib.mkOption { description = "Mount options that will be placed before ».mounts.*.options«."; type = lib.types.listOf lib.types.str; default = [ "noatime" "nodev" "nosuid" ]; };
};
zfsNoSyncProps = { sync = "disabled"; logbias = "throughput"; }; # According to the documentation, »logbias« should be irrelevant without sync (i.e. no logging), but some claim setting it to »throughput« still improves performance.
in {
options.${prefix} = { fs.temproot = {
enable = lib.mkEnableOption "filesystem layouts with ephemeral root";
# Settings for filesystems that will be cleared on reboot:
temp = {
type = lib.mkOption { description = ''
"tmpfs": Creates »tmpfs« filesystems at »/« and all specified mount points.
"zfs": ...
"bind": Expects a filesystem to be mounted at »/«. Creates a hook to cre-create that filesystem on boot (TODO: implement and then enable this), and bind-mounts any additional mounts to ».bind.source+"/"+<mount.source>« (creating those paths if necessary).
''; type = lib.types.enum [ "tmpfs" "zfs" ]; default = "tmpfs"; };
} // (optionsFor "temp" "temporary");
# Settings for filesystems that persist across reboots:
local = {
type = lib.mkOption { description = ''
"bind": Expects a (locally persistent) filesystem to be mounted at ».bind.target«, and bind-mounts any additional mounts to ».bind.source+"/"+<mount.source>« (creating those paths if necessary).
"zfs": ...
''; type = lib.types.enum [ "bind" "zfs" ]; default = "bind"; };
} // (optionsFor "local" "locally persistent");
# Settings for filesystems that should have remote backups:
remote = {
type = lib.mkOption { description = ''
"bind": Expects a filesystem to be mounted at ».bind.target« that gets backed. Bind-mounts any additional mounts to ».bind.source+"/"+<mount.source>« (creating those paths if necessary).
"zfs": ...
''; type = lib.types.enum [ "bind" "zfs" ]; default = "bind"; };
} // (optionsFor "remote" "remotely backed-up");
swap = {
size = lib.mkOption { description = "Size of the swap partition or file to create."; type = lib.types.nullOr lib.types.str; default = null; };
encrypted = lib.mkOption { description = "Whether to encrypt the swap with a persistent key. Only relevant if ».asPartition = true«."; type = lib.types.bool; default = false; };
asPartition = lib.mkOption { description = "Whether to create a swap partition instead of a file."; type = lib.types.bool; default = cfg.local.type == "zfs"; };
};
persistenceFixes = (lib.mkEnableOption "some fixes to cope with »/« being ephemeral") // { default = true; example = false; };
}; };
config = let
in lib.mkIf cfg.enable (lib.mkMerge ([ ({
${prefix} = {
fs.temproot.temp.mounts = {
"/tmp" = { mode = "1777"; };
};
fs.temproot.local.mounts = {
"/nix" = { zfsProps = zfsNoSyncProps; mode = "755"; }; # this (or /nix/store) is required
"/var/log" = { source = "logs"; };
"/local" = { source = "system"; };
# »/swap« is used by »cfg.swap.asPartition = false«
};
fs.temproot.remote.mounts = {
"/remote" = { source = "system"; extraFsOptions = { neededForBoot = true; }; }; # if any secrets need to be picked up by »activate«, they should be here
};
};
boot.tmpOnTmpfs = false; # This would create a systemd mount unit for »/tmp«.
}) (lib.mkIf cfg.persistenceFixes { # Cope with the consequences of having »/« (including »/{etc,var,root,...}«) cleared on every reboot.
systemd.tmpfiles.rules = [ # keep in mind: this does not get applied super early ...
# nixos config
"L+ /etc/nixos/ - - - - ../../remote/etc/nixos/"
# »/root/.nix-channels« is already being restored.
# »/var/lib/nixos« remains ephemeral. Where it matters, explicitly define a U/GID!
# root's command history
"f /remote/root/.bash_history 0600 root root -"
"L+ /root/.bash_history - - - - ../remote/root/.bash_history"
"f /remote/root/.local/share/nix/repl-history 0600 root root -"
"L+ /root/.local/share/nix/repl-history - - - - ../../../../remote/root/.local/share/nix/repl-history"
];
fileSystems = { # this does get applied early
# (on systems without hardware clock, this allows systemd to provide an at least monolithic time after restarts)
"/var/lib/systemd/timesync" = { device = "/local/var/lib/systemd/timesync"; options = [ "bind" "nofail" ]; }; # TODO: add »neededForBoot = true«?
# save persistent timer states
"/var/lib/systemd/timers" = { device = "/local/var/lib/systemd/timers"; options = [ "bind" "nofail" ]; }; # TODO: add »neededForBoot = true«?
};
security.sudo.extraConfig = "Defaults lecture=never"; # default is »once«, but we'd forget that we did that
}) (lib.mkIf (cfg.swap.size != null && cfg.swap.asPartition) (let # Convenience option to create a local F2FS optimized to host the nix store:
useLuks = config.${prefix}.fs.keystore.keys?"luks/swap-${hash}/0";
in {
${prefix} = {
fs.disks.partitions."swap-${hash}" = { type = "8200"; size = cfg.swap.size; order = 1250; };
fs.keystore.keys."luks/swap-${hash}/0" = lib.mkIf cfg.swap.encrypted (lib.mkOptionDefault "random");
};
swapDevices = [ { device = "/dev/${if useLuks then "mapper" else "disk/by-partlabel"}/swap-${hash}"; } ];
})) (lib.mkIf (cfg.swap.size != null && !cfg.swap.asPartition) {
swapDevices = [ { device = "${cfg.local.bind.source}/swap"; size = (lib.wip.parseSizeSuffix cfg.swap.size) / 1024 / 1024; } ];
}) (lib.mkIf (cfg.temp.type == "tmpfs") { # (only temp can be of type tmpfs)
fileSystems = lib.mapAttrs (target: { options, uid, gid, mode, extraFsOptions, ... }: (extraFsOptions // {
fsType = "tmpfs"; device = "tmpfs"; options = (extraFsOptions.options or [ ]) ++ cfg.temp.mountOptions ++ options ++ [ "uid=${toString uid}" "gid=${toString gid}" "mode=${mode}" ];
})) ({ "/" = { options = [ ]; uid = 0; gid = 0; mode = "755"; extraFsOptions = { }; }; } // cfg.temp.mounts);
}) (lib.mkIf (cfg.temp.type == "zfs") {
boot.initrd.postDeviceCommands = lib.mkAfter ''
echo 'Clearing root ZFS'
( zfs list -H -o name -t snapshot -r ${cfg.temp.zfs.dataset} | grep '@empty$' | xargs -n1 --no-run-if-empty zfs rollback -r )
'';
}) (lib.mkIf (cfg.temp.type == "bind") { # (TODO: this should completely clear or even recreate the »cfg.temp.bind.source«)
boot.cleanTmpDir = true; # Clear »/tmp« on reboot.
}) (lib.mkIf (cfg.local.type == "bind" && (cfg.local.bind.base == "f2fs" || cfg.local.bind.base == "f2fs-encrypted")) (let # Convenience option to create a local F2FS optimized to host the nix store:
useLuks = config.${prefix}.fs.keystore.keys?"luks/local-${hash}/0";
in {
# TODO: fsck
${prefix} = {
fs.keystore.keys."luks/local-${hash}/0" = lib.mkIf (cfg.local.bind.base == "f2fs-encrypted") (lib.mkOptionDefault "random");
fs.disks.partitions."local-${hash}" = {
type = "8300"; order = 1000; disk = "primary"; size = "50%";
};
};
fileSystems.${cfg.local.bind.source} = { fsType = "f2fs"; device = "/dev/${if useLuks then "mapper" else "disk/by-partlabel"}/local-${hash}"; formatOptions = lib.mkDefault (lib.concatStrings [
"-O extra_attr" # required by other options
",inode_checksum" # enable inode checksum
",sb_checksum" # enable superblock checksum
",compression" # allow compression
#"-w ?" # "sector size in bytes"
# sector ? segments < section < zone
]); options = lib.mkDefault ([
# F2FS compresses only for performance and wear. The whole uncompressed space is still reserved (in case the file content needs to get replaced by incompressible data in-place). To free the gained space, »ioctl(fd, F2FS_IOC_RELEASE_COMPRESS_BLOCKS)« needs to be called per file, making the file immutable. Nix could do that when moving stuff into the store.
"compress_mode=fs" # enable compression for all files
"compress_algorithm=lz4" # compress using lz4
"compress_chksum" # verify checksums (when decompressing data blocks?)
"lazytime" # update timestamps asynchronously
] ++ cfg.local.mountOptions); };
# TODO: "F2FS and its tools support various parameters not only for configuring on-disk layout, but also for selecting allocation and cleaning algorithms."
})) ] ++ (map (type: (lib.mkIf (cfg.${type}.type == "bind") {
fileSystems = (lib.mapAttrs (target: args@{ source, uid, gid, mode, extraFsOptions, ... }: extraFsOptions // (rec {
device = "${cfg.${type}.bind.source}/${source}"; options = (extraFsOptions.options or [ ]) ++ [ "bind" ] ++ cfg.${type}.mountOptions ++ args.options;
preMountCommands = ''
${extraFsOptions.preMountCommands or ""}
mkdir -pm 000 -- ${lib.escapeShellArg target}
mkdir -pm 000 -- ${lib.escapeShellArg device}
chown ${toString uid}:${toString gid} -- ${lib.escapeShellArg device}
chmod ${mode} -- ${lib.escapeShellArg device}
'';
})) cfg.${type}.mounts) // {
${cfg.${type}.bind.source} = { neededForBoot = lib.any utils.fsNeededForBoot (lib.attrValues (builtins.intersectAttrs cfg.${type}.mounts config.fileSystems)); };
};
})) [ "temp" "local" "remote" ]) ++ (map (type: (lib.mkIf (cfg.${type}.type == "zfs") (let
dataset = cfg.${type}.zfs.dataset;
in {
${prefix} = {
fs.zfs.enable = true;
fs.zfs.pools.${lib.head (lib.splitString "/" dataset)} = { }; # ensure the pool exists (all properties can be adjusted)
fs.keystore.keys."zfs/${dataset}" = lib.mkIf (type == "remote" && config.${prefix}.fs.keystore.enable) (lib.mkOptionDefault "random"); # the entire point of ZFS remote are backups, and those should be encrypted
fs.zfs.datasets = {
${dataset} = {
mount = false; props = { canmount = "off"; mountpoint = "/"; } // (if type == "temp" then { refreservation = "1G"; } // zfsNoSyncProps else { });
};
} // (if type == "temp" then {
"${dataset}/root" = {
mount = true; props = { canmount = "noauto"; mountpoint = "/"; }; mode = "755";
};
} else { }) // (lib.wip.mapMerge (target: { source, options, zfsProps, uid, gid, mode, ... }: {
"${dataset}/${source}" = {
mount = if lib.elem "noauto" options then "noauto" else true; inherit uid gid mode;
props = { canmount = "noauto"; mountpoint = target; } // zfsProps;
};
}) cfg.${type}.mounts);
};
fileSystems = lib.mapAttrs (target: args@{ extraFsOptions, ... }: (
extraFsOptions
)) cfg.${type}.mounts;
}))) [ "temp" "local" "remote" ])));
}

165
modules/fs/zfs.nix.md Normal file
View File

@ -0,0 +1,165 @@
/*
# ZFS Pools and Datasets
This module primarily allows the specification of ZFS pools and datasets. The declared pools and datasets are complemented with some default and are then used by [`lib/setup-scripts/zfs.sh`](../../lib/setup-scripts/zfs.sh) to create them during system installation, and can optionally later be kept up to date (with the config) at config activation time or during reboot.
Additionally, this module sets some defaults for ZFS (but only in a "always better than nothing" style, so `lib.mkForce null` should never be necessary).
## Implementation
```nix
#*/# end of MarkDown, beginning of NixOS module:
dirname: inputs: { config, pkgs, lib, ... }: let inherit (inputs.self) lib; in let
cfg = config.${prefix}.fs.zfs;
prefix = inputs.config.prefix;
hash = builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName);
in let module = {
options.${prefix} = { fs.zfs = {
enable = lib.mkEnableOption "NixOS managed ZFS pools and datasets";
pools = lib.mkOption {
description = "ZFS pools created during this host's installation.";
type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: { options = {
name = lib.mkOption { description = "Attribute name as name of the pool."; type = lib.types.str; default = name; readOnly = true; };
vdevArgs = lib.mkOption { description = "List of arguments that specify the virtual devices (vdevs) used when initially creating the pool. Can consist of the device type keywords and partition labels. The latter are prefixed with »/dev/mapper/« if a mapping with that name is configured or »/dev/disk/by-partlabel/« otherwise, and then the resulting argument sequence is is used verbatim in »zpool create«."; type = lib.types.listOf lib.types.str; default = [ name ]; example = [ "raidz1 data1-..." "data2-..." "data3-..." "cache" "cache-..." ]; };
props = lib.mkOption { description = "Zpool properties to pass when creating the pool. May also set »feature@...« and »compatibility«."; type = lib.types.attrsOf (lib.types.nullOr lib.types.str); default = { }; };
autoApplyDuringBoot = (lib.mkEnableOption "automatically re-applying changed dataset properties and create missing datasets in the initramfs phase during boot for this pool> This can be useful since the keystore is open but no datasets are mounted at that time") // { default = true; };
autoApplyOnActivation = (lib.mkEnableOption "automatically re-applying changed dataset properties and create missing datasets on system activation for this pool. This may fail for some changes since datasets may be mounted and the keystore is usually closed at this time. Enable ».autoApplyDuringBoot« and reboot to address this") // { default = true; };
}; config = {
props.ashift = lib.mkOptionDefault "12"; # be explicit
props.comment = lib.mkOptionDefault "hostname=${config.networking.hostName};"; # This is just nice to know without needing to inspect the datasets.
props.cachefile = lib.mkOptionDefault "none"; # If it works on first boot without (stateful) cachefile, then it will also do so later.
}; }));
default = { };
};
datasets = lib.mkOption {
description = "ZFS datasets managed and mounted on this host.";
type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: { options = {
name = lib.mkOption { description = "Attribute name as name of the pool."; type = lib.types.str; default = name; readOnly = true; };
props = lib.mkOption { description = "ZFS properties to set on the dataset."; type = lib.types.attrsOf (lib.types.nullOr lib.types.str); default = { }; };
mount = lib.mkOption { description = "Whether to create a »fileSystems« entry to mount the dataset. »noauto« creates an entry with that option set."; type = lib.types.enum [ true "noauto" false ]; default = false; };
permissions = lib.mkOption { description = ''Permissions to set on the dataset via »zfs allow«. Attribute names should express propagation/who and match »/^[dl]?([ug]\d+|e)$/«, the values are the list of permissions granted.''; type = lib.types.attrsOf lib.types.commas; default = { }; };
uid = lib.mkOption { description = "UID owning the dataset's root directory."; type = lib.types.ints.unsigned; default = 0; };
gid = lib.mkOption { description = "GID owning the dataset's root directory."; type = lib.types.ints.unsigned; default = 0; };
mode = lib.mkOption { description = "Permission mode of the dataset's root directory."; type = lib.types.str; default = "750"; };
}; config = {
props.canmount = lib.mkOptionDefault "off"; # (need to know this explicitly for each dataset)
}; }));
default = { };
};
extraInitrdPools = lib.mkOption { description = "Additional pool that are imported in the initrd."; type = lib.types.listOf lib.types.str; default = [ ]; apply = lib.unique; };
}; };
config = let
in lib.mkIf cfg.enable (lib.mkMerge [ ({
# boot.(initrd.)supportedFilesystems = [ "zfs" ]; # NixOS should figure that out itself based on zfs being used in »config.fileSystems«.
# boot.zfs.extraPools = [ ]; # Don't need to import pools that have at least one dataset listed in »config.fileSystems« / with ».mount != false«.
boot.zfs.devNodes = lib.mkDefault ''/dev/disk/by-partlabel" -d "/dev/mapper''; # Do automatic imports (initrd & systemd) by-partlabel or mapped device, instead of by-id, since that is how the pools were created. (This option is meant to support only a single path, but since it is not properly escaped, this works to pass two paths.)
services.zfs.autoScrub.enable = true;
services.zfs.trim.enable = true; # (default)
fileSystems = lib.wip.mapMerge (path: { props, mount, ... }: if mount != false then {
"${props.mountpoint}" = { fsType = "zfs"; device = path; options = [ "zfsutil" ] ++ (lib.optionals (mount == "noauto") [ "noauto" ]); };
} else { }) cfg.datasets;
${prefix} = {
# Set default root dataset properties for every pool:
fs.zfs.datasets = lib.mapAttrs (name: { ... }: { props = {
# Properties to set at the root dataset of the root pool at its creation. All are inherited by default, but some can't be changed later.
devices = lib.mkOptionDefault "off"; # Don't allow character or block devices on the file systems, where they might be owned by non-root users.
setuid = lib.mkOptionDefault "off"; # Don't allow suid binaries (NixOS puts them in »/run/wrappers/«).
compression = lib.mkOptionDefault "lz4"; # Seems to be the best compromise between compression and CPU load.
atime = lib.mkOptionDefault "off"; relatime = lib.mkOptionDefault "on"; # Very much don't need access times at all.
acltype = lib.mkOptionDefault "posix"; # Enable ACLs (access control lists) on linux; might be useful at some point. (»posix« is the same as »posixacl«, but this is the normalized form)
xattr = lib.mkOptionDefault "sa"; # Allow extended attributes and store them as system attributes, recommended with »acltype=posix«.
dnodesize = lib.mkOptionDefault "auto"; # Recommenced with »xattr=sa«. (A dnode is roughly equal to inodes, storing file directory or meta data.)
#normalization = lib.mkOptionDefault "formD"; # Don't enforce utf-8, and thus don't normalize file names; instead accept any byte stream as file name.
canmount = lib.mkOptionDefault "off"; mountpoint = lib.mkOptionDefault "none"; # Assume the pool root is a "container", unless overwritten.
}; }) cfg.pools;
# All pools that have at least one dataset that (explicitly or implicitly) has a key to be loaded from »/run/keystore-.../zfs/« have to be imported in the initramfs while the keystore is open:
fs.zfs.extraInitrdPools = (lib.mapAttrsToList (name: _: lib.head (lib.splitString "/" name)) (lib.filterAttrs (name: { props, ... }: if props?keylocation then lib.wip.startsWith "file:///run/keystore-" props.keylocation else config.${prefix}.fs.keystore.keys?"zfs/${name}") cfg.datasets));
# Might as well set some defaults for all partitions required (though for all but one at least some of the values will need to be changed):
fs.disks.partitions = lib.wip.mapMergeUnique (name: { ${name} = { # (This also implicitly ensures that no partition is used twice for zpools.)
type = lib.mkDefault "bf00"; size = lib.mkOptionDefault null; order = lib.mkDefault 500;
}; }) (lib.wip.filterMismatching ''/|^(mirror|raidz[123]?|draid[123]?.*|spare|log|dedup|special|cache)$'' (lib.concatLists (lib.catAttrs "vdevArgs" (lib.attrValues cfg.pools))));
};
boot.initrd.postDeviceCommands = (lib.mkAfter ''
${lib.concatStringsSep "\n" (map verbose.initrd-import-zpool cfg.extraInitrdPools)}
${verbose.initrd-load-keys}
'');
}) (let
inherit (config.system.build) extraUtils;
anyPool = filterBy: lib.any (pool: pool.${filterBy}) (lib.attrValues cfg.pools);
poolNames = filterBy: lib.attrNames (lib.filterAttrs (name: pool: pool.${filterBy}) cfg.pools);
filter = pool: "^${pool}($|[/])";
ensure-datasets = pkgs.writeShellScript "ensure-datasets" ''
${lib.wip.substituteImplicit { inherit pkgs; scripts = { inherit (lib.wip.setup-scripts) zfs utils; }; context = { inherit config; }; }}
set -eu ; ensure-datasets "$@"
'';
ensure-datasets-for = filterBy: zfs: ''( if [ ! "''${IN_NIXOS_ENTER:-}" ] && [ -e ${zfs} ] ; then
PATH=$(dirname $(realpath ${zfs})):$PATH # (want to use the version that the kernel module uses)
${lib.concatStrings (map (pool: ''
expected=${lib.escapeShellArg (builtins.toJSON (lib.mapAttrs (n: v: v.props) (lib.filterAttrs (path: _: path == pool || lib.wip.startsWith "${pool}/" path) cfg.datasets)))}
if [ "$(zfs get -H -o value nixos-${prefix}:applied-datasets ${pool})" != "$expected" ] ; then
${ensure-datasets} / ${lib.escapeShellArg (filter pool)} && zfs set nixos-${prefix}:applied-datasets="$expected" ${pool}
fi
'') (poolNames filterBy))}
fi )'';
in {
boot.initrd.postDeviceCommands = lib.mkIf (anyPool "autoApplyDuringBoot") (lib.mkAfter ''
${ensure-datasets-for "autoApplyDuringBoot" "${extraUtils}/bin/zfs"}
'');
boot.initrd.supportedFilesystems = lib.mkIf (anyPool "autoApplyDuringBoot") [ "zfs" ];
${prefix}.fs.zfs.extraInitrdPools = (poolNames "autoApplyDuringBoot");
system.activationScripts.A_ensure-datasets = lib.mkIf (anyPool "autoApplyOnActivation") {
text = ensure-datasets-for "autoApplyOnActivation" "/run/booted-system/sw/bin/zfs";
}; # these are sorted alphabetically, unless one gets "lifted up" by some other ending on it via its ».deps« field
}) ]);
}; verbose = {
# copied verbatim from https://github.com/NixOS/nixpkgs/blob/f989e13983fd1619f723b42ba271fe0b781dd24b/nixos/modules/tasks/filesystems/zfs.nix
# It would be nice if this was done in a somewhat more composable way (why isn't this a function?) ...
initrd-import-zpool = pool: ''
echo -n "importing root ZFS pool \"${pool}\"..."
# Loop across the import until it succeeds, because the devices needed may not be discovered yet.
if ! poolImported "${pool}"; then
for trial in `seq 1 60`; do
poolReady "${pool}" > /dev/null && msg="$(poolImport "${pool}" 2>&1)" && break
sleep 1
echo -n .
done
echo
if [[ -n "$msg" ]]; then
echo "$msg";
fi
poolImported "${pool}" || poolImport "${pool}" # Try one last time, e.g. to import a degraded pool.
fi
'';
initrd-load-keys = let
inherit (lib) isBool optionalString concatMapStrings; cfgZfs = config.boot.zfs;
in ''
${if isBool cfgZfs.requestEncryptionCredentials
then optionalString cfgZfs.requestEncryptionCredentials ''
zfs load-key -a
''
else concatMapStrings (fs: ''
zfs load-key ${fs}
'') cfgZfs.requestEncryptionCredentials}
'';
}; in module

View File

@ -66,6 +66,7 @@ in {
}) (lib.mkIf (cfg.rootKeys != [ ]) {
# TODO: This is suboptimal when the system gets activated more than once. Could use a »tmpfiles« rule, or simply »>« (instead of »>>« here).
system.activationScripts.root-authorized_keys = ''
mkdir -pm 700 /root/.ssh/
[ -e /root/.ssh/authorized_keys ] || install -m 600 -T /dev/null /root/.ssh/authorized_keys