It has recently occurred to me that to maintain good change control, in addition to safeguarding previous versions and going back to an earlier point in time, GiHub could be used.

Ok, no news here, one of the main functions of GitHub is this.

But what if, in addition, we take advantage of these capabilities for the configurations of our network devices? In principle it seems like a good idea. Let’s see.

We need a GitHub account, if you don’t have one yet, what are you waiting for?

We need some configuration. Let’s set .cfg as our standard for configuration files. Here is an example of a basic Cisco ASA configuration. This configuration is cut in many places, don’t expect it to work, it is for example.

: Saved

: 
: Serial Number: JBE10166MUZ
: Hardware:   ASA5506, 4096 MB RAM, CPU Atom C2000 series 1250 MHz, 1 CPU (4 cores)
:
ASA Version 9.7(1)4 
!
hostname asa
xlate per-session deny tcp any4 any4
xlate per-session deny tcp any4 any6
xlate per-session deny tcp any6 any4
xlate per-session deny tcp any6 any6
xlate per-session deny udp any4 any4 eq domain
xlate per-session deny udp any4 any6 eq domain
xlate per-session deny udp any6 any4 eq domain
xlate per-session deny udp any6 any6 eq domain
names

!
interface GigabitEthernet1/1
 channel-group 1 mode on
 no nameif
 no security-level
 no ip address
!
interface GigabitEthernet1/2
 channel-group 1 mode on
 no nameif
 no security-level
 no ip address
!
interface GigabitEthernet1/3
 channel-group 2 mode on
 no nameif
 no security-level
 no ip address
!
interface GigabitEthernet1/4
 channel-group 2 mode on
 no nameif
 no security-level
 no ip address
!
interface GigabitEthernet1/5
 channel-group 3 mode on
 no nameif
 no security-level
 no ip address
!
interface GigabitEthernet1/6
 channel-group 3 mode on
 no nameif
 no security-level
 no ip address
!
interface GigabitEthernet1/7
 channel-group 4 mode on
 no nameif
 no security-level
 no ip address
!
interface GigabitEthernet1/8
 channel-group 4 mode on
 no nameif
 no security-level
 no ip address
!
interface Management1/1
 management-only
 shutdown
 no nameif
 no security-level
 no ip address
!
interface Port-channel1
 description link1
 lacp max-bundle 8
 nameif outside
 security-level 0
 ip address 192.168.1.10 255.255.255.0 
!
interface Port-channel2
 description link2
 lacp max-bundle 8
 nameif link2
 security-level 100
 ip address 192.168.10.10 255.255.255.0 
!
interface Port-channel3
 description link2
 lacp max-bundle 8
 nameif link2
 security-level 100
 ip address 192.168.20.10 255.255.255.0 
!
interface Port-channel4
 lacp max-bundle 8
 nameif link4
 security-level 50
 ip address 192.168.30.10 255.255.255.0  
!
ftp mode passive
clock timezone GMT 1
dns domain-lookup link2
dns server-group DefaultDNS
 name-server 8.8.8.8 link1 
pager lines 24
logging enable
logging timestamp
logging buffer-size 8192
logging monitor debugging
logging buffered informational
logging trap informational
logging asdm informational
logging device-id hostname
no failover
no monitor-interface service-module 
icmp unreachable rate-limit 1 burst-size 1
asdm image disk0:/asdm-771-151.bin
no asdm history enable
arp timeout 14400
no arp permit-nonconnected
arp rate-limit 16384
route homelan 0.0.0.0 0.0.0.0 192.168.1.1 1
timeout xlate 3:00:00
timeout pat-xlate 0:00:30
timeout conn 1:00:00 half-closed 0:10:00 udp 0:02:00 sctp 0:02:00 icmp 0:00:02
timeout sunrpc 0:10:00 h323 0:05:00 h225 1:00:00 mgcp 0:05:00 mgcp-pat 0:05:00
timeout sip 0:30:00 sip_media 0:02:00 sip-invite 0:03:00 sip-disconnect 0:02:00
timeout sip-provisional-media 0:02:00 uauth 0:05:00 absolute
timeout tcp-proxy-reassembly 0:01:00
timeout floating-conn 0:00:00
timeout conn-holddown 0:00:15
timeout igp stale-route 0:01:10
user-identity default-domain LOCAL
aaa authentication enable console LOCAL 
aaa authentication http console LOCAL 
aaa authentication ssh console LOCAL 
http server enable
http 192.168.1.0 255.255.192.0 link1
no snmp-server location
no snmp-server contact
service sw-reset-button
crypto ipsec security-association pmtu-aging infinite
crypto ca trustpool policy
telnet timeout 5
ssh stricthostkeycheck
ssh 192.168.1.0 255.255.255.0 link1
ssh timeout 5
ssh version 2
ssh key-exchange group dh-group1-sha1
console timeout 5
vpn-addr-assign local reuse-delay 10

threat-detection basic-threat
threat-detection statistics
threat-detection statistics tcp-intercept rate-interval 30 burst-rate 400 average-rate 200
dynamic-filter updater-client enable
dynamic-filter use-database
dynamic-filter ambiguous-is-black
ntp server 147.156.7.18
ntp server 185.132.136.116 prefer

!
class-map inspection_default
 match default-inspection-traffic
!
!
policy-map type inspect dns preset_dns_map
 parameters
  message-length maximum client auto
  message-length maximum 512
  no tcp-inspection
