Automating Active Directory Authentication on Linux with Ansible

Automating Active Directory Authentication on Linux with Ansible

Why do anything manually when you can use Ansible?

As I wrote here, I created an Active Directory domain and joined a single test VM to the domain.

Now I'll be writing about automating this process with ansible so that I can configure all my machines effortlessly.

Goals of this project

As with the last post, I want to join the servers to AD and then enable SSH authentication with public keys though altSecurityIdentities.

I currently run two types of Linux servers. RHEL 8 and  Ubuntu 20.04 LTS. Because of that, there are a couple of differences when configuring the setup. The most obvious one is that some of the packages are named differently. Some lesser-known differences are that RHEL comes with the authselect utility but Ubuntu does not. Another one is that the sftp binary is located in different paths and that causes the differences in sshd_config.

Because of these discrepancies, I'm going to implement ansible roles for both operating systems.

Automation Outline

Let's go through the steps that will be written as ansible tasks.

  • Check & install needed packages
  • Configure Kerberos
  • Join the domain
  • Create a new user group named ADMIN_hostname
  • Configure SSSD, sshd and sudoers
  • Configure PAM
  • Enable & restart systemd services

Information || Variables required

Let's also list all the information that we need to define in the playbook.

  • Active Directory Domain
  • Active Directory Admin Credentials
  • OS-Specific:
    • sftp binary location
    • Packages to install

The active directory information is not something that changes much. Admin Credentials should be encrypted inside an Ansible Vault.

Variables and Roles

So let's write our first lines of the playbook! I'm using the hosts virtual as that contains my Ansible-managed virtual machines.

- hosts: virtual
  become: true
  vars:
    AD_Domain: AD.SORSA.CLOUD
    AD_Domain_alt: ad.sorsa.cloud
    Join_OU: DC=ad,DC=sorsa,DC=cloud
    SRV_ADM_GRP_OU: OU=groups,DC=ad,DC=sorsa,DC=cloud

I've added become: true as all of the tasks are going to require elevated privileges.

For variables, I have defined several variables that are essentially the same domain. Perhaps one could template these all but I don't think that would be very sane.

Creating an Active Directory role

Now we should handle the AD admin credentials. Let's create a new role with the following structure:

roles/
└── active_directory
    ├── files 
    ├── meta
    ├── tasks
    │   └── main.yml
    ├── templates
    └── vars
        └── credentials.yml

Let's first create credentials.yml:

ad_admin_username: USERNAME
ad_admin_password: PASSWORD
roles/active_directory/vars/credentials.yml

Next, we can encrypt this file with ansible-vault encrypt credentials.yml. After this, you can use either --vault-pass or --ask-vault-pass to unlock the vault for the play.

Now create main.yml:

- name: Load AD admin credentials
  include_vars: credentials.yml
roles/active_directory/tasks/main.yml

This loads the credentials variables whenever the active_directory role is defined.

Creating the OS-Specific roles

After creating the AD role, we can now create a role for each OS Family.

roles/
├── rhel
│   ├── tasks
│   │   └── main.yml
│   └── vars
│       ├── ad-packages.yml
│       └── main.yml
└── ubuntu
    ├── tasks
    │   └── main.yml
    └── vars
        ├── ad-packages.yml
        └── main.yml

These two roles are identical except for the hardcoded variables in vars/. Let's start with vars/main.yml:

# Ubuntu
sftp_server_location: /usr/lib/openssh/sftp-server 

# RHEL
sftp_server_location: /usr/libexec/openssh/sftp-server
vars/main.yml

Here are the ad-packages.yml for both OSes:

active_directory_pkgs:
 - sssd
 - sssd-tools
 - realmd
 - oddjob
 - oddjob-mkhomedir
 - adcli
 - samba-common
 - krb5-user
 - libnss-ldap
 - libpam-ldap
 - ldap-utils
roles/ubuntu/vars/ad-packages.yml
active_directory_pkgs:
 - sssd
 - sssd-tools
 - realmd
 - oddjob
 - oddjob-mkhomedir
 - adcli
 - samba-common
 - samba-common-tools
 - krb5-workstation
 - openldap-clients
roles/rhel/vars/ad-packages.yml

Now let's add some include logic! This is the tasks/main.yml file:

- include_vars: main.yml

- include_vars: ad-packages.yml
  when: "'active_directory' in role_names"
tasks/main.yml

First, we include the main.yml file explicitly. Variables defined in vars/main.yml are available automatically for role-specific plays but to access it from our main playbook we need to include it with include_vars.

Then we conditionally include_vars the ad-packages.yml, if the role active_directory is loaded too.

Loading the correct OS role with ansible_os_family

Now we edit (or create) a role named common so that the tasks/main.yml contains this:

- include_role: 
    name: ubuntu
  when: ansible_os_family == 'Debian'

- include_role:
    name: rhel
  when: ansible_os_family == 'RedHat'

Now depending on the current OS Family, the correct role is loaded. Great! Let's include these new roles into our playbook:

- hosts: virtual
  roles:
    - common
    - active_directory

  become: true
  vars:
    AD_Domain: AD.SORSA.CLOUD
    AD_Domain_alt: ad.sorsa.cloud
    Join_OU: DC=ad,DC=sorsa,DC=cloud
    SRV_ADM_GRP_OU: OU=groups,DC=ad,DC=sorsa,DC=cloud
    username: "{{ ad_admin_username }}"
    password: "{{ ad_admin_password }}"
    
    ssh_sftp_location: "{{ sftp_server_location }}"

Templates

Before we define our tasks, let's go over all the templates that we need:

  • Kerberos: krb5.j2
  • SSSD: sssd.conf.j2
  • SSHD: sshdconfig.j2
  • Sudoers: ADsudoers.j2

I'm placing these into a templates folder in the ansible folder base. You should probably modify these templates as you see fit.

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 }}
krb5.j2

Lots of templating. This configures only a single realm but has an includedir on the first line so that drop-in files can be added.

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

[domain/{{ AD_Domain_alt }}]
ad_domain = {{ AD_Domain_alt }}
krb5_realm = {{ AD_Domain }}
realmd_tags = manages-system joined-with-adcli 
cache_credentials = True
id_provider = ad
krb5_store_password_if_offline = True
default_shell = /bin/bash
ldap_id_mapping = True
use_fully_qualified_names = True
fallback_homedir = /home/%u@%d
access_provider = ad

ldap_user_extra_attrs = altSecurityIdentities
ldap_user_ssh_public_key = altSecurityIdentities
sssd.conf.j2

This is a pretty basic SSSD config, mainly templated for the domain. Do note the ldap_user_ssh_public_key!

%Enterprise\ Admins@{{ AD_Domain_alt }}      ALL=(ALL:ALL) NOPASSWD:ALL
%ADM_{{ ansible_hostname }}@{{ AD_Domain_alt }}   ALL=(ALL:ALL) ALL
ADsudoers.j2

A small template that can be placed to /etc/sudoers.d/.

HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key

SyslogFacility AUTHPRIV

PermitRootLogin no

AuthorizedKeysFile	.ssh/authorized_keys

AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys
AuthorizedKeysCommandUser root

PasswordAuthentication no

ChallengeResponseAuthentication no

GSSAPIAuthentication yes
GSSAPICleanupCredentials no

UsePAM yes

X11Forwarding yes

PrintMotd no

AcceptEnv LANG LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
AcceptEnv LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT
AcceptEnv LC_IDENTIFICATION LC_ALL LANGUAGE
AcceptEnv XMODIFIERS

Subsystem	sftp	{{ ssh_sftp_location }}
sshd_config.j2

Do note that this sshd configuration is pretty strict, it disables root login and password login. Note how AuthorizedKeysCommand uses sss_ssh_authorizedkeys.

