NFSv4 with Kerberos against Active Directory

What this guide will help you do: You will be able to access NFSv4 shares when logged onto a Linux client system with an Active Directory user account, your NFS traffic will no longer be clear-text and vulnerable to traffic snooping, both server and client will transparently verify each other’s identity and you will be able to map AD user and group permissions to your NFS shares.

Prerequisites:
All Linux machines involved must be joined to AD, see here for an example of a valid configuration.
They must have their hostnames in FQDN format
They must have valid A+PTR DNS records
Port 2049 on the server needs to be accessible
ALL of the below has to work:

getent group "Domain Users"
id my.ad.account
kinit my.ad.account@MY.DOMAIN
nslookup my.domain
chronyc sources (or another timesync client, should point towards your domain time sources)

NFSv4 server (RHEL/CENTOS 8.x) configuration:

First, we install the server binaries and enable require services:

yum install -y nfs-utils
systemctl enable gssproxy.service
systemctl enable nfs-server

Your /etc/idmapd.conf on the NFS server should have the following:

[General]
Domain = my.domain
Local-Realms = MY.DOMAIN

[Translation]
Method = nsswitch,static
GSS-Methods = nsswitch,static

Let’s disable old protocol versions in /etc/nfs.conf:

[nfsd]
vers2=n
vers3=n

Now we disable unneeded pre-nfv4 services and start the server:

systemctl mask --now rpc-statd.service rpcbind.service rpcbind.socket
systemctl start nfs-server

Let’s create a directory on the server to be shared:

mkdir /nfs-export
chown aduser /nfs-export
chgrp adgroup /nfs-export

Assuming you did not disable SELinux, we need to fix the security context:

yum install -y policycoreutils-python
semanage fcontext -a -t nfs_t "/nfs-export(/.*)?"
restorecon -Rv /nfs-export

Now we create our share via /etc/exports file on the server and allow clients from 192.168.1.0/24 access:

/nfs-export  192.168.1.0/24(sec=krb5p,rw,sync)

And export the newly created share with:

exportfs -rav

You can check for any current exports on the server with:

cat /var/lib/nfs/etab
exportfs -v

We obtain a ticket for our AD admin account and create an NFS SPN for our server AD computer account:

kinit my.ad.account@MY.DOMAIN
adcli update --service-name=nfs

You can check that a new keytab entry was created for the SPN in /etc/krb5.keytab on the server with:

klist -k

Having multiple new entries show up is normal to support a multitude of cipher suites. If you open the server’s computer account details in AD and look up servicePrincipalName in Attribute Editor, you should now see entries that look like nfs/hostname and nfs/fqdn.

NFSv4 client configuration:

This is the easy part. Assuming your client was joined to AD in the same fashion as the server via the method previously linked, mounting the share is simply a matter of having the tools installed and the correct service enabled on the client. No actual NFS configuration is required.

Redhat/CentOS 8.x (seemingly autoenabled the required nfs-client.target service):

yum install -y nfs-utils
mount -vvv -t nfs4 -o vers=4.2,sec=krb5p,rw nfs.server.fqdn:/nfs-export /mnt/whatever

Ubuntu 20.04 LTS:

apt install nfs-common
systemctl enable nfs-client.target
systemctl start nfs-client.target
mount -vvv -t nfs4 -o vers=4.2,sec=krb5p,rw nfs.server.fqdn:/nfs-export /mnt/whatever

Login via SSH to the Linux NFSv4 client using an AD user account that you granted access while creating the export directory on the server and MAGIC HAPPENED. The AD user logged on to the client has access to the files shared from the server to the client.

If you need to have local root user on multiple kerberized NFS clients to be able to access and manipulate files and directories created by each other, look here.

Ansible: joining Redhat 8 to Active Directory

Now that I’ve been using Ansible for joining Redhat and CentOS 8.x Linux machines to Active Directory domains in production for a while, I’m confident enough to share the playbook I’ve been using. This example uses password-based authentication and sudo elevation for the ansible user, so make sure to edit accordingly in case you are using key-based auth.

When testing, don’t forget to add your AD account to the newly created AD admin group, this has bitten me way more times than I’d like to admit and in case you’re wondering what that OID in ad_access_filter does: it allows for recursive group permissions to work, so while you can add users directly, you do not HAVE to.

join-ad-linux-rhel tree:

admin.account@server join-ad-linux-rhel]$ tree
.
├── ansible.cfg
├── hosts
├── join-ad-linux-rhel.yaml
└── templates
    ├── ADsudoers.j2
    ├── krb5.j2
    └── sssd.j2

1 directory, 6 files

join-ad-linux-rhel/ansible.cfg:

[defaults]
inventory           = hosts
host_key_checking   = False

join-ad-linux-rhel/hosts:

[all]
examplehost ansible_host=192.168.1.100 ansible_ssh_user=ansibleuser

