Netconf

Published: 2022-09-27

Netconf is shaping up to become the next generation of network automation. It brings the promise of "network-wide" transactions, where any change is fully performed or not at all. The downside to Netconf is the complexity of the protocol, creating a steep learning curve one must get over to start working with netconf. This article aims to help you understand transaction-style configuration aswell as make that learning curve just a bit smaller.

The Netconf protocol runs on top of SSH on port 830. It actually has its own SSH subsystem similar to SFTP. Netconf is built to use XML, so all client-server messaging will be using this syntax. If you're used to JSON or Yaml then this might will take some time to get used to, but it's not the end of the world.

Another big component of Netconf is Yang, a data modeling language used to describe each Netconf capability supported by the Netconf server, which we will cover below.

Network-wide transactions

When using netconf to apply a configuration change on multiple devices, a transaction-style of applying configuration changes can be used. It means that if you configure five routers in your network, the configuration "transaction" is only successful if the configuration change was successful on all five routers. If one router fails to apply the configuration, the transaction fails and all devices roll back to the previously active configuration.

This feature is a must-have for network automation in any large network. It is simply not good enough to have a failed configuration change be partially applied. The failed change may even cause an outage and network downtime. If the transaction fails on any device, all devices must revert to the previous configuration.

Netconf supports this transactional behavior by using a candidate datastore that exist separately from the active running configuration datastore. By making changes in the candidate datastore, multiple changes can be prepared before any changes are committed to the running-configuration. This is nothing new if you've ever worked with JunOS or IOS-XR, but if you've lived your life with IOS and IOS-XE devices, then this might be a foreign concept.

Netconf uses commits to create transactions that, via automation, can run on a network-wide scale. It actually also implements the "commit" feature on IOS-XE devices, which is what I will be exploring in this article. Unfortunately, not all devices that support Netconf has to support the candidate capability, but most of them thankfully do.

Enabling Netconf on IOS-XE

Enabling the netconf server on IOS-XE is quite simple. We create a local admin account that is allowed to access the device, we enable the netconf-yang feature and that's it! The router now has a netconf-server that is reachable on 10.1.1.2:830. Do note that we need to explicitly enable the candidate-datastore functionality, it does not activate by default.

aaa new-model
aaa authorization exec default local 
username admin privilege 15 password admin
!
int gi2
 ip address 10.1.1.2/24
!
netconf-yang
netconf-yang feature candidate-datastore

R2# show platform software yang-management process
confd    : Running
nesd     : Running
syncfd   : Running
ncsshd   : Running
dmiauthd : Running
nginx    : Running
ndbmand  : Running
pubd     : Running

R2# show netconf schema
<investigate the output on your own, not covered here>


Connecting with Netconf

This is what it looks like when you connect to a Netconf server from a linux host:

$ ssh admin@10.1.1.2 -p 830 -s netconf
admin@10.1.1.2's password: 
<?xml version="1.0" encoding="UTF-8"?>
<hello xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
  <capabilities>
    <capability>urn:ietf:params:netconf:capability:confirmed-commit:1.1</capability>
    <capability>urn:ietf:params:netconf:capability:candidate:1.0</capability>
    <capability>urn:ietf:params:netconf:capability:rollback-on-error:1.0</capability>
    <capability>http://cisco.com/ns/yang/Cisco-IOS-XE-ospf</capability>
    ...
    <capability>http://openconfig.net/yang/bgp</capability>
    <capability>urn:ietf:params:xml:ns:yang:ietf-interfaces</capability>
    <capability>urn:ietf:params:xml:ns:yang:ietf-ospf</capability>
  </capabilities>
  <session-id>27</session-id>
</hello>
]]>]]>

We used the builtin Linux SSH client to connect to an IOS-XE router at 10.1.1.2 via Netconf. The server hello message contains a huge list of capabilities, so we should probably take a moment to explore what these are. The above output only show a select few capabilities and not the full list.

Netconf capabilities

Each advertised capability represents a netconf module used for configuring or monitoring parts of the router. Some capabilities and modules are developed by IETF, others by the OpenConfig Consortium or even by vendors themselves. As such they all carry different names, as you can see in the output above.

If you want to edit any interfaces on your router, you most likely want to use the ietf-interfaces module. Also notice that there are multiple OSPF modules, one developed by IETF and one by Cisco. The feature-set may differ so it is up to you to decide which one to use.

Part of what makes netconf great is that the client can request the blueprints for each capability. Using a get-schema command on a capability, which we will show later in this article, outputs a schematic file that explains what the module does and what input it expects from the netconf client. This allows you to build the message with the correct formatting without having to rely on some external website or document telling you what format to use, the netconf server does that on its own.

Netconf Client Hello

To fully establish the netconf connection, we the client must also send a Hello message. This is rather simple but is necessary as the session will be terminated by the server if we start sending commands without saying hello first.

<hello xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <capabilities>
   <capability>urn:ietf:params:netconf:base:1.0</capability>
 </capabilities>
</hello>
]]>]]>

Note that both the server and client Hello messages end with ]]>]]>. This is how netconf tells the other end that the message has ended, so every message we send must contain these characters at the end.

Netconf message types

I have added the most common netconf message types in this list. We will be using most of them in this article. Netconf uses RPC, so any message we send is sent as an RPC message. Each reply from the server is sent as an RPC reply.

  • <get>

    Retrieves device config and operational state

  • <get-config>

    Retrieves all of part of the configuration in the specified datastore. Use filters to select which parts of the config you want to retrieve.

  • <get-schema>

    Retrieves the schematics/blueprints for a specific netconf capability, letting you see what input it requires when you interact with it. Very useful when developing a program to interact with the server.

  • <edit-config>

    Make changes to the existing config in the selected datastore.

  • <lock/unlock>

    Lock the datastore so that noone else can make changes. Unlock it when you're done.

  • <close-session>

    Gracefully terminates the netconf session.

  • <kill-session>

    Forcefully kill the netconf session. Requires session-id. I couldn't get this operation to work on my IOS-XE devices, it wouldn't accept the message.

Closing and killing the session is useful if a transaction fails, as any uncommitted changes are discarded when you log out. Any lock is also released, allowing others to start configuring the device.


Yang and get-schema

Yang is a language used to describe the contents of any netconf capability/module. Using the netconf get-schema message on a specific netconf capability, the server will return output in the Yang language format that describes what the module looks like, what input the module expects and perhaps even what output it can provide.

Schematics for ietf-interfaces

Let's look at what the IOS-XE ietf-interfaces Yang output looks like:

# Message
<rpc message-id="101" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <get-schema xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring">
 <identifier>ietf-interfaces</identifier>
   <format>yang</format>
 </get-schema>
</rpc>
]]>]]>
# Reply
<?xml version="1.0" encoding="UTF-8"?>
<rpc-reply 
  xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" 
  message-id="101">
<data xmlns='urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring'>
#
# START OF YANG OUTPUT
#
module ietf-interfaces {
  namespace "urn:ietf:params:xml:ns:yang:ietf-interfaces";
  prefix if;
  container interfaces {
    description
      "Interface configuration parameters.";
    list interface {
      key "name";
      description
        "The list of configured interfaces on the device.
     leaf name {
        type string;
        description
          "The name of the interface.
      }
      leaf description {
        type string;
        description
          "A textual description of the interface.
      }
      leaf type {
        type identityref {
          base interface-type;
        }
        mandatory true;
        description
          "The type of the interface.
      }
      leaf enabled {
        type boolean;
        default "true";
        description
          "This leaf contains the configured state of the interface.
        }
      }
    }
  }
}
#
# END OF YANG OUTPUT
#
]]></data>
</rpc-reply>]]>]]>

Reading the output from top-to-bottom we see that the module name is indeed ietf-interfaces. It mentions a namespace which we must reference whenever we want to use this module, because it tells Netconf exactly which module to load while processing our netconf messages. The namespace has a prefix version "if", which is basically an abbreviated version of the namespace. We can reference the prefix instead of the full namespace, which keeps netconf messages shorter.

Just below prefix, we find the container interfaces. This is basically the root of the tree. Inside this container we find a list called interface, this list contains all interfaces found on the router, physical or virtual.

Each interface entry contains multiple leaf variables that can be assigned different values. For example, "description" is a leaf of type string, which means it can contain any sequence of characters inside it. The "enabled" leaf is a boolean, meaning it can be True or False. If the interface has "enabled" set to False, the interface is in administrative shutdown.

Lastly we have the leaf "type" of type identityref, which is a reference to a function elsewhere that specifies what the interface type may look like. It would be bad form to simply make it a string, allowing the user to enter whatever data they wanted. With an identityref the module can do input validation to ensure correct values are entered.

A list of interfaces can therefore be represented like this:

interfaces = {
    'interface': [
        {
            'name': 'GigabitEthernet3',
            'description': 'R2-R3',
            'enabled': True
        },
        {
            'name': 'GigabitEthernet4',
            'description': 'R2-R4',
            'enabled': True
        },
    ]
}

Note that this module does not handle any IPv4 or IPv6 addressing. If you want to configure an IP-address on an interface you need to use the ietf-ip module that augments the ietf-interfaces module.

Schematics for ietf-ip

We want to configure an IP-address on our interfaces, so let's explore a netconf capability/module that will let us do that:

# Message
<rpc message-id="101" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <get-schema xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring">
 <identifier>ietf-ip</identifier>
   <format>yang</format>
 </get-schema>
</rpc>
# Reply
]]>]]>
<?xml version="1.0" encoding="UTF-8"?>
<rpc-reply 
  xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" 
  message-id="101">
<data xmlns='urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring'>
#
# START OF YANG OUTPUT
#
module ietf-ip {
  namespace "urn:ietf:params:xml:ns:yang:ietf-ip";
  prefix ip;
  import ietf-interfaces {
    prefix if;
  }
  augment "/if:interfaces/if:interface" {
    description
      "Parameters for configuring IP on interfaces.";
    container ipv4 {
      description
        "Parameters for the IPv4 address family.";
      leaf mtu {
        type uint16 {
          range "68..max";
        }
        units octets;
        description
          "The size, in octets, of the largest IPv4 packet allowed.";
      }
      list address {
        key "ip";
        description
          "The list of configured IPv4 addresses on the interface.";
        leaf ip {
          type inet:ipv4-address-no-zone;
          description
            "The IPv4 address on the interface.";
        }
        choice subnet {
          mandatory true;
          description
            "Prefix-length or a netmask.";
          leaf prefix-length {
            type uint8 {
              range "0..32";
            }
            description
              "The length of the subnet prefix.";
          }
          leaf netmask {
            if-feature ipv4-non-contiguous-netmasks;
            type yang:dotted-quad;
            description
              "The subnet specified as a netmask.";
          }
        }
      }
    }
  }
}
#
# END OF YANG OUTPUT
#
]]></data>
</rpc-reply>]]>]]>

Looking at this module, there are some new keywords here. Starting from the top, we can see the ietf-ip imports and augments the ietf-interfaces netconf module. This augment allows the operator to use the two modules together to configure an IP-address on an interface. The developers could have put all of the features into the ietf-interfaces module, but it would end up becoming a massive module considering all the protocols that can be configured on any given interface. By separating the functionality, there is less code in one place and the complexity is much reduced.

Going further into the augment line, it specifies where the augment should be placed in the variable hierarchy. Using this information we learn that the "ipv4" container must exist inside an interface in the /interfaces/interface list. The expected input is therefore (xpath again):

