FreeRADIUS Dynamic Clients from netbox

netbox is an asset management system, which includes IPAM and DCIM functions. In an IT environment it serves as the source of truth. Therefore all other services should use netbox as its backend. In this article I want to describe how to configure FreeRADIUS to lookup clients in netbox.

The process is to define a switch in netbox and have FreeRAIDUS lookup up the details about the new device when this client sends a RADIUS request the first time.

Of course, you also can derive the complete configuration of the new switch from netbox and some templates and deploy it by means of zero touch provisioning with ansible or saltstack.

FreeRADIUS Dynamic Clients

FreeRADIUS features to trigger a lookup whenever an unknown client sends its first request. A positive answer is cached so this client will be known for future requests.

Dynamic Clients

The client definition below tells FreeRADIIUS that dynamic clients are located in the 192.0.2.0/24 network. These clients are allowed to use the feature. Typically this would be the management network of your access devices.

client dynamic {
        ipaddr = 192.0.2.0/24
        dynamic_clients = dynamic_clients
        lifetime = 3600
}

The configuration specifies the dynamic_clients server to process these requests from (yet) unknown clients.

This architecture has the advantage that no other scripts are needed, only FreeRADIUS and netbox. But the netbox should be available all the time, because FreeRADIUS looks up the client after the lifetime of the cached information. Setting the lifetime to a higher value does not solve the need for a high availability, but only allows longer outages before authentication will start to fail.

The Server

The server section basically just defines a call of the python3 module in the authorize section:

server dynamic_clients {
        authorize {
                update request {
                        &FreeRADIUS-Client-IP-Address = "%{Packet-Src-IP-Address}"
                }
                python3
                update control {
                        &FreeRADIUS-Client-IP-Address = "%{Packet-Src-IP-Address}"
                }
                ok
        }
}

The module will be called with the source address of the incomming packet. If the python3 module returns ok then request will be successful.

First I wanted the use the rest module of FreeRADIUS to call the REST API of netbox directly. But parsing the return attributes in the rest module of FreeRADIUSv3 is not possible. At least not possible to such an extent that is required here. So I use the python3 module to be more flexible.

The developers promised that the rest module will be revised in version 4 of the server. So using this rest module should be possible in the next version of FreeRADIUS.

The Python3 Program

The python3 module defines the python programm that is called. I modified the example config to call a programm dynclients.py. Since I only use the python3 module for authorization, I do not need to define the other functions.

python3 {
        module = dynclients
        pass_all_vps_dict = yes
        mod_instantiate = ${.module}
        func_instantiate = instantiate
        mod_detach = ${.module}
        func_detach = detach
        mod_authorize = ${.module}
        func_authorize = authorize
        mod_authenticate = ${.module}
        mod_preacct = ${.module}
        mod_accounting = ${.module}
        mod_checksimul = ${.module}
        mod_pre_proxy = ${.module}
        mod_post_proxy = ${.module}
        mod_post_auth = ${.module}
        mod_recv_coa = ${.module}
        mod_send_coa = ${.module}
}

The programm dynclients.py itself uses the REST API of netbox to gather all information about the new client. It uses the source IP address as search string. The shared secret of the client is defined in a config_context named radius_server for the device. So all devices can have different shared secrets, or there can be one config_context globally defined.

#!/usr/libexec/platform-python

import radiusd
import pynetbox

url   = "https://netbox.example.com"
token = "0123456"
nb    = pynetbox.api(url, token=token)

def instantiate(p):
  print("*** instantiate ***")
  print(p)

def authorize(p):
  print("*** authorize ***")
  radiusd.radlog(radiusd.L_INFO, '*** radlog call in authorize ***')

  # check to have an IP address
  for avpair in p['request']:
    (attribute, value) = avpair
    if (attribute=="FreeRADIUS-Client-IP-Address"):
      address = value

  # Get information for that address from netbox
  nbaddress = nb.ipam.ip_addresses.get(address=address)

  # Fail if IP address unknown to netbox
  if not nbaddress:
    return radiusd.RLM_MODULE_NOTFOUND

  # Get information abount the device.
  nbdevice = nb.dcim.devices.get(name=nbaddress.assigned_object.device.name)

  # Fail if no device defined
  if not nbdevice:
    return radiusd.RLM_MODULE_NOTFOUND

  # Known device. Update the shared secret from config_context of the device.
  update_dict = {
    "config": ( ('FreeRADIUS-Client-Secret', nbdevice['config_context']['radius_server'][0]['secret']),
                ('FreeRADIUS-Client-Shortname', nbdevice['name']), ),
  }
  return radiusd.RLM_MODULE_OK, update_dict

Dynamic Client

The following log snipplet shows a part of the debug output of the FreeRADIUS server when a new clients sends its first request.

Ready to process requests
(0) server dynamic_clients {
(0) # Executing section authorize from file /etc/raddb/sites-enabled/dynamic-clients
(0)   authorize {
(0)     update request {
(0)       EXPAND %{Packet-Src-IP-Address}
(0)          --> 192.0.2.16
(0)       &FreeRADIUS-Client-IP-Address = 192.0.2.16
(0)     } # update request = noop
*** authorize ***
*** radlog call in authorize ***
authorize - 'config:FreeRADIUS-Client-Secret' = 'testing123'
authorize - 'config:FreeRADIUS-Client-Shortname' = 'testnas'
(0)     [python3] = ok
(0)     update control {
(0)       EXPAND %{Packet-Src-IP-Address}
(0)          --> 192.0.2.16.219
(0)       &FreeRADIUS-Client-IP-Address = 192.0.2.16
(0)     } # update control = noop
(0)     [ok] = ok
(0)   } # authorize = ok
(0) } # server dynamic_clients
(0) Converting control list to client fields
(0)   ipv4addr = 192.0.2.16
(0)   secret = testing123
(0)   shortname = testnas
Adding client 192.0.2.16/32
(...)
Michael Schwartzkopff, 20 Dec 2021