# 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 = self.lib.usersMerge lib; 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 = { ... }: { home.username = "olivier"; home.homeDirectory = "/home/olivier"; home.stateVersion = "25.11"; programs.home-manager.enable = true; }; }; 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; }; }; # 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; }) ]; }; }