feat: desktop host with /home on a second disk

Adds hosts/desktop: 512G system disk, 1T disk for /home, each in its own
LUKS container. modules/disk.nix gains moonarch.disk.home, which moves the
/home subvolume to the second disk and drops it from the first.

New modules/user.nix declares moonarch.user without a default, so the login
name exists once per host instead of once per reference. Both hosts set an
initialPassword: swaylock authenticates through pam_unix and cannot unlock a
screen for an account without one.
This commit is contained in:
nevaforget committed 2026-08-20 12:09:07 +02:00
1 parent ec985a8f7f
commit eb9b5fd3d1
7 files changed
+246 -36

No files matched your search

Generated
+4 -4
View File
@@ -24,11 +24,11 @@
"moonarch": {
"flake": false,
"locked": {
"lastModified": 1786722426,
"narHash": "sha256-zJyJC/7GWLj5DOblOVEsJ/Ki+bZBh+2Uws07pA6r4No=",
"lastModified": 1787067355,
"narHash": "sha256-2n1XcruV9FxUHOh05HdETpep6IIk1PNpcl58MjMcrhA=",
"ref": "refs/heads/main",
"rev": "fe552fc7a5160597b9b73e5ec39b4fce4f63bcd1",
"revCount": 162,
"rev": "e21c046e3251317703e9dd2f2d4a76f3a5211329",
"revCount": 166,
"type": "git",
"url": "https://gitea.moonarch.de/nevaforget/moonarch.git"
},
+10 -2
View File
@@ -75,6 +75,7 @@
nixosModules.moonarch = { ... }: {
imports = [
inputs.disko.nixosModules.disko
./modules/user.nix
./modules/disk.nix
./modules/desktop.nix
./modules/greetd.nix
@@ -98,9 +99,16 @@
];
};
# Desktop: 512G system disk, 1T disk for /home.
desktop = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
./hosts/desktop
self.nixosModules.moonarch
];
};
# ThinkPad T14 Gen 3 (AMD Ryzen 7 PRO 6850U).
# Needs hosts/thinkpad/hardware-configuration.nix from the installer
# before it evaluates — see README.
thinkpad = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
+118
View File
@@ -0,0 +1,118 @@
# ABOUTME: Host configuration for the desktop: 512G system disk, 1T disk for /home.
# ABOUTME: Disk layout comes from modules/disk.nix, nothing is generated on the machine.
{ config, lib, pkgs, ... }:
{
# Both device paths are placeholders. disko-install overrides them with
# `--disk main <path>` and `--disk home <path>`, so the real paths are read
# from lsblk on the machine and never travel into this repository. The values
# here are deliberately invalid: without the flags the install fails instead
# of erasing whatever happens to be first in the enumeration.
moonarch.disk = {
enable = true;
device = "/dev/disk/by-id/SET-VIA-disko-install--disk-main";
home = {
enable = true;
device = "/dev/disk/by-id/SET-VIA-disko-install--disk-home";
};
};
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
boot.kernelPackages = pkgs.linuxPackages_zen;
boot.kernelParams = [ "quiet" ];
networking.hostName = "desktop";
networking.networkmanager.enable = true;
time.timeZone = "Europe/Berlin";
i18n.defaultLocale = "de_DE.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "de_DE.UTF-8";
LC_IDENTIFICATION = "de_DE.UTF-8";
LC_MEASUREMENT = "de_DE.UTF-8";
LC_MONETARY = "de_DE.UTF-8";
LC_NAME = "de_DE.UTF-8";
LC_NUMERIC = "de_DE.UTF-8";
LC_PAPER = "de_DE.UTF-8";
LC_TELEPHONE = "de_DE.UTF-8";
LC_TIME = "de_DE.UTF-8";
};
console.keyMap = "de";
services.xserver.xkb.layout = "de";
moonarch.user = "dkressler";
users.users.${config.moonarch.user} = {
isNormalUser = true;
extraGroups = [ "networkmanager" "wheel" "video" "input" "plugdev" "docker" ];
shell = pkgs.zsh;
# Autologin gets past the greeter without a password, but swaylock
# authenticates through pam_unix and cannot unlock a screen for an account
# that has none. `mutableUsers` is at its default, so this applies when the
# account is created and `passwd` overrides it afterwards. Change it after
# the first boot — this value is in the repository.
initialPassword = "moonarch";
};
# LUKS already asked for a passphrase at boot, so the greeter would only ask
# for a second one. initial_session runs once per boot — the greeter comes
# back on logout, which is also where a session gets switched. greetd's
# restart option flips itself off when this is set.
services.greetd.settings.initial_session = {
command = "${pkgs.niri}/bin/niri-session";
user = config.moonarch.user;
};
# Snapshots of root and home. snap-pac has no counterpart here: NixOS keeps
# its own generations, so pre/post package snapshots are redundant. /home is
# its own btrfs on the second disk, which snapper does not care about — a
# subvolume is a subvolume.
services.snapper = {
configs = {
root = {
SUBVOLUME = "/";
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
home = {
SUBVOLUME = "/home";
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
};
snapshotInterval = "hourly";
cleanupInterval = "1d";
};
# No nixos-hardware profile applies to a self-built desktop, so the firmware
# this machine needs is enabled here instead.
hardware.enableRedistributableFirmware = true;
# The nixos-hardware profile derives this from enableRedistributableFirmware
# on the ThinkPad; here nothing does, so without this line the CPU runs on the
# microcode in its BIOS.
hardware.cpu.amd.updateMicrocode = true;
# The GPU is AMD, so amdgpu and Mesa cover it and there is nothing to declare:
# the kernel module is in the default initrd and Mesa comes with the graphics
# stack. An NVIDIA card would have needed videoDrivers and hardware.nvidia.
services.fstrim.enable = true;
# modules/services.nix turns fwupd on for every host. Lenovo publishes to the
# LVFS, so it earns its place on the ThinkPad; desktop mainboard vendors
# mostly do not, which leaves a daemon with nothing to update. mkForce because
# the shared module sets it unconditionally.
services.fwupd.enable = lib.mkForce false;
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nixpkgs.config.allowUnfree = true;
# The release this machine is first installed with. It stays at this value
# for the life of the installation and is never raised by an update.
system.stateVersion = "26.11";
}
+9 -7
View File
@@ -1,7 +1,7 @@
# ABOUTME: Host configuration for the QEMU/KVM test VM.
# ABOUTME: Launcher script lives outside this repo at ~/VMs/moonix-vm.sh.
{ pkgs, modulesPath, ... }:
{ config, pkgs, modulesPath, ... }:
{
# virtio_blk and virtio_pci are not in boot.initrd.availableKernelModules by
@@ -32,23 +32,25 @@
i18n.defaultLocale = "de_DE.UTF-8";
console.keyMap = "de";
users.users.kresdo = {
moonarch.user = "moon";
users.users.${config.moonarch.user} = {
isNormalUser = true;
description = "Dominik Kressler";
extraGroups = [ "networkmanager" "wheel" "video" "input" ];
shell = pkgs.zsh;
# Throwaway password for a throwaway guest. `mutableUsers` is at its
# default, so this applies when the account is created and `passwd`
# overrides it afterwards.
initialPassword = "moonarch";
};
# LUKS already asked for a passphrase at boot, so the greeter would only ask
# for a second one. initial_session runs once per boot — the greeter comes
# back on logout, which is also where a session gets switched. greetd's
# restart option flips itself off when this is set.
#
# The account still needs a password: swaylock authenticates through
# pam_unix, and without a /etc/shadow entry the screen cannot be unlocked.
services.greetd.settings.initial_session = {
command = "${pkgs.niri}/bin/niri-session";
user = "kresdo";
user = config.moonarch.user;
};
# Development access from the host via the QEMU port forward on 127.0.0.1:2222.
+22 -15
View File
@@ -1,7 +1,7 @@
# ABOUTME: Host configuration for the ThinkPad T14 Gen 3 (AMD).
# ABOUTME: Disk layout comes from modules/disk.nix, nothing is generated on the machine.
{ pkgs, ... }:
{ config, pkgs, ... }:
{
# Partitioning, LUKS and the btrfs subvolumes. The device path is the only
@@ -35,20 +35,18 @@
console.keyMap = "de";
services.xserver.xkb.layout = "de";
users.users.dkressler = {
moonarch.user = "dkressler";
users.users.${config.moonarch.user} = {
isNormalUser = true;
description = "Dominik Kressler";
extraGroups = [ "networkmanager" "wheel" "video" "input" "plugdev" "docker" ];
shell = pkgs.zsh;
# No password is declared here, so a freshly installed system has no
# /etc/shadow entry for this account. Autologin gets past the greeter
# without one, but swaylock then cannot unlock the screen, so the password
# is still set once after installing, see README.
#
# To make it declarative instead, put the output of `mkpasswd -m sha-512`
# in initialHashedPassword. That commits a hash to the repository, which is
# offline attackable if the repository ever leaks:
# initialHashedPassword = "$6$...";
# Autologin gets past the greeter without a password, but swaylock
# authenticates through pam_unix and cannot unlock a screen for an account
# that has none. `mutableUsers` is at its default, so this applies when the
# account is created and `passwd` overrides it afterwards. Change it after
# the first boot — this value is in the repository.
initialPassword = "moonarch";
};
# LUKS already asked for a passphrase at boot, so the greeter would only ask
@@ -57,7 +55,7 @@
# restart option flips itself off when this is set.
services.greetd.settings.initial_session = {
command = "${pkgs.niri}/bin/niri-session";
user = "dkressler";
user = config.moonarch.user;
};
# Snapshots of root and home. snap-pac has no counterpart here: NixOS keeps
@@ -66,13 +64,13 @@
configs = {
root = {
SUBVOLUME = "/";
ALLOW_USERS = [ "dkressler" ];
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
home = {
SUBVOLUME = "/home";
ALLOW_USERS = [ "dkressler" ];
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
@@ -81,6 +79,15 @@
cleanupInterval = "1d";
};
# The nixos-hardware profile only sets hardware.cpu.amd.updateMicrocode as a
# default derived from this option, so without it there is neither a microcode
# update nor the redistributable firmware the WLAN card and amdgpu load.
hardware.enableRedistributableFirmware = true;
# Fingerprint reader. The NixOS module wires pam_fprintd into the PAM stacks
# it owns; enrol a finger with `fprintd-enroll` after installing.
services.fprintd.enable = true;
services.fstrim.enable = true;
services.fwupd.enable = true;
+63 -8
View File
@@ -8,12 +8,15 @@ let
mountOptions = [ "compress=zstd" "noatime" ];
subvolumes = {
systemSubvolumes = {
"/root" = { mountpoint = "/"; inherit mountOptions; };
"/home" = { mountpoint = "/home"; 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.
@@ -23,7 +26,11 @@ let
};
};
btrfs = {
homeSubvolumes = {
"/home" = { mountpoint = "/home"; inherit mountOptions; };
};
btrfs = subvolumes: {
type = "btrfs";
# Overwrite an existing signature instead of asking. The device is erased
# by this point either way.
@@ -34,14 +41,24 @@ let
# 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.
luks = {
#
# 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";
name = "crypted";
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;
content = btrfs;
inherit content;
};
# 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 = {
@@ -63,6 +80,26 @@ in
'';
};
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>`.
'';
};
};
encrypt = lib.mkOption {
type = lib.types.bool;
default = true;
@@ -97,7 +134,8 @@ in
};
};
config = lib.mkIf cfg.enable {
config = lib.mkIf cfg.enable (lib.mkMerge [
{
disko.devices.disk.main = {
type = "disk";
device = cfg.device;
@@ -118,10 +156,27 @@ in
# fileSystems entries point at does not depend on cfg.encrypt.
root = {
size = "100%";
content = if cfg.encrypt then luks else btrfs;
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;
};
};
};
})
]);
}
+20
View File
@@ -0,0 +1,20 @@
# ABOUTME: Declares the moonarch.user option; every host sets its own login name.
# ABOUTME: Autologin, group membership and snapper read moonarch.user from here.
{ lib, ... }:
{
options.moonarch.user = lib.mkOption {
type = lib.types.str;
example = "moon";
description = ''
Login name of the primary account, and therefore the directory name under
/home. Autologin, the group memberships and snapper's ALLOW_USERS all read
this, so the name is declared once instead of once per reference.
There is no default: the name belongs to the machine, so every host sets
it. Changing it on an installed system does not rename the home directory —
that stays behind under the old name.
'';
};
}