A plain fstab entry is invisible to Thunar and friends — they list removable media and gvfs entries, so the disk could only be reached by typing its path. x-gvfs-show puts it in the sidebar, and because the entry is noauto it appears as a drive that is not mounted yet, which is what it usually is. Note when changing this: the gvfs volume monitor reads fstab at startup, so an already-running one has to be restarted before the entry shows up.
284 lines
10 KiB
Nix
284 lines
10 KiB
Nix
# ABOUTME: Declarative disk layout for every host: GPT, optional LUKS2, btrfs.
|
|
# ABOUTME: A host sets moonarch.disk.device; the layout itself is shared.
|
|
|
|
{ config, lib, ... }:
|
|
|
|
let
|
|
cfg = config.moonarch.disk;
|
|
|
|
mountOptions = [ "compress=zstd" "noatime" ];
|
|
|
|
systemSubvolumes = {
|
|
"/root" = { mountpoint = "/"; inherit mountOptions; };
|
|
# Separate so the store stays out of the snapper snapshots of /. It is
|
|
# reproducible from the flake and would only inflate them.
|
|
"/nix" = { mountpoint = "/nix"; inherit mountOptions; };
|
|
} // lib.optionalAttrs (!cfg.home.enable) {
|
|
# Only here when there is no second disk. With one, /home lives over there
|
|
# and the main disk carries the system alone.
|
|
"/home" = { mountpoint = "/home"; inherit mountOptions; };
|
|
} // lib.optionalAttrs (cfg.swapSize != "") {
|
|
# No compression or noatime here: disko creates the swapfile inside this
|
|
# subvolume, and btrfs requires it to be nodatacow, which it sets itself.
|
|
"/swap" = {
|
|
mountpoint = "/.swapvol";
|
|
swap.swapfile.size = cfg.swapSize;
|
|
};
|
|
};
|
|
|
|
homeSubvolumes = {
|
|
"/home" = { mountpoint = "/home"; inherit mountOptions; };
|
|
};
|
|
|
|
# A disk holding nothing anyone waits for is allowed to be absent. These
|
|
# options make that a non-event: the mount happens on first access rather than
|
|
# at boot, systemd stops waiting for the device after five seconds, and the
|
|
# filesystem is unmounted again once nothing has touched it for ten minutes,
|
|
# which leaves the disk idle and cool most of the time.
|
|
scratchMountOptions = mountOptions ++ [
|
|
"noauto"
|
|
"nofail"
|
|
"x-systemd.automount"
|
|
"x-systemd.device-timeout=5"
|
|
"x-systemd.mount-timeout=10"
|
|
"x-systemd.idle-timeout=600"
|
|
# Without this the disk is reachable only by typing its path: file managers
|
|
# list removable media and gvfs entries, not plain fstab lines. Since the
|
|
# entry is noauto, it shows up as a drive that is not mounted yet, which is
|
|
# what it usually is.
|
|
"x-gvfs-show"
|
|
"x-gvfs-name=Storage"
|
|
];
|
|
|
|
storageSubvolumes = {
|
|
"/storage" = { mountpoint = "/storage"; mountOptions = scratchMountOptions; };
|
|
};
|
|
|
|
btrfs = subvolumes: {
|
|
type = "btrfs";
|
|
# Overwrite an existing signature instead of asking. The device is erased
|
|
# by this point either way.
|
|
extraArgs = [ "-f" ];
|
|
inherit subvolumes;
|
|
};
|
|
|
|
# Neither passwordFile nor settings.keyFile is set, which makes disko's
|
|
# askPassword default to true: it prompts for the passphrase twice while
|
|
# formatting and never puts it on a command line.
|
|
#
|
|
# With a second disk each container is asked for separately while formatting.
|
|
# Give both the same passphrase: systemd stage 1 caches the first one and
|
|
# tries it on the second container, so the boot prompts once. If it ever asks
|
|
# twice, boot.initrd.systemd.enable = false falls back to the script initrd,
|
|
# whose boot.initrd.luks.reusePassphrases does the same thing explicitly.
|
|
luks = name: content: {
|
|
type = "luks";
|
|
inherit name;
|
|
# Lets TRIM reach the SSD through the container. The tradeoff is that the
|
|
# pattern of used blocks becomes visible on the raw device.
|
|
settings.allowDiscards = true;
|
|
inherit content;
|
|
};
|
|
|
|
# Same container, opened after stage 1 instead of inside the initrd. A disk
|
|
# that stage 1 waits for can stop the machine from booting when it is missing
|
|
# or has died, which is exactly what must not happen for expendable storage;
|
|
# initrdUnlock = false keeps it out of there. Nothing then opens it on its own,
|
|
# so the caller has to supply a crypttab entry — see storageCrypttab below.
|
|
luksAfterInitrd = name: content: (luks name content) // { initrdUnlock = false; };
|
|
|
|
# Wraps a filesystem in LUKS unless the host opted out of encryption.
|
|
maybeEncrypted = name: subvolumes:
|
|
if cfg.encrypt then luks name (btrfs subvolumes) else btrfs subvolumes;
|
|
in
|
|
{
|
|
options.moonarch.disk = {
|
|
enable = lib.mkEnableOption "the declarative disk layout";
|
|
|
|
device = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "/dev/disk/by-id/nvme-eui.0123456789abcdef";
|
|
description = ''
|
|
Whole disk to partition, as a /dev/disk/by-id/ path — that one survives
|
|
enumeration changes, unlike /dev/nvme0n1.
|
|
|
|
This value decides which disk gets erased. Check it against lsblk on the
|
|
target machine before installing.
|
|
|
|
Only the partitioning reads it. The generated fileSystems entries go
|
|
through /dev/disk/by-partlabel/, so a booted system does not depend on
|
|
the path staying the same.
|
|
'';
|
|
};
|
|
|
|
home = {
|
|
enable = lib.mkEnableOption "a second disk carrying /home";
|
|
|
|
device = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "/dev/disk/by-id/nvme-eui.fedcba9876543210";
|
|
description = ''
|
|
Second whole disk, erased and partitioned like the first one. It takes
|
|
the /home subvolume, which then no longer exists on the main disk.
|
|
|
|
Encryption follows moonarch.disk.encrypt, so both disks are either
|
|
encrypted or neither is. The container is named crypted-home to keep
|
|
it apart from the system one.
|
|
|
|
disko-install addresses it as `--disk home <path>`, the same way the
|
|
main disk is `--disk main <path>`.
|
|
'';
|
|
};
|
|
};
|
|
|
|
storage = {
|
|
enable = lib.mkEnableOption "a disk for data whose loss costs nothing";
|
|
|
|
device = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "/dev/disk/by-id/nvme-eui.0f1e2d3c4b5a6978";
|
|
description = ''
|
|
Whole disk for expendable data, mounted at /storage — a game library,
|
|
downloads, images. Nothing that would be missed and nothing anyone
|
|
waits for, because this disk is allowed to be slow, absent or dead.
|
|
|
|
Unlike the other two it is never unlocked in the initrd, so a failure
|
|
cannot hold up the boot. It is mounted on first access instead and
|
|
unmounted again when idle. That is what makes it usable for a drive
|
|
that is not entirely trustworthy.
|
|
|
|
disko-install addresses it as `--disk storage <path>`.
|
|
'';
|
|
};
|
|
|
|
keyFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "/var/keys/storage.key";
|
|
description = ''
|
|
Key file that unlocks the container without a prompt. It has to live on
|
|
an already-decrypted filesystem — the default sits on the root disk, so
|
|
it is covered by that disk's encryption and unreadable without the boot
|
|
passphrase.
|
|
|
|
Create it once and add it as a second key slot; the existing passphrase
|
|
keeps working:
|
|
|
|
dd bs=512 count=4 if=/dev/random of=<keyFile> iflag=fullblock
|
|
chmod 400 <keyFile>
|
|
cryptsetup luksAddKey <partition> <keyFile>
|
|
'';
|
|
};
|
|
};
|
|
|
|
encrypt = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Put the filesystem inside a LUKS2 container. The layout is otherwise
|
|
identical, so a machine that does not need encryption — a throwaway VM —
|
|
gets the same subvolumes.
|
|
'';
|
|
};
|
|
|
|
espSize = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "1G";
|
|
description = ''
|
|
Size of the EFI system partition. Every boot entry keeps its kernel and
|
|
initrd here, so this scales with the number of generations kept.
|
|
'';
|
|
};
|
|
|
|
swapSize = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "8G";
|
|
example = "";
|
|
description = ''
|
|
Size of the swapfile in the /.swapvol subvolume. Empty string creates no
|
|
swap at all.
|
|
|
|
Sized as a safety valve, not as a replacement for RAM. Hibernation needs
|
|
at least as much swap as RAM plus a resume_offset kernel parameter, which
|
|
is not set up here — see DECISIONS.md.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable (lib.mkMerge [
|
|
{
|
|
disko.devices.disk.main = {
|
|
type = "disk";
|
|
device = cfg.device;
|
|
content = {
|
|
type = "gpt";
|
|
partitions = {
|
|
ESP = {
|
|
size = cfg.espSize;
|
|
type = "EF00";
|
|
content = {
|
|
type = "filesystem";
|
|
format = "vfat";
|
|
mountpoint = "/boot";
|
|
mountOptions = [ "umask=0077" ];
|
|
};
|
|
};
|
|
# Named the same whether or not it is encrypted, so the partlabel the
|
|
# fileSystems entries point at does not depend on cfg.encrypt.
|
|
root = {
|
|
size = "100%";
|
|
content = maybeEncrypted "crypted" systemSubvolumes;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|
|
|
|
# Second disk. No ESP on it: the firmware boots from the main disk, and a
|
|
# second one would only be another thing to keep in sync.
|
|
(lib.mkIf cfg.home.enable {
|
|
disko.devices.disk.home = {
|
|
type = "disk";
|
|
device = cfg.home.device;
|
|
content = {
|
|
type = "gpt";
|
|
partitions.home = {
|
|
size = "100%";
|
|
content = maybeEncrypted "crypted-home" homeSubvolumes;
|
|
};
|
|
};
|
|
};
|
|
})
|
|
|
|
# Expendable storage, same shape as the disk above with one difference that
|
|
# changes everything about it: its container never enters the initrd, so a
|
|
# drive that is slow, missing or dead cannot stop the machine from booting.
|
|
(lib.mkIf cfg.storage.enable {
|
|
disko.devices.disk.storage = {
|
|
type = "disk";
|
|
device = cfg.storage.device;
|
|
content = {
|
|
type = "gpt";
|
|
partitions.storage = {
|
|
size = "100%";
|
|
content =
|
|
if cfg.encrypt
|
|
then luksAfterInitrd "storage" (btrfs storageSubvolumes)
|
|
else btrfs storageSubvolumes;
|
|
};
|
|
};
|
|
};
|
|
})
|
|
|
|
# Keeping it out of the initrd means nothing opens it any more, so this is
|
|
# what does. The partition is addressed by its partlabel, which disko derives
|
|
# from the disk and partition names above — no UUID has to be pasted in here,
|
|
# and a replaced disk needs no edit. noauto leaves the opening to the mount
|
|
# in storageSubvolumes, nofail keeps a failure from becoming a boot problem.
|
|
(lib.mkIf (cfg.storage.enable && cfg.encrypt) {
|
|
environment.etc.crypttab.text = ''
|
|
storage /dev/disk/by-partlabel/disk-storage-storage ${cfg.storage.keyFile} noauto,nofail
|
|
'';
|
|
})
|
|
]);
|
|
}
|