policy-map global_policy
 class inspection_default
  inspect ftp 
  inspect h323 h225 
  inspect h323 ras 
  inspect ip-options 
  inspect netbios 
  inspect rsh 
  inspect rtsp 
  inspect skinny  
  inspect esmtp 
  inspect sqlnet 
  inspect sunrpc 
  inspect tftp 
  inspect sip  
  inspect xdmcp 
  inspect dns preset_dns_map 
  inspect icmp 
  inspect pptp 
 class class-default
  user-statistics accounting
!
service-policy global_policy global
prompt hostname context 
no call-home reporting anonymous
hpm topN enable
Cryptochecksum:5dadcc88d501a253a006c4rf11808ff8
: end

We need Python and some libraries, such as pyGithub. If you don’t know it, when you have finished creating your GitHub account, you can read the documentation of this library. It will help you understand its capabilities, how to use it, and it will clarify your doubts.

Already have GitHub? The best thing to do is to create a CMD token. It will help you to avoid having to enter username and password every time our script interacts with GitHub. Remember, a script is not a person, so a token simplifies a lot.

And our script. It is not complex, the only difficulty it has is the pyGithub library and its use, which also requires a basic knowledge of Git (hash, commit, push, pull, merge…)

#!/usr/bin/env python3
'''
'''

__author__ = 'Aaron Castro'
__author_email__ = 'aaron.castro.sanchez@outlook.com'
__copyright__ = 'Aaron Castro'
__license__ = 'MIT'

import argparse, re
from os import listdir
from os.path import isfile, join
from github import Github

# Class to put some color on the print output
class bcolors:
    HEADER = '\033[95m'
    OKBLUE = '\033[94m'
    OKCYAN = '\033[96m'
    OKGREEN = '\033[92m'
    WARNING = '\033[93m'
    FAIL = '\033[91m'
    ENDC = '\033[0m'
    BOLD = '\033[1m'
    UNDERLINE = '\033[4m'

# Get all our CLI arguments
def get_arguments():
    parser = argparse.ArgumentParser()
    parser.add_argument('-r', '--repo', required = True)
    parser.add_argument('-t', '--token', required = True)
    parser.add_argument('-p', '--path', default = './') # Defaults to user's home

    args = parser.parse_args()

    return(args)

# Inits GitHub repository
# * Token needed https://github.com/settings/tokens See section CMD Line
def init_git(repo, token):  
    g = Github(token)

    return(g.get_user().get_repo(repo))

# Gets all repository content file names
# * There will be one file name per local config file
def get_repo_content(repo):
    contents = repo.get_contents('/')
    file_list = []
    while len(contents) > 0:
        file = str(contents.pop(0).path)
        file_list.append(file)
    return(file_list)

# Gets all config files in the specified path
def get_local_content(path):
    return([file for file in listdir(path) if isfile(join(path, file)) and file.endswith('.cfg')])

# Pushes any new files existing locally but not in repo
def push_new_files(repo, local):
    try:
        new_files = list(set(get_local_content(local)) - set(get_repo_content(repo))) # Gets local files not in repo. Uses 2 sets from lists. Which words in list#1 are not in list #2
        for _ in new_files:
            file = open(_, 'r')
            repo.create_file(_, _, file.read())
    except:
        return(False)

    return(True)

# Updates existing repo files that also exist in the local path
def push_old_files(repo, local):
    try:
        old_files = list(set(get_local_content(local)) & set(get_repo_content(repo))) # Gets common files locally and repo. Uses 2 sets from lists. Which words in list#1 are also in list #2
        for _ in old_files:
            file = open(_, 'r')
            contents = repo.get_contents(_)
            repo.update_file(_, _, file.read(), contents.sha)
    except:
        return(False)
    
    return(True)

# Main function    
def main():
    args = get_arguments()
    if args.repo and args.token:
        # Init repository
        print('[' + bcolors.OKBLUE + 'I' + bcolors.ENDC + '] Init Github repository connection... ', end = '')
        repo = init_git(args.repo, args.token)
        if repo:
            print('[' + bcolors.OKGREEN + 'OK' + bcolors.ENDC + ']')
        else:
            print('[' + bcolors.FAIL + 'FAIL' + bcolors.ENDC + ']')
        # Pushes new config files
        print('[' + bcolors.OKBLUE + 'I' + bcolors.ENDC + '] Pushing new files... ', end = '')
        if push_new_files(repo, args.path):
            print('[' + bcolors.OKGREEN + 'OK' + bcolors.ENDC + ']')
        else:
            print('[' + bcolors.FAIL + 'FAIL' + bcolors.ENDC + ']')
        # Updates existing config files
        print('[' + bcolors.OKBLUE + 'I' + bcolors.ENDC + '] Updating existing files... ', end = '')
        if push_old_files(repo, args.path):
            print('[' + bcolors.OKGREEN + 'OK' + bcolors.ENDC + ']')
        else:
            print('[' + bcolors.FAIL + 'FAIL' + bcolors.ENDC + ']')

if __name__ == '__main__':
    main()

Needless to say, it is a version that just works. It is not debugged, nor do I expect to touch it much more, it does what it has to do, that’s enough for me. Whoever needs it, can use it respecting, well you know, the license it has.

It is up to you to regularly get all the configurations on the same directory, and to schedule the execution of the script. Surely cron will be able to give you a hand.