Using configuration templates

  • by Patrick Ogenstad
  • April 25, 2018

You might have noticed that we defined the staging credentials in two places when connecting to the device, it was both in the network-confg file which is a static file and in /opt/ztp/app/network.py. There are a few problems with this ranging from security concerns depending on where we store the files, to practical issues like if we change the password we need to do it in two places.

One solution could be to let some other system regenerate the network-confg file along with some input file which is read by the network.py function. Since our TFTP server is written in Python we have quite a lot of flexibility in how we handle things. Instead of relying on an external system we can solve this within the application.

Jinja2 templates

The use of Jinja templates have become very common for generating configuration files and we will use it within our application too. Jinja is a Python package which needs to be installed on our server and we need a directory to store our templates.

pip install jinja2

mkdir /opt/ztp/templates

In this directory we create the template file /opt/ztp/templates/network-confg.

hostname staging
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


crypto key generate rsa general-keys modulus 2048

end

It more or less looks the same as our old file but now we have two variables that we refer to in the template, staging_user and staging_password.

Serving templated files from the TFTP server

Before we can serve this file to connecting devices we need to have Jinja parse the template and generate the actual configuration.

We need to add another file to the application, /opt/ztp/app/templating.py:

from jinja2 import Environment, FileSystemLoader, StrictUndefined

templates_path = '/opt/ztp/templates'


def render_file(template, **kwargs):

    env = Environment(
        loader=FileSystemLoader(templates_path),
        undefined=StrictUndefined,
        trim_blocks=True
    )

    template = env.get_template(template)
    return template.render(**kwargs)

The render_file function will take a template name as its first parameter followed by a range of optional parameters containing variables to be used within the template, the resulting config will then be returned to the code calling this function.

Previously our TFTP server only served static files, but now we want to deviate from that. In the main file ztp_tftp.py we had the StaticHandler class which took care of this for us.

class StaticHandler(BaseHandler):

    def get_response_data(self):
        return TftpData(self._path)

We could just make a small change to this to use the data returned from the templates instead, however we might end up also wanting to serve binary files such as device images. What we instead need is to allow for the option of serving a file generated by a template in some cases while also having the current system in place for other file types. While we could add this logic within the current class it can be simpler just to move this functionality from the main file and into the app directory.

Create the new file /opt/ztp/app/dispatcher.py with this code:

from app.templating import render_file
from fbtftp.base_handler import StringResponseData
import os

TFTP_ROOT = '/opt/ztp/tftproot'

staging_user = os.environ.get('ZTP_STAGING_USER')
staging_password = os.environ.get('ZTP_STAGING_PASS')


class TftpData:

    def __init__(self, filename):
        path = os.path.join(TFTP_ROOT, filename)
        self._size = os.stat(path).st_size
        self._reader = open(path, 'rb')

    def read(self, data):
        return self._reader.read(data)

    def size(self):
        return self._size

    def close(self):
        self._reader.close()


def request_dispatcher(file_path):

    if 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)

Instead of defining the credentials in the code we are using the environment variables ZTP_STAGING_USER and ZTP_STAGING_PASS. This is of course only an example, in your environment you might have a system in place for keys, such as Vault. Or you might prefer to have them in a file on disk. The environment envariables should be easy to try universally. In this first iteration of this file we will be using the Jinja templating system if a device specifically requests the “network-confg” file, otherwise we will use the old class TftpData which serves static files.

We also moved the TftpData class from ztp_tftp.py so we can delete that class from /opt/ztp/ztp_tftp.py.

from app.broker import trigger_job
from app.dispatcher import request_dispatcher


class DynamicHandler(BaseHandler):

    def get_response_data(self):
        return request_dispatcher(self._path)


class TftpServer(BaseServer):

    def get_handler(self, server_addr, peer, path, options):
        return DynamicHandler(
            server_addr, peer, path, options, session_stats)

The request_dispatcher function is imported and the StaticHandler is renamed to DynamicHandler.

A small change is also needed to /opt/ztp/app/network.py.

import os
import time
from napalm import get_network_driver

staging_user = os.environ.get('ZTP_STAGING_USER')
staging_password = os.environ.get('ZTP_STAGING_PASS')


def get_napalm_connection(host, device_type, attempts=360, timeout=1):
    driver = get_network_driver(device_type)
    device = driver(hostname=host, username=staging_user,
                    password=staging_password)

Before testing the application with these new changes remember to set your environment variables, both in the windows you are running the TFTP server and the one that’s running rq.

export ZTP_STAGING_USER=staging_user
export ZTP_STAGING_PASS=It_is_S3cret

You should now be good to go to add other content to the template or choose which variables you want to send in.

The first switch I’m booting up always thinks that it’s back in 1993 when it wakes up. You could use a the Jinja template to speed up the NTP sync once the device is configured and set the time as part of the initialization.

>>> from datetime import datetime
>>> timestamp = datetime.now().strftime("%H:%M:%S %d %b %Y")
>>> print(timestamp)
21:12:05 19 Apr 2018
>>>

A variable such as this could later be used in the template as:

do clock set {{ timestamp }}

While dynamic templates are great, they heavily rely a well structured datasource to be used as input.