diff --git a/hosts/common/default.nix b/hosts/common/default.nix index 7cc9df91a..35346d2e2 100644 --- a/hosts/common/default.nix +++ b/hosts/common/default.nix @@ -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"; diff --git a/hosts/common/ssh.nix b/hosts/common/ssh.nix index a213f5283..cbfdc885d 100644 --- a/hosts/common/ssh.nix +++ b/hosts/common/ssh.nix @@ -6,6 +6,8 @@ # 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 + # + # TODO: this is just a symlink: can we define this the same way we would `environment.etc. = `? system.activationScripts.persist-ssh-host-keys.text = '' mkdir -p /etc/ssh ln -sf /nix/persist/etc/ssh/host_keys /etc/ssh/ diff --git a/hosts/common/users.nix b/hosts/common/users.nix index 2c59abc2e..bdf76ea7a 100644 --- a/hosts/common/users.nix +++ b/hosts/common/users.nix @@ -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! diff --git a/modules/default.nix b/modules/default.nix index 6b4718bea..9272ae0b6 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -7,7 +7,7 @@ ./home-manager ./packages.nix ./image.nix - ./impermanence.nix + ./impermanence ./nixcache.nix ./services ]; diff --git a/modules/home-manager/neovim.nix b/modules/home-manager/neovim.nix index aba76f936..7e10f77d1 100644 --- a/modules/home-manager/neovim.nix +++ b/modules/home-manager/neovim.nix @@ -2,7 +2,8 @@ lib.mkIf config.sane.home-manager.enable { - sane.impermanence.home-dirs = [ ".cache/vim-swap" ]; + # TODO(impermanence): re-enable! + # sane.impermanence.home-dirs = [ ".cache/vim-swap" ]; home-manager.users.colin.programs.neovim = { # neovim: https://github.com/neovim/neovim diff --git a/modules/impermanence.nix b/modules/impermanence.nix deleted file mode 100644 index 0ba3a698a..000000000 --- a/modules/impermanence.nix +++ /dev/null @@ -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: - # - - # - 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 ""; - }) - ]); -} - diff --git a/modules/impermanence/default.nix b/modules/impermanence/default.nix new file mode 100644 index 000000000..fbcc9889f --- /dev/null +++ b/modules/impermanence/default.nix @@ -0,0 +1,303 @@ +# 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, ... }: + +with lib; +let + cfg = config.sane.impermanence; + # 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) != {}; + 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 = path: let + dashes = builtins.replaceStrings ["/"] ["-"] path; + startswith = builtins.substring 0 1 dashes; + in if startswith == "-" + then substring 1 255 dashes + else dashes + ; + + # 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. + parentDir = str: normPath (builtins.dirOf (normPath str)); + + 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 ]; + # directory = throw (builtins.toString opt.directory); + # directory = builtins.traceVerbose opt.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; + + # include these anchor points as "virtual" nodes in below fs tree. + home-dir = { + inherit (home-dir-defaults) user group mode; + directory = normPath home-dir-defaults.relativeTo; + }; + root-dir = { + inherit (sys-dir-defaults) user group mode; + directory = normPath sys-dir-defaults.relativeTo; + }; + + unexpanded-tree = builtins.listToAttrs (builtins.map + (dir: { + name = dir.directory; + value = dir; + }) + (ingested-dirs ++ [ home-dir root-dir ]) + ); + + # ensures the provided node and all parent nodes exist + ensureNode = tree: path: ( + let + parent-path = parentDir path; + tree-with-parent = if parent-path == "/" + then tree + else ensureNode tree parent-path; + parent = tree-with-parent."${parent-path}"; + # how to initialize this node if it doesn't exist explicitly. + default-node = parent // { directory = path; }; + in + { "${path}" = default-node; } // tree-with-parent + ); + + # finally, this tree has no orphan nodes + expanded-tree = foldl' ensureNode unexpanded-tree (builtins.attrNames unexpanded-tree); +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; + }; + + config = mkIf cfg.enable (lib.mkMerge [ + (lib.mkIf cfg.root-on-tmpfs { + fileSystems."/" = { + device = "none"; + fsType = "tmpfs"; + options = [ + "mode=755" + "size=1G" + "defaults" + ]; + }; + }) + + { + # 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: + # - + # - 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 .mount services for every fileSystems entry. + # 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; + perms-service = "impermanence-perms-${mount-service}"; + parent-mount-service = cleanName (parentDir opt.directory); + parent-perms-service = "impermanence-perms-${parent-mount-service}"; + is-mount = opt ? store; + in { + fileSystems."${opt.directory}" = lib.mkIf is-mount { + device = concatPaths [ opt.store.device opt.directory ]; + options = [ + "bind" + "x-systemd.requires=${backing-mount}.mount" # this should be implicit + "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."${perms-service}" = let + perms-script = pkgs.writeShellScript "impermanence-prepare-perms" '' + path="$1" + user="$2" + group="$3" + mode="$4" + mkdir "$path" || test -d "$path" + chmod "$mode" "$src" + chown "$user:$group" "$src" + ''; + in { + description = "prepare permissions for ${opt.directory}"; + serviceConfig = { + ExecStart = ''${perms-script} ${opt.directory} ${opt.user} ${opt.group} ${opt.mode}''; + Type = "oneshot"; + }; + wantedBy = lib.mkIf is-mount [ "${mount-service}.mount" ]; + after = lib.mkIf (opt.directory != "/") [ "${parent-perms-service}.service" ]; + wants = lib.mkIf (opt.directory != "/") [ "${parent-perms-service}.service" ]; + }; + }; + cfgs = builtins.map cfgFor (builtins.attrValues expanded-tree); + # cfgs = builtins.map cfgFor ingested-dirs; + # cfgs = [ (cfgFor (ingestDirOption home-dir-defaults ".cache")) ]; + # myMerge = items: builtins.foldl' (acc: new: acc // new) {} items; + in { + # fileSystems = myMerge (catAttrs "fileSystems" cfgs); + fileSystems = lib.mkMerge (builtins.catAttrs "fileSystems" cfgs); + systemd = lib.mkMerge (catAttrs "systemd" cfgs); + } + ) + + ({ + # secret decoding depends on /etc/ssh keys, which may be persisted + system.activationScripts.setupSecrets.deps = [ "persist-ssh-host-keys" ]; + system.activationScripts.setupSecretsForUsers = lib.mkIf secrets-for-users { + deps = [ "persist-ssh-host-keys" ]; + }; + # populated by ssh.nix, which persists /etc/ssh/host_keys + system.activationScripts.persist-ssh-host-keys.text = lib.mkDefault ""; + }) + ]); +} + diff --git a/modules/packages.nix b/modules/packages.nix index f205902b6..cb9ad98fc 100644 --- a/modules/packages.nix +++ b/modules/packages.nix @@ -115,12 +115,14 @@ let lollypop mesa-demos - { pkg = mpv; dir = [ ".config/mpv/watch_later" ]; } + # TODO(impermanence): re-enable! + # { pkg = mpv; dir = [ ".config/mpv/watch_later" ]; } networkmanagerapplet # not strictly necessary, but allows caching articles; offline use, etc. - { pkg = newsflash; dir = [ ".local/share/news-flash" ]; } + # TODO(impermanence): re-enable! + # { pkg = newsflash; dir = [ ".local/share/news-flash" ]; } { pkg = nheko; private = [ ".config/nheko" # config file (including client token) @@ -143,7 +145,8 @@ let # config (e.g. server connection details) is persisted in ~/.config/sublime-music/config.json # possible to pass config as a CLI arg (sublime-music -c config.json) # { pkg = sublime-music; dir = [ ".local/share/sublime-music" ]; } - { pkg = sublime-music-mobile; dir = [ ".local/share/sublime-music" ]; } + # TODO(impermanence): re-enable! + # { pkg = sublime-music-mobile; dir = [ ".local/share/sublime-music" ]; } tdesktop # broken on phosh { pkg = tokodon; private = [ ".cache/KDE/tokodon" ]; }