mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-26 02:33:02 +02:00
6147 lines
216 KiB
Python
Executable File
6147 lines
216 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
import base64
|
||
import json
|
||
import argparse
|
||
import sys
|
||
import re
|
||
import textwrap
|
||
import ipaddress
|
||
from collections import deque
|
||
from datetime import datetime, timezone
|
||
|
||
UNIT_TEST = False
|
||
|
||
|
||
def compress_interface_list(interfaces):
|
||
"""Converts interface list to compact range notation.
|
||
|
||
Args:
|
||
interfaces: List of interface names
|
||
|
||
Returns:
|
||
str: Compressed representation using ranges
|
||
|
||
Algorithm:
|
||
1. Extract prefix+number pairs via regex
|
||
2. Group by prefix, sort numerically
|
||
3. Find consecutive sequences
|
||
4. Format as ranges (e1-e4) or singles (e1)
|
||
5. Combine with non-numeric interfaces
|
||
|
||
Examples:
|
||
['e1', 'e2', 'e3', 'e4'] -> 'e1-e4'
|
||
['e1', 'e2', 'e4', 'e5'] -> 'e1-e2, e4-e5'
|
||
['eth0', 'eth1', 'br0'] -> 'eth0-eth1, br0'
|
||
"""
|
||
if not interfaces:
|
||
return ""
|
||
|
||
if len(interfaces) == 1:
|
||
return interfaces[0]
|
||
|
||
# Group interfaces by their prefix (e.g., 'e', 'eth', 'br')
|
||
groups = {}
|
||
standalone = []
|
||
|
||
for iface in interfaces:
|
||
# Extract prefix and number using regex
|
||
match = re.match(r'^([a-zA-Z]+)(\d+)$', iface)
|
||
if match:
|
||
prefix = match.group(1)
|
||
number = int(match.group(2))
|
||
if prefix not in groups:
|
||
groups[prefix] = []
|
||
groups[prefix].append((number, iface))
|
||
else:
|
||
# Interface doesn't follow prefix+number pattern
|
||
standalone.append(iface)
|
||
|
||
# Process each group to find ranges
|
||
result_parts = []
|
||
|
||
for prefix in sorted(groups.keys()):
|
||
# Sort by number
|
||
numbers_and_ifaces = sorted(groups[prefix])
|
||
ranges = []
|
||
start = None
|
||
end = None
|
||
|
||
for number, iface in numbers_and_ifaces:
|
||
if start is None:
|
||
# Start new range
|
||
start = number
|
||
end = number
|
||
elif number == end + 1:
|
||
# Extend current range
|
||
end = number
|
||
else:
|
||
# End current range and start new one
|
||
if start == end:
|
||
ranges.append(f"{prefix}{start}")
|
||
else:
|
||
ranges.append(f"{prefix}{start}-{prefix}{end}")
|
||
start = number
|
||
end = number
|
||
|
||
# Add the final range
|
||
if start is not None:
|
||
if start == end:
|
||
ranges.append(f"{prefix}{start}")
|
||
else:
|
||
ranges.append(f"{prefix}{start}-{prefix}{end}")
|
||
|
||
result_parts.extend(ranges)
|
||
|
||
# Add standalone interfaces
|
||
result_parts.extend(sorted(standalone))
|
||
|
||
return ", ".join(result_parts)
|
||
|
||
|
||
class Pad:
|
||
flags = 2
|
||
iface = 16
|
||
proto = 14
|
||
state = 12
|
||
data = 41
|
||
|
||
|
||
class PadMdb:
|
||
bridge = 7
|
||
vlan = 6
|
||
group = 20
|
||
ports = 45
|
||
|
||
|
||
class PadStpPort:
|
||
port = 12
|
||
id = 7
|
||
state = 12
|
||
role = 12
|
||
edge = 6
|
||
designated = 31
|
||
|
||
total = 12 + 7 + 12 + 12 + 6 + 31
|
||
|
||
|
||
class PadRoute:
|
||
dest = 30
|
||
pref = 8
|
||
next_hop = 30
|
||
protocol = 6
|
||
uptime = 9
|
||
|
||
@staticmethod
|
||
def set(ipv):
|
||
"""Set default padding based on the IP version ('ipv4' or 'ipv6')."""
|
||
if ipv == 'ipv4':
|
||
PadRoute.dest = 18
|
||
PadRoute.next_hop = 15
|
||
elif ipv == 'ipv6':
|
||
PadRoute.dest = 43
|
||
PadRoute.next_hop = 39
|
||
else:
|
||
raise ValueError(f"unknown IP version: {ipv}")
|
||
|
||
|
||
class PadSoftware:
|
||
name = 11
|
||
date = 25
|
||
hash = 64
|
||
state = 10
|
||
version = 23
|
||
|
||
|
||
class PadDhcpServer:
|
||
ip = 17
|
||
mac = 19
|
||
host = 21
|
||
cid = 22
|
||
exp = 10
|
||
|
||
|
||
class PadSensor:
|
||
name = 30
|
||
value = 22
|
||
status = 10
|
||
|
||
@classmethod
|
||
def table_width(cls):
|
||
"""Total width of sensor table (matches show system width)"""
|
||
return cls.name + cls.value + cls.status
|
||
|
||
|
||
class PadLldp:
|
||
interface = 16
|
||
rem_idx = 10
|
||
time = 12
|
||
chassis_id = 20
|
||
port_id = 20
|
||
|
||
|
||
def format_memory_bytes(bytes_val):
|
||
"""Convert bytes to human-readable format"""
|
||
if bytes_val == 0:
|
||
return " "
|
||
elif bytes_val < 1024:
|
||
return f"{bytes_val}B"
|
||
elif bytes_val < 1024 * 1024:
|
||
return f"{bytes_val // 1024}K"
|
||
elif bytes_val < 1024 * 1024 * 1024:
|
||
return f"{bytes_val // (1024 * 1024):.1f}M"
|
||
else:
|
||
return f"{bytes_val // (1024 * 1024 * 1024):.1f}G"
|
||
|
||
|
||
def format_uptime_seconds(seconds):
|
||
"""Convert seconds to compact time format"""
|
||
if seconds == 0:
|
||
return " "
|
||
elif seconds < 60:
|
||
return f"{seconds}s"
|
||
elif seconds < 3600:
|
||
return f"{seconds // 60}m"
|
||
elif seconds < 86400:
|
||
return f"{seconds // 3600}h"
|
||
else:
|
||
return f"{seconds // 86400}d"
|
||
|
||
|
||
class Column:
|
||
"""Column definition for SimpleTable"""
|
||
def __init__(self, name, align='left', formatter=None, flexible=False, min_width=None):
|
||
self.name = name
|
||
self.align = align
|
||
self.formatter = formatter
|
||
self.flexible = flexible
|
||
self.min_width = min_width
|
||
|
||
class SimpleTable:
|
||
"""Simple table formatter that handles ANSI colors correctly and calculates dynamic column widths"""
|
||
|
||
def __init__(self, columns, min_width=None):
|
||
self.columns = columns
|
||
self.rows = []
|
||
self.min_width = min_width
|
||
self._column_widths = None # Cache calculated widths
|
||
|
||
@staticmethod
|
||
def visible_width(text):
|
||
"""Return visible character count, excluding ANSI escape sequences"""
|
||
ansi_pattern = r'\x1b\[[0-9;]*m'
|
||
clean_text = re.sub(ansi_pattern, '', str(text))
|
||
return len(clean_text)
|
||
|
||
def row(self, *values):
|
||
"""Store row data for later formatting"""
|
||
if len(values) != len(self.columns):
|
||
raise ValueError(f"Expected {len(self.columns)} values, got {len(values)}")
|
||
self.rows.append(values)
|
||
|
||
def width(self):
|
||
"""Calculate and return total table width"""
|
||
if self._column_widths is None:
|
||
self._column_widths = self._calculate_column_widths()
|
||
|
||
# Sum column widths + 2-char separator between columns
|
||
total = sum(self._column_widths)
|
||
if self._column_widths:
|
||
# Separators only between columns, not after the last one
|
||
total += (len(self._column_widths) - 1) * 2
|
||
|
||
return total
|
||
|
||
def adjust_padding(self, width):
|
||
"""Distribute padding to width evenly across flexible columns"""
|
||
if self._column_widths is None:
|
||
self._column_widths = self._calculate_column_widths()
|
||
|
||
current_width = self.width()
|
||
extra_width = width - current_width
|
||
|
||
if extra_width <= 0:
|
||
return # Already at or above target
|
||
|
||
# Find flexible columns
|
||
flex_indices = [i for i, col in enumerate(self.columns) if col.flexible]
|
||
|
||
if not flex_indices:
|
||
return # No flexible columns to expand
|
||
|
||
# Distribute evenly
|
||
per_column = extra_width // len(flex_indices)
|
||
remainder = extra_width % len(flex_indices)
|
||
|
||
for i, idx in enumerate(flex_indices):
|
||
self._column_widths[idx] += per_column
|
||
# Give remainder to first columns
|
||
if i < remainder:
|
||
self._column_widths[idx] += 1
|
||
|
||
def print(self, styled=True):
|
||
"""Calculate widths and print complete table"""
|
||
if self._column_widths is None:
|
||
self._column_widths = self._calculate_column_widths()
|
||
|
||
# Apply minimum width if specified
|
||
if self.min_width:
|
||
self.adjust_padding(self.min_width)
|
||
|
||
print(self._format_header(self._column_widths, styled))
|
||
for row_data in self.rows:
|
||
print(self._format_row(row_data, self._column_widths))
|
||
|
||
def _calculate_column_widths(self):
|
||
"""Calculate maximum width needed for each column"""
|
||
widths = [self.visible_width(col.name) for col in self.columns]
|
||
|
||
for row_data in self.rows:
|
||
for i, (value, column) in enumerate(zip(row_data, self.columns)):
|
||
formatted_value = column.formatter(value) if column.formatter else value
|
||
value_width = self.visible_width(str(formatted_value))
|
||
widths[i] = max(widths[i], value_width)
|
||
|
||
# Apply column minimum widths
|
||
for i, column in enumerate(self.columns):
|
||
if column.min_width:
|
||
widths[i] = max(widths[i], column.min_width)
|
||
|
||
return widths
|
||
|
||
def _format_header(self, column_widths, styled=True):
|
||
"""Generate formatted header row"""
|
||
header_parts = []
|
||
for i, column in enumerate(self.columns):
|
||
width = column_widths[i]
|
||
# Add separator " " only between columns, not after the last one
|
||
is_last = (i == len(self.columns) - 1)
|
||
separator = "" if is_last else " "
|
||
|
||
if column.align == 'right':
|
||
header_parts.append(f"{column.name:>{width}}{separator}")
|
||
else:
|
||
header_parts.append(f"{column.name:{width}}{separator}")
|
||
|
||
header_str = ''.join(header_parts)
|
||
return Decore.invert(header_str) if styled else header_str
|
||
|
||
def _format_row(self, row_data, column_widths):
|
||
"""Format a single data row"""
|
||
row_parts = []
|
||
for i, (value, column) in enumerate(zip(row_data, self.columns)):
|
||
is_last = (i == len(self.columns) - 1)
|
||
formatted_value = self._format_column_value(value, column, column_widths[i], is_last)
|
||
row_parts.append(formatted_value)
|
||
|
||
return ''.join(row_parts)
|
||
|
||
def _format_column_value(self, value, column, width, is_last=False):
|
||
"""Format a single column value with proper alignment"""
|
||
if column.formatter:
|
||
value = column.formatter(value)
|
||
|
||
value_str = str(value)
|
||
visible_len = self.visible_width(value_str)
|
||
|
||
# Add separator " " only between columns, not after the last one
|
||
separator = "" if is_last else " "
|
||
|
||
# Don't add trailing spaces to the last column
|
||
if is_last:
|
||
if column.align == 'right':
|
||
padding = width - visible_len
|
||
return ' ' * max(0, padding) + value_str
|
||
return value_str
|
||
elif column.align == 'right':
|
||
padding = width - visible_len
|
||
return ' ' * max(0, padding) + value_str + separator
|
||
else:
|
||
padding = width - visible_len
|
||
return value_str + ' ' * max(0, padding) + separator
|
||
|
||
|
||
class Canvas:
|
||
"""Multi-item rendering canvas for unified width alignment across tables and content.
|
||
|
||
Canvas coordinates the display of multiple SimpleTable instances and other content
|
||
types (text, titles, spacing, pre-rendered content) with consistent width alignment.
|
||
This creates professional-looking output where all table headers and content align
|
||
to the same width, with flexible columns distributing extra space evenly.
|
||
|
||
Purpose:
|
||
- Achieve visual alignment across heterogeneous content
|
||
- Minimize performance overhead with single-pass data traversal
|
||
- Support mixed content: tables, text, titles, matrices, spacing
|
||
|
||
How it works:
|
||
1. Buffer items in sequence (add_text, add_table, add_title, etc.)
|
||
2. Calculate maximum width from all SimpleTable instances
|
||
3. Apply flex padding to narrower tables via SimpleTable.adjust_padding()
|
||
4. Render all items sequentially with unified width
|
||
|
||
Interaction with SimpleTable:
|
||
- Calls SimpleTable.width() to determine natural table width
|
||
- Calls SimpleTable.adjust_padding(max_width) to expand flexible columns
|
||
- SimpleTable columns marked with flexible=True distribute extra width evenly
|
||
- Preserves table alignment (left/right) and formatting
|
||
|
||
Performance:
|
||
- Single data traversal: tables built once, widths calculated once
|
||
- Memory over CPU: buffers all items before rendering
|
||
- Optimized for embedded systems (ARM Cortex-A7)
|
||
|
||
Example:
|
||
See show_firewall() for a complete usage example demonstrating:
|
||
- Mixed content types (status text, matrix, tables)
|
||
- Multiple tables with different flexible columns
|
||
- Centered matrix using get_max_width()
|
||
- Proper spacing and titles
|
||
|
||
Args:
|
||
min_width: Optional minimum width for all tables (default: None)
|
||
"""
|
||
|
||
def __init__(self, min_width=None):
|
||
self.min_width = min_width
|
||
self.items = [] # List of (type, content) tuples
|
||
|
||
def add_text(self, text):
|
||
"""Add a plain text line"""
|
||
self.items.append(('text', text))
|
||
|
||
def add_title(self, text):
|
||
"""Add a section title (will be centered to canvas width)"""
|
||
self.items.append(('title', text))
|
||
|
||
def add_raw(self, text):
|
||
"""Add pre-rendered multi-line text (like zone matrix)"""
|
||
self.items.append(('raw', text))
|
||
|
||
def add_spacing(self, lines=1):
|
||
"""Add blank lines for spacing"""
|
||
self.items.append(('spacing', lines))
|
||
|
||
def add_table(self, table):
|
||
"""Add a SimpleTable instance"""
|
||
if not isinstance(table, SimpleTable):
|
||
raise ValueError("Expected SimpleTable instance")
|
||
self.items.append(('table', table))
|
||
|
||
def insert(self, index, item_type, content):
|
||
"""Insert an item at a specific position"""
|
||
self.items.insert(index, (item_type, content))
|
||
|
||
def insert_text(self, index, text):
|
||
"""Insert a plain text line at position"""
|
||
self.insert(index, 'text', text)
|
||
|
||
def insert_title(self, index, text):
|
||
"""Insert a section title at position"""
|
||
self.insert(index, 'title', text)
|
||
|
||
def insert_raw(self, index, text):
|
||
"""Insert pre-rendered text at position"""
|
||
self.insert(index, 'raw', text)
|
||
|
||
def insert_spacing(self, index, lines=1):
|
||
"""Insert blank lines at position"""
|
||
self.insert(index, 'spacing', lines)
|
||
|
||
def insert_table(self, index, table):
|
||
"""Insert a SimpleTable at position"""
|
||
if not isinstance(table, SimpleTable):
|
||
raise ValueError("Expected SimpleTable instance")
|
||
self.insert(index, 'table', table)
|
||
|
||
def get_max_width(self):
|
||
"""Calculate and return the maximum width from all tables"""
|
||
max_width = self.min_width or 0
|
||
|
||
for item_type, content in self.items:
|
||
if item_type == 'table':
|
||
max_width = max(max_width, content.width())
|
||
|
||
return max_width
|
||
|
||
def render(self):
|
||
"""Calculate widths, apply padding, and output all items"""
|
||
# First pass: find maximum width from all tables
|
||
max_width = self.get_max_width()
|
||
|
||
# Second pass: apply padding to all tables with flexible columns
|
||
for item_type, content in self.items:
|
||
if item_type == 'table':
|
||
content.adjust_padding(max_width)
|
||
|
||
# Third pass: render each item
|
||
for i, (item_type, content) in enumerate(self.items):
|
||
if item_type == 'text':
|
||
print(content)
|
||
elif item_type == 'title':
|
||
# Title with underline matching canvas width
|
||
underline = "─" * max_width
|
||
print(underline)
|
||
print(Decore.bold(content))
|
||
elif item_type == 'raw':
|
||
# Pre-rendered content (like matrix)
|
||
print(content)
|
||
elif item_type == 'spacing':
|
||
for _ in range(content):
|
||
print()
|
||
elif item_type == 'table':
|
||
content.print()
|
||
|
||
|
||
class Decore():
|
||
@staticmethod
|
||
def decorate(sgr, txt, restore="0"):
|
||
return f"\033[{sgr}m{txt}\033[{restore}m"
|
||
|
||
@staticmethod
|
||
def invert(txt):
|
||
return Decore.decorate("7", txt)
|
||
|
||
@staticmethod
|
||
def bold(txt):
|
||
return Decore.decorate("1", txt)
|
||
|
||
@staticmethod
|
||
def red(txt):
|
||
return Decore.decorate("31", txt, "39")
|
||
|
||
@staticmethod
|
||
def green(txt):
|
||
return Decore.decorate("32", txt, "39")
|
||
|
||
@staticmethod
|
||
def bright_green(txt):
|
||
return Decore.decorate("1;32", txt, "0")
|
||
|
||
@staticmethod
|
||
def yellow(txt):
|
||
return Decore.decorate("33", txt, "39")
|
||
|
||
@staticmethod
|
||
def bold_yellow(txt):
|
||
return Decore.decorate("1;33", txt, "0")
|
||
|
||
@staticmethod
|
||
def flashing_red(txt):
|
||
return Decore.decorate("5;31", txt, "0")
|
||
|
||
@staticmethod
|
||
def underline(txt):
|
||
return Decore.decorate("4", txt, "24")
|
||
|
||
@staticmethod
|
||
def gray_bg(txt):
|
||
return Decore.decorate("100", txt)
|
||
|
||
@staticmethod
|
||
def red_bg(txt):
|
||
return Decore.decorate("41", txt, "49")
|
||
|
||
@staticmethod
|
||
def green_bg(txt):
|
||
return Decore.decorate("42", txt, "49")
|
||
|
||
@staticmethod
|
||
def yellow_bg(txt):
|
||
return Decore.decorate("43", txt, "49")
|
||
|
||
@staticmethod
|
||
def title(txt, width=None, bold=True):
|
||
"""Print section header with horizontal bar line above it
|
||
Args:
|
||
txt: The header text to display
|
||
width: Length of horizontal bar line (defaults to len(txt))
|
||
bold: Whether to make the text bold
|
||
"""
|
||
length = width if width is not None else len(txt)
|
||
underline = "─" * length
|
||
print(underline)
|
||
if bold:
|
||
print(Decore.bold(txt))
|
||
else:
|
||
print(txt)
|
||
|
||
|
||
def signal_to_status(signal):
|
||
if signal >= -50:
|
||
status = Decore.bright_green("excellent")
|
||
elif signal >= -60:
|
||
status = Decore.green("good")
|
||
elif signal >= -70:
|
||
status = Decore.yellow("poor")
|
||
else:
|
||
status = Decore.red("bad")
|
||
|
||
return status
|
||
|
||
|
||
def datetime_now():
|
||
if UNIT_TEST:
|
||
return datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def get_json_data(default, indata, *args):
|
||
data = indata
|
||
for arg in args:
|
||
if arg in data:
|
||
data = data.get(arg)
|
||
else:
|
||
return default
|
||
|
||
return data
|
||
|
||
|
||
def remove_yang_prefix(key):
|
||
parts = key.split(":", 1)
|
||
if len(parts) > 1:
|
||
return parts[1]
|
||
return key
|
||
|
||
|
||
def format_description(label, description, width=60):
|
||
"""Format description text with proper line wrapping"""
|
||
if not description:
|
||
return f"{label:<20}:"
|
||
|
||
lines = textwrap.wrap(description, width=width)
|
||
if not lines:
|
||
return f"{label:<20}:"
|
||
|
||
# First line with label
|
||
result = f"{label:<20}: {lines[0]}"
|
||
# Subsequent lines indented
|
||
for line in lines[1:]:
|
||
result += f"\n{'':<20} {line}"
|
||
|
||
return result
|
||
|
||
|
||
class Date(datetime):
|
||
def _pretty_delta(delta):
|
||
assert(delta.total_seconds() > 0)
|
||
days = delta.days
|
||
seconds = delta.seconds
|
||
hours, remainder = divmod(seconds, 3600)
|
||
minutes, seconds = divmod(remainder, 60)
|
||
|
||
segments = (
|
||
("day", days),
|
||
("hour", hours),
|
||
("minute", minutes),
|
||
("second", seconds),
|
||
)
|
||
|
||
for i, seg in enumerate(segments):
|
||
if not seg[1]:
|
||
continue
|
||
|
||
out = f"{seg[1]} {seg[0]}{'s' if seg[1] != 1 else ''}"
|
||
if seg[0] == "second":
|
||
return out
|
||
|
||
seg = segments[i+1]
|
||
out += f" and {seg[1]} {seg[0]}{'s' if seg[1] != 1 else ''}"
|
||
return out
|
||
|
||
def pretty(self):
|
||
now = datetime_now()
|
||
if self < now:
|
||
delta = Date._pretty_delta(now - self)
|
||
return f"{delta} ago"
|
||
elif self > now:
|
||
delta = Date._pretty_delta(self - now)
|
||
return f"in {delta}"
|
||
|
||
return "now"
|
||
|
||
@classmethod
|
||
def from_yang(cls, ydate):
|
||
"""Conver a YANG formatted date string into a Python datetime"""
|
||
if not ydate:
|
||
return None
|
||
|
||
date, tz = ydate.split("+")
|
||
tz = tz.replace(":", "")
|
||
return cls.strptime(f"{date}+{tz}", "%Y-%m-%dT%H:%M:%S%z")
|
||
|
||
class Route:
|
||
def __init__(self, data, ip):
|
||
self.data = data
|
||
self.ip = ip
|
||
self.prefix = data.get(f'ietf-{ip}-unicast-routing:destination-prefix', '')
|
||
self.protocol = data.get('source-protocol', '').split(':')[-1]
|
||
self.last_updated = data.get('last-updated', '')
|
||
self.active = data.get('active', False)
|
||
self.pref = data.get('route-preference', '')
|
||
self.metric = data.get('ietf-ospf:metric', 0)
|
||
self.next_hop = []
|
||
next_hop_list = get_json_data(None, self.data, 'next-hop', 'next-hop-list')
|
||
if next_hop_list:
|
||
for nh in next_hop_list["next-hop"]:
|
||
if nh.get(f"ietf-{ip}-unicast-routing:address"):
|
||
hop = nh[f"ietf-{ip}-unicast-routing:address"]
|
||
elif nh.get("outgoing-interface"):
|
||
hop = nh["outgoing-interface"]
|
||
else:
|
||
hop = "unspecified"
|
||
|
||
fib = nh.get('infix-routing:installed', False)
|
||
self.next_hop.append((hop, fib))
|
||
else:
|
||
interface = get_json_data(None, self.data, 'next-hop', 'outgoing-interface')
|
||
address = get_json_data(None, self.data, 'next-hop', f'ietf-{ip}-unicast-routing:next-hop-address')
|
||
special = get_json_data(None, self.data, 'next-hop', 'special-next-hop')
|
||
|
||
if address:
|
||
self.next_hop.append(address)
|
||
elif interface:
|
||
self.next_hop.append(interface)
|
||
elif special:
|
||
self.next_hop.append(special)
|
||
else:
|
||
self.next_hop.append("unspecified")
|
||
|
||
def get_distance_and_metric(self):
|
||
if isinstance(self.pref, int):
|
||
distance = self.pref
|
||
metric = self.metric
|
||
else:
|
||
distance, metric = 0, 0
|
||
|
||
return distance, metric
|
||
|
||
def datetime2uptime(self):
|
||
"""Convert 'last-updated' string to uptime in AAhBBmCCs format."""
|
||
ONE_DAY_SECOND = 60 * 60 * 24
|
||
ONE_WEEK_SECOND = ONE_DAY_SECOND * 7
|
||
|
||
if not self.last_updated:
|
||
return "0h0m0s"
|
||
|
||
# Replace the colon in the timezone offset (e.g., +00:00 -> +0000)
|
||
pos = self.last_updated.rfind('+')
|
||
if pos != -1:
|
||
adjusted = self.last_updated[:pos] + self.last_updated[pos:].replace(':', '')
|
||
else:
|
||
adjusted = self.last_updated
|
||
|
||
last_updated = datetime.strptime(adjusted, '%Y-%m-%dT%H:%M:%S%z')
|
||
current_time = datetime_now()
|
||
uptime_delta = current_time - last_updated
|
||
|
||
total_seconds = int(uptime_delta.total_seconds())
|
||
total_days = total_seconds // ONE_DAY_SECOND
|
||
total_weeks = total_days // 7
|
||
|
||
hours = (total_seconds % ONE_DAY_SECOND) // 3600
|
||
minutes = (total_seconds % 3600) // 60
|
||
seconds = total_seconds % 60
|
||
|
||
if total_seconds < ONE_DAY_SECOND:
|
||
return f"{hours:02}:{minutes:02}:{seconds:02}"
|
||
elif total_seconds < ONE_WEEK_SECOND:
|
||
return f"{total_days}d{hours:02}h{minutes:02}m"
|
||
else:
|
||
days_remaining = total_days % 7
|
||
return f"{total_weeks:02}w{days_remaining}d{hours:02}h"
|
||
|
||
def print(self):
|
||
PadRoute.set(self.ip)
|
||
distance, metric = self.get_distance_and_metric()
|
||
uptime = self.datetime2uptime()
|
||
pref = f"{distance}/{metric}"
|
||
hop, fib = self.next_hop[0]
|
||
|
||
row = ">" if self.active else " "
|
||
row += "*" if fib else " "
|
||
row += " "
|
||
row += f"{self.prefix:<{PadRoute.dest}} "
|
||
row += f"{pref:>{PadRoute.pref}} "
|
||
row += f"{hop:<{PadRoute.next_hop}} "
|
||
row += f"{self.protocol:<{PadRoute.protocol}} "
|
||
row += f"{uptime:>{PadRoute.uptime}}"
|
||
print(row)
|
||
for nh in self.next_hop[1:]:
|
||
hop, fib = nh
|
||
row = " "
|
||
row += "*" if fib else " "
|
||
row += " "
|
||
row += f"{'':<{PadRoute.dest}} "
|
||
row += f"{'':>{PadRoute.pref}} "
|
||
row += f"{hop:<{PadRoute.next_hop}} "
|
||
print(row)
|
||
|
||
|
||
class Software:
|
||
"""Software bundle class """
|
||
def __init__(self, data):
|
||
self.data = data
|
||
self.name = data.get('bootname', '')
|
||
self.size = data.get('size', '')
|
||
self.type = data.get('class', '')
|
||
self.hash = data.get('sha256', '')
|
||
self.state = data.get('state', '')
|
||
self.version = get_json_data('', self.data, 'bundle', 'version')
|
||
self.date = get_json_data('', self.data, 'installed', 'datetime')
|
||
|
||
def is_rootfs(self):
|
||
"""True if bundle type is 'rootfs'"""
|
||
return self.type == "rootfs"
|
||
|
||
def print(self):
|
||
"""Brief information about one bundle"""
|
||
row = f"{self.name:<{PadSoftware.name}}"
|
||
row += f"{self.state:<{PadSoftware.state}}"
|
||
row += f"{self.version:<{PadSoftware.version}}"
|
||
row += f"{self.date:<{PadSoftware.date}}"
|
||
print(row)
|
||
|
||
def detail(self):
|
||
"""Detailed information about one bundle"""
|
||
print(f"Name : {self.name}")
|
||
print(f"State : {self.state}")
|
||
print(f"Version : {self.version}")
|
||
print(f"Size : {self.size}")
|
||
print(f"SHA-256 : {self.hash}")
|
||
print(f"Installed : {self.date}")
|
||
|
||
|
||
class USBport:
|
||
def __init__(self, data):
|
||
self.data = data
|
||
self.name = data.get('name', '')
|
||
self.state = get_json_data('', self.data, 'state', 'admin-state')
|
||
self.oper = get_json_data('', self.data, 'state', 'oper-state')
|
||
|
||
|
||
class Sensor:
|
||
def __init__(self, data):
|
||
self.data = data
|
||
self.name = data.get('name', 'unknown')
|
||
self.description = data.get('description') # Human-readable description
|
||
self.parent = data.get('parent') # Parent component name
|
||
sensor_data = data.get('sensor-data', {})
|
||
self.value_type = sensor_data.get('value-type', 'unknown')
|
||
self.value = sensor_data.get('value', 0)
|
||
self.value_scale = sensor_data.get('value-scale', 'units')
|
||
self.oper_status = sensor_data.get('oper-status', 'unknown')
|
||
|
||
def get_formatted_value(self):
|
||
"""Convert sensor value based on scale and type"""
|
||
# Handle temperature sensors
|
||
if self.value_type == 'celsius':
|
||
if self.value_scale == 'milli':
|
||
temp_celsius = self.value / 1000.0
|
||
# Color code based on temperature thresholds
|
||
if temp_celsius < 60:
|
||
return Decore.green(f"{temp_celsius:.1f} °C")
|
||
elif temp_celsius < 75:
|
||
return Decore.yellow(f"{temp_celsius:.1f} °C")
|
||
else:
|
||
return Decore.red(f"{temp_celsius:.1f} °C")
|
||
else:
|
||
return f"{self.value} °C"
|
||
|
||
# Handle fan speed sensors
|
||
elif self.value_type == 'rpm':
|
||
return f"{self.value} RPM"
|
||
|
||
# Handle voltage sensors
|
||
elif self.value_type == 'volts-DC':
|
||
if self.value_scale == 'milli':
|
||
volts = self.value / 1000.0
|
||
return f"{volts:.2f} VDC"
|
||
else:
|
||
return f"{self.value} VDC"
|
||
|
||
# Handle current sensors
|
||
elif self.value_type == 'amperes':
|
||
if self.value_scale == 'milli':
|
||
amps = self.value / 1000.0
|
||
return f"{amps:.3f} A"
|
||
else:
|
||
return f"{self.value} A"
|
||
|
||
# Handle power sensors
|
||
elif self.value_type == 'watts':
|
||
if self.value_scale == 'micro':
|
||
watts = self.value / 1000000.0
|
||
return f"{watts:.3f} W"
|
||
elif self.value_scale == 'milli':
|
||
watts = self.value / 1000.0
|
||
return f"{watts:.2f} W"
|
||
else:
|
||
return f"{self.value} W"
|
||
|
||
# Handle PWM fan sensors (reported as "other" type with milli scale)
|
||
# PWM duty cycle is reported as percentage (0-100)
|
||
elif self.value_type == 'other' and self.value_scale == 'milli':
|
||
# Check if this is likely a PWM sensor based on description or name
|
||
name_lower = self.name.lower()
|
||
desc_lower = (self.description or "").lower()
|
||
if 'pwm' in desc_lower or 'fan' in name_lower or 'fan' in desc_lower:
|
||
percent = self.value / 1000.0
|
||
return f"{percent:.1f}%"
|
||
# Fall through for other "other" type sensors
|
||
|
||
# For unknown sensor types, show raw value
|
||
if self.value_type in ['other', 'unknown']:
|
||
return f"{self.value}"
|
||
else:
|
||
return f"{self.value} {self.value_type}"
|
||
|
||
def print(self, indent=0):
|
||
import re
|
||
# Add indentation for child sensors
|
||
indent_str = " " * indent
|
||
|
||
# Determine display name: prefer description, fallback to name
|
||
if self.description:
|
||
# Use description if available (e.g., "WiFi Radio wlan0 (2.4 GHz)")
|
||
display_name = self.description
|
||
elif indent > 0 and self.parent:
|
||
# Child sensor: strip parent prefix from name
|
||
# "sfp1-RX_power" -> "RX_power" -> "Rx Power"
|
||
display_name = self.name
|
||
if display_name.startswith(self.parent + "-"):
|
||
display_name = display_name[len(self.parent) + 1:]
|
||
# Format: "RX_power" -> "Rx Power"
|
||
display_name = display_name.replace('_', ' ').replace('-', ' ').title()
|
||
else:
|
||
# Standalone sensor without description: use name as-is
|
||
display_name = self.name
|
||
|
||
row = f"{indent_str}{display_name:<{PadSensor.name - len(indent_str)}}"
|
||
# For colored value, pad manually to account for ANSI codes
|
||
value_str = self.get_formatted_value()
|
||
# Count visible characters (strip ANSI codes for length calculation)
|
||
visible_len = len(re.sub(r'\x1b\[[0-9;]*m', '', value_str))
|
||
padding = PadSensor.value - visible_len
|
||
row += value_str + (' ' * padding)
|
||
row += f"{self.oper_status:<{PadSensor.status}}"
|
||
print(row)
|
||
|
||
|
||
class STPBridgeID:
|
||
def __init__(self, id):
|
||
self.id = id
|
||
|
||
def __str__(self):
|
||
prio, sysid, addr = (
|
||
self.id["priority"],
|
||
self.id["system-id"],
|
||
self.id["address"]
|
||
)
|
||
return f"{prio:1x}.{sysid:03x}.{addr}"
|
||
|
||
|
||
class STPPortID:
|
||
def __init__(self, id):
|
||
self.id = id
|
||
|
||
def __str__(self):
|
||
prio, pid = (
|
||
self.id["priority"],
|
||
self.id["port-id"],
|
||
)
|
||
return f"{prio:1x}.{pid:03x}"
|
||
|
||
|
||
class DhcpServer:
|
||
def __init__(self, data):
|
||
self.data = data
|
||
self.leases = []
|
||
now = datetime.now(timezone.utc)
|
||
for lease in get_json_data([], self.data, 'leases', 'lease'):
|
||
if lease["expires"] == "never":
|
||
exp = "never"
|
||
else:
|
||
dt = datetime.strptime(lease['expires'], '%Y-%m-%dT%H:%M:%S%z')
|
||
seconds = int((dt - now).total_seconds())
|
||
exp = self.format_duration(seconds)
|
||
self.leases.append({
|
||
"ip": lease["address"],
|
||
"mac": lease["phys-address"],
|
||
"cid": lease["client-id"],
|
||
"host": lease["hostname"],
|
||
"exp": exp
|
||
})
|
||
|
||
stats = get_json_data([], self.data, 'statistics')
|
||
self.out_offers = stats["out-offers"]
|
||
self.out_acks = stats["out-acks"]
|
||
self.out_naks = stats["out-naks"]
|
||
self.in_declines = stats["in-declines"]
|
||
self.in_discovers = stats["in-discovers"]
|
||
self.in_requests = stats["in-requests"]
|
||
self.in_releases = stats["in-releases"]
|
||
self.in_informs = stats["in-informs"]
|
||
|
||
def format_duration(self, seconds):
|
||
"""Convert seconds to DDdHHhMMmSSs format, omitting zero values"""
|
||
if seconds < 0:
|
||
return "expired"
|
||
|
||
days, remainder = divmod(seconds, 86400)
|
||
hours, remainder = divmod(remainder, 3600)
|
||
minutes, seconds = divmod(remainder, 60)
|
||
|
||
parts = []
|
||
if days:
|
||
parts.append(f"{days}d")
|
||
if hours:
|
||
parts.append(f"{hours}h")
|
||
if minutes:
|
||
parts.append(f"{minutes}m")
|
||
if seconds or not parts:
|
||
parts.append(f"{seconds}s")
|
||
|
||
return "".join(parts)
|
||
|
||
def print(self):
|
||
for lease in self.leases:
|
||
ip = lease["ip"]
|
||
mac = lease["mac"]
|
||
cid = lease["cid"]
|
||
exp = lease['exp']
|
||
host = lease["host"][:20]
|
||
row = f"{ip:<{PadDhcpServer.ip}}"
|
||
row += f"{mac:<{PadDhcpServer.mac}}"
|
||
row += f"{host:<{PadDhcpServer.host}}"
|
||
row += f"{cid:<{PadDhcpServer.cid}}"
|
||
row += f"{exp:>{PadDhcpServer.exp}}"
|
||
print(row)
|
||
|
||
def print_stats(self):
|
||
print(f"{'DHCP offers sent':<{32}}: {self.out_offers}")
|
||
print(f"{'DHCP ACK messages sent':<{32}}: {self.out_acks}")
|
||
print(f"{'DHCP NAK messages sent':<{32}}: {self.out_naks}")
|
||
print(f"{'DHCP decline messages received':<{32}}: {self.in_declines}")
|
||
print(f"{'DHCP discover messages received':<{32}}: {self.in_discovers}")
|
||
print(f"{'DHCP request messages received':<{32}}: {self.in_requests}")
|
||
print(f"{'DHCP release messages received':<{32}}: {self.in_discovers}")
|
||
print(f"{'DHCP inform messages received':<{32}}: {self.in_discovers}")
|
||
|
||
|
||
class Iface:
|
||
# Class variable to hold routing-enabled interfaces for the current display session
|
||
_routing_ifaces = set()
|
||
_keystore = {}
|
||
|
||
def __init__(self, data):
|
||
self.data = data
|
||
self.name = data.get('name', '')
|
||
self.type = data.get('type', '')
|
||
self.index = data.get('if-index', '')
|
||
self.oper_status = data.get('oper-status', '')
|
||
self.autoneg = get_json_data('unknown', self.data, 'ieee802-ethernet-interface:ethernet',
|
||
'auto-negotiation', 'enable')
|
||
self.duplex = get_json_data('', self.data,'ieee802-ethernet-interface:ethernet','duplex')
|
||
self.speed = get_json_data('', self.data, 'speed')
|
||
self.pmd_type = get_json_data('', self.data, 'ieee802-ethernet-interface:ethernet', 'pmd-type')
|
||
self.phy_type = get_json_data('', self.data, 'ieee802-ethernet-interface:ethernet', 'phy-type')
|
||
self.advertised = get_json_data([], self.data, 'ieee802-ethernet-interface:ethernet',
|
||
'auto-negotiation',
|
||
'infix-ethernet-interface:advertised-pmd-types')
|
||
self.supported = get_json_data([], self.data,
|
||
'ieee802-ethernet-interface:ethernet',
|
||
'infix-ethernet-interface:supported-pmd-types')
|
||
self.phys_address = data.get('phys-address', '')
|
||
|
||
self.br_mdb = get_json_data({}, self.data, 'infix-interfaces:bridge', 'multicast-filters')
|
||
self.br_vlans = get_json_data({}, self.data, 'infix-interfaces:bridge', 'vlans', "vlan")
|
||
self.bridge = get_json_data('', self.data, 'infix-interfaces:bridge-port', 'bridge')
|
||
self.pvid = get_json_data('', self.data, 'infix-interfaces:bridge-port', 'pvid')
|
||
self.stp_state = get_json_data('', self.data, 'infix-interfaces:bridge-port',
|
||
'stp', 'cist', 'state')
|
||
|
||
self.lag_mode = get_json_data('', self.data, 'infix-interfaces:lag', 'mode')
|
||
if self.lag_mode:
|
||
self.lag_type = get_json_data('', self.data, 'infix-interfaces:lag', 'static', 'mode')
|
||
if self.lag_mode == "lacp":
|
||
self.lag_hash = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'hash')
|
||
self.lacp_id = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'aggregator-id')
|
||
self.lacp_actor_key = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'actor-key')
|
||
self.lacp_partner_key = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'partner-key')
|
||
self.lacp_partner_mac = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'partner-mac')
|
||
self.lacp_sys_prio = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'system-priority')
|
||
else:
|
||
self.lag_hash = get_json_data('', self.data, 'infix-interfaces:lag', 'static', 'hash')
|
||
self.link_updelay = get_json_data('', self.data, 'infix-interfaces:lag', 'link-monitor', 'debounce', 'up')
|
||
self.link_downdelay = get_json_data('', self.data, 'infix-interfaces:lag', 'link-monitor', 'debounce', 'down')
|
||
|
||
self.lacp_mode = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'mode')
|
||
rate = get_json_data('', self.data, 'infix-interfaces:lag', 'lacp', 'rate')
|
||
self.lacp_rate = "fast (1s)" if rate == "fast" else "slow (30 sec)"
|
||
|
||
self.lag = get_json_data('', self.data, 'infix-interfaces:lag-port', 'lag')
|
||
if self.lag:
|
||
self.lag_state = get_json_data('', self.data, 'infix-interfaces:lag-port', 'state')
|
||
self.lacp_id = get_json_data('', self.data, 'infix-interfaces:lag-port', 'lacp', 'aggregator-id')
|
||
self.lacp_state = get_json_data('', self.data, 'infix-interfaces:lag-port', 'lacp', 'actor-state')
|
||
self.lacp_pstate = get_json_data('', self.data, 'infix-interfaces:lag-port', 'lacp', 'partner-state')
|
||
self.link_failures = get_json_data('', self.data, 'infix-interfaces:lag-port', 'link-failures')
|
||
|
||
self.containers = get_json_data('', self.data, 'infix-interfaces:container-network', 'containers')
|
||
|
||
|
||
if data.get('statistics'):
|
||
self.in_octets = data.get('statistics').get('in-octets', '')
|
||
self.out_octets = data.get('statistics').get('out-octets', '')
|
||
else:
|
||
self.in_octets = ''
|
||
self.out_octets = ''
|
||
|
||
if self.data.get('ietf-ip:ipv4'):
|
||
self.mtu = self.data.get('ietf-ip:ipv4').get('mtu', '')
|
||
self.ipv4_addr = self.data.get('ietf-ip:ipv4').get('address', '')
|
||
else:
|
||
self.mtu = ''
|
||
self.ipv4_addr = []
|
||
|
||
if self.data.get('ietf-ip:ipv6'):
|
||
self.ipv6_addr = self.data.get('ietf-ip:ipv6').get('address', '')
|
||
else:
|
||
self.ipv6_addr = []
|
||
|
||
self.gre = self.data.get('infix-interfaces:gre')
|
||
self.vxlan = self.data.get('infix-interfaces:vxlan')
|
||
self.wifi = self.data.get('infix-interfaces:wifi')
|
||
self.wireguard = self.data.get('infix-interfaces:wireguard')
|
||
|
||
if self.data.get('infix-interfaces:vlan'):
|
||
vlan_data = self.data.get('infix-interfaces:vlan')
|
||
self.lower_if = vlan_data.get('lower-layer-if', None)
|
||
self.vid = vlan_data.get('id', None)
|
||
else:
|
||
self.lower_if = ''
|
||
self.vid = None
|
||
|
||
def is_wifi(self):
|
||
return self.type == "infix-if-type:wifi"
|
||
|
||
def is_vlan(self):
|
||
return self.type == "infix-if-type:vlan"
|
||
|
||
def is_in_container(self):
|
||
# Return negative if cointainer isn't set or is an empty list
|
||
return getattr(self, 'containers', None)
|
||
|
||
def is_bridge(self):
|
||
return self.type == "infix-if-type:bridge"
|
||
|
||
def is_lag(self):
|
||
return self.type == "infix-if-type:lag"
|
||
|
||
def is_veth(self):
|
||
return self.data['type'] == "infix-if-type:veth"
|
||
|
||
def is_vxlan(self):
|
||
return self.data['type'] == "infix-if-type:vxlan"
|
||
|
||
def is_gre(self):
|
||
return self.data['type'] == "infix-if-type:gre"
|
||
|
||
def is_gretap(self):
|
||
return self.data['type'] == "infix-if-type:gretap"
|
||
|
||
def is_wireguard(self):
|
||
return self.data['type'] == "infix-if-type:wireguard"
|
||
|
||
def oper(self, detail=False):
|
||
"""Remap in brief overview to fit column widths."""
|
||
if not detail and self.oper_status == "lower-layer-down":
|
||
return "lower-down"
|
||
return self.oper_status
|
||
|
||
def pr_name(self, pipe=""):
|
||
flag = "⇅" if self.name in Iface._routing_ifaces else " "
|
||
print(f"{flag:<{Pad.flags}}", end="")
|
||
print(f"{pipe}{self.name:<{Pad.iface - len(pipe)}}", end="")
|
||
|
||
def pr_proto_ipv4(self, pipe=''):
|
||
for addr in self.ipv4_addr:
|
||
origin = f"({addr['origin']})" if addr.get('origin') else ""
|
||
|
||
row = f"{'':<{Pad.flags}}"
|
||
row += f"{pipe:<{Pad.iface}}"
|
||
row += f"{'ipv4':<{Pad.proto}}"
|
||
row += f"{'':<{Pad.state}}{addr['ip']}/{addr['prefix-length']} {origin}"
|
||
print(row)
|
||
|
||
def pr_proto_ipv6(self, pipe=''):
|
||
for addr in self.ipv6_addr:
|
||
origin = f"({addr['origin']})" if addr.get('origin') else ""
|
||
|
||
row = f"{'':<{Pad.flags}}"
|
||
row += f"{pipe:<{Pad.iface}}"
|
||
row += f"{'ipv6':<{Pad.proto}}"
|
||
row += f"{'':<{Pad.state}}{addr['ip']}/{addr['prefix-length']} {origin}"
|
||
print(row)
|
||
|
||
@staticmethod
|
||
def _pr_label_list(label, values):
|
||
"""Print label-prefixed values, one per row.
|
||
|
||
First row carries the label and a colon; later rows are blank-
|
||
prefixed continuations. When 'values' is empty, prints just the
|
||
label and a trailing colon — matching how 'ipv4 addresses' has
|
||
always been rendered when the interface has no IPs.
|
||
"""
|
||
if not values:
|
||
print(f"{label:<{19}}:")
|
||
return
|
||
first = True
|
||
for value in values:
|
||
key = label if first else ''
|
||
colon = ':' if first else ' '
|
||
print(f"{key:<{19}}{colon} {value}")
|
||
first = False
|
||
|
||
@staticmethod
|
||
def _pmd_label(identity):
|
||
"""Render an IEEE pmd-type / phy-type identityref as a display string.
|
||
|
||
Strips the module/leaf prefix and rewrites the IEEE 'BASE-' literal
|
||
to the lowercase 'base' the rest of the codebase uses (matching
|
||
ethtool's link-mode string format). Returns empty for empty input.
|
||
"""
|
||
if not identity:
|
||
return ""
|
||
return identity.removeprefix(
|
||
"ieee802-ethernet-phy-type:pmd-type-"
|
||
).removeprefix(
|
||
"ieee802-ethernet-phy-type:phy-type-"
|
||
).replace("BASE-", "base")
|
||
|
||
def _phy_label(self):
|
||
"""Display label for the active link.
|
||
|
||
Prefers pmd-type (specific medium, e.g. '10GbaseLR') over phy-type
|
||
(family-level, e.g. '10GbaseR') when both are populated. Empty
|
||
when neither is reported — non-ethernet interfaces don't have these
|
||
leaves, and yanger skips them for ethernet ports with no link or
|
||
unmapped (port, speed, duplex) tuple.
|
||
"""
|
||
return self._pmd_label(self.pmd_type or self.phy_type)
|
||
|
||
def _pr_proto_common(self, name, pipe='', data='', show_state=True):
|
||
# pipe="" means this row follows pr_name() on the same line, so col 1-2
|
||
# are already on screen. A non-empty pipe (" ", "│", "└ ") prints col
|
||
# 1-2 with that marker. show_state=False blanks the STATE column for
|
||
# sub-rows whose state was already reflected in the row above.
|
||
row = ""
|
||
if len(pipe) > 0:
|
||
row = f"{'':<{Pad.flags}}"
|
||
row += f"{pipe:<{Pad.iface}}"
|
||
|
||
row += f"{name:<{Pad.proto}}"
|
||
if show_state:
|
||
dec = Decore.green if self.oper() == "up" else Decore.red
|
||
row += dec(f"{self.oper().upper():<{Pad.state}}")
|
||
else:
|
||
row += f"{'':<{Pad.state}}"
|
||
if data:
|
||
row += f"{data:<{Pad.data}}"
|
||
return row
|
||
|
||
def _pr_phy_row(self, pipe=''):
|
||
"""Emit the physical-medium row when applicable. Return True if printed.
|
||
|
||
Skipped when pmd-type is unknown or the link is not up — keeping the
|
||
common 'boring port' cases quiet and falling back to the ethernet row.
|
||
"""
|
||
label = self._phy_label()
|
||
if not label or self.oper() != "up":
|
||
return False
|
||
data = f"duplex: {self.duplex}" if self.duplex else ""
|
||
print(self._pr_proto_common(label, pipe, data))
|
||
return True
|
||
|
||
def pr_proto_eth(self, pipe=''):
|
||
print(self._pr_proto_common("ethernet", pipe, self.phys_address or ""))
|
||
|
||
def pr_proto_eth_subrow(self, pipe=' '):
|
||
"""Ethernet row beneath another protocol row: bare MAC, no STATE column."""
|
||
print(self._pr_proto_common("ethernet", pipe, self.phys_address or "",
|
||
show_state=False))
|
||
|
||
def pr_proto_veth(self, pipe=''):
|
||
parts = []
|
||
if self.phys_address:
|
||
parts.append(self.phys_address)
|
||
if self.lower_if:
|
||
parts.append(f"peer: {self.lower_if}")
|
||
print(self._pr_proto_common("veth", pipe, " ".join(parts)))
|
||
|
||
def pr_proto_gre(self, pipe=''):
|
||
data = ""
|
||
if self.gre and (remote := self.gre.get('remote')):
|
||
data = f"remote: {remote}"
|
||
print(self._pr_proto_common("gre", pipe, data))
|
||
|
||
def pr_proto_vxlan(self, pipe=''):
|
||
parts = []
|
||
if self.vxlan:
|
||
if (vni := self.vxlan.get('vni')) is not None:
|
||
parts.append(f"vni: {vni}")
|
||
if remote := self.vxlan.get('remote'):
|
||
parts.append(f"remote: {remote}")
|
||
print(self._pr_proto_common("vxlan", pipe, " ".join(parts)))
|
||
|
||
def pr_proto_wireguard(self, pipe=''):
|
||
data = ""
|
||
if self.wireguard:
|
||
peer_status = self.wireguard.get('peer-status', {})
|
||
peers = peer_status.get('peer', [])
|
||
total_peers = len(peers)
|
||
up_peers = sum(1 for p in peers if p.get('connection-status') == 'up')
|
||
if total_peers > 0:
|
||
plural = "s" if total_peers != 1 else ""
|
||
data = f"{total_peers} peer{plural} ({up_peers} up)"
|
||
print(self._pr_proto_common("wireguard", pipe, data))
|
||
|
||
def pr_proto_loopack(self, pipe=''):
|
||
print(self._pr_proto_common("loopback", pipe))
|
||
|
||
def pr_wifi_ssids(self):
|
||
width = 70
|
||
Decore.title("Available Networks", width)
|
||
ssid_table = SimpleTable([
|
||
Column('SSID', flexible=True),
|
||
Column('BSSID'),
|
||
Column('SECURITY'),
|
||
Column('SIGNAL'),
|
||
Column('CHANNEL', 'right')
|
||
], min_width=width)
|
||
|
||
station = self.wifi.get("station", {})
|
||
results = station.get("scan-results", {})
|
||
for result in results:
|
||
ssid = result.get('ssid', 'Hidden')
|
||
bssid = result.get("bssid", "unknown")
|
||
encstr = ", ".join(result.get("encryption", ["Unknown"]))
|
||
status = signal_to_status(result.get("signal-strength", -100))
|
||
channel = result.get("channel", "?")
|
||
|
||
ssid_table.row(ssid, bssid, encstr, status, channel)
|
||
ssid_table.print()
|
||
|
||
def pr_wifi_stations(self):
|
||
"""Display connected stations for AP mode"""
|
||
if not self.wifi:
|
||
return
|
||
|
||
# Get stations from access-point container
|
||
ap = self.wifi.get("access-point", {})
|
||
stations_data = ap.get("stations", {})
|
||
stations = stations_data.get("station", [])
|
||
|
||
if not stations:
|
||
return
|
||
|
||
print("\nCONNECTED STATIONS:")
|
||
stations_table = SimpleTable([
|
||
Column('MAC'),
|
||
Column('SIGNAL'),
|
||
Column('TIME'),
|
||
Column('RX PKT'),
|
||
Column('TX PKT'),
|
||
Column('RX BYTES'),
|
||
Column('TX BYTES'),
|
||
Column('RX SPEED'),
|
||
Column('TX SPEED')
|
||
])
|
||
|
||
for station in stations:
|
||
mac = station.get("mac-address", "unknown")
|
||
signal = station.get("signal-strength")
|
||
signal_str = signal_to_status(signal) if signal is not None else "------"
|
||
|
||
conn_time = station.get("connected-time", 0)
|
||
time_str = f"{conn_time}s"
|
||
|
||
rx_pkt = station.get("rx-packets", 0)
|
||
tx_pkt = station.get("tx-packets", 0)
|
||
rx_bytes = station.get("rx-bytes", 0)
|
||
tx_bytes = station.get("tx-bytes", 0)
|
||
|
||
# Speed in 100 kbps units, convert to Mbps for display
|
||
rx_speed = station.get("rx-speed", 0)
|
||
tx_speed = station.get("tx-speed", 0)
|
||
rx_speed_str = f"{rx_speed / 10:.1f}" if rx_speed else "-"
|
||
tx_speed_str = f"{tx_speed / 10:.1f}" if tx_speed else "-"
|
||
|
||
stations_table.row(mac, signal_str, time_str, rx_pkt, tx_pkt,
|
||
rx_bytes, tx_bytes, rx_speed_str, tx_speed_str)
|
||
|
||
stations_table.print()
|
||
|
||
def pr_wifi_peers(self):
|
||
if not self.wifi:
|
||
return
|
||
|
||
mesh = self.wifi.get("mesh-point", {})
|
||
peers_data = mesh.get("peers", {})
|
||
peers = peers_data.get("peer", [])
|
||
|
||
if not peers:
|
||
return
|
||
|
||
print("\nCONNECTED PEERS:")
|
||
peers_table = SimpleTable([
|
||
Column('MAC'),
|
||
Column('SIGNAL'),
|
||
Column('TIME'),
|
||
Column('RX PKT'),
|
||
Column('TX PKT'),
|
||
Column('RX BYTES'),
|
||
Column('TX BYTES'),
|
||
Column('RX SPEED'),
|
||
Column('TX SPEED')
|
||
])
|
||
|
||
for peer in peers:
|
||
mac = peer.get("mac-address", "unknown")
|
||
signal = peer.get("signal-strength")
|
||
signal_str = signal_to_status(signal) if signal is not None else "------"
|
||
|
||
conn_time = peer.get("connected-time", 0)
|
||
time_str = f"{conn_time}s"
|
||
|
||
rx_pkt = peer.get("rx-packets", 0)
|
||
tx_pkt = peer.get("tx-packets", 0)
|
||
rx_bytes = peer.get("rx-bytes", 0)
|
||
tx_bytes = peer.get("tx-bytes", 0)
|
||
|
||
rx_speed = peer.get("rx-speed", 0)
|
||
tx_speed = peer.get("tx-speed", 0)
|
||
rx_speed_str = f"{rx_speed / 10:.1f}" if rx_speed else "-"
|
||
tx_speed_str = f"{tx_speed / 10:.1f}" if tx_speed else "-"
|
||
|
||
peers_table.row(mac, signal_str, time_str, rx_pkt, tx_pkt,
|
||
rx_bytes, tx_bytes, rx_speed_str, tx_speed_str)
|
||
|
||
peers_table.print()
|
||
|
||
|
||
def pr_proto_wifi(self, pipe=''):
|
||
ssid = None
|
||
signal = None
|
||
mode = None
|
||
|
||
if self.wifi:
|
||
if "access-point" in self.wifi:
|
||
ap = self.wifi["access-point"]
|
||
ssid = ap.get("ssid", "------")
|
||
mode = "AP"
|
||
stations_data = ap.get("stations", {})
|
||
stations = stations_data.get("station", [])
|
||
station_count = len(stations)
|
||
data_str = f"{mode}, ssid: {ssid}, stations: {station_count}"
|
||
elif "mesh-point" in self.wifi:
|
||
mesh = self.wifi["mesh-point"]
|
||
mesh_id = mesh.get("mesh-id", "------")
|
||
mode = "Mesh"
|
||
peers_data = mesh.get("peers", {})
|
||
peers = peers_data.get("peer", [])
|
||
data_str = f"{mode}, mesh-id: {mesh_id}, peers: {len(peers)}"
|
||
else:
|
||
station=self.wifi.get("station", {})
|
||
ssid = station.get("ssid", "------")
|
||
signal = station.get("signal-strength")
|
||
mode = "Station"
|
||
if signal is not None:
|
||
signal_str = signal_to_status(signal)
|
||
data_str = f"{mode}, ssid: {ssid}, signal: {signal_str}"
|
||
else:
|
||
data_str = f"{mode}, ssid: {ssid}"
|
||
else:
|
||
data_str = "ssid: ------"
|
||
|
||
print(self._pr_proto_common("wifi", pipe, data_str))
|
||
|
||
def pr_proto_br(self, br_vlans):
|
||
data_str = ""
|
||
|
||
row = f"{'bridge':<{Pad.proto}}"
|
||
|
||
if self.oper() == "up":
|
||
dec = Decore.green if self.stp_state == "forwarding" else Decore.yellow
|
||
row += dec(f"{self.stp_state.upper():<{Pad.state}}")
|
||
else:
|
||
row += Decore.red(f"{self.oper().upper():<{Pad.state}}")
|
||
|
||
vlans = []
|
||
for vlan in br_vlans:
|
||
if 'tagged' in vlan and self.name in vlan['tagged']:
|
||
vlans.append(f"{vlan['vid']}t")
|
||
if 'untagged' in vlan and self.name in vlan['untagged']:
|
||
vlans.append(f"{vlan['vid']}u")
|
||
tokens = []
|
||
if vlans:
|
||
tokens.append(f"vlan: {','.join(vlans)}")
|
||
if self.pvid:
|
||
tokens.append(f"pvid: {self.pvid}")
|
||
data_str = " ".join(tokens)
|
||
|
||
if data_str:
|
||
row += f"{data_str:<{Pad.data}}"
|
||
|
||
print(row)
|
||
|
||
def pr_bridge(self, _ifaces):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_br(self.br_vlans)
|
||
|
||
lowers = []
|
||
for _iface in [Iface(data) for data in _ifaces]:
|
||
if _iface.bridge and _iface.bridge == self.name:
|
||
lowers.append(_iface)
|
||
|
||
if lowers:
|
||
self.pr_proto_eth_subrow(pipe='│')
|
||
self.pr_proto_ipv4(pipe='│')
|
||
self.pr_proto_ipv6(pipe='│')
|
||
else:
|
||
self.pr_proto_eth_subrow()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
for i, lower in enumerate(lowers):
|
||
pipe = '└ ' if (i == len(lowers) -1) else '├ '
|
||
lower.pr_name(pipe)
|
||
lower.pr_proto_br(self.br_vlans)
|
||
|
||
def pr_loopback(self):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_loopack()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
def pr_proto_lag(self, member=True):
|
||
data_str = ""
|
||
|
||
row = f"{'lag':<{Pad.proto}}"
|
||
if member:
|
||
state = self.lag_state.upper()
|
||
if self.oper() == "up":
|
||
row += Decore.green(f"{state:<{Pad.state}}")
|
||
else:
|
||
row += Decore.yellow(f"{state:<{Pad.state}}")
|
||
if self.lacp_state:
|
||
lacp = ', '.join(self.lacp_state)
|
||
data_str += lacp
|
||
else:
|
||
dec = Decore.green if self.oper() == "up" else Decore.yellow
|
||
row += dec(f"{self.oper().upper():<{Pad.state}}")
|
||
data_str += f"{self.lag_mode}"
|
||
if self.lag_mode == "lacp":
|
||
data_str += f": {self.lacp_mode}"
|
||
data_str += f", rate: {self.lacp_rate}"
|
||
data_str += f", hash: {self.lag_hash}"
|
||
else:
|
||
data_str += f": {self.lag_type}"
|
||
data_str += f", hash: {self.lag_hash}"
|
||
|
||
if data_str:
|
||
row += f"{data_str:<{Pad.data}}"
|
||
|
||
print(row)
|
||
|
||
def pr_lag(self, _ifaces):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_lag(member=False)
|
||
|
||
lowers = []
|
||
for _iface in [Iface(data) for data in _ifaces]:
|
||
if _iface.lag and _iface.lag == self.name:
|
||
lowers.append(_iface)
|
||
|
||
if lowers:
|
||
self.pr_proto_eth_subrow(pipe='│')
|
||
self.pr_proto_ipv4(pipe='│')
|
||
self.pr_proto_ipv6(pipe='│')
|
||
else:
|
||
self.pr_proto_eth_subrow()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
for i, lower in enumerate(lowers):
|
||
pipe = '└ ' if (i == len(lowers) -1) else '├ '
|
||
lower.pr_name(pipe)
|
||
lower.pr_proto_lag()
|
||
|
||
def pr_veth(self):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_veth()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
def pr_gre(self):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_gre()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
def pr_gretap(self):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_gre()
|
||
self.pr_proto_eth_subrow()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
def pr_vxlan(self):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_vxlan()
|
||
self.pr_proto_eth_subrow()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
def pr_wireguard(self):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_wireguard()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
def pr_wifi(self):
|
||
self.pr_name(pipe="")
|
||
self.pr_proto_wifi()
|
||
self.pr_proto_eth_subrow()
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
|
||
def pr_vlan(self, _ifaces):
|
||
self.pr_name(pipe="")
|
||
data = f"vid: {self.vid}" if self.vid is not None else ""
|
||
print(self._pr_proto_common("vlan", "", data))
|
||
|
||
if self.lower_if:
|
||
self.pr_proto_ipv4(pipe='│')
|
||
self.pr_proto_ipv6(pipe='│')
|
||
else:
|
||
self.pr_proto_ipv4()
|
||
self.pr_proto_ipv6()
|
||
return
|
||
|
||
parent = find_iface(_ifaces, self.lower_if)
|
||
if not parent:
|
||
print(f"Error, didn't find parent interface for vlan {self.name}")
|
||
sys.exit(1)
|
||
parent.pr_name(pipe='└ ')
|
||
print()
|
||
|
||
def pr_container(self):
|
||
# Add ⇅ flag for interfaces with IP forwarding enabled
|
||
flag = "⇅" if self.name in Iface._routing_ifaces else " "
|
||
# Flag column is outside the gray background
|
||
print(f"{flag:<{Pad.flags}}", end="")
|
||
|
||
row = f"{self.name:<{Pad.iface}}"
|
||
row += f"{'container':<{Pad.proto}}"
|
||
row += f"{'':<{Pad.state}}"
|
||
row += f"{', ' . join(self.containers):<{Pad.data}}"
|
||
|
||
print(Decore.gray_bg(row))
|
||
|
||
def pr_iface(self):
|
||
if self.is_in_container():
|
||
print(Decore.gray_bg(f"{'owned by container':<{19}}: {', ' . join(self.containers)}"))
|
||
|
||
print(f"{'name':<{19}}: {self.name}")
|
||
print(f"{'type':<{19}}: {self.type.split(':')[1]}")
|
||
print(f"{'index':<{19}}: {self.index}")
|
||
|
||
if self.mtu:
|
||
print(f"{'mtu':<{19}}: {self.mtu}")
|
||
if self.oper():
|
||
print(f"{'operational status':<{19}}: {self.oper(detail=True)}")
|
||
|
||
forwarding = "enabled" if self.name in Iface._routing_ifaces else "disabled"
|
||
print(f"{'ip forwarding':<{19}}: {forwarding}")
|
||
|
||
if self.lower_if:
|
||
print(f"{'lower-layer-if':<{19}}: {self.lower_if}")
|
||
|
||
if label := self._phy_label():
|
||
print(f"{'link mode':<{19}}: {label}")
|
||
|
||
if self.speed:
|
||
mbps = int(self.speed) // 1_000_000
|
||
print(f"{'speed':<{19}}: {mbps}")
|
||
|
||
if self.duplex:
|
||
print(f"{'duplex':<{19}}: {self.duplex}")
|
||
|
||
if self.autoneg != 'unknown':
|
||
val = "on" if self.autoneg else "off"
|
||
print(f"{'auto-negotiation':<{19}}: {val}")
|
||
|
||
if self.advertised:
|
||
self._pr_label_list('advertised',
|
||
[self._pmd_label(pmd) for pmd in self.advertised])
|
||
|
||
if self.supported:
|
||
self._pr_label_list('supported',
|
||
[self._pmd_label(pmd) for pmd in self.supported])
|
||
|
||
if self.phys_address:
|
||
print(f"{'physical address':<{19}}: {self.phys_address}")
|
||
|
||
if self.lag_mode:
|
||
print(f"{'lag mode':<{19}}: {self.lag_mode}")
|
||
if self.lag_mode == "lacp":
|
||
print(f"{'lag hash':<{19}}: {self.lag_hash}")
|
||
print(f"{'lacp mode':<{19}}: {self.lacp_mode}")
|
||
print(f"{'lacp rate':<{19}}: {self.lacp_rate}")
|
||
print(f"{'lacp aggregate id':<{19}}: {self.lacp_id}")
|
||
print(f"{'lacp sys priority':<{19}}: {self.lacp_sys_prio}")
|
||
print(f"{'lacp actor key':<{19}}: {self.lacp_actor_key}")
|
||
print(f"{'lacp partner key':<{19}}: {self.lacp_partner_key}")
|
||
print(f"{'lacp partner mac':<{19}}: {self.lacp_partner_mac}")
|
||
else:
|
||
print(f"{'lag type':<{19}}: {self.lag_type}")
|
||
print(f"{'lag hash':<{19}}: {self.lag_hash}")
|
||
print(f"{'link debounce up':<{19}}: {self.link_updelay} msec")
|
||
print(f"{'link debounce down':<{19}}: {self.link_downdelay} msec")
|
||
|
||
if self.lag:
|
||
print(f"{'lag member':<{19}}: {self.lag}")
|
||
print(f"{'lag member state':<{19}}: {self.lag_state}")
|
||
if self.lacp_state:
|
||
print(f"{'lacp aggregate id':<{19}}: {self.lacp_id}")
|
||
print(f"{'lacp actor state':<{19}}: {', '.join(self.lacp_state)}")
|
||
print(f"{'lacp partner state':<{19}}: {', '.join(self.lacp_pstate)}")
|
||
print(f"{'link failure count':<{19}}: {self.link_failures}")
|
||
|
||
def _addr_lines(addrs):
|
||
for addr in addrs:
|
||
origin = f"({addr['origin']})" if addr.get('origin') else ""
|
||
yield f"{addr['ip']}/{addr['prefix-length']} {origin}"
|
||
|
||
self._pr_label_list('ipv4 addresses', list(_addr_lines(self.ipv4_addr)))
|
||
self._pr_label_list('ipv6 addresses', list(_addr_lines(self.ipv6_addr)))
|
||
|
||
if self.in_octets and self.out_octets:
|
||
print(f"{'in-octets':<{19}}: {self.in_octets}")
|
||
print(f"{'out-octets':<{19}}: {self.out_octets}")
|
||
|
||
if self.wifi:
|
||
if "access-point" in self.wifi:
|
||
ap = self.wifi['access-point']
|
||
mode = "access-point"
|
||
ssid = ap.get('ssid', "----")
|
||
stations_data = ap.get("stations", {})
|
||
stations = stations_data.get("station", [])
|
||
print(f"{'mode':<{19}}: {mode}")
|
||
print(f"{'ssid':<{19}}: {ssid}")
|
||
print(f"{'connected stations':<{19}}: {len(stations)}")
|
||
self.pr_wifi_stations()
|
||
elif "mesh-point" in self.wifi:
|
||
mesh = self.wifi['mesh-point']
|
||
mode = "mesh-point"
|
||
mesh_id = mesh.get('mesh-id', "----")
|
||
peers_data = mesh.get("peers", {})
|
||
peers = peers_data.get("peer", [])
|
||
print(f"{'mode':<{20}}: {mode}")
|
||
print(f"{'mesh-id':<{20}}: {mesh_id}")
|
||
print(f"{'connected peers':<{20}}: {len(peers)}")
|
||
self.pr_wifi_peers()
|
||
else:
|
||
mode = "station"
|
||
station = self.wifi.get('station', {})
|
||
signal = station.get('signal-strength')
|
||
ssid = station.get('ssid', "----")
|
||
print(f"{'mode':<{19}}: {mode}")
|
||
print(f"{'ssid':<{19}}: {ssid}")
|
||
if signal is not None:
|
||
signal_status = signal_to_status(signal)
|
||
print(f"{'signal':<{19}}: {signal} dBm ({signal_status})")
|
||
rx_speed = station.get('rx-speed')
|
||
tx_speed = station.get('tx-speed')
|
||
if rx_speed is not None:
|
||
print(f"{'rx bitrate':<{19}}: {rx_speed / 10:.1f} Mbps")
|
||
if tx_speed is not None:
|
||
print(f"{'tx bitrate':<{19}}: {tx_speed / 10:.1f} Mbps")
|
||
if "scan-results" in station:
|
||
self.pr_wifi_ssids()
|
||
|
||
if self.gre:
|
||
print(f"{'local address':<{19}}: {self.gre['local']}")
|
||
print(f"{'remote address':<{19}}: {self.gre['remote']}")
|
||
|
||
if self.vxlan:
|
||
print(f"{'local address':<{19}}: {self.vxlan['local']}")
|
||
print(f"{'remote address':<{19}}: {self.vxlan['remote']}")
|
||
print(f"{'VxLAN id':<{19}}: {self.vxlan['vni']}")
|
||
|
||
if self.wireguard:
|
||
peer_status = self.wireguard.get('peer-status', {})
|
||
peers = peer_status.get('peer', [])
|
||
if peers:
|
||
print(f"{'peers':<{19}}: {len(peers)}")
|
||
for idx, peer in enumerate(peers, 1):
|
||
print(f"\n Peer {idx}:")
|
||
|
||
# Public key (always 44 chars: 43 + '=')
|
||
if pubkey := peer.get('public-key'):
|
||
print(f" {'public key':<{18}}: {pubkey}")
|
||
|
||
# Connection status with color
|
||
status = peer.get('connection-status', 'unknown')
|
||
if status == 'up':
|
||
status_str = Decore.green(status.upper())
|
||
else:
|
||
status_str = Decore.red(status.upper())
|
||
print(f" {'status':<{18}}: {status_str}")
|
||
|
||
# Endpoint information
|
||
if endpoint := peer.get('endpoint-address'):
|
||
port = peer.get('endpoint-port', '')
|
||
endpoint_str = f"{endpoint}:{port}" if port else endpoint
|
||
print(f" {'endpoint':<{18}}: {endpoint_str}")
|
||
|
||
# Latest handshake
|
||
if handshake := peer.get('latest-handshake'):
|
||
print(f" {'latest handshake':<{18}}: {handshake}")
|
||
|
||
# Transfer statistics
|
||
if transfer := peer.get('transfer'):
|
||
tx = transfer.get('tx-bytes', '0')
|
||
rx = transfer.get('rx-bytes', '0')
|
||
print(f" {'transfer tx':<{18}}: {tx} bytes")
|
||
print(f" {'transfer rx':<{18}}: {rx} bytes")
|
||
|
||
|
||
frame = get_json_data([], self.data,'ieee802-ethernet-interface:ethernet',
|
||
'statistics', 'frame')
|
||
if frame:
|
||
Decore.title("Ethernet Statistics")
|
||
for key, val in frame.items():
|
||
key = remove_yang_prefix(key)
|
||
print(f"{key:<{25}}: {val}")
|
||
|
||
def pr_mdb(self, bridge):
|
||
for group in self.br_mdb.get("multicast-filter", {}):
|
||
row = f"{bridge:<{PadMdb.bridge}}"
|
||
row += f"{'':<{PadMdb.vlan}}"
|
||
row += f"{group['group']:<{PadMdb.group}}"
|
||
if (group.get("ports")):
|
||
ports = ", ".join(port_dict["port"] for port_dict in group["ports"])
|
||
else:
|
||
ports = ""
|
||
row += f"{ports}"
|
||
print(row)
|
||
|
||
def pr_vlans_mdb(self, bridge):
|
||
for vlan in self.br_vlans:
|
||
filters=vlan.get("multicast-filters", {})
|
||
for group in filters.get("multicast-filter", []):
|
||
row = f"{bridge:<{PadMdb.bridge}}"
|
||
row += f"{vlan['vid']:<{PadMdb.vlan}}"
|
||
row += f"{group['group']:<{PadMdb.group}}"
|
||
if (group.get("ports")):
|
||
ports = ", ".join(port_dict["port"] for port_dict in group["ports"])
|
||
else:
|
||
ports = ""
|
||
row += f"{ports}"
|
||
print(row)
|
||
|
||
def pr_stp(self):
|
||
if not (stp := get_json_data({}, self.data, 'infix-interfaces:bridge', 'stp')):
|
||
return
|
||
|
||
if bid := stp["cist"].get("bridge-id"):
|
||
bid = STPBridgeID(bid)
|
||
else:
|
||
bid = "UNKNOWN BRIDGE ID"
|
||
|
||
if rid := stp["cist"].get("root-id"):
|
||
rid = STPBridgeID(rid)
|
||
else:
|
||
rid = "none"
|
||
|
||
print(f"{'bridge-id':<{20}}: {bid} ({self.name})")
|
||
print(f"{'root-id':<{20}}: {rid}")
|
||
print(f"{'protocol':<{20}}: {stp.get('force-protocol', 'UNKNOWN')}")
|
||
print(f"{'hello time':<{20}}: {stp.get('hello-time', 'UNKNOWN'):2} seconds")
|
||
print(f"{'forward delay':<{20}}: {stp.get('forward-delay', 'UNKNOWN'):2} seconds")
|
||
print(f"{'max age':<{20}}: {stp.get('max-age', 'UNKNOWN'):2} seconds")
|
||
print(f"{'transmit hold count':<{20}}: {stp.get('transmit-hold-count', 'UNKNOWN'):2}")
|
||
print(f"{'max hops':<{20}}: {stp.get('max-hops', 'UNKNOWN'):2}")
|
||
|
||
if tc := stp["cist"].get("topology-change"):
|
||
print(f"{'topology change':<{20}}:")
|
||
print(f"{' count':<{20}}: {tc.get('count', 'UNKNOWN')}")
|
||
print(f"{' in progress':<{20}}: {'YES' if tc.get('in-progress') else 'no'}")
|
||
print(f"{' last change':<{20}}: {Date.from_yang(tc.get('time')).pretty()}")
|
||
print(f"{' port':<{20}}: {tc.get('port', 'UNKNOWN')}")
|
||
|
||
|
||
class LldpNeighbor:
|
||
def __init__(self, iface, data):
|
||
self.interface = iface
|
||
self.remote_index = data.get("remote-index", 0)
|
||
self.time_mark = data.get("time-mark", 0)
|
||
self.chassis_id = data.get("chassis-id", "unknown")
|
||
self.port_id = data.get("port-id", "unknown")
|
||
|
||
def print(self):
|
||
row = (
|
||
f"{self.interface:<{PadLldp.interface}}"
|
||
f"{self.remote_index:<{PadLldp.rem_idx}}"
|
||
f"{self.time_mark:<{PadLldp.time}}"
|
||
f"{self.chassis_id:<{PadLldp.chassis_id}}"
|
||
f"{self.port_id:<{PadLldp.port_id}}"
|
||
)
|
||
print(row)
|
||
|
||
|
||
def find_iface(_ifaces, name):
|
||
for _iface in [Iface(data) for data in _ifaces]:
|
||
if _iface.name == name:
|
||
return _iface
|
||
|
||
return False
|
||
|
||
|
||
def version_sort(s):
|
||
return [int(x) if x.isdigit() else x for x in re.split(r'(\d+)', s)]
|
||
|
||
|
||
def ifname_sort(iface):
|
||
return version_sort(iface["name"])
|
||
|
||
|
||
def brport_sort(iface):
|
||
brname = iface.get("infix-interfaces:bridge-port", {}).get("bridge", "")
|
||
return version_sort(brname) + version_sort(iface["name"])
|
||
|
||
|
||
def print_interface(iface):
|
||
iface.pr_name()
|
||
if iface._pr_phy_row():
|
||
iface.pr_proto_eth_subrow()
|
||
else:
|
||
iface.pr_proto_eth()
|
||
iface.pr_proto_ipv4()
|
||
iface.pr_proto_ipv6()
|
||
|
||
|
||
def pr_interface_list(json):
|
||
hdr = (f"{'⚑':<{Pad.flags}}"
|
||
f"{'INTERFACE':<{Pad.iface}}"
|
||
f"{'PROTOCOL':<{Pad.proto}}"
|
||
f"{'STATE':<{Pad.state}}"
|
||
f"{'DATA':<{Pad.data}}")
|
||
|
||
print(Decore.invert(hdr))
|
||
|
||
# Set class variable with routing-enabled interfaces (with IP forwarding)
|
||
if "ietf-routing:routing" in json:
|
||
routing_data = json["ietf-routing:routing"].get("interfaces", {})
|
||
Iface._routing_ifaces = set(routing_data.get("interface", []))
|
||
else:
|
||
Iface._routing_ifaces = set()
|
||
|
||
ifaces = sorted(json["ietf-interfaces:interfaces"]["interface"],
|
||
key=ifname_sort)
|
||
iface = find_iface(ifaces, "lo")
|
||
if iface:
|
||
iface.pr_loopback()
|
||
|
||
for iface in [Iface(data) for data in ifaces]:
|
||
if iface.name == "lo":
|
||
continue
|
||
|
||
if iface.is_in_container():
|
||
iface.pr_container()
|
||
continue
|
||
|
||
if iface.is_bridge():
|
||
iface.pr_bridge(ifaces)
|
||
continue
|
||
|
||
if iface.is_lag():
|
||
iface.pr_lag(ifaces)
|
||
continue
|
||
|
||
if iface.is_veth():
|
||
iface.pr_veth()
|
||
continue
|
||
|
||
if iface.is_gre():
|
||
iface.pr_gre()
|
||
continue
|
||
|
||
if iface.is_gretap():
|
||
iface.pr_gretap()
|
||
continue
|
||
|
||
if iface.is_vxlan():
|
||
iface.pr_vxlan()
|
||
continue
|
||
|
||
if iface.is_wireguard():
|
||
iface.pr_wireguard()
|
||
continue
|
||
|
||
if iface.is_wifi():
|
||
iface.pr_wifi()
|
||
continue
|
||
|
||
if iface.is_vlan():
|
||
iface.pr_vlan(ifaces)
|
||
continue
|
||
|
||
# These interfaces are printed by their parent, such as bridge
|
||
if iface.lower_if:
|
||
continue
|
||
if iface.bridge:
|
||
continue
|
||
if iface.lag:
|
||
continue
|
||
|
||
print_interface(iface)
|
||
|
||
|
||
def show_interfaces(json, name):
|
||
# Set class variable with routing-enabled interfaces (with IP forwarding)
|
||
if "ietf-routing:routing" in json:
|
||
routing_data = json["ietf-routing:routing"].get("interfaces", {})
|
||
Iface._routing_ifaces = set(routing_data.get("interface", []))
|
||
else:
|
||
Iface._routing_ifaces = set()
|
||
|
||
if name:
|
||
if not json.get("ietf-interfaces:interfaces"):
|
||
print(f"No interface data found for \"{name}\"")
|
||
sys.exit(1)
|
||
iface = find_iface(json["ietf-interfaces:interfaces"]["interface"], name)
|
||
if not iface:
|
||
print(f"Interface \"{name}\" not found")
|
||
sys.exit(1)
|
||
else:
|
||
iface.pr_iface()
|
||
else:
|
||
if not json.get("ietf-interfaces:interfaces"):
|
||
print("Error, top level \"ietf-interfaces:interfaces\" missing")
|
||
sys.exit(1)
|
||
pr_interface_list(json)
|
||
|
||
|
||
def show_bridge_mdb(json):
|
||
header_printed = False
|
||
if not json.get("ietf-interfaces:interfaces"):
|
||
print("Error, top level \"ietf-interfaces:interface\" missing")
|
||
sys.exit(1)
|
||
|
||
ifaces = sorted(json["ietf-interfaces:interfaces"]["interface"],
|
||
key=ifname_sort)
|
||
for iface in [Iface(data) for data in ifaces]:
|
||
if iface.type != "infix-if-type:bridge":
|
||
continue
|
||
if not header_printed:
|
||
hdr = (f"{'BRIDGE':<{PadMdb.bridge}}"
|
||
f"{'VID':<{PadMdb.vlan}}"
|
||
f"{'GROUP':<{PadMdb.group}}"
|
||
f"{'PORTS':<{PadMdb.ports}}")
|
||
print(Decore.invert(hdr))
|
||
header_printed = True
|
||
iface.pr_mdb(iface.name)
|
||
iface.pr_vlans_mdb(iface.name)
|
||
|
||
|
||
def show_bridge_stp_port(ifname, brport):
|
||
stp = brport["stp"]
|
||
|
||
state = stp["cist"]["state"]
|
||
if state == "forwarding":
|
||
state = Decore.green(f"{state.upper():<{PadStpPort.state}}")
|
||
else:
|
||
state = Decore.yellow(f"{state.upper():<{PadStpPort.state}}")
|
||
|
||
role = stp["cist"]["role"]
|
||
if role == "root":
|
||
role = Decore.bold(f"{role:<{PadStpPort.role}}")
|
||
else:
|
||
role = f"{role:<{PadStpPort.role}}"
|
||
|
||
designated = "unknown"
|
||
if cdesbr := stp["cist"].get("designated", {}).get("bridge-id"):
|
||
brid = str(STPBridgeID(cdesbr))
|
||
|
||
cdesport = stp["cist"]["designated"].get("port-id")
|
||
port = str(STPPortID(cdesport)) if cdesport else "UNKNOWN"
|
||
designated = f"{brid} ({port})"
|
||
|
||
row = (
|
||
f"{ifname:<{PadStpPort.port}}"
|
||
f"{str(STPPortID(stp['cist']['port-id'])):<{PadStpPort.id}}"
|
||
f"{state}"
|
||
f"{role}"
|
||
f"{'yes' if stp['edge'] else 'no':<{PadStpPort.edge}}"
|
||
f"{designated:<{PadStpPort.designated}}"
|
||
)
|
||
print(row)
|
||
|
||
|
||
def show_bridge_stp(json):
|
||
if not json.get("ietf-interfaces:interfaces"):
|
||
print("Error, top level \"ietf-interfaces:interface\" missing")
|
||
sys.exit(1)
|
||
|
||
brs = sorted(filter(lambda i: i.get("type") == "infix-if-type:bridge",
|
||
json["ietf-interfaces:interfaces"].get("interface",[])),
|
||
key=ifname_sort)
|
||
|
||
for i, br in enumerate(brs):
|
||
if i:
|
||
print()
|
||
Iface(br).pr_stp()
|
||
|
||
ports = sorted(filter(lambda i: i.get("infix-interfaces:bridge-port"),
|
||
json["ietf-interfaces:interfaces"].get("interface",[])),
|
||
key=brport_sort)
|
||
if not ports:
|
||
return
|
||
|
||
print()
|
||
hdr = (
|
||
f"{'PORT':<{PadStpPort.port}}"
|
||
f"{'ID':<{PadStpPort.id}}"
|
||
f"{'STATE':<{PadStpPort.state}}"
|
||
f"{'ROLE':<{PadStpPort.role}}"
|
||
f"{'EDGE':<{PadStpPort.edge}}"
|
||
f"{'DESIGNATED BRIDGE':<{PadStpPort.designated}}"
|
||
)
|
||
print(Decore.invert(hdr))
|
||
|
||
lastbr = None
|
||
for port in ports:
|
||
brport = port["infix-interfaces:bridge-port"]
|
||
if not brport.get("stp"):
|
||
continue
|
||
|
||
if brport["bridge"] != lastbr:
|
||
lastbr = brport["bridge"]
|
||
separator = f"{'bridge:'+lastbr:<{PadStpPort.total}}"
|
||
print(Decore.gray_bg(separator))
|
||
|
||
show_bridge_stp_port(port["name"], brport)
|
||
|
||
|
||
def show_routing_table(json, ip):
|
||
if not json.get("ietf-routing:routing"):
|
||
print("Error, top level \"ietf-routing:routing\" missing")
|
||
sys.exit(1)
|
||
|
||
PadRoute.set(ip)
|
||
hdr = (f" {'DESTINATION':<{PadRoute.dest}} "
|
||
f"{'PREF':>{PadRoute.pref}} "
|
||
f"{'NEXT-HOP':<{PadRoute.next_hop}} "
|
||
f"{'PROTO':<{PadRoute.protocol}} "
|
||
f"{'UPTIME':>{PadRoute.uptime}}")
|
||
|
||
print(Decore.invert(hdr))
|
||
for rib in get_json_data({}, json, 'ietf-routing:routing', 'ribs', 'rib'):
|
||
if rib["name"] != ip:
|
||
continue
|
||
|
||
routes = get_json_data(None, rib, "routes", "route")
|
||
if routes:
|
||
for r in routes:
|
||
route = Route(r, ip)
|
||
route.print()
|
||
|
||
|
||
def find_slot(_slots, name):
|
||
for _slot in [Software(data) for data in _slots]:
|
||
if _slot.name == name:
|
||
return _slot
|
||
|
||
return False
|
||
|
||
|
||
def show_software(json, name):
|
||
if not json.get("ietf-system:system-state", "infix-system:software"):
|
||
print("Error, cannot find infix-system:software")
|
||
sys.exit(1)
|
||
|
||
software = get_json_data({}, json, 'ietf-system:system-state', 'infix-system:software')
|
||
slots = software.get("slot")
|
||
boot_order = software.get("boot-order", ["Unknown"])
|
||
if name:
|
||
slot = find_slot(slots, name)
|
||
if slot:
|
||
slot.detail()
|
||
else:
|
||
order = ""
|
||
for boot in boot_order:
|
||
order += f"{boot.strip()} "
|
||
print(f"Boot order : {order}")
|
||
print("")
|
||
|
||
hdr = (f"{'NAME':<{PadSoftware.name}}"
|
||
f"{'STATE':<{PadSoftware.state}}"
|
||
f"{'VERSION':<{PadSoftware.version}}"
|
||
f"{'DATE':<{PadSoftware.date}}")
|
||
print(Decore.invert(hdr))
|
||
for _s in reversed(slots):
|
||
slot = Software(_s)
|
||
if slot.is_rootfs():
|
||
slot.print()
|
||
|
||
|
||
def show_services(json):
|
||
if not json.get("ietf-system:system-state", "infix-system:services"):
|
||
print("Error, cannot find infix-system:services")
|
||
sys.exit(1)
|
||
|
||
services_data = get_json_data({}, json, 'ietf-system:system-state', 'infix-system:services')
|
||
services = services_data.get("service", [])
|
||
|
||
service_table = SimpleTable([
|
||
Column('NAME'),
|
||
Column('STATUS'),
|
||
Column('PID', 'right'),
|
||
Column('MEM', 'right'),
|
||
Column('UP', 'right'),
|
||
Column('RST', 'right'),
|
||
Column('DESCRIPTION')
|
||
])
|
||
|
||
for svc in services:
|
||
name = svc.get('name', '')
|
||
status = svc.get('status', '')
|
||
pid = svc.get('pid', 0)
|
||
description = svc.get('description', '')
|
||
stats = svc.get('statistics', {})
|
||
|
||
if status in ('running', 'active', 'done'):
|
||
status_str = Decore.green(status)
|
||
elif status in ('crashed', 'failed', 'halted', 'missing', 'dead', 'conflict'):
|
||
status_str = Decore.red(status)
|
||
else:
|
||
status_str = Decore.yellow(status)
|
||
|
||
pid_str = str(pid) if pid > 0 else ' '
|
||
|
||
memory_bytes = int(stats.get('memory-usage', 0))
|
||
uptime_secs = int(stats.get('uptime', 0))
|
||
restart_count = stats.get('restart-count', 0)
|
||
|
||
memory_str = format_memory_bytes(memory_bytes)
|
||
uptime_str = format_uptime_seconds(uptime_secs)
|
||
|
||
service_table.row(name, status_str, pid_str, memory_str,
|
||
uptime_str, restart_count, description)
|
||
|
||
service_table.print()
|
||
|
||
|
||
def show_hardware(json):
|
||
if not json.get("ietf-hardware:hardware"):
|
||
print("Error, top level \"ietf-hardware:component\" missing")
|
||
sys.exit(1)
|
||
|
||
components = get_json_data({}, json, "ietf-hardware:hardware", "component")
|
||
|
||
motherboard = [c for c in components if c.get("class") == "iana-hardware:chassis"]
|
||
usb_ports = [c for c in components if c.get("class") == "infix-hardware:usb"]
|
||
sensors = [c for c in components if c.get("class") == "iana-hardware:sensor"]
|
||
wifi_radios = [c for c in components if c.get("class") == "infix-hardware:wifi"]
|
||
gps_receivers = [c for c in components if c.get("class") == "infix-hardware:gps"]
|
||
|
||
width = max(PadSensor.table_width(), 62)
|
||
|
||
# Display full-width inverted heading
|
||
print(Decore.invert(f"{'HARDWARE COMPONENTS':<{width}}"))
|
||
|
||
if motherboard:
|
||
board = motherboard[0] # Should only be one
|
||
Decore.title("Board Information", width)
|
||
|
||
if board.get("model-name"):
|
||
print(f"Model : {board['model-name']}")
|
||
if board.get("mfg-name"):
|
||
print(f"Manufacturer : {board['mfg-name']}")
|
||
if board.get("serial-num"):
|
||
print(f"Serial Number : {board['serial-num']}")
|
||
if board.get("infix-hardware:phys-address"):
|
||
print(f"Base MAC Address : {board['infix-hardware:phys-address']}")
|
||
if board.get("hardware-rev"):
|
||
print(f"Hardware Revision : {board['hardware-rev']}")
|
||
|
||
if wifi_radios:
|
||
Decore.title("WiFi radios", width)
|
||
|
||
radios_table = SimpleTable([
|
||
Column('NAME', flexible=True),
|
||
Column('MANUFACTURER', flexible=True),
|
||
Column('BANDS', 'right'),
|
||
Column('STANDARDS', 'right'),
|
||
Column('MAX AP', 'right')
|
||
], min_width=width)
|
||
|
||
for component in wifi_radios:
|
||
phy = component.get("name", "")
|
||
manufacturer = component.get("mfg-name", "Unknown")
|
||
|
||
radio_data = component.get("infix-hardware:wifi-radio", {})
|
||
|
||
bands = radio_data.get("bands", [])
|
||
band_names = []
|
||
has_ht = False
|
||
has_vht = False
|
||
has_he = False
|
||
|
||
for band in bands:
|
||
if band.get("name"):
|
||
band_names.append(band["name"])
|
||
if band.get("ht-capable"):
|
||
has_ht = True
|
||
if band.get("vht-capable"):
|
||
has_vht = True
|
||
if band.get("he-capable"):
|
||
has_he = True
|
||
|
||
bands_str = "/".join(band_names) if band_names else "Unknown"
|
||
|
||
standards = []
|
||
if has_ht:
|
||
standards.append("11n")
|
||
if has_vht:
|
||
standards.append("11ac")
|
||
if has_he:
|
||
standards.append("11ax")
|
||
standard_str = "/".join(standards) if standards else "Unknown"
|
||
|
||
max_if = radio_data.get("max-interfaces", {})
|
||
max_ap = max_if.get('ap', 'N/A') if max_if else 'N/A'
|
||
|
||
radios_table.row(phy, manufacturer, bands_str, standard_str, max_ap)
|
||
radios_table.print()
|
||
|
||
if gps_receivers:
|
||
Decore.title("GPS/GNSS Receivers", width)
|
||
|
||
gps_table = SimpleTable([
|
||
Column('NAME'),
|
||
Column('DEVICE'),
|
||
Column('STATUS'),
|
||
Column('FIX'),
|
||
Column('SATELLITES'),
|
||
Column('POSITION', flexible=True),
|
||
Column('PPS')
|
||
], min_width=width)
|
||
|
||
for component in gps_receivers:
|
||
gps = component.get("infix-hardware:gps-receiver", {})
|
||
name = component.get("name", "unknown")
|
||
device = gps.get("device", "N/A")
|
||
activated = gps.get("activated", False)
|
||
status = "Active" if activated else "Inactive"
|
||
fix = gps.get("fix-mode", "none").upper()
|
||
|
||
sat_vis = gps.get("satellites-visible")
|
||
sat_used = gps.get("satellites-used")
|
||
satellites = f"{sat_used}/{sat_vis}" if sat_vis is not None else "N/A"
|
||
|
||
lat = gps.get("latitude")
|
||
lon = gps.get("longitude")
|
||
alt = gps.get("altitude")
|
||
if lat is not None and lon is not None:
|
||
lat_f = float(lat)
|
||
lon_f = float(lon)
|
||
lat_dir = "N" if lat_f >= 0 else "S"
|
||
lon_dir = "E" if lon_f >= 0 else "W"
|
||
pos = f"{abs(lat_f):.6f}{lat_dir} {abs(lon_f):.6f}{lon_dir}"
|
||
if alt is not None:
|
||
pos += f" {alt}m"
|
||
else:
|
||
pos = "N/A"
|
||
|
||
pps = "Yes" if gps.get("pps-available", False) else "No"
|
||
|
||
gps_table.row(name, device, status, fix, satellites, pos, pps)
|
||
|
||
gps_table.print()
|
||
if usb_ports:
|
||
Decore.title("USB Ports", width)
|
||
|
||
usb_table = SimpleTable([
|
||
Column('NAME', flexible=True),
|
||
Column('STATE'),
|
||
Column('OPER')
|
||
], min_width=width)
|
||
|
||
for component in usb_ports:
|
||
port = USBport(component)
|
||
usb_table.row(port.name, port.state, port.oper)
|
||
|
||
usb_table.print()
|
||
|
||
if sensors:
|
||
Decore.title("Sensors", width)
|
||
|
||
# Print header
|
||
hdr = (f"{'NAME':<{PadSensor.name}}"
|
||
f"{'VALUE':<{PadSensor.value}}"
|
||
f"{'STATUS':<{PadSensor.status}}")
|
||
print(Decore.invert(hdr))
|
||
|
||
# Build parent-child map
|
||
children = {} # parent_name -> [child_components]
|
||
standalone = [] # components without parents
|
||
|
||
for component in sensors:
|
||
parent = component.get("parent")
|
||
if parent:
|
||
if parent not in children:
|
||
children[parent] = []
|
||
children[parent].append(component)
|
||
else:
|
||
standalone.append(component)
|
||
|
||
# Get all parent modules (non-sensor components)
|
||
modules = [c for c in components if c.get("class") == "iana-hardware:module"]
|
||
|
||
# Display modules with their child sensors (indented)
|
||
for module in sorted(modules, key=lambda m: m.get("name", "")):
|
||
module_name = module.get("name", "unknown")
|
||
print(f"\n{module_name}:")
|
||
|
||
if module_name in children:
|
||
for child in sorted(children[module_name], key=lambda c: c.get("name", "")):
|
||
sensor = Sensor(child)
|
||
sensor.print(indent=1)
|
||
|
||
# Display standalone sensors (no parent)
|
||
if standalone:
|
||
if modules:
|
||
print() # Add blank line between modules and standalone
|
||
for component in sorted(standalone, key=lambda c: c.get("name", "")):
|
||
sensor = Sensor(component)
|
||
sensor.print()
|
||
|
||
|
||
def resolve_container_network(network, all_ifaces):
|
||
"""Resolve container network to bridge names or network type.
|
||
|
||
Args:
|
||
network: Container network dict from operational data
|
||
all_ifaces: List of all interface dicts
|
||
|
||
Returns:
|
||
str: Network description (bridge name, "host", interface name, or "-")
|
||
"""
|
||
if network.get('host'):
|
||
return "host"
|
||
|
||
if 'interface' not in network:
|
||
return "-"
|
||
|
||
interfaces = network.get('interface', [])
|
||
if not interfaces:
|
||
return "-"
|
||
|
||
network_names = []
|
||
for iface in interfaces:
|
||
iface_name = iface.get('name', '')
|
||
iface_data = next((i for i in all_ifaces if i.get('name') == iface_name), None)
|
||
|
||
bridge_found = False
|
||
if iface_data:
|
||
# Check if this is a VETH with a peer (host-side veth)
|
||
veth = iface_data.get('infix-interfaces:veth', {})
|
||
peer_name = veth.get('peer')
|
||
|
||
if peer_name:
|
||
# Find the peer interface and check if it's a bridge port
|
||
peer_data = next((i for i in all_ifaces if i.get('name') == peer_name), None)
|
||
if peer_data:
|
||
bridge_port = peer_data.get('infix-interfaces:bridge-port', {})
|
||
bridge_name = bridge_port.get('bridge')
|
||
if bridge_name:
|
||
network_names.append(bridge_name)
|
||
bridge_found = True
|
||
|
||
# If not found via veth peer, try reverse lookup
|
||
if not bridge_found:
|
||
for host_iface in all_ifaces:
|
||
container_net = host_iface.get('infix-interfaces:container-network', {})
|
||
containers_list = container_net.get('containers', [])
|
||
|
||
if iface_name in containers_list:
|
||
bridge_port = host_iface.get('infix-interfaces:bridge-port', {})
|
||
bridge_name = bridge_port.get('bridge')
|
||
|
||
if bridge_name:
|
||
network_names.append(bridge_name)
|
||
bridge_found = True
|
||
break
|
||
|
||
if not bridge_found:
|
||
network_names.append(iface_name)
|
||
|
||
return ', '.join(network_names)
|
||
|
||
|
||
def show_container(json):
|
||
"""Display container table view with resource usage."""
|
||
containers_data = json.get("infix-containers:containers", {})
|
||
containers = containers_data.get("container", [])
|
||
|
||
if not containers:
|
||
print("No containers configured.")
|
||
return
|
||
|
||
# Get interface data for network resolution
|
||
all_ifaces_data = json.get('ietf-interfaces:interfaces', {})
|
||
all_ifaces = all_ifaces_data.get('interface', [])
|
||
|
||
# Create table with column definitions
|
||
container_table = SimpleTable([
|
||
Column('NAME'),
|
||
Column('STATUS'),
|
||
Column('NETWORK'),
|
||
Column('MEMORY (KiB)', 'right'),
|
||
Column('CPU%', 'right')
|
||
])
|
||
|
||
for container in containers:
|
||
name = container.get("name", "")
|
||
status = container.get("status", "")
|
||
|
||
# Get network information
|
||
network = container.get('network', {})
|
||
network_str = resolve_container_network(network, all_ifaces)
|
||
|
||
# Get resource limits and usage
|
||
resource_limit = container.get("resource-limit", {})
|
||
resource_usage = container.get("resource-usage", {})
|
||
|
||
mem_limit = resource_limit.get("memory")
|
||
mem_usage = resource_usage.get("memory")
|
||
cpu_usage = resource_usage.get("cpu", "0.0")
|
||
|
||
# Format memory display
|
||
if mem_usage is not None and mem_limit is not None:
|
||
memory_str = f"{mem_usage}/{mem_limit}"
|
||
elif mem_usage is not None:
|
||
memory_str = str(mem_usage)
|
||
else:
|
||
memory_str = "-"
|
||
|
||
# Format CPU display
|
||
cpu_str = str(cpu_usage)
|
||
|
||
# Color code status like in show_services
|
||
if status in ('running', 'active'):
|
||
status_str = Decore.green(status)
|
||
elif status in ('exited', 'stopped', 'created'):
|
||
status_str = Decore.yellow(status)
|
||
elif status in ('error', 'dead'):
|
||
status_str = Decore.red(status)
|
||
else:
|
||
status_str = status
|
||
|
||
container_table.row(name, status_str, network_str, memory_str, cpu_str)
|
||
|
||
container_table.print()
|
||
|
||
|
||
def show_container_detail(json, name):
|
||
"""Display detailed container view with full resource information."""
|
||
containers_data = json.get("infix-containers:containers", {})
|
||
containers = containers_data.get("container", [])
|
||
|
||
container = None
|
||
for c in containers:
|
||
if c.get("name") == name:
|
||
container = c
|
||
break
|
||
|
||
if not container:
|
||
print(f"Container '{name}' not found.")
|
||
return
|
||
|
||
print(f"Name : {container.get('name', '-')}")
|
||
print(f"Container ID : {container.get('id', '-')}")
|
||
print(f"Image : {container.get('image', '-')}")
|
||
print(f"Image ID : {container.get('image-id', '')}")
|
||
|
||
command = container.get('command')
|
||
if command:
|
||
print(f"Command : {command}")
|
||
|
||
# Get network information
|
||
network = container.get('network', {})
|
||
all_ifaces_data = json.get('ietf-interfaces:interfaces', {})
|
||
all_ifaces = all_ifaces_data.get('interface', [])
|
||
network_str = resolve_container_network(network, all_ifaces)
|
||
print(f"Network : {network_str}")
|
||
|
||
print(f"Status : {container.get('status', '-')}")
|
||
print(f"Running : {'yes' if container.get('running') else 'no'}")
|
||
|
||
resource_limit = container.get("resource-limit", {})
|
||
resource_usage = container.get("resource-usage", {})
|
||
|
||
pids = resource_usage.get("pids")
|
||
if pids is not None:
|
||
print(f"Processes : {pids}")
|
||
|
||
mem_limit = resource_limit.get("memory")
|
||
if mem_limit is not None:
|
||
print(f"Memory Limit : {mem_limit} KiB")
|
||
|
||
mem_usage = resource_usage.get("memory")
|
||
if mem_usage is not None:
|
||
mem_usage_int = int(mem_usage)
|
||
if mem_limit:
|
||
mem_limit_int = int(mem_limit)
|
||
percent = (mem_usage_int / mem_limit_int) * 100
|
||
print(f"Memory Usage : {mem_usage_int} KiB ({percent:.1f}%)")
|
||
else:
|
||
print(f"Memory Usage : {mem_usage_int} KiB")
|
||
|
||
cpu_limit = resource_limit.get("cpu")
|
||
if cpu_limit is not None:
|
||
cpu_limit_int = int(cpu_limit)
|
||
cores = cpu_limit_int / 1000.0
|
||
print(f"CPU Limit : {cpu_limit_int} millicores ({cores:.1f} cores)")
|
||
|
||
cpu_usage = resource_usage.get("cpu")
|
||
if cpu_usage is not None:
|
||
print(f"CPU Usage : {cpu_usage}%")
|
||
|
||
|
||
def show_ntp_source_detail_single(source, is_association=False):
|
||
"""Display detailed information for a single NTP source (auto-detected single source)"""
|
||
print(f"{'Server address':<20}: {source.get('address', 'N/A')}")
|
||
|
||
# State
|
||
if is_association:
|
||
prefer = source.get("prefer", False)
|
||
state_desc = "Selected sync source" if prefer else "Candidate"
|
||
else:
|
||
state = source.get('state', 'unknown')
|
||
state_desc = {
|
||
'selected': 'Selected sync source',
|
||
'candidate': 'Candidate',
|
||
'unreach': 'Unreachable',
|
||
'not-combined': 'Not combined'
|
||
}.get(state, state)
|
||
print(f"{'State':<20}: {state_desc}")
|
||
|
||
# Stratum
|
||
stratum = source.get('stratum')
|
||
if stratum is not None:
|
||
print(f"{'Stratum':<20}: {stratum}")
|
||
|
||
# Poll interval
|
||
poll = source.get('poll')
|
||
if poll is not None:
|
||
poll_seconds = 2 ** poll
|
||
print(f"{'Poll interval':<20}: {poll} (2^{poll} seconds = {poll_seconds}s)")
|
||
|
||
def show_ntp(json, address=None):
|
||
"""Unified NTP status display for both client and server modes"""
|
||
ntp_data = json.get("ietf-ntp:ntp", {})
|
||
port = ntp_data.get("port")
|
||
is_server = port is not None
|
||
|
||
sources = []
|
||
if is_server:
|
||
associations = ntp_data.get("associations", {}).get("association", [])
|
||
sources = associations
|
||
else:
|
||
system_state = json.get("ietf-system:system-state", {})
|
||
if system_state:
|
||
sources = get_json_data({}, json, 'ietf-system:system-state', 'infix-system:ntp', 'sources', 'source')
|
||
|
||
if address:
|
||
matching = [s for s in sources if s.get('address') == address]
|
||
if not matching:
|
||
print(f"No NTP source found with address: {address}")
|
||
return
|
||
if is_server:
|
||
show_ntp_association_detail(matching[0])
|
||
else:
|
||
show_ntp_source_detail_single(matching[0], False)
|
||
return
|
||
|
||
# Check for GPS/GNSS hardware reference clocks
|
||
hw_components = json.get("ietf-hardware:hardware", {}).get("component", [])
|
||
gps_sources = [c for c in hw_components if c.get("class") == "infix-hardware:gps"]
|
||
|
||
if is_server:
|
||
if sources:
|
||
print(f"{'Mode':<20}: Relay (no local reference clock)")
|
||
elif gps_sources:
|
||
gps_names = ", ".join(c.get("name", "?") for c in gps_sources)
|
||
print(f"{'Mode':<20}: Server (GPS reference clock: {gps_names})")
|
||
else:
|
||
print(f"{'Mode':<20}: Server (local reference clock)")
|
||
print(f"{'Port':<20}: {port}")
|
||
|
||
# Show operational stratum
|
||
refclock = ntp_data.get("refclock-master")
|
||
if refclock:
|
||
stratum = refclock.get("master-stratum")
|
||
if stratum is not None:
|
||
print(f"{'Stratum':<20}: {stratum}")
|
||
|
||
# Show reference time
|
||
clock_state = ntp_data.get("clock-state", {}).get("system-status", {})
|
||
ref_time = clock_state.get("reference-time")
|
||
if ref_time:
|
||
from datetime import datetime
|
||
try:
|
||
dt = datetime.fromisoformat(ref_time.replace("Z", "+00:00"))
|
||
ref_time_str = dt.strftime("%a %b %d %H:%M:%S %Y")
|
||
print(f"{'Ref time (UTC)':<20}: {ref_time_str}")
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
interfaces = ntp_data.get("interfaces", {}).get("interface", [])
|
||
if interfaces:
|
||
print(f"{'Interfaces':<20}: {', '.join([iface.get('name', '?') for iface in interfaces])}")
|
||
else:
|
||
print(f"{'Interfaces':<20}: All")
|
||
|
||
stats = ntp_data.get("ntp-statistics")
|
||
if stats:
|
||
print(f"{'Packets Received':<20}: {stats.get('packet-received', 0):,}")
|
||
print(f"{'Packets Sent':<20}: {stats.get('packet-sent', 0):,}")
|
||
print(f"{'Packets Dropped':<20}: {stats.get('packet-dropped', 0):,}")
|
||
print(f"{'Send Failures':<20}: {stats.get('packet-sent-fail', 0):,}")
|
||
else:
|
||
print(f"{'Mode':<20}: Client")
|
||
|
||
# Show local clock state in client mode
|
||
clock_state = ntp_data.get("clock-state", {}).get("system-status", {})
|
||
|
||
# Show local operational stratum
|
||
stratum = clock_state.get("clock-stratum")
|
||
if stratum is not None:
|
||
print(f"{'Stratum':<20}: {stratum}")
|
||
|
||
# Show reference time
|
||
ref_time = clock_state.get("reference-time")
|
||
if ref_time:
|
||
from datetime import datetime
|
||
try:
|
||
dt = datetime.fromisoformat(ref_time.replace("Z", "+00:00"))
|
||
ref_time_str = dt.strftime("%a %b %d %H:%M:%S %Y")
|
||
print(f"{'Ref time (UTC)':<20}: {ref_time_str}")
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
if len(sources) == 0:
|
||
return
|
||
if len(sources) == 1:
|
||
show_ntp_source_detail_single(sources[0], is_server)
|
||
return
|
||
print()
|
||
table = SimpleTable([
|
||
Column('ADDRESS'),
|
||
Column('MODE'),
|
||
Column('STATE'),
|
||
Column('STRATUM', 'right'),
|
||
Column('POLL', 'right')
|
||
])
|
||
|
||
for source in sources:
|
||
# Extract fields - handle both association and ietf-system format
|
||
address = source.get('address', 'N/A')
|
||
|
||
if is_server:
|
||
# Association format
|
||
local_mode = source.get("local-mode", "")
|
||
if ":" in local_mode:
|
||
local_mode = local_mode.split(":")[-1]
|
||
mode_str = local_mode
|
||
prefer = source.get("prefer", False)
|
||
state_str = "selected" if prefer else "candidate"
|
||
else:
|
||
# ietf-system format
|
||
mode_str = source.get('mode', 'unknown')
|
||
state_str = source.get('state', 'unknown')
|
||
if state_str == 'not-combined':
|
||
state_str = 'not combined'
|
||
|
||
stratum = source.get('stratum', 0)
|
||
poll = source.get('poll', 0)
|
||
poll_str = f"{2**poll}s" if poll else "-"
|
||
|
||
table.row(address, mode_str, state_str, stratum, poll_str)
|
||
|
||
table.print()
|
||
|
||
|
||
def show_ntp_tracking(json):
|
||
"""Display NTP clock tracking state using YANG operational data"""
|
||
ntp_data = json.get("ietf-ntp:ntp")
|
||
if not ntp_data:
|
||
print("NTP server not enabled.")
|
||
return
|
||
|
||
clock_state = ntp_data.get("clock-state", {}).get("system-status", {})
|
||
if not clock_state:
|
||
print("No clock state data available.")
|
||
return
|
||
|
||
# Reference ID
|
||
refid = clock_state.get("clock-refid", "N/A")
|
||
print(f"{'Reference ID':<20}: {refid}")
|
||
|
||
# Stratum
|
||
stratum = clock_state.get("clock-stratum", 16)
|
||
print(f"{'Stratum':<20}: {stratum}")
|
||
|
||
# Reference time (show epoch if not set)
|
||
ref_time = clock_state.get("reference-time")
|
||
if ref_time:
|
||
from datetime import datetime
|
||
try:
|
||
dt = datetime.fromisoformat(ref_time.replace("Z", "+00:00"))
|
||
ref_time_str = dt.strftime("%a %b %d %H:%M:%S %Y")
|
||
except (ValueError, AttributeError):
|
||
ref_time_str = ref_time
|
||
else:
|
||
ref_time_str = "Thu Jan 01 00:00:00 1970"
|
||
print(f"{'Ref time (UTC)':<20}: {ref_time_str}")
|
||
|
||
# System time offset (3 fraction-digits in ms = microsecond precision)
|
||
offset = clock_state.get("clock-offset")
|
||
if offset is not None:
|
||
offset_sec = float(offset) / 1000.0
|
||
sign = "slow" if offset_sec >= 0 else "fast"
|
||
print(f"{'System time':<20}: {abs(offset_sec):.6f} seconds {sign} of NTP time")
|
||
|
||
# Last offset (infix-ntp augment)
|
||
last_offset = clock_state.get("infix-ntp:last-offset")
|
||
if last_offset is not None:
|
||
last_offset_sec = float(last_offset)
|
||
sign = "+" if last_offset_sec >= 0 else ""
|
||
print(f"{'Last offset':<20}: {sign}{last_offset_sec:.9f} seconds")
|
||
else:
|
||
print(f"{'Last offset':<20}: N/A")
|
||
|
||
# RMS offset (infix-ntp augment)
|
||
rms_offset = clock_state.get("infix-ntp:rms-offset")
|
||
if rms_offset is not None:
|
||
print(f"{'RMS offset':<20}: {float(rms_offset):.9f} seconds")
|
||
else:
|
||
print(f"{'RMS offset':<20}: N/A")
|
||
|
||
# Frequency (convert from Hz difference to ppm)
|
||
nominal_freq = clock_state.get("nominal-freq")
|
||
actual_freq = clock_state.get("actual-freq")
|
||
if nominal_freq and actual_freq:
|
||
freq_diff_hz = float(actual_freq) - float(nominal_freq)
|
||
freq_ppm = (freq_diff_hz / float(nominal_freq)) * 1000000.0
|
||
if freq_ppm == 0.0:
|
||
direction = "slow"
|
||
else:
|
||
direction = "slow" if freq_ppm < 0 else "fast"
|
||
print(f"{'Frequency':<20}: {abs(freq_ppm):.3f} ppm {direction}")
|
||
|
||
# Residual freq (infix-ntp augment)
|
||
residual_freq = clock_state.get("infix-ntp:residual-freq")
|
||
if residual_freq is not None:
|
||
residual_freq_val = float(residual_freq)
|
||
sign = "+" if residual_freq_val >= 0 else ""
|
||
print(f"{'Residual freq':<20}: {sign}{residual_freq_val:.3f} ppm")
|
||
else:
|
||
print(f"{'Residual freq':<20}: N/A")
|
||
|
||
# Skew (infix-ntp augment)
|
||
skew = clock_state.get("infix-ntp:skew")
|
||
if skew is not None:
|
||
print(f"{'Skew':<20}: {float(skew):.3f} ppm")
|
||
else:
|
||
print(f"{'Skew':<20}: N/A")
|
||
|
||
# Root delay (3 fraction-digits in ms = microsecond precision)
|
||
root_delay = clock_state.get("root-delay")
|
||
if root_delay is not None:
|
||
root_delay_sec = float(root_delay) / 1000.0
|
||
print(f"{'Root delay':<20}: {root_delay_sec:.6f} seconds")
|
||
|
||
# Root dispersion (3 fraction-digits in ms = microsecond precision)
|
||
root_disp = clock_state.get("root-dispersion")
|
||
if root_disp is not None:
|
||
root_disp_sec = float(root_disp) / 1000.0
|
||
print(f"{'Root dispersion':<20}: {root_disp_sec:.6f} seconds")
|
||
|
||
# Update interval (infix-ntp augment)
|
||
update_interval = clock_state.get("infix-ntp:update-interval")
|
||
if update_interval is not None:
|
||
print(f"{'Update interval':<20}: {float(update_interval):.1f} seconds")
|
||
else:
|
||
print(f"{'Update interval':<20}: N/A")
|
||
|
||
# Leap status
|
||
sync_state = clock_state.get("sync-state", "")
|
||
if "clock-synchronized" in sync_state:
|
||
leap_status = "Normal"
|
||
elif "clock-never-set" in sync_state:
|
||
leap_status = "Not synchronised"
|
||
else:
|
||
leap_status = "Unknown"
|
||
print(f"{'Leap status':<20}: {leap_status}")
|
||
|
||
|
||
def show_ntp_association_detail(assoc):
|
||
"""Display detailed information for a single NTP association"""
|
||
print(f"{'Address':<20}: {assoc.get('address', 'N/A')}")
|
||
|
||
# Mode
|
||
local_mode = assoc.get("local-mode", "")
|
||
if ":" in local_mode:
|
||
local_mode = local_mode.split(":")[-1]
|
||
|
||
mode_desc = {
|
||
'client': 'Server (client mode) [^]',
|
||
'active': 'Peer (symmetric active) [=]',
|
||
'broadcast-client': 'Broadcast/Local refclock [#]'
|
||
}.get(local_mode, local_mode)
|
||
print(f"{'Mode':<20}: {mode_desc}")
|
||
|
||
# State/Prefer
|
||
prefer = assoc.get("prefer", False)
|
||
state_desc = "Selected sync source [*]" if prefer else "Candidate [+]"
|
||
print(f"{'State':<20}: {state_desc}")
|
||
|
||
# Configured
|
||
isconfigured = assoc.get("isconfigured", False)
|
||
print(f"{'Configured':<20}: {'Yes' if isconfigured else 'No (dynamic)'}")
|
||
|
||
# Stratum
|
||
stratum = assoc.get("stratum")
|
||
if stratum is not None:
|
||
print(f"{'Stratum':<20}: {stratum}")
|
||
|
||
# Poll interval
|
||
poll = assoc.get("poll")
|
||
if poll is not None:
|
||
print(f"{'Poll interval':<20}: {poll} (2^{poll} seconds = {2**poll}s)")
|
||
|
||
# Reachability
|
||
reach = assoc.get("reach")
|
||
if reach is not None:
|
||
print(f"{'Reachability':<20}: {reach:03o} (octal) = {reach:08b}b")
|
||
|
||
# Time since last packet
|
||
now = assoc.get("now")
|
||
if now is not None:
|
||
print(f"{'Last RX':<20}: {now}s ago")
|
||
|
||
# Offset
|
||
offset = assoc.get("offset")
|
||
if offset is not None:
|
||
offset_ms = float(offset)
|
||
if abs(offset_ms) < 1.0:
|
||
offset_str = f"{offset_ms * 1000.0:+.1f}us ({offset_ms:+.6f}ms)"
|
||
else:
|
||
offset_str = f"{offset_ms:+.3f}ms ({offset_ms / 1000.0:+.6f}s)"
|
||
print(f"{'Offset':<20}: {offset_str}")
|
||
|
||
# Delay
|
||
delay = assoc.get("delay")
|
||
if delay is not None:
|
||
delay_ms = float(delay)
|
||
if abs(delay_ms) < 1.0:
|
||
delay_str = f"{delay_ms * 1000.0:.1f}us ({delay_ms:.6f}ms)"
|
||
else:
|
||
delay_str = f"{delay_ms:.3f}ms ({delay_ms / 1000.0:.6f}s)"
|
||
print(f"{'Delay':<20}: {delay_str}")
|
||
|
||
# Dispersion
|
||
dispersion = assoc.get("dispersion")
|
||
if dispersion is not None:
|
||
disp_ms = float(dispersion)
|
||
if abs(disp_ms) < 1.0:
|
||
disp_str = f"{disp_ms * 1000.0:.1f}us ({disp_ms:.6f}ms)"
|
||
else:
|
||
disp_str = f"{disp_ms:.3f}ms ({disp_ms / 1000.0:.6f}s)"
|
||
print(f"{'Dispersion':<20}: {disp_str}")
|
||
|
||
def show_ntp_source(json, address=None):
|
||
"""Display NTP associations/sources"""
|
||
ntp_data = json.get("ietf-ntp:ntp")
|
||
if not ntp_data:
|
||
print("NTP server not enabled.")
|
||
return
|
||
|
||
associations = ntp_data.get("associations", {}).get("association", [])
|
||
|
||
# Check for GPS/GNSS reference clock sources from hardware data
|
||
hw_components = json.get("ietf-hardware:hardware", {}).get("component", [])
|
||
gps_sources = [c for c in hw_components if c.get("class") == "infix-hardware:gps"]
|
||
|
||
# Show GPS reference clocks
|
||
if gps_sources:
|
||
clock_state = ntp_data.get("clock-state", {}).get("system-status", {})
|
||
clock_refid = clock_state.get("clock-refid", "").strip()
|
||
|
||
for gps in gps_sources:
|
||
gps_data = gps.get("infix-hardware:gps-receiver", {})
|
||
name = gps.get("name", "unknown")
|
||
driver = gps_data.get("driver", "Unknown")
|
||
fix = gps_data.get("fix-mode", "none")
|
||
activated = gps_data.get("activated", False)
|
||
sat_used = gps_data.get("satellites-used", 0)
|
||
sat_vis = gps_data.get("satellites-visible", 0)
|
||
|
||
# Determine if this GPS is the current sync source
|
||
is_synced = clock_refid in ("GPS", "PPS", "GLO", "GAL", "BDS", "GNSS")
|
||
|
||
state = "selected" if is_synced else ("active" if activated else "inactive")
|
||
print(f"{'Reference Clock':<20}: {name} ({driver})")
|
||
print(f"{'Status':<20}: {state}")
|
||
print(f"{'Fix Mode':<20}: {fix.upper()}")
|
||
if sat_vis:
|
||
print(f"{'Satellites':<20}: {sat_used}/{sat_vis} (used/visible)")
|
||
print()
|
||
|
||
if not associations and not gps_sources:
|
||
print("No NTP associations found.")
|
||
return
|
||
|
||
if not associations:
|
||
return
|
||
|
||
# If address specified, show detailed view for that association
|
||
if address:
|
||
matching = [a for a in associations if a.get('address') == address]
|
||
if not matching:
|
||
print(f"No NTP association found with address: {address}")
|
||
return
|
||
show_ntp_association_detail(matching[0])
|
||
return
|
||
|
||
# If single association, show detailed view automatically
|
||
if len(associations) == 1:
|
||
show_ntp_association_detail(associations[0])
|
||
return
|
||
|
||
# Table with chronyc-style columns
|
||
table = SimpleTable([
|
||
Column('MS'),
|
||
Column('Name/IP address', flexible=True),
|
||
Column('Stratum', 'right'),
|
||
Column('Poll', 'right'),
|
||
Column('Reach', 'right'),
|
||
Column('LastRx', 'right'),
|
||
Column('Last sample', 'right')
|
||
])
|
||
|
||
# Build rows
|
||
for assoc in associations:
|
||
# State indicator: * = prefer (sync source), + = candidate
|
||
prefer = assoc.get("prefer", False)
|
||
state = "*" if prefer else "+"
|
||
|
||
# Mode indicator
|
||
local_mode = assoc.get("local-mode", "")
|
||
if ":" in local_mode:
|
||
local_mode = local_mode.split(":")[-1]
|
||
# Map to chronyc-style mode indicators
|
||
mode_indicator = "^" # Default to server mode
|
||
if local_mode == "active":
|
||
mode_indicator = "="
|
||
elif local_mode == "broadcast-client":
|
||
mode_indicator = "#"
|
||
|
||
ms_col = f"{mode_indicator}{state}"
|
||
address = assoc.get("address", "N/A")
|
||
stratum = assoc.get("stratum", 0)
|
||
|
||
# Poll interval (log2 seconds)
|
||
poll = assoc.get("poll")
|
||
poll_str = str(poll) if poll is not None else "-"
|
||
|
||
# Reachability register (display as octal)
|
||
reach = assoc.get("reach")
|
||
if reach is not None:
|
||
reach_str = f"{reach:03o}"
|
||
else:
|
||
reach_str = "-"
|
||
|
||
# Time since last packet (LastRx)
|
||
now = assoc.get("now")
|
||
now_str = str(now) if now is not None else "-"
|
||
|
||
# Offset (in milliseconds, convert to microseconds for display)
|
||
offset = assoc.get("offset")
|
||
if offset is not None:
|
||
offset_ms = float(offset)
|
||
if abs(offset_ms) < 1.0:
|
||
# Show in microseconds if less than 1ms
|
||
offset_str = f"{offset_ms * 1000.0:+.0f}us"
|
||
else:
|
||
# Show in milliseconds
|
||
offset_str = f"{offset_ms:+.3f}ms"
|
||
else:
|
||
offset_str = "-"
|
||
|
||
# Delay (in milliseconds) - show as +/- similar to chronyc
|
||
delay = assoc.get("delay")
|
||
if delay is not None:
|
||
delay_ms = float(delay)
|
||
if abs(delay_ms) < 1.0:
|
||
delay_str = f"+/- {delay_ms * 1000.0:.0f}us"
|
||
else:
|
||
delay_str = f"+/- {delay_ms:.3f}ms"
|
||
else:
|
||
delay_str = ""
|
||
|
||
# Last sample column combines offset and delay like chronyc
|
||
if offset_str != "-":
|
||
last_sample = f"{offset_str} {delay_str}"
|
||
else:
|
||
last_sample = "-"
|
||
|
||
table.row(ms_col, address, stratum, poll_str, reach_str, now_str, last_sample)
|
||
|
||
table.print()
|
||
|
||
|
||
def _analyze_group_permissions(nacm):
|
||
"""Analyze NACM rules to determine effective permissions per group.
|
||
|
||
NACM rule evaluation order:
|
||
1. Rule-lists are processed sequentially in configuration order
|
||
2. Within each rule-list, rules are evaluated sequentially
|
||
3. First matching rule wins - no further rules are evaluated
|
||
4. If no rule matches, global defaults apply
|
||
5. permit-all (module-name=*, access-operations=*) bypasses everything
|
||
"""
|
||
read_default = nacm.get('read-default', 'permit') == 'permit'
|
||
write_default = nacm.get('write-default', 'permit') == 'permit'
|
||
exec_default = nacm.get('exec-default', 'permit') == 'permit'
|
||
|
||
rule_lists = nacm.get('rule-list', [])
|
||
groups_config = nacm.get('groups', {}).get('group', [])
|
||
|
||
# Collect all deny rules that apply to "*" (all groups)
|
||
# These create restrictions for groups without permit-all
|
||
global_denials = []
|
||
for rule_list in rule_lists:
|
||
if '*' in rule_list.get('group', []):
|
||
for rule in rule_list.get('rule', []):
|
||
if rule.get('action') == 'deny':
|
||
global_denials.append(rule)
|
||
|
||
results = []
|
||
|
||
for group in groups_config:
|
||
group_name = group.get('name', '')
|
||
|
||
# Defaults
|
||
can_read = read_default
|
||
can_write = write_default
|
||
can_exec = exec_default
|
||
restrictions = []
|
||
has_permit_all = False
|
||
|
||
# Process rule-lists in order (first match wins)
|
||
for rule_list in rule_lists:
|
||
list_groups = rule_list.get('group', [])
|
||
|
||
# Check if this rule-list applies to this group specifically
|
||
if group_name not in list_groups:
|
||
continue
|
||
|
||
for rule in rule_list.get('rule', []):
|
||
action = rule.get('action')
|
||
access_ops = rule.get('access-operations', '')
|
||
module_name = rule.get('module-name', '')
|
||
|
||
# permit-all pattern: bypasses ALL other rules including global denials
|
||
if action == 'permit' and module_name == '*' and access_ops == '*':
|
||
has_permit_all = True
|
||
break
|
||
|
||
# deny pattern for write+exec (like guest)
|
||
if action == 'deny' and module_name == '*':
|
||
ops = access_ops.lower()
|
||
if all(op in ops for op in ['create', 'update', 'delete']):
|
||
can_write = False
|
||
if 'exec' in ops:
|
||
can_exec = False
|
||
|
||
if has_permit_all:
|
||
break
|
||
|
||
# If not permit-all, global denials create restrictions
|
||
# These affect READ too (access-operations: "*" includes read)
|
||
if not has_permit_all:
|
||
for rule in global_denials:
|
||
path = rule.get('path', '')
|
||
module = rule.get('module-name', '')
|
||
|
||
if path:
|
||
# Extract meaningful part: /ietf-system:system/.../password -> password
|
||
restriction = path.rstrip('/').split('/')[-1]
|
||
elif module:
|
||
# Remove ietf- prefix for brevity: ietf-keystore -> keystore
|
||
restriction = module.replace('ietf-', '')
|
||
else:
|
||
continue
|
||
|
||
if restriction and restriction not in restrictions:
|
||
restrictions.append(restriction)
|
||
|
||
results.append({
|
||
'group': group_name,
|
||
'read': can_read,
|
||
'write': can_write,
|
||
'exec': can_exec,
|
||
'restrictions': restrictions,
|
||
'has_permit_all': has_permit_all
|
||
})
|
||
|
||
return results
|
||
|
||
|
||
def _nacm_permissions_matrix(nacm, width=None):
|
||
"""Render NACM group permissions as a colored matrix.
|
||
|
||
Symbols: ✓=full access, ⚠=restricted, ✗=denied
|
||
|
||
Returns:
|
||
Multi-line string containing the rendered matrix, or None if no groups
|
||
"""
|
||
permissions = _analyze_group_permissions(nacm)
|
||
if not permissions:
|
||
return None
|
||
|
||
# Column widths
|
||
group_col_width = max(len(p['group']) for p in permissions)
|
||
group_col_width = max(group_col_width, 5) # Minimum for "GROUP"
|
||
cell_width = 7 # Width for READ/WRITE/EXEC columns
|
||
|
||
# Box drawing
|
||
top_border = "┌" + "─" * (group_col_width + 2) + "┬"
|
||
top_border += "─" * (cell_width + 2) + "┬"
|
||
top_border += "─" * (cell_width + 2) + "┬"
|
||
top_border += "─" * (cell_width + 2) + "┐"
|
||
|
||
header_border = "├" + "─" * (group_col_width + 2) + "┼"
|
||
header_border += "─" * (cell_width + 2) + "┼"
|
||
header_border += "─" * (cell_width + 2) + "┼"
|
||
header_border += "─" * (cell_width + 2) + "┤"
|
||
|
||
bottom_border = "└" + "─" * (group_col_width + 2) + "┴"
|
||
bottom_border += "─" * (cell_width + 2) + "┴"
|
||
bottom_border += "─" * (cell_width + 2) + "┴"
|
||
bottom_border += "─" * (cell_width + 2) + "┘"
|
||
|
||
# Header row
|
||
header = f"│ {'GROUP':<{group_col_width}} │"
|
||
header += f" {'READ':^{cell_width}} │"
|
||
header += f" {'WRITE':^{cell_width}} │"
|
||
header += f" {'EXEC':^{cell_width}} │"
|
||
|
||
# Calculate centering
|
||
matrix_width = len(top_border)
|
||
if width and width > matrix_width:
|
||
padding = (width - matrix_width) // 2
|
||
indent = " " * padding
|
||
else:
|
||
indent = ""
|
||
|
||
lines = []
|
||
lines.append(indent + top_border)
|
||
lines.append(indent + header)
|
||
lines.append(indent + header_border)
|
||
|
||
# Data rows
|
||
for perm in permissions:
|
||
group_name = perm['group']
|
||
has_restrictions = bool(perm['restrictions'])
|
||
|
||
# Determine symbols and colors for each cell
|
||
def get_cell(has_access, has_restrictions):
|
||
if not has_access:
|
||
return Decore.red_bg(f" {'✗':^{cell_width}} ")
|
||
elif has_restrictions:
|
||
return Decore.yellow_bg(f" {'⚠':^{cell_width}} ")
|
||
else:
|
||
return Decore.green_bg(f" {'✓':^{cell_width}} ")
|
||
|
||
read_cell = get_cell(perm['read'], has_restrictions)
|
||
write_cell = get_cell(perm['write'], has_restrictions)
|
||
exec_cell = get_cell(perm['exec'], has_restrictions)
|
||
|
||
row = f"│ {group_name:<{group_col_width}} │{read_cell}│{write_cell}│{exec_cell}│"
|
||
lines.append(indent + row)
|
||
|
||
lines.append(indent + bottom_border)
|
||
|
||
# Legend
|
||
legend_data = [
|
||
("✓ Full", Decore.green_bg),
|
||
("⚠ Restricted", Decore.yellow_bg),
|
||
("✗ Denied", Decore.red_bg)
|
||
]
|
||
colorized_parts = [bg_func(f" {text} ") for text, bg_func in legend_data]
|
||
legend = " ".join(colorized_parts)
|
||
|
||
if width:
|
||
visible_legend = " ".join([f" {text} " for text, _ in legend_data])
|
||
legend_padding = max(0, (width - len(visible_legend)) // 2)
|
||
lines.append(" " * legend_padding + legend)
|
||
else:
|
||
lines.append(indent + legend)
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def show_nacm(json):
|
||
"""Display users and NACM (Network Configuration Access Control) groups"""
|
||
min_width = 62
|
||
|
||
nacm = json.get("ietf-netconf-acm:nacm", {})
|
||
if not nacm:
|
||
print("NACM not configured.")
|
||
print()
|
||
|
||
if nacm:
|
||
enabled = "yes" if nacm.get("enable-nacm", True) else "no"
|
||
print(f"{'enabled':<21}: {enabled}")
|
||
print(f"{'default read access':<21}: {nacm.get('read-default', 'permit')}")
|
||
print(f"{'default write access':<21}: {nacm.get('write-default', 'deny')}")
|
||
print(f"{'default exec access':<21}: {nacm.get('exec-default', 'permit')}")
|
||
print(f"{'denied operations':<21}: {nacm.get('denied-operations', 0)}")
|
||
print(f"{'denied data writes':<21}: {nacm.get('denied-data-writes', 0)}")
|
||
print(f"{'denied notifications':<21}: {nacm.get('denied-notifications', 0)}")
|
||
print()
|
||
|
||
|
||
# Group permissions matrix
|
||
if nacm:
|
||
matrix = _nacm_permissions_matrix(nacm, min_width)
|
||
if matrix:
|
||
print(matrix)
|
||
print()
|
||
|
||
# Users table
|
||
system = json.get("ietf-system:system", {})
|
||
users = system.get("authentication", {}).get("user", [])
|
||
if users:
|
||
user_table = SimpleTable([
|
||
Column('USER', min_width=12),
|
||
Column('SHELL'),
|
||
Column('LOGIN', flexible=True)
|
||
], min_width=min_width)
|
||
|
||
for user in users:
|
||
name = user.get("name", "")
|
||
shell_data = user.get("infix-system:shell", "false")
|
||
shell = shell_data.split(":")[-1] if ":" in shell_data else shell_data
|
||
|
||
has_password = bool(user.get("password"))
|
||
has_keys = bool(user.get("authorized-key"))
|
||
if has_password and has_keys:
|
||
login = "password+key"
|
||
elif has_password:
|
||
login = "password"
|
||
elif has_keys:
|
||
login = "key"
|
||
else:
|
||
login = "-"
|
||
|
||
user_table.row(name, shell, login)
|
||
|
||
user_table.print()
|
||
print()
|
||
|
||
# Groups table
|
||
groups = nacm.get("groups", {}).get("group", []) if nacm else []
|
||
if groups:
|
||
group_table = SimpleTable([
|
||
Column('GROUP', min_width=12),
|
||
Column('USERS', flexible=True)
|
||
], min_width=min_width)
|
||
|
||
for group in groups:
|
||
name = group.get("name", "")
|
||
members = " ".join(group.get("user-name", []))
|
||
group_table.row(name, members)
|
||
|
||
group_table.print()
|
||
print()
|
||
|
||
|
||
def show_nacm_group(json):
|
||
"""Display detailed information about a specific NACM group."""
|
||
group_name = json.get('_group_name')
|
||
nacm = json.get("ietf-netconf-acm:nacm", {})
|
||
|
||
if not nacm:
|
||
print("NACM not configured.")
|
||
return
|
||
|
||
# Find the group
|
||
groups = nacm.get("groups", {}).get("group", [])
|
||
group = None
|
||
for g in groups:
|
||
if g.get("name") == group_name:
|
||
group = g
|
||
break
|
||
|
||
if not group:
|
||
print(f"Group '{group_name}' not found.")
|
||
return
|
||
|
||
# Group info
|
||
members = group.get("user-name", [])
|
||
members_str = ", ".join(members) if members else "(none)"
|
||
print(f"{'members':<17}: {members_str}")
|
||
|
||
# Analyze permissions
|
||
permissions = _analyze_group_permissions(nacm)
|
||
group_perm = None
|
||
for p in permissions:
|
||
if p['group'] == group_name:
|
||
group_perm = p
|
||
break
|
||
|
||
if group_perm:
|
||
def perm_str(has_access, has_restrictions):
|
||
if not has_access:
|
||
return Decore.red("no")
|
||
elif has_restrictions:
|
||
return Decore.yellow("restricted")
|
||
else:
|
||
return Decore.green("yes")
|
||
|
||
has_restrictions = bool(group_perm['restrictions'])
|
||
print(f"{'read permission':<17}: {perm_str(group_perm['read'], has_restrictions)}")
|
||
print(f"{'write permission':<17}: {perm_str(group_perm['write'], has_restrictions)}")
|
||
print(f"{'exec permission':<17}: {perm_str(group_perm['exec'], has_restrictions)}")
|
||
|
||
# Find applicable rules, respecting NACM evaluation order
|
||
# Rule-lists are evaluated in order; first matching rule wins
|
||
# If a permit-all rule is found, no further rules would be evaluated
|
||
rule_lists = nacm.get("rule-list", [])
|
||
applicable_rules = []
|
||
has_permit_all = group_perm.get('has_permit_all', False) if group_perm else False
|
||
|
||
for rule_list in rule_lists:
|
||
list_groups = rule_list.get("group", [])
|
||
|
||
# Check if this rule-list applies to this group specifically (not via "*")
|
||
if group_name in list_groups:
|
||
list_name = rule_list.get("name", "")
|
||
for rule in rule_list.get("rule", []):
|
||
applicable_rules.append({
|
||
'list': list_name,
|
||
'rule': rule,
|
||
'applies_via': group_name
|
||
})
|
||
|
||
# Check if this is a permit-all rule
|
||
action = rule.get('action')
|
||
module_name = rule.get('module-name', '')
|
||
access_ops = rule.get('access-operations', '')
|
||
if action == 'permit' and module_name == '*' and access_ops == '*':
|
||
# permit-all matches everything, stop here
|
||
break
|
||
|
||
if has_permit_all:
|
||
# Don't process any more rule-lists
|
||
break
|
||
|
||
# Only add rules from "*" group if we don't have permit-all
|
||
if not has_permit_all:
|
||
for rule_list in rule_lists:
|
||
list_groups = rule_list.get("group", [])
|
||
if "*" in list_groups and group_name not in list_groups:
|
||
list_name = rule_list.get("name", "")
|
||
for rule in rule_list.get("rule", []):
|
||
applicable_rules.append({
|
||
'list': list_name,
|
||
'rule': rule,
|
||
'applies_via': "*"
|
||
})
|
||
|
||
if applicable_rules:
|
||
print(f"{'applicable rules':<17}: {len(applicable_rules)}")
|
||
|
||
for item in applicable_rules:
|
||
rule = item['rule']
|
||
rule_name = rule.get('name', '(unnamed)')
|
||
action = rule.get('action', 'permit')
|
||
access_ops = rule.get('access-operations', '*')
|
||
module_name = rule.get('module-name', '')
|
||
path = rule.get('path', '')
|
||
rpc_name = rule.get('rpc-name', '')
|
||
comment = rule.get('comment', '')
|
||
|
||
# Format action with color
|
||
if action == 'permit':
|
||
action_str = Decore.green("permit")
|
||
else:
|
||
action_str = Decore.red("deny")
|
||
|
||
# Build target description
|
||
target = ""
|
||
if path:
|
||
target = path
|
||
elif module_name:
|
||
target = module_name
|
||
if rpc_name:
|
||
target += f" (rpc: {rpc_name})"
|
||
|
||
applies_note = f" (via '*')" if item['applies_via'] == '*' else ""
|
||
|
||
Decore.title(f"{rule_name}{applies_note}", width=70)
|
||
print(f"{' action':<13}: {action_str}")
|
||
print(f"{' operations':<13}: {access_ops}")
|
||
if target:
|
||
print(f"{' target':<13}: {target}")
|
||
if comment:
|
||
print(f"{' comment':<13}: {comment}")
|
||
print()
|
||
|
||
|
||
def show_nacm_user(json):
|
||
"""Display detailed information about a specific user's NACM permissions."""
|
||
user_name = json.get('_user_name')
|
||
nacm = json.get("ietf-netconf-acm:nacm", {})
|
||
system = json.get("ietf-system:system", {})
|
||
|
||
# Find the user
|
||
users = system.get("authentication", {}).get("user", [])
|
||
user = None
|
||
for u in users:
|
||
if u.get("name") == user_name:
|
||
user = u
|
||
break
|
||
|
||
if not user:
|
||
print(f"User '{user_name}' not found.")
|
||
return
|
||
|
||
# User details
|
||
shell_data = user.get("infix-system:shell", "false")
|
||
shell = shell_data.split(":")[-1] if ":" in shell_data else shell_data
|
||
print(f"{'shell':<17}: {shell}")
|
||
|
||
has_password = bool(user.get("password"))
|
||
has_keys = bool(user.get("authorized-key"))
|
||
if has_password and has_keys:
|
||
login = "password + SSH key"
|
||
elif has_password:
|
||
login = "password"
|
||
elif has_keys:
|
||
login = "SSH key"
|
||
else:
|
||
login = "(none configured)"
|
||
print(f"{'login':<17}: {login}")
|
||
|
||
# Find which NACM groups this user belongs to
|
||
if not nacm:
|
||
print("NACM not configured.")
|
||
return
|
||
|
||
groups = nacm.get("groups", {}).get("group", [])
|
||
user_groups = []
|
||
for group in groups:
|
||
if user_name in group.get("user-name", []):
|
||
user_groups.append(group.get("name"))
|
||
|
||
groups_str = ", ".join(user_groups) if user_groups else "(none)"
|
||
print(f"{'nacm group':<17}: {groups_str}")
|
||
|
||
# Show effective permissions for each group
|
||
if user_groups:
|
||
permissions = _analyze_group_permissions(nacm)
|
||
|
||
for group_name in user_groups:
|
||
for p in permissions:
|
||
if p['group'] == group_name:
|
||
def perm_str(has_access, has_restrictions):
|
||
if not has_access:
|
||
return Decore.red("no")
|
||
elif has_restrictions:
|
||
return Decore.yellow("restricted")
|
||
else:
|
||
return Decore.green("yes")
|
||
|
||
has_restrictions = bool(p['restrictions'])
|
||
print(f"{'read permission':<17}: {perm_str(p['read'], has_restrictions)}")
|
||
print(f"{'write permission':<17}: {perm_str(p['write'], has_restrictions)}")
|
||
print(f"{'exec permission':<17}: {perm_str(p['exec'], has_restrictions)}")
|
||
break
|
||
|
||
print()
|
||
print("For detailed rules, use: show nacm group <name>")
|
||
|
||
|
||
def _keystore_format_name(key_format):
|
||
"""Simplify key-format to a short display name."""
|
||
fmt = key_format.split(':')[-1] if ':' in key_format else key_format
|
||
return fmt.replace('-key-format', '')
|
||
|
||
|
||
def _keystore_decode_symmetric(key):
|
||
"""Decode a symmetric key value for display."""
|
||
key_format = key.get('key-format', '')
|
||
fmt = _keystore_format_name(key_format)
|
||
b64val = key.get('cleartext-symmetric-key', '')
|
||
|
||
if fmt == 'passphrase' and b64val:
|
||
try:
|
||
return base64.b64decode(b64val).decode('utf-8')
|
||
except Exception:
|
||
return b64val
|
||
return b64val if b64val else '-'
|
||
|
||
|
||
def _keystore_find_key(keystore, kind, name):
|
||
"""Find a key by type and name in the keystore."""
|
||
if kind == 'symmetric':
|
||
keys = keystore.get('symmetric-keys', {}).get('symmetric-key', [])
|
||
else:
|
||
keys = keystore.get('asymmetric-keys', {}).get('asymmetric-key', [])
|
||
for key in keys:
|
||
if key.get('name') == name:
|
||
return key
|
||
return None
|
||
|
||
|
||
def _keystore_asym_type(key):
|
||
"""Derive asymmetric key algorithm from key format fields."""
|
||
for field in ('private-key-format', 'public-key-format'):
|
||
fmt = key.get(field, '')
|
||
name = fmt.split(':')[-1] if ':' in fmt else fmt
|
||
name = name.replace('-private-key-format', '').replace('-public-key-format', '')
|
||
if name:
|
||
return name
|
||
return ''
|
||
|
||
|
||
def show_keystore_detail(keystore, kind, name):
|
||
"""Display detailed information about a specific key."""
|
||
key = _keystore_find_key(keystore, kind, name)
|
||
if not key:
|
||
print(f'{kind.capitalize()} key "{name}" not found.')
|
||
return
|
||
|
||
print(f"{'name':<{20}}: {name}")
|
||
if kind == 'symmetric':
|
||
fmt = _keystore_format_name(key.get('key-format', ''))
|
||
value = _keystore_decode_symmetric(key)
|
||
print(f"{'format':<{20}}: {fmt}")
|
||
print(f"{'value':<{20}}: {value}")
|
||
else:
|
||
ktype = _keystore_asym_type(key)
|
||
if ktype:
|
||
print(f"{'algorithm':<{20}}: {ktype}")
|
||
pub_fmt = _keystore_format_name(key.get('public-key-format', ''))
|
||
if pub_fmt:
|
||
print(f"{'public key format':<{20}}: {pub_fmt}")
|
||
pub_key = key.get('public-key', '')
|
||
if pub_key:
|
||
print(f"{'public key':<{20}}: {pub_key}")
|
||
|
||
|
||
def show_keystore(json, kind=None, name=None):
|
||
"""Display keystore keys overview or detail for a specific key."""
|
||
keystore = json.get("ietf-keystore:keystore", {})
|
||
if not keystore:
|
||
print("Keystore is empty.")
|
||
return
|
||
|
||
if kind and name:
|
||
show_keystore_detail(keystore, kind, name)
|
||
return
|
||
|
||
TABLE_WIDTH = 72
|
||
|
||
# Symmetric keys
|
||
sym_keys_data = keystore.get('symmetric-keys', {}).get('symmetric-key', [])
|
||
if sym_keys_data:
|
||
Decore.title("Symmetric Keys", TABLE_WIDTH)
|
||
table = SimpleTable([
|
||
Column('NAME', flexible=True),
|
||
Column('FORMAT'),
|
||
Column('VALUE', flexible=True)
|
||
], min_width=TABLE_WIDTH)
|
||
|
||
for key in sym_keys_data:
|
||
name = key.get('name', '')
|
||
fmt = _keystore_format_name(key.get('key-format', ''))
|
||
value = _keystore_decode_symmetric(key)
|
||
table.row(name, fmt, value)
|
||
|
||
table.print()
|
||
|
||
# Asymmetric keys
|
||
asym_keys_data = keystore.get('asymmetric-keys', {}).get('asymmetric-key', [])
|
||
if asym_keys_data:
|
||
Decore.title("Asymmetric Keys", TABLE_WIDTH)
|
||
table = SimpleTable([
|
||
Column('NAME', flexible=True),
|
||
Column('TYPE'),
|
||
Column('PUBLIC KEY', flexible=True)
|
||
], min_width=TABLE_WIDTH)
|
||
|
||
for key in asym_keys_data:
|
||
name = key.get('name', '')
|
||
ktype = _keystore_asym_type(key)
|
||
|
||
pub_key = key.get('public-key', '')
|
||
if len(pub_key) > 40:
|
||
pub_key = pub_key[:37] + '...'
|
||
|
||
table.row(name, ktype, pub_key)
|
||
|
||
table.print()
|
||
|
||
if not sym_keys_data and not asym_keys_data:
|
||
print("Keystore is empty.")
|
||
|
||
|
||
def show_system(json):
|
||
"""System information overivew"""
|
||
if not json.get("ietf-system:system-state"):
|
||
print("Error: No system data available.")
|
||
sys.exit(1)
|
||
|
||
system_state = json["ietf-system:system-state"]
|
||
platform = system_state.get("platform", {})
|
||
clock = system_state.get("clock", {})
|
||
software = system_state.get("infix-system:software", {})
|
||
runtime = json.get("runtime", {})
|
||
|
||
# Calculate uptime
|
||
uptime_str = "Unknown"
|
||
if clock.get("current-datetime") and clock.get("boot-datetime"):
|
||
try:
|
||
current = datetime.fromisoformat(clock["current-datetime"].replace("Z", "+00:00"))
|
||
boot = datetime.fromisoformat(clock["boot-datetime"].replace("Z", "+00:00"))
|
||
uptime = current - boot
|
||
days = uptime.days
|
||
hours, remainder = divmod(uptime.seconds, 3600)
|
||
minutes, seconds = divmod(remainder, 60)
|
||
uptime_str = f"{days}d {hours:02d}:{minutes:02d}:{seconds:02d}"
|
||
except (ValueError, KeyError):
|
||
pass
|
||
|
||
width = 62
|
||
print(Decore.invert(f"{'SYSTEM INFORMATION':<{width}}"))
|
||
print(f"{'OS Name':<20}: {platform.get('os-name', 'Unknown')}")
|
||
print(f"{'OS Version':<20}: {platform.get('os-version', 'Unknown')}")
|
||
print(f"{'Architecture':<20}: {platform.get('machine', 'Unknown')}")
|
||
|
||
booted = software.get("booted", "Unknown")
|
||
slots = software.get("slot", [])
|
||
booted_slot = None
|
||
for slot in slots:
|
||
if slot.get("state") == "booted":
|
||
booted_slot = slot
|
||
break
|
||
|
||
if booted_slot:
|
||
bundle = booted_slot.get("bundle", {})
|
||
print(f"{'Boot Partition':<20}: {booted} ({bundle.get('version', 'Unknown')})")
|
||
else:
|
||
print(f"{'Boot Partition':<20}: {booted}")
|
||
|
||
# Format current time more readably: "2025-10-18 13:23:47 +00:00"
|
||
current_time = clock.get('current-datetime', 'Unknown')
|
||
if current_time != 'Unknown':
|
||
try:
|
||
dt = datetime.fromisoformat(current_time.replace("Z", "+00:00"))
|
||
# Format as "YYYY-MM-DD HH:MM:SS +HH:MM" (keep UTC offset)
|
||
current_time = dt.strftime('%Y-%m-%d %H:%M:%S %z')
|
||
# Insert colon in timezone offset: +0000 -> +00:00
|
||
if len(current_time) >= 5 and current_time[-5] in ['+', '-']:
|
||
current_time = current_time[:-2] + ':' + current_time[-2:]
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
print(f"{'Current Time':<20}: {current_time}")
|
||
print(f"{'Uptime':<20}: {uptime_str}")
|
||
|
||
Decore.title("Status", width)
|
||
|
||
load = runtime.get("load", {})
|
||
if load:
|
||
print(f"{'Load Average':<20}: {load.get('1min', '?')}, {load.get('5min', '?')}, {load.get('15min', '?')}")
|
||
|
||
memory = runtime.get("memory", {})
|
||
if memory:
|
||
total = memory.get("MemTotal", 0)
|
||
available = memory.get("MemAvailable", 0)
|
||
used = total - available
|
||
percent = int((used / total * 100)) if total > 0 else 0
|
||
print(f"{'Memory':<20}: {used} / {total} MB ({percent}% used)")
|
||
|
||
# Show CPU temperature and fan speed on one line
|
||
cpu_temp = runtime.get("cpu_temp")
|
||
fan_rpm = runtime.get("fan_rpm")
|
||
|
||
if cpu_temp is not None or fan_rpm is not None:
|
||
status_line = ""
|
||
|
||
# Add CPU temperature with color coding
|
||
if cpu_temp is not None:
|
||
temp_str = f"{cpu_temp:.1f} °C"
|
||
if cpu_temp < 60:
|
||
temp_colored = Decore.green(temp_str)
|
||
elif cpu_temp < 75:
|
||
temp_colored = Decore.yellow(temp_str)
|
||
else:
|
||
temp_colored = Decore.red(temp_str)
|
||
status_line = f"CPU: {temp_colored}"
|
||
|
||
# Add fan speed if available
|
||
if fan_rpm is not None:
|
||
if status_line:
|
||
status_line += f", Fan: {fan_rpm} RPM"
|
||
else:
|
||
status_line = f"Fan: {fan_rpm} RPM"
|
||
|
||
if status_line:
|
||
print(f"{'Hardware':<20}: {status_line}")
|
||
|
||
disk = runtime.get("disk", [])
|
||
# Filter out root partition (/) - it's read-only and shows confusing 100% usage
|
||
disk_filtered = [d for d in disk if d.get("mount") != "/"]
|
||
if disk_filtered:
|
||
Decore.title("Disk Usage", width)
|
||
disk_table = SimpleTable([
|
||
Column('MOUNTPOINT', flexible=True),
|
||
Column('SIZE', 'right'),
|
||
Column('USED', 'right'),
|
||
Column('AVAIL', 'right'),
|
||
Column('USE%', 'right')
|
||
], min_width=width)
|
||
|
||
for d in disk_filtered:
|
||
disk_table.row(
|
||
d.get("mount", "?"),
|
||
d.get("size", "?"),
|
||
d.get("used", "?"),
|
||
d.get("available", "?"),
|
||
d.get("percent", "?")
|
||
)
|
||
disk_table.print()
|
||
|
||
|
||
def show_dhcp_server(json, stats):
|
||
data = json.get("infix-dhcp-server:dhcp-server")
|
||
if not data:
|
||
print("DHCP server not enabled.")
|
||
return
|
||
|
||
server = DhcpServer(data)
|
||
|
||
if stats:
|
||
server.print_stats()
|
||
else:
|
||
hdr = (f"{'IP ADDRESS':<{PadDhcpServer.ip}}"
|
||
f"{'MAC':<{PadDhcpServer.mac}}"
|
||
f"{'HOSTNAME':<{PadDhcpServer.host}}"
|
||
f"{'CLIENT ID':<{PadDhcpServer.cid}}"
|
||
f"{'EXPIRES':>{PadDhcpServer.exp}}")
|
||
print(Decore.invert(hdr))
|
||
server.print()
|
||
|
||
|
||
def show_lldp(json):
|
||
if not json.get("ieee802-dot1ab-lldp:lldp"):
|
||
print("Error: No LLDP data available.")
|
||
sys.exit(1)
|
||
|
||
lldp_ports = json["ieee802-dot1ab-lldp:lldp"].get("port", [])
|
||
|
||
if not lldp_ports:
|
||
print("No LLDP neighbors found.")
|
||
return
|
||
|
||
header = (
|
||
f"{'INTERFACE':<{PadLldp.interface}}"
|
||
f"{'REM-IDX':<{PadLldp.rem_idx}}"
|
||
f"{'TIME':<{PadLldp.time}}"
|
||
f"{'CHASSIS-ID':<{PadLldp.chassis_id}}"
|
||
f"{'PORT-ID':<{PadLldp.port_id}}"
|
||
)
|
||
|
||
print(Decore.invert(header))
|
||
|
||
for port_data in lldp_ports:
|
||
port_name = port_data["name"]
|
||
neighbors = port_data.get("remote-systems-data", [])
|
||
|
||
for neighbor in neighbors:
|
||
entry = LldpNeighbor(port_name, neighbor)
|
||
entry.print()
|
||
|
||
|
||
def _mdns_sort_addrs(addresses):
|
||
"""Sort addresses: IPv4 first, then non-link-local IPv6, then link-local IPv6."""
|
||
def key(a):
|
||
if ":" not in a:
|
||
return 0
|
||
if a.lower().startswith("fe80:"):
|
||
return 2
|
||
return 1
|
||
return sorted(addresses, key=key)
|
||
|
||
|
||
def _mdns_last_seen(ts):
|
||
"""Extract HH:MM:SS from RFC 3339 timestamp."""
|
||
if not ts:
|
||
return "-"
|
||
try:
|
||
return ts.split("T")[1][:8]
|
||
except (IndexError, AttributeError):
|
||
return "-"
|
||
|
||
|
||
def _mdns_svc_name(stype):
|
||
"""'_https._tcp' → 'https', '_netconf-ssh._tcp' → 'netconf-ssh'."""
|
||
return stype.lstrip("_").split("._")[0]
|
||
|
||
|
||
def show_mdns(json):
|
||
mdns = json.get("infix-services:mdns", {})
|
||
if not mdns:
|
||
print("mDNS not configured.")
|
||
return
|
||
|
||
# Configuration
|
||
enabled = mdns.get("enabled")
|
||
domain = mdns.get("domain", "local")
|
||
hostname = mdns.get("hostname")
|
||
reflector_on = mdns.get("reflector", {}).get("enabled")
|
||
|
||
if enabled is not None:
|
||
print(f"{'Enabled':<16}: {'yes' if enabled else 'no'}")
|
||
print(f"{'Domain':<16}: {domain}")
|
||
if hostname:
|
||
print(f"{'Hostname':<16}: {hostname}")
|
||
|
||
ifaces = mdns.get("interfaces", {})
|
||
if ifaces.get("allow"):
|
||
print(f"{'Allow':<16}: {', '.join(ifaces['allow'])}")
|
||
if ifaces.get("deny"):
|
||
print(f"{'Deny':<16}: {', '.join(ifaces['deny'])}")
|
||
|
||
reflector = mdns.get("reflector", {})
|
||
if reflector_on is not None:
|
||
print(f"{'Reflector':<16}: {'yes' if reflector_on else 'no'}")
|
||
if reflector.get("service-filter"):
|
||
print(f"{'Svc filter':<16}: {', '.join(reflector['service-filter'])}")
|
||
|
||
# Neighbors
|
||
neighbors = mdns.get("neighbors", {}).get("neighbor", [])
|
||
if not neighbors:
|
||
print("\nNo mDNS neighbors.")
|
||
return
|
||
|
||
print()
|
||
table = SimpleTable([
|
||
Column("HOSTNAME", flexible=True),
|
||
Column("ADDRESS"),
|
||
Column("LAST SEEN"),
|
||
Column("SERVICES"),
|
||
])
|
||
|
||
for nbr in sorted(neighbors, key=lambda n: n.get("hostname", "")):
|
||
addrs = _mdns_sort_addrs(nbr.get("address", []))
|
||
ts = _mdns_last_seen(nbr.get("last-seen", ""))
|
||
svcs = nbr.get("service", [])
|
||
svc_str = " ".join(
|
||
f"{_mdns_svc_name(s.get('type', '?'))}({s.get('port', 0)})"
|
||
for s in svcs
|
||
) if svcs else "-"
|
||
table.row(nbr.get("hostname", "?"), addrs[0] if addrs else "-", ts, svc_str)
|
||
for addr in addrs[1:]:
|
||
table.row("", addr, "", "")
|
||
|
||
table.print()
|
||
|
||
|
||
def parse_firewall_log_line(line):
|
||
"""Parse a single firewall log line into structured data"""
|
||
|
||
# Look for kernel logs with netfilter IN=/OUT= fields
|
||
if not ('kernel' in line and 'IN=' in line and 'OUT=' in line):
|
||
return None
|
||
|
||
# Extract timestamp from syslog format: Aug 17 12:34:56
|
||
timestamp_match = re.match(r'^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})', line.strip())
|
||
if not timestamp_match:
|
||
return None
|
||
|
||
timestamp = timestamp_match.group(1)
|
||
|
||
# Look for action indicator in the log line
|
||
action = 'DROP'
|
||
if 'REJECT' in line:
|
||
action = 'REJECT'
|
||
|
||
# Extract key fields from netfilter log
|
||
patterns = {
|
||
'in_iface': r'IN=([^\s]*)',
|
||
'out_iface': r'OUT=([^\s]*)',
|
||
'src': r'SRC=([^\s]+)',
|
||
'dst': r'DST=([^\s]+)',
|
||
'proto': r'PROTO=([^\s]+)',
|
||
'spt': r'SPT=([^\s]+)',
|
||
'dpt': r'DPT=([^\s]+)',
|
||
}
|
||
|
||
parsed = {'timestamp': timestamp, 'action': action}
|
||
|
||
for key, pattern in patterns.items():
|
||
match = re.search(pattern, line)
|
||
value = match.group(1) if match else ''
|
||
|
||
# Compress any IPv6 addresses for src and dst
|
||
if key in ['src', 'dst'] and value:
|
||
try:
|
||
ip = ipaddress.ip_address(value)
|
||
if isinstance(ip, ipaddress.IPv6Address):
|
||
value = str(ip.compressed)
|
||
except ValueError:
|
||
# Not a valid IP address, keep original value
|
||
pass
|
||
|
||
parsed[key] = value
|
||
|
||
return parsed
|
||
|
||
|
||
def firewall_log_table(limit=None):
|
||
"""Create firewall log table (returns None if no logs available)"""
|
||
try:
|
||
with open('/var/log/firewall.log', 'r', encoding='utf-8') as f:
|
||
lines = deque(f, maxlen=limit)
|
||
|
||
if not lines:
|
||
return None
|
||
|
||
# Create table with column definitions - mark flexible columns
|
||
log_table = SimpleTable([
|
||
Column('TIME'),
|
||
Column('ACTION', 'right'),
|
||
Column('IIF', 'right'),
|
||
Column('SOURCE', flexible=True),
|
||
Column('DEST', flexible=True),
|
||
Column('PROTO'),
|
||
Column('PORT', 'right')
|
||
])
|
||
|
||
for line in lines:
|
||
parsed = parse_firewall_log_line(line)
|
||
if not parsed:
|
||
continue
|
||
|
||
time_str = ''
|
||
if parsed['timestamp']:
|
||
try:
|
||
ts = parsed['timestamp'].strip()
|
||
if 'T' in ts: # ISO format
|
||
dt = datetime.fromisoformat(ts)
|
||
time_str = dt.strftime("%b %d %H:%M:%S")
|
||
else: # syslog format
|
||
dt = datetime.strptime(ts, "%b %d %H:%M:%S")
|
||
time_str = dt.strftime("%b %d %H:%M:%S")
|
||
except Exception:
|
||
time_str = parsed['timestamp'][:14] # Truncate long timestamps
|
||
|
||
if parsed['action'] == 'REJECT':
|
||
action_str = Decore.red(parsed['action'])
|
||
else:
|
||
action_str = Decore.yellow(parsed['action'])
|
||
|
||
log_table.row(time_str, action_str, parsed['in_iface'],
|
||
parsed['src'], parsed['dst'], parsed['proto'],
|
||
parsed['dpt'])
|
||
|
||
return log_table
|
||
|
||
except (FileNotFoundError, Exception):
|
||
return None
|
||
|
||
|
||
def show_firewall_logs(limit=None):
|
||
"""Show recent firewall log entries, tail -N equivalent (legacy)"""
|
||
log_table = firewall_log_table(limit)
|
||
if log_table:
|
||
log_table.print()
|
||
else:
|
||
print("No logs found (may be disabled or no denied traffic)")
|
||
|
||
|
||
def show_firewall(json):
|
||
"""Main firewall display orchestrator.
|
||
|
||
Args:
|
||
json: Complete firewall configuration dict
|
||
|
||
Displays:
|
||
- Status with lockdown/logging alerts
|
||
- Zone-to-zone traffic matrix
|
||
- Zone and policy tables
|
||
- Firewall logs (if logging enabled)
|
||
"""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
if not fw:
|
||
print("Firewall disabled.")
|
||
return
|
||
|
||
# Create Canvas instance
|
||
canvas = Canvas()
|
||
|
||
# Build firewall status with contextual alerts
|
||
lockdown_state = fw.get('lockdown', False)
|
||
logging_enabled = fw.get('logging', 'off') != 'off'
|
||
|
||
firewall_status = "active"
|
||
if lockdown_state: # Lockdown mode takes priority
|
||
firewall_status += f" [ {Decore.flashing_red('LOCKDOWN MODE')} ]"
|
||
elif logging_enabled:
|
||
firewall_status += f" [ {Decore.bold_yellow('MONITORING')} ]"
|
||
|
||
# Add status information
|
||
canvas.add_text(f"{Decore.bold('Firewall'):<28}: {firewall_status}")
|
||
|
||
lockdown_display = "active" if lockdown_state else "inactive"
|
||
canvas.add_text(f"{Decore.bold('Lockdown mode'):<28}: {lockdown_display}")
|
||
canvas.add_text(f"{Decore.bold('Default zone'):<28}: {fw.get('default', 'unknown')}")
|
||
canvas.add_text(f"{Decore.bold('Log denied traffic'):<28}: {fw.get('logging', 'off')}")
|
||
|
||
canvas.add_spacing()
|
||
|
||
# Create tables
|
||
zone_table = firewall_zone_table(json)
|
||
policy_table = firewall_policy_table(json)
|
||
|
||
# Add zone table
|
||
if zone_table:
|
||
canvas.add_title("Zones")
|
||
canvas.add_table(zone_table)
|
||
canvas.add_spacing()
|
||
|
||
# Add policy table
|
||
if policy_table:
|
||
canvas.add_title("Policies")
|
||
canvas.add_table(policy_table)
|
||
canvas.add_spacing()
|
||
|
||
# Add firewall logs if logging is enabled
|
||
if logging_enabled:
|
||
log_table = firewall_log_table(limit=10)
|
||
if log_table:
|
||
canvas.add_title("Log (last 10)")
|
||
canvas.add_table(log_table)
|
||
canvas.add_spacing()
|
||
|
||
# Get max width for matrix centering
|
||
max_width = canvas.get_max_width()
|
||
|
||
# Render matrix centered to max table width
|
||
matrix_text = firewall_matrix(fw, width=max_width)
|
||
if matrix_text:
|
||
# Insert matrix after status lines and first spacing (index 5)
|
||
# Status: 4 lines + 1 spacing = index 5
|
||
canvas.insert_title(5, "Zone Matrix")
|
||
canvas.insert_raw(6, matrix_text)
|
||
canvas.insert_spacing(7)
|
||
|
||
# Render the canvas
|
||
canvas.render()
|
||
|
||
|
||
def build_policy_map(policies):
|
||
"""Creates optimized (ingress,egress) -> policy_info lookup map.
|
||
|
||
Args:
|
||
policies: List of policy dicts
|
||
|
||
Returns:
|
||
dict: {(from_zone, to_zone): {
|
||
'allow': bool, # Full access (action=accept, no restrictions)
|
||
'conditional': bool, # Limited access (services/port-forwards)
|
||
'services': set, # Allowed service names
|
||
'policies': list # Contributing policy names
|
||
}}
|
||
|
||
Logic:
|
||
- action=accept + no services/port-forwards → allow=True
|
||
- action=accept + services/port-forwards → conditional=True
|
||
- action=reject + services → conditional=True
|
||
- action=reject + no services → allow=False, conditional=False
|
||
|
||
Excludes: Global ANY-to-ANY policies (handled separately)
|
||
"""
|
||
policy_map = {}
|
||
|
||
for policy in policies:
|
||
ingress_zones = policy.get('ingress', [])
|
||
egress_zones = policy.get('egress', [])
|
||
services = policy.get('service', [])
|
||
action = policy.get('action', 'reject')
|
||
policy_name = policy.get('name', 'unknown')
|
||
|
||
for ing in ingress_zones:
|
||
for egr in egress_zones:
|
||
# Handle specific zone-to-zone flows (excluding global ANY-to-ANY)
|
||
if not (ing == 'ANY' and egr == 'ANY'):
|
||
key = (ing, egr)
|
||
|
||
if key not in policy_map:
|
||
policy_map[key] = {
|
||
'allow': False,
|
||
'conditional': False,
|
||
'services': set(),
|
||
'policies': []
|
||
}
|
||
|
||
if action in ['accept', 'continue']:
|
||
if services:
|
||
# Accept policy with specific services - conditional only
|
||
policy_map[key]['conditional'] = True
|
||
policy_map[key]['services'].update(services)
|
||
# Don't set allow=True - this is conditional, not full allow
|
||
else:
|
||
# Accept policy with no restrictions - allow all
|
||
policy_map[key]['allow'] = True
|
||
else:
|
||
# Reject/drop policies
|
||
if services:
|
||
# Reject with service exceptions - conditional access only
|
||
policy_map[key]['conditional'] = True
|
||
policy_map[key]['services'].update(services)
|
||
# Don't set allow=True - this is conditional, not full allow
|
||
# else: reject with no services - stays deny (allow=False)
|
||
|
||
policy_map[key]['policies'].append(policy_name)
|
||
return policy_map
|
||
|
||
|
||
def format_port_forwards(port_forwards, max_length=35):
|
||
"""Format port-forward rules for compact display.
|
||
|
||
Args:
|
||
port_forwards: List of port-forward dicts
|
||
max_length: Maximum string length before truncation
|
||
|
||
Returns:
|
||
str: Formatted port-forward string like ":80/tcp→192.168.1.10:8080, :443/tcp→..."
|
||
"""
|
||
if not port_forwards:
|
||
return ""
|
||
|
||
formatted_rules = []
|
||
for pf in port_forwards:
|
||
lower = pf.get('lower')
|
||
upper = pf.get('upper')
|
||
proto = pf.get('proto', 'tcp')
|
||
to_addr = pf.get('to', {}).get('addr')
|
||
to_port = pf.get('to', {}).get('port', lower)
|
||
|
||
if lower:
|
||
# Format port range or single port
|
||
if upper and upper != lower:
|
||
port_spec = f":{lower}-{upper}/{proto}"
|
||
else:
|
||
port_spec = f":{lower}/{proto}"
|
||
|
||
# Format destination
|
||
if to_addr:
|
||
if to_port:
|
||
dest = f"{to_addr}:{to_port}"
|
||
else:
|
||
dest = to_addr
|
||
formatted_rules.append(f"{port_spec} → {dest}")
|
||
|
||
result = ", ".join(formatted_rules)
|
||
if len(result) > max_length:
|
||
# Truncate and add indicator
|
||
truncated = result[:max_length-4] + "..."
|
||
return truncated
|
||
return result
|
||
|
||
|
||
def find_zone_by_ip(ip_str, zones):
|
||
"""Find which zone contains the given IP address.
|
||
|
||
Args:
|
||
ip_str: IP address string to lookup
|
||
zones: List of zone configurations
|
||
|
||
Returns:
|
||
str: Zone name if found, None otherwise
|
||
"""
|
||
try:
|
||
target_ip = ipaddress.ip_address(ip_str)
|
||
for zone in zones:
|
||
networks = zone.get('network', [])
|
||
for network_str in networks:
|
||
try:
|
||
network = ipaddress.ip_network(network_str, strict=False)
|
||
if target_ip in network:
|
||
return zone['name']
|
||
except ValueError:
|
||
continue
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def traffic_flow(from_zone, to_zone, policy_map, zones, policies, cell_width):
|
||
"""Core zone-to-zone traffic analysis with colored cell output.
|
||
|
||
Args:
|
||
from_zone, to_zone: Zone names for traffic direction
|
||
policy_map: Pre-computed policy lookup from build_policy_map()
|
||
zones: Zone configuration list
|
||
policies: Not used (kept for compatibility)
|
||
cell_width: Matrix cell width for formatting
|
||
|
||
Returns:
|
||
str: Colored terminal cell with symbol (✓/✗/⚠/—)
|
||
|
||
Logic:
|
||
- HOST↔HOST: Gray — (not applicable)
|
||
- HOST→zone: Green ✓ (firewall can reach zones)
|
||
- zone→HOST: Based on zone input config (action/services)
|
||
- zone→zone: Based on explicit policy only
|
||
- Intra-zone: Requires explicit policy
|
||
- No policy: Red ✗ (default deny)
|
||
|
||
Symbols: ✓=allow all, ✗=deny, ⚠=conditional, —=n/a
|
||
"""
|
||
def make_cell(symbol, bg_func):
|
||
# Create full-width colored cell
|
||
return bg_func(f" {symbol:^{cell_width}} ")
|
||
|
||
# Handle HOST zone specially
|
||
if from_zone == "HOST" and to_zone == "HOST":
|
||
# HOST-to-HOST communication (localhost) - not applicable
|
||
return make_cell("—", Decore.gray_bg)
|
||
|
||
# HOST-to-zone traffic: firewall input rules control this
|
||
if from_zone == "HOST":
|
||
# Traffic from firewall device to zones - typically allowed
|
||
return make_cell("✓", Decore.green_bg)
|
||
|
||
# zone-to-HOST traffic: controlled by zone input configuration
|
||
if to_zone == "HOST":
|
||
# Find the zone configuration for this source zone
|
||
zone_config = None
|
||
for zone in zones:
|
||
if zone.get('name') == from_zone:
|
||
zone_config = zone
|
||
break
|
||
|
||
if zone_config:
|
||
action = zone_config.get('action', 'reject')
|
||
services = zone_config.get('service', [])
|
||
port_forwards = zone_config.get('port-forward', [])
|
||
|
||
if action == 'accept':
|
||
# Zone allows all traffic to HOST
|
||
return make_cell("✓", Decore.green_bg)
|
||
elif services or port_forwards:
|
||
# Zone has service exceptions or port forwards (conditional access)
|
||
return make_cell("⚠", Decore.yellow_bg)
|
||
else:
|
||
# Zone blocks all traffic to HOST
|
||
return make_cell("✗", Decore.red_bg)
|
||
|
||
# Zone not found - default deny
|
||
return make_cell("✗", Decore.red_bg)
|
||
|
||
# Intra-zone communication now requires explicit policies
|
||
if from_zone == to_zone:
|
||
# Look for explicit intra-zone policy (e.g., "lan-to-lan")
|
||
key = (from_zone, to_zone)
|
||
policy = policy_map.get(key)
|
||
if policy and policy['allow']:
|
||
return make_cell("✓", Decore.green_bg)
|
||
return make_cell("✗", Decore.red_bg)
|
||
|
||
# Check for explicit policy between these zones
|
||
key = (from_zone, to_zone)
|
||
policy = policy_map.get(key)
|
||
|
||
if policy:
|
||
if policy['allow']:
|
||
# Policy explicitly allows all traffic
|
||
return make_cell("✓", Decore.green_bg)
|
||
elif policy['conditional']:
|
||
# Policy has service restrictions or port forwards
|
||
return make_cell("⚠", Decore.yellow_bg)
|
||
else:
|
||
# Policy explicitly denies
|
||
return make_cell("✗", Decore.red_bg)
|
||
|
||
# Check for port-forwards from source zone to target zone via IP mapping
|
||
source_zone_config = None
|
||
for zone in zones:
|
||
if zone.get('name') == from_zone:
|
||
source_zone_config = zone
|
||
break
|
||
|
||
if source_zone_config:
|
||
port_forwards = source_zone_config.get('port-forward', [])
|
||
for pf in port_forwards:
|
||
to_addr = pf.get('to', {}).get('addr')
|
||
if to_addr:
|
||
target_zone = find_zone_by_ip(to_addr, zones)
|
||
if target_zone == to_zone:
|
||
# Port forward creates conditional access to target zone
|
||
return make_cell("⚠", Decore.yellow_bg)
|
||
|
||
# No explicit policy - default deny
|
||
return make_cell("✗", Decore.red_bg)
|
||
|
||
|
||
def firewall_matrix(fw, width=None):
|
||
"""Render zone-to-zone traffic flow matrix as a multi-line string.
|
||
|
||
Args:
|
||
fw: Firewall config dict with zones/policies
|
||
width: Optional target width for centering (from Canvas)
|
||
|
||
Symbols: ✓=allow, ✗=deny, ⚠=conditional, —=n/a
|
||
|
||
Returns:
|
||
Multi-line string containing the rendered matrix, or None if < 2 zones
|
||
"""
|
||
zones = fw.get('zone', [])
|
||
policies = fw.get('policy', [])
|
||
|
||
# Build zone list - include zones with interfaces OR networks
|
||
zone_names = []
|
||
for z in zones:
|
||
interfaces = z.get('interface', [])
|
||
networks = z.get('network', [])
|
||
# Include if zone has interfaces OR networks (non-empty lists)
|
||
if interfaces or networks:
|
||
zone_names.append(z['name'])
|
||
|
||
# Always add the implicit HOST zone
|
||
zone_names.insert(0, "HOST")
|
||
|
||
if len(zone_names) <= 1:
|
||
return None
|
||
|
||
# Build enhanced policy lookup map
|
||
policy_map = build_policy_map(policies)
|
||
|
||
max_zone_len = max(len(zone) for zone in zone_names)
|
||
col_width = max(max_zone_len, 1) # At least 1 char for symbols
|
||
left_col_width = max_zone_len
|
||
|
||
# Box drawing characters for proper borders, '+ 2' is for spacing
|
||
top_border = "┌" + "─" * left_col_width + "──" + "┬"
|
||
for _ in zone_names:
|
||
top_border += "─" * (col_width + 2) + "┬"
|
||
top_border = top_border[:-1] + "┐" # Replace last ┬ with ┐
|
||
|
||
middle_border = "├" + "─" * left_col_width + "──" + "┼"
|
||
for _ in zone_names:
|
||
middle_border += "─" * (col_width + 2) + "┼"
|
||
middle_border = middle_border[:-1] + "┤" # Replace last ┼ with ┤
|
||
|
||
bottom_border = "└" + "─" * left_col_width + "──" + "┴"
|
||
for _ in zone_names:
|
||
bottom_border += "─" * (col_width + 2) + "┴"
|
||
bottom_border = bottom_border[:-1] + "┘" # Replace last ┴ with ┘
|
||
|
||
# Header with arrow in top-left cell
|
||
hdr = f"│ {'→':^{left_col_width}} │"
|
||
for zone in zone_names:
|
||
hdr += f" {zone:^{col_width}} │"
|
||
|
||
# Build matrix rows
|
||
lines = []
|
||
|
||
# Calculate centering if width is provided
|
||
matrix_width = len(top_border)
|
||
if width and width > matrix_width:
|
||
padding = (width - matrix_width) // 2
|
||
indent = " " * padding
|
||
else:
|
||
indent = ""
|
||
|
||
lines.append(indent + top_border)
|
||
lines.append(indent + hdr)
|
||
lines.append(indent + middle_border)
|
||
|
||
for from_zone in zone_names:
|
||
row = f"│ {from_zone:>{left_col_width}} │"
|
||
for to_zone in zone_names:
|
||
# Find symbol for this cell based on traffic flow
|
||
symbol = traffic_flow(from_zone, to_zone, policy_map,
|
||
zones, policies, col_width)
|
||
row += f"{symbol}│"
|
||
lines.append(indent + row)
|
||
lines.append(indent + bottom_border)
|
||
|
||
# Add legend
|
||
legend_data = [
|
||
("✓ Allow", Decore.green_bg),
|
||
("✗ Deny", Decore.red_bg),
|
||
("⚠ Conditional", Decore.yellow_bg)
|
||
]
|
||
|
||
colorized_parts = [bg_func(f" {text} ") for text, bg_func in legend_data]
|
||
legend = " ".join(colorized_parts)
|
||
|
||
if width:
|
||
# Calculate legend centering
|
||
# Use visible width (without ANSI codes) for calculation
|
||
visible_legend = " ".join([f" {text} " for text, _ in legend_data])
|
||
legend_padding = max(0, (width - len(visible_legend)) // 2)
|
||
lines.append(" " * legend_padding + legend)
|
||
else:
|
||
lines.append(indent + legend)
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def show_firewall_matrix(json):
|
||
"""Renders visual zone-to-zone traffic flow matrix."""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
if fw:
|
||
print(firewall_matrix(fw))
|
||
|
||
|
||
def firewall_zone_table(json):
|
||
"""Create firewall zones table (returns SimpleTable or None)"""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
zones = fw.get('zone', [])
|
||
|
||
if not zones:
|
||
return None
|
||
|
||
# Create zones table with flexible columns
|
||
zone_table = SimpleTable([
|
||
Column(''), # Lock icon
|
||
Column('NAME', flexible=True),
|
||
Column('TYPE'),
|
||
Column('DATA', flexible=True),
|
||
Column('ALLOWED HOST SERVICES', flexible=True)
|
||
])
|
||
|
||
for zone in zones:
|
||
name = zone.get('name', '')
|
||
action = zone.get('action', 'reject')
|
||
interface_list = zone.get('interface', [])
|
||
network_list = zone.get('network', [])
|
||
port_forwards = zone.get('port-forward', [])
|
||
services = zone.get('service', [])
|
||
|
||
if action == 'accept':
|
||
services_display = "ANY"
|
||
elif services:
|
||
services_display = ", ".join(services)
|
||
else:
|
||
services_display = "(none)"
|
||
|
||
immutable = zone.get('immutable', False)
|
||
locked = "⚷" if immutable else " "
|
||
|
||
# Build configuration strings
|
||
config_lines = []
|
||
|
||
# Interfaces
|
||
if interface_list:
|
||
interfaces = compress_interface_list(interface_list)
|
||
config_lines.append(("iif", interfaces))
|
||
else:
|
||
config_lines.append(("iif", "(none)"))
|
||
|
||
# Networks
|
||
if network_list:
|
||
networks = ", ".join(network_list)
|
||
config_lines.append(("net", networks))
|
||
|
||
# Port forwards
|
||
if port_forwards:
|
||
pf_display = format_port_forwards(port_forwards)
|
||
config_lines.append(("fwd", pf_display))
|
||
|
||
# Add first line with zone name and services
|
||
if config_lines:
|
||
first_type, first_data = config_lines[0]
|
||
zone_table.row(locked, name, first_type, first_data, services_display)
|
||
|
||
# Add additional configuration lines as separate rows
|
||
for config_type, config_data in config_lines[1:]:
|
||
zone_table.row('', '', config_type, config_data, '')
|
||
|
||
return zone_table
|
||
|
||
|
||
def show_firewall_zone(json, zone_name=None):
|
||
"""Displays zone configuration table or detailed zone analysis.
|
||
|
||
Args:
|
||
json: Complete firewall config
|
||
zone_name: Optional specific zone name for detail view
|
||
|
||
Table mode (zone_name=None):
|
||
- Zone name, type (ifaces/networks), members, HOST services
|
||
- Uses compress_interface_list() for clean display
|
||
- Shows immutable lock indicators
|
||
|
||
Detail mode (zone_name specified):
|
||
- Complete zone configuration
|
||
- Traffic flow analysis to all other zones
|
||
- Policy-based access determination
|
||
"""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
zones = fw.get('zone', [])
|
||
policies = fw.get('policy', [])
|
||
|
||
if zone_name:
|
||
zone = next((z for z in zones if z.get('name') == zone_name), None)
|
||
if not zone:
|
||
print(f"Zone '{zone_name}' not found")
|
||
return
|
||
|
||
description = zone.get('description', '')
|
||
interfaces = zone.get('interface', [])
|
||
if not interfaces:
|
||
interfaces = ""
|
||
networks = zone.get('network', [])
|
||
if not networks:
|
||
networks = ""
|
||
services = zone.get('service', [])
|
||
action = zone.get('action', 'reject')
|
||
|
||
if action == 'accept':
|
||
services_display = "ANY"
|
||
elif services:
|
||
services_display = ", ".join(services)
|
||
else:
|
||
services_display = "(none)"
|
||
|
||
print(format_description('description', description))
|
||
print(f"{'name':<20}: {zone_name}")
|
||
print(f"{'action':<20}: {action}")
|
||
print(f"{'interface':<20}: {compress_interface_list(interfaces)}")
|
||
print(f"{'networks':<20}: {', '.join(networks)}")
|
||
print(f"{'services (to HOST)':<20}: {services_display}")
|
||
|
||
# Show port forwards if any
|
||
port_forwards = zone.get('port-forward', [])
|
||
if port_forwards:
|
||
print(f"{'port forwards':<20}: {len(port_forwards)} rule(s)")
|
||
for fwd in port_forwards:
|
||
lower = fwd.get('lower')
|
||
upper = fwd.get('upper')
|
||
proto = fwd.get('proto', '')
|
||
|
||
if upper:
|
||
from_port = f"{lower}-{upper}/{proto}"
|
||
else:
|
||
from_port = f"{lower}/{proto}"
|
||
|
||
to_info = fwd.get('to', {})
|
||
to_addr = to_info.get('addr', '')
|
||
to_port = to_info.get('port', lower) # Default to source port
|
||
|
||
if upper and to_port:
|
||
# Calculate upper destination port for ranges
|
||
port_diff = upper - lower
|
||
to_upper = to_port + port_diff
|
||
to_port_str = f"{to_port}-{to_upper}"
|
||
else:
|
||
to_port_str = str(to_port)
|
||
|
||
to_display = f"{to_addr}:{to_port_str}"
|
||
print(f"{' - ' + from_port:<18} → {to_display}")
|
||
|
||
# Create traffic flows table
|
||
flow_table = SimpleTable([
|
||
Column('TO ZONE'),
|
||
Column('ACTION'),
|
||
Column('POLICY'),
|
||
Column('SERVICES')
|
||
])
|
||
|
||
# Add HOST zone first
|
||
current_zone = next((z for z in zones if z.get('name') == zone_name), None)
|
||
if current_zone:
|
||
# Zone-to-HOST traffic logic
|
||
action = current_zone.get('action', 'reject')
|
||
services = current_zone.get('service', [])
|
||
|
||
if action == 'accept':
|
||
host_action = "✓ allow"
|
||
host_services = "(any)"
|
||
elif services:
|
||
host_action = "⚠ conditional"
|
||
host_services = ", ".join(services)
|
||
else:
|
||
host_action = "✗ deny"
|
||
host_services = "(none)"
|
||
|
||
flow_table.row('HOST', host_action, '(services)', host_services)
|
||
|
||
# Add other zones
|
||
for other_zone in zones:
|
||
if other_zone.get('name') == zone_name:
|
||
continue
|
||
|
||
# Check if there's a policy allowing this flow
|
||
other_name = other_zone.get('name')
|
||
policy_name = "(none)"
|
||
action = "✗ deny"
|
||
services_display = "(none)"
|
||
|
||
for policy in policies:
|
||
if zone_name in policy.get('ingress', []) and other_name \
|
||
in policy.get('egress', []):
|
||
policy_name = policy.get('name', 'unknown')
|
||
policy_action = policy.get('action', 'reject')
|
||
policy_services = policy.get('service', [])
|
||
|
||
if policy_action == 'accept':
|
||
action = "✓ allow"
|
||
services_display = "(any)"
|
||
elif policy_services:
|
||
action = "⚠ conditional"
|
||
services_display = ", ".join(policy_services)
|
||
else:
|
||
action = "✗ deny"
|
||
services_display = "(none)"
|
||
break
|
||
|
||
flow_table.row(other_name, action, policy_name, services_display)
|
||
|
||
Decore.title(f"Traffic Flows: {zone_name} →")
|
||
flow_table.print()
|
||
else:
|
||
# Use helper to create and display zones table
|
||
zone_table = firewall_zone_table(json)
|
||
if zone_table:
|
||
zone_table.min_width = 72
|
||
zone_table.print()
|
||
|
||
|
||
def firewall_policy_table(json):
|
||
"""Create firewall policies table (returns SimpleTable or None)"""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
policies = fw.get('policy', [])
|
||
|
||
if not policies:
|
||
return None
|
||
|
||
# Create policies table with flexible columns
|
||
policy_table = SimpleTable([
|
||
Column(''), # Lock icon
|
||
Column('NAME', flexible=True),
|
||
Column('ACTION'),
|
||
Column('INGRESS', flexible=True),
|
||
Column('EGRESS', flexible=True)
|
||
])
|
||
|
||
sorted_policies = sorted(policies, key=lambda p: p.get('priority', 32767))
|
||
for policy in sorted_policies:
|
||
name = policy.get('name', '')
|
||
ingress = ", ".join(policy.get('ingress', []))
|
||
egress = ", ".join(policy.get('egress', []))
|
||
action = policy.get('action', 'reject')
|
||
|
||
immutable = policy.get('immutable', False)
|
||
locked = "⚷" if immutable else " "
|
||
|
||
policy_table.row(locked, name, action, ingress, egress)
|
||
|
||
return policy_table
|
||
|
||
|
||
def show_firewall_policy(json, policy_name=None):
|
||
"""Displays policy configuration table or detailed policy analysis.
|
||
|
||
Args:
|
||
json: Complete firewall config
|
||
policy_name: Optional specific policy name for detail view
|
||
|
||
Table mode (policy_name=None):
|
||
- Policy name, action, ingress/egress zones
|
||
- Sorted by priority (lower = higher precedence)
|
||
- Shows immutable lock indicators
|
||
|
||
Detail mode (policy_name specified):
|
||
- Complete policy configuration
|
||
- Custom ICMP filters with type/family details
|
||
- Port forwarding rules with range mapping
|
||
- Service restrictions and masquerade settings
|
||
"""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
policies = fw.get('policy', [])
|
||
|
||
if policy_name:
|
||
policy = next((p for p in policies if p.get('name') == policy_name), None)
|
||
if not policy:
|
||
print(f"Policy '{policy_name}' not found")
|
||
return
|
||
|
||
ingress = policy.get('ingress', [])
|
||
egress = policy.get('egress', [])
|
||
action = policy.get('action', 'reject')
|
||
masquerade = "yes" if policy.get('masquerade') else "no"
|
||
description = policy.get('description', '')
|
||
services = policy.get('service', [])
|
||
custom = policy.get('custom', {})
|
||
custom_filters = custom.get('filter', [])
|
||
|
||
if policy == 'accept':
|
||
services_display = "(all)"
|
||
else:
|
||
services_display = ", ".join(services) if services else "(none)"
|
||
|
||
print(format_description('description', description))
|
||
print(f"{'name':<20}: {policy_name}")
|
||
print(f"{'ingress':<20}: {', '.join(ingress) if ingress else '(none)'}")
|
||
print(f"{'egress':<20}: {', '.join(egress) if egress else '(none)'}")
|
||
print(f"{'action':<20}: {action}")
|
||
print(f"{'masquerade':<20}: {masquerade}")
|
||
print(f"{'services':<20}: {services_display}")
|
||
|
||
if custom_filters:
|
||
print(f"{'custom filters':<20}: {len(custom_filters)} filter(s)")
|
||
|
||
sorted_filters = sorted(custom_filters, key=lambda f: f.get('priority', 32767))
|
||
for _, filter_entry in enumerate(sorted_filters):
|
||
action = filter_entry.get('action', 'accept')
|
||
family = filter_entry.get('family', 'both')
|
||
|
||
icmp = filter_entry.get('icmp')
|
||
if icmp:
|
||
icmp_type = icmp.get('type', 'unknown')
|
||
print(f"{' - ' + action:<6} {family} icmp-type {icmp_type}")
|
||
else:
|
||
print(f"{' - ' + action:<6} {family} (unknown type)")
|
||
else:
|
||
# Use helper to create and display policies table
|
||
policy_table = firewall_policy_table(json)
|
||
if policy_table:
|
||
policy_table.min_width = 72
|
||
policy_table.print()
|
||
|
||
|
||
def format_port_list(ports):
|
||
"""Format port list from YANG data"""
|
||
if not ports:
|
||
return "(none)"
|
||
|
||
formatted = []
|
||
for port in ports:
|
||
proto = port.get('proto', 'tcp')
|
||
lower = port.get('lower')
|
||
upper = port.get('upper')
|
||
|
||
if upper and upper != lower:
|
||
formatted.append(f"{lower}-{upper}/{proto}")
|
||
else:
|
||
formatted.append(f"{lower}/{proto}")
|
||
|
||
return ", ".join(formatted)
|
||
|
||
|
||
def firewall_service_table(json):
|
||
"""Create firewall services table (returns SimpleTable or None)"""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
services = fw.get('service', [])
|
||
|
||
if not services:
|
||
return None
|
||
|
||
# Create services table with flexible columns
|
||
service_table = SimpleTable([
|
||
Column('NAME'),
|
||
Column('PORTS', flexible=True)
|
||
])
|
||
|
||
for service in services:
|
||
name = service.get('name', '')
|
||
ports = format_port_list(service.get('port', []))
|
||
service_table.row(name, ports)
|
||
|
||
return service_table
|
||
|
||
|
||
def show_firewall_service(json, name=None):
|
||
"""Show firewall services table or specific service details"""
|
||
fw = json.get('infix-firewall:firewall', {})
|
||
services = fw.get('service', [])
|
||
|
||
if name:
|
||
service = next((s for s in services if s.get('name') == name), None)
|
||
if not service:
|
||
print(f"Service '{name}' not found")
|
||
return
|
||
|
||
ports = format_port_list(service.get('port', []))
|
||
description = service.get('description', '')
|
||
|
||
print(f"{'name':<20}: {name}")
|
||
print(f"{'ports':<20}: {ports}")
|
||
print(format_description('description', description))
|
||
else:
|
||
# Use helper to create and display services table
|
||
service_table = firewall_service_table(json)
|
||
if service_table:
|
||
service_table.min_width = 72
|
||
service_table.print()
|
||
|
||
|
||
def show_ospf(json_data):
|
||
"""Show OSPF general instance information"""
|
||
routing = json_data.get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
|
||
|
||
ospf_instance = None
|
||
for protocol in protocols:
|
||
if 'ietf-ospf:ospf' in protocol:
|
||
ospf_instance = protocol
|
||
break
|
||
|
||
if not ospf_instance:
|
||
print("OSPF is not configured or running")
|
||
return
|
||
|
||
ospf = ospf_instance.get('ietf-ospf:ospf', {})
|
||
router_id = ospf.get('router-id', '0.0.0.0')
|
||
areas = ospf.get('areas', {}).get('area', [])
|
||
|
||
# OSPF Process header
|
||
print(f" OSPF Routing Process, Router ID: {router_id}")
|
||
print(f" Number of areas attached to this router: {len(areas)}")
|
||
print()
|
||
|
||
if not areas:
|
||
print("No areas configured")
|
||
return
|
||
|
||
# Display detailed area information
|
||
for area in areas:
|
||
area_id = area.get('area-id', '0.0.0.0')
|
||
area_type = area.get('area-type', 'ietf-ospf:normal-area')
|
||
|
||
# Determine area type display
|
||
if area_id == "0.0.0.0":
|
||
area_label = f" Area ID: {area_id} (Backbone)"
|
||
else:
|
||
if 'nssa' in area_type.lower():
|
||
area_label = f" Area ID: {area_id} (NSSA)"
|
||
elif 'stub' in area_type.lower():
|
||
area_label = f" Area ID: {area_id} (Stub)"
|
||
else:
|
||
area_label = f" Area ID: {area_id}"
|
||
|
||
print(area_label)
|
||
|
||
interfaces = area.get('interfaces', {}).get('interface', [])
|
||
interface_count = len(interfaces)
|
||
|
||
# Count active interfaces and neighbors
|
||
active_count = 0
|
||
neighbor_count = 0
|
||
fully_adjacent = 0
|
||
|
||
for iface in interfaces:
|
||
# Count active interfaces (enabled and up)
|
||
if iface.get('enabled', False):
|
||
state = iface.get('state', 'down')
|
||
if state != 'down':
|
||
active_count += 1
|
||
|
||
# Count neighbors
|
||
neighbors = iface.get('neighbors', {}).get('neighbor', [])
|
||
neighbor_count += len(neighbors)
|
||
|
||
# Count fully adjacent neighbors (full state)
|
||
for neighbor in neighbors:
|
||
if neighbor.get('state', '') == 'full':
|
||
fully_adjacent += 1
|
||
|
||
print(f" Number of interfaces in this area: Total: {interface_count}, Active: {active_count}")
|
||
print(f" Number of fully adjacent neighbors in this area: {fully_adjacent}")
|
||
print()
|
||
|
||
|
||
def show_ospf_interfaces(json_data):
|
||
"""Show OSPF interface information"""
|
||
routing = json_data.get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
|
||
|
||
ospf_instance = None
|
||
for protocol in protocols:
|
||
if 'ietf-ospf:ospf' in protocol:
|
||
ospf_instance = protocol
|
||
break
|
||
|
||
if not ospf_instance:
|
||
print("OSPF is not configured or running")
|
||
return
|
||
|
||
ospf = ospf_instance.get('ietf-ospf:ospf', {})
|
||
router_id = ospf.get('router-id', '0.0.0.0')
|
||
areas = ospf.get('areas', {}).get('area', [])
|
||
|
||
if not areas:
|
||
print("No OSPF interfaces configured")
|
||
return
|
||
|
||
# Check if specific interface requested
|
||
requested_ifname = json_data.get('_ifname')
|
||
|
||
# Collect all interfaces from all areas
|
||
all_interfaces = []
|
||
for area in areas:
|
||
area_id = area.get('area-id', '0.0.0.0')
|
||
interfaces = area.get('interfaces', {}).get('interface', [])
|
||
for iface in interfaces:
|
||
iface['_area_id'] = area_id # Add area context
|
||
all_interfaces.append(iface)
|
||
|
||
if not all_interfaces:
|
||
print("No OSPF interfaces found")
|
||
return
|
||
|
||
# If specific interface requested, show detailed view
|
||
if requested_ifname:
|
||
target_iface = None
|
||
for iface in all_interfaces:
|
||
if iface.get('name') == requested_ifname:
|
||
target_iface = iface
|
||
break
|
||
|
||
if not target_iface:
|
||
print(f"Interface {requested_ifname} not found in OSPF")
|
||
return
|
||
|
||
# Display detailed interface information (vtysh-style)
|
||
name = target_iface.get('name', 'unknown')
|
||
area_id = target_iface.get('_area_id', '0.0.0.0')
|
||
state = target_iface.get('state', 'down')
|
||
cost = target_iface.get('cost', 0)
|
||
priority = target_iface.get('priority', 1)
|
||
iface_type = target_iface.get('interface-type', '')
|
||
hello_interval = target_iface.get('hello-interval', 10)
|
||
dead_interval = target_iface.get('dead-interval', 40)
|
||
retransmit_interval = target_iface.get('retransmit-interval', 5)
|
||
transmit_delay = target_iface.get('transmit-delay', 1)
|
||
dr_id = target_iface.get('dr-router-id', '-')
|
||
dr_addr = target_iface.get('dr-ip-addr', '-')
|
||
bdr_id = target_iface.get('bdr-router-id', '-')
|
||
bdr_addr = target_iface.get('bdr-ip-addr', '-')
|
||
neighbors = target_iface.get('neighbors', {}).get('neighbor', [])
|
||
|
||
# Get interface IP address from interfaces data
|
||
ip_address = None
|
||
broadcast = None
|
||
if json_data.get('_interfaces'):
|
||
interfaces = json_data['_interfaces'].get('ietf-interfaces:interfaces', {}).get('interface', [])
|
||
for iface in interfaces:
|
||
if iface.get('name') == name:
|
||
# Get IPv4 address
|
||
ipv4 = iface.get('ietf-ip:ipv4', {})
|
||
addresses = ipv4.get('address', [])
|
||
if addresses:
|
||
addr = addresses[0]
|
||
ip = addr.get('ip', '')
|
||
prefix_len = addr.get('prefix-length', 0)
|
||
if ip and prefix_len:
|
||
ip_address = f"{ip}/{prefix_len}"
|
||
# Calculate broadcast (simple approximation for /30 networks)
|
||
import ipaddress
|
||
try:
|
||
net = ipaddress.IPv4Network(ip_address, strict=False)
|
||
broadcast = str(net.broadcast_address)
|
||
except:
|
||
broadcast = None
|
||
break
|
||
|
||
# Get BFD information from routing data
|
||
bfd_detect_mult = None
|
||
bfd_rx_interval = None
|
||
bfd_tx_interval = None
|
||
bfd_state = None
|
||
if json_data.get('_routing'):
|
||
routing = json_data['_routing'].get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
|
||
for proto in protocols:
|
||
if proto.get('type') == 'infix-routing:bfdv1':
|
||
bfd = proto.get('ietf-bfd:bfd', {})
|
||
ip_sh = bfd.get('ietf-bfd-ip-sh:ip-sh', {})
|
||
sessions_container = ip_sh.get('sessions', {})
|
||
sessions = sessions_container.get('session', [])
|
||
# Find BFD session for this interface
|
||
for session in sessions:
|
||
if session.get('interface') == name:
|
||
session_running = session.get('session-running', {})
|
||
# Get session state
|
||
bfd_state = session_running.get('local-state', 'down')
|
||
# Get detection multiplier and intervals
|
||
rx_interval = session_running.get('negotiated-rx-interval', 0)
|
||
tx_interval = session_running.get('negotiated-tx-interval', 0)
|
||
detection_time = session_running.get('detection-time', 0)
|
||
if rx_interval and detection_time:
|
||
bfd_detect_mult = detection_time // rx_interval
|
||
bfd_rx_interval = rx_interval // 1000 if rx_interval else 0 # Convert μs to ms
|
||
bfd_tx_interval = tx_interval // 1000 if tx_interval else 0
|
||
break
|
||
break
|
||
|
||
# State display
|
||
state_display = state.upper() if state in ['dr', 'bdr'] else state.capitalize()
|
||
if state == 'dr-other':
|
||
state_display = 'DROther'
|
||
|
||
# Network type display (match FRR/Cisco format)
|
||
network_type_map = {
|
||
'point-to-point': 'POINTOPOINT',
|
||
'broadcast': 'BROADCAST',
|
||
'non-broadcast': 'NBMA',
|
||
'point-to-multipoint': 'POINTOMULTIPOINT',
|
||
'hybrid': 'POINTOMULTIPOINT'
|
||
}
|
||
network_type = network_type_map.get(iface_type, iface_type.upper() if iface_type else 'LOOPBACK')
|
||
|
||
print(f"{name} is up")
|
||
if ip_address:
|
||
broadcast_str = f", Broadcast {broadcast}" if broadcast else ""
|
||
print(f" Internet Address {ip_address}{broadcast_str}, Area {area_id}")
|
||
else:
|
||
print(f" Internet Address (not available), Area {area_id}")
|
||
print(f" Router ID {router_id}, Network Type {network_type}, Cost: {cost}")
|
||
print(f" Transmit Delay is {transmit_delay} sec, State {state_display}, Priority {priority}")
|
||
|
||
if dr_id != '-':
|
||
print(f" Designated Router (ID) {dr_id}, Interface Address {dr_addr}")
|
||
if bdr_id != '-':
|
||
print(f" Backup Designated Router (ID) {bdr_id}, Interface Address {bdr_addr}")
|
||
|
||
print(f" Timer intervals configured, Hello {hello_interval}s, Dead {dead_interval}s, Retransmit {retransmit_interval}")
|
||
|
||
# Count adjacent neighbors
|
||
adjacent_count = sum(1 for n in neighbors if n.get('state') == 'full')
|
||
print(f" Neighbor Count is {len(neighbors)}, Adjacent neighbor count is {adjacent_count}")
|
||
|
||
# Show BFD status if available
|
||
if bfd_detect_mult is not None and bfd_rx_interval is not None and bfd_tx_interval is not None:
|
||
# Format state for display
|
||
state_display_map = {
|
||
'up': 'Up (two-way connection established)',
|
||
'down': 'Down (no connection)',
|
||
'init': 'Init (initializing, no two-way yet)',
|
||
'adminDown': 'AdminDown (administratively disabled)'
|
||
}
|
||
bfd_state_display = state_display_map.get(bfd_state, bfd_state.capitalize() if bfd_state else 'Unknown')
|
||
print(f" BFD: Status: {bfd_state_display}")
|
||
print(f" Detect Multiplier: {bfd_detect_mult}, Min Rx interval: {bfd_rx_interval}, Min Tx interval: {bfd_tx_interval}")
|
||
else:
|
||
print(f" BFD: Disabled")
|
||
|
||
return
|
||
|
||
# Display table view (no specific interface)
|
||
type_display_map = {
|
||
'point-to-point': 'P2P',
|
||
'broadcast': 'Broadcast',
|
||
'non-broadcast': 'NBMA',
|
||
'point-to-multipoint': 'P2MP',
|
||
'hybrid': 'Hybrid'
|
||
}
|
||
|
||
def fmt_state(state):
|
||
if state in ('dr', 'bdr'):
|
||
return state.upper()
|
||
if state == 'dr-other':
|
||
return 'DROther'
|
||
return state.capitalize()
|
||
|
||
table = SimpleTable([
|
||
Column('INTERFACE'),
|
||
Column('AREA'),
|
||
Column('TYPE'),
|
||
Column('STATE'),
|
||
Column('COST', 'right'),
|
||
Column('PRI', 'right'),
|
||
Column('DR'),
|
||
Column('BDR'),
|
||
Column('NBRS', 'right')
|
||
])
|
||
|
||
for iface in all_interfaces:
|
||
name = iface.get('name', 'unknown')
|
||
area_id = iface.get('_area_id', '0.0.0.0')
|
||
state = iface.get('state', 'down')
|
||
iface_type = iface.get('interface-type', '')
|
||
cost = iface.get('cost', 0)
|
||
priority = iface.get('priority', 1)
|
||
dr_id = iface.get('dr-router-id', '-')
|
||
bdr_id = iface.get('bdr-router-id', '-')
|
||
neighbors = iface.get('neighbors', {}).get('neighbor', [])
|
||
|
||
table.row(name, area_id,
|
||
type_display_map.get(iface_type, iface_type.capitalize() if iface_type else '-'),
|
||
fmt_state(state),
|
||
cost, priority, dr_id, bdr_id, len(neighbors))
|
||
|
||
table.print()
|
||
|
||
|
||
def show_ospf_neighbor(json_data):
|
||
"""Show OSPF neighbor information"""
|
||
routing = json_data.get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
|
||
|
||
ospf_instance = None
|
||
for protocol in protocols:
|
||
if 'ietf-ospf:ospf' in protocol:
|
||
ospf_instance = protocol
|
||
break
|
||
|
||
if not ospf_instance:
|
||
print("OSPF is not configured or running")
|
||
return
|
||
|
||
ospf = ospf_instance.get('ietf-ospf:ospf', {})
|
||
areas = ospf.get('areas', {}).get('area', [])
|
||
|
||
if not areas:
|
||
print("No OSPF areas configured")
|
||
return
|
||
|
||
# Collect all neighbors from all interfaces in all areas
|
||
all_neighbors = []
|
||
for area in areas:
|
||
area_id = area.get('area-id', '0.0.0.0')
|
||
interfaces = area.get('interfaces', {}).get('interface', [])
|
||
for iface in interfaces:
|
||
iface_name = iface.get('name', 'unknown')
|
||
neighbors = iface.get('neighbors', {}).get('neighbor', [])
|
||
for neighbor in neighbors:
|
||
neighbor['_interface'] = iface_name
|
||
neighbor['_area_id'] = area_id
|
||
all_neighbors.append(neighbor)
|
||
|
||
if not all_neighbors:
|
||
print("No OSPF neighbors found")
|
||
return
|
||
|
||
# Display table header with PRI and UPTIME columns
|
||
hdr = f"{'NEIGHBOR ID':<16} {'PRI':<4} {'STATE':<12} {'UPTIME':<10} {'DEAD TIME':<10} {'ADDRESS':<16} {'INTERFACE':<18} {'AREA':<12}"
|
||
print(Decore.invert(hdr))
|
||
|
||
for neighbor in all_neighbors:
|
||
neighbor_id = neighbor.get('neighbor-router-id', '0.0.0.0')
|
||
address = neighbor.get('address', '0.0.0.0')
|
||
state = neighbor.get('state', 'down')
|
||
priority = neighbor.get('priority', 1) # Default priority is 1
|
||
uptime = neighbor.get('infix-routing:uptime', 0)
|
||
dead_timer = neighbor.get('dead-timer', 0)
|
||
# Use interface-name (e.g., "e5:10.0.23.1") if available, fallback to interface name
|
||
interface = neighbor.get('infix-routing:interface-name', neighbor.get('_interface', 'unknown'))
|
||
area_id = neighbor.get('_area_id', '0.0.0.0')
|
||
role = neighbor.get('infix-routing:role', '')
|
||
|
||
# Capitalize state and add role if present
|
||
state_base = state.capitalize() if state != '2-way' else '2-Way'
|
||
if role and state_base == 'Full':
|
||
state_display = f"{state_base}/{role}"
|
||
else:
|
||
state_display = state_base
|
||
|
||
# Format uptime (convert seconds to human-readable format)
|
||
if uptime > 0:
|
||
days = uptime // 86400
|
||
hours = (uptime % 86400) // 3600
|
||
minutes = (uptime % 3600) // 60
|
||
seconds = uptime % 60
|
||
|
||
if days > 0:
|
||
uptime_display = f"{days}d{hours:02d}h{minutes:02d}m"
|
||
elif hours > 0:
|
||
uptime_display = f"{hours}h{minutes:02d}m{seconds:02d}s"
|
||
elif minutes > 0:
|
||
uptime_display = f"{minutes}m{seconds:02d}s"
|
||
else:
|
||
uptime_display = f"{seconds}s"
|
||
else:
|
||
uptime_display = "-"
|
||
|
||
# Format dead timer
|
||
dead_display = f"{dead_timer}s" if dead_timer > 0 else "-"
|
||
|
||
print(f"{neighbor_id:<16} {priority:<4} {state_display:<12} {uptime_display:<10} {dead_display:<10} {address:<16} {interface:<18} {area_id:<12}")
|
||
|
||
|
||
def show_ospf_routes(json_data):
|
||
"""Show OSPF routing table (local-rib)"""
|
||
routing = json_data.get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
|
||
|
||
ospf_instance = None
|
||
for protocol in protocols:
|
||
if 'ietf-ospf:ospf' in protocol:
|
||
ospf_instance = protocol
|
||
break
|
||
|
||
if not ospf_instance:
|
||
print("OSPF is not configured or running")
|
||
return
|
||
|
||
ospf = ospf_instance.get('ietf-ospf:ospf', {})
|
||
local_rib = ospf.get('local-rib', {})
|
||
routes = local_rib.get('route', [])
|
||
|
||
if not routes:
|
||
print("No OSPF routes in local RIB")
|
||
return
|
||
|
||
# Display table header (AREA column from infix-routing augmentation)
|
||
hdr = f"{'DESTINATION':<20} {'TYPE':<10} {'AREA':<12} {'METRIC':<8} {'NEXT HOP':<16} {'INTERFACE':<12}"
|
||
print(Decore.invert(hdr))
|
||
|
||
for route in routes:
|
||
prefix = route.get('prefix', '0.0.0.0/0')
|
||
route_type = route.get('route-type', 'unknown')
|
||
# Check for area-id with infix-routing prefix (augmented field)
|
||
area_id = route.get('infix-routing:area-id', route.get('area-id', '-'))
|
||
metric = route.get('metric', 0)
|
||
|
||
# Simplify route type display
|
||
type_map = {
|
||
'intra-area': 'Intra',
|
||
'inter-area': 'Inter',
|
||
'external-1': 'Ext-1',
|
||
'external-2': 'Ext-2',
|
||
'nssa-1': 'NSSA-1',
|
||
'nssa-2': 'NSSA-2'
|
||
}
|
||
type_display = type_map.get(route_type, route_type)
|
||
|
||
# Get next hops
|
||
next_hops_data = route.get('next-hops', {})
|
||
next_hops = next_hops_data.get('next-hop', [])
|
||
|
||
if not next_hops:
|
||
print(f"{prefix:<20} {type_display:<10} {area_id:<12} {metric:<8} {'-':<16} {'-':<12}")
|
||
else:
|
||
# Display first next hop on same line as route
|
||
first_hop = next_hops[0]
|
||
next_hop_addr = first_hop.get('next-hop', '-')
|
||
outgoing_iface = first_hop.get('outgoing-interface', '-')
|
||
|
||
print(f"{prefix:<20} {type_display:<10} {area_id:<12} {metric:<8} {next_hop_addr:<16} {outgoing_iface:<12}")
|
||
|
||
# Display additional next hops indented
|
||
for hop in next_hops[1:]:
|
||
next_hop_addr = hop.get('next-hop', '-')
|
||
outgoing_iface = hop.get('outgoing-interface', '-')
|
||
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', {})
|
||
protocols = routing.get('control-plane-protocols', {})
|
||
protocol_list = protocols.get('control-plane-protocol', [])
|
||
|
||
bfd_protocol = None
|
||
for proto in protocol_list:
|
||
if proto.get('type') == 'infix-routing:bfdv1':
|
||
bfd_protocol = proto
|
||
break
|
||
|
||
if not bfd_protocol:
|
||
print("BFD is not enabled")
|
||
return
|
||
|
||
print("Is enabled, single-hop (ospf)")
|
||
|
||
|
||
def show_bfd_peers(json_data):
|
||
"""Show BFD peer sessions in detailed format"""
|
||
routing = json_data.get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {})
|
||
protocol_list = protocols.get('control-plane-protocol', [])
|
||
|
||
bfd_protocol = None
|
||
for proto in protocol_list:
|
||
if proto.get('type') == 'infix-routing:bfdv1':
|
||
bfd_protocol = proto
|
||
break
|
||
|
||
if not bfd_protocol:
|
||
print("BFD is not enabled")
|
||
return
|
||
|
||
bfd = bfd_protocol.get('ietf-bfd:bfd', {})
|
||
ip_sh = bfd.get('ietf-bfd-ip-sh:ip-sh', {})
|
||
sessions_container = ip_sh.get('sessions', {})
|
||
sessions = sessions_container.get('session', [])
|
||
|
||
if not sessions:
|
||
print("No BFD sessions found")
|
||
return
|
||
|
||
print("BFD Peers:")
|
||
for session in sessions:
|
||
peer = session.get('dest-addr', 'unknown')
|
||
interface = session.get('interface', 'unknown')
|
||
local_disc = session.get('local-discriminator', 0)
|
||
remote_disc = session.get('remote-discriminator', 0)
|
||
|
||
# Get session running state
|
||
session_running = session.get('session-running', {})
|
||
local_state = session_running.get('local-state', 'unknown')
|
||
remote_state = session_running.get('remote-state', 'unknown')
|
||
local_diag = session_running.get('local-diagnostic', 'unknown')
|
||
detection_mode = session_running.get('detection-mode', 'unknown')
|
||
detection_time = session_running.get('detection-time', 0)
|
||
rx_interval = session_running.get('negotiated-rx-interval', 0)
|
||
tx_interval = session_running.get('negotiated-tx-interval', 0)
|
||
|
||
# Convert microseconds to milliseconds for display
|
||
rx_ms = rx_interval // 1000 if rx_interval else 0
|
||
tx_ms = tx_interval // 1000 if tx_interval else 0
|
||
detect_mult = detection_time // rx_interval if rx_interval and detection_time else 0
|
||
|
||
print(f" peer {peer}")
|
||
print(f" ID: {local_disc}")
|
||
print(f" Remote ID: {remote_disc}")
|
||
print(f" Status: {local_state}")
|
||
print(f" Diagnostics: {local_diag}")
|
||
print(f" Remote diagnostics: {local_diag}")
|
||
print(f" Peer Type: dynamic")
|
||
print(f" Local timers:")
|
||
print(f" Detect-multiplier: {detect_mult}")
|
||
print(f" Receive interval: {rx_ms}ms")
|
||
print(f" Transmission interval: {tx_ms}ms")
|
||
print(f" Echo transmission interval: disabled")
|
||
print(f" Remote timers:")
|
||
print(f" Detect-multiplier: {detect_mult}")
|
||
print(f" Receive interval: {rx_ms}ms")
|
||
print(f" Transmission interval: {tx_ms}ms")
|
||
print(f" Echo transmission interval: disabled")
|
||
print()
|
||
|
||
|
||
def show_bfd_peers_brief(json_data):
|
||
"""Show BFD peers in brief table format"""
|
||
routing = json_data.get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {})
|
||
protocol_list = protocols.get('control-plane-protocol', [])
|
||
|
||
# Find BFD protocol instance
|
||
bfd_protocol = None
|
||
for proto in protocol_list:
|
||
if proto.get('type') == 'infix-routing:bfdv1':
|
||
bfd_protocol = proto
|
||
break
|
||
|
||
if not bfd_protocol:
|
||
print("BFD is not enabled")
|
||
return
|
||
|
||
bfd = bfd_protocol.get('ietf-bfd:bfd', {})
|
||
ip_sh = bfd.get('ietf-bfd-ip-sh:ip-sh', {})
|
||
sessions_container = ip_sh.get('sessions', {})
|
||
sessions = sessions_container.get('session', [])
|
||
|
||
if not sessions:
|
||
print("No BFD sessions found")
|
||
return
|
||
|
||
# Build interface to IP mapping by looking up from peer address
|
||
# We need to extract the local IP from the same subnet as the peer
|
||
ifaces_data = json_data.get('_interfaces', {})
|
||
if_ip_map = {}
|
||
|
||
if ifaces_data:
|
||
interfaces = ifaces_data.get('ietf-interfaces:interfaces', {}).get('interface', [])
|
||
for iface in interfaces:
|
||
ifname = iface.get('name', '')
|
||
ipv4 = iface.get('ietf-ip:ipv4', {})
|
||
addresses = ipv4.get('address', [])
|
||
for addr_entry in addresses:
|
||
ip = addr_entry.get('ip', '')
|
||
prefix_len = addr_entry.get('prefix-length', 0)
|
||
if ip and ifname:
|
||
if_ip_map[ifname] = ip
|
||
|
||
print(f"Session count: {len(sessions)}")
|
||
hdr = f"{'SESSION ID':<16} {'LOCAL':<22} {'PEER':<22} {'STATE':<10}"
|
||
print(Decore.invert(hdr))
|
||
|
||
for session in sessions:
|
||
local_disc = session.get('local-discriminator', 0)
|
||
interface = session.get('interface', '-')
|
||
peer = session.get('dest-addr', '-')
|
||
|
||
# Get session running state
|
||
session_running = session.get('session-running', {})
|
||
local_state = session_running.get('local-state', 'unknown')
|
||
|
||
# Format local address as interface:ip
|
||
local_ip = if_ip_map.get(interface, '')
|
||
if local_ip:
|
||
local_addr = f"{interface}:{local_ip}"
|
||
else:
|
||
local_addr = interface
|
||
|
||
print(f"{local_disc:<16} {local_addr:<22} {peer:<22} {local_state:<10}")
|
||
|
||
|
||
def show_bfd_peer(json_data):
|
||
"""Show specific BFD peer details"""
|
||
peer_addr = json_data.get('_peer_addr')
|
||
if not peer_addr:
|
||
print("Error: No peer address specified")
|
||
return
|
||
|
||
routing = json_data.get('ietf-routing:routing', {})
|
||
protocols = routing.get('control-plane-protocols', {})
|
||
protocol_list = protocols.get('control-plane-protocol', [])
|
||
|
||
# Find BFD protocol instance
|
||
bfd_protocol = None
|
||
for proto in protocol_list:
|
||
if proto.get('type') == 'infix-routing:bfdv1':
|
||
bfd_protocol = proto
|
||
break
|
||
|
||
if not bfd_protocol:
|
||
print("BFD is not enabled")
|
||
return
|
||
|
||
bfd = bfd_protocol.get('ietf-bfd:bfd', {})
|
||
ip_sh = bfd.get('ietf-bfd-ip-sh:ip-sh', {})
|
||
sessions_container = ip_sh.get('sessions', {})
|
||
sessions = sessions_container.get('session', [])
|
||
|
||
# Find the specific peer
|
||
target_session = None
|
||
for session in sessions:
|
||
if session.get('dest-addr') == peer_addr:
|
||
target_session = session
|
||
break
|
||
|
||
if not target_session:
|
||
print(f"BFD peer {peer_addr} not found")
|
||
return
|
||
|
||
# Display single peer details
|
||
peer = target_session.get('dest-addr', 'unknown')
|
||
interface = target_session.get('interface', 'unknown')
|
||
local_disc = target_session.get('local-discriminator', 0)
|
||
remote_disc = target_session.get('remote-discriminator', 0)
|
||
|
||
# Get session running state
|
||
session_running = target_session.get('session-running', {})
|
||
local_state = session_running.get('local-state', 'unknown')
|
||
remote_state = session_running.get('remote-state', 'unknown')
|
||
local_diag = session_running.get('local-diagnostic', 'unknown')
|
||
detection_mode = session_running.get('detection-mode', 'unknown')
|
||
detection_time = session_running.get('detection-time', 0)
|
||
rx_interval = session_running.get('negotiated-rx-interval', 0)
|
||
tx_interval = session_running.get('negotiated-tx-interval', 0)
|
||
|
||
# Convert microseconds to milliseconds for display
|
||
rx_ms = rx_interval // 1000 if rx_interval else 0
|
||
tx_ms = tx_interval // 1000 if tx_interval else 0
|
||
detect_mult = detection_time // rx_interval if rx_interval and detection_time else 0
|
||
|
||
print("BFD Peer:")
|
||
print(f" peer {peer}")
|
||
print(f" ID: {local_disc}")
|
||
print(f" Remote ID: {remote_disc}")
|
||
print(f" Status: {local_state}")
|
||
print(f" Diagnostics: {local_diag}")
|
||
print(f" Remote diagnostics: {local_diag}")
|
||
print(f" Peer Type: dynamic")
|
||
print(f" Local timers:")
|
||
print(f" Detect-multiplier: {detect_mult}")
|
||
print(f" Receive interval: {rx_ms}ms")
|
||
print(f" Transmission interval: {tx_ms}ms")
|
||
print(f" Echo transmission interval: disabled")
|
||
print(f" Remote timers:")
|
||
print(f" Detect-multiplier: {detect_mult}")
|
||
print(f" Receive interval: {rx_ms}ms")
|
||
print(f" Transmission interval: {tx_ms}ms")
|
||
print(f" Echo transmission interval: disabled")
|
||
|
||
|
||
def show_bfd(json_data):
|
||
"""Legacy function - redirect to show_bfd_peers_brief for backward compatibility"""
|
||
show_bfd_peers_brief(json_data)
|
||
|
||
|
||
def _ptp_ns(yang_val):
|
||
"""Convert YANG time-interval (ns × 2^16, stored as str) to integer nanoseconds."""
|
||
try:
|
||
return int(yang_val) // 65536
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _ptp_strip(identity):
|
||
"""Strip YANG module prefix from identityref value.
|
||
|
||
'ieee1588-ptp-tt:cc-default' → 'cc-default'
|
||
"""
|
||
if identity and ':' in identity:
|
||
return identity.split(':', 1)[1]
|
||
return identity or ''
|
||
|
||
|
||
_PTP_INSTANCE_TYPE_NAMES = {
|
||
"oc": "Ordinary Clock",
|
||
"bc": "Boundary Clock",
|
||
"p2p-tc": "P2P Transparent Clock",
|
||
"e2e-tc": "E2E Transparent Clock",
|
||
}
|
||
|
||
_PTP_PORT_STATE_COLOR = {
|
||
"time-transmitter": Decore.green,
|
||
"time-receiver": Decore.green,
|
||
"pre-time-transmitter": Decore.yellow,
|
||
"uncalibrated": Decore.yellow,
|
||
"listening": Decore.yellow,
|
||
"passive": lambda x: x,
|
||
"faulty": Decore.red,
|
||
"disabled": Decore.red,
|
||
"initializing": lambda x: x,
|
||
}
|
||
|
||
|
||
def show_ptp(json_data, instance_index=None):
|
||
"""Show PTP instance status."""
|
||
ptp = json_data.get("ieee1588-ptp-tt:ptp", {})
|
||
instances = ptp.get("instances", {}).get("instance", [])
|
||
|
||
if not instances:
|
||
print("PTP: no instances configured.")
|
||
return
|
||
|
||
for inst in instances:
|
||
idx = inst.get("instance-index", "?")
|
||
|
||
if instance_index is not None and str(idx) != str(instance_index):
|
||
continue
|
||
|
||
dds = inst.get("default-ds", {})
|
||
cds = inst.get("current-ds", {})
|
||
pds = inst.get("parent-ds", {})
|
||
tpds = inst.get("time-properties-ds", {})
|
||
ports = inst.get("ports", {}).get("port", [])
|
||
|
||
itype = _PTP_INSTANCE_TYPE_NAMES.get(dds.get("instance-type", "oc"), "Unknown")
|
||
domain = dds.get("domain-number", 0)
|
||
clock_id = dds.get("clock-identity", "?")
|
||
|
||
# ── Header ────────────────────────────────────────────────────────────
|
||
header = f"PTP Instance {idx}"
|
||
subtitle = f"{itype} · domain {domain}"
|
||
pad_len = max(1, 40 - len(header))
|
||
pad = " " * pad_len
|
||
rule_w = max(68, len(header) + pad_len + len(subtitle))
|
||
print(f"{Decore.bold(header)}{pad}{subtitle}")
|
||
print("─" * rule_w)
|
||
|
||
# ── Clock / GM identity ───────────────────────────────────────────────
|
||
W = 24
|
||
gm_id = pds.get("grandmaster-identity", "")
|
||
print(f" {'Clock identity':<{W}}: {clock_id}")
|
||
if gm_id and gm_id != clock_id:
|
||
print(f" {'Grandmaster':<{W}}: {gm_id}")
|
||
else:
|
||
print(f" {'Grandmaster':<{W}}: (self)")
|
||
|
||
# ── Priorities ────────────────────────────────────────────────────────
|
||
p1, p2 = dds.get("priority1", "?"), dds.get("priority2", "?")
|
||
print(f" {'Priority1/Priority2':<{W}}: {p1} / {p2}")
|
||
|
||
if gm_id and gm_id != clock_id:
|
||
gp1 = pds.get("grandmaster-priority1", "?")
|
||
gp2 = pds.get("grandmaster-priority2", "?")
|
||
print(f" {'GM Priority1/Priority2':<{W}}: {gp1} / {gp2}")
|
||
|
||
# ── Clock quality ─────────────────────────────────────────────────────
|
||
cq = dds.get("clock-quality", {})
|
||
cc = _ptp_strip(cq.get("clock-class", ""))
|
||
if cc:
|
||
print(f" {'Clock class':<{W}}: {cc}")
|
||
|
||
if gm_id and gm_id != clock_id:
|
||
gcq = pds.get("grandmaster-clock-quality", {})
|
||
gcc = _ptp_strip(gcq.get("clock-class", ""))
|
||
if gcc and gcc != cc:
|
||
print(f" {'GM clock class':<{W}}: {gcc}")
|
||
|
||
# ── Time source ───────────────────────────────────────────────────────
|
||
ts = _ptp_strip(tpds.get("time-source", ""))
|
||
if ts:
|
||
print(f" {'Time source':<{W}}: {ts}")
|
||
|
||
if dds.get("time-receiver-only"):
|
||
print(f" {'Mode':<{W}}: time-receiver only")
|
||
|
||
# ── Time properties ───────────────────────────────────────────────────
|
||
ptp_ts = "yes" if tpds.get("ptp-timescale") else "no"
|
||
t_trace = "yes" if tpds.get("time-traceable") else "no"
|
||
f_trace = "yes" if tpds.get("frequency-traceable") else "no"
|
||
utc_off = tpds.get("current-utc-offset")
|
||
utc_str = f"{utc_off} s" if utc_off is not None else "N/A"
|
||
print(f" {'PTP timescale':<{W}}: {ptp_ts}")
|
||
print(f" {'UTC offset':<{W}}: {utc_str}")
|
||
print(f" {'Time traceable':<{W}}: {t_trace}")
|
||
print(f" {'Freq. traceable':<{W}}: {f_trace}")
|
||
|
||
# ── Sync status ───────────────────────────────────────────────────────
|
||
offset = _ptp_ns(cds.get("offset-from-time-transmitter"))
|
||
delay = _ptp_ns(cds.get("mean-delay"))
|
||
steps = cds.get("steps-removed")
|
||
if offset is not None:
|
||
print(f" {'Offset from GM':<{W}}: {offset} ns")
|
||
if delay is not None:
|
||
print(f" {'Mean path delay':<{W}}: {delay} ns")
|
||
if steps is not None:
|
||
print(f" {'Steps removed':<{W}}: {steps}")
|
||
|
||
# ── Ports ─────────────────────────────────────────────────────────────
|
||
if ports:
|
||
print()
|
||
Decore.title("Ports", width=rule_w)
|
||
|
||
port_table = SimpleTable([
|
||
Column("PORT", align='right'),
|
||
Column("INTERFACE", flexible=True),
|
||
Column("STATE", flexible=True),
|
||
Column("DELAY"),
|
||
Column("LINK DELAY (ns)", align='right'),
|
||
])
|
||
stats_table = SimpleTable([
|
||
Column("PORT", align='right'),
|
||
Column("INTERFACE", flexible=True),
|
||
Column("SYNC \u25bc", align='right'),
|
||
Column("SYNC \u25b2", align='right'),
|
||
Column("ANN \u25bc", align='right'),
|
||
Column("ANN \u25b2", align='right'),
|
||
Column("PD \u25bc", align='right'),
|
||
Column("PD \u25b2", align='right'),
|
||
])
|
||
has_stats = False
|
||
|
||
for port in ports:
|
||
pidx = port.get("port-index", "?")
|
||
pds_ = port.get("port-ds", {})
|
||
iface = port.get("underlying-interface",
|
||
pds_.get("port-identity", {}).get("clock-identity", "?"))
|
||
|
||
state_raw = pds_.get("port-state", "?")
|
||
color_fn = _PTP_PORT_STATE_COLOR.get(state_raw, lambda x: x)
|
||
state_str = color_fn(state_raw)
|
||
|
||
dm = (pds_.get("delay-mechanism") or "?").upper()
|
||
mld = _ptp_ns(pds_.get("mean-link-delay"))
|
||
mld_str = str(mld) if mld is not None else ""
|
||
|
||
port_table.row(str(pidx), iface, state_str, dm, mld_str)
|
||
|
||
st = port.get("ieee802-dot1as-gptp:port-statistics-ds", {})
|
||
if st:
|
||
has_stats = True
|
||
stats_table.row(
|
||
str(pidx), iface,
|
||
str(st.get("rx-sync-count", 0)),
|
||
str(st.get("tx-sync-count", 0)),
|
||
str(st.get("rx-announce-count", 0)),
|
||
str(st.get("tx-announce-count", 0)),
|
||
str(st.get("rx-pdelay-req-count", 0)),
|
||
str(st.get("tx-pdelay-req-count", 0)),
|
||
)
|
||
|
||
port_table.adjust_padding(rule_w)
|
||
port_table.print()
|
||
|
||
if has_stats:
|
||
print()
|
||
Decore.title("Message Statistics (\u25bc\u202frx \u25b2\u202ftx)", width=rule_w)
|
||
stats_table.adjust_padding(rule_w)
|
||
stats_table.print()
|
||
|
||
print()
|
||
|
||
|
||
def main():
|
||
global UNIT_TEST
|
||
|
||
try:
|
||
raw = sys.stdin.read()
|
||
json_data = json.loads(raw) if raw.strip() else {}
|
||
except json.JSONDecodeError:
|
||
print("Error, invalid JSON input")
|
||
sys.exit(1)
|
||
except Exception as e:
|
||
print("Error, unexpected error parsing JSON")
|
||
sys.exit(1)
|
||
|
||
parser = argparse.ArgumentParser(description="JSON CLI Pretty Printer")
|
||
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
||
|
||
parser.add_argument('-t', '--test', action='store_true', help='Enable unit test mode')
|
||
|
||
subparsers.add_parser('show-boot-order', help='Show NTP sources')
|
||
|
||
subparsers.add_parser('show-bridge-mdb', help='Show bridge MDB')
|
||
|
||
subparsers.add_parser('show-bridge-stp', help='Show spanning tree state')
|
||
|
||
subparsers.add_parser('show-dhcp-server', help='Show DHCP server') \
|
||
.add_argument("-s", "--stats", action="store_true", help="Show server statistics")
|
||
|
||
subparsers.add_parser('show-container', help='Show containers table')
|
||
subparsers.add_parser('show-container-detail', help='Show container details') \
|
||
.add_argument('name', help='Container name')
|
||
|
||
subparsers.add_parser('show-hardware', help='Show USB ports')
|
||
|
||
subparsers.add_parser('show-interfaces', help='Show interfaces') \
|
||
.add_argument('-n', '--name', help='Interface name')
|
||
|
||
subparsers.add_parser('show-lldp', help='Show LLDP neighbors')
|
||
|
||
subparsers.add_parser('show-mdns', help='Show mDNS configuration and neighbors')
|
||
|
||
subparsers.add_parser('show-firewall', help='Show firewall overview')
|
||
subparsers.add_parser('show-firewall-matrix', help='Show firewall matrix')
|
||
subparsers.add_parser('show-firewall-zone', help='Show firewall zones') \
|
||
.add_argument('name', nargs='?', help='Zone name')
|
||
subparsers.add_parser('show-firewall-policy', help='Show firewall policies') \
|
||
.add_argument('name', nargs='?', help='Policy name')
|
||
subparsers.add_parser('show-firewall-service', help='Show firewall services') \
|
||
.add_argument('name', nargs='?', help='Service name')
|
||
subparsers.add_parser('show-firewall-log', help='Show firewall log') \
|
||
.add_argument('limit', nargs='?', help='Last N lines, default: all')
|
||
|
||
subparsers.add_parser('show-nacm', help='Show NACM status and groups')
|
||
subparsers.add_parser('show-nacm-group', help='Show NACM group details')
|
||
subparsers.add_parser('show-nacm-user', help='Show NACM user details')
|
||
|
||
ks_parser = subparsers.add_parser('show-keystore', help='Show keystore keys')
|
||
ks_parser.add_argument('-t', '--type', help='Key type (symmetric or asymmetric)')
|
||
ks_parser.add_argument('-n', '--name', help='Key name')
|
||
|
||
subparsers.add_parser('show-ptp', help='Show PTP instance status') \
|
||
.add_argument('instance', nargs='?', help='Instance index (optional)')
|
||
|
||
subparsers.add_parser('show-ntp', help='Show NTP status') \
|
||
.add_argument('-a', '--address', help='Show details for specific address')
|
||
subparsers.add_parser('show-ntp-tracking', help='Show NTP tracking status')
|
||
subparsers.add_parser('show-ntp-source', help='Show NTP associations/sources') \
|
||
.add_argument('-a', '--address', help='Show details for specific source')
|
||
|
||
subparsers.add_parser('show-bfd', help='Show BFD sessions')
|
||
subparsers.add_parser('show-bfd-status', help='Show BFD status')
|
||
subparsers.add_parser('show-bfd-peers', help='Show BFD peers')
|
||
subparsers.add_parser('show-bfd-peers-brief', help='Show BFD peers brief')
|
||
subparsers.add_parser('show-bfd-peer', help='Show BFD peer')
|
||
|
||
subparsers.add_parser('show-ospf', help='Show OSPF instance information')
|
||
subparsers.add_parser('show-ospf-interfaces', help='Show OSPF interfaces')
|
||
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')
|
||
|
||
subparsers.add_parser('show-services', help='Show system services')
|
||
|
||
subparsers.add_parser('show-software', help='Show software versions') \
|
||
.add_argument('-n', '--name', help='Slotname')
|
||
|
||
subparsers.add_parser('show-system', help='Show system overview')
|
||
|
||
args = parser.parse_args()
|
||
UNIT_TEST = args.test
|
||
|
||
if args.command == "show-bridge-mdb":
|
||
show_bridge_mdb(json_data)
|
||
elif args.command == "show-bridge-stp":
|
||
show_bridge_stp(json_data)
|
||
elif args.command == "show-dhcp-server":
|
||
show_dhcp_server(json_data, args.stats)
|
||
elif args.command == "show-container":
|
||
show_container(json_data)
|
||
elif args.command == "show-container-detail":
|
||
show_container_detail(json_data, args.name)
|
||
elif args.command == "show-hardware":
|
||
show_hardware(json_data)
|
||
elif args.command == "show-interfaces":
|
||
show_interfaces(json_data, args.name)
|
||
elif args.command == "show-lldp":
|
||
show_lldp(json_data)
|
||
elif args.command == "show-mdns":
|
||
show_mdns(json_data)
|
||
elif args.command == "show-firewall":
|
||
show_firewall(json_data)
|
||
elif args.command == "show-firewall-matrix":
|
||
show_firewall_matrix(json_data)
|
||
elif args.command == "show-firewall-zone":
|
||
show_firewall_zone(json_data, args.name)
|
||
elif args.command == "show-firewall-policy":
|
||
show_firewall_policy(json_data, args.name)
|
||
elif args.command == "show-firewall-service":
|
||
show_firewall_service(json_data, args.name)
|
||
elif args.command == "show-firewall-log":
|
||
show_firewall_logs(args.limit)
|
||
elif args.command == "show-nacm":
|
||
show_nacm(json_data)
|
||
elif args.command == "show-nacm-group":
|
||
show_nacm_group(json_data)
|
||
elif args.command == "show-nacm-user":
|
||
show_nacm_user(json_data)
|
||
elif args.command == "show-keystore":
|
||
show_keystore(json_data, getattr(args, 'type', None), args.name)
|
||
elif args.command == "show-ptp":
|
||
show_ptp(json_data, getattr(args, 'instance', None))
|
||
elif args.command == "show-ntp":
|
||
show_ntp(json_data, args.address)
|
||
elif args.command == "show-ntp-tracking":
|
||
show_ntp_tracking(json_data)
|
||
elif args.command == "show-ntp-source":
|
||
show_ntp_source(json_data, args.address)
|
||
elif args.command == "show-wifi-radio":
|
||
show_wifi_radio(json_data)
|
||
elif args.command == "show-bfd":
|
||
show_bfd(json_data)
|
||
elif args.command == "show-bfd-status":
|
||
show_bfd_status(json_data)
|
||
elif args.command == "show-bfd-peers":
|
||
show_bfd_peers(json_data)
|
||
elif args.command == "show-bfd-peers-brief":
|
||
show_bfd_peers_brief(json_data)
|
||
elif args.command == "show-bfd-peer":
|
||
show_bfd_peer(json_data)
|
||
elif args.command == "show-ospf":
|
||
show_ospf(json_data)
|
||
elif args.command == "show-ospf-interfaces":
|
||
show_ospf_interfaces(json_data)
|
||
elif args.command == "show-ospf-neighbor":
|
||
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":
|
||
show_software(json_data, args.name)
|
||
elif args.command == "show-services":
|
||
show_services(json_data)
|
||
elif args.command == "show-system":
|
||
show_system(json_data)
|
||
|
||
else:
|
||
print(f"Error, unknown command '{args.command}'")
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|