diff options
Diffstat (limited to 'modules/users')
| -rw-r--r-- | modules/users/default.nix | 261 | ||||
| -rw-r--r-- | modules/users/group.nix | 26 | ||||
| -rw-r--r-- | modules/users/user.nix | 75 |
3 files changed, 262 insertions, 100 deletions
diff --git a/modules/users/default.nix b/modules/users/default.nix index 25cc97e..574f5a4 100644 --- a/modules/users/default.nix +++ b/modules/users/default.nix @@ -1,14 +1,16 @@ { config, lib, pkgs, ... }: -with lib; - let + inherit (lib) concatStringsSep concatMapStringsSep elem escapeShellArg + escapeShellArgs filter filterAttrs flatten flip mapAttrs' mapAttrsToList + mkAfter mkIf mkMerge mkOption mkOrder mkRemovedOptionModule optionals + optionalString types; + cfg = config.users; group = import ./group.nix; user = import ./user.nix; - toArguments = concatMapStringsSep " " (v: "'${v}'"); toGID = v: { "${toString v.gid}" = v.name; }; toUID = v: { "${toString v.uid}" = v.name; }; @@ -32,14 +34,24 @@ let then "/run/current-system/sw${v.shellPath}" else v; + systemShells = + let + shells = mapAttrsToList (_: u: u.shell) cfg.users; + in + filter types.shellPackage.check shells; + in { + imports = [ + (mkRemovedOptionModule [ "users" "forceRecreate" ] "") + ]; + options = { users.knownGroups = mkOption { type = types.listOf types.str; default = []; - description = lib.mdDoc '' + description = '' List of groups owned and managed by nix-darwin. Used to indicate what users are safe to create/delete based on the configuration. Don't add system groups to this. @@ -49,7 +61,7 @@ in users.knownUsers = mkOption { type = types.listOf types.str; default = []; - description = lib.mdDoc '' + description = '' List of users owned and managed by nix-darwin. Used to indicate what users are safe to create/delete based on the configuration. Don't add the admin user or other system users to this. @@ -59,13 +71,13 @@ in users.groups = mkOption { type = types.attrsOf (types.submodule group); default = {}; - description = lib.mdDoc "Configuration for groups."; + description = "Configuration for groups."; }; users.users = mkOption { type = types.attrsOf (types.submodule user); default = {}; - description = lib.mdDoc "Configuration for users."; + description = "Configuration for users."; }; users.gids = mkOption { @@ -79,62 +91,185 @@ in type = types.attrsOf types.str; default = {}; }; - - users.forceRecreate = mkOption { - internal = true; - type = types.bool; - default = false; - description = lib.mdDoc "Remove and recreate existing groups/users."; - }; }; config = { + assertions = [ + { + # We don't check `root` like the rest of the users as on some systems `root`'s + # home directory is set to `/var/root /private/var/root` + assertion = cfg.users ? root -> (cfg.users.root.home == null || cfg.users.root.home == "/var/root"); + message = "`users.users.root.home` must be set to either `null` or `/var/root`."; + } + { + assertion = !builtins.elem "root" deletedUsers; + message = "Remove `root` from `users.knownUsers` if you no longer want nix-darwin to manage it."; + } + ] ++ flatten (flip mapAttrsToList cfg.users (name: user: + map (shell: { + assertion = let + s = user.shell.pname or null; + in + !user.ignoreShellProgramCheck -> (s == shell || (shell == "bash" && s == "bash-interactive")) -> (config.programs.${shell}.enable == true); + message = '' + users.users.${user.name}.shell is set to ${shell}, but + programs.${shell}.enable is not true. This will cause the ${shell} + shell to lack the basic Nix directories in its PATH and might make + logging in as that user impossible. You can fix it with: + programs.${shell}.enable = true; + + If you know what you're doing and you are fine with the behavior, + set users.users.${user.name}.ignoreShellProgramCheck = true; + instead. + ''; + }) [ + "bash" + "fish" + "zsh" + ] + )); + + warnings = flatten (flip mapAttrsToList cfg.users (name: user: + mkIf + (user.shell.pname or null == "bash") + "Set `users.users.${name}.shell = pkgs.bashInteractive;` instead of `pkgs.bash` as it does not include `readline`." + )); users.gids = mkMerge gids; users.uids = mkMerge uids; - system.activationScripts.groups.text = mkIf (cfg.knownGroups != []) '' - echo "setting up groups..." >&2 + # NOTE: We put this in `system.checks` as we want this to run first to avoid partial activations + # however currently that runs at user level activation as that runs before system level activation + # TODO: replace `$USER` with `$SUDO_USER` when system.checks runs from system level + system.checks.text = mkIf (builtins.length (createdUsers ++ deletedUsers) > 0) (mkAfter '' + ensurePerms() { + homeDirectory=$(dscl . -read /Users/nobody NFSHomeDirectory) + homeDirectory=''${homeDirectory#NFSHomeDirectory: } - ${concatMapStringsSep "\n" (v: '' - ${optionalString cfg.forceRecreate '' - g=$(dscl . -read '/Groups/${v.name}' PrimaryGroupID 2> /dev/null) || true - g=''${g#PrimaryGroupID: } - if [[ "$g" -eq ${toString v.gid} ]]; then - echo "deleting group ${v.name}..." >&2 - dscl . -delete '/Groups/${v.name}' 2> /dev/null + if ! sudo dscl . -change /Users/nobody NFSHomeDirectory "$homeDirectory" "$homeDirectory" &> /dev/null; then + if [[ -n "$SSH_CONNECTION" ]]; then + printf >&2 '\e[1;31merror: users cannot be %s over SSH without Full Disk Access, aborting activation\e[0m\n' "$2" + # shellcheck disable=SC2016 + printf >&2 'The user %s could not be %s as `darwin-rebuild` was not executed with Full Disk Access over SSH.\n' "$1" "$2" + printf >&2 'You can either:\n' + printf >&2 '\n' + printf >&2 ' grant Full Disk Access to all programs run over SSH\n' + printf >&2 '\n' + printf >&2 'or\n' + printf >&2 '\n' + # shellcheck disable=SC2016 + printf >&2 ' run `darwin-rebuild` in a graphical session.\n' + printf >&2 '\n' + printf >&2 'The option "Allow full disk access for remote users" can be found by\n' + printf >&2 'navigating to System Settings > General > Sharing > Remote Login\n' + printf >&2 'and then pressing on the i icon next to the switch.\n' + exit 1 else - echo "[1;31mwarning: existing group '${v.name}' has unexpected gid $g, skipping...[0m" >&2 + # The TCC service required to change home directories is `kTCCServiceSystemPolicySysAdminFiles` + # and we can reset it to ensure the user gets another prompt + tccutil reset SystemPolicySysAdminFiles > /dev/null + + if ! sudo dscl . -change /Users/nobody NFSHomeDirectory "$homeDirectory" "$homeDirectory" &> /dev/null; then + printf >&2 '\e[1;31merror: permission denied when trying to %s user %s, aborting activation\e[0m\n' "$2" "$1" + # shellcheck disable=SC2016 + printf >&2 '`darwin-rebuild` requires permissions to administrate your computer,\n' + printf >&2 'please accept the dialog that pops up.\n' + printf >&2 '\n' + # shellcheck disable=SC2016 + printf >&2 'If you do not wish to be prompted every time `darwin-rebuild updates your users,\n' + printf >&2 'you can grant Full Disk Access to your terminal emulator in System Settings.\n' + printf >&2 '\n' + printf >&2 'This can be found in System Settings > Privacy & Security > Full Disk Access.\n' + exit 1 + fi + fi + + fi + } + + ${concatMapStringsSep "\n" (v: let + name = escapeShellArg v.name; + dsclUser = escapeShellArg "/Users/${v.name}"; + in '' + u=$(id -u ${name} 2> /dev/null) || true + if ! [[ -n "$u" && "$u" -ne "${toString v.uid}" ]]; then + if [ -z "$u" ]; then + ensurePerms ${name} create + + ${optionalString (v.home != null && v.name != "root") '' + else + homeDirectory=$(dscl . -read ${dsclUser} NFSHomeDirectory) + homeDirectory=''${homeDirectory#NFSHomeDirectory: } + if [[ ${escapeShellArg v.home} != "$homeDirectory" ]]; then + printf >&2 '\e[1;31merror: config contains the wrong home directory for %s, aborting activation\e[0m\n' ${name} + printf >&2 'nix-darwin does not support changing the home directory of existing users.\n' + printf >&2 '\n' + printf >&2 'Please set:\n' + printf >&2 '\n' + printf >&2 ' users.users.%s.home = "%s";\n' ${name} "$homeDirectory" + printf >&2 '\n' + printf >&2 'or remove it from your configuration.\n' + exit 1 + fi + ''} + fi + fi + '') createdUsers} + + ${concatMapStringsSep "\n" (v: let + name = escapeShellArg v; + in '' + u=$(id -u ${name} 2> /dev/null) || true + if [ -n "$u" ]; then + if [ "$u" -gt 501 ]; then + # TODO: add `darwin.primaryUser` as well + if [[ ${name} == "$USER" ]]; then + # shellcheck disable=SC2016 + printf >&2 '\e[1;31merror: refusing to delete the user calling `darwin-rebuild` (%s), aborting activation\e[0m\n', ${name} + exit 1 + fi + + ensurePerms ${name} delete fi - ''} + fi + '') deletedUsers} + ''); + + system.activationScripts.groups.text = mkIf (cfg.knownGroups != []) '' + echo "setting up groups..." >&2 - g=$(dscl . -read '/Groups/${v.name}' PrimaryGroupID 2> /dev/null) || true + ${concatMapStringsSep "\n" (v: let + dsclGroup = escapeShellArg "/Groups/${v.name}"; + in '' + g=$(dscl . -read ${dsclGroup} PrimaryGroupID 2> /dev/null) || true g=''${g#PrimaryGroupID: } if [ -z "$g" ]; then echo "creating group ${v.name}..." >&2 - dscl . -create '/Groups/${v.name}' PrimaryGroupID ${toString v.gid} - dscl . -create '/Groups/${v.name}' RealName '${v.description}' + dscl . -create ${dsclGroup} PrimaryGroupID ${toString v.gid} + dscl . -create ${dsclGroup} RealName ${escapeShellArg v.description} g=${toString v.gid} fi if [ "$g" -eq ${toString v.gid} ]; then - g=$(dscl . -read '/Groups/${v.name}' GroupMembership 2> /dev/null) || true + g=$(dscl . -read ${dsclGroup} GroupMembership 2> /dev/null) || true if [ "$g" != 'GroupMembership: ${concatStringsSep " " v.members}' ]; then echo "updating group members ${v.name}..." >&2 - dscl . -create '/Groups/${v.name}' GroupMembership ${toArguments v.members} + dscl . -create ${dsclGroup} GroupMembership ${escapeShellArgs v.members} fi else echo "[1;31mwarning: existing group '${v.name}' has unexpected gid $g, skipping...[0m" >&2 fi '') createdGroups} - ${concatMapStringsSep "\n" (name: '' - g=$(dscl . -read '/Groups/${name}' PrimaryGroupID 2> /dev/null) || true + ${concatMapStringsSep "\n" (name: let + dsclGroup = escapeShellArg "/Groups/${name}"; + in '' + g=$(dscl . -read ${dsclGroup} PrimaryGroupID 2> /dev/null) || true g=''${g#PrimaryGroupID: } if [ -n "$g" ]; then if [ "$g" -gt 501 ]; then echo "deleting group ${name}..." >&2 - dscl . -delete '/Groups/${name}' 2> /dev/null + dscl . -delete ${dsclGroup} else echo "[1;31mwarning: existing group '${name}' has unexpected gid $g, skipping...[0m" >&2 fi @@ -145,44 +280,51 @@ in system.activationScripts.users.text = mkIf (cfg.knownUsers != []) '' echo "setting up users..." >&2 - ${concatMapStringsSep "\n" (v: '' - ${optionalString cfg.forceRecreate '' - u=$(dscl . -read '/Users/${v.name}' UniqueID 2> /dev/null) || true - u=''${u#UniqueID: } - if [[ "$u" -eq ${toString v.uid} ]]; then - echo "deleting user ${v.name}..." >&2 - dscl . -delete '/Users/${v.name}' 2> /dev/null - else - echo "[1;31mwarning: existing user '${v.name}' has unexpected uid $u, skipping...[0m" >&2 - fi - ''} - - u=$(dscl . -read '/Users/${v.name}' UniqueID 2> /dev/null) || true - u=''${u#UniqueID: } + ${concatMapStringsSep "\n" (v: let + name = escapeShellArg v.name; + dsclUser = escapeShellArg "/Users/${v.name}"; + in '' + u=$(id -u ${name} 2> /dev/null) || true if [[ -n "$u" && "$u" -ne "${toString v.uid}" ]]; then echo "[1;31mwarning: existing user '${v.name}' has unexpected uid $u, skipping...[0m" >&2 else if [ -z "$u" ]; then echo "creating user ${v.name}..." >&2 - dscl . -create '/Users/${v.name}' UniqueID ${toString v.uid} - dscl . -create '/Users/${v.name}' PrimaryGroupID ${toString v.gid} - dscl . -create '/Users/${v.name}' IsHidden ${if v.isHidden then "1" else "0"} - dscl . -create '/Users/${v.name}' RealName '${v.description}' - dscl . -create '/Users/${v.name}' NFSHomeDirectory '${v.home}' - ${optionalString v.createHome "createhomedir -cu '${v.name}'"} + + sysadminctl -addUser ${escapeShellArgs ([ + v.name + "-UID" v.uid + "-GID" v.gid ] + ++ (optionals (v.description != null) [ "-fullName" v.description ]) + ++ [ "-home" (if v.home != null then v.home else "/var/empty") ] + ++ [ "-shell" (if v.shell != null then shellPath v.shell else "/usr/bin/false") ])} 2> /dev/null + + # We need to check as `sysadminctl -addUser` still exits with exit code 0 when there's an error + if ! id ${name} &> /dev/null; then + printf >&2 '\e[1;31merror: failed to create user %s, aborting activation\e[0m\n' ${name} + exit 1 + fi + + dscl . -create ${dsclUser} IsHidden ${if v.isHidden then "1" else "0"} + + # `sysadminctl -addUser` won't create the home directory if we use the `-home` + # flag so we need to do it ourselves + ${optionalString (v.home != null && v.createHome) "createhomedir -cu ${name} > /dev/null"} fi - # Always set the shell path, in case it was updated - dscl . -create '/Users/${v.name}' UserShell ${lib.escapeShellArg (shellPath v.shell)} + + # Update properties on known users to keep them inline with configuration + dscl . -create ${dsclUser} PrimaryGroupID ${toString v.gid} + ${optionalString (v.description != null) "dscl . -create ${dsclUser} RealName ${escapeShellArg v.description}"} + ${optionalString (v.shell != null) "dscl . -create ${dsclUser} UserShell ${escapeShellArg (shellPath v.shell)}"} fi '') createdUsers} ${concatMapStringsSep "\n" (name: '' - u=$(dscl . -read '/Users/${name}' UniqueID 2> /dev/null) || true - u=''${u#UniqueID: } + u=$(id -u ${escapeShellArg name} 2> /dev/null) || true if [ -n "$u" ]; then if [ "$u" -gt 501 ]; then echo "deleting user ${name}..." >&2 - dscl . -delete '/Users/${name}' 2> /dev/null + dscl . -delete ${escapeShellArg "/Users/${name}"} else echo "[1;31mwarning: existing user '${name}' has unexpected uid $u, skipping...[0m" >&2 fi @@ -190,6 +332,9 @@ in '') deletedUsers} ''; + # Install all the user shells + environment.systemPackages = systemShells; + environment.etc = mapAttrs' (name: { packages, ... }: { name = "profiles/per-user/${name}"; value.source = pkgs.buildEnv { diff --git a/modules/users/group.nix b/modules/users/group.nix index cfda76f..da3feb1 100644 --- a/modules/users/group.nix +++ b/modules/users/group.nix @@ -1,41 +1,33 @@ { name, lib, ... }: -with lib; - { - options = { + options = let + inherit (lib) mkOption types; + in { name = mkOption { type = types.str; - description = lib.mdDoc '' + default = name; + description = '' The group's name. If undefined, the name of the attribute set will be used. ''; }; gid = mkOption { - type = mkOptionType { - name = "gid"; - check = t: isInt t && t > 501; - }; - description = lib.mdDoc "The group's GID."; + type = types.int; + description = "The group's GID."; }; members = mkOption { type = types.listOf types.str; default = []; - description = lib.mdDoc "The group's members."; + description = "The group's members."; }; description = mkOption { type = types.str; default = ""; - description = lib.mdDoc "The group's description."; + description = "The group's description."; }; }; - - config = { - - name = mkDefault name; - - }; } diff --git a/modules/users/user.nix b/modules/users/user.nix index 60592fc..5256ac3 100644 --- a/modules/users/user.nix +++ b/modules/users/user.nix @@ -1,42 +1,49 @@ { name, lib, ... }: -with lib; - { - options = { + options = let + inherit (lib) literalExpression mkOption types; + in { name = mkOption { - type = types.str; - description = lib.mdDoc '' + type = types.nonEmptyStr; + default = name; + description = '' The name of the user account. If undefined, the name of the attribute set will be used. ''; }; description = mkOption { - type = types.str; - default = ""; + type = types.nullOr types.nonEmptyStr; + default = null; example = "Alice Q. User"; - description = lib.mdDoc '' + description = '' A short description of the user account, typically the user's full name. + + This defaults to `null` which means, on creation, `sysadminctl` + will pick the description which is usually always {option}`name`. + + Using an empty name is not supported and breaks macOS like + making the user not appear in Directory Utility. ''; }; uid = mkOption { type = types.int; - description = lib.mdDoc "The user's UID."; + description = "The user's UID."; }; gid = mkOption { type = types.int; default = 20; - description = lib.mdDoc "The user's primary group."; + description = "The user's primary group."; }; isHidden = mkOption { type = types.bool; default = true; - description = lib.mdDoc "Whether to make the user account hidden."; + description = "Whether to make the user account hidden."; }; # extraGroups = mkOption { @@ -46,39 +53,57 @@ with lib; # }; home = mkOption { - type = types.path; - default = "/var/empty"; - description = lib.mdDoc "The user's home directory."; + type = types.nullOr types.path; + default = null; + description = '' + The user's home directory. This defaults to `null`. + + When this is set to `null`, if the user has not been created yet, + they will be created with the home directory `/var/empty` to match + the old default. + ''; }; createHome = mkOption { type = types.bool; default = false; - description = lib.mdDoc "Create the home directory when creating the user."; + description = "Create the home directory when creating the user."; }; shell = mkOption { - type = types.either types.shellPackage types.path; - default = "/sbin/nologin"; + type = types.nullOr (types.either types.shellPackage types.path); + default = null; example = literalExpression "pkgs.bashInteractive"; - description = lib.mdDoc "The user's shell."; + description = '' + The user's shell. This defaults to `null`. + + When this is set to `null`, if the user has not been created yet, + they will be created with the shell `/usr/bin/false` to prevent + interactive login. If the user already exists, the value is + considered managed by macOS and `nix-darwin` will not change it. + ''; + }; + + ignoreShellProgramCheck = mkOption { + type = types.bool; + default = false; + description = '' + By default, nix-darwin will check that programs.SHELL.enable is set to + true if the user has a custom shell specified. If that behavior isn't + required and there are custom overrides in place to make sure that the + shell is functional, set this to true. + ''; }; packages = mkOption { type = types.listOf types.package; default = []; example = literalExpression "[ pkgs.firefox pkgs.thunderbird ]"; - description = lib.mdDoc '' + description = '' The set of packages that should be made availabe to the user. This is in contrast to {option}`environment.systemPackages`, which adds packages to all users. ''; }; }; - - config = { - - name = mkDefault name; - - }; } |
