Generating Cisco IOS config files with Python

Generating Cisco IOS config files with Python

Cisco IOS. It’s fun to configure, isn’t it? No?

In an effort to learn Python scripting, I decided to take a bit of the monotony of managing and updating IOS config files away and replace it with the monotony of managing and updating spreadsheets! The real goal with this project was to design a ‘gitops’ system for periodically checking configs against baselines and build a stripped down orchestration platform. This turned out to be a little ambitious, but I’m fairly happy with the results regardless.

This system involves three tables (formatted as CSV) and one .ini general config file that contains device specific information.

   switch-name +
               |- svi.csv
               |- vlan.csv
               |- switch.csv
               \- config.ini

   ioscfg.py /foo/bar/switch-name/

The different tables look like this:

svi.csv - Configures switch virtual interfaces with IP/IPv6 addresses, sets DHCP relay addresses, and configures basic failover with HSRP.

vlan_id comment ipv4_addr ipv4_subn dhcp_relay hsrp_ipv4 hsrp_primary
10 Finance-2Floor 10.123.10.2 255.255.255.0 10.123.10.10 10.123.10.1 TRUE
15 Marketing-3Floor 10.123.15.2 255.255.255.0 10.123.10.10 10.123.15.1 FALSE
20 Sales-4Floor 10.123.20.2 255.255.255.0 10.123.10.10 10.123.20.1 TRUE
25 Research-5Floor 10.123.25.2 255.255.255.0 10.123.10.10 10.123.25.1 FALSE

vlan.csv - Sets up the VLAN database on the switch:

vlan_id name state
10 Finance-2Floor active
15 Marketing-3Floor active
20 Sales-4Floor active
25 Research-5Floor active
80 Inactive suspend
99 Trunk active
101 IT active

switch.csv - Sets port roles on the switchports:

interface port_id comment ac_vlan tr_untag tr_tag
Gi 0/1 A-1 Uplink to R-1 99 “1,10,15,20,25,80-101”
Gi 0/2 A-2 Uplink to R-2 99 “1,10,15,20,25,80-101”
Gi 0/3 1 PC-1 10
Gi 0/4 2 PC-2 15
Gi 0/5 3 PC-3 20
Gi 0/6 4 PC-4 25
Gi 0/7 5 MGMT-PC 101
Gi 0/8 6 Access Switch 99 “1,10,15,20,25”
Gi 0/9 7 Unused port

config.ini - Configures system settings for the device, such as the roles, protocols, services, and passwords. (Again, NOT for production)

[DEFAULT]
hostname = xyu-n5w-ds01
vlan = True
switch = True
svi = True
routing = True
routing_eigrp = False
routing_ospf = True

[SYSTEM]
domain = lab.internal
domain_lookup = False

[AUTH]
admin_password = cisco123
password_encryption = True
logging_sync = True
constrain_ssh = True
cons_timeout = False
cons_login = True
banner_login = No unauthorized access!

[SERVICES]
ssh_enable = True
ssh_timeout = 60
ssh_retries = 5
rsa_modulus = 2048
http_server = False
https_server = True
ipv6_routing = False
ip_routing = True

Now that we have our config data in place, we can start bringing it into a script that will create our config files.

To start, let’s set up the arguments for the script. These will determine the role of the device and which parts of the config file will be generated.

import sys
import csv
import json
import configparser
import argparse
import datetime 

ar = argparse.ArgumentParser()
ar.add_argument("--config_path",
    help="Path to configuration files",
    default='./'
)
ar.add_argument('--switch',
    action='store_true',
    help="Configure this device as a switch"
)
ar.add_argument('--router',
    action='store_true',
    help="Configure the device as a router",
)
ar.add_argument('--multilayer',
    action='store_true',
    help="Configure the device as a multilayer switch",
)
args = ar.parse_args()

Now that we have the configuration parameters, we can bring in the different configuration files. This currently assumes that config files are in the same directory as the script, though this will likely change in a future version.

config_file = args.config_path + '/config.ini'
vlan_file = args.config_path + '/vlan.csv'
switch_file = args.config_path + '/switch.csv'
svi_file = args.config_path + '/svi.csv'

