LDAP & NFS

LDAP Description

LDAP stands for Lightweight Directory Access Protocol. It is a protocol for accessing directory services on the network. LDAP is at the basis of Active Directory. Linux systems commonly use the OpenLDAP variant. The term directory services can translate into virtually any information services such as telephone directory, account information, address book data used by mail clients, etc. This scheme is found virtually everywhere for network-based information services and validation (e.g., at WCU itself). A good discussion can be found in the wikipedia:
http://en.wikipedia.org/wiki/LDAP
Our OpenLDAP implementation is based on the online Ubuntu LDAP documentation:
OpenLDAP Server Docs
Unfortunately, the Ubuntu document is overly complicated, having you access and create very low-level features of the LDAP database setup.

As an LDAP novice, you'll find LDAP to be a very bewildering system. However, it has been around a very long time and is quite stable for setting up network-based user account information.

LDAP makes extensive use of LDIF (LDAP Data Interchange Format) configuration files to create entities with the LDAP database. Some common terminology used in LDIF is this:
dn:           distinguished name, a unique identifier for the record
dc:           domain component
cn:           common name, often a simple way to refer to the record
o:            organization
ou:           organizational unit
objectClass:  a class to which this entity belongs
The objectClass key signifies that LDAP is an object-oriented system whereby records belong to classes which specify properties of the record attributes (required, optional, etc). The classes are described in so-called schema files which reside in the directory /etc/ldap/schema. The multiple occurrences of objectClass which you'll see suggests that LDAP employs multiple inheritance.

The domain components (dc) are key constituents of the dn values of records. These dc values are often suggestive of a DNS domain components, which is often the case. Our fully qualified domain name MACHINE.cs.wcupa.edu would correspond to the a record whose base dn would be this:
dc=MACHINE,dc=cs,dc=wcupa,dc=edu
This entry is often used as the so-called base distinguished name, identifying the record which corresponds to our machine which provides the LDAP service. To simplify things, our example uses the base dn of:
dc=MACHINE
In a real-world situation, the former value would be preferable; however, LDAP doesn't really require any relationship between its names and DNS names, so you can do whatever you like.

Preparation for LDAP Server installation

We're going to rely on Python modules (scripts) to populate, present, and modify the LDAP database. At the basis is a module which will provide all the necessary variable definitions, which you will create as:
/usr/local/share/ldap/myldap.py
in a directory accessible only to you (and root of course).

Create LDAP Support Library

$ sudo mkdir /usr/local/share/ldap
$ sudo chgrp LOGIN /usr/local/share/ldap
$ sudo chmod 770 /usr/local/share/ldap
In order to make this directory accessible to Python, we need set the PYTHONPATH environment variable, so:
$ nano ~/.profile
append the line:
export PYTHONPATH=/usr/local/share/ldap
You must log out / log in to have this variable be in effect. Verify when you reenter:
$ printenv PYTHONPATH
/usr/local/share/ldap

Additional PYTHONPATH modifications in PyDev

To pick up the PYTHONPATH setting, we need to reconstruct the Python Interpreter in Eclipse. Go through Window ⇾ Preferences and select
PyDev ⇾ Interpreters ⇾ Python Interpreter
The python interpreter entry you created originally should be selected. We want to recreate it.

Click the button, then the button.

Proceed as before. Add this information:
Interpreter Name:       python
Interpreter Executable: /usr/bin/python
Click Apply to activate. You should now see in the bottom window that
/usr/local/share/ldap
is one of the directory entries.


Keep going. We need the crypt module, but it is missing from the Python2 directories. Instead it appears in the Python3 directory:
/usr/lib/python3.5/crypt.py
The significance is that this module works for both Python versions; however, PyDev does not recognize this subtlety.

Click the and navigate (start at File System) to the following folder and click OK to select it:
/usr/lib/python3.5
This folder should appear as the last entry in the PYTHONPATH choices. Click Apply to activate and then OK to leave.

LDAP Server installation