Inside the ipv4 container we can then create a list of IP-addresses, the first one is the primary address and any subsequent address becomes secondary addresses. Each address must contain an IP and a subnet, the subnet in a prefix-length or netmask format.

The full variable tree would look something like this:

interfaces = {
    'interface': [
        {
            'name': 'GigabitEthernet3',
            'description': 'R2-R3',
            'enabled': True,
            'ipv4': {
                'address': [
                    {
                        'ip': 10.2.3.2,
                        'subnet': 255.255.255.248
                    }
                ]
            }
        },
        {
            'name': 'GigabitEthernet4',
            'description': 'R2-R4',
            'enabled': True
            'ipv4': {
                'address': [
                    {
                        'ip': 10.2.4.2,
                        'subnet': 255.255.255.248
                    },
                    {
                        'ip': 11.2.4.2,
                        'subnet': 255.255.255.248
                    }
                ]
            }
        },
    ]
}

Learning how to fetch and read these Yang modules is crucial for learning Netconf as this is how each netconf module/capability is documented.


Configuring an Interface with Netconf

You've done a marvelous job patiently reading this far without actually seeing any configuration changes being made and the wait is finally over. In this example we will edit the GigabitEthernet2 interface, setting its IP-address to 1.1.1.1/29.

<rpc message-id="101" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
  <edit-config>
    <target>
      <candidate></candidate>
    </target>
    <error-option>rollback-on-error</error-option>
    <config>
      <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
        <interface>
          <name>GigabitEthernet2</name>
          <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
            <address>
              <ip>1.1.1.1</ip>
              <netmask>255.255.255.248</netmask>
            </address>
          </ipv4>
        </interface>
      </interfaces>
    </config>
  </edit-config>
</rpc>
]]>]]>
<?xml version="1.0" encoding="utf-8"?>
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="101">
  <ok></ok>
</rpc-reply>
]]>]]>

Let's dissect this message. The RPC message contains a new keyword edit-config. We set the candidate datastore as the target instead of the running-config, allowing us to chain multiple edit-config messages together before we commit/apply the config changes. Abort and rollback if any error is encountered.

The config message uses the ietf-interfaces and ietf-ip modules to configure the GigabitEthernet2 interface with IP-address 1.1.1.1/29. The server replies with a simple ok to show that the config was successfully installed in the candidate configuration.

To apply the changes to the running-config we need to send the commit command like this:

<rpc message-id="102" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <commit>
 </commit>
</rpc>
]]>]]>
<?xml version="1.0" encoding="utf-8"?>
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="102">
  <ok></ok>
</rpc-reply>
]]>]]>

Again, the server responds with a simple ok, but the config change in the candidate datastore has now been applied in the running-config datastore. The change was successful!

Let's see what config is necessary to remove the IP-address again:

# Remove IP-address
<rpc message-id="101" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
  <edit-config>
    <target>
      <candidate></candidate>
    </target>
    <error-option>rollback-on-error</error-option>
    <config xmlns:xc="urn:ietf:params:xml:ns:netconf:base:1.0">
      <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
        <interface>
          <name>GigabitEthernet2</name>
          <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
            <address xc:operation="remove">
              <ip>1.1.1.1</ip>
            </address>
          </ipv4>
        </interface>
      </interfaces>
    </config>
  </edit-config>
</rpc>
]]>]]>
<?xml version="1.0" encoding="utf-8"?>
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="101">
  <ok></ok>
</rpc-reply>
# Commit to running-config
<rpc message-id="102" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <commit>
 </commit>
</rpc>
]]>]]>
<?xml version="1.0" encoding="utf-8"?>
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="102">
  <ok></ok>
</rpc-reply>
]]>]]>

What's new in this message is the remove operation. The default operation is merge. Other operations like replace, create and delete exist with different use cases not covered here.


Performing a network-wide transaction

To display the power of the network-wide transactions I have to harness the power of Python on my netconf client. I also require a network of routers that my client can connect to, see topology below:

