Location aware DHCP

  • by Patrick Ogenstad
  • April 25, 2018

Up until now, the TFTP server has only sent out the network-confg file as that is the one that new devices are requesting. Even if we can use dynamic templates the ZTP server doesn’t have a lot of information to act upon. We aren’t however actually only limited to the requested filename network-confg, we also have the IP address of the requesting device. The IP address can tell us where the device is located logically. The other obvious way would be to use static assignments for the device, this would require us to know the mac address of each device we are going to provision.

The manual assignment is the approach I use for my Philips Hue bridge at home (in the config file for ISC DHCP):

subnet 172.29.58.0 netmask 255.255.255.0 {
  range 172.29.58.150 172.29.58.240;
  option domain-name-servers 172.29.50.34, 172.29.52.37;
  option domain-name "int.ogenstad.com";
  option subnet-mask 255.255.255.0;
  option routers 172.29.58.1;
  default-lease-time 600;
  max-lease-time 7200;

  host hue {
        hardware ethernet 00:17:88:79:14:DE;
        fixed-address 172.29.58.48;
  }

}

If you would choose the above approach to set a fixed address to all of your devices, just keep in mind that the DHCP configuration is a part of the zero touch provisioning system. If you are editing those by hand your process isn’t zero touch. At some point you might need to have some sort of data entry that might be manual, but configuration files such as the one above should be generated in the same way as with the device config files.

Using DHCP Option 82

The approach I will use here instead to leverage option 82 in DHCP. This means that I need to make a few changes to my first switch, i.e the one I’m connecting the first device too.

ip dhcp snooping vlan 20
ip dhcp snooping information option format remote-id hostname
ip dhcp snooping

interface FastEthernet0/3
 description ESXI
 ip dhcp snooping trust

interface FastEthernet0/7
 switchport access vlan 20
 switchport mode access
 ip dhcp snooping information option allow-untrusted
 ip dhcp snooping vlan 20 information option format-type circuit-id string fa0_7

The thing to notice here is that I set the remote-id value of hostname and the circuit-id to match that of the interface, i.e. fa0_7 for FastEthernet0/7. With this in place we can make some changes to the DHCP installation.

subnet 172.29.50.0 netmask 255.255.255.224 {
  range 172.29.50.18 172.29.50.24;
  option domain-name-servers 172.29.50.34, 172.29.52.37;
  option domain-name "int.ogenstad.com";
  option subnet-mask 255.255.255.224;
  option routers 172.29.50.1;
  option ip-tftp-server 172.29.50.12;
  filename = concat(substring(option agent.remote-id, 2,200), "__", substring(option agent.circuit-id, 2, 200), ".cfg");
  default-lease-time 600;
  max-lease-time 7200;
}

What we do here is to override the value for the default file that the network device will request when it’s booting up. If I connect a device to FastEthernet0/7 on og-sw-01 a new device will try to load the file og-sw-01__fa0_7.cfg from the DHCP server.

While it can be misleading at first when the switch we want to name og-sw-02 requests a file with a name of another switch, in reality, we will use this information as a query to the dynamic TFTP server.

What we are doing is to inform the TFTP server that something has been plugged into FastEthernet0/7 on og-sw-01, we can now check the inventory to see what that is. At this stage, we can introduce some basic error checking like validating in the inventory that we expect to see something on FastEthernet0/7. We can notify the installer in Slack immediately, to say that nothing should be connected to that port (if this was possible), which could indicate that either the inventory doesn’t have the information or it could be a matter of just plugging into the incorrect port. We could also inform the installer that something was connected to FastEthernet0/7 and based on the data in the inventory it should have been a Catalyst 2960 connected through GigabitEthernet0/1. In the background, we can even setup a task that logs into og-sw-01 and checks CDP/LLDP neighbors on Fa0/7 to validate that the platform and remote port is correct according to the inventory.

Keeping the installation technician informed

We start by adding some info to the installer and anyone else who happens to be following along in Slack for that matter.

In /opt/ztp/app/task.py


from app.inventory import devices

def ztp_start(host, file):
    if '__' in file:
        neighbor_host, neighbor_if = file.split('__')
        neighbor_if = neighbor_if.split('.')[0]
        ztp_device = False
        for interface in devices[neighbor_host]['links']:
            if neighbor_if == interface['interface_brief']:
                ztp_device = interface['neighbor']
                ztp_interface = interface['neighbor_if']
                ztp_platform = devices[ztp_device]['platform']
                mgmt_ip = devices[ztp_device]['management_ip']
        if ztp_device:
            msg = '{}/{} connected {}, expecting {}/{} ({})'.format(
                neighbor_host, neighbor_if, host, ztp_device,
                ztp_interface, ztp_platform)
        else:
            msg = "{}/{} connected {}, that doesn't look right...".format(
                neighbor_host, neighbor_if, host)

        host = mgmt_ip
        notify_slack(msg)

    else:
        msg = '{} downloaded {}'.format(host, file)
        notify_slack(msg)

