Compare commits
14 Commits
staging/im
...
wip/imperm
Author | SHA1 | Date | |
---|---|---|---|
aeb2f63d65 | |||
528ffdb58e | |||
b6887b305e | |||
08dfc80c98 | |||
5a273213f6 | |||
0a6d88dfc1 | |||
50dfd482cf | |||
9743aee79d | |||
0819899102 | |||
d3ff68217e | |||
1a96859994 | |||
af92a2250e | |||
d00f9b15d7 | |||
aa1c1f40cb |
@@ -18,6 +18,16 @@
|
||||
sane.packages.enableConsolePkgs = true;
|
||||
sane.packages.enableSystemPkgs = true;
|
||||
|
||||
sane.impermanence.dirs = [
|
||||
"/var/log"
|
||||
"/var/backup" # for e.g. postgres dumps
|
||||
# TODO: move elsewhere
|
||||
"/var/lib/alsa" # preserve output levels, default devices
|
||||
"/var/lib/bluetooth" # preserve bluetooth handshakes
|
||||
"/var/lib/colord" # preserve color calibrations (?)
|
||||
"/var/lib/machines" # maybe not needed, but would be painful to add a VM and forget.
|
||||
];
|
||||
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
# time.timeZone = "America/Los_Angeles";
|
||||
|
@@ -10,7 +10,7 @@
|
||||
# so for now, generate something unique from the host ssh key.
|
||||
# TODO: move this into modules?
|
||||
system.activationScripts.machine-id = {
|
||||
deps = [ "persist-ssh-host-keys" ];
|
||||
deps = [ "etc" ];
|
||||
text = "sha256sum /etc/ssh/host_keys/ssh_host_ed25519_key | cut -c 1-32 > /etc/machine-id";
|
||||
};
|
||||
}
|
||||
|
@@ -33,10 +33,6 @@
|
||||
# You can avoid this by adding a string to the full path instead, i.e.
|
||||
# sops.defaultSopsFile = "/root/.sops/secrets/example.yaml";
|
||||
sops.defaultSopsFile = ../../secrets/universal.yaml;
|
||||
# This will automatically import SSH keys as age keys
|
||||
sops.age.sshKeyPaths = [
|
||||
"/etc/ssh/host_keys/ssh_host_ed25519_key"
|
||||
];
|
||||
sops.gnupg.sshKeyPaths = []; # disable RSA key import
|
||||
# This is using an age key that is expected to already be in the filesystem
|
||||
# sops.age.keyFile = "/home/colin/.ssh/age.pub";
|
||||
|
@@ -1,18 +1,10 @@
|
||||
{ ... }:
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
# we can't naively `mount /etc/ssh/host_keys` directly,
|
||||
# as /etc/fstab may not be populated yet (since that file depends on e.g. activationScripts.users)
|
||||
# we can't even depend on impermanence's `createPersistentStorageDirs` to create the source/target directories
|
||||
# since that also depends on `users`.
|
||||
# previously we manually `mount --bind` the host_keys here, but it's difficult to make that idempotent.
|
||||
# symlinking seems to work just as well, and is easier to make idempotent
|
||||
system.activationScripts.persist-ssh-host-keys.text = ''
|
||||
mkdir -p /etc/ssh
|
||||
ln -sf /nix/persist/etc/ssh/host_keys /etc/ssh/
|
||||
'';
|
||||
environment.etc."ssh/host_keys".source = "/nix/persist/etc/ssh/host_keys";
|
||||
|
||||
services.openssh.hostKeys = [
|
||||
{ type = "rsa"; bits = 4096; path = "/etc/ssh/host_keys/ssh_host_rsa_key"; }
|
||||
{ type = "ed25519"; path = "/etc/ssh/host_keys/ssh_host_ed25519_key"; }
|
||||
];
|
||||
|
||||
}
|
||||
|
@@ -84,7 +84,8 @@ in
|
||||
|
||||
sane.impermanence.home-dirs = [
|
||||
# cache is probably too big to fit on the tmpfs
|
||||
{ directory = ".cache"; encryptedClearOnBoot = true; }
|
||||
# { directory = ".cache"; encryptedClearOnBoot = true; }
|
||||
{ directory = ".cache/mozilla"; encryptedClearOnBoot = true; }
|
||||
".cargo"
|
||||
".rustup"
|
||||
# TODO: move this to ~/private!
|
||||
|
@@ -3,12 +3,14 @@
|
||||
{
|
||||
imports = [
|
||||
./allocations.nix
|
||||
./fs.nix
|
||||
./gui
|
||||
./home-manager
|
||||
./packages.nix
|
||||
./image.nix
|
||||
./impermanence.nix
|
||||
./impermanence
|
||||
./nixcache.nix
|
||||
./services
|
||||
./sops.nix
|
||||
];
|
||||
}
|
||||
|
155
modules/fs.nix
Normal file
155
modules/fs.nix
Normal file
@@ -0,0 +1,155 @@
|
||||
{ config, lib, pkgs, utils, ... }:
|
||||
with lib;
|
||||
let
|
||||
cfg = config.sane.fs;
|
||||
|
||||
# sane.fs."<path>" top-level options
|
||||
fsEntry = types.submodule ({ name, ...}: let
|
||||
parent = parentDir name;
|
||||
has-parent = hasParent name;
|
||||
parent-cfg = if has-parent then cfg."${parent}" else {};
|
||||
in {
|
||||
options = {
|
||||
dir = mkOption {
|
||||
type = mkDirEntryType (parent-cfg.dir or {
|
||||
user = "root";
|
||||
group = "root";
|
||||
mode = "0755";
|
||||
});
|
||||
};
|
||||
depends = mkOption {
|
||||
type = types.listOf types.str;
|
||||
description = "list of systemd services needed to be run before this service";
|
||||
default = [];
|
||||
};
|
||||
service = mkOption {
|
||||
type = types.str;
|
||||
description = "name of the systemd service which ensures this entry";
|
||||
default = "ensure-${utils.escapeSystemdPath name}";
|
||||
};
|
||||
};
|
||||
config = {
|
||||
# we put this here instead of as a `default` to ensure that users who specify additional
|
||||
# dependencies still get a dep on the parent (unless they assign with `mkForce`).
|
||||
depends = if has-parent then [ "${parent-cfg.service}.service" ] else [];
|
||||
};
|
||||
});
|
||||
# sane.fs."<path>".dir sub-options
|
||||
mkDirEntryType = defaults: types.submodule {
|
||||
options = {
|
||||
user = mkOption {
|
||||
type = types.str; # TODO: use uid?
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
};
|
||||
mode = mkOption {
|
||||
type = types.str;
|
||||
};
|
||||
};
|
||||
config = lib.mkDefault defaults;
|
||||
};
|
||||
|
||||
# given a fsEntry definition, output the `config` attrs it generates.
|
||||
mkFsConfig = path: opt: {
|
||||
systemd.services."${opt.service}" = {
|
||||
description = "prepare ${path}";
|
||||
script = ensure-dir-script;
|
||||
scriptArgs = "${path} ${opt.dir.user} ${opt.dir.group} ${opt.dir.mode}";
|
||||
serviceConfig.Type = "oneshot";
|
||||
after = opt.depends;
|
||||
wants = opt.depends;
|
||||
# prevent systemd making this unit implicitly dependent on sysinit.target.
|
||||
# see: <https://www.freedesktop.org/software/systemd/man/systemd.special.html>
|
||||
unitConfig.DefaultDependencies = "no";
|
||||
};
|
||||
};
|
||||
|
||||
# systemd/shell script used to create and set perms for a specific dir
|
||||
ensure-dir-script = ''
|
||||
path="$1"
|
||||
user="$2"
|
||||
group="$3"
|
||||
mode="$4"
|
||||
|
||||
if ! test -d "$path"
|
||||
then
|
||||
# if the directory *doesn't* exist, try creating it
|
||||
# if we fail to create it, ensure we raced with something else and that it's actually a directory
|
||||
mkdir "$path" || test -d "$path"
|
||||
fi
|
||||
chmod "$mode" "$path"
|
||||
chown "$user:$group" "$path"
|
||||
'';
|
||||
|
||||
# split the string path into a list of string components.
|
||||
# root directory "/" becomes the empty list [].
|
||||
# implicitly performs normalization so that:
|
||||
# splitPath "a//b/" => ["a" "b"]
|
||||
# splitPath "/a/b" => ["a" "b"]
|
||||
splitPath = str: builtins.filter (seg: (builtins.isString seg) && seg != "" ) (builtins.split "/" str);
|
||||
# return a string path, with leading slash but no trailing slash
|
||||
joinPathAbs = comps: "/" + (builtins.concatStringsSep "/" comps);
|
||||
concatPaths = paths: joinPathAbs (builtins.concatLists (builtins.map (p: splitPath p) paths));
|
||||
# normalize the given path
|
||||
normPath = str: joinPathAbs (splitPath str);
|
||||
# return the parent directory. doesn't care about leading/trailing slashes.
|
||||
# the parent of "/" is "/".
|
||||
parentDir = str: normPath (builtins.dirOf (normPath str));
|
||||
hasParent = str: (parentDir str) != (normPath str);
|
||||
|
||||
# return all ancestors of this path.
|
||||
# e.g. ancestorsOf "/foo/bar/baz" => [ "/" "/foo" "/foo/bar" ]
|
||||
ancestorsOf = path: if hasParent path then
|
||||
ancestorsOf (parentDir path) ++ [ (parentDir path) ]
|
||||
else
|
||||
[ ]
|
||||
;
|
||||
|
||||
# attrsOf fsEntry type which for every entry ensures that all ancestor entries are created.
|
||||
# we do this with a custom type to ensure that users can access `config.sane.fs."/parent/path"`
|
||||
# when inferred.
|
||||
fsTree = let
|
||||
baseType = types.attrsOf fsEntry;
|
||||
# merge is called once, with all collected `sane.fs` definitions passed and we coalesce those
|
||||
# into a single value `x` as if the user had wrote simply `sane.fs = x` in a single location.
|
||||
# so option defaulting and such happens *after* `merge` is called.
|
||||
merge = loc: defs: let
|
||||
# loc is the location of the option holding this type, e.g. ["sane" "fs"].
|
||||
# each def is an { value = attrsOf fsEntry instance; file = "..."; }
|
||||
pathsForDef = def: attrNames def.value;
|
||||
origPaths = concatLists (builtins.map pathsForDef defs);
|
||||
extraPaths = concatLists (builtins.map ancestorsOf origPaths);
|
||||
extraDefs = builtins.map (p: {
|
||||
file = ./.;
|
||||
value = {
|
||||
"${p}".dir = {};
|
||||
};
|
||||
}) extraPaths;
|
||||
in
|
||||
baseType.merge loc (defs ++ extraDefs);
|
||||
in
|
||||
lib.mkOptionType {
|
||||
inherit merge;
|
||||
name = "fsTree";
|
||||
description = "attrset representation of a file-system tree";
|
||||
# ensure that every path is in canonical form, else we might get duplicates and subtle errors
|
||||
check = tree: builtins.all (p: p == normPath p) (builtins.attrNames tree);
|
||||
};
|
||||
|
||||
in {
|
||||
options = {
|
||||
sane.fs = mkOption {
|
||||
# type = types.attrsOf fsEntry;
|
||||
type = fsTree;
|
||||
default = {};
|
||||
};
|
||||
};
|
||||
|
||||
config = let
|
||||
cfgs = builtins.attrValues (builtins.mapAttrs mkFsConfig cfg);
|
||||
in {
|
||||
# we can't lib.mkMerge at the top-level, so do it per-attribute
|
||||
systemd = lib.mkMerge (catAttrs "systemd" cfgs);
|
||||
};
|
||||
}
|
@@ -1,264 +0,0 @@
|
||||
# borrows from:
|
||||
# https://xeiaso.net/blog/paranoid-nixos-2021-07-18
|
||||
# https://elis.nu/blog/2020/05/nixos-tmpfs-as-root/
|
||||
# https://github.com/nix-community/impermanence
|
||||
{ config, impermanence, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.sane.impermanence;
|
||||
# taken from sops-nix code: checks if any secrets are needed to create /etc/shadow
|
||||
secretsForUsers = (lib.filterAttrs (_: v: v.neededForUsers) config.sops.secrets) != {};
|
||||
persist-base = "/nix/persist";
|
||||
encrypted-clear-on-boot-base = "/mnt/crypt/clearedonboot";
|
||||
encrypted-clear-on-boot-store = "/nix/persist/crypt/clearedonboot";
|
||||
encrypted-clear-on-boot-key = "/mnt/crypt/clearedonboot.key"; # TODO: move this to /tmp, but that requires tmp be mounted first?
|
||||
home-dir-defaults = {
|
||||
user = "colin";
|
||||
group = "users";
|
||||
mode = "0755";
|
||||
relativeTo = "/home/colin/";
|
||||
};
|
||||
sys-dir-defaults = {
|
||||
user = "root";
|
||||
group = "root";
|
||||
mode = "0755";
|
||||
relativeTo = "";
|
||||
};
|
||||
|
||||
# turn a path into a name suitable for systemd
|
||||
clean-name = path: let
|
||||
dashes = builtins.replaceStrings ["/"] ["-"] path;
|
||||
startswith = builtins.substring 0 1 dashes;
|
||||
in if startswith == "-"
|
||||
then substring 1 255 dashes
|
||||
else dashes
|
||||
;
|
||||
|
||||
dir-options = defaults: types.submodule {
|
||||
options = {
|
||||
encryptedClearOnBoot = mkOption {
|
||||
default = false;
|
||||
type = types.bool;
|
||||
};
|
||||
directory = mkOption {
|
||||
type = types.str;
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = defaults.user;
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = defaults.group;
|
||||
};
|
||||
mode = mkOption {
|
||||
type = types.str;
|
||||
default = defaults.mode;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
ingest-dir-option = defaults: opt:
|
||||
if isString opt then
|
||||
ingest-dir-option defaults { directory = opt; }
|
||||
else
|
||||
rec {
|
||||
encryptedClearOnBoot = opt.encryptedClearOnBoot or false;
|
||||
srcDevice = if encryptedClearOnBoot
|
||||
then encrypted-clear-on-boot-base
|
||||
else persist-base
|
||||
;
|
||||
srcPath = "${srcDevice}${directory}";
|
||||
directory = defaults.relativeTo + opt.directory;
|
||||
user = opt.user or defaults.user;
|
||||
group = opt.group or defaults.group;
|
||||
mode = opt.mode or defaults.mode;
|
||||
}
|
||||
;
|
||||
ingest-dir-options = defaults: opts: builtins.map (ingest-dir-option defaults) opts;
|
||||
ingested-home-dirs = ingest-dir-options home-dir-defaults cfg.home-dirs;
|
||||
ingested-sys-dirs = ingest-dir-options sys-dir-defaults cfg.dirs;
|
||||
ingested-default-dirs = ingest-dir-options sys-dir-defaults [
|
||||
"/var/log"
|
||||
"/var/backup" # for e.g. postgres dumps
|
||||
# TODO: move elsewhere
|
||||
"/var/lib/alsa" # preserve output levels, default devices
|
||||
"/var/lib/bluetooth" # preserve bluetooth handshakes
|
||||
"/var/lib/colord" # preserve color calibrations (?)
|
||||
"/var/lib/machines" # maybe not needed, but would be painful to add a VM and forget.
|
||||
];
|
||||
ingested-dirs = ingested-home-dirs ++ ingested-sys-dirs ++ ingested-default-dirs;
|
||||
ingested-crypt-dirs = builtins.filter (o: o.encryptedClearOnBoot) ingested-dirs;
|
||||
ingested-plain-dirs = builtins.filter (o: !o.encryptedClearOnBoot) ingested-dirs;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
sane.impermanence.enable = mkOption {
|
||||
default = false;
|
||||
type = types.bool;
|
||||
};
|
||||
sane.impermanence.root-on-tmpfs = mkOption {
|
||||
default = false;
|
||||
type = types.bool;
|
||||
description = "define / to be a tmpfs. make sure to mount some other device to /nix";
|
||||
};
|
||||
sane.impermanence.encrypted-clear-on-boot = mkOption {
|
||||
default = builtins.any (opt: opt.encryptedClearOnBoot) ingested-dirs;
|
||||
type = types.bool;
|
||||
description = "define ${encrypted-clear-on-boot-base} to be an encrypted filesystem which is unreadable after power-off";
|
||||
};
|
||||
sane.impermanence.home-dirs = mkOption {
|
||||
default = [];
|
||||
type = types.listOf (types.either types.str (dir-options home-dir-defaults));
|
||||
};
|
||||
sane.impermanence.dirs = mkOption {
|
||||
default = [];
|
||||
type = types.listOf (types.either types.str (dir-options sys-dir-defaults));
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable (lib.mkMerge [
|
||||
(lib.mkIf cfg.root-on-tmpfs {
|
||||
fileSystems."/" = {
|
||||
device = "none";
|
||||
fsType = "tmpfs";
|
||||
options = [
|
||||
"mode=755"
|
||||
"size=1G"
|
||||
"defaults"
|
||||
];
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.encrypted-clear-on-boot {
|
||||
# without this, we get `fusermount: fuse device not found, try 'modprobe fuse' first`.
|
||||
# - that only happens after a activation-via-boot -- not activation-after-rebuild-switch.
|
||||
# it seems likely that systemd loads `fuse` by default. see:
|
||||
# - </etc/systemd/system/sysinit.target.wants/sys-fs-fuse-connections.mount>
|
||||
# - triggers: /etc/systemd/system/modprobe@.service
|
||||
# - calls `modprobe`
|
||||
# note: even `boot.kernelModules = ...` isn't enough: that option creates /etc/modules-load.d/, which is ingested only by systemd.
|
||||
# note: `boot.initrd.availableKernelModules` ALSO isn't enough: idk why.
|
||||
# TODO: might not be necessary now we're using fileSystems and systemd
|
||||
boot.initrd.kernelModules = [ "fuse" ];
|
||||
|
||||
system.activationScripts.prepareEncryptedClearedOnBoot =
|
||||
let
|
||||
script = pkgs.writeShellApplication {
|
||||
name = "prepareEncryptedClearedOnBoot";
|
||||
runtimeInputs = with pkgs; [ gocryptfs ];
|
||||
text = ''
|
||||
backing="$1"
|
||||
passfile="$2"
|
||||
if ! test -e "$passfile"
|
||||
then
|
||||
tmpdir=$(dirname "$passfile")
|
||||
mkdir -p "$backing" "$tmpdir"
|
||||
# if the key doesn't exist, it's probably not mounted => delete the backing dir
|
||||
rm -rf "''${backing:?}"/*
|
||||
# generate key. we can "safely" keep it around for the lifetime of this boot
|
||||
dd if=/dev/random bs=128 count=1 | base64 --wrap=0 > "$passfile"
|
||||
# initialize the crypt store
|
||||
gocryptfs -quiet -passfile "$passfile" -init "$backing"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
in {
|
||||
text = ''${script}/bin/prepareEncryptedClearedOnBoot ${encrypted-clear-on-boot-store} ${encrypted-clear-on-boot-key}'';
|
||||
};
|
||||
|
||||
fileSystems."${encrypted-clear-on-boot-base}" = {
|
||||
device = encrypted-clear-on-boot-store;
|
||||
fsType = "fuse.gocryptfs";
|
||||
options = [
|
||||
"nodev"
|
||||
"nosuid"
|
||||
"allow_other"
|
||||
"passfile=${encrypted-clear-on-boot-key}"
|
||||
"defaults"
|
||||
];
|
||||
noCheck = true;
|
||||
};
|
||||
|
||||
environment.systemPackages = [ pkgs.gocryptfs ]; # fuse needs to find gocryptfs
|
||||
|
||||
system.activationScripts.createPersistentStorageDirs.deps = [ "prepareEncryptedClearedOnBoot" ];
|
||||
})
|
||||
|
||||
(
|
||||
let cfgFor = opt:
|
||||
let
|
||||
parent-mount = "mnt-crypt-clearedonboot";
|
||||
mount-service = clean-name opt.directory;
|
||||
perms-service = "impermanence-perms-${mount-service}";
|
||||
in {
|
||||
fileSystems = {
|
||||
name = opt.directory;
|
||||
value = {
|
||||
device = opt.srcPath;
|
||||
options = [
|
||||
"bind"
|
||||
"x-systemd.requires=${parent-mount}.mount"
|
||||
"x-systemd.after=${perms-service}.service"
|
||||
# `wants` doesn't seem to make it to the service file here :-(
|
||||
"x-systemd.wants=${perms-service}.service"
|
||||
];
|
||||
# fsType = "bind";
|
||||
noCheck = true;
|
||||
};
|
||||
};
|
||||
|
||||
# create services which ensure the source directories exist and have correct ownership/perms before mounting
|
||||
systemd.services = {
|
||||
name = "${perms-service}";
|
||||
value = {
|
||||
description = "prepare permissions for ${opt.directory}";
|
||||
serviceConfig = {
|
||||
ExecStart = pkgs.writeShellScript "${perms-service}" ''
|
||||
mkdir -p ${opt.srcPath}
|
||||
chmod ${opt.mode} ${opt.srcPath}
|
||||
chown ${opt.user}:${opt.group} ${opt.srcPath}
|
||||
'';
|
||||
Type = "oneshot";
|
||||
};
|
||||
after = [ "${parent-mount}.mount" ];
|
||||
wants = [ "${parent-mount}.mount" ];
|
||||
wantedBy = [ "${mount-service}.mount" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
in {
|
||||
fileSystems = builtins.listToAttrs (builtins.map (opt: (cfgFor opt).fileSystems) ingested-crypt-dirs);
|
||||
systemd.services = builtins.listToAttrs (builtins.map (opt: (cfgFor opt).systemd.services) ingested-crypt-dirs);
|
||||
}
|
||||
)
|
||||
|
||||
({
|
||||
# make sure logs from initrd can be persisted to disk -- i think?
|
||||
sane.image.extraDirectories = [ "/nix/persist/var/log" ];
|
||||
|
||||
environment.persistence."${persist-base}".directories = builtins.map (opt: {
|
||||
inherit (opt) directory user group mode;
|
||||
}) ingested-plain-dirs;
|
||||
|
||||
# for each edge in a mount path, impermanence gives that target directory the same permissions
|
||||
# as the matching folder in /nix/persist.
|
||||
# /nix/persist is often created with poor permissions. so patch them to get the desired directory permissions.
|
||||
system.activationScripts.fixImpermanencePerms = {
|
||||
text = "chmod ${config.users.users.colin.homeMode} /nix/persist/home/colin";
|
||||
deps = [ "users" ];
|
||||
};
|
||||
system.activationScripts.createPersistentStorageDirs.deps = [ "fixImpermanencePerms" ];
|
||||
|
||||
# secret decoding depends on /etc/ssh keys, which may be persisted
|
||||
system.activationScripts.setupSecrets.deps = [ "persist-ssh-host-keys" ];
|
||||
system.activationScripts.setupSecretsForUsers = lib.mkIf secretsForUsers {
|
||||
deps = [ "persist-ssh-host-keys" ];
|
||||
};
|
||||
# populated by ssh.nix, which persists /etc/ssh/host_keys
|
||||
system.activationScripts.persist-ssh-host-keys.text = lib.mkDefault "";
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
232
modules/impermanence/default.nix
Normal file
232
modules/impermanence/default.nix
Normal file
@@ -0,0 +1,232 @@
|
||||
# borrows from:
|
||||
# https://xeiaso.net/blog/paranoid-nixos-2021-07-18
|
||||
# https://elis.nu/blog/2020/05/nixos-tmpfs-as-root/
|
||||
# https://github.com/nix-community/impermanence
|
||||
{ config, lib, pkgs, utils, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.sane.impermanence;
|
||||
getStore = { encryptedClearOnBoot, ... }: (
|
||||
if encryptedClearOnBoot then {
|
||||
device = "/mnt/impermanence/crypt/clearedonboot";
|
||||
underlying = {
|
||||
path = "/nix/persist/crypt/clearedonboot";
|
||||
# TODO: consider moving this to /tmp, but that requires tmp be mounted first?
|
||||
type = "gocryptfs";
|
||||
key = "/mnt/impermanence/crypt/clearedonboot.key";
|
||||
};
|
||||
} else {
|
||||
device = "/nix/persist";
|
||||
# device = "/mnt/impermenanence/persist/plain";
|
||||
# underlying = {
|
||||
# path = "/nix/persist";
|
||||
# type = "bind";
|
||||
# };
|
||||
}
|
||||
);
|
||||
home-dir-defaults = {
|
||||
user = "colin";
|
||||
group = "users";
|
||||
mode = "0755";
|
||||
relativeTo = "/home/colin";
|
||||
};
|
||||
sys-dir-defaults = {
|
||||
user = "root";
|
||||
group = "root";
|
||||
mode = "0755";
|
||||
relativeTo = "";
|
||||
};
|
||||
|
||||
# turn a path into a name suitable for systemd
|
||||
cleanName = utils.escapeSystemdPath;
|
||||
|
||||
# split the string path into a list of string components.
|
||||
# root directory "/" becomes the empty list [].
|
||||
# implicitly performs normalization so that:
|
||||
# splitPath "a//b/" => ["a" "b"]
|
||||
# splitPath "/a/b" => ["a" "b"]
|
||||
splitPath = str: builtins.filter (seg: (builtins.isString seg) && seg != "" ) (builtins.split "/" str);
|
||||
# return a string path, with leading slash but no trailing slash
|
||||
joinPathAbs = comps: "/" + (builtins.concatStringsSep "/" comps);
|
||||
concatPaths = paths: joinPathAbs (builtins.concatLists (builtins.map (p: splitPath p) paths));
|
||||
|
||||
dirOptions = defaults: types.submodule {
|
||||
options = {
|
||||
encryptedClearOnBoot = mkOption {
|
||||
default = false;
|
||||
type = types.bool;
|
||||
};
|
||||
directory = mkOption {
|
||||
type = types.str;
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = defaults.user;
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = defaults.group;
|
||||
};
|
||||
mode = mkOption {
|
||||
type = types.str;
|
||||
default = defaults.mode;
|
||||
};
|
||||
};
|
||||
};
|
||||
mkDirsOption = defaults: mkOption {
|
||||
default = [];
|
||||
type = types.listOf (types.coercedTo types.str (d: { directory = d; }) (dirOptions defaults));
|
||||
# apply = map (d: if isString d then { directory = d; } else d);
|
||||
};
|
||||
|
||||
# expand user options with more context
|
||||
ingestDirOption = defaults: opt: {
|
||||
inherit (opt) user group mode;
|
||||
directory = concatPaths [ defaults.relativeTo opt.directory ];
|
||||
|
||||
## helpful context
|
||||
store = builtins.addErrorContext ''while ingestDirOption on ${opt.directory} with attrs ${builtins.concatStringsSep " " (attrNames opt)}''
|
||||
(getStore opt);
|
||||
};
|
||||
|
||||
ingestDirOptions = defaults: opts: builtins.map (ingestDirOption defaults) opts;
|
||||
ingested-home-dirs = ingestDirOptions home-dir-defaults cfg.home-dirs;
|
||||
ingested-sys-dirs = ingestDirOptions sys-dir-defaults cfg.dirs;
|
||||
ingested-dirs = ingested-home-dirs ++ ingested-sys-dirs;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
sane.impermanence.enable = mkOption {
|
||||
default = false;
|
||||
type = types.bool;
|
||||
};
|
||||
sane.impermanence.root-on-tmpfs = mkOption {
|
||||
default = false;
|
||||
type = types.bool;
|
||||
description = "define / to be a tmpfs. make sure to mount some other device to /nix";
|
||||
};
|
||||
sane.impermanence.home-dirs = mkDirsOption home-dir-defaults;
|
||||
sane.impermanence.dirs = mkDirsOption sys-dir-defaults;
|
||||
};
|
||||
|
||||
imports = [
|
||||
./root-on-tmpfs.nix
|
||||
];
|
||||
|
||||
config = mkIf cfg.enable (lib.mkMerge [
|
||||
{
|
||||
# TODO: move to sane.fs, to auto-ensure all user dirs?
|
||||
sane.fs."/home/colin".dir = {
|
||||
user = "colin";
|
||||
group = config.users.users.colin.group;
|
||||
mode = config.users.users.colin.homeMode;
|
||||
};
|
||||
|
||||
# without this, we get `fusermount: fuse device not found, try 'modprobe fuse' first`.
|
||||
# - that only happens after a activation-via-boot -- not activation-after-rebuild-switch.
|
||||
# it seems likely that systemd loads `fuse` by default. see:
|
||||
# - </etc/systemd/system/sysinit.target.wants/sys-fs-fuse-connections.mount>
|
||||
# - triggers: /etc/systemd/system/modprobe@.service
|
||||
# - calls `modprobe`
|
||||
# note: even `boot.kernelModules = ...` isn't enough: that option creates /etc/modules-load.d/, which is ingested only by systemd.
|
||||
# note: `boot.initrd.availableKernelModules` ALSO isn't enough: idk why.
|
||||
# TODO: might not be necessary now we're using fileSystems and systemd
|
||||
boot.initrd.kernelModules = [ "fuse" ];
|
||||
|
||||
# TODO: convert this to a systemd unit file?
|
||||
system.activationScripts.prepareEncryptedClearedOnBoot =
|
||||
let
|
||||
script = pkgs.writeShellApplication {
|
||||
name = "prepareEncryptedClearedOnBoot";
|
||||
runtimeInputs = with pkgs; [ gocryptfs ];
|
||||
text = ''
|
||||
backing="$1"
|
||||
passfile="$2"
|
||||
if ! test -e "$passfile"
|
||||
then
|
||||
tmpdir=$(dirname "$passfile")
|
||||
mkdir -p "$backing" "$tmpdir"
|
||||
# if the key doesn't exist, it's probably not mounted => delete the backing dir
|
||||
rm -rf "''${backing:?}"/*
|
||||
# generate key. we can "safely" keep it around for the lifetime of this boot
|
||||
dd if=/dev/random bs=128 count=1 | base64 --wrap=0 > "$passfile"
|
||||
# initialize the crypt store
|
||||
gocryptfs -quiet -passfile "$passfile" -init "$backing"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
store = getStore { encryptedClearOnBoot = true; };
|
||||
in {
|
||||
text = ''${script}/bin/prepareEncryptedClearedOnBoot ${store.underlying.path} ${store.underlying.key}'';
|
||||
};
|
||||
|
||||
fileSystems = let
|
||||
store = getStore { encryptedClearOnBoot = true; };
|
||||
in {
|
||||
"${store.device}" = {
|
||||
device = store.underlying.path;
|
||||
fsType = "fuse.gocryptfs";
|
||||
options = [
|
||||
"nodev"
|
||||
"nosuid"
|
||||
"allow_other"
|
||||
"passfile=${store.underlying.key}"
|
||||
"defaults"
|
||||
];
|
||||
noCheck = true;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ pkgs.gocryptfs ]; # fuse needs to find gocryptfs
|
||||
}
|
||||
|
||||
(
|
||||
let cfgFor = opt:
|
||||
let
|
||||
# systemd creates <path>.mount services for every fileSystems entry.
|
||||
# <path> gets escaped as part of that: this code tries to guess that escaped name here.
|
||||
# backing-mount = cleanName opt.store.device;
|
||||
mount-service = cleanName opt.directory;
|
||||
backing-path = concatPaths [ opt.store.device opt.directory ];
|
||||
|
||||
dir-service = config.sane.fs."${opt.directory}".service;
|
||||
backing-service = config.sane.fs."${backing-path}".service;
|
||||
in {
|
||||
# create destination and backing directory, with correct perms
|
||||
sane.fs."${opt.directory}".dir = {
|
||||
inherit (opt) user group mode;
|
||||
};
|
||||
sane.fs."${backing-path}".dir = {
|
||||
inherit (opt) user group mode;
|
||||
};
|
||||
# define the mountpoint.
|
||||
fileSystems."${opt.directory}" = {
|
||||
device = backing-path;
|
||||
options = [
|
||||
"bind"
|
||||
# "x-systemd.requires=${backing-mount}.mount" # this should be implicit
|
||||
"x-systemd.after=${backing-service}"
|
||||
"x-systemd.after=${dir-service}"
|
||||
# `wants` doesn't seem to make it to the service file here :-(
|
||||
# "x-systemd.wants=${backing-service}"
|
||||
# "x-systemd.wants=${dir-service}"
|
||||
];
|
||||
# fsType = "bind";
|
||||
noCheck = true;
|
||||
};
|
||||
systemd.services."${backing-service}".wantedBy = [ "${mount-service}.mount" ];
|
||||
systemd.services."${dir-service}".wantedBy = [ "${mount-service}.mount" ];
|
||||
|
||||
};
|
||||
cfgs = builtins.map cfgFor ingested-dirs;
|
||||
in {
|
||||
fileSystems = lib.mkMerge (catAttrs "fileSystems" cfgs);
|
||||
sane.fs = lib.mkMerge (catAttrs "fs" (catAttrs "sane" cfgs));
|
||||
systemd = lib.mkMerge (catAttrs "systemd" cfgs);
|
||||
}
|
||||
)
|
||||
|
||||
]);
|
||||
}
|
||||
|
16
modules/impermanence/root-on-tmpfs.nix
Normal file
16
modules/impermanence/root-on-tmpfs.nix
Normal file
@@ -0,0 +1,16 @@
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
cfg = config.sane.impermanence;
|
||||
in
|
||||
{
|
||||
fileSystems."/" = lib.mkIf (cfg.enable && cfg.root-on-tmpfs) {
|
||||
device = "none";
|
||||
fsType = "tmpfs";
|
||||
options = [
|
||||
"mode=755"
|
||||
"size=1G"
|
||||
"defaults"
|
||||
];
|
||||
};
|
||||
}
|
32
modules/sops.nix
Normal file
32
modules/sops.nix
Normal file
@@ -0,0 +1,32 @@
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
# taken from sops-nix code: checks if any secrets are needed to create /etc/shadow
|
||||
secrets-for-users = (lib.filterAttrs (_: v: v.neededForUsers) config.sops.secrets) != {};
|
||||
sops-files = config.sops.age.sshKeyPaths ++ config.sops.gnupg.sshKeyPaths ++ [ config.sops.age.keyFile ];
|
||||
keys-in-etc = builtins.any (p: builtins.substring 0 5 p == "/etc/") sops-files;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (secrets-for-users && keys-in-etc) {
|
||||
# secret decoding depends on keys in /etc/ (like the ssh host key), so make sure those are present.
|
||||
system.activationScripts.setupSecretsForUsers = lib.mkIf secrets-for-users {
|
||||
deps = [ "etc" ];
|
||||
};
|
||||
# TODO: we should selectively remove "users" and "groups", but keep manually specified deps?
|
||||
system.activationScripts.etc.deps = lib.mkForce [];
|
||||
assertions = builtins.concatLists (builtins.attrValues (
|
||||
builtins.mapAttrs
|
||||
(path: value: [
|
||||
{
|
||||
assertion = (builtins.substring 0 1 value.user) == "+";
|
||||
message = "non-numeric user for /etc/${path}: ${value.user} prevents early /etc linking";
|
||||
}
|
||||
{
|
||||
assertion = (builtins.substring 0 1 value.group) == "+";
|
||||
message = "non-numeric group for /etc/${path}: ${value.group} prevents early /etc linking";
|
||||
}
|
||||
])
|
||||
config.environment.etc
|
||||
));
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user