The topology consists of three IOS-XE routers, R2, R3 and R4. They are connected in a ring topology with OSPF enabled on each link. Each router is also connected to an out-of-band management network (10.1.1.0/24) where our netconf client is located.

The transaction

In this change, I intend to reconfigure all the OSPF links from 10.x.x.x to 11.x.x.x addresses in a single multi-device transaction. The client will use netconf to connect to each router, remove any 10.x.x.x address from the OSPF-enabled interfaces and replace it with a 11.x.x.x address. Before the change is committed, the client will fetch information about any OSPF neighbors active on each router.

After the change is committed, but not yet confirmed, the netconf client will again fetch info about the OSPF neighbors and compare it to the previous results. If there is any mismatch, the transaction will have failed and the changes immediately be rolled back on all devices. If there's no mismatch, the commit will be confirmed and the transaction is considered a success.

While this sounds like a contrived example, it is quite good at showing different ways the transaction can fail, causing a rollback. What if, for example, applying the new IP-address on one of the R2 interfaces fails? If the netconf implementation is good, the netconf server will return an error, triggering the client to abort the transaction. If no error is reported, the netconf client will still verify that everything is ok by checking that OSPF adjacencies are back online before confirming the change. If any connection time out during the change, the transaction fails and all device configs are rolled back.

The code

The python code in this example was developed by me while I was learning netconf. Despite the existence of popular python netconf-client called ncclient, I felt that it would be more beneficial for me to actually see what happens under the hood. I therefore decided on using a combination of pexpect and xmltodict. Pexpect allows me to interact with the netconf server by sending XML messages as strings and scraping the server XML output back into Python. I then use the xmltodict to transform the XML data into python native dictionary format.

The goal is for anyone who runs this code to be able to see every message that is sent back and forth by setting "debug=True" when the initiate the NetconfServerIOSXE object.

main.py

This is the file we run to start executing the change. It starts by connecting to three netconf servers, R2, R3 and R4. We discard and previous changes made to the candidate-datastore and then lock it so that only we can make changes while we are connected. We fetch the OSPF neighbors so that we can compare the results after the config changes have been made.

Then we reconfigure the interfaces. Note that I have elected to remove the interface IP-addresses twice. This is because the IOS-XE version that I'm running, when rolling back a failed change will add the same interface twice to the interface inside the candidate datastore. For this odd reason we have to remove it twice before configuring the new IP-address.

After the IP-addresses have changed, we perform a confirmed-commit with a timeout of 30 seconds. This means that the changes are applied, but if no second commit is received within 30 seconds, the transaction is considered failed and a rollback is initiated.

During the first 15 seconds of this 30-second window, the script waits for the changes to be applied and the new OSPF adjacencies to come up. After 15 seconds we fetch the OSPF adjacencies again and compare them to the pre-change results. If all is well, we send the final commit and the transaction is a success!

import time
from netconf_server_iosxe import NetconfServerIOSXE

def main():

    R2 = NetconfServerIOSXE(name="R2", ip="10.1.1.2", debug=True)
    R3 = NetconfServerIOSXE(name="R3", ip="10.1.1.3", debug=False)
    R4 = NetconfServerIOSXE(name="R4", ip="10.1.1.4", debug=False)
    routers = [R2, R3, R4]

    """Set up Netconf connection"""
    for router in routers:
        error = router.connect()
        if error:
            quit(error)

    """Discard previous config changes and lock the candidate config 
    so no one else can make changes"""
    for router in routers:
        router.discard_changes()
        router.lock_candidate()

    """Get OSPF neighbors"""
    for router in routers:
        router.ospf_neighbors = router.get_ospf_neighbors()
        print(router.ospf_neighbors)

    """Reconfigure interfaces"""
    R2.remove_interface_ip(name="GigabitEthernet3", ip="10.2.3.2")
    R2.remove_interface_ip(name="GigabitEthernet3", ip="10.2.3.2")
    R2.remove_interface_ip(name="GigabitEthernet4", ip="10.2.4.2")
    R2.remove_interface_ip(name="GigabitEthernet4", ip="10.2.4.2")
    R2.configure_interface(name="GigabitEthernet3", ip="11.2.3.2", netmask="255.255.255.248")
    R2.configure_interface(name="GigabitEthernet4", ip="11.2.4.2", netmask="255.255.255.248")

    R3.remove_interface_ip(name="GigabitEthernet2", ip="10.2.3.3")
    R3.remove_interface_ip(name="GigabitEthernet2", ip="10.2.3.3")
    R3.remove_interface_ip(name="GigabitEthernet4", ip="10.3.4.3")
    R3.remove_interface_ip(name="GigabitEthernet4", ip="10.3.4.3")
    R3.configure_interface(name="GigabitEthernet2", ip="11.2.3.3", netmask="255.255.255.248")
    R3.configure_interface(name="GigabitEthernet4", ip="11.3.4.3", netmask="255.255.255.248")

    R4.remove_interface_ip(name="GigabitEthernet3", ip="10.3.4.4")
    R4.remove_interface_ip(name="GigabitEthernet3", ip="10.3.4.4")
    R4.remove_interface_ip(name="GigabitEthernet2", ip="10.2.4.4")
    R4.remove_interface_ip(name="GigabitEthernet2", ip="10.2.4.4")
    R4.configure_interface(name="GigabitEthernet3", ip="11.3.4.4", netmask="255.255.255.248")
    R4.configure_interface(name="GigabitEthernet2", ip="11.2.4.4", netmask="255.255.255.248")

    """Attempt to apply changes, we get 30 seconds to check for errors"""
    R2.commit_with_confirm(timeout=30)
    R3.commit_with_confirm(timeout=30)
    R4.commit_with_confirm(timeout=30)

    time.sleep(15)

    """Get OSPF neighbors again"""
    for router in routers:
        ospf_neighbors = router.get_ospf_neighbors()
        if ospf_neighbors != router.ospf_neighbors:
            print(f"{router.name} OSPF neighbors mismatch!")
            print(f"  Before: {router.ospf_neighbors}")
            print(f"   After: {ospf_neighbors}")
            quit("  Script aborted")

    """Commit changes if all went well"""
    R2.commit()
    R3.commit()
    R4.commit()

    print("Success!")


if __name__ == "__main__":
    main()


netconf_server_iosxe.py

This file implements the NetconfServerIOSXE python class, of which I'm creating instances in the main.py file.

Starting with the connect() method, we spawn a new pexpect process that connects to the router via SSH, enters the password and saves the SSH process to self.ssh to be used later. This function also sends the initial Hello message from the client, completing the session establishment.

The send_message() function is an internal method used to interact with the netconf server, generating RPC messages in XML that are sent to the server. The response is then fetched, printed if debug=True, and converted into a python dictionary and is then returned to whichever piece of code called the function. Pretty much all other methods in this class use the send_message() function to interact with the server, the other methods just describe what the XML message should look like.

Looking at get_config_interfaces(), we can see one such netconf message put together. The message uses get-config with an interfaces filter applied, referencing the ietf-interfaces module.

The configure_interface() method geenrates a bit more involved message where multiple modules work together to create the XML message necessary to configure an IP-address on an interface.

The code contains some methods that are not used in my example, but could be useful for your own exploration and experiments. Some examples are get_route() and validate().

If you want to test this code out for yourself, you can clone my GIT repository here:

https://github.com/emieli/netconf-iosxe

import pexpect
import xmltodict
from netaddr import IPAddress


class NetconfServerIOSXE:
    """Python class for interacting with IOS-XE Netconf Server via SSH."""

    def __init__(self, name: str, ip: str, port=830, debug=False, username="admin", password="admin"):

        self.counter = 0
        self.debug = debug
        self.ip = ip
        self.name = name
        self.port = port
        self.username = username
        self.password = password

    @property
    def message_id(self):
        """Each netconf message should use a unique message ID"""
        self.counter += 1
        return self.counter

    def connect(self) -> None:
        """Setting up netconf SSH connection to server"""

        """Initial SSH connection attempt"""
        try:
            ssh = pexpect.spawn(f"ssh {self.username}@{self.ip} -p {self.port} -s netconf")
        except pexpect.exceptions.TIMEOUT:
            return f"Error when connecting to {self.name}: {ssh.before.decode('utf8')}"

        """Check for "password" or "unknown host" output"""
        try:
            index = ssh.expect(["password: ", "Are you sure you want to continue connecting"])
        except pexpect.exceptions.EOF:
            return f"Error when connecting to {self.name}: {ssh.before.decode('utf8')}"

        if index == 1:
            """Save host key"""
            ssh.sendline("yes")
            ssh.expect("password: ")

        """Enter password"""
        try:
            ssh.sendline(self.password)
            if self.debug:
                print("Password entered")
            ssh.expect("]]>]]>")
        except pexpect.exceptions.TIMEOUT:
            return f"Error when connecting to {self.name}: {ssh.before.decode('utf8')}"

        """Retrieve session ID, this is only really used for kill-session."""
        output = ssh.before.decode("utf-8").replace('<?xml version="1.0" encoding="UTF-8"?>', "")
        output = xmltodict.parse(output)
        self.session_id = output["hello"]["session-id"]
        self.ssh = ssh

        """Sending initial netconf Hello message"""
        message = {
            "hello": {
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "capabilities": {"capability": "urn:ietf:params:netconf:base:1.0"},
            }
        }
        xml = xmltodict.unparse(message)
        ssh.sendline(xml + "]]>]]>")
        if self.debug:
            print("Hello message sent")
        ssh.expect("]]>]]>")

        return

    def send_message(self, message: dict) -> dict:
        """Send netconf message to server, process output and return it."""

        xml = xmltodict.unparse(message, pretty=True, indent="  ")
        if self.debug:
            print(f"\n====== {self.name} ======")
            print(xml.strip())
        self.ssh.sendline(xml + "]]>]]>")
        self.ssh.expect("]]>]]>")  # end of RPC message
        self.ssh.expect("]]>]]>")  # end of RPC-reply

        output_xml = self.ssh.before.decode("utf-8").replace('<?xml version="1.0" encoding="UTF-8"?>', "")
        output_dict = xmltodict.parse(output_xml)
        if self.debug:
            print(xmltodict.unparse(output_dict, pretty=True, indent="  "))

        if "rpc-error" in output_dict:
            return output_dict["rpc-error"]

        return output_dict["rpc-reply"]

    def discard_changes(self):
        """Discard any previous config changes in the candidate datastore, giving us a clean slate"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "discard-changes": None,
            }
        }
        return self.send_message(message)

    def get_ospf_neighbors(self) -> dict:
        """Retrieve all current OSPF neighbor router-IDs. Returns a dictionary in this format:\n
        {'GigabitEthernet4': '10.0.0.4', 'GigabitEthernet3': '10.0.0.3'}"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "get": {"filter": {"ospf-oper-data": {"@xmlns": "http://cisco.com/ns/yang/Cisco-IOS-XE-ospf-oper"}}},
            }
        }
        output = self.send_message(message)

        """Process the output and return data in the dictionary format we specified above"""
        try:
            output = output["data"]["ospf-oper-data"]["ospfv2-instance"]["ospfv2-area"]["ospfv2-interface"]
        except KeyError:
            """No data found, return nothing"""
            return

        neighbors = {}
        for interface in output:
            if "ospfv2-neighbor" in interface:
                neighbor = IPAddress(interface["ospfv2-neighbor"]["nbr-id"])
                neighbors[interface["name"]] = str(neighbor)
        return neighbors

    def get_config_interfaces(self) -> dict:
        """Retrieve interface config"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "get-config": {
                    "source": {"candidate": None},
                    "filter": {"interfaces": {"@xmlns": "urn:ietf:params:xml:ns:yang:ietf-interfaces"}},
                },
            }
        }
        return self.send_message(message)

    def configure_interface(self, name, ip, netmask):
        """Configure an interface with an IP-addres and a netmask"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "edit-config": {
                    "target": {"candidate": None},
                    "error-option": "rollback-on-error",
                    "config": {
                        "interfaces": {
                            "@xmlns": "urn:ietf:params:xml:ns:yang:ietf-interfaces",
                            "interface": {
                                "name": name,
                                "ipv4": {
                                    "@xmlns": "urn:ietf:params:xml:ns:yang:ietf-ip",
                                    "address": {"ip": ip, "netmask": netmask},
                                },
                            },
                        },
                    },
                },
            },
        }
        return self.send_message(message)

    def remove_interface_ip(self, name, ip):
        """Remove an IP-address from the selected interface"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "edit-config": {
                    "target": {"candidate": None},
                    "error-option": "rollback-on-error",
                    "config": {
                        "@xmlns:xc": "urn:ietf:params:xml:ns:netconf:base:1.0",
                        "interfaces": {
                            "@xmlns": "urn:ietf:params:xml:ns:yang:ietf-interfaces",
                            "interface": [
                                {
                                    "name": name,
                                    "ipv4": {
                                        "@xmlns": "urn:ietf:params:xml:ns:yang:ietf-ip",
                                        "address": {"@xc:operation": "remove", "ip": ip},
                                    },
                                }
                            ],
                        },
                    },
                },
            },
        }
        return self.send_message(message)

    def get_route(self, prefix):
        """Check the routing table for information about a specific prefix"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "get": {
                    "filter": {
                        "routing-state": {
                            "@xmlns": "urn:ietf:params:xml:ns:yang:ietf-routing",
                            "routing-instance": {
                                "name": "default",
                                "ribs": {
                                    "rib": {
                                        "name": "ipv4-default",
                                        "routes": {
                                            "route": {
                                                "destination-prefix": prefix,
                                            }
                                        },
                                    }
                                },
                            },
                        }
                    }
                },
            },
        }
        return self.send_message(message)

    def close_session(self):
        """Gracefully kills the netconf session, undoing any changes so far."""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "close-session": None,
            }
        }
        return self.send_message(message)

    def validate(self):
        """ "Validates" the config, I haven't ever seen it actually do anything."""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "validate": {"source": {"candidate": None}},
            }
        }
        return self.send_message(message)

    def commit_with_confirm(self, timeout: int = 30):
        """Apply config changes, then wait the timeout period. If no second commit
        is received before the timer expires, then rollback."""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "commit": {"confirmed": None, "confirm-timeout": timeout},
            }
        }
        return self.send_message(message)

    def commit(self):
        """Apply changes without possibility of rollbacks."""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "commit": None,
            }
        }
        return self.send_message(message)

    def lock_candidate(self):
        """Lock the candidate datastore so that only we can make changes"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "lock": {"target": {"candidate": None}},
            }
        }
        return self.send_message(message)

    def lock_running(self):
        """Lock running config so that only we can make changes"""

        message = {
            "rpc": {
                "@message-id": self.message_id,
                "@xmlns": "urn:ietf:params:xml:ns:netconf:base:1.0",
                "lock": {"target": {"running": None}},
            }
        }
        return self.send_message(message)

Conclusion

In conclusion, I would like to thank you for reading. I hope this was useful and worth your time. If you want to learn more about netconf I suggest your next read is this book: https://learning.oreilly.com/library/view/network-programmability-with/9780135180471/


Copyright 2021-2023, Emil Eliasson.
All Rights Reserved.