# Fully declarative FreeIPA domain membership. # # Imported by modules/common/configuration.nix — no per-host wiring needed. # Enables itself automatically on any host that has a sops-encrypted keytab # at secrets/.keytab; is a no-op for all other hosts. # # To enroll a new host: # 0. scripts/secrets/sync-host-keys.sh # 1. scripts/ipa/create-nixos-ipa-host-account.sh [--ip ] # (adds .sops.yaml rule, runs ipa host-add, encrypts keytab in one step) # 2. git add secrets/.keytab .sops.yaml && git commit # 3. Deploy — no further steps required. # # Manual fallback (if the script isn't usable): # a. On the FreeIPA server: ipa host-add [--ip-address=] --force # b. On the FreeIPA server: ipa-getkeytab -s -p host/ -k /tmp/.keytab # c. From the repo root (path must match for sops creation rule to apply): # cp /tmp/.keytab secrets/.keytab # sops -e --input-type binary -i secrets/.keytab # d. Commit secrets/.keytab and the updated .sops.yaml, then deploy. # # vars dependencies: homeDomain, ipaServer, domainControllerIp, ipaUser { config, lib, pkgs, vars, ... }: let keytabPath = ../../secrets + "/${config.networking.hostName}.keytab"; enabled = builtins.pathExists keytabPath; realm = lib.strings.toUpper vars.homeDomain; fqdn = "${config.networking.hostName}.${vars.homeDomain}"; # "sweet.home" -> "dc=sweet,dc=home" basedn = lib.strings.concatMapStringsSep "," (c: "dc=${c}") (lib.strings.splitString "." vars.homeDomain); # security.ipa.certificate expects a derivation (package), not a raw path. caCertPkg = pkgs.writeText "ipa-ca.crt" (builtins.readFile ../../certs/ipa-ca.crt); in lib.mkIf enabled { networking.domain = lib.mkDefault vars.homeDomain; networking.nameservers = lib.mkDefault [ vars.domainControllerIp ]; security = { ipa = { enable = true; domain = vars.homeDomain; inherit realm; server = vars.ipaServer; certificate = caCertPkg; inherit basedn; ipaHostname = fqdn; offlinePasswords = true; cacheCredentials = true; }; # Create the home directory on first login if it doesn't exist yet. # IPA users have no pre-created home on the host; without this sshd # opens a session to a non-existent directory and resets the connection. # lightdm also needs this so the GUI login path can create the home dir # if it was not pre-seeded by the tmpfiles rule above (e.g. on first boot # before SSSD has resolved the user). pam.services = { sshd.makeHomeDir = true; lightdm.makeHomeDir = true; # pam_unix returns PAM_AUTHINFO_UNAVAIL without prompting when the local # stub has "!" in shadow (account locked), so PAM_AUTHTOK is never set # and pam_sss's use_first_pass fails with "No authentication token". # Changing to try_first_pass makes pam_sss prompt independently when no # prior module has set the token, restoring IPA password login via # LightDM and su. login.rules.auth.sss.settings = lib.mkForce { try_first_pass = true; }; su.rules.auth.sss.settings = lib.mkForce { try_first_pass = true; }; }; # HM with useUserPackages = true (flake.nix) sets users.users.${ipaUser}.packages, # which forces the stub into /etc/passwd. pam_sss.so with the "localusers" flag # (added by NixOS when SSSD is enabled) then skips SSSD for any user it finds in # local /etc/passwd — including this stub — falling through to pam_unix, which has # no password for the stub → sudo auth always fails. # # Fix: NOPASSWD for the IPA user. The IPA user already authenticated to reach a # shell (SSH public key from IPA or Kerberos), so re-prompting via a broken PAM # path is security theater on a single-admin homelab. sudo.extraRules = [{ users = [ vars.ipaUser ]; commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }]; }]; }; systemd = { # Fetch SSH public keys from IPA so users can log in with the key stored # in their IPA profile rather than needing ~/.ssh/authorized_keys on every # host. sss_ssh_authorizedkeys queries SSSD (which queries IPA LDAP). # # /nix/store is 1775 (group-writable by nixbld). OpenSSH 10.0+ rejects # AuthorizedKeysCommand binaries whose path contains any group-writable # component, silently skipping the command. Copy to /usr/local/bin (all # components root-owned, 755) so the path passes sshd's safety check. tmpfiles.rules = [ "d /usr/local 0755 root root - -" "d /usr/local/bin 0755 root root - -" "C+ /usr/local/bin/sss_ssh_authorizedkeys 0555 root root - ${pkgs.sssd}/bin/sss_ssh_authorizedkeys" # Pre-create the IPA user's home dir so Home Manager activation succeeds # even before their first login. On a fresh system SSSD may not have # resolved the user yet — tmpfiles warns and skips in that case (non-fatal), # and pam_mkhomedir covers the first-login path as a fallback. "d /home/${vars.ipaUser} 0700 ${vars.ipaUser} ${vars.ipaUser} - -" ]; # security.ipa enables Kerberos (security.krb5) which causes systemd to # start auth-rpcgss-module.service and rpc-gssd.service for Kerberos NFS # authentication. LXC containers can't load the auth_rpcgss kernel module # and don't have /var/lib/nfs/rpc_pipefs, so both services fail. # # The NixOS IPA module already adds a drop-in for auth-rpcgss-module.service # with ConditionPathExists=/etc/krb5.keytab. We use lib.mkForce to win the # text conflict and add ConditionVirtualization=!container alongside it so # the service is skipped (not failed) in containers that do have a keytab. # Same fix for rpc-gssd.service which also fails in containers. units = lib.mkIf config.boot.isContainer { "auth-rpcgss-module.service" = { overrideStrategy = "asDropinIfExists"; text = lib.mkForce '' [Unit] ConditionPathExists= ConditionPathExists=/etc/krb5.keytab ConditionVirtualization=!container ''; }; # rpc-gssd also has ConditionPathExists from the NixOS IPA module (and an # X-Restart-Triggers store path from systemd.nix). Use mkForce to win; # omit X-Restart-Triggers since this service is skipped in containers anyway. "rpc-gssd.service" = { overrideStrategy = "asDropinIfExists"; text = lib.mkForce '' [Unit] ConditionPathExists= ConditionPathExists=/etc/krb5.keytab ConditionVirtualization=!container ''; }; }; # home-manager-.service fails on first enrollment because /home/wayne # doesn't exist until the user's first login (pam_mkhomedir creates it then). # ConditionPathExists makes systemd skip the service (exit 0, condition not # met) instead of failing. After first login the dir exists and subsequent # rebuilds activate HM normally. services."home-manager-${vars.ipaUser}".unitConfig.ConditionPathExists = "/home/${vars.ipaUser}"; }; services.openssh.extraConfig = '' AuthorizedKeysCommand /usr/local/bin/sss_ssh_authorizedkeys %u AuthorizedKeysCommandUser nobody ''; # Host keytab: pre-provisioned on the IPA server, sops-encrypted binary. # Placed at /etc/krb5.keytab before SSSD starts so the host authenticates # to IPA without running ipa-client-install. sops.secrets."ipa-host-keytab" = { sopsFile = keytabPath; format = "binary"; path = "/etc/krb5.keytab"; owner = "root"; group = "root"; mode = "0600"; restartUnits = [ "sssd.service" ]; }; # NixOS requires isNormalUser/isSystemUser + group on any entry in # users.users. HM with useUserPackages = true (set in flake.nix) adds a stub # entry for each HM user so it can install packages to # /etc/profiles/per-user//. This definition satisfies those assertions. # With security.ipa setting "passwd: sss files" in nsswitch, SSSD's IPA entry # takes priority for NSS lookups — this local stub is only a fallback when # SSSD is unreachable (at which point auth fails anyway). users.users.${vars.ipaUser} = { isNormalUser = true; group = "users"; extraGroups = [ "wheel" ]; createHome = false; # "!" is not a password hash — it is the standard "account locked" marker. # It cannot authenticate anyone locally. It exists solely so NixOS generates # a shadow entry for this stub user; without one pam_unix returns # PAM_AUTHINFO_UNAVAIL before prompting, which means PAM_AUTHTOK is never # set and the subsequent pam_sss use_first_pass call has nothing to work # with — blocking LightDM and su logins even when IPA/SSSD auth succeeds. hashedPassword = "!"; }; # Home Manager config for the IPA primary user, applied on every enrolled # host. Manages what IPA doesn't: dotfiles, user-scoped packages, session # variables. Switch-nix/Test-nix/buildImage are system-wide (configuration.nix) # so they don't need to be repeated here. # # homeDirectory uses mkForce because HM's NixOS integration module sets it to # "/var/empty" for users not found in config.users.users at eval time (SSSD # users aren't visible there). home-manager.users.${vars.ipaUser} = { pkgs, ... }: { home = { username = vars.ipaUser; homeDirectory = lib.mkForce "/home/${vars.ipaUser}"; stateVersion = "26.05"; packages = with pkgs; [ tmux sshfs ]; sessionVariables.EDITOR = "nano"; }; programs.home-manager.enable = true; programs.bash.enable = true; }; }