With the above code we can inform the person plugging in the device about where it was plugged in and what if anything the system expected to see there. You can see that the if-statement looks for the presence of “__” in the filename, so it will match on og-sw-01__fa0_7.cfg but not on network-confg.

The next thing we need to address is that the TFTP server has to serve some content when it receives a request based on the option 82 configuration we entered in the DHCP server.

To do this we will make some modifications to /opt/ztp/app/dispatcher.py

from app.inventory import devices

def request_dispatcher(file_path):

    if "__" in file_path:
        neighbor_host, neighbor_if = file_path.split('__')
        neighbor_if = neighbor_if.split('.')[0]
        for interface in devices[neighbor_host]['links']:
            if neighbor_if == interface['interface_brief']:
                ztp_host = interface['neighbor']

        ztp_device = {}
        ztp_device['hostname'] = ztp_host
        ztp_device['staging_user'] = staging_user
        ztp_device['staging_password'] = staging_password
        ztp_device.update(devices[ztp_host])

        config = render_file("base-configuration",
                             **ztp_device)

        return StringResponseData(config)

    elif file_path == 'network-confg':
        config = render_file(file_path, staging_user=staging_user,
                             staging_password=staging_password)
        return StringResponseData(config)
    else:
        return TftpData(file_path)

In the modified dispatcher.py we reference a new template which needs to be created /opt/ztp/templates/base-configuration

hostname {{ hostname }}
ip domain-name networklore.com

lldp run

aaa new-model
aaa authentication login LOCALDB local
aaa authorization exec LOCALDB-AUTHZ local

username {{ staging_user }} priv 15 secret {{ staging_password }}

line vty 0 4
 authorization exec LOCALDB-AUTHZ
 login authentication LOCALDB
 transport input ssh

crypto key generate rsa general-keys modulus 2048


snmp-server location {{ location }}

vtp mode transparent
vlan internal allocation policy ascending

no ip http server
no ip http secure-server

ip dhcp snooping information option format remote-id hostname
ip dhcp snooping

{% for vlan in vlans %}
vlan {{ vlan.id }}
 name {{ vlan.name }}

ip dhcp snooping vlan {{ vlan.id }}
{% endfor %}

{% if platform == 'c2960' %}
ip default-gateway {{ gateway }}

interface Vlan1
 shut

{% else %}
ip route 0.0.0.0 0.0.0.0 {{ gateway }}
{% endif %}

{% for if in ip_interfaces %}
interface {{ if.name }}
 ip address {{ if.ip }} {{ if.mask }}
{% if if.ip_helper is defined %}
 ip helper-address {{ if.ip_helper }}
{% endif %}
 no shut

{% endfor %}

{% for if in links %}
interface {{ if.interface }}

{% if if.access_vlan is defined %}
 switchport mode access
 switchport access vlan {{ if.access_vlan }}
{% endif %}

{% if if.snooping_trust is defined %}
 ip dhcp snooping trust
{% else %} 
 ip dhcp snooping information option allow-untrusted
{% if if.access_vlan is defined %}
 ip dhcp snooping vlan {{ if.access_vlan }} information option format-type circuit-id string {{ if.interface_brief }}
{% endif %}
{% endif %}

{% if if.trunk is defined %}
 switchport mode trunk
 switchport trunk native vlan {{ if.trunk.native }}
 switchpor trunk allowed vlans {{ if.trunk.vlans|join(', ') }}
{% endif %}


{% endfor %}

end

Please note that the above template is only intended for demonstration purposes, just modify it to your environment. You might also find that it’s better to just use a template to get the device online and then use the jobs we have setup and trigger a connection from your regular automation tool.

Current progress

With the above changes we can test to provision all three of the devices. This is how it currently looks:

Provisioning devices

It turned out quite nice. Of course the output could be a more helpful and some of the information will only be relevant if something is wrong. Also remember that the output in Slack is just an example, this could be any system which fits your process and workflow.

Regarding the use of option 82

If you decide to use DHCP option 82 as part of your provisioning remember to validate that your devices actually supports this. You might end up having to use other suboptions like subscriber-id, or come up with another solution alltogether at least for some links in your network.