cli: layered show interface, IEEE 802.3.2-2025 ethernet model

Import ieee802-ethernet-interface@2025-09-10.yang, from IEEE 802.3.2-2025,
and companion ieee802-ethernet-phy-type.yang, replacing the 2019 revision.
Operational interface speed moves to ietf-interfaces:speed, RFC 8343,
eth:speed is now obsolete.  Bump infix-interfaces.yang revision to track
the upgrade.

The CLI 'show interface' command renders interfaces as bottom-up layered
protocol rows.  Ethernet interfaces a new IEEE link-mode row (1000baseT,
10GbaseLR, ...) is added with 'duplex: full|half' in the DATA column.
Without link the ethernet row is the first row.  Tunnels and wifi follow
the same layering: gre/vxlan rows carry remote:/vni: tokens with optional
ethernet sub-row for the L2-bearing variants; wifi rows carry 'station' or
'access-point' ssid:, signal:, stations: tokens.

admin@bpi-76-8f-c2:/> show interface
⚑ INTERFACE   PROTOCOL      STATE       DATA
⇅ lo          loopback      UP
              ipv4                      127.0.0.1/8 (static)
              ipv6                      ::1/128 (static)
⇅ br0         bridge        DOWN
  │           ethernet                  32:2e:3f:76:8f:c2
  │           ipv4                      192.168.0.1/24 (static)
⇅ ├ lan0      bridge        LOWER-DOWN
⇅ ├ lan1      bridge        LOWER-DOWN
⇅ ├ lan2      bridge        LOWER-DOWN
⇅ └ lan3      bridge        LOWER-DOWN
⇅ gre0        gre           UP          remote: 198.51.100.7
              ipv4                      10.255.0.1/30 (static)
⇅ sfp1        ethernet      DOWN        ca:59:f0:77:80:5b
⇅ wan         1000baseT     UP          duplex: full
              ethernet                  32:2e:3f:76:8f:c2
              ipv4                      192.168.0.235/24 (dhcp)
              ipv6                      fe80::302e:3fff:fe76:8fc2/64 (link-layer)
⇅ wan.10      vlan          UP          vid: 10
  │           ipv4                      10.0.10.1/24 (static)
  └ wan
⇅ wan.10.20   vlan          UP          vid: 20
  │           ipv4                      10.0.10.20/28 (static)
  └ wan.10
⇅ wifi0       wifi          UP          station ssid: corp-net signal: good
              ethernet                  dc:a6:32:00:11:22
              ipv4                      192.168.7.42/24 (dhcp)

yanger populates phy-type and pmd-type from a static ETHTOOL link-mode
identity table keyed on (port, speed, duplex).  Both leaves are populated
for unambiguous media (copper, DAC, copper-T); for generic fiber where
the (port, speed, duplex) tuple can map to multiple PMDs (SR vs LR vs
ER, etc.) only the phy-type family identity is emitted — guessing the
PMD would be misleading.  cli_pretty prefers pmd-type (specific) and
falls back to phy-type (family) for display.

yanger also emits ietf-interfaces:speed and fixes a long-standing
'auto-negotation' typo (yanger had never matched ethtool's correctly-
spelled JSON field, so operational auto-negotiation always read
'unknown').

Filling the WG's gap on fixed-speed config
------------------------------------------

IEEE Std 802.3.2-2025 obsoleted the eth:speed leaf without providing a
config-true replacement; the standards-correct way to express what
older Infix configurations called 'fixed speed' is to restrict the set
of PMDs auto-negotiation may advertise.  Augment auto-negotiation with
two leaf-lists:

  auto-negotiation/advertised-pmd-types  config-true,  leaf-list of pmd-type
  ethernet/supported-pmd-types           config-false, leaf-list of pmd-type

Pinning a port to a single mode is now expressed as a single-entry
advertised-pmd-types list.  The kernel-supported set is exposed as
operational state for diagnosis — SFP/SFP+ cages report the inserted
module's capabilities, narrowing the list to a single PMD when the
optic only supports one mode (as suggested by @wkz in PR review).
When the supported list collapses to a single entry, yanger uses it
to refine the operational pmd-type leaf — more accurate than the
(port, speed, duplex) lookup for fiber.

confd C apply path (src/confd/src/ieee802-ethernet-interface.c)
translates each advertised pmd-type identity to the corresponding
ETHTOOL_LINK_MODE_*_BIT_*, ORs them into a mask, and emits
'ethtool --change <if> autoneg on advertise 0x<mask>'.  An empty
list keeps the historical default of advertising every supported
mode.  The (legacy) 'auto-negotiation/enable = false' + speed/duplex
branch is removed — it has no model representation any more.

cli_pretty's detail view gains 'advertised' and 'supported' rows
listing the PMDs in each set.  A new _pr_label_list() helper unifies
multi-row rendering across these new rows and the pre-existing
ipv4/ipv6 address rows.