Install the necessary packages. The installation requires you to create an LDAP administrator password. Like the MySQL root password, we will record this into a file, so you don't have to remember it.
LDAP admin password:
Install the server-side LDAP like this:
$ sudo apt-get install slapd ldap-utils python-ldap
The configuration is two steps, the first to enter the password:
┌─────────────────────────┤ Configuring slapd ├──────────────────────────┐   
│ Please enter the password for the admin entry in your LDAP directory.  │   
│ Administrator password:                                                │   
│                                                                        │   
│  LDAP_ADMIN_PASS                                                       │   
│                                                                        │   
│                                 <Ok>                                   │   
│                                                                        |   
└────────────────────────────────────────────────────────────────────────┘   
The second step confirms the password.

The next phase is to create the initial LDAP setup:
$ sudo dpkg-reconfigure slapd
Follow these steps:
  1. Omit OpenLDAP server configuration?
    Take the No default.
  2. DNS domain name (this is very important):
    MACHINE
  3. Organization name (this probably doesn't matter much):
    MACHINE
  4. Administrator password:
    LDAP_ADMIN_PASS
  5. Confirm password:
    LDAP_ADMIN_PASS
  6. Database backend to use.
    Take the MDB default.
  7. Do you want the database to be removed when slapd is purged?
    For pedagogical sake, set this to Yes.
  8. Move old database?
    Take the Yes default.
  9. Allow LDAPv2 protocol?
    Take the No default.
Saying Yes in step 7 allows you to "completely start over" if something goes wrong by doing:
$ sudo apt-get purge slapd
$ sudo apt-get install slapd

Now create the myldap.py module. Create it as normal user:
$ nano /usr/local/share/ldap/myldap.py
Make this be the content:

/usr/local/share/myldap.py
ldap_url  = "ldapi:///"
base_dc   = "MACHINE"
base_dn   = "dc={}".format(base_dc)
admin_dn  = "cn=admin,{}".format(base_dn)
people_dn = "ou=People,{}".format(base_dn)
group_dn  = "ou=Group,{}".format(base_dn)
admin_pwd = "LDAP_ADMIN_PASS"
select

Set the default base for LDAP utils

The file /etc/ldap/ldap.conf provides defaults for the ldap utility commands. We only need the base dn as the value of BASE (it's commented out with a sample setting):

/etc/ldap/ldap.conf (modification)
BASE    dc=MACHINE

See what you have so far

In order to illustrate some basic behavior of LDAP client access and to prove the effectiveness of the admin dn, run the ldapsearch utility in two ways:
$ ldapsearch -x

$ ldapsearch -xW -D "cn=admin,dc=MACHINE"
Enter LDAP Password: LDAP_ADMIN_PASS
The output is not very informative, but the access methods are. LDAP access, say, in contrast to MySQL, has separate phases: The first ldapsearch call uses anonymous binding on the connection. The ubiquitous "-x" option means to use simple authentication, i.e., not using SASL (don't worry if this makes no sense). With anonymous binding, it is often the case that most of the information in the records can be read, but not written.

When the "-D" option is used it means that the connection is bound to a specific entity specified by the distinguished name provided. In the above example, the distinguished name is that of the admin user which has read/write privileges on all the records. The "-W" option means that the password needed to authenticate the administrator will be provided through standard input.

It is an interesting exploration into complexity to look at the backend configuration directory, /etc/ldap/slapd.d/:
$ sudo ls -RF /etc/ldap/slapd.d/
You can now "forget" the LDAP admin password with respect to this document.
LDAP admin password:

Populate the LDAP Database

We're going to use a few Python files to populate and modify the LDAP database. These are contained in the Python ldap project. Download the source archive
ldap.zip
I'll recommend that you use Eclipse, so extract the archive into the ~/workspace/ directory and create a PyDev project, ldap, from the folder
~/workspace/ldap
Our scripts will create LDIF files, and it's useful to the view these files in Eclipse. So do another Eclipse MarketPlace installation. Enter this into the Find box:
Find:
You should see one choice:
Apache Directory Studio.
Click Install and then follow through by the usual "say yes" steps.

Getting the PYTHONPATH export setting

I have been having problems using this scheme over Remote Desktop in that the PYTHONPATH setting from ~/.profile does not seem to take. If necessary, force it in a shell by first running:
$ export PYTHONPATH=/usr/local/share/ldap

Python LDAP populate script

We will employ a home-grown script, ldif_add.py, to add LDIF record entries:

ldif_add.py
#!/usr/bin/env python
 
'''
@author: rkline
reference: https://www.python-ldap.org/doc/html/ldif.html
'''
import argparse, ldap, os
from ldif import LDIFParser
import ldap.modlist as modlist
 
from myldap import ldap_url, admin_dn, admin_pwd
 
parser = argparse.ArgumentParser()
parser.add_argument('ldif', help='ldif file')
args = parser.parse_args()
 
# connect and admin-bind
cx = ldap.initialize( ldap_url )
cx.simple_bind_s( admin_dn, admin_pwd ) 
 
# as suggested in the docs, create an extension of the
# LDIFParser class and make the handler method do the adding
class MyLDIF(LDIFParser):
    def __init__(self, infile):
        LDIFParser.__init__(self, infile)
 
    def handle(self, dn, attrs):
        try:
            results = cx.search_s( dn, ldap.SCOPE_BASE, '(objectClass=*)' )
        except:
            results = None
        if results:
            print 'entry exists for dn:', dn
            return 1
        ldif = modlist.addModlist(attrs)
        print "added entry for dn: ", dn
        cx.add_s(dn,ldif)
        return 0
 
ldif_file = args.ldif
if not os.path.exists(ldif_file):
    print '**** no such file: {}'.format(ldif_file)
    exit(1)
 
parser = MyLDIF(open(ldif_file, 'r'))
parser.parse()
The ldif_add.py script exhibits some features common to any LDAP program:
  1. Establish a (initially anonymous) connection to the LDAP server:
    cx = ldap.initialize( ldap_url )
  2. In order to make some changes, bind the connection to the admin dn:
    cx.simple_bind_s( admin_dn, admin_pwd )

Creating LDIF files

It's best to see the ldif_add.py script in action. The idea is that you're going to generate an LDIF file and then run this scrip to enter the entities into the database. Every LDIF file you use must contain information specific to your LDAP site, for example the initial entities for People and Group contain these starting "dn" lines, respectively:
dn: ou=People,dc=MACHINE 

dn: ou=Group,dc=MACHINE 
We are going to write Python modules which reference the site-specific information in myldap.py and make dynamic substitutions in order to create the static LDIF output files.

People entity

Start by getting a shell to the right place:
$ cd workspace/ldap
Run all the Python scripts from this directory.

Start with the module:

people_ldif.py
from string import Template
from myldap import base_dn
 
__tmpl = Template('''\
dn: ou=People,$BASE_DN
ou: People
objectClass: organizationalUnit
objectClass: top
'''
)
 
print __tmpl.substitute( BASE_DN = base_dn )
Execute it
$ python people_ldif.py
getting the LDIF output:
dn: ou=People,dc=MACHINE
ou: People
objectClass: organizationalUnit
objectClass: top
 
To add this entry to the LDAP database, redirect the standard output into an LDIF file and then call ldif_add.py on that file.
$ python people_ldif.py > people.ldif

$ ./ldif_add.py people.ldif
adding new entry "ou=People,dc=MACHINE"

Group entity

Same foes for the group_ldif.py module:

group_ldif.py
from string import Template
from myldap import base_dn
 
__tmpl = Template('''\
dn: ou=Group,$BASE_DN
ou: Group
objectClass: organizationalUnit
objectClass: top
'''
)
 
print __tmpl.substitute( BASE_DN = base_dn )
$ python group_ldif.py > group.ldif

$ ./ldif_add.py group.ldif
adding new entry "ou=Group,dc=MACHINE"
Check what you've got so far:
$ ldapsearch -x

Why the string.Template.substitute and not format?

The key idea in all the substitutions made is to identify a substitute variable:
$BASE_DN
and replace it by the value of base_dn found in myldap.py. So why not use our usual format with "{}" instead. There are several reasons:

Add entries for two users

The users on our system, like a Linux system, have LDIF entries making them a "People" entity as well as a "Group" entity. You can put more than one LDIF entry into the same file. They must be separated by one or more empty line (implying that a single LDIF entity cannot have empty lines).

Here are our two sample persons. Each defines a People entry and a Group entry.

asmith_ldif.py
from string import Template
from myldap import base_dn
 
__tmpl = Template('''\
dn: uid=asmith,ou=People,$BASE_DN
uid: asmith
cn: Alice Smith
objectClass: account
objectClass: posixAccount
objectClass: top
objectClass: shadowAccount
userPassword:
shadowLastChange: 0
shadowMax: 99999
shadowWarning: 7
loginShell: /bin/bash
uidNumber: 2001
gidNumber: 2001
homeDirectory: /home/asmith
gecos: Alice Smith
 
dn: cn=asmith,ou=Group,$BASE_DN
cn: asmith
objectClass: posixGroup
objectClass: top
userPassword: {crypt}x
gidNumber: 2001
'''
)
 
print __tmpl.substitute( BASE_DN = base_dn )

bjones_ldif.py
from string import Template
from myldap import base_dn
 
__tmpl = Template('''\
dn: uid=bjones,ou=People,$BASE_DN
uid: bjones
cn: Bob Jones
objectClass: account
objectClass: posixAccount
objectClass: top
objectClass: shadowAccount
userPassword:
shadowLastChange: 0
shadowMax: 99999
shadowWarning: 7
loginShell: /bin/bash
uidNumber: 2002
gidNumber: 2002
homeDirectory: /home/bjones
gecos: Bob Jones
 
dn: cn=bjones,ou=Group,$BASE_DN
cn: bjones
objectClass: posixGroup
objectClass: top
userPassword: {crypt}x
gidNumber: 2002
'''
)
 
print __tmpl.substitute( BASE_DN = base_dn )
$ python asmith_ldif.py > asmith.ldif
$ python bjones_ldif.py > bjones.ldif

$ ./ldif_add.py asmith.ldif
added entry for dn:  "uid=asmith,ou=People,dc=MACHINE"
added entry for dn:  "cn=asmith,ou=Group,dc=MACHINE"

$ ./ldif_add.py bjones.ldif
added entry for dn:  "uid=bjones,ou=People,dc=MACHINE"
added entry for dn:  "cn=bjones,ou=Group,dc=MACHINE"
After this, see what all you've got:
$ ldapsearch -x

Relationship of people entries to UNIX login information

The information in the python LDIF files corresponds to what would appear in these three system files. Take a look at the LDIF file asmith.ldif.

If asmith were a normal user on a UNIX system, there would information in these files:
  1. /etc/passwd: specifies uid, uidNumber, gidNumber, gecos, homeDirectory, and loginShell:
    asmith:x:2001:100:Alice Smith:/home/asmith:/bin/bash
  2. /etc/group: identifies the gidNumber:
    asmith:x:2001:
  3. /etc/shadow: specifies these entries (see "man 5 shadow"):
    asmith:F2:F3:F4:F5:F6:F7:F8:
    Association with LDAP fields are as follows. Dates are days since 1/1/1970.
    F2: encrypted password (userPassword)
    F3: date of last password change (shadowLastChange)
    F4: min. password age = # days before password can be changed (0 = no minimum)
    F5: max. password age = # days before password expires (shadowMax)
    F6: password warning period = expiration warning period (shadowWarning)
    F7: password inactivity period = # days after expiration before must change
    F8: account expiration = date when account expires
    	

Manipulate LDAP Entities

LDAP is an odd object-oriented database, but just like a database, we want to be able to read, modify and delete entries as well as add them.

List user entries

The next program to study is this:

ldif_userDump.py
#!/usr/bin/env python
 
'''
@author: rkline
'''
import argparse, ldap, sys
from ldif import LDIFWriter
 
from myldap import ldap_url, admin_dn, admin_pwd, people_dn, group_dn
 
parser = argparse.ArgumentParser()
parser.add_argument('login', help='user login', nargs='?')
args = parser.parse_args()
 
cx = ldap.initialize( ldap_url )
 
output = sys.stdout
writer = LDIFWriter(output)
 
if not args.login:
    # called without arguments, print all user and group entries
 
    print "==> People entries\n"
 
    results = cx.search_s( people_dn, ldap.SCOPE_SUBTREE, '(uid=*)' )
 
    for res in results:
        dn = res[0]
        attrs = res[1]
        writer.unparse(dn,attrs)
 
    print "==> Group entries\n"
 
    results = cx.search_s(group_dn, ldap.SCOPE_SUBTREE, '(cn=*)')
 
    for res in results:
        dn = res[0]
        attrs = res[1]
        writer.unparse(dn,attrs)
 
else:
    # called with a login, print the user and group entries for that login
 
    login = args.login
 
    # admin bind to get more secure information
    cx.simple_bind_s( admin_dn, admin_pwd ) 
 
    results = cx.search_s(people_dn, ldap.SCOPE_SUBTREE, '(uid={})'.format(login))
 
    if len(results) == 0:
        print "\n**** No user entry for {}\n".format(login)
    else:
        result = results[0]  # the first result in search (only expecting one)
        dn = result[0]       # the first element
        attrs = result[1]
 
        writer.unparse(dn,attrs)
 
    results = cx.search_s(group_dn, ldap.SCOPE_SUBTREE, '(cn={})'.format(login))
 
    if len(results) == 0:
        print "\n**** No group entry for {}\n".format(login)
    else:
        result = results[0]  # the first result in search (only expecting one)
        dn = result[0]       # the first element
        attrs = result[1]
 
        writer.unparse(dn,attrs)
The two usages are to get all user/group entries in LDIF format:
$ ./ldif_userDump.py
and to get a single one, e.g.,
$ ./ldif_userDump.py asmith
Using the first call, you can easily get the key "dn" field entries for every user by filtering through grep:
$ ./ldif_userDump.py | grep ^dn
A pedagogical point is that fining all entities is done with anonymous binding (none) whereas finding a single one is done with admin binding.

In either case, we find all entities by:
# people
results = cx.search_s( people_dn, ldap.SCOPE_SUBTREE, '(uid=*)' )
 
# group
results = cx.search_s(group_dn, ldap.SCOPE_SUBTREE, '(cn=*)')
When a login is given, we find a single entity like this (it could be done through anonymous binding as well):
results = ...
if len(results) == 0:
    print "\n**** No ... entry for {}\n".format(login)
else:
    result = results[0]  # the first result in search (only expecting one)
    dn = result[0]       # the first element
    attrs = result[1]
 
    writer.unparse(dn,attrs)
The initial visual difference between the "all" and "single" dumps is the level on information given. The "single" dump gives the all important field (initially empty)
userPassword
whereas the the "all" dump does not.

Remove Entities

The next script is one which removes an LDAP entity. The way to do so is by the "dn" entry.

removeDN.py
#!/usr/bin/env python
 
'''
@author: rkline
'''
 
import ldap, argparse
 
from myldap import ldap_url, admin_dn, admin_pwd
 
parser = argparse.ArgumentParser()
parser.add_argument('dn', help='dn entry')
args = parser.parse_args()
 
cx = ldap.initialize( ldap_url )
cx.simple_bind_s( admin_dn, admin_pwd ) 
 
dn = args.dn
 
cx.delete(dn)
Say I want to get rid of user bjones, do this to identify the two "dn" entities you want to remove:
$ ./ldif_userDump.py  | grep ^dn | grep bjones
Then get rid of them like this (quoting the dn values is necessary):
$ ./removeDN.py "uid=bjones,ou=People,dc=MACHINE"
$ ./removeDN.py "cn=bjones,ou=Group,dc=MACHINE"
Confirm by running the previous ./ldif_userDump.py call to see that there's no output.

The removeDN.py script achieves its affect in a very direct way. What it does not do (as far as I can tell) is indicate whether a removal occurred or not, which feels a bit incomplete.

Modify: change user password

The next and last script to look at is one which changes a user password.

changePwd.py
#!/usr/bin/env python
 
import ldap
import getpass, argparse, pprint
import string, random
import crypt
from time import time
 
from myldap import ldap_url, admin_dn, admin_pwd, people_dn
 
parser = argparse.ArgumentParser()
parser.add_argument('uid', help='user login')
args = parser.parse_args()
 
uid = args.uid
 
cx = ldap.initialize( ldap_url )
 
results = cx.search_s( people_dn, ldap.SCOPE_SUBTREE, '(uid={})'.format(uid) )
 
if len(results) == 0:
    print "\n**** No such user\n"
    exit(1)
 
result = results[0]  # the first result in search (only expecting one)
user_dn = result[0]  # the first element
 
cx.simple_bind_s( admin_dn, admin_pwd ) 
 
password = getpass.getpass("Password: ")
confirm  = getpass.getpass(" Confirm: ")
 
if password != confirm:
    print "\n**** Confirmation not same as Password\n"
    exit(1)
 
# get the encrypted password
chars = string.ascii_uppercase + string.ascii_lowercase + string.digits + '/.'
salt = ''.join(random.SystemRandom().choice(chars) for _ in range(8))
salt = '$6$' + salt + '$'
encrypted = crypt.crypt(password,salt)
userPassword = '{crypt}' + encrypted
 
'''
This is another approach to get a salted SHA password
 
import os, hashlib, base64
salt = os.urandom(4)  # random 4-byte binary
h = hashlib.sha1()
h.update(password)
h.update(salt)
userPassword = "{SSHA}" + base64.b64encode(h.digest() + salt)
'''
 
seconds_per_day = 24 * 60 * 60
shadowLastChange = int( time() / seconds_per_day )
 
attrs = [
    (ldap.MOD_REPLACE, 'shadowLastChange', str(shadowLastChange) ),
    (ldap.MOD_REPLACE, 'userPassword', userPassword ),
]
 
cx.modify_s(user_dn, attrs)
 
results = cx.search_s( people_dn, ldap.SCOPE_SUBTREE, '(uid={})'.format(uid) )
 
user_result = results[0]
 
print "\n------------------------------------"
print pprint.pformat(user_result)
print "------------------------------------"
Change the password of asmith to be your own login password (it's never in plain text and it's one less password to remember):
$ ./changePwd.py asmith
Password: your-login-password
 Confirm: your-login-password
Verify that this password works.
$ ldapsearch -xW -D "uid=asmith,ou=People,dc=MACHINE"
Enter LDAP Password: your-login-password
and take a look at the new dump entries:
$ ./ldif_userDump.py asmith
The changePwd.py script exhibits new features:
  1. We obtain the password (and confirmation) without echoing to terminal with this code:
    password = getpass.getpass("Password: ")
  2. We obtain the correct encryption of the password as follows:
    1. Obtain a string of all characters usable for the "salt" of as SSHA512 encrpytion:
      chars = string.ascii_uppercase + string.ascii_lowercase + string.digits + '/.'
      
    2. Construct the salt part by taking 8 random choices from the chars string and surrounding them by the SSHA512 salt designation:
      salt = ''.join(random.SystemRandom().choice(chars) for _ in range(8))
      salt = '$6$' + salt + '$'
      
    3. Create the 512-bit encryption based on the password and the salt:
      encrypted = crypt.crypt(password,salt)
      
    4. Put the whole thing together, salt + encrypted password, plus a designation of the general type of encryption created:
      userPassword = '{crypt}' + encrypted
      
  3. Use the connection to make the necessary modifications on the users' LDAP entry:
    attrs = [
      (ldap.MOD_REPLACE, 'shadowLastChange', str(shadowLastChange) ),
      (ldap.MOD_REPLACE, 'userPassword', userPassword ),
    ]
    cx.modify_s( user_dn, attrs )

Become an LDAP client

The LDAP server-side is in place. We now want our machine to be an LDAP client. Install this:
$ sudo apt-get install libnss-ldap
A configurator for the auth-client-config package runs automatically. Make these choices:
LDAP server: ldapi:/// (the default)
Distinguished name base: dc=MACHINE (not the default)
LDAP version: 3 (the default)
Make local root Database admin? No (not the default)
Does the LDAP database
require login?
No (the default)

The effects are to add and start a new service, libnss-ldap, and configure these files to use ldap for all authentication forms:
/etc/pam.d/
   common-account
   common-auth
   common-password
   common-session
   common-session-noninteractive
Most of the information entered creates the key control file: /etc/ldap.conf. Most of the file consists of commented lines, but you can find the un-commented lines by:
$ grep -v ^# /etc/ldap.conf | grep -P "\S"

base dc=MACHINE
uri  ldapi:///
ldap_version 3
pam_password md5
If you need to change this LDAP client configuration, Ubuntu suggests doing this:
$ sudo dpkg-reconfigure ldap-auth-config

Next, create the setup that allows system clients to authenticate against LDAP:
$ sudo auth-client-config -t nss -p lac_ldap
The affected file is /etc/nsswitch.conf which is used to determine choices for obtaining user (and other) information. You can cat this file and observe the content:
$ cat /etc/nsswitch.conf
...
# pre_auth-client-config # passwd:         compat
passwd: files ldap
# pre_auth-client-config # group:          compat
group: files ldap
# pre_auth-client-config # shadow:         compat
shadow: files ldap
...
We need to make configuration changes in the appropriate PAM modules so that LDAP authentication is used. Run this:
$ sudo pam-auth-update
This will ask you to indicate which authentication profiles to enable (the list includes the newly added LDAP Authentication). All are probably be checked by default. Just tab to OK and Enter.

To pick up these low-level changes, you must reboot your system:
$ sudo reboot

Verify account status for asmith

Coming back from reboot, the observation is the presence of asmith as a system user by:
$ getent passwd
$ getent group
$ sudo getent shadow
Secondly, trying switching user to asmith:
$ su - asmith
Password: your-login-password
No directory, logging in with HOME=/
asmith@MACHINE:/$ exit
Give asmith a home directory by this simple one-liner:
$ sudo mkhomedir_helper asmith
Then try again:
$ su - asmith
Finally, assuming you have installed SASL from Web Authentication and HTTPS, you can verify that aperson is a "valid user" for SASL authentication by doing:
$ testsaslauthd -u asmith -p your-login-password
The user "asmith" is not in any of the standard password files, but SASL authentication, by default, uses the PAM mechanism, as prescribed in the configuration file /etc/default/saslauthd. In particular LDAP users can be recognized as valid users for Apache Mod_Authn_SASL authentication. Same goes with External authentication which you can test by:
$ pwauth
asmith
your-login-password
$ echo $?
0

NFS

NFS (Network File System) is about accessing the file system of a server from a separate client machine.

We are going to make our machine an NFS server and serve the /home directory to the virtual machine. Recent Ubuntu systems default to NFS version 4 which seems to needs way too much tinkering to get it right, so we'll stick with the older version 3.

Server setup

The host will be the NFS server. We'll set ip up so that any VM client can mount the "/home" directory from the host.
  1. Install the server package:
    [MACHINE] $ sudo apt-get install nfs-kernel-server
    
  2. Export the entire /home file system to any virtual machine. Edit the file /etc/exports on MACHINE, adding this line:
    /home 192.168.122.0/24(rw,async,no_subtree_check)
    
  3. Restart the NFS service and verify exports:
    [MACHINE]$ sudo service nfs-kernel-server restart
    
    [MACHINE]$ sudo exportfs
    /home         	192.168.122.0/24
    

VM Client

We want a new VM with the name nfsvm.
  1. Create and access:
    [MACHINE]$ uvt-kvm create nfsvm --memory 256
    [MACHINE]$ uvt-kvm ssh nfsvm --insecure
    
  2. Make it have the static IP address 192.168.122.13 and reboot. See Virtualization with KVM for examples.
  3. Create an /etc/hosts entry and an ~/.ssh/config entry for the new VM nfsvm. Again, see Virtualization with KVM for examples.
  4. Access it from the host and update:
    [MACHINE]$ ssh nfsvm
    [nfsvm]$ sudo apt-get update
    

Make the VM be an LDAP client

Install the basic LDAP client-side login access package on the guest. The procedure is almost the same as what we did for the host machine.
[nfsvm]$ sudo apt-get install libnss-ldap ldap-utils
Make these choices in the configurator (the difference from the host machine setup is highlighted):

LDAP server: ldap://192.168.122.1 (not the default)
Distinguished name base: dc=MACHINE (not the default)
LDAP version: 3 (the default)
Make local root Database admin? No (not the default)
Does the LDAP database
require login?
No (the default)

Create the setup that allows system clients to authenticate against LDAP:
[nfsvm]$ sudo auth-client-config -t nss -p lac_ldap
Finally, run this command to establish LDAP authentication within PAM:
[nfsvm]$ sudo pam-auth-update
Tab to OK and Enter. Then reboot the virtual machine:
[nfsvm]$ sudo reboot
Go back in and confirm that the user "asmith" is there:
[nfsvm]$ getent passwd

VM as NFS Client

Now we want the guest VM to be an NFS client of the host. In particular we want the user "asmith" to access her home directory on the host as /home/asmith on the VM guest.

Install the NFS client software:
[nfsvm]$ sudo apt-get install nfs-common
Then mount the /home directory, exported from the host onto the client:
[nfsvm]$ sudo mount 192.168.122.1:/home /home
[nfsvm]$ ls /home
Doing so has now made the ubuntu home hidden. If we were to log out we could not get back into the nfsvm!

Make "asmith" the VM admin instead of ubuntu

We need to do some prep work before continuing. First, on the client side, create a sudoers entry for asmith:
[nfsvm]$ sudo su
[nfsvm]# nano /etc/sudoers.d/90-cloud-init-users
Add a line for "asmith" same as that for "ubuntu" (be careful to get the syntax EXACTLY correct):
asmith ALL=(ALL) NOPASSWD:ALL
Then on the host side, create a public key for "asmith" and authorize access to localhost:
[MACHINE]$ sudo su
[MACHINE]$ su - asmith
[asmith@MACHINE]$ ssh-keygen
[asmith@MACHINE]$ cd .ssh/
[asmith@MACHINE]$ cp id_rsa.pub authorized_keys
Test it for asmith. It should go through without a password.
[asmith@MACHINE]$ ssh nfsvm
[asmith@nfsvm]$ sudo su
[root@nfsvm]# 
Also add your public key (i.e. for LOGIN) onto the authorized keys of "asmith" so that you can get it, too. On the host, as root:
[MACHINE]# cat /home/LOGIN/.ssh/id_rsa.pub >> /home/asmith/.ssh/authorized_keys
So "asmith" is now the new official admin on nfsvm when /home is mounted from the client.

Make the mount permanent

Using the root shell from "asmith", edit the file /etc/fstab on the client, adding a line to the end:

/etc/fstab (on nfsvm)
192.168.122.1:/home   /home   nfs  defaults   0  0
Then reboot nfsvm.

Test asmith admin access

When nfsvm comes back, you should be able to get in as "asmith" per se, or better, as you using the "asmith" login:
[LOGIN@MACHINE]$ ssh asmith@nfsvm
[asmith@nfsvm]$ exit
This is now the way in for you, so change your ~/.ssh/config file entry to:
Host nfsvm
  user asmith
and then confirm access:
[LOGIN@MACHINE]$ ssh nfsvm


© Robert M. Kline