cfg = ConfigParser.ConfigParser() 
cfg.read(config_file)
def system_config(): 
    out = []
    out.append("\nhostname " + cfg.get('SYSTEM','hostname'))
    out.append("ip domain-name " + cfg.get('SYSTEM','domain'))

    if cfg.getboolean('SYSTEM','domain_lookup') == False:
        out.append("no ip domain lookup")
    return out

This function sets up the various authentication parameters for the cisco device. Depending on the settings in the config.ini file, SSH and Telnet may be enabled or disabled depending on the desired security of the system.

def auth_config():
    out = []

    out.append('\n\n'+
        '! -------------------- !\n' +
        '! -- Authentication -- !\n' +
        '! -------------------- !\n'
    )

    out.append("line vty 0 15\n  login local")
    if cfg.getboolean('AUTH','constrain_ssh') == True:
        out.append("  transport input ssh")
    else:
        out.append("  transport input all")

    # Console config:
    out.append("line con 0")
    if cfg.getboolean('AUTH','cons_login') == False:
        out.append("  no login")
    else:
        out.append("  login local")
    if cfg.getboolean('AUTH','logging_sync') == True :
        out.append("  logging sync")
    if cfg.getboolean('AUTH','cons_timeout')==False:
        out.append("  exec timeout 0 0")
    out.append("exit\n!")

    # Password encryption: 
    if cfg.getboolean('AUTH','password_encryption') == True:
        out.append("service password encryption")

    out.append("banner login # " + cfg.get('AUTH','banner_login') + "#")
    out.append('username admin priv 15 secret ' + cfg.get('AUTH','admin_password'))
    out.append('\n')

    return out

Next, the system services will be set up. Again, depending on the settings specified in the config.ini file, services like HTTP or HTTPS might be enabled or disabled. Note that some services might not be available on all platforms, for example ipv6 unicast-routing might not be available on devices that don’t have enough TCAM or may require an SDM modification. Your milage may vary.

def services_config():
    out = [] 

    out.append('\n' + 
        '! ---------------------------- !\n' +
        '! -- System services config -- !\n' +
        '! ---------------------------- !\n'
    )

    if cfg.getboolean('SERVICES','ssh_enable') == True:
        out.append("crypto key generate rsa general-keys modulus " + 
            cfg.get('SERVICES','rsa_modulus') + "\n!")
        out.append("ip ssh version 2\nip ssh auth retries 5\nip ssh time-out 30")

    if cfg.getboolean('SERVICES','http_server') == True:
        out.append("ip http server")
    if cfg.getboolean('SERVICES','http_server') == False:
        out.append("no ip http server")    

    if cfg.getboolean('SERVICES','https_server') == True:
        out.append("ip http secure-server")
    if cfg.getboolean('SERVICES','https_server') == False:
        out.append("no ip http secure-server")

    if cfg.getboolean('SERVICES','ipv6_routing') == True:
        out.append("ipv6 unicast-routing")
    
    if cfg.getboolean('SERVICES','ip_routing') == True:
        out.append("ip routing")

    return out

When switch mode is enabled, the script will use the contents of the vlan.csv to add VLANs to the local database. This also requires that VTP is not enabled on the switch (though we are assuming that we’re starting with a clean device, so that most likely won’t be the case).

def vlan_config():
    out = []

    out.append('\n\n' +
        '! -------------------------- !\n' +
        '! -- Vlan Database Config -- !\n' +
        '! -------------------------- !\n'
    )

    with open(vlan_file, 'r') as csv_file:
        reader = csv.DictReader(csv_file, delimiter=',')

        for i in reader:
            out.append(
                "vlan " + i['vlan_id'] + "\n" +
                "  name "  + i['name'] + "\n" +
                "  state " + i['state'] 
            )
    out.append("  exit\n")
    return out 