The detailed interface view also gains a 'link mode' row carrying the
pmd-type / phy-type-derived label.

Migration of existing startup configurations
--------------------------------------------

Bump confd version 1.8 → 1.9 to trigger the migration on upgrade.
share/migrate/1.9/10-ethernet-advertise.sh translates old configs of
the form

  ethernet { auto-negotiation { enable false; } speed S; duplex D; }

into

  ethernet { auto-negotiation { advertised-pmd-types [PMD]; } duplex D; }

mapping (S, D) → PMD via a static lookup covering the copper-T speeds
the deprecated speed leaf historically supported (10/100/1000/2.5G/
5G/10G).  Interfaces that don't disable auto-negotiation, or that
lack a speed leaf, are passed through untouched.

Closes #530
Closes #805

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-05-27 20:53:29 +02:00
parent 68e4dc3e9b
commit 7fa141178f
27 changed files with 4994 additions and 1399 deletions
+169 -122
View File
@@ -101,7 +101,7 @@ def compress_interface_list(interfaces):
class Pad:
flags = 2
iface = 16
proto = 11
proto = 14
state = 12
data = 41
@@ -1042,7 +1042,15 @@ class Iface:
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, 'ieee802-ethernet-interface:ethernet', 'speed')
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')
@@ -1107,9 +1115,12 @@ class Iface:
self.wireguard = self.data.get('infix-interfaces:wireguard')
if self.data.get('infix-interfaces:vlan'):
self.lower_if = self.data.get('infix-interfaces:vlan', None).get('lower-layer-if',None)
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"
@@ -1173,66 +1184,130 @@ class Iface:
row += f"{'':<{Pad.state}}{addr['ip']}/{addr['prefix-length']} {origin}"
print(row)
def _pr_proto_common(self, name, phys_address, pipe=''):
@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:<{20}}:")
return
first = True
for value in values:
key = label if first else ''
colon = ':' if first else ' '
print(f"{key:<{20}}{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}}"
dec = Decore.green if self.oper() == "up" else Decore.red
row += dec(f"{self.oper().upper():<{Pad.state}}")
if phys_address:
row += f"{self.phys_address:<{Pad.data}}"
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=''):
row = self._pr_proto_common("ethernet", True, pipe);
print(row)
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=''):
row = self._pr_proto_common("veth", True, pipe);
parts = []
if self.phys_address:
parts.append(self.phys_address)
if self.lower_if:
row = f"{'':<{Pad.iface}}"
row += f"{'veth':<{Pad.proto}}"
row += f"{'':<{Pad.state}}"
row += f"peer:{self.lower_if}"
print(row)
def pr_proto_gretap(self, pipe=''):
row = self._pr_proto_common("gretap", True, pipe);
print(row)
parts.append(f"peer: {self.lower_if}")
print(self._pr_proto_common("veth", pipe, " ".join(parts)))
def pr_proto_gre(self, pipe=''):
row = self._pr_proto_common("gre", False, pipe);
print(row)
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=''):
row = self._pr_proto_common("vxlan", True, pipe);
print(row)
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=''):
row = self._pr_proto_common("wireguard", False, 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:
row += f"{total_peers} peer"
if total_peers != 1:
row += "s"
row += f" ({up_peers} up)"
print(row)
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=''):
row = self._pr_proto_common("loopback", False, pipe);
print(row)
print(self._pr_proto_common("loopback", pipe))
def pr_wifi_ssids(self):
width = 70
@@ -1309,42 +1384,19 @@ class Iface:
def pr_proto_wifi(self, pipe=''):
row = self._pr_proto_common("ethernet", True, pipe);
print(row)
ssid = None
signal = None
mode = None
if self.wifi:
# Detect mode: AP has "stations", Station has "signal-strength" or "scan-results"
ap=self.wifi.get("access-point", {})
if ap:
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}"
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}"
if self.wifi and (ap := self.wifi.get("access-point", {})):
ssid = ap.get("ssid", "------")
stations = ap.get("stations", {}).get("station", [])
data_str = f"access-point ssid: {ssid} stations: {len(stations)}"
elif self.wifi and (station := self.wifi.get("station", {})):
ssid = station.get("ssid", "------")
data_str = f"station ssid: {ssid}"
if (signal := station.get("signal-strength")) is not None:
data_str += f" signal: {signal_to_status(signal)}"
else:
data_str = "ssid: ------"
row = f"{'':<{Pad.flags}}"
row += f"{pipe:<{Pad.iface}}"
row = f"{'':<{Pad.flags}}"
row += f"{pipe:<{Pad.iface}}"
row += f"{'wifi':<{Pad.proto}}"
row += f"{'':<{Pad.state}}{data_str}"
print(row)
print(self._pr_proto_common("wifi", pipe, data_str))
def pr_proto_br(self, br_vlans):
data_str = ""
@@ -1357,23 +1409,18 @@ class Iface:
else:
row += Decore.red(f"{self.oper().upper():<{Pad.state}}")
vlans = []
for vlan in br_vlans:
if 'tagged' in vlan:
for tagged in vlan['tagged']:
if tagged == self.name:
if data_str:
data_str += f",{vlan['vid']}t"
else:
data_str += f"vlan:{vlan['vid']}t"
if 'untagged' in vlan:
for untagged in vlan['untagged']:
if untagged == self.name:
if data_str:
data_str += f",{vlan['vid']}u"
else:
data_str += f"vlan:{vlan['vid']}u"
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:
data_str += f" pvid:{self.pvid}"
tokens.append(f"pvid: {self.pvid}")
data_str = " ".join(tokens)
if data_str:
row += f"{data_str:<{Pad.data}}"
@@ -1390,11 +1437,11 @@ class Iface:
lowers.append(_iface)
if lowers:
self.pr_proto_eth(pipe='')
self.pr_proto_eth_subrow(pipe='')
self.pr_proto_ipv4(pipe='')
self.pr_proto_ipv6(pipe='')
else:
self.pr_proto_eth(pipe=' ')
self.pr_proto_eth_subrow()
self.pr_proto_ipv4()
self.pr_proto_ipv6()
@@ -1449,11 +1496,11 @@ class Iface:
lowers.append(_iface)
if lowers:
self.pr_proto_eth(pipe='')
self.pr_proto_eth_subrow(pipe='')
self.pr_proto_ipv4(pipe='')
self.pr_proto_ipv6(pipe='')
else:
self.pr_proto_eth(pipe=' ')
self.pr_proto_eth_subrow()
self.pr_proto_ipv4()
self.pr_proto_ipv6()
@@ -1476,13 +1523,15 @@ class Iface:
def pr_gretap(self):
self.pr_name(pipe="")
self.pr_proto_gretap()
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()
@@ -1495,12 +1544,14 @@ class Iface:
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="")
self.pr_proto_eth()
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='')
@@ -1515,7 +1566,7 @@ class Iface:
print(f"Error, didn't find parent interface for vlan {self.name}")
sys.exit(1)
parent.pr_name(pipe='')
parent.pr_proto_eth()
print()
def pr_container(self):
# Add ⇅ flag for interfaces with IP forwarding enabled
@@ -1549,16 +1600,27 @@ class Iface:
if self.lower_if:
print(f"{'lower-layer-if':<{20}}: {self.lower_if}")
if self.autoneg != 'unknown':
val = "on" if self.autoneg else "off"
print(f"{'auto-negotiation':<{20}}: {val}")
if label := self._phy_label():
print(f"{'link mode':<{20}}: {label}")
if self.speed:
mbps = int(self.speed) // 1_000_000
print(f"{'speed':<{20}}: {mbps}")
if self.duplex:
print(f"{'duplex':<{20}}: {self.duplex}")
if self.speed:
mbs = float(self.speed) * 1000
print(f"{'speed':<{20}}: {int(mbs)}")
if self.autoneg != 'unknown':
val = "on" if self.autoneg else "off"
print(f"{'auto-negotiation':<{20}}: {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':<{20}}: {self.phys_address}")
@@ -1589,31 +1651,13 @@ class Iface:
print(f"{'lacp partner state':<{20}}: {', '.join(self.lacp_pstate)}")
print(f"{'link failure count':<{20}}: {self.link_failures}")
if self.ipv4_addr:
first = True
for addr in self.ipv4_addr:
def _addr_lines(addrs):
for addr in addrs:
origin = f"({addr['origin']})" if addr.get('origin') else ""
key = 'ipv4 addresses' if first else ''
colon = ':' if first else ' '
row = f"{key:<{20}}{colon} "
row += f"{addr['ip']}/{addr['prefix-length']} {origin}"
print(row)
first = False
else:
print(f"{'ipv4 addresses':<{20}}:")
yield f"{addr['ip']}/{addr['prefix-length']} {origin}"
if self.ipv6_addr:
first = True
for addr in self.ipv6_addr:
origin = f"({addr['origin']})" if addr.get('origin') else ""
key = 'ipv6 addresses' if first else ''
colon = ':' if first else ' '
row = f"{key:<{20}}{colon} "
row += f"{addr['ip']}/{addr['prefix-length']} {origin}"
print(row)
first = False
else:
print(f"{'ipv6 addresses':<{20}}:")
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':<{20}}: {self.in_octets}")
@@ -1804,7 +1848,10 @@ def brport_sort(iface):
def print_interface(iface):
iface.pr_name()
iface.pr_proto_eth()
if iface._pr_phy_row():
iface.pr_proto_eth_subrow()
else:
iface.pr_proto_eth()
iface.pr_proto_ipv4()
iface.pr_proto_ipv6()