join-ad-linux-rhel/join-ad-linux-rhel.yaml:

- hosts: all
  user: ansibleuser
  become: true

  vars:
    pkgs:
      - sssd
      - sssd-tools
      - realmd
      - oddjob
      - oddjob-mkhomedir
      - adcli
      - samba-common
      - samba-common-tools
      - krb5-workstation
      - openldap-clients
    AD_Domain: MY.DOMAIN
    AD_Domain_alt: my.domain
    Join_OU: OU="member servers",OU=computers,DC=my,DC=domain
    SRV_ADM_GRP_OU: OU=groups,DC=my,DC=domain

  vars_prompt:
    - name: username
      prompt: "What is your AD administrator username?"
      private: no

    - name: password
      prompt: "What is your AD administrator password?"
      private: yes

    - name: adhostname
      prompt: "What is the desired hostname in a simple, non-fqdn format?"
      private: no

  tasks:

  - name: Checking if running RedHat/CentOS
    fail:
      msg: The system is not running RedHat/CentOS, aborting
    when: ansible_facts['os_family'] != 'RedHat'

  - name: Checking if packages required to join AD realm are present
    yum: name={{ pkgs }} state=present update_cache=yes

  - name: Settings up hostname
    shell: hostnamectl set-hostname {{ adhostname }}.{{ AD_Domain_alt }}

  - name: Joinining the AD realm (creating AD computer account and updating /etc/krb5.keytab)
    shell: echo '{{ password }}' | adcli join --stdin-password {{ AD_Domain }} -U {{ username }} --domain-ou={{ Join_OU }}

  - name: Creating AD server admin group
    shell: echo '{{ password }}' | adcli create-group ADM_{{ adhostname }} --stdin-password --domain={{ AD_Domain }} --description="Admin group for {{ adhostname }} server" --domain-ou={{ SRV_ADM_GRP_OU }} -U {{ username }}

  - name: Configuring sssd.conf
    template:
      src: sssd.j2
      dest: /etc/sssd/sssd.conf
      owner: root
      group: root
      mode: 0600

  - name: Configuring krb5.conf
    template:
      src: krb5.j2
      dest: /etc/krb5.conf
      owner: root
      group: root
      mode: 0644

  - name: Configuring sudoers
    template:
      src: ADsudoers.j2
      dest: /etc/sudoers.d/ADsudoers
      owner: root
      group: root
      mode: 0440

  - name: Configuring PAM/SSHD to use SSSD
    shell: authselect select sssd with-mkhomedir --force

  - name: Enabling oddjobd service
    systemd:
      name: oddjobd.service
      enabled: yes
      state: started

  - name: Restarting SSSD
    systemd:
      name: sssd
      enabled: yes
      state: restarted

join-ad-linux-rhel/templates/ADsudoers.j2:

%ADM_{{ adhostname }}@{{ AD_Domain_alt }}      ALL=(ALL:ALL) ALL

join-ad-linux-rhel/templates/sssd.j2:

[sssd]
domains = {{ AD_Domain_alt }}
config_file_version = 2
services = nss, pam

[domain/{{ AD_Domain_alt }}]
krb5_realm = {{ AD_Domain }}
realmd_tags = manages-system joined-with-adcli
cache_credentials = True
id_provider = ad
default_shell = /bin/bash
ldap_id_mapping = True
use_fully_qualified_names = False
fallback_homedir = /home/%u
access_provider = ad
ad_maximum_machine_account_password_age = 30
ad_access_filter = DOM:{{ AD_Domain_alt }}:(memberOf:1.2.840.113556.1.4.1941:=cn=ADM_{{ adhostname }},{{ SRV_ADM_GRP_OU }})

dyndns_update = false
dyndns_update_ptr = false

#debug_level = 9

join-ad-linux-rhel/templates/krb5.j2

includedir /etc/krb5.conf.d/

[logging]
    default = FILE:/var/log/krb5libs.log
    kdc = FILE:/var/log/krb5kdc.log
    admin_server = FILE:/var/log/kadmind.log

[libdefaults]
    dns_lookup_realm = true
    dns_lookup_kdc = true
    ticket_lifetime = 24h
    renew_lifetime = 7d
    forwardable = true
    rdns = false
    pkinit_anchors = FILE:/etc/pki/tls/certs/ca-bundle.crt
    spake_preauth_groups = edwards25519
    default_realm = {{ AD_Domain }}
    default_ccache_name = KEYRING:persistent:%{uid}

[realms]
    {{ AD_Domain }} = {
    }

[domain_realm]
    .{{ AD_Domain_alt }} = {{ AD_Domain }}
    {{ AD_Domain_alt }} = {{ AD_Domain }}

When copy/pasting these into actual files on your system, take care to ensure indentation isn’t lost as YAML is very particular about these things. Run the playbook with:

ansible-playbook -k -K join-ad-linux-rhel.yaml