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:
2026-06-16 04:45:59 +00:00
parent 2f78102090
commit 318d7169f7
5 changed files with 186 additions and 77 deletions
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29 -6
View File
@@ -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()