Switch networkctl parsing from text to JSON output
Replace fragile text-based parsing of ● 1: lo
Link File: n/a
Network File: n/a
State: carrier (unmanaged)
Online state: unknown
Type: loopback
Hardware Address: 00:00:00:00:00:00
MTU: 65536
QDisc: noqueue
IPv6 Address Generation Mode: eui64
Number of Queues (Tx/Rx): 1/1
Address: 127.0.0.1
::1
May 30 22:43:27 vacuum-wall systemd-networkd[315]: lo: Link UP
May 30 22:43:27 vacuum-wall systemd-networkd[315]: lo: Gained carrier
● 77: eth0
Link File: /usr/lib/systemd/network/99-default.link
Network File: /etc/systemd/network/eth0.network
State: routable (configured)
Online state: online
Type: ether
Kind: veth
Driver: veth
Hardware Address: 8e:63:52:6b:ea:e8
MTU: 1500 (min: 68, max: 65535)
QDisc: noqueue
IPv6 Address Generation Mode: eui64
Number of Queues (Tx/Rx): 8/8
Auto negotiation: no
Speed: 10Gbps
Duplex: full
Port: tp
Address: 192.168.1.5 (DHCPv4 via 192.168.1.1)
2600:4040:a6c1:4a00:8c63:52ff:fe6b:eae8
fe80::8c63:52ff:fe6b:eae8
Gateway: 192.168.1.1
fe80::3ebd:c5ff:fe2b:bd99
DNS: 192.168.1.1
2600:4040:a6c1:4a00::1
Search Domains: myfiosgateway.com
Activation Policy: up
Required For Online: yes
DHCPv4 Client ID: 8e:63:52:6b:ea:e8
DHCPv6 Client IAID: 0xf3d61521
DHCPv6 Client DUID: DUID-EN/Vendor:0000ab11b94215a519e8ca54
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Link UP
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Gained carrier
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Configuring with /etc/systemd/network/eth0.network.
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: Gained IPv6LL
May 30 22:43:27 vacuum-wall systemd-networkd[315]: eth0: DHCPv4 address 192.168.1.5/24, gateway 192.168.1.1 acquired from 192.168.1.1
● 79: eth1
Link File: /usr/lib/systemd/network/99-default.link
Network File: /etc/systemd/network/50-eth1.network
State: routable (configured)
Online state: online
Type: ether
Kind: veth
Driver: veth
Hardware Address: 8e:63:52:6b:ea:8e
MTU: 1500 (min: 68, max: 65535)
QDisc: noqueue
IPv6 Address Generation Mode: eui64
Number of Queues (Tx/Rx): 8/8
Auto negotiation: no
Speed: 10Gbps
Duplex: full
Port: tp
Address: 10.4.20.1
fd42:a304:c836:2a7f:8c63:52ff:fe6b:ea8e
fe80::8c63:52ff:fe6b:ea8e
Gateway: fe80::1266:6aff:fe76:bc5b
DNS: fd42:a304:c836:2a7f::1
Activation Policy: up
Required For Online: yes
DHCPv6 Client IAID: 0x1da7c7a5
DHCPv6 Client DUID: DUID-EN/Vendor:0000ab11b94215a519e8ca54
Jun 01 03:48:43 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/50-eth1.network.
Jun 01 04:05:47 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:05:47 vacuum-wall systemd-networkd[315]: eth1: DHCPv6 lease lost
Jun 01 04:05:47 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:06:51 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:06:51 vacuum-wall systemd-networkd[315]: eth1: DHCPv6 lease lost
Jun 01 04:06:51 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/99-eth1.network.
Jun 01 04:09:35 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/50-eth1.network.
Jun 01 04:09:35 vacuum-wall systemd-networkd[315]: eth1: DHCPv6 lease lost
Jun 01 04:09:35 vacuum-wall systemd-networkd[315]: eth1: Reconfiguring with /etc/systemd/network/50-eth1.network. with structured JSON parsing using {"Interfaces":[{"Index":1,"Name":"lo","Type":"loopback","Flags":65609,"FlagsString":"up,loopback,running,lower-up","KernelOperationalState":0,"KernelOperationalStateString":"unknown","MTU":65536,"MinimumMTU":0,"MaximumMTU":4294967295,"AdministrativeState":"unmanaged","OperationalState":"carrier","CarrierState":"carrier","AddressState":"off","IPv4AddressState":"off","IPv6AddressState":"off","OnlineState":null,"Addresses":[{"Family":2,"Address":[127,0,0,1],"PrefixLength":8,"ConfigSource":"foreign","Scope":254,"ScopeString":"host","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":10,"Address":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"PrefixLength":128,"ConfigSource":"foreign","Scope":254,"ScopeString":"host","Flags":128,"FlagsString":"permanent","ConfigState":"configured"}],"Routes":[{"Family":10,"Destination":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[127,255,255,255],"DestinationPrefixLength":32,"PreferredSource":[127,0,0,1],"TOS":0,"Scope":253,"Protocol":2,"Type":3,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"broadcast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[127,0,0,1],"DestinationPrefixLength":32,"PreferredSource":[127,0,0,1],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[127,0,0,0],"DestinationPrefixLength":8,"PreferredSource":[127,0,0,1],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"}]},{"Index":77,"Name":"eth0","Kind":"veth","Type":"ether","Driver":"veth","Flags":69699,"FlagsString":"up,broadcast,running,multicast,lower-up","KernelOperationalState":6,"KernelOperationalStateString":"up","MTU":1500,"MinimumMTU":68,"MaximumMTU":65535,"HardwareAddress":[142,99,82,107,234,232],"BroadcastAddress":[255,255,255,255,255,255],"IPv6LinkLocalAddress":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,232],"AdministrativeState":"configured","OperationalState":"routable","CarrierState":"carrier","AddressState":"routable","IPv4AddressState":"routable","IPv6AddressState":"routable","OnlineState":"online","NetworkFile":"/etc/systemd/network/eth0.network","NetworkFileDropins":[],"RequiredForOnline":true,"RequiredOperationalStateForOnline":[null,null],"RequiredFamilyForOnline":"any","ActivationPolicy":"up","DNS":[{"Family":2,"Address":[192,168,1,1],"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1]},{"Family":10,"Address":[38,0,64,64,166,193,74,0,0,0,0,0,0,0,0,1],"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153]}],"SearchDomains":[{"Domain":"myfiosgateway.com","ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1]}],"DNSSettings":[{"LLMNR":"yes","ConfigSource":"static"},{"MDNS":"no","ConfigSource":"static"}],"Addresses":[{"Family":2,"Address":[192,168,1,5],"PrefixLength":24,"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1],"Broadcast":[192,168,1,255],"Scope":0,"ScopeString":"global","Flags":0,"FlagsString":null,"PreferredLifetimeUSec":1734964293205,"PreferredLifetimeUsec":1734964293205,"ValidLifetimeUSec":1734964293205,"ValidLifetimeUsec":1734964293205,"ConfigState":"configured"},{"Family":10,"Address":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,232],"PrefixLength":64,"ConfigSource":"foreign","Scope":253,"ScopeString":"link","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":10,"Address":[38,0,64,64,166,193,74,0,140,99,82,255,254,107,234,232],"PrefixLength":64,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"Scope":0,"ScopeString":"global","Flags":768,"FlagsString":"manage-temporary-address,no-prefixroute","PreferredLifetimeUSec":1677495569859,"PreferredLifetimeUsec":1677495569859,"ValidLifetimeUSec":1677495569859,"ValidLifetimeUsec":1677495569859,"ConfigState":"configured"}],"NextHops":[{"ID":1635324079,"Family":10,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"Gateway":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"Flags":0,"FlagsString":"","Protocol":9,"ProtocolString":"9","Blackhole":false,"ConfigState":"configured"}],"Routes":[{"Family":2,"Destination":[192,168,1,0],"DestinationPrefixLength":24,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":253,"Protocol":2,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[192,168,1,255],"DestinationPrefixLength":32,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":253,"Protocol":2,"Type":3,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"broadcast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[38,0,64,64,166,193,74,0,140,99,82,255,254,107,234,232],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[38,0,64,64,166,193,74,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":64,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","LifetimeUSec":1677495568830,"ConfigState":"configured"},{"Family":10,"Destination":[254,128,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":64,"TOS":0,"Scope":0,"Protocol":2,"Type":1,"Priority":256,"Table":254,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[0,0,0,0],"DestinationPrefixLength":0,"Gateway":[192,168,1,1],"PreferredSource":[192,168,1,5],"TOS":0,"Scope":0,"Protocol":16,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1],"ScopeString":"global","ProtocolString":"16","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[192,168,1,1],"DestinationPrefixLength":32,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":253,"Protocol":16,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"DHCPv4","ConfigProvider":[192,168,1,1],"ScopeString":"link","ProtocolString":"16","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[192,168,1,5],"DestinationPrefixLength":32,"PreferredSource":[192,168,1,5],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":8,"TOS":0,"Scope":0,"Protocol":2,"Type":5,"Priority":256,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"multicast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,232],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"}],"DHCPv4Client":{"Lease":{"LeaseTimestampUSec":1648564292241,"Timeout1USec":1691764292241,"Timeout2USec":1724164292241},"ClientIdentifier":[1,142,99,82,107,234,232]},"DHCPv6Client":{"Lease":{"LeaseTimestampUSec":958440922609},"DUID":[0,2,0,0,171,17,185,66,21,165,25,232,202,84]}},{"Index":79,"Name":"eth1","Kind":"veth","Type":"ether","Driver":"veth","Flags":69699,"FlagsString":"up,broadcast,running,multicast,lower-up","KernelOperationalState":6,"KernelOperationalStateString":"up","MTU":1500,"MinimumMTU":68,"MaximumMTU":65535,"HardwareAddress":[142,99,82,107,234,142],"BroadcastAddress":[255,255,255,255,255,255],"IPv6LinkLocalAddress":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,142],"AdministrativeState":"configured","OperationalState":"routable","CarrierState":"carrier","AddressState":"routable","IPv4AddressState":"routable","IPv6AddressState":"routable","OnlineState":"online","NetworkFile":"/etc/systemd/network/50-eth1.network","NetworkFileDropins":[],"RequiredForOnline":true,"RequiredOperationalStateForOnline":[null,null],"RequiredFamilyForOnline":"any","ActivationPolicy":"up","DNS":[{"Family":10,"Address":[253,66,163,4,200,54,42,127,0,0,0,0,0,0,0,1],"ConfigSource":"DHCPv6","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91]},{"Family":10,"Address":[253,66,163,4,200,54,42,127,0,0,0,0,0,0,0,1],"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91]}],"DNSSettings":[{"LLMNR":"yes","ConfigSource":"static"},{"MDNS":"no","ConfigSource":"static"}],"Addresses":[{"Family":10,"Address":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,142],"PrefixLength":64,"ConfigSource":"foreign","Scope":253,"ScopeString":"link","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":2,"Address":[10,4,20,1],"PrefixLength":24,"ConfigSource":"static","Broadcast":[10,4,20,255],"Scope":0,"ScopeString":"global","Flags":128,"FlagsString":"permanent","ConfigState":"configured"},{"Family":10,"Address":[253,66,163,4,200,54,42,127,140,99,82,255,254,107,234,142],"PrefixLength":64,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"Scope":0,"ScopeString":"global","Flags":896,"FlagsString":"permanent,manage-temporary-address,no-prefixroute","ConfigState":"configured"}],"NextHops":[{"ID":3144860678,"Family":10,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"Gateway":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"Flags":0,"FlagsString":"","Protocol":9,"ProtocolString":"9","Blackhole":false,"ConfigState":"configured"}],"Routes":[{"Family":2,"Destination":[10,4,20,255],"DestinationPrefixLength":32,"PreferredSource":[10,4,20,1],"TOS":0,"Scope":253,"Protocol":2,"Type":3,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"broadcast","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[254,128,0,0,0,0,0,0,140,99,82,255,254,107,234,142],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[253,66,163,4,200,54,42,127,0,0,0,0,0,0,0,0],"DestinationPrefixLength":64,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":10,"Destination":[253,66,163,4,200,54,42,127,140,99,82,255,254,107,234,142],"DestinationPrefixLength":128,"TOS":0,"Scope":0,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"global","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[10,4,20,1],"DestinationPrefixLength":32,"PreferredSource":[10,4,20,1],"TOS":0,"Scope":254,"Protocol":2,"Type":2,"Priority":0,"Table":255,"Flags":0,"ConfigSource":"foreign","ScopeString":"host","ProtocolString":"kernel","TypeString":"local","TableString":"local","Preference":0,"FlagsString":"","ConfigState":"configured"},{"Family":2,"Destination":[10,4,20,0],"DestinationPrefixLength":24,"PreferredSource":[10,4,20,1],"TOS":0,"Scope":253,"Protocol":2,"Type":1,"Priority":0,"Table":254,"Flags":0,"ConfigSource":"foreign","ScopeString":"link","ProtocolString":"kernel","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","ConfigState":"configured"}],"DHCPv6Client":{"Lease":{"LeaseTimestampUSec":1585104388996},"DUID":[0,2,0,0,171,17,185,66,21,165,25,232,202,84]}}],"Routes":[{"Family":10,"Destination":[38,0,64,64,166,193,74,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":56,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":512,"Table":254,"Flags":0,"NextHopID":1635324079,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":1,"FlagsString":"","LifetimeUSec":1677495568830,"ConfigState":"configured"},{"Family":10,"Destination":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":0,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"NextHopID":3144860678,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,18,102,106,255,254,118,188,91],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","LifetimeUSec":1671685709586,"ConfigState":"configured"},{"Family":10,"Destination":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"DestinationPrefixLength":0,"TOS":0,"Scope":0,"Protocol":9,"Type":1,"Priority":1024,"Table":254,"Flags":0,"NextHopID":1635324079,"ConfigSource":"NDisc","ConfigProvider":[254,128,0,0,0,0,0,0,62,189,197,255,254,43,189,153],"ScopeString":"global","ProtocolString":"9","TypeString":"unicast","TableString":"main","Preference":0,"FlagsString":"","LifetimeUSec":1671195568830,"ConfigState":"configured"}],"RoutingPolicyRules":[{"Family":10,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":0,"FirewallMark":0,"FirewallMask":0,"Table":255,"TableString":"local","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":10,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":32766,"FirewallMark":0,"FirewallMask":0,"Table":254,"TableString":"main","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":2,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":32767,"FirewallMark":0,"FirewallMask":0,"Table":253,"TableString":"default","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":2,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":0,"FirewallMark":0,"FirewallMask":0,"Table":255,"TableString":"local","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"},{"Family":2,"Protocol":2,"ProtocolString":"kernel","TOS":0,"Type":1,"TypeString":"table","IPProtocol":0,"IPProtocolString":"ip","Priority":32766,"FirewallMark":0,"FirewallMask":0,"Table":254,"TableString":"main","Invert":false,"ConfigSource":"foreign","ConfigState":"configured"}]}. This provides more reliable and maintainable runtime state extraction.
lib/network.py:
- Rewrite parse_networkctl_status() to parse JSON instead of text lines
- Add _bytes_to_ip() helper for converting address byte arrays to IP strings
- Extract addresses, gateway (from Routes), DNS, MAC, and state from structured JSON
- Add proper error handling for malformed JSON input
daemon/handlers/network.py:
- Update all 3 callers (get_interfaces, get_interface, save_interface) to use --json=short
- Fix get_interfaces to include runtime-only interfaces by unioning config and runtime names (minus lo), rather than only iterating config-defined interfaces
lib/state.py:
- Update _collect_networkd to use --json=short flag
tests/test_network.py, tests/test_network_integration.py:
- Update all test fixtures from text output to matching JSON structure
This commit is contained in:
@@ -103,13 +103,14 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
runtime: dict[str, Any] = {}
|
runtime: dict[str, Any] = {}
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
raw = run(["networkctl", "status", "--all"], sudo=True)
|
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||||
runtime = parse_networkctl_status(raw)
|
runtime = parse_networkctl_status(raw)
|
||||||
|
|
||||||
merged: dict[str, Any] = {}
|
merged: dict[str, Any] = {}
|
||||||
for name, config_entry in ifaces_cfg.items():
|
all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"}
|
||||||
|
for name in sorted(all_names):
|
||||||
merged[name] = {
|
merged[name] = {
|
||||||
"config": config_entry,
|
"config": ifaces_cfg.get(name, {}),
|
||||||
"runtime": runtime.get(name, {}),
|
"runtime": runtime.get(name, {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
|
|
||||||
runtime: dict[str, Any] = {}
|
runtime: dict[str, Any] = {}
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
raw = run(["networkctl", "status", "--all"], sudo=True)
|
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||||
runtime = parse_networkctl_status(raw)
|
runtime = parse_networkctl_status(raw)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -160,7 +161,7 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
|||||||
)
|
)
|
||||||
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
raw = run(["networkctl", "status", "--all"], sudo=True)
|
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||||
runtime = parse_networkctl_status(raw)
|
runtime = parse_networkctl_status(raw)
|
||||||
if name not in runtime:
|
if name not in runtime:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
+67
-52
@@ -1,10 +1,12 @@
|
|||||||
"""Networkd/IP configuration module.
|
"""Networkd/IP configuration module.
|
||||||
|
|
||||||
Reads/writes config/network/config.json, renders .network INI files,
|
Reads/writes config/network/config.json, renders .network INI files,
|
||||||
and parses networkctl status output for runtime state.
|
and parses networkctl JSON output for runtime state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -427,70 +429,83 @@ def render_network_file(iface_name: str, cfg_entry: dict[str, Any]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes_to_ip(addr_bytes: list[int], family: int) -> str:
|
||||||
|
"""Convert networkctl JSON address byte array to string."""
|
||||||
|
if family == 2:
|
||||||
|
return str(ipaddress.ip_address(bytes(addr_bytes)))
|
||||||
|
return str(ipaddress.IPv6Address(bytes(addr_bytes)))
|
||||||
|
|
||||||
|
|
||||||
def parse_networkctl_status(output: str) -> dict[str, Any]:
|
def parse_networkctl_status(output: str) -> dict[str, Any]:
|
||||||
"""Parse ``networkctl status --all`` output into runtime state dict.
|
"""Parse ``networkctl status --json=short --all`` JSON output into runtime state dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
output: Raw command output from networkctl status.
|
output: JSON command output from networkctl status.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping interface names to their runtime state including
|
Dict mapping interface names to their runtime state including
|
||||||
addresses, gateway, DNS, and link state.
|
addresses, gateway, DNS, and link state.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(output)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
current_iface: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
def _is_iface_header(line: str) -> bool:
|
for iface in data.get("Interfaces", []):
|
||||||
"""Check if a line looks like an interface header (digits:name ...)."""
|
name = iface.get("Name")
|
||||||
colon_idx = line.find(":")
|
if not name:
|
||||||
if colon_idx < 0:
|
|
||||||
return False
|
|
||||||
header = line[:colon_idx].strip()
|
|
||||||
return bool(header) and header[-1].isdigit()
|
|
||||||
|
|
||||||
for raw_line in output.splitlines():
|
|
||||||
stripped = raw_line.strip()
|
|
||||||
if not stripped:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Interface header: "1: eth0" or similar
|
# Addresses
|
||||||
if _is_iface_header(raw_line):
|
addresses = []
|
||||||
parts = raw_line.split(":", 1)[1].strip().split()
|
for a in iface.get("Addresses", []):
|
||||||
if parts:
|
try:
|
||||||
iface_name = parts[0]
|
ip = _bytes_to_ip(a["Address"], a["Family"])
|
||||||
current_iface = {
|
addresses.append(f"{ip}/{a['PrefixLength']}")
|
||||||
"addresses": [],
|
except (KeyError, ValueError, TypeError):
|
||||||
"gateway": None,
|
continue
|
||||||
"dns": [],
|
|
||||||
"mac": None,
|
# Gateway — find default route (Destination 0.0.0.0/0)
|
||||||
"state": "unknown",
|
gateway = None
|
||||||
"link": parts[1] if len(parts) > 1 else "unknown",
|
for route in iface.get("Routes", []):
|
||||||
|
if route.get("Family") != 2:
|
||||||
|
continue
|
||||||
|
dest = route.get("Destination", [])
|
||||||
|
prefix = route.get("DestinationPrefixLength", 32)
|
||||||
|
if len(dest) == 4 and all(d == 0 for d in dest) and prefix == 0:
|
||||||
|
gw_bytes = route.get("Gateway")
|
||||||
|
if gw_bytes:
|
||||||
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
|
gateway = _bytes_to_ip(gw_bytes, 2)
|
||||||
|
break
|
||||||
|
|
||||||
|
# DNS
|
||||||
|
dns = []
|
||||||
|
for d in iface.get("DNS", []):
|
||||||
|
try:
|
||||||
|
dns.append(_bytes_to_ip(d["Address"], d["Family"]))
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# MAC
|
||||||
|
mac = None
|
||||||
|
hw = iface.get("HardwareAddress")
|
||||||
|
if hw:
|
||||||
|
mac = ":".join(f"{b:02x}" for b in hw)
|
||||||
|
|
||||||
|
# State
|
||||||
|
state = iface.get("OperationalState") or "unknown"
|
||||||
|
|
||||||
|
result[name] = {
|
||||||
|
"addresses": addresses,
|
||||||
|
"gateway": gateway,
|
||||||
|
"dns": dns,
|
||||||
|
"mac": mac,
|
||||||
|
"state": state,
|
||||||
|
"link": iface.get("Type", "unknown"),
|
||||||
}
|
}
|
||||||
result[iface_name] = current_iface
|
|
||||||
continue
|
|
||||||
|
|
||||||
if current_iface is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if stripped.startswith("State:"):
|
|
||||||
current_iface["state"] = stripped.split(":", 1)[1].strip()
|
|
||||||
elif stripped.startswith("Gateway:"):
|
|
||||||
gw = stripped.split(":", 1)[1].strip()
|
|
||||||
if gw and gw.lower() not in ("n/a", ""):
|
|
||||||
current_iface["gateway"] = gw
|
|
||||||
elif stripped.startswith("DNS:"):
|
|
||||||
dns_str = stripped.split(":", 1)[1].strip()
|
|
||||||
if dns_str and dns_str.lower() != "n/a":
|
|
||||||
current_iface["dns"] = [d.strip() for d in dns_str.split() if d.strip()]
|
|
||||||
elif stripped.startswith("Hardware Address:"):
|
|
||||||
current_iface["mac"] = stripped.split(":", 2)[2].strip()
|
|
||||||
elif stripped.startswith("Addresses:"):
|
|
||||||
addr_str = stripped.split(":", 1)[1].strip()
|
|
||||||
if addr_str and addr_str.lower() != "n/a":
|
|
||||||
for tok in addr_str.split():
|
|
||||||
addr = tok.rstrip(",")
|
|
||||||
if "/" in addr:
|
|
||||||
current_iface["addresses"].append(addr)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -807,7 +807,7 @@ def _collect_networkd() -> dict[str, Any]:
|
|||||||
result: dict[str, dict[str, Any]] = {}
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = run(["networkctl", "status", "--all"], sudo=True)
|
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||||
result = parse_networkctl_status(raw)
|
result = parse_networkctl_status(raw)
|
||||||
if not result:
|
if not result:
|
||||||
return {"interfaces": {}, "timestamp": _now_iso()}
|
return {"interfaces": {}, "timestamp": _now_iso()}
|
||||||
|
|||||||
+85
-15
@@ -1,5 +1,7 @@
|
|||||||
"""Tests for lib.network module — networkd config, rendering, and parsing."""
|
"""Tests for lib.network module — networkd config, rendering, and parsing."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lib import network as _net
|
from lib import network as _net
|
||||||
@@ -282,12 +284,36 @@ class TestParseNetworkctlStatus:
|
|||||||
assert _net.parse_networkctl_status("") == {}
|
assert _net.parse_networkctl_status("") == {}
|
||||||
|
|
||||||
def test_single_interface(self):
|
def test_single_interface(self):
|
||||||
output = (
|
output = json.dumps(
|
||||||
"1: eth0 ethernet 192.168.1.0/24 routable\n"
|
{
|
||||||
" State: routable\n"
|
"Interfaces": [
|
||||||
" Addresses: 192.168.1.1/24,\n"
|
{
|
||||||
" Gateway: 192.168.1.254\n"
|
"Name": "eth0",
|
||||||
" DNS: 8.8.8.8 8.8.4.4\n"
|
"Type": "ether",
|
||||||
|
"AdministrativeState": "configured",
|
||||||
|
"OperationalState": "routable",
|
||||||
|
"Addresses": [
|
||||||
|
{
|
||||||
|
"Family": 2,
|
||||||
|
"Address": [192, 168, 1, 1],
|
||||||
|
"PrefixLength": 24,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DNS": [
|
||||||
|
{"Family": 2, "Address": [8, 8, 8, 8]},
|
||||||
|
{"Family": 2, "Address": [8, 8, 4, 4]},
|
||||||
|
],
|
||||||
|
"Routes": [
|
||||||
|
{
|
||||||
|
"Family": 2,
|
||||||
|
"Destination": [0, 0, 0, 0],
|
||||||
|
"DestinationPrefixLength": 0,
|
||||||
|
"Gateway": [192, 168, 1, 254],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
)
|
)
|
||||||
result = _net.parse_networkctl_status(output)
|
result = _net.parse_networkctl_status(output)
|
||||||
assert "eth0" in result
|
assert "eth0" in result
|
||||||
@@ -298,24 +324,68 @@ class TestParseNetworkctlStatus:
|
|||||||
assert "8.8.4.4" in iface["dns"]
|
assert "8.8.4.4" in iface["dns"]
|
||||||
|
|
||||||
def test_unmanaged(self):
|
def test_unmanaged(self):
|
||||||
output = "2: lo loopback 127.0.0.1/8 unmanaged\n"
|
output = json.dumps(
|
||||||
|
{
|
||||||
|
"Interfaces": [
|
||||||
|
{
|
||||||
|
"Name": "lo",
|
||||||
|
"Type": "loopback",
|
||||||
|
"AdministrativeState": "unmanaged",
|
||||||
|
"OperationalState": "carrier",
|
||||||
|
"Addresses": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
result = _net.parse_networkctl_status(output)
|
result = _net.parse_networkctl_status(output)
|
||||||
assert "lo" in result
|
assert "lo" in result
|
||||||
|
|
||||||
def test_no_addresses(self):
|
def test_no_addresses(self):
|
||||||
output = "1: eth0 ethernet (none) degraded\n State: degraded\n"
|
output = json.dumps(
|
||||||
|
{
|
||||||
|
"Interfaces": [
|
||||||
|
{
|
||||||
|
"Name": "eth0",
|
||||||
|
"Type": "ether",
|
||||||
|
"AdministrativeState": "degraded",
|
||||||
|
"OperationalState": "degraded",
|
||||||
|
"Addresses": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
result = _net.parse_networkctl_status(output)
|
result = _net.parse_networkctl_status(output)
|
||||||
assert "eth0" in result
|
assert "eth0" in result
|
||||||
assert result["eth0"]["addresses"] == []
|
assert result["eth0"]["addresses"] == []
|
||||||
|
|
||||||
def test_multiple_interfaces(self):
|
def test_multiple_interfaces(self):
|
||||||
output = (
|
output = json.dumps(
|
||||||
"1: eth0 ethernet 192.168.1.0/24 routable\n"
|
{
|
||||||
" State: routable\n"
|
"Interfaces": [
|
||||||
" Addresses: 192.168.1.1/24,\n"
|
{
|
||||||
"2: eth1 ethernet 10.0.0.0/24 routable\n"
|
"Name": "eth0",
|
||||||
" State: routable\n"
|
"Type": "ether",
|
||||||
" Addresses: 10.0.0.1/24,\n"
|
"AdministrativeState": "configured",
|
||||||
|
"OperationalState": "routable",
|
||||||
|
"Addresses": [
|
||||||
|
{
|
||||||
|
"Family": 2,
|
||||||
|
"Address": [192, 168, 1, 1],
|
||||||
|
"PrefixLength": 24,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "eth1",
|
||||||
|
"Type": "ether",
|
||||||
|
"AdministrativeState": "configured",
|
||||||
|
"OperationalState": "routable",
|
||||||
|
"Addresses": [
|
||||||
|
{"Family": 2, "Address": [10, 0, 0, 1], "PrefixLength": 24}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
)
|
)
|
||||||
result = _net.parse_networkctl_status(output)
|
result = _net.parse_networkctl_status(output)
|
||||||
assert "eth0" in result
|
assert "eth0" in result
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Tests TF-8 (DNS upstream sync), TF-9 (DHCP range inference), TF-10 (zone inference).
|
Tests TF-8 (DNS upstream sync), TF-9 (DHCP range inference), TF-10 (zone inference).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -290,12 +291,34 @@ class TestStateParserDedup:
|
|||||||
import lib.state as _state
|
import lib.state as _state
|
||||||
|
|
||||||
with patch("lib.state.run") as mock_run:
|
with patch("lib.state.run") as mock_run:
|
||||||
mock_run.return_value = (
|
mock_run.return_value = json.dumps(
|
||||||
"1: eth0 ethernet 10.0.0.0/24 routable\n"
|
{
|
||||||
" State: routable\n"
|
"Interfaces": [
|
||||||
" Addresses: 10.0.0.1/24,\n"
|
{
|
||||||
" Gateway: 10.0.0.254\n"
|
"Name": "eth0",
|
||||||
" DNS: 8.8.8.8\n"
|
"Type": "ether",
|
||||||
|
"OperationalState": "routable",
|
||||||
|
"Addresses": [
|
||||||
|
{
|
||||||
|
"Family": 2,
|
||||||
|
"Address": [10, 0, 0, 1],
|
||||||
|
"PrefixLength": 24,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DNS": [
|
||||||
|
{"Family": 2, "Address": [8, 8, 8, 8]},
|
||||||
|
],
|
||||||
|
"Routes": [
|
||||||
|
{
|
||||||
|
"Family": 2,
|
||||||
|
"Destination": [0, 0, 0, 0],
|
||||||
|
"DestinationPrefixLength": 0,
|
||||||
|
"Gateway": [10, 0, 0, 254],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
)
|
)
|
||||||
result = _state._collect_networkd()
|
result = _state._collect_networkd()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user