Playbook Tasks

The first thing to do is install all the packages. This is easy:

  - name: Checking if packages required to join AD realm are present
    package:
      name: "{{ active_directory_pkgs }}"
      state: latest

Now we configure Kerberos so that the joining succeeds. As we defined includedir /etc/krb5.conf.d/ in the template, let's also ensure the folder exists:

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

  - name: Create /etc/krb5.conf.d
    file:
      path: /etc/krb5.conf.d
      state: directory
      owner: root
      group: root
      mode: 755

Next up is joining the domain! I'd rather not use the shell module but didn't find a better way to do this yet. We pipe the password to stdin and then run adcli with the correct parameters.

  - name: Joining 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 }}

Creating the admin group is similar. The only thing we add is a failed_when because adcli returns code 5 if the group already exists.

  - name: Creating AD server admin group
    shell: echo '{{ password }}' | adcli create-group ADMIN_{{ ansible_hostname }} --stdin-password --domain={{ AD_Domain }} --description="Admin group for {{ ansible_hostname }} server" --domain-ou={{ SRV_ADM_GRP_OU }} -U {{ username }}
    register: group_result
    failed_when: group_result.rc != 0 and group_result.rc != 5

Now let's do the templating, pretty straightforward.

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

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

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

Next up we need to do some conditioning based on the distro. As RHEL comes with the awesome authselect utility, I'm using that. On Ubuntu that is not available so I want to enable mkhomedir with pam-auth-update.

  - name: Configuring PAM/SSHD to use SSSD on RHEL
    shell: authselect select sssd with-mkhomedir --force
    when: ansible_os_family == 'RedHat'

  - name: Configuring PAM to enable mkhomedir on Ubuntu
    shell: pam-auth-update --enable mkhomedir
    when: ansible_os_family == 'Debian'

Now the last thing to do is enable and set the state of all the services we need!

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

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

  - name: Restarting SSHD
    systemd:
      name: sshd
      enabled: yes
      state: restarted

And here is the playbook in its entirety:

- hosts: virtual
  roles:
    - common
    - active_directory

  become: true
  vars:
    AD_Domain: AD.SORSA.CLOUD
    AD_Domain_alt: ad.sorsa.cloud
    Join_OU: DC=ad,DC=sorsa,DC=cloud
    SRV_ADM_GRP_OU: OU=groups,DC=ad,DC=sorsa,DC=cloud
    username: "{{ ad_admin_username }}"
    password: "{{ ad_admin_password }}"
    
    ssh_sftp_location: "{{ sftp_server_location }}"

  tasks:
  - name: Checking if packages required to join AD realm are present
    package:
      name: "{{ active_directory_pkgs }}"
      state: latest

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

  - name: Create /etc/krb5.conf.d
    file:
      path: /etc/krb5.conf.d
      state: directory
      owner: root
      group: root
      mode: 755

  - name: Joining 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 ADMIN_{{ ansible_hostname }} --stdin-password --domain={{ AD_Domain }} --description="Admin group for {{ ansible_hostname }} server" --domain-ou={{ SRV_ADM_GRP_OU }} -U {{ username }}
    register: group_result
    failed_when: group_result.rc != 0 and group_result.rc != 5

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

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

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

  - name: Configuring PAM/SSHD to use SSSD on RHEL
    shell: authselect select sssd with-mkhomedir --force
    when: ansible_os_family == 'RedHat'

  - name: Configuring PAM to enable mkhomedir on Ubuntu
    shell: pam-auth-update --enable mkhomedir
    when: ansible_os_family == 'Debian'


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

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

  - name: Restarting SSHD
    systemd:
      name: sshd
      enabled: yes
      state: restarted

Results & Conclusion

After running this playbook all the machines in the inventory are successfully in my AD domain. Woohoo! Also logging in with the AD public key over ssh works great.

I love the flexibility and readability that roles add to ansible playbooks. I will definitely be using the OS-specific roles more in the future!