def switch_config(): 
    out = []

    out.append('\n' +
        '! ----------------------- !\n' +
        '! -- Switchport Config -- !\n' +
        '! ----------------------- !\n'
    )

    with open(switch_file, 'r') as csv_file:
        reader = csv.DictReader(csv_file, delimiter=',')

        for i in reader:
            out.append("interface " + i['interface'])
            out.append(
                "  description [" + i['port_id'] + "] :: " + i['comment'])
            if i['ac_vlan']:
                out.append(
                    "  switchport mode access\n  switchport access vlan " + i['ac_vlan'])
                out.append("  no shutdown\n!")
            elif i['tr_tag']:
                out.append(
                    "  switchport trunk encapsulation dot1q\n  switchport mode trunk")
                out.append(
                    "  switchport trunk native vlan " + i['tr_untag'])
                out.append(
                    "  switchport trunk allowed vlan " + i['tr_tag'])
                out.append("  no shutdown\n!")
            else:
                out.append("  switchport nonegotiate\n  shutdown\n!")
    out.append("exit\n")
    return out

When multilayer switching is enabled, SVIs (Switcch Virtual Interfaces) will be created and configured with IPv4 and IPv6 addresses.

This also supports basic high availability when using more than one device by configuring HSRP. Of course, STP topologies must match up in a way that a failed router will either trigger an STP recalculation, or the backup router will be reachable without a recalculation.

In a future version, I do intent to automate spanning-tree protocol as part of this script.

def svi_config():
    out = []

    out.append('\n' + 
        '! ------------------------ !\n' +
        '! -- Virtual Interfaces -- !\n' +
        '! ------------------------ !\n'
    )

    with open(svi_file, 'r') as csv_file:
        reader = csv.DictReader(csv_file, delimiter=',')

        for i in reader:
            out.append("interface vlan " + i['vlan_id'])
            out.append("  description " + i['comment'])
            out.append("  ip address " + i['ipv4_addr'] + ' ' + i['ipv4_subn'])
            
            if not i['ipv6_local']:
                out.append("  ipv6 address autoconfig")
            else: 
                out.append("  ipv6 address " + i['ipv6_local'] + ' link-local')
            out.append("  ipv6 address " + i['ipv6_global'])


            if i['hsrp_ipv4']:
                out.append("  standby " + i['vlan_id'] + " ip " + i['hsrp_ipv4'])
                if i['hsrp_primary']:
                    out.append("  standby " + i['vlan_id'] + " priority 50")
                    out.append("  standby " + i['vlan_id'] + " preempt")
                else: 
                    out.append("  standby " + i['vlan_id'] + "priority 150")
            if i['dhcp_relay']:
                out.append("  ip helper address " + i['dhcp_relay'])
            out.append("  no shutdown\n!")
    out.append('exit\n')
    return out

Finally, the main function will call out to the other ones depending on the config parameters used on the command line. This will also save the finished config file with the date and time as part of the file name.

def main(): 

    output_file = []
    output_file.append([
        '! -- Configuration script for device ' + cfg.get('SYSTEM','hostname'), 
        '! -- Generated on {:%Y/%m/%d-%H:%M}'.format(datetime.datetime.now()),
        '! -- https://nbailey.ca\n\n',
        'configure terminal'
    ])
    
    output_file.append(system_config())
    output_file.append(auth_config())
    output_file.append(services_config())

    # optional configs: 
    if args.switch or args.multilayer:
        output_file.append(vlan_config())
        output_file.append(switch_config())
    
    if args.multilayer:
        output_file.append(svi_config())

    if args.router or args.multilayer:
        output_file.append(routing_config())
        output_file.append(routing_config_eigrp())
        output_file.append(routing_config_ospf())
    
    output_file.append(['\ndo write-memory'])

    output_file_name = cfg.get('SYSTEM','hostname') + '{:-%Y%m%d-%H%M}.cfg'.format(datetime.datetime.now())
    with open (output_file_name, 'a') as output:
        for i in output_file: output.write("\n".join(i))
    
main()

And that’s it for now. In the future I want to make this script much more in depth and add much more functionality to make much more useful config files. As it is right now, it’s almost entirely for testing purposes. As always, don’t run code you downloaded from a stranger on the internet :)