Files
vacuum-wall/lib/network.py
T
mteehan 318d7169f7 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
2026-06-16 04:45:59 +00:00

721 lines
26 KiB
Python

"""Networkd/IP configuration module.
Reads/writes config/network/config.json, renders .network INI files,
and parses networkctl JSON output for runtime state.
"""
import contextlib
import ipaddress
import json
import logging
from pathlib import Path
from typing import Any
from lib.common import load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "network"
CONFIG_FILE = CONFIG_DIR / "config.json"
DATA_DIR = PROJECT_DIR / "data" / "networkd"
DEFAULT_CONFIG: dict[str, Any] = {"interfaces": {}}
KNOWN_INTERFACE_KEYS: set[str] = {
"addresses",
"ipv6_addresses",
"gateway",
"ipv6_gateway",
"dns",
"ipv6_dns",
"domains",
"ipv6_domains",
"dns_default_route",
"dhcp",
"routes",
"bind_carrier",
"ignore_carrier_loss",
"keep_configuration",
"configure_without_carrier",
"link_local_addressing",
"ipv6_link_local_address_generation_mode",
"ipv6_stable_secret_address",
"ipv4_ll_start_address",
"ipv4_ll_route",
"default_route_on_device",
"ipv6_hop_limit",
"ipv6_retransmission_time_sec",
"ipv4_duplicate_address_detection_timeout_sec",
"ipv4_reverse_path_filter",
"ipv4_accept_local",
"ipv4_route_localnet",
"ipv4_proxy_arp",
"ipv4_proxy_arp_private_vlan",
"ipv6_proxy_ndp",
"ipv6_proxy_ndp_address",
"ipv6_send_ra",
"m_pls_routing",
"keep_master",
"ip_family",
"link",
"dhcp_client",
}
__all__ = [
"KNOWN_INTERFACE_KEYS",
"collect_upstream_dns",
"generate_network_files",
"get_config",
"infer_dhcp_ranges",
"infer_zones",
"parse_networkctl_status",
"render_network_file",
"save_config",
]
def get_config() -> dict[str, Any]:
"""Read network config from config/network/config.json.
Returns:
Dict with ``interfaces`` mapping interface names to config entries.
"""
if not CONFIG_FILE.exists():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
return load_json(CONFIG_FILE)
def save_config(cfg: dict[str, Any]) -> None:
"""Persist network config to disk.
Args:
cfg: Full config dict to write.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, cfg, indent=2)
# -----------------------------------------------------------------------
# Inline key-value emitters — append to a lines list
# -----------------------------------------------------------------------
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_int(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_bool(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_bool_opt(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_any(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
if isinstance(v, bool):
lines.append(f"{key}={'yes' if v else 'no'}")
else:
lines.append(f"{key}={v}")
def render_network_file(iface_name: str, cfg_entry: dict[str, Any]) -> str:
"""Render a .network INI file for an interface.
Args:
iface_name: Interface name (e.g. "eth0").
cfg_entry: Dict with networkd config keys per the schema in
todo.md (addresses, gateway, dns, routes, link, dhcp_client, etc.).
Returns:
INI content string ready to write as 50-<name>.network file.
"""
lines: list[str] = []
d = cfg_entry
# ------------------------------------------------------------------
# [Match]
# ------------------------------------------------------------------
lines.append("[Match]")
lines.append(f"Name={iface_name}")
lines.append("")
# ------------------------------------------------------------------
# [Link]
# ------------------------------------------------------------------
link = d.get("link", {})
if link:
lines.append("[Link]")
_emit_int(lines, "MTUBytes", "mtu_bytes", link)
_emit_str(lines, "MACAddress", "mac_address", link)
_emit_bool_opt(lines, "ARP", "arp", link)
_emit_bool_opt(lines, "Multicast", "multicast", link)
_emit_bool_opt(lines, "AllMulticast", "all_multicast", link)
_emit_bool_opt(lines, "Promiscuous", "promiscuous", link)
_emit_bool(lines, "Unmanaged", "unmanaged", link)
_emit_str(lines, "ActivationPolicy", "activation_policy", link)
_emit_any(lines, "RequiredForOnline", "required_for_online", link)
lines.append("")
# ------------------------------------------------------------------
# [Network]
# ------------------------------------------------------------------
lines.append("[Network]")
_emit_str(lines, "DHCP", "dhcp", d)
_emit_str(lines, "Gateway", "gateway", d)
_emit_str(lines, "IPv6Gateway", "ipv6_gateway", d)
for dns in d.get("dns", []):
lines.append(f"DNS={dns}")
for dns in d.get("ipv6_dns", []):
lines.append(f"IPv6DNS={dns}")
for dm in d.get("domains", []):
lines.append(f"Domains={dm}")
for dm in d.get("ipv6_domains", []):
lines.append(f"IPv6Domains={dm}")
_emit_bool(lines, "DNSDefaultRoute", "dns_default_route", d)
for bc in d.get("bind_carrier", []):
lines.append(f"BindCarrier={bc}")
_emit_any(lines, "IgnoreCarrierLoss", "ignore_carrier_loss", d)
_emit_any(lines, "KeepConfiguration", "keep_configuration", d)
_emit_bool(lines, "ConfigureWithoutCarrier", "configure_without_carrier", d)
_emit_str(lines, "LinkLocalAddressing", "link_local_addressing", d)
_emit_str(
lines,
"IPv6LinkLocalAddressGenerationMode",
"ipv6_link_local_address_generation_mode",
d,
)
_emit_str(lines, "IPv6StableSecretAddress", "ipv6_stable_secret_address", d)
_emit_str(lines, "IPv4LLStartAddress", "ipv4_ll_start_address", d)
_emit_bool(lines, "IPv4LLRoute", "ipv4_ll_route", d)
_emit_bool(lines, "DefaultRouteOnDevice", "default_route_on_device", d)
_emit_int(lines, "IPv6HopLimit", "ipv6_hop_limit", d)
_emit_str(lines, "IPv6RetransmissionTimeSec", "ipv6_retransmission_time_sec", d)
_emit_str(
lines,
"IPv4DuplicateAddressDetectionTimeoutSec",
"ipv4_duplicate_address_detection_timeout_sec",
d,
)
_emit_str(lines, "IPv4ReversePathFilter", "ipv4_reverse_path_filter", d)
_emit_bool(lines, "IPv4AcceptLocal", "ipv4_accept_local", d)
_emit_bool(lines, "IPv4RouteLocalnet", "ipv4_route_localnet", d)
_emit_bool(lines, "IPv4ProxyARP", "ipv4_proxy_arp", d)
_emit_bool(lines, "IPv4ProxyARPPrivateVLAN", "ipv4_proxy_arp_private_vlan", d)
_emit_bool(lines, "IPv6ProxyNDP", "ipv6_proxy_ndp", d)
_emit_str(lines, "IPv6ProxyNDPAddress", "ipv6_proxy_ndp_address", d)
_emit_bool(lines, "IPv6SendRA", "ipv6_send_ra", d)
_emit_bool(lines, "MPLSRouting", "m_pls_routing", d)
_emit_bool(lines, "KeepMaster", "keep_master", d)
_emit_str(lines, "IPFamily", "ip_family", d)
lines.append("")
# ------------------------------------------------------------------
# [Address] sections — one per entry
# ------------------------------------------------------------------
addresses = d.get("addresses", [])
for i, addr in enumerate(addresses):
if not isinstance(addr, dict):
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
lines.append(f"Address={addr}")
lines.append("")
continue
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
_emit_str(lines, "Address", "address", addr)
_emit_str(lines, "Label", "label", addr)
_emit_str(lines, "Scope", "scope", addr)
_emit_int(lines, "RouteMetric", "route_metric", addr)
_emit_str(
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
)
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
lines.append("")
# ------------------------------------------------------------------
# [Address] sections — IPv6
# ------------------------------------------------------------------
ipv6_addrs = d.get("ipv6_addresses", [])
offset = len(addresses)
for i, addr in enumerate(ipv6_addrs):
if not isinstance(addr, dict):
lines.append(f"[Address#{offset + i}]")
lines.append(f"Address={addr}")
lines.append("")
continue
lines.append(f"[Address#{offset + i}]")
_emit_str(lines, "Address", "address", addr)
_emit_str(lines, "Label", "label", addr)
_emit_str(lines, "Scope", "scope", addr)
_emit_int(lines, "RouteMetric", "route_metric", addr)
_emit_str(
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
)
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
lines.append("")
# ------------------------------------------------------------------
# [Route#N] sections
# ------------------------------------------------------------------
routes = d.get("routes", [])
for i, route in enumerate(routes):
if not isinstance(route, dict):
continue
lines.append("[Route]" if i == 0 else f"[Route#{i}]")
_emit_str(lines, "Destination", "destination", route)
_emit_str(lines, "Gateway", "gateway", route)
_emit_int(lines, "Metric", "metric", route)
_emit_any(lines, "Table", "table", route)
_emit_str(lines, "Type", "type", route)
_emit_str(lines, "Scope", "scope", route)
_emit_bool(lines, "GatewayOnLink", "gateway_on_link", route)
_emit_str(lines, "IPv6Preference", "ipv6_preference", route)
_emit_int(lines, "InitialCongestionWindow", "initial_congestion_window", route)
_emit_int(
lines,
"InitialAdvertisedReceiveWindow",
"initial_advertised_receive_window",
route,
)
_emit_bool(lines, "QuickAck", "quick_ack", route)
_emit_bool(lines, "FastOpenNoCookie", "fast_open_no_cookie", route)
_emit_int(lines, "MTUBytes", "mtu_bytes", route)
_emit_any(lines, "Protocol", "protocol", route)
_emit_int(lines, "NextHop", "next_hop", route)
for mpr in route.get("multi_path_route", []):
lines.append(f"MultiPathRoute={mpr}")
lines.append("")
# ------------------------------------------------------------------
# [DHCPv4] / [DHCPv6]
# ------------------------------------------------------------------
dhcp_client = d.get("dhcp_client", {})
if dhcp_client:
dhcp_mode = d.get("dhcp", "no")
render_v4 = dhcp_mode in ("yes", "ipv4")
render_v6 = dhcp_mode in ("yes", "ipv6")
if not render_v4 and not render_v6:
render_v4 = True
render_v6 = True
if render_v4:
lines.append("[DHCPv4]")
_emit_str(lines, "Hostname", "hostname", dhcp_client)
_emit_any(lines, "DUID", "duid", dhcp_client)
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
_emit_str(lines, "IAID", "iaid", dhcp_client)
_emit_any(lines, "ClientIdentifier", "client_identifier", dhcp_client)
_emit_bool(lines, "RapidCommit", "rapid_commit", dhcp_client)
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
_emit_bool(lines, "UseMTU", "use_mtu", dhcp_client)
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
_emit_bool(lines, "UseRoutes", "use_routes", dhcp_client)
_emit_int(lines, "RouteMetric", "route_metric", dhcp_client)
_emit_bool(lines, "SendDecline", "send_decline", dhcp_client)
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
_emit_str(lines, "IPServiceType", "ip_service_type", dhcp_client)
_emit_int(lines, "SocketPriority", "socket_priority", dhcp_client)
_emit_bool(lines, "BOOTP", "bootp", dhcp_client)
_emit_str(lines, "Label", "label", dhcp_client)
_emit_int(lines, "MaxAttempts", "max_attempts", dhcp_client)
_emit_int(lines, "ListenPort", "listen_port", dhcp_client)
_emit_int(lines, "ServerPort", "server_port", dhcp_client)
_emit_str(lines, "MUDURL", "mud_url", dhcp_client)
_emit_str(lines, "BootFilename", "boot_filename", dhcp_client)
for opt in dhcp_client.get("send_option", []):
if isinstance(opt, dict):
lines.append(
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
)
else:
lines.append(f"SendOption={opt}")
for opt in dhcp_client.get("send_vendor_option", []):
if isinstance(opt, dict):
lines.append(
f"SendVendorOption={opt.get('code', '-')}"
f" {opt.get('vendor_code', '')}"
f" {opt.get('value', '')}"
)
else:
lines.append(f"SendVendorOption={opt}")
for uc in dhcp_client.get("user_class", []):
lines.append(f"UserClass={uc}")
_emit_str(
lines, "VendorClassIdentifier", "vendor_class_identifier", dhcp_client
)
_emit_str(lines, "RequestOptions", "request_options", dhcp_client)
lines.append("")
if render_v6:
lines.append("[DHCPv6]")
_emit_bool(lines, "SendHostname", "send_hostname", dhcp_client)
_emit_str(lines, "Hostname", "hostname", dhcp_client)
_emit_any(lines, "DUID", "duid", dhcp_client)
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
_emit_str(lines, "IAID", "iaid", dhcp_client)
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
_emit_str(lines, "RapidCommit", "rapid_commit", dhcp_client)
_emit_str(
lines, "PrefixDelegationHint", "prefix_delegation_hint", dhcp_client
)
_emit_str(
lines, "UnassignedSubnetPolicy", "unassigned_subnet_policy", dhcp_client
)
_emit_bool(lines, "UseAddress", "use_address", dhcp_client)
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
_emit_bool(lines, "UseDelegatedPrefix", "use_delegated_prefix", dhcp_client)
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
_emit_bool(lines, "UseDNR", "use_dnr", dhcp_client)
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
_emit_bool(lines, "SendRelease", "send_release", dhcp_client)
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
_emit_str(lines, "WithoutRA", "without_ra", dhcp_client)
for opt in dhcp_client.get("send_option", []):
if isinstance(opt, dict):
lines.append(
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
)
else:
lines.append(f"SendOption={opt}")
for opt in dhcp_client.get("send_vendor_option", []):
if isinstance(opt, dict):
lines.append(
f"SendVendorOption={opt.get('code', '-')}"
f" {opt.get('vendor_code', '')}"
f" {opt.get('value', '')}"
)
else:
lines.append(f"SendVendorOption={opt}")
for uc in dhcp_client.get("user_class", []):
lines.append(f"UserClass={uc}")
for vc in dhcp_client.get("vendor_class", []):
lines.append(f"VendorClass={vc}")
lines.append("")
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]:
"""Parse ``networkctl status --json=short --all`` JSON output into runtime state dict.
Args:
output: JSON command output from networkctl status.
Returns:
Dict mapping interface names to their runtime state including
addresses, gateway, DNS, and link state.
"""
try:
data = json.loads(output)
except (json.JSONDecodeError, TypeError):
return {}
result: dict[str, Any] = {}
for iface in data.get("Interfaces", []):
name = iface.get("Name")
if not name:
continue
# Addresses
addresses = []
for a in iface.get("Addresses", []):
try:
ip = _bytes_to_ip(a["Address"], a["Family"])
addresses.append(f"{ip}/{a['PrefixLength']}")
except (KeyError, ValueError, TypeError):
continue
# Gateway — find default route (Destination 0.0.0.0/0)
gateway = None
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"),
}
return result
def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]:
"""Walk config and write all 99-<name>.network files to data/networkd/.
Also removes stale .network files that no longer match config.
Args:
cfg: Network config dict (from get_config).
Returns:
Dict with ``generated`` (new/updated files) and ``cleaned``
(removed stale files) path lists.
"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
generated: list[Path] = []
cleaned: list[Path] = []
# Build set of expected filenames
expected_names: set[str] = set()
interfaces_cfg = cfg.get("interfaces", {})
for iface_name, entry in interfaces_cfg.items():
if not isinstance(entry, dict):
continue
fname = f"99-{iface_name}.network"
expected_names.add(fname)
content = render_network_file(iface_name, entry)
out_path = DATA_DIR / fname
out_path.write_text(content)
generated.append(out_path)
# Remove stale files from DATA_DIR
existing_files: set[str] = {
f.name for f in DATA_DIR.iterdir() if f.name.endswith(".network")
}
for fname in existing_files - expected_names:
(DATA_DIR / fname).unlink()
cleaned.append(DATA_DIR / fname)
if cleaned:
logger.info("Cleaned %d stale .network files from %s", len(cleaned), DATA_DIR)
logger.info("Generated %d .network files in %s", len(generated), DATA_DIR)
return {"generated": generated, "cleaned": cleaned}
# ---------------------------------------------------------------------------
# TF-8: upstream DNS collection
# ---------------------------------------------------------------------------
_IS_LOCAL = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _is_local_dns(addr: str) -> bool:
try:
ip = ipaddress.ip_address(addr)
for net in _IS_LOCAL:
if ip.version == net.version and ip in net:
return True
except ValueError:
pass
return False
def collect_upstream_dns(cfg: dict[str, Any]) -> list[str]:
"""Collect public DNS servers from networkd config, filtering local ranges.
Args:
cfg: Network config dict (from get_config).
Returns:
Deduplicated list of upstream DNS server addresses.
"""
seen: set[str] = set()
result: list[str] = []
for entry in cfg.get("interfaces", {}).values():
if not isinstance(entry, dict):
continue
for dns in entry.get("dns", []):
if not _is_local_dns(dns) and dns not in seen:
seen.add(dns)
result.append(dns)
for dns in entry.get("ipv6_dns", []):
if not _is_local_dns(dns) and dns not in seen:
seen.add(dns)
result.append(dns)
return result
# ---------------------------------------------------------------------------
# TF-9: DHCP range inference
# ---------------------------------------------------------------------------
def infer_dhcp_ranges(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Infer candidate DHCP ranges from static interface IPs.
For each interface with a static IPv4 address, calculates a candidate
DHCP range covering the usable addresses in the subnet.
Args:
cfg: Network config dict (from get_config).
Returns:
Dict mapping interface name to dict with ``subnet``, ``prefix``,
``start``, and ``end`` keys. Interfaces with no inferrable range
are omitted.
"""
result: dict[str, dict[str, Any]] = {}
for name, entry in cfg.get("interfaces", {}).items():
if not isinstance(entry, dict):
continue
for addr in entry.get("addresses", []):
if isinstance(addr, dict):
addr = addr.get("address", "")
if not addr or "/" not in str(addr):
continue
try:
net = ipaddress.ip_network(addr, strict=False)
except ValueError:
continue
if net.version != 4:
continue
if net.num_addresses < 4:
continue
net_addr = net.network_address
broadcast = net.broadcast_address
_start = net_addr + 100
_end = net_addr + 200
_start = min(_start, broadcast - 1)
_end = min(_end, broadcast - 1)
if _start > _end:
continue
result[name] = {
"subnet": str(net_addr),
"prefix": net.prefixlen,
"start": str(_start),
"end": str(_end),
}
break
return result
# ---------------------------------------------------------------------------
# TF-10: firewalld zone inference
# ---------------------------------------------------------------------------
def _looks_wan(entry: dict[str, Any]) -> bool:
"""Heuristic: interface has DHCP or public-facing address."""
if entry.get("dhcp") in ("yes", "ipv4"):
return True
for addr in entry.get("addresses", []):
if isinstance(addr, dict):
addr = addr.get("address", "")
if not addr or "/" not in str(addr):
continue
try:
net = ipaddress.ip_network(addr, strict=False)
except ValueError:
continue
if net.version != 4:
continue
ga = list(net.hosts())
if ga and not _is_local_dns(str(ga[0])):
return True
return False
def _looks_management(entry: dict[str, Any]) -> bool:
"""Heuristic: interface has management-subnet addresses."""
return bool(entry.get("routes"))
def infer_zones(
cfg: dict[str, Any],
) -> dict[str, str]:
"""Classify network interfaces into firewalld zones.
Heuristics:
- Interface name contains ``wg`` → ``"wan"``
- DHCP-enabled or public-facing IP → ``"wan"``
- Has explicit routes configured → ``"management"``
- Everything else → ``"lan"``
Args:
cfg: Network config dict (from get_config).
Returns:
Dict mapping interface name to suggested zone name.
"""
result: dict[str, str] = {}
for name, entry in cfg.get("interfaces", {}).items():
if not isinstance(entry, dict):
continue
if "wg" in name or _looks_wan(entry):
result[name] = "wan"
elif _looks_management(entry):
result[name] = "management"
else:
result[name] = "lan"
return result