Skip to content

Instantly share code, notes, and snippets.

@mailinglists35
Created April 25, 2026 21:05
Show Gist options
  • Select an option

  • Save mailinglists35/3a17cf0b63cb4522fd9785218a57df84 to your computer and use it in GitHub Desktop.

Select an option

Save mailinglists35/3a17cf0b63cb4522fd9785218a57df84 to your computer and use it in GitHub Desktop.
view Network Manager interfaces as a tree of dependencies (bridge/vlan etc) inside a summary overview (ip addresses, firewalld zone, ipv4.routes) - ideally what `nmcli c s --tree` should have had as an option
#!/usr/bin/env python3
"""
Copyright (c) 2026 Mai Ling
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
___
Generated with Google Gemini 3.1 Pro
"""
import subprocess
import sys
import re
import argparse
import os
import itertools
def run_cmd(cmd):
try:
res = subprocess.run(
cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=True
)
return res.stdout.strip()
except subprocess.CalledProcessError:
return ""
def deduplicate(seq):
seen = set()
res = []
for x in seq:
if x not in seen:
res.append(x)
seen.add(x)
return res
def get_runtime_ips():
"""Extrage rapid IP-urile efectiv active în kernel pentru a face diferența (+/-)"""
raw = run_cmd("nmcli -t -c no device show")
data = {}
curr_dev = None
for line in raw.splitlines():
if line.startswith("GENERAL.DEVICE:"):
curr_dev = line.split(":", 1)[1].strip()
data[curr_dev] = {'ipv4': [], 'ipv6': []}
elif curr_dev:
if line.startswith("IP4.ADDRESS["):
ip = line.split(":", 1)[1].strip()
data[curr_dev]['ipv4'].append(ip)
elif line.startswith("IP6.ADDRESS["):
ip = line.split(":", 1)[1].strip()
data[curr_dev]['ipv6'].append(ip)
return data
def get_connections():
raw = run_cmd("nmcli -t -c no -f NAME,UUID,TYPE,DEVICE connection show")
if not raw:
print("Error: nmcli returned no data or the command failed.")
sys.exit(1)
conns = []
for line in raw.splitlines():
parts = re.split(r'(?<!\\):', line)
parts = [p.replace('\\:', ':') for p in parts]
if len(parts) >= 4:
conns.append({
'name': parts[0],
'uuid': parts[1],
'type': parts[2],
'device': parts[3],
'master': '',
'vlan_parent': '',
'zone': '',
'ipv4_cfg': [],
'ipv6_cfg': [],
'routes': [],
'children': [],
'is_child': False,
'layered_on': None,
'parent_node': None
})
return conns
def enrich_connections(conns):
runtime_data = get_runtime_ips()
for c in conns:
raw = run_cmd(f"nmcli -t -c no connection show '{c['uuid']}'")
for line in raw.splitlines():
parts = re.split(r'(?<!\\):', line, maxsplit=1)
if len(parts) == 2:
key = parts[0]
val = parts[1].replace('\\:', ':').strip()
if key == 'connection.master':
c['master'] = val
elif key.endswith('.parent'):
c['vlan_parent'] = val
elif key == 'ipv4.addresses':
if val: c['ipv4_cfg'].extend([x.strip() for x in val.split(',')])
elif key == 'ipv6.addresses':
if val: c['ipv6_cfg'].extend([x.strip() for x in val.split(',')])
elif key == 'ipv4.routes':
if val: c['routes'].extend([x.strip() for x in val.split(',')])
cfg_ip4 = deduplicate(c['ipv4_cfg'])
cfg_ip6 = deduplicate(c['ipv6_cfg'])
c['routes'] = deduplicate(c['routes'])
dev = c.get('device')
rt_ip4 = runtime_data.get(dev, {}).get('ipv4', []) if dev else []
rt_ip6 = runtime_data.get(dev, {}).get('ipv6', []) if dev else []
final_ip4 = []
for ip in cfg_ip4:
if ip in rt_ip4:
final_ip4.append(ip)
else:
final_ip4.append(f"{ip} -")
for ip in rt_ip4:
if ip not in cfg_ip4:
final_ip4.append(f"{ip} +")
final_ip6 = []
for ip in cfg_ip6:
if ip in rt_ip6:
final_ip6.append(ip)
else:
final_ip6.append(f"{ip} -")
for ip in rt_ip6:
if ip not in cfg_ip6:
final_ip6.append(f"{ip} +")
c['ipv4'] = final_ip4
c['ipv6'] = final_ip6
def build_tree():
conns = get_connections()
enrich_connections(conns)
by_name = {c['name']: c for c in conns}
by_uuid = {c['uuid']: c for c in conns}
by_device = {c['device']: c for c in conns if c.get('device')}
for c in conns:
master = c.get('master')
parent_dev = c.get('vlan_parent')
# Regula OSI pură:
# 1. Agregat (Sclav) -> Copil (în jos)
if master:
for by_dict in (by_uuid, by_name, by_device):
if master in by_dict:
master_node = by_dict[master]
master_node['children'].append(c)
c['parent_node'] = master_node
c['is_child'] = True
break
# 2. Overlay (L2 Parent) -> Layered (în sus)
if parent_dev:
for by_dict in (by_uuid, by_name, by_device):
if parent_dev in by_dict:
parent_node = by_dict[parent_dev]
c['layered_on'] = parent_node
break
roots = [c for c in conns if not c['is_child']]
return roots, conns
def sort_tree(nodes):
nodes.sort(key=lambda x: x.get('name', '').lower())
for node in nodes:
if node['children']:
sort_tree(node['children'])
def filter_online(nodes):
active_nodes = []
for node in nodes:
if node['children']:
node['children'] = filter_online(node['children'])
if node.get('device') or node['children']:
active_nodes.append(node)
return active_nodes
def filter_excluded(nodes, excluded_types):
filtered = []
for node in nodes:
if node.get('type') in excluded_types:
continue
if node['children']:
node['children'] = filter_excluded(node['children'], excluded_types)
filtered.append(node)
return filtered
def get_firewall_zones(devices):
zones = {}
if not devices:
return zones, ""
res = subprocess.run("firewall-cmd --state", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
if res.returncode != 0 or res.stdout.strip() != "running":
return zones, "Warning: firewalld is not running or inaccessible (try running with -s/--sudo)."
failed_reads = False
for dev in devices:
cmd_res = subprocess.run(f"firewall-cmd --get-zone-of-interface={dev}", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
if cmd_res.returncode == 0 and cmd_res.stdout.strip() and "no zone" not in cmd_res.stdout:
zones[dev] = cmd_res.stdout.strip()
else:
failed_reads = True
warning = "Warning: Could not read zones for some interfaces (permission denied? try -s/--sudo)." if not zones and failed_reads else ""
return zones, warning
def extract_unique_devices(nodes, dev_set):
for n in nodes:
dev = n.get('device')
if dev:
dev_set.add(dev)
if n['children']:
extract_unique_devices(n['children'], dev_set)
def assign_zones(nodes, zones_dict):
for n in nodes:
dev = n.get('device')
if dev and dev in zones_dict:
n['zone'] = zones_dict[dev]
if n['children']:
assign_zones(n['children'], zones_dict)
def prepare_display_fields(nodes, show_ipv6):
for node in nodes:
ips = node['ipv4'].copy()
if show_ipv6:
ips.extend(node['ipv6'])
node['ip_list'] = ips
node['route_list'] = node['routes']
if node['children']:
prepare_display_fields(node['children'], show_ipv6)
def should_auto_compact(nodes):
for node in nodes:
if len(node.get('ip_list', [])) > 2 or len(node.get('route_list', [])) > 2:
return True
if node['children'] and should_auto_compact(node['children']):
return True
return False
def format_display_fields(nodes, compact_ips):
for node in nodes:
if not compact_ips:
node['display_ips'] = ", ".join(node['ip_list'])
node['display_routes'] = ", ".join(node['route_list'])
if node['children']:
format_display_fields(node['children'], compact_ips)
def is_simple_layer(layer_roots):
if not layer_roots: return True
if len(layer_roots) > 1: return False
stack = [layer_roots[0]]
while stack:
curr = stack.pop()
if len(curr['children']) > 1:
return False
stack.extend(curr['children'])
return True
def get_tree_strings(name, padding, is_last, depth, align_right, has_children, drops_to_base=False):
"""Sursa Unică a Adevărului. `drops_to_base` forțează continuitatea firului spre nodul de dedesubt."""
if drops_to_base:
is_last = False
has_children = True
if depth == 0:
raw_inner = f"{name}"
cp_inner = "│" if has_children else ""
next_pad = ""
else:
if align_right:
connector = " ──┘" if is_last else " ──┤"
cp_inner = (" " if is_last else " │") + padding
next_pad = (" " if is_last else " │") + padding
raw_inner = f"{name}{connector}{padding}"
else:
connector = "└── " if is_last else "├── "
cp_inner = padding + (" " if is_last else "│ ")
next_pad = padding + (" " if is_last else "│ ")
raw_inner = f"{padding}{connector}{name}"
return f" {raw_inner} ", (f" {cp_inner} " if cp_inner else " "), next_pad
def calculate_widths(nodes, padding="", depth=0, widths=None, align_right=False, compact_ips=False):
if widths is None:
widths = {'name': 4, 'zone': 4, 'device': 6, 'ips': 12}
for i, node in enumerate(nodes):
is_last = (i == len(nodes) - 1)
name = node.get('name', 'N/A')
has_children = bool(node['children'])
# La calcul nu forțăm drops_to_base pentru că lățimea stringului rămâne identică
raw_disp, _, next_pad = get_tree_strings(name, padding, is_last, depth, align_right, has_children, False)
widths['name'] = max(widths['name'], len(raw_disp) + 2)
widths['zone'] = max(widths['zone'], len(node.get('zone', '')))
dev = node.get('device') or ''
widths['device'] = max(widths['device'], len(dev))
if compact_ips:
max_ip_len = max([len(ip) for ip in node.get('ip_list', [])] + [0])
widths['ips'] = max(widths['ips'], max_ip_len)
else:
widths['ips'] = max(widths['ips'], len(node.get('display_ips', '')))
if node['children']:
calculate_widths(node['children'], next_pad, depth + 1, widths, align_right, compact_ips)
return widths
def get_base_root(node):
curr = node
while curr.get('parent_node'):
curr = curr['parent_node']
return curr
def get_layered_base_root(root):
stack = [root]
while stack:
curr = stack.pop()
if curr.get('layered_on'):
return get_base_root(curr['layered_on'])
stack.extend(curr['children'])
return root
def is_tall(node):
return len(node.get('ip_list', [])) > 3 or len(node.get('route_list', [])) > 3
def print_separator_line(widths, show_uuid, align_right, bus_context, cp_disp):
W = widths['name']
if bus_context and bus_context['active']:
bus_char = '│'
pad_len = max(0, W - len(cp_disp) - 1)
if align_right:
formatted_name = f" {bus_char}{' ' * pad_len}{cp_disp[1:]}"
else:
formatted_name = f"{cp_disp[:-1]}{' ' * pad_len}{bus_char} "
else:
name_align = ">" if align_right else "<"
formatted_name = f"{cp_disp:{name_align}{W}}"
uuid_str_empty = f"{'':<38} " if show_uuid else ""
line = f"{'':<{widths['zone']}} {uuid_str_empty}{'':<17} {'':<{widths['device']}} {formatted_name} {'':<{widths['ips']}} "
print(line.rstrip())
def print_tree(nodes, widths, show_uuid, padding="", depth=0, align_right=False, justify_cols=False, compact_ips=False, bus_context=None, is_simple_layer_chain=False):
W = widths['name']
for i, node in enumerate(nodes):
is_last = (i == len(nodes) - 1)
name = node.get('name', 'N/A')
has_children = bool(node['children'])
# Dacă e ultimul nod dintr-un lanț simplu de layer, forțează generarea liniei "│" pentru a se conecta de baza de jos
drops_to_base = is_simple_layer_chain and not node['children']
raw_disp, cp_disp, next_pad = get_tree_strings(name, padding, is_last, depth, align_right, has_children, drops_to_base)
if compact_ips and i > 0 and is_tall(nodes[i-1]) and is_tall(node):
print_separator_line(widths, show_uuid, align_right, bus_context, cp_disp)
if bus_context:
is_layered = node['uuid'] in bus_context['layered_uuids']
if not bus_context['active']:
if is_layered:
bus_context['active'] = True
bus_char = '┌' if align_right else '┐'
else:
bus_char = ' '
else:
if is_layered:
bus_char = '├' if align_right else '┤'
else:
bus_char = '│'
fill_char = '─' if is_layered else ' '
pad_len = max(0, W - len(raw_disp) - 1)
if align_right:
formatted_name = f" {bus_char}{fill_char * pad_len}{raw_disp[1:]}"
else:
formatted_name = f"{raw_disp[:-1]}{fill_char * pad_len}{bus_char} "
else:
name_align = ">" if align_right else "<"
formatted_name = f"{raw_disp:{name_align}{W}}"
uuid = node.get('uuid', 'N/A')
zone = node.get('zone', '')
ctype = node.get('type', 'N/A')
device = node.get('device') or ''
c_al = ">" if justify_cols else "<"
ips_to_print = node.get('ip_list', [])
routes_to_print = node.get('route_list', [])
if not compact_ips:
display_ip_str = node.get('display_ips', '')
ips_to_print = [display_ip_str] if display_ip_str else []
display_route_str = node.get('display_routes', '')
routes_to_print = [display_route_str] if display_route_str else []
first_ip = ips_to_print[0] if ips_to_print else ""
first_route = routes_to_print[0] if routes_to_print else ""
uuid_str = f"{uuid:{c_al}38} " if show_uuid else ""
line = f"{zone:{c_al}{widths['zone']}} {uuid_str}{ctype:{c_al}17} {device:{c_al}{widths['device']}} {formatted_name} {first_ip:<{widths['ips']}} {first_route}"
print(line.rstrip())
if compact_ips:
empty_zone = ""
empty_ctype = ""
empty_device = ""
uuid_str_empty = f"{'':{c_al}38} " if show_uuid else ""
for extra_ip, extra_route in itertools.zip_longest(ips_to_print[1:], routes_to_print[1:], fillvalue=""):
if bus_context:
bus_char = '│' if bus_context['active'] else ' '
pad_len = max(0, W - len(cp_disp) - 1)
if align_right:
formatted_cp = f" {bus_char}{' ' * pad_len}{cp_disp[1:]}"
else:
formatted_cp = f"{cp_disp[:-1]}{' ' * pad_len}{bus_char} "
else:
name_align = ">" if align_right else "<"
formatted_cp = f"{cp_disp:{name_align}{W}}"
line = f"{empty_zone:{c_al}{widths['zone']}} {uuid_str_empty}{empty_ctype:{c_al}17} {empty_device:{c_al}{widths['device']}} {formatted_cp} {extra_ip:<{widths['ips']}} {extra_route}"
print(line.rstrip())
if node['children']:
print_tree(node['children'], widths, show_uuid, next_pad, depth + 1, align_right, justify_cols, compact_ips, bus_context, is_simple_layer_chain)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Displays NetworkManager connections in a tree format.")
parser.add_argument('-a', '--all', action='store_true', help="Show all connections, including simple (non-nested) ones")
parser.add_argument('-o', '--online', action='store_true', help="Show only online connections (hides offline interfaces without a DEVICE)")
parser.add_argument('-6', '--ipv6', action='store_true', help="Include IPv6 addresses in the output")
parser.add_argument('-u', '--uuid', action='store_true', help="Show the UUID column (hidden by default)")
parser.add_argument('-s', '--sudo', action='store_true', help="Automatically elevate privileges using sudo if not root")
parser.add_argument('-r', '--right', action='store_true', help="Right-align the interface tree (mirrored layout for aligned IP columns)")
parser.add_argument('-j', '--justify', action='store_true', help="Right-align standard data columns (ZONE, TYPE, DEVICE)")
parser.add_argument('-x', '--exclude', type=str, help="CSV list of interface types to exclude (e.g. vpn,tun)")
parser.add_argument('-c', '--compact', action='store_true', help="Display one IP address and Route per line instead of comma-separated lists")
args = parser.parse_args()
if args.sudo and os.geteuid() != 0:
print("Elevating privileges via sudo...", file=sys.stderr)
try:
os.execvp('sudo', ['sudo', sys.executable] + sys.argv)
except OSError as e:
print(f"Failed to execute sudo: {e}", file=sys.stderr)
sys.exit(1)
tree_roots, all_conns = build_tree()
sort_tree(tree_roots)
if args.online:
tree_roots = filter_online(tree_roots)
if not args.all:
tree_roots = [node for node in tree_roots if len(node['children']) > 0 or get_layered_base_root(node) != node]
if args.exclude:
ex_types = [t.strip() for t in args.exclude.split(',')]
tree_roots = filter_excluded(tree_roots, ex_types)
if not tree_roots:
print("No connections matching the filter criteria were found.")
sys.exit(0)
unique_devices = set()
extract_unique_devices(tree_roots, unique_devices)
fw_zones, fw_warning = get_firewall_zones(unique_devices)
if fw_warning:
print(fw_warning, file=sys.stderr)
assign_zones(tree_roots, fw_zones)
prepare_display_fields(tree_roots, show_ipv6=args.ipv6)
if not args.compact and should_auto_compact(tree_roots):
args.compact = True
format_display_fields(tree_roots, compact_ips=args.compact)
widths = calculate_widths(tree_roots, align_right=args.right, compact_ips=args.compact)
name_align_char = ">" if args.right else "<"
c_al = ">" if args.justify else "<"
uuid_header = f"{'UUID':{c_al}38} " if args.uuid else ""
header = f"{'ZONE':{c_al}{widths['zone']}} {uuid_header}{'TYPE':{c_al}17} {'DEVICE':{c_al}{widths['device']}} {'NAME':{name_align_char}{widths['name']}} {'IP_ADDRESSES':<{widths['ips']}} ROUTES"
print(header)
print("-" * len(header))
clusters = {}
for root in tree_roots:
base = get_layered_base_root(root)
bid = base['uuid']
if bid not in clusters:
clusters[bid] = {'base': base, 'layers': []}
if root != base:
clusters[bid]['layers'].append(root)
simple_clusters = []
complex_clusters = []
for bid, cluster in clusters.items():
if not cluster['layers'] and not cluster['base']['children']:
simple_clusters.append(cluster)
else:
complex_clusters.append(cluster)
if simple_clusters:
for i, c in enumerate(simple_clusters):
_, cp_disp_root, _ = get_tree_strings(c['base'].get('name', ''), "", False, 0, args.right, bool(c['base']['children']), False)
if args.compact and i > 0 and is_tall(simple_clusters[i-1]['base']) and is_tall(c['base']):
print_separator_line(widths, args.uuid, args.right, None, cp_disp_root)
print_tree([c['base']], widths, show_uuid=args.uuid, align_right=args.right, justify_cols=args.justify, compact_ips=args.compact)
if complex_clusters:
print()
for index, c in enumerate(complex_clusters):
if index > 0:
print()
layered_uuids = []
def extract_layered(nodes):
for n in nodes:
if n.get('layered_on'):
layered_uuids.append(n['uuid'])
if n['children']:
extract_layered(n['children'])
extract_layered(c['layers'])
is_simple = is_simple_layer(c['layers'])
if is_simple:
inverted_align = args.right
bus_context = None
else:
inverted_align = not args.right
bus_context = {'active': False, 'layered_uuids': layered_uuids} if layered_uuids else None
for l_root in c['layers']:
print_tree([l_root], widths, show_uuid=args.uuid, align_right=inverted_align, justify_cols=args.justify, compact_ips=args.compact, bus_context=bus_context, is_simple_layer_chain=is_simple)
if c['base'] in tree_roots:
print_tree([c['base']], widths, show_uuid=args.uuid, align_right=args.right, justify_cols=args.justify, compact_ips=args.compact)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment