Miscellany

RADIUS

User File

You could possibly skip LDAP and use only a user file.

#! /bin/sh
cd /etc/raddb
if [ ! -e .last-reload ] || [ "`find users -nt .last-reload`" ]; then
    if radiusd -C > .last-reload 2>&1; then
        kill -1 `cat /var/run/radiusd.pid`
    else
        mail -s "radius reload failed!" root < .last-reload
    fi
fi
touch .last-reload

And it may be of some value to include multiple files with the $INCLUDE directive.

Testing VLAN Assignment

The ‘WiFi’ client group we created in the RADIUS setup stipulated they’d all be on the 10 network. To test from localhost, you’ll need to explicitly add that. A quick way is to edit the default site file.

A Static Example

Find the post-auth section, Add the block after the update block in the existing post-auth block. Here’s a static sample.

vi /etc/freeradius/3.0/sites-available/default
...
...
post-auth {
        ...
        ...
        update {
                &reply: += &session-state:
        }

        update reply {
                &Tunnel-Type = 13,
                &Tunnel-Medium-Type = 6,
                &Tunnel-Private-Group-Id = "1020"
        }

A Dynamic Example

You can also test this with a users file, so as to testing getting the result from a user database.

# Remove the 'update reply' block and any static assignments added above
vi /etc/freeradius/3.0/sites-available/default
# The attributes will be assigned directly from the userdb to the reply
vim  /etc/freeradius/3.0/users

bob     NT-Password := "066DDFD4EF0E9CD7C256FE77191EF43C"
        Tunnel-Type = "VLAN",
        Tunnel-Medium-Type = "IEEE-802",
        Tunnel-Private-Group-Id = "1020"

radtest should show you something like

radtest -t mschap bob hello localhost 0 testing123

...
...
Received Access-Accept Id 255 from 127.0.0.1:1812 to 127.0.0.1:37509 length 102
        MS-CHAP-MPPE-Keys = 0x0000000000000000ac0782e2de2337dee40e54ee732c1af5
        MS-MPPE-Encryption-Policy = Encryption-Allowed
        MS-MPPE-Encryption-Types = RC4-40or128-bit-Allowed
        Tunnel-Type:0 = VLAN
        Tunnel-Medium-Type:0 = IEEE-802
        Tunnel-Private-Group-Id:0 = "1020"

Note that with RADIUS attribute definitions you can use the numerical representation, 6, or the actual string “IEEE-802”. It’s rumored that the string works best with the user database, but it’s worth testing yourself.

Certificate Life

The Let’s Encrypt certs are short-lived. Apple will prompt you again every time it renews, which is currently 2 months. It will show as valid, but they just want to notify you it’s changed. This can be annoying, but since the industry is moving towards shorter validity periods with a goal of 47 days by 2029, we can only hope Apple changes how they handle it.

LDAP

Why Use LDAP

for large numbers or real-time changes. One can use a relational database like MySQL, but most of the tools in the Identity Management space work better with LDAP. In the past, one would pass-thru the authentication directly to LDAP. But this doesn’t work with MSCHAPv2. Instead, we will treat OpenLDAP as a database as recommended in the FreeRADIUS docs: ‘We generally recommend that LDAP should be used as a database…"

How LDAP Passwords Work

There is an attribute for LDAP Passwords; userPassword. But this isn’t suitable for our use as it’s designed to be plain text string as per RFC4519. The admin password is salted SHA.

You may notice when using radtest that Bob’s password is returned as what looks like a hash. But it’s not. The userPassword attribute is a base64 encoded plain text string as per RFC4519. You can decode it with a echo Ym9ic1Bhc3M= | base64 --decode. This is one of the reasons we used a hash.

Using a Password Hash

It’s counter intuitive, but many documents and even the LDAP RFC4519 prefer passwords stored in plain-text as this ensures compatibility with all protocols. In the above test, the password is encrypted by the client (-t mschap) before it’s sent. The client tells the server what algorithm it used and the server hashes it’s own copy on the fly to compare. If another client used a different protocol, the server could still handle it.

But we’re only interested in one password protocol, MSCHAPv2. That allows us to store just the hash on the server and makes security guys breathe easier. Do this with the smbcrypt utility that’s installed with FreeRADIUS.

Password Value Header

You’ll notice that we’re storing the NT Hash with a header in the userPassword attribute. OpenLDAP doesn’t understand nthash as a scheme and so it blocks direct logins with it, but it’s happy to pass the data to RADIUS as a base64 encoded text string.

# Let's take a look at the person object to see we have to work with
ldapsearch -x -s base -b cn=Subschema objectClasses -LLL -o ldif-wrap=no |  sed -nr '/person/ p' | sed -r 's/[$()]+/\n /g'

# There are several variants, but lets look at the first one, the basic 'person' object. You'll see something like

        objectClasses:
          2.5.6.6 NAME 'person' DESC 'RFC2256: a person' SUP top STRUCTURAL MUST
            sn
            cn
          MAY
            userPassword
            telephoneNumber
            seeAlso
            description

# Let's take a look at the attributes to make sure we can just put text in them.
ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' attributeTypes | grep description

        attributeTypes: ( 2.5.4.13 NAME 'description' DESC 'RFC4519: descriptive information' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' ldapSyntaxes | grep  1.3.6.1.4.1.1466.115.121.1.15

        ldapSyntaxes: ( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )

# So what's a directory string? One or more aribtray characters. That will work fine for an aribitrary description like 'staff'

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' attributeTypes | grep seeAlso

        attributeTypes: ( 2.5.4.34 NAME 'seeAlso' DESC 'RFC4519: DN of related object' SUP distinguishedName )

# That won't work as it must refer to another ldap object. How about telephoneNumber

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' attributeTypes | grep telephoneNumber

        attributeTypes: ( 2.5.4.20 NAME 'telephoneNumber' DESC 'RFC2256: Telephone Number' EQUALITY telephoneNumberMatch SUBSTR telephoneNumberSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.50{32} )

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' ldapSyntaxes | grep 1.3.6.1.4.1.1466.115.121.1.50

        ldapSyntaxes: ( 1.3.6.1.4.1.1466.115.121.1.50 DESC 'Telephone Number' )

# According to the web, that syntax is 'a string of [printable characters]' and that will work for a date if we needed to mis-use it.

Resetting OpenLDAP ACLs

If you have misconfigured your ACLs but can’t determine how, you may benefit from a reset.

# Default ACLs
vi acls-orig.ldif
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read

ldapmodify -Y EXTERNAL -H ldapi:/// -f idm-replace.ldif

Understanding LDAP ACLs

Take a look at the base, or default ACLs.

# su to root so we can use the EXTERNAL auth for a change.
su -
ldapsearch -Q -LLL -Y EXTERNAL -H ldapi:/// -b cn=config '(olcDatabase={1}mdb)' olcAccess

dn: olcDatabase={1}mdb,cn=config
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read

These rules are somewhat cryptic at first but you can puzzle them out once you see that it’s a set of three-word rules. The first line is the DN of rule object itself (which you can ignore) and the successive lines refer to a thing, and then who can do what with it.

In the first ACL, it’s referring to the attribute userPassword and says by self write and then by anon auth and then by * none. The middle one is a puzzler at first, but means anon can go back and try to authenticate, to be reconsidered later.

Importantly, these rules are short-circuit style. The by * none at the end of the first ACL will catch everyone, so even though the last ACL says by or everything by everyone read, the userPassword in the first ACL hit first and overrides for that attribute.

Changing ACLs

In our case, we need the FreeRADIUS user as a ‘read’ and the IDM user as a ‘write’. This is a modify-replace ldap operation as we need to replace rule 0.

vi FreeRADIUS-IDM-password.ldif

dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by dn="cn=FreeRADIUS,dc=example,dc=org" read by dn="cn=IDM,dc=example,dc=org" write by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read

ldapmodify -Y EXTERNAL -H ldapi:/// -f FreeRADIUS-IDM-password.ldif

We also need the IDM account to manage the user container. You must grant it specifically to the container itself (so it can add entries) and to the children (so it can delete and modify existing entries). This is a modify-add ldap operation and as we are adding these at rule 2, it will push anything after down automatically.

vi IDM-Access.ldif

dn: olcDatabase={1}mdb,cn=config
changetype: modify
add: olcAccess
olcAccess: {2}to dn.exact="ou=people,dc=example,dc=org" attrs=children by dn="cn=IDM,dc=example,dc=org" manage
olcAccess: {3}to dn.children="ou=people,dc=example,dc=org" by dn="cn=IDM,dc=example,dc=org" manage

ldapmodify -Y EXTERNAL -H ldapi:/// -f idm-access.ldif

That will give us the new ACL list of:

ldapsearch -Q -LLL -Y EXTERNAL -H ldapi:/// -b cn=config '(olcDatabase={1}mdb)' olcAccess

dn: olcDatabase={1}mdb,cn=config
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by dn="cn=FreeRADIUS,dc=example,dc=org" read by dn="cn=IDM,dc=example,dc=org" write by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to dn.exact="ou=people,dc=example,dc=org" attrs=children by dn="cn=IDM,dc=example,dc=org" manage
olcAccess: {3}to dn.children="ou=people,dc=example,dc=org" by dn="cn=IDM,dc=example,dc=org" manage
olcAccess: {4}to * by * read

Root Password Reset

ldap_bind: Invalid credentials (49)

I see this sometimes and it appears to be a bug with the debconf that pops up everyone so often. You must manually set the password.

# Get the hash for the password you want to use
slappasswd 

# This command connects to an interactive ldap session
ldapmodify -Y EXTERNAL -H ldapi://

# Type in the next the lines to set the password
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcRootPW
olcRootPW: {SSHA}4uDGf5aZylGWXoTde6Mvsp5CIph+XFNo

# Hit enter one more time and you should see the message
modifying entry "olcDatabase={1}mdb,cn=config"

# Hit Ctrl-C to end the interactive session, then restart slapd
Ctrl-C
systemctl restart slapd

Anon vs Unauth

LDAP distinguishes between anonymous and unauthenticated. The latter being when a user identifies themselves but doesn’t supply a password. This is disabled by default.

# Unauthenticated binds (user ID but no password) are disabled by default already
ldapsearch -x -LLL -w "" -D cn=admin,dc=example,dc=org -b dc=example,dc=org

System Chain Compilation

If your system doesn’t have the Let’s Encrypt CA, you must compile the CA certs into a chain-file for yourself and any other clients who may not have a modern trust store.

# Who signed our cert?
openssl x509 -in /etc/letsencrypt/live/wifi.example.org/cert.pem -noout -subject -issuer

        subject=CN = wifi.example.org
        issuer=C = US, O = Let's Encrypt, CN = R3
# Where is their cert?
openssl x509 -in /etc/letsencrypt/live/wifi.example.org/cert.pem -text | grep "CA Issuers"

        CA Issuers - URI:http://r3.i.lencr.org/
# Let's get that cert and see if it was in turned signed by someone else and where to get that cert
wget http://r3.i.lencr.org/ -O r3.der
openssl x509 -inform DER -in r3.der -noout -subject -issuer

        subject=C = US, O = Let's Encrypt, CN = R3
        issuer=C = US, O = Internet Security Research Group, CN = ISRG Root X1
openssl x509 -inform DER -in r3.der -text | grep "CA Issuers"

        CA Issuers - URI:http://x1.i.lencr.org/

wget http://x1.i.lencr.org/ -O x1.der

# OK, if this is the 'root' signer, it will have itself as it's issuer
openssl x509 -inform DER -in x1.der -noout -subject -issuer

        subject=C = US, O = Internet Security Research Group, CN = ISRG Root X1
        issuer=C = US, O = Internet Security Research Group, CN = ISRG Root X1

# Success!

# Now lets put both of those in a .pem file for the apps to use
openssl x509 -inform DER -in x1.der > x1-r3.pem
openssl x509 -inform DER -in r3.der >> x1-r3.pem


# And test with the CA option. 
# You need to make sure the host name matches 
echo 127.0.1.1  wifi.example.org >> /etc/hosts

LDAPTLS_CACERT=./x1-r3.pem ldapsearch -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org

# If you get unexpected results, you can use the -d -1 options for testing
LDAPTLS_CACERT=./x1-r3.pem ldapsearch -d -1 -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org

# And let's add it to our general conf. Check /etc/ldap/ldap.conf for the right crt
su -c "cat x1-r3.pem >> /etc/ssl/certs/ca-certificates.crt"

# This will now work as expected
ldapsearch -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org

Resources

Checking the schema LDAP object plain text show Resetting the ldap admin password http://deployingradius.com/documents/configuration/pap.html https://xenomorph.net/linux/ubuntu/misc/radius-unifi/ https://clintonmetu.com/2018/05/setting-up-freeradius-openldap-on-a-raspberry-pi-for-network-device-authentication/ https://wiki.debian.org/LDAP/OpenLDAPSetup


Last modified July 31, 2025: nac additions (3334349)