# Users & SSH inbound: options + catalog defaults + integration. Single module # that used to be split across `modules/system/users/{default,catalog-options, # catalog-default,home-integration}.nix`. Per-host overrides / extra HM modules # work the same way they always have; only the file structure changed. { self, ... }: { flake.nixosModules.users = { config, options, lib, ... }: let cfg = config.chiasson.users; usersLib = let userHm = user: user.homeManager or { }; userSsh = user: user.ssh or { }; in rec { resolveHomeManagerModule = moduleSpec: let t = builtins.typeOf moduleSpec; in if t == "path" || t == "string" then import moduleSpec else moduleSpec; selectedUsersAttr = { catalog, enabled, hostOverrides }: lib.listToAttrs ( map (name: { inherit name; value = lib.recursiveUpdate catalog.${name} (hostOverrides.${name} or { }); }) enabled ); missingEnabledNames = catalog: enabled: builtins.filter (name: !(builtins.hasAttr name catalog)) enabled; strayHomeUserKeys = homeUsers: enabled: builtins.filter (k: !(builtins.elem k enabled)) (builtins.attrNames homeUsers); mkNixosUser = name: user: { isNormalUser = user.isNormalUser or true; description = user.description or name; extraGroups = user.extraGroups or [ ]; } // lib.optionalAttrs (user ? hashedPasswordFile && user.hashedPasswordFile != null) { hashedPasswordFile = user.hashedPasswordFile; }; hmWiredNames = selectedUsers: lib.attrNames ( lib.filterAttrs ( _: user: let hm = userHm user; in (hm.enable or false) && (hm.module or null) != null ) selectedUsers ); rbwOutboundSnippet = name: user: let outboundCfg = ((userSsh user).outbound or { }).rbw or { }; in lib.mkIf (outboundCfg.enable or false) { chiasson.ssh.outbound.rbw.enable = true; chiasson.ssh.outbound.rbw.user = name; chiasson.ssh.outbound.rbw.hosts = if (outboundCfg.hosts or "all") == "all" then [ "all" ] else outboundCfg.hosts; chiasson.ssh.outbound.rbw.extraIdentities = outboundCfg.extraIdentities or [ ]; }; mkHmUserModule = { name, user, hostExtraModules }: let hm = userHm user; hmModule = resolveHomeManagerModule hm.module; in lib.mkMerge ( [ hmModule (rbwOutboundSnippet name user) ] ++ (hm.extraModules or [ ]) ++ hostExtraModules ); inboundAuthorizedOrNull = user: let inboundCfg = (userSsh user).inbound or { }; in if !(inboundCfg.enable or false) then null else if (inboundCfg.authorizedHosts or "all") == "all" then "all" else inboundCfg.authorizedHosts; inboundHostsAttr = selectedUsers: lib.pipe selectedUsers [ (lib.mapAttrs (_: inboundAuthorizedOrNull)) (lib.filterAttrs (_: v: v != null)) ]; }; inventory = self.lib.sshInventory; olivierEnabled = lib.elem "olivier" cfg.enabled; # Merge catalog + per-host overrides into the final user attrs. selectedUsers = lib.listToAttrs ( map (name: { inherit name; value = lib.recursiveUpdate cfg.catalog.${name} (cfg.hostOverrides.${name} or { }); }) cfg.enabled ); names = usersLib.hmWiredNames selectedUsers; missing = usersLib.missingEnabledNames cfg.catalog cfg.enabled; stray = usersLib.strayHomeUserKeys cfg.extraModules cfg.enabled; hmAvailable = lib.hasAttrByPath [ "home-manager" "users" ] options; inboundUsersAttr = usersLib.inboundHostsAttr selectedUsers; hmUsersAttr = lib.listToAttrs ( map (name: { inherit name; value = usersLib.mkHmUserModule { inherit name; user = selectedUsers.${name}; hostExtraModules = cfg.extraModules.${name} or [ ]; }; }) names ); # Fish shell wiring: HM's fish module declares its package, but # /etc/passwd + /etc/shells need updating so login shells work. # Only fires for HM users that actually enable fish. hmFishUsers = if !hmAvailable then { } else lib.filterAttrs ( name: hmUser: (hmUser.programs.fish.enable or false) && builtins.elem name names ) config.home-manager.users; in { imports = [ self.nixosModules.sshInbound { _module.args = { inherit self usersLib; }; } ]; #################### # Options #################### options.chiasson.users = { catalog = lib.mkOption { type = lib.types.attrs; default = { }; description = "User records; defaults below. Override with `hostOverrides` or `mkForce`."; }; enabled = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; description = "Catalog names to materialize as `users.users` on this machine."; }; hostOverrides = lib.mkOption { type = lib.types.attrs; default = { }; description = "`recursiveUpdate`'d onto catalog users on this host."; }; # Standard `attrsOf (listOf …)` already concatenates lists across # module definitions; no custom merge function needed. extraModules = lib.mkOption { type = lib.types.attrsOf (lib.types.listOf lib.types.unspecified); default = { }; description = '' Per-user Home Manager extraModules keyed by catalog name. Lists from multiple modules (desktopHomeBase + this host's home.nix) are concatenated. ''; }; homeManager.autoWire = lib.mkOption { type = lib.types.bool; default = true; description = "Auto-create Home Manager users from the catalog."; }; }; #################### # Catalog defaults + integration #################### # Nix won't accept both `config.X = …` and `config = …` in the same # attrset (the path-attr partially defines `config`, then the explicit # `config = …` is a redefinition error). All `config.` writes must # therefore live INSIDE the single mkMerge block below. config = lib.mkMerge [ #################### # Catalog defaults #################### { chiasson.users.catalog = { olivier = { isNormalUser = true; description = "Olivier"; extraGroups = [ "networkmanager" "wheel" "docker" "fuse" "uinput" "kvm" # `video` lets brightnessctl/light udev rules own /sys/backlight without sudo. # Harmless on headless hosts (no devices). "video" # DRI render + input for gamescope / Steam on Wayland, no sudo. "render" "input" ]; hashedPasswordFile = lib.mkIf olivierEnabled ( config.sops.secrets."users/olivier/hashedPassword".path ); homeManager = { enable = true; module = { pkgs, ... }: { home.username = "olivier"; home.homeDirectory = "/home/olivier"; home.stateVersion = "25.11"; programs.home-manager.enable = true; # Declarative rbw config — replaces per-host # `rbw config set email|base_url`. Neither value is a # secret (URL + login email), so plaintext is fine, same # as the public keys committed in ssh-inventory.nix. programs.rbw = { enable = true; settings = { email = "olivierchiasson@hotmail.fr"; base_url = "https://bitwarden.chiasson.cloud"; }; }; }; }; ssh.inbound.enable = true; ssh.inbound.authorizedHosts = "all"; ssh.outbound.rbw.enable = true; ssh.outbound.rbw.hosts = [ "all" ]; # olivier's laptop: the HM SSH module writes the matching # `.pub` filter for each of these and emits a `Match user` # block, so `ssh server@r5500` / `ssh builder@nix-server` # route the right private key from the rbw agent. ssh.outbound.rbw.extraIdentities = [ "server" "builder" ]; }; server = { isNormalUser = true; description = "Server user"; extraGroups = [ "wheel" ]; homeManager = { enable = false; module = null; }; ssh.inbound.enable = true; ssh.inbound.authorizedHosts = "all"; ssh.outbound.rbw.enable = false; ssh.outbound.rbw.hosts = [ "all" ]; }; builder = { isNormalUser = true; description = "Navi fleet deploy (push + activate only)"; extraGroups = [ ]; createHome = false; homeManager = { enable = false; module = null; }; ssh.inbound.enable = true; ssh.inbound.authorizedHosts = "all"; }; }; # Pull in olivier's hashed password from sops; `neededForUsers` # makes nixos decrypt it at boot so the user record can read it. sops.secrets."users/olivier/hashedPassword" = lib.mkIf olivierEnabled { neededForUsers = true; }; } #################### # Integration #################### { assertions = [ { assertion = missing == [ ]; message = "chiasson.users.enabled references unknown catalog names: ${lib.concatStringsSep ", " missing}"; } { assertion = stray == [ ]; message = "chiasson.users.extraModules has keys not in chiasson.users.enabled: ${lib.concatStringsSep ", " stray}"; } ]; # NixOS user accounts (no HM, raw). users.users = lib.mapAttrs (name: user: usersLib.mkNixosUser name user) selectedUsers; } (lib.optionalAttrs hmAvailable { "home-manager".useGlobalPkgs = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) true; "home-manager".sharedModules = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) [ self.homeManagerModules.sshOutboundRbw ]; "home-manager".users = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) hmUsersAttr; }) # Wire user-specific ACLs into the inbound module — fail the build # if a user has no matching pubkey. (lib.mkIf (inboundUsersAttr != { }) { chiasson.ssh.inbound.enable = true; chiasson.ssh.inbound.userAuthorizedHosts = inboundUsersAttr; }) # Fish-shell wiring: HM knows the package; we sync /etc/passwd + # /etc/shells with `mkForce`. `mkNixosUser` doesn't emit `shell`, so # the force is uncontested. (lib.mkIf (hmFishUsers != { }) { environment.shells = lib.mkAfter ( lib.mapAttrsToList (_: hmUser: lib.getExe hmUser.programs.fish.package) hmFishUsers ); users.users = lib.mapAttrs (name: hmUser: { shell = lib.mkForce (lib.getExe hmUser.programs.fish.package); }) hmFishUsers; }) ]; }; }