confd: initial support for rip

Fixes #582

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2025-12-05 10:58:35 +01:00
parent dc6e82759a
commit 1cbc9a49a2
16 changed files with 2213 additions and 14 deletions
+172
View File
@@ -3260,6 +3260,165 @@ def show_ospf_routes(json_data):
print(f"{'':<52} {next_hop_addr:<16} {outgoing_iface:<12}")
def show_rip(json_data):
"""Show RIP general instance information"""
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
rip_instance = None
for protocol in protocols:
if 'ietf-rip:rip' in protocol:
rip_instance = protocol
break
if not rip_instance:
print("RIP is not configured or running")
return
rip = rip_instance.get('ietf-rip:rip', {})
# Display RIP configuration
print(" RIP Routing Process")
print()
distance = rip.get('distance', 120)
default_metric = rip.get('default-metric', 1)
num_routes = rip.get('num-of-routes', 0)
print(f" Administrative distance: {distance}")
print(f" Default metric: {default_metric}")
print(f" Number of RIP routes: {num_routes}")
print()
# Display timers if available
timers = rip.get('timers', {})
if timers:
update = timers.get('update-interval', 30)
invalid = timers.get('invalid-interval', 180)
flush = timers.get('flush-interval', 240)
print(" Timers:")
print(f" Update interval: {update} seconds")
print(f" Invalid interval: {invalid} seconds")
print(f" Flush interval: {flush} seconds")
print()
# Display interfaces
interfaces = rip.get('interfaces', {}).get('interface', [])
if interfaces:
print(f" Number of interfaces: {len(interfaces)}")
for iface in interfaces:
iface_name = iface.get('interface', 'unknown')
print(f" {iface_name}")
print()
def show_rip_routes(json_data):
"""Show RIP routing table"""
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
rip_instance = None
for protocol in protocols:
if 'ietf-rip:rip' in protocol:
rip_instance = protocol
break
if not rip_instance:
print("RIP is not configured or running")
return
rip = rip_instance.get('ietf-rip:rip', {})
routes = rip.get('ipv4', {}).get('routes', {}).get('route', [])
if not routes:
print("No RIP routes")
return
# Header
hdr = f"{'PREFIX':<20} {'METRIC':<8} {'NEXT-HOP':<16} {'INTERFACE':<12}"
print(Decore.invert(hdr))
for route in routes:
prefix = route.get('ipv4-prefix', 'unknown')
metric = route.get('metric', 0)
next_hop = route.get('next-hop', '-')
interface = route.get('interface', '-')
print(f"{prefix:<20} {metric:<8} {next_hop:<16} {interface:<12}")
def show_rip_interfaces(json_data):
"""Show RIP interface information"""
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
rip_instance = None
for protocol in protocols:
if 'ietf-rip:rip' in protocol:
rip_instance = protocol
break
if not rip_instance:
print("RIP is not configured or running")
return
rip = rip_instance.get('ietf-rip:rip', {})
interfaces = rip.get('interfaces', {}).get('interface', [])
if not interfaces:
print("No RIP interfaces")
return
# Header
hdr = f"{'INTERFACE':<12} {'STATUS':<10} {'SEND':<6} {'RECV':<6} {'SPLIT-HORIZON':<20} {'PASSIVE':<10}"
print(Decore.invert(hdr))
for iface in interfaces:
iface_name = iface.get('interface', 'unknown')
oper_status = iface.get('oper-status', 'down')
send_ver = iface.get('send-version', '-')
recv_ver = iface.get('receive-version', '-')
split_horizon = iface.get('split-horizon', 'simple')
passive = 'Yes' if iface.get('passive', False) else 'No'
print(f"{iface_name:<12} {oper_status:<10} {send_ver:<6} {recv_ver:<6} {split_horizon:<20} {passive:<10}")
def show_rip_neighbors(json_data):
"""Show RIP neighbor information"""
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
rip_instance = None
for protocol in protocols:
if 'ietf-rip:rip' in protocol:
rip_instance = protocol
break
if not rip_instance:
print("RIP is not configured or running")
return
rip = rip_instance.get('ietf-rip:rip', {})
ipv4 = rip.get('ipv4', {})
neighbors = ipv4.get('neighbors', {}).get('neighbor', [])
if not neighbors:
print("No RIP neighbors")
return
# Header
hdr = f"{'ADDRESS':<16} {'BAD-PACKETS':<14} {'BAD-ROUTES':<12}"
print(Decore.invert(hdr))
for neighbor in neighbors:
address = neighbor.get('ipv4-address', 'unknown')
bad_packets = neighbor.get('bad-packets-rcvd', 0)
bad_routes = neighbor.get('bad-routes-rcvd', 0)
print(f"{address:<16} {bad_packets:<14} {bad_routes:<12}")
def show_bfd_status(json_data):
"""Show BFD status summary"""
routing = json_data.get('ietf-routing:routing', {})
@@ -3549,6 +3708,11 @@ def main():
subparsers.add_parser('show-ospf-neighbor', help='Show OSPF neighbors')
subparsers.add_parser('show-ospf-routes', help='Show OSPF routing table')
subparsers.add_parser('show-rip', help='Show RIP instance information')
subparsers.add_parser('show-rip-routes', help='Show RIP routing table')
subparsers.add_parser('show-rip-interfaces', help='Show RIP interfaces')
subparsers.add_parser('show-rip-neighbors', help='Show RIP neighbors')
subparsers.add_parser('show-routing-table', help='Show the routing table') \
.add_argument('-i', '--ip', required=True, help='IPv4 or IPv6 address')
@@ -3602,6 +3766,14 @@ def main():
show_ospf_neighbor(json_data)
elif args.command == "show-ospf-routes":
show_ospf_routes(json_data)
elif args.command == "show-rip":
show_rip(json_data)
elif args.command == "show-rip-routes":
show_rip_routes(json_data)
elif args.command == "show-rip-interfaces":
show_rip_interfaces(json_data)
elif args.command == "show-rip-neighbors":
show_rip_neighbors(json_data)
elif args.command == "show-routing-table":
show_routing_table(json_data, args.ip)
elif args.command == "show-software":
+3 -1
View File
@@ -6,7 +6,8 @@ license = "MIT"
packages = [
{ include = "yanger" },
{ include = "cli_pretty" },
{ include = "ospf_status" }
{ include = "ospf_status" },
{ include = "rip_status" }
]
authors = [
"KernelKit developers"
@@ -21,3 +22,4 @@ build-backend = "poetry.core.masonry.api"
yanger = "yanger.__main__:main"
cli-pretty = "cli_pretty:main"
ospf-status = "ospf_status:main"
rip-status = "rip_status.rip_status:main"
+1
View File
@@ -0,0 +1 @@
# SPDX-License-Identifier: BSD-3-Clause
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/python3
# This script is used to transform the output from the show ip rip commands
# to match the ietf-rip YANG model structure.
#
# This makes the parsing for the operational parts of YANG model more easy
#
import sys
import json
import subprocess
def run_json_cmd(cmd, default=None, check=True):
"""Run a command (array of args) with JSON output and return the JSON"""
try:
result = subprocess.run(cmd, check=check, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
output = result.stdout
data = json.loads(output)
except (subprocess.CalledProcessError, json.JSONDecodeError):
if default is not None:
return default
raise
return data
def main():
"""
Collects RIP operational data from FRR vtysh and transforms it
into a structure that matches the ietf-rip YANG model.
"""
rip_routes_cmd = ['sudo', 'vtysh', '-c', "show ip rip json"]
rip_status_cmd = ['sudo', 'vtysh', '-c', "show ip rip status json"]
try:
routes = run_json_cmd(rip_routes_cmd, default={})
status = run_json_cmd(rip_status_cmd, default={})
except (subprocess.CalledProcessError, json.JSONDecodeError):
return {}
# Build the structure matching ietf-rip YANG model
result = {
"routes": routes.get("routes", {}),
"status": status
}
return result
if __name__ == "__main__":
try:
data = main()
print(json.dumps(data, indent=2))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
+3
View File
@@ -63,6 +63,9 @@ def main():
elif args.model == 'ietf-ospf':
from . import ietf_ospf
yang_data = ietf_ospf.operational()
elif args.model == 'ietf-rip':
from . import ietf_rip
yang_data = ietf_rip.operational()
elif args.model == 'ietf-hardware':
from . import ietf_hardware
yang_data = ietf_hardware.operational()
+270
View File
@@ -0,0 +1,270 @@
import re
from .host import HOST
def parse_rip_status():
"""Parse 'show ip rip status' text output to extract operational state
Returns dict with keys: update-interval, invalid-interval, flush-interval,
default-metric, distance, interfaces (list), neighbors (list)
"""
try:
# HOST.run expects tuple, returns text string directly
text = HOST.run(tuple(['vtysh', '-c', 'show ip rip status']), default="")
if not text:
return {}
except Exception as e:
return {}
status = {}
# Parse: "Sending updates every 30 seconds"
match = re.search(r'Sending updates every (\d+) seconds', text)
if match:
status['update-interval'] = int(match.group(1))
# Parse: "Timeout after 180 seconds"
match = re.search(r'Timeout after (\d+) seconds', text)
if match:
status['invalid-interval'] = int(match.group(1))
# Parse: "garbage collect after 240 seconds"
match = re.search(r'garbage collect after (\d+) seconds', text)
if match:
status['flush-interval'] = int(match.group(1))
# Parse: "Default redistribution metric is 1"
match = re.search(r'Default redistribution metric is (\d+)', text)
if match:
status['default-metric'] = int(match.group(1))
# Parse: "Distance: (default is 120)"
match = re.search(r'Distance: \(default is (\d+)\)', text)
if match:
status['distance'] = int(match.group(1))
# Parse interface table:
# Interface Send Recv Key-chain
# e5 2 2
interfaces = []
in_interface_section = False
for line in text.split('\n'):
line = line.strip()
# Detect start of interface section
if 'Interface' in line and 'Send' in line and 'Recv' in line:
in_interface_section = True
continue
# Stop at next section
if in_interface_section and (line.startswith('Routing for Networks:') or
line.startswith('Routing Information Sources:')):
break
# Parse interface lines
if in_interface_section and line:
# Format: "e5 2 2 "
parts = line.split()
if len(parts) >= 3 and not line.startswith('Interface'):
iface_name = parts[0]
send_version = parts[1]
recv_version = parts[2]
interfaces.append({
'name': iface_name,
'send-version': int(send_version),
'recv-version': int(recv_version)
})
if interfaces:
status['interfaces'] = interfaces
# Parse Routing Information Sources table:
# Gateway BadPackets BadRoutes Distance Last Update
# 192.168.50.2 0 0 120 00:00:09
neighbors = []
in_neighbor_section = False
for line in text.split('\n'):
line = line.strip()
# Detect start of Routing Information Sources section
if line.startswith('Routing Information Sources:'):
in_neighbor_section = True
continue
# Skip the header line
if in_neighbor_section and 'Gateway' in line and 'BadPackets' in line:
continue
# Stop at next section (Distance line or empty lines after table)
if in_neighbor_section and (line.startswith('Distance:') or
(not line and neighbors)):
break
# Parse neighbor lines
if in_neighbor_section and line:
# Format: "192.168.50.2 0 0 120 00:00:09"
parts = line.split()
if len(parts) >= 5:
try:
gateway = parts[0]
bad_packets = int(parts[1])
bad_routes = int(parts[2])
distance = int(parts[3])
last_update = parts[4] # Format: HH:MM:SS
neighbors.append({
'address': gateway,
'bad-packets': bad_packets,
'bad-routes': bad_routes,
'distance': distance,
'last-update': last_update
})
except (ValueError, IndexError):
# Skip lines that don't parse correctly
continue
if neighbors:
status['neighbors'] = neighbors
return status
def add_rip(control_protocols):
"""Populate RIP operational data
Note: FRR's RIP implementation provides JSON for routing table but not for
status commands, so we combine JSON route data with text parsing for status.
"""
# Get operational status from text parsing
status = parse_rip_status()
# Check if RIP is running - if we can't get status, it's probably not running
if not status:
return
control_protocol = {}
control_protocol["type"] = "infix-routing:ripv2"
control_protocol["name"] = "default"
control_protocol["ietf-rip:rip"] = {}
rip = control_protocol["ietf-rip:rip"]
# Add global operational state
if status.get('distance'):
rip['distance'] = status['distance']
if status.get('default-metric'):
rip['default-metric'] = status['default-metric']
# Add timers if available
if any(k in status for k in ['update-interval', 'invalid-interval', 'flush-interval']):
rip['timers'] = {}
if status.get('update-interval'):
rip['timers']['update-interval'] = status['update-interval']
if status.get('invalid-interval'):
rip['timers']['invalid-interval'] = status['invalid-interval']
if status.get('flush-interval'):
rip['timers']['flush-interval'] = status['flush-interval']
# Add interfaces if available
if status.get('interfaces'):
rip['interfaces'] = {'interface': []}
for iface in status['interfaces']:
iface_data = {
'interface': iface['name'],
'oper-status': 'up' # If it's in the list, it's operational
}
# Map FRR version numbers to YANG enum values
# FRR shows: 1=v1, 2=v2, and we assume if both are enabled it would show differently
send_ver = iface.get('send-version')
if send_ver == 1:
iface_data['send-version'] = '1'
elif send_ver == 2:
iface_data['send-version'] = '2'
# Note: FRR might show this differently for mixed mode
recv_ver = iface.get('recv-version')
if recv_ver == 1:
iface_data['receive-version'] = '1'
elif recv_ver == 2:
iface_data['receive-version'] = '2'
rip['interfaces']['interface'].append(iface_data)
# Get RIP-learned routes from routing table (JSON)
# This shows routes learned via RIP (R(n) entries), not redistributed routes
route_data = HOST.run_json(['vtysh', '-c', 'show ip route rip json'], default={})
routes = []
for prefix, entries in route_data.items():
if not entries or '/' not in prefix:
continue
# Use first entry (RIP doesn't typically have ECMP)
entry = entries[0] if isinstance(entries, list) else entries
route = {
"ipv4-prefix": prefix,
"metric": entry.get("metric", 0),
"route-type": "rip"
}
# Get next hop information
nexthops = entry.get("nexthops", [])
if nexthops:
first_hop = nexthops[0]
if first_hop.get("ip"):
route["next-hop"] = first_hop["ip"]
if first_hop.get("interfaceName"):
route["interface"] = first_hop["interfaceName"]
routes.append(route)
# Add neighbors to operational data
neighbors_list = []
if status.get('neighbors'):
for neighbor in status['neighbors']:
neighbor_data = {
'ipv4-address': neighbor['address'],
'bad-packets-rcvd': neighbor['bad-packets'],
'bad-routes-rcvd': neighbor['bad-routes']
}
# Note: IETF YANG expects last-update as yang:date-and-time
# but FRR gives us a relative time like "00:00:09"
# We'll skip last-update for now or convert it if needed
neighbors_list.append(neighbor_data)
# Add routes and neighbors to operational data
if routes or neighbors_list:
if "ipv4" not in rip:
rip["ipv4"] = {}
if routes:
rip["ipv4"]["routes"] = {
"route": routes
}
# Add route count
rip["num-of-routes"] = len(routes)
if neighbors_list:
rip["ipv4"]["neighbors"] = {
"neighbor": neighbors_list
}
# Add the control-protocol
if "ietf-routing:control-plane-protocol" not in control_protocols:
control_protocols["ietf-routing:control-plane-protocol"] = []
control_protocols["ietf-routing:control-plane-protocol"].append(control_protocol)
def operational():
"""Return RIP operational data in YANG format"""
out = {
"ietf-routing:routing": {
"control-plane-protocols": {}
}
}
add_rip(out['ietf-routing:routing']['control-plane-protocols'])
return out
+4 -1
View File
@@ -51,6 +51,7 @@ def add_protocol(routes, proto):
'static': 'static',
'ospf': 'ietf-ospf:ospfv2',
'ospf6': 'ietf-ospf:ospfv3',
'rip': 'ietf-rip:rip',
}
out = {}
@@ -75,9 +76,11 @@ def add_protocol(routes, proto):
new['source-protocol'] = pmap.get(frr, 'infix-routing:kernel')
new['route-preference'] = route.get('distance', 0)
# Metric only available in the model for OSPF routes
# Metric only available in the model for OSPF and RIP routes
if 'ospf' in frr:
new['ietf-ospf:metric'] = route.get('metric', 0)
elif 'rip' in frr:
new['ietf-rip:metric'] = route.get('metric', 0)
# See https://datatracker.ietf.org/doc/html/rfc7951#section-6.9
# for details on how presence leaves are encoded in JSON: [null]