lib.net_config

   1import re
   2from collections import deque
   3from typing import List, Tuple, Dict, Any, Literal, TypeAlias
   4from ipaddress import (ip_address, ip_interface, ip_network,
   5                       IPv4Address, IPv4Interface, IPv4Network,
   6                       IPv6Address, IPv6Interface, IPv6Network)
   7
   8from SRE.lib_sre import NetScheme0, Grade0
   9
  10IPAddress: TypeAlias = IPv4Address | IPv6Address
  11IPInterface: TypeAlias = IPv4Interface | IPv6Interface
  12IPNetwork: TypeAlias = IPv4Network | IPv6Network
  13
  14# One interface: (addresses, routes).  Both families may share one entry (dual stack), IPv4
  15# first by convention:
  16#   ([IPv4Interface('10.0.0.1/24'), IPv6Interface('fd00:1::1/64')],
  17#    [(IPv4Network('0.0.0.0/0'), gw4), (IPv6Network('::/0'), gw6)])
  18NetConfigInterface: TypeAlias = (
  19        Tuple[
  20            List[IPInterface],
  21            List[Tuple[IPNetwork, IPAddress | IPInterface]]
  22        ]
  23        | Literal['dhcp']
  24        | None
  25)
  26
  27NetConfigEntry: TypeAlias = List[NetConfigInterface]  # indexed by eth number
  28
  29NetConfig: TypeAlias = Dict[str, NetConfigEntry]  # machine name -> entry
  30
  31SysctlConfig = Dict[str, Any]
  32
  33_IFACE_TYPES = (IPv4Interface, IPv6Interface)
  34_DEFAULT_NETS = {'0.0.0.0/0', '::/0'}
  35_DEFAULT_NET_BY_VERSION = {4: IPv4Network('0.0.0.0/0'), 6: IPv6Network('::/0')}
  36_INET_RE = re.compile(r'^\s+inet\s+(\d+\.\d+\.\d+\.\d+)/(\d+)')
  37# the inet6 line of `ip a` does not end with the interface name: "inet6 fd00::1/64 scope global"
  38_INET6_RE = re.compile(r'^\s+inet6\s+([0-9a-fA-F:.]+)/(\d+)(.*)$')
  39_SCOPE_LINK_RE = re.compile(r'\bscope\s+link\b')
  40_TEMPORARY_RE = re.compile(r'\btemporary\b')
  41# first word of `ip -6 route` lines that are not plain routes
  42_V6_ROUTE_TYPES = {'unreachable', 'blackhole', 'prohibit', 'throw', 'anycast', 'local', 'multicast',
  43                   'broadcast', 'nat'}
  44
  45
  46def _gw_ip(via):
  47    """Bare address of a next hop given either as an Address or as an Interface."""
  48    return via.ip if isinstance(via, _IFACE_TYPES) else via
  49
  50
  51def _skip_inet6_line(line: str, link_local: bool) -> bool:
  52    """True for the inet6 lines that are never part of a configuration: link-local addresses
  53    (unless *link_local*) and temporary (privacy) SLAAC addresses."""
  54    return (not link_local and bool(_SCOPE_LINK_RE.search(line))) or bool(_TEMPORARY_RE.search(line))
  55
  56
  57def get_ip_addresses(grade: Grade0, machine_name: str, step: int = 1, ipv6: bool = False,
  58                     link_local: bool = False) -> Dict[str, List[Tuple[str, int]]]:
  59    """Run 'ip a' on machine_name and parse the output.
  60
  61    Returns a dict mapping interface name -> list of (address, prefix_len).
  62
  63    Only IPv4 addresses are returned unless ``ipv6=True``, which adds the IPv6 addresses
  64    (``inet6`` lines) after the IPv4 ones: global addresses, SLAAC (``dynamic`` /
  65    ``mngtmpaddr``) addresses and ``::1``; link-local addresses (``scope link``) only with
  66    ``link_local=True``; temporary (privacy) addresses never.
  67
  68    Addresses for each interface are sorted by:
  69      1. family (IPv4 before IPv6)
  70      2. prefix length descending
  71      3. IP address lexicographically ascending
  72
  73    Edge cases:
  74      - virtual interfaces (e.g. eth0@if5) -> name stripped to 'eth0'
  75      - non-zero exit code or empty output  -> {}
  76    """
  77    output, code = grade.test(machine_name, 'ip a', step=step)
  78    if code != 0 or not output:
  79        return {}
  80    result: Dict[str, List[Tuple[str, int]]] = {}
  81    current_iface = None
  82    for line in output.splitlines():
  83        m = re.match(r'^\d+:\s+(\S+?)(?:@\S+)?:', line)
  84        if m:
  85            current_iface = m.group(1)
  86            result.setdefault(current_iface, [])
  87            continue
  88        m = _INET_RE.match(line)
  89        if m and current_iface is not None:
  90            result[current_iface].append((m.group(1), int(m.group(2))))
  91            continue
  92        if ipv6 and current_iface is not None:
  93            m = _INET6_RE.match(line)
  94            if m and not _skip_inet6_line(m.group(3), link_local):
  95                result[current_iface].append((m.group(1), int(m.group(2))))
  96    for iface in result:
  97        result[iface].sort(key=lambda x: (6 if ':' in x[0] else 4, -x[1], x[0]))
  98    return result
  99
 100
 101def _parse_ip_route(output: str, v6: bool) -> Dict[Tuple[str, int], Tuple[str, str, int]]:
 102    result: Dict[Tuple[str, int], Tuple[str, str, int]] = {}
 103    for line in output.splitlines():
 104        parts = line.split()
 105        if not parts:
 106            continue
 107        dest = parts[0]
 108        if v6 and dest in _V6_ROUTE_TYPES:
 109            continue
 110        if dest == 'default':
 111            net, mask = ('::', 0) if v6 else ('0.0.0.0', 0)
 112        elif '/' in dest:
 113            net, mask_str = dest.split('/')
 114            mask = int(mask_str)
 115        else:
 116            net, mask = dest, (128 if v6 else 32)
 117        via, dev, metric = '', '', 0
 118        i = 1
 119        while i < len(parts):
 120            if parts[i] == 'via' and i + 1 < len(parts):
 121                via = parts[i + 1]
 122                i += 2
 123            elif parts[i] == 'dev' and i + 1 < len(parts):
 124                dev = parts[i + 1]
 125                i += 2
 126            elif parts[i] == 'metric' and i + 1 < len(parts):
 127                metric = int(parts[i + 1])
 128                i += 2
 129            else:
 130                i += 1
 131        result[(net, mask)] = (via, dev, metric)
 132    return result
 133
 134
 135def get_routes(grade: Grade0, machine_name: str, step: int = 1,
 136               ipv6: bool = False) -> Dict[Tuple[str, int], Tuple[str, str, int]]:
 137    """Run 'ip route' (and 'ip -6 route' with ``ipv6=True``) on machine_name and parse the output.
 138
 139    Returns a dict mapping (network, mask) -> (via, dev, metric).
 140
 141    Edge cases:
 142      - 'default' route              -> key ('0.0.0.0', 0), or ('::', 0) in the IPv6 table
 143      - host route without mask      -> mask 32 (128 in the IPv6 table)
 144      - direct route (no via)        -> via ''
 145      - no explicit metric           -> metric 0
 146      - non-zero exit code or empty output -> {} (IPv4 table; the IPv6 routes are still
 147        returned when the IPv4 command fails)
 148      - IPv6 table: 'unreachable', 'blackhole', ... entries are skipped; the kernel's
 149        connected routes (fd00::/64 dev eth0 proto kernel, fe80::/64) have no via, like
 150        the IPv4 ones, and the default route of a router advertisement is kept
 151        (default via fe80::... proto ra)
 152    """
 153    output, code = grade.test(machine_name, 'ip route', step=step)
 154    if ipv6:
 155        output6, code6 = grade.test(machine_name, 'ip -6 route', step=step)
 156    result = {} if (code != 0 or not output) else _parse_ip_route(output, False)
 157    if ipv6 and code6 == 0 and output6:
 158        result.update(_parse_ip_route(output6, True))
 159    return result
 160
 161
 162def get_sysctl_conf(grade: Grade0, machine_name: str, step: int = 1) -> Dict[str, str]:
 163    """Read /etc/sysctl.conf and all files under /etc/sysctl.d/ and parse kernel parameters.
 164
 165    Returns a dict mapping parameter name -> value (both as strings).
 166
 167    Parsing rules:
 168      - lines starting with '#' or ';' are comments and ignored
 169      - blank lines are ignored
 170      - accepted formats: 'key = value' and 'key=value'
 171      - later definitions override earlier ones (sysctl.d files override sysctl.conf)
 172
 173    Edge cases:
 174      - non-zero exit code from any grade.test() -> {} immediately
 175      - first-pass empty output                  -> {} and continues to next call
 176    """
 177    result: Dict[str, str] = {}
 178
 179    def _parse(text: str) -> None:
 180        for line in text.splitlines():
 181            line = line.strip()
 182            if not line or line[0] in ('#', ';'):
 183                continue
 184            if '=' in line:
 185                key, _, value = line.partition('=')
 186                result[key.strip()] = value.strip()
 187
 188    output, code = grade.test(machine_name, 'cat /etc/sysctl.conf', step=step)
 189    if code != 0:
 190        return {}
 191    _parse(output)
 192
 193    output2, code = grade.test(machine_name, 'cat /etc/sysctl.d/*', step=step)
 194    if code != 0:
 195        return {}
 196    _parse(output2)
 197
 198    return result
 199
 200
 201def get_net_config_entry(grade: Grade0, machine_name: str, step: int = 1, ipv6: bool = False) -> NetConfigEntry:
 202    """Run 'ip a', 'ip route', and 'ip link show' on machine_name and return a NetConfig.
 203
 204    Each entry in the returned list corresponds to one interface (eth0, eth1, …)
 205    in index order.
 206
 207    Entry types:
 208      - Tuple (addresses, routes): statically configured interface.
 209      - 'dhcp': interface whose address(es) were all assigned dynamically (DHCP).
 210      - None: interface present in the kernel but carrying no IP address.
 211
 212    The list covers eth0 … ethN where N is the highest eth index reported by
 213    'ip link show'.  Interfaces beyond the highest present index are not included.
 214
 215    ``ipv6=True`` adds the IPv6 addresses (see get_ip_addresses: no link-local, no
 216    temporary address) and the routes of the IPv6 table to the same entries, IPv4 first.
 217    A SLAAC address counts as dynamic: an interface with a static IPv4 address and a
 218    SLAAC IPv6 address is a tuple holding both, an IPv6-only SLAAC interface is 'dhcp'.
 219    The default route learnt from a router advertisement has a link-local next hop: it
 220    is attached to the interface named by its 'dev'.
 221
 222    Edge cases:
 223      - non-zero exit code or empty output from any command -> []
 224      - interfaces whose name does not match eth\\d+  -> ignored
 225      - routes with an unknown gateway -> attached to the first interface, or
 226        dropped if there are no interfaces
 227    """
 228    raw_addrs = get_ip_addresses(grade, machine_name, step=step, ipv6=ipv6)
 229    raw_routes = get_routes(grade, machine_name, step=step, ipv6=ipv6)
 230
 231    # Re-use the cached 'ip a' output to detect dynamic (DHCP) addresses.
 232    ip_a_out, _ = grade.test(machine_name, 'ip a', step=step)
 233
 234    # Parse which interfaces have *all* their inet addresses marked dynamic.
 235    dynamic_ifaces: set[str] = set()
 236    current_iface: str | None = None
 237    iface_addr_flags: dict[str, list[bool]] = {}  # iface -> [is_dynamic, ...]
 238    addr_line_re = re.compile(r'^\s+inet6?\s+\S+') if ipv6 else re.compile(r'^\s+inet\s+\S+')
 239    for line in (ip_a_out or '').splitlines():
 240        m = re.match(r'^\d+:\s+(\S+?)(?:@\S+)?:', line)
 241        if m:
 242            current_iface = m.group(1)
 243            iface_addr_flags.setdefault(current_iface, [])
 244            continue
 245        m = addr_line_re.match(line)
 246        if m and current_iface is not None:
 247            if ipv6 and line.lstrip().startswith('inet6') and _skip_inet6_line(line, link_local=False):
 248                continue
 249            iface_addr_flags.setdefault(current_iface, []).append('dynamic' in line)
 250    for iface, flags in iface_addr_flags.items():
 251        if flags and all(flags):
 252            dynamic_ifaces.add(iface)
 253
 254    # Discover all present eth<N> interfaces via 'ip link show'.
 255    ip_link_out, link_code = grade.test(machine_name, 'ip link show', step=step)
 256    if link_code != 0 or not ip_link_out:
 257        return []
 258    present_eths: set[str] = set(
 259        re.findall(r'^\d+:\s+(eth\d+)(?:@\S+)?:', ip_link_out, re.MULTILINE)
 260    )
 261    if not present_eths:
 262        return []
 263
 264    max_n = max(int(name[3:]) for name in present_eths)
 265
 266    # Build interface → addresses lookup from raw_addrs (only non-empty entries).
 267    eth_addrs: dict[str, list[tuple[str, int]]] = {
 268        name: addrs
 269        for name, addrs in raw_addrs.items()
 270        if name.startswith('eth') and name[3:].isdigit() and addrs
 271    }
 272
 273    # Collect static eth interfaces in order (for route distribution).
 274    static_eth_ifaces: list[tuple[str, list[IPInterface]]] = []
 275    for n in range(max_n + 1):
 276        name = f'eth{n}'
 277        if name not in present_eths:
 278            continue
 279        if name in eth_addrs and name not in dynamic_ifaces:
 280            ifaces = [ip_interface(f"{addr}/{plen}") for addr, plen in eth_addrs[name]]
 281            if ifaces:
 282                static_eth_ifaces.append((name, ifaces))
 283
 284    # Distribute routes among static interfaces (existing logic).
 285    iface_networks: list[list[IPNetwork]] = [
 286        [iface.network for iface in ifaces]
 287        for _, ifaces in static_eth_ifaces
 288    ]
 289    routes_per_iface: list[list[tuple[IPNetwork, IPAddress]]] = [
 290        [] for _ in static_eth_ifaces
 291    ]
 292    for (net_str, mask), (via, dev, _metric) in raw_routes.items():
 293        if not via:
 294            continue
 295        dest = ip_network(f"{net_str}/{mask}")
 296        gw = ip_address(via)
 297        matched = None
 298        for idx, nets in enumerate(iface_networks):
 299            if any(gw in net for net in nets):  # always False across families
 300                matched = idx
 301                break
 302        if matched is None and ipv6:
 303            # a router advertisement's default route: link-local next hop, in no configured prefix
 304            matched = next((idx for idx, (name, _) in enumerate(static_eth_ifaces) if name == dev), None)
 305        if matched is None:
 306            matched = 0
 307        if routes_per_iface:
 308            routes_per_iface[matched].append((dest, gw))
 309
 310    static_map: dict[str, tuple[list[IPInterface], list[tuple[IPNetwork, IPAddress]]]] = {
 311        name: (ifaces, routes_per_iface[idx])
 312        for idx, (name, ifaces) in enumerate(static_eth_ifaces)
 313    }
 314
 315    # Assemble final NetConfig list.
 316    result: NetConfigEntry = []
 317    for n in range(max_n + 1):
 318        name = f'eth{n}'
 319        if name not in present_eths:
 320            result.append(None)
 321            continue
 322        if name in dynamic_ifaces:
 323            result.append('dhcp')
 324        elif name in static_map:
 325            result.append(static_map[name])
 326        else:
 327            result.append(None)
 328
 329    return result
 330
 331
 332def get_persistent_net_config_entry(grade: Grade0, machine_name: str, step: int = 1,
 333                                    ipv6: bool = False) -> tuple[NetConfigEntry, int]:
 334    """Parse /etc/network/interfaces (+ interfaces.d/) and return (NetConfig, error_count).
 335
 336    Reconstructs the network configuration that would result from
 337    'systemctl networking start' (i.e. ifup -a).
 338
 339    Entry types in the returned NetConfig:
 340      - Tuple (addresses, routes): inet-static stanza for eth<N>.
 341      - 'dhcp': inet-dhcp stanza for eth<N>.
 342      - None: eth<N> mentioned in an auto/allow-hotplug line but with no stanza.
 343
 344    Only eth<N> interfaces are included, in index order.
 345
 346    error_count counts lines that are syntactically wrong or have invalid
 347    values (bad IP/network, unknown stanza keywords, malformed iface lines…).
 348
 349    Parsing rules:
 350      - 'address <ip>[/<prefix>]' — primary address; prefix may be absent
 351      - 'netmask <mask>'          — applied to primary address when it has no prefix
 352      - 'gateway <ip>'            — inserted as a 0.0.0.0/0 route
 353      - 'post-up / up ip addr add <addr> dev <iface>' — extra addresses
 354      - 'post-up / up ip route add <net> via <gw>'    — static routes
 355      - Other post-up/up/down commands are silently ignored
 356
 357    ``ipv6=False`` (default): 'inet6' stanzas are ignored like 'manual' ones (not parsed,
 358    not counted).  ``ipv6=True``: 'iface ethN inet6 static' stanzas are parsed too
 359    ('address' with a prefix or 'netmask <prefixlen>', 'gateway' -> ::/0 route, 'post-up ip
 360    [-6] route add' / 'ip [-6] addr add'); the IPv4 and IPv6 parts of an interface are merged
 361    into one tuple (IPv4 first); 'inet6 dhcp' / 'inet6 auto' count as dynamic, so an
 362    interface that is dynamic in both families is 'dhcp'.
 363    """
 364    out1, code1 = grade.test(machine_name, 'cat /etc/network/interfaces', step=step, allow_error=True)
 365    out2, code2 = grade.test(machine_name, 'cat /etc/network/interfaces.d/*', step=step, allow_error=True)
 366
 367    lines: list[str] = []
 368    include_interfaces_d = False
 369    if code1 == 0 and out1:
 370        lines.extend(out1.splitlines())
 371        for raw_line in out1.splitlines():
 372            stripped = raw_line.strip()
 373            if not stripped or stripped.startswith('#'):
 374                continue
 375            parts = stripped.split()
 376            if (parts[0] in ('source', 'source-directory')
 377                    and len(parts) >= 2
 378                    and '/etc/network/interfaces.d' in parts[1]):
 379                include_interfaces_d = True
 380                break
 381    if include_interfaces_d and code2 == 0 and out2:
 382        lines.extend(out2.splitlines())
 383
 384    errors = 0
 385
 386    # Known stanza-level options that ifup accepts — won't be counted as errors.
 387    _KNOWN_OPTS = {
 388        'address', 'netmask', 'broadcast', 'network', 'gateway',
 389        'metric', 'hwaddress', 'mtu', 'scope',
 390        'pre-up', 'up', 'post-up', 'down', 'pre-down', 'post-down',
 391        'dns-nameservers', 'dns-search', 'dns-domain',
 392        'vlan-raw-device', 'bridge_ports', 'bridge_stp', 'bridge_fd',
 393        'bond-master', 'bond-slaves', 'bond-mode', 'bond-miimon',
 394        'wpa-ssid', 'wpa-psk', 'wpa-conf',
 395    }
 396    # inet6-specific options (ifupdown's inet6.defn)
 397    _KNOWN_OPTS6 = _KNOWN_OPTS | {
 398        'accept_ra', 'autoconf', 'privext', 'dad-attempts', 'dad-interval', 'preferred-lifetime',
 399        'media',
 400    }
 401
 402    def _new_bucket():
 403        return {
 404            'primary': None, 'has_prefix': False,
 405            'netmask': None, 'extras': [],
 406            'routes': [], 'gateway': None,
 407        }
 408
 409    # Accumulate per-interface data keyed by (interface name, family).
 410    iface_data: dict[tuple[str, int], dict] = {}
 411    dhcp_ifaces: set[str] = set()  # interfaces with 'inet dhcp' stanza
 412    dyn6_ifaces: set[str] = set()  # interfaces with 'inet6 dhcp' / 'inet6 auto' stanza (ipv6=True)
 413    auto_ifaces: set[str] = set()  # interfaces seen in auto/allow-hotplug lines
 414    current: tuple[str, int] | None = None  # (name, family) of the active static stanza
 415    in_ignored_stanza: bool = False  # True inside loopback / dhcp / manual stanza
 416
 417    for raw_line in lines:
 418        stripped = raw_line.strip()
 419        if not stripped or stripped.startswith('#'):
 420            continue
 421
 422        parts = stripped.split()
 423        kw = parts[0]
 424
 425        if kw == 'iface':
 426            if len(parts) < 4:
 427                errors += 1
 428                current = None
 429                in_ignored_stanza = False
 430                continue
 431            iface_name, family, method = parts[1], parts[2], parts[3]
 432            if family == 'inet' and method == 'static':
 433                current = (iface_name, 4)
 434                in_ignored_stanza = False
 435                iface_data.setdefault(current, _new_bucket())
 436            elif family == 'inet' and method == 'dhcp':
 437                dhcp_ifaces.add(iface_name)
 438                current = None
 439                in_ignored_stanza = True
 440            elif ipv6 and family == 'inet6' and method == 'static':
 441                current = (iface_name, 6)
 442                in_ignored_stanza = False
 443                iface_data.setdefault(current, _new_bucket())
 444            elif ipv6 and family == 'inet6' and method in ('dhcp', 'auto'):
 445                dyn6_ifaces.add(iface_name)
 446                current = None
 447                in_ignored_stanza = True
 448            else:
 449                current = None  # loopback / manual / other — irrelevant
 450                in_ignored_stanza = True
 451
 452        elif kw in ('auto', 'allow-hotplug', 'allow-auto'):
 453            for iface_name in parts[1:]:
 454                auto_ifaces.add(iface_name)
 455
 456        elif kw in ('source', 'source-directory', 'mapping',
 457                    'no-auto-down', 'no-scripts'):
 458            pass  # Valid top-level directives, nothing to record
 459
 460        else:
 461            # Option line within a stanza (indentation not required by ifupdown).
 462            # If outside any stanza entirely, count as an error.
 463            directive = kw
 464
 465            if current is None:
 466                if not in_ignored_stanza:
 467                    errors += 1
 468                continue
 469
 470            data = iface_data[current]
 471            fam = current[1]
 472            iface_cls = IPv4Interface if fam == 4 else IPv6Interface
 473            addr_cls = IPv4Address if fam == 4 else IPv6Address
 474
 475            if directive == 'address':
 476                if len(parts) < 2:
 477                    errors += 1
 478                else:
 479                    try:
 480                        iface = iface_cls(parts[1])
 481                        data['primary'] = iface
 482                        data['has_prefix'] = '/' in parts[1]
 483                    except ValueError:
 484                        errors += 1
 485
 486            elif directive == 'netmask':
 487                if len(parts) < 2:
 488                    errors += 1
 489                else:
 490                    data['netmask'] = parts[1]
 491
 492            elif directive == 'gateway':
 493                if len(parts) < 2:
 494                    errors += 1
 495                else:
 496                    try:
 497                        data['gateway'] = addr_cls(parts[1])
 498                    except ValueError:
 499                        errors += 1
 500
 501            elif directive in ('post-up', 'up'):
 502                cmd = stripped[len(directive):].strip()
 503                if not ipv6:
 504                    m = re.match(r'ip\s+route\s+add\s+(\S+)\s+via\s+(\S+)', cmd)
 505                    if m:
 506                        try:
 507                            net = IPv4Network(m.group(1), strict=False)
 508                            gw = IPv4Address(m.group(2))
 509                            data['routes'].append((net, gw))
 510                        except ValueError:
 511                            errors += 1
 512                        continue
 513                    m = re.match(r'ip\s+addr\s+add\s+(\S+)\s+dev\s+\S+', cmd)
 514                    if m:
 515                        try:
 516                            data['extras'].append(IPv4Interface(m.group(1)))
 517                        except ValueError:
 518                            errors += 1
 519                else:
 520                    m = re.match(r'ip\s+(?:-[46]\s+)?route\s+add\s+(\S+)\s+via\s+(\S+)', cmd)
 521                    if m:
 522                        try:
 523                            net = ip_network(m.group(1), strict=False)
 524                            gw = ip_address(m.group(2))
 525                            if net.version != gw.version:
 526                                raise ValueError("route and next hop of different families")
 527                            data['routes'].append((net, gw))
 528                        except ValueError:
 529                            errors += 1
 530                        continue
 531                    m = re.match(r'ip\s+(?:-[46]\s+)?addr\s+add\s+(\S+)\s+dev\s+\S+', cmd)
 532                    if m:
 533                        try:
 534                            data['extras'].append(ip_interface(m.group(1)))
 535                        except ValueError:
 536                            errors += 1
 537                # Other post-up / up commands (iptables, arp…) are not errors
 538
 539            elif directive not in (_KNOWN_OPTS6 if fam == 6 else _KNOWN_OPTS):
 540                errors += 1
 541
 542    # Collect all eth<N> interface names we know about, in index order.
 543    all_known = (
 544            {name for name, _ in iface_data} | dhcp_ifaces | dyn6_ifaces |
 545            {n for n in auto_ifaces if n.startswith('eth') and n[3:].isdigit()}
 546    )
 547    eth_names = sorted(
 548        [n for n in all_known if n.startswith('eth') and n[3:].isdigit()],
 549        key=lambda n: int(n[3:])
 550    )
 551
 552    def _finalize(data: dict, fam: int):
 553        """(addresses, routes) of one family bucket, or None without a primary address."""
 554        nonlocal errors
 555        primary = data['primary']
 556        if primary is None:
 557            return None
 558
 559        # Combine bare IP with netmask when no CIDR prefix was given.
 560        if not data['has_prefix'] and data['netmask']:
 561            try:
 562                if fam == 4:
 563                    prefix = IPv4Network(f'0.0.0.0/{data["netmask"]}').prefixlen
 564                    primary = IPv4Interface(f'{primary.ip}/{prefix}')
 565                else:
 566                    prefix = int(data['netmask'])
 567                    primary = IPv6Interface(f'{primary.ip}/{prefix}')  # ValueError beyond 128
 568            except ValueError:
 569                errors += 1
 570
 571        addresses = [primary] + data['extras']
 572
 573        routes = list(data['routes'])
 574        if data['gateway']:
 575            routes.insert(0, (_DEFAULT_NET_BY_VERSION[fam], data['gateway']))
 576
 577        return addresses, routes
 578
 579    result: NetConfigEntry = []
 580    for name in eth_names:
 581        if not ipv6:
 582            if name in dhcp_ifaces:
 583                result.append('dhcp')
 584            elif (name, 4) not in iface_data:
 585                # Mentioned in auto but no inet stanza — unconfigured.
 586                result.append(None)
 587            else:
 588                result.append(_finalize(iface_data[(name, 4)], 4))
 589            continue
 590
 591        v4 = None if (name in dhcp_ifaces or (name, 4) not in iface_data) else _finalize(iface_data[(name, 4)], 4)
 592        v6 = None if (name in dyn6_ifaces or (name, 6) not in iface_data) else _finalize(iface_data[(name, 6)], 6)
 593        if v4 is None and v6 is None:
 594            result.append('dhcp' if (name in dhcp_ifaces or name in dyn6_ifaces) else None)
 595        else:
 596            addresses = (v4[0] if v4 else []) + (v6[0] if v6 else [])
 597            routes = (v4[1] if v4 else []) + (v6[1] if v6 else [])
 598            result.append((addresses, routes))
 599
 600    return result, errors
 601
 602
 603class _AttrDict(dict):
 604    __getattr__ = dict.__getitem__
 605
 606
 607def net_config_entry_family(nc_entry: NetConfigEntry, version: int) -> NetConfigEntry:
 608    """Keep only the addresses and routes of IP *version* (4 or 6) in each tuple of
 609    *nc_entry*; 'dhcp' and None entries are returned unchanged.  Use it to grade the two
 610    families of a dual-stack configuration separately with eval_net_config()."""
 611    result: NetConfigEntry = []
 612    for entry in nc_entry:
 613        if not isinstance(entry, tuple):
 614            result.append(entry)
 615            continue
 616        ifaces, routes = entry
 617        result.append(([i for i in ifaces if i.version == version],
 618                       [(net, gw) for net, gw in routes if net.version == version]))
 619    return result
 620
 621
 622def eval_net_config(grade: Grade0, expected: NetConfigEntry, machine_name: str = None,
 623                    current: NetConfigEntry | None = None, step: int = 1) -> _AttrDict:
 624    """Compare current and expected NetConfig; return a dict with 10 keys:
 625
 626    Existing keys (unchanged semantics — 'dhcp' and None entries are skipped):
 627    - "ips"                   : number of IP addresses that are correct
 628    - "default_route"         : 1 if the default route matches the expected one, 0 otherwise
 629    - "other_routes"          : number of correct non-default static routes
 630    - "wrong_routes"          : number of non-default static routes present in current but not in expected
 631    - "ips_expected"          : number of IP addresses in expected
 632    - "default_route_expected": 1 if expected has a default route, 0 otherwise
 633    - "other_routes_expected" : number of non-default static routes in expected
 634
 635    New keys for DHCP/None tracking:
 636    - "dhcp_interfaces"         : positions where both expected and current are 'dhcp'
 637    - "dhcp_interfaces_expected": count of 'dhcp' entries in expected
 638    - "none_interfaces_expected": count of None entries in expected
 639
 640    If *current* is None, get_net_config() is called first.
 641    IP addresses are compared as interface strings (address + prefix), IPv4 and IPv6 alike.
 642    Routes are compared as (network, gateway) pairs; 0.0.0.0/0 and ::/0 are both default
 643    routes ("default_route" is 1 only when every expected default route is present and no
 644    other one is: split the families with net_config_entry_family() to grade them apart).
 645    """
 646    if current is None:
 647        if machine_name is None:
 648            raise ValueError("eval_net_config: current and machine_name can't be both None")
 649        current = get_net_config_entry(grade, machine_name, step=step)
 650
 651    def _collect_ips(nc: NetConfigEntry) -> set[str]:
 652        result = set()
 653        for entry in nc:
 654            if not isinstance(entry, tuple):
 655                continue
 656            ifaces, _ = entry
 657            for iface in ifaces:
 658                result.add(str(iface))
 659        return result
 660
 661    def _collect_routes(nc: NetConfigEntry) -> set[tuple[str, str]]:
 662        result = set()
 663        for entry in nc:
 664            if not isinstance(entry, tuple):
 665                continue
 666            _, routes = entry
 667            for net, gw in routes:
 668                if gw is None:
 669                    gw_str = ''
 670                elif isinstance(gw, _IFACE_TYPES):
 671                    gw_str = str(gw.ip)
 672                else:
 673                    gw_str = str(gw)
 674                result.add((str(net), gw_str))
 675        return result
 676
 677    exp_ips = _collect_ips(expected)
 678    cur_ips = _collect_ips(current)
 679
 680    exp_routes = _collect_routes(expected)
 681    cur_routes = _collect_routes(current)
 682
 683    exp_default = {r for r in exp_routes if r[0] in _DEFAULT_NETS}
 684    cur_default = {r for r in cur_routes if r[0] in _DEFAULT_NETS}
 685    exp_non_default = {r for r in exp_routes if r[0] not in _DEFAULT_NETS}
 686    cur_non_default = {r for r in cur_routes if r[0] not in _DEFAULT_NETS}
 687
 688    dhcp_match = sum(
 689        1 for e, c in zip(expected, current)
 690        if e == 'dhcp' and c == 'dhcp'
 691    )
 692
 693    return _AttrDict(
 694        ips=len(exp_ips & cur_ips),
 695        default_route=1 if exp_default == cur_default else 0,
 696        other_routes=len(exp_non_default & cur_non_default),
 697        wrong_routes=len(cur_non_default - exp_non_default),
 698        ips_expected=len(exp_ips),
 699        default_route_expected=1 if exp_default else 0,
 700        other_routes_expected=len(exp_non_default),
 701        dhcp_interfaces=dhcp_match,
 702        dhcp_interfaces_expected=sum(1 for e in expected if e == 'dhcp'),
 703        none_interfaces_expected=sum(1 for e in expected if e is None),
 704    )
 705
 706
 707def _route_line(interface: str, net, via) -> str:
 708    """'post-up' line adding a static route in /etc/network/interfaces."""
 709    via_addr = _gw_ip(via)
 710    if net.version == 6:
 711        line = f'    post-up ip -6 route add {net.compressed} via {via_addr}'
 712        if via_addr.is_link_local:
 713            line += f' dev {interface}'
 714        return line
 715    return f'    post-up ip route add {net.compressed} via {via_addr}'
 716
 717
 718def _static_stanza(interface: str, family: int, addresses: list, routes: list, extra_routes: list) -> list[str]:
 719    """Lines of one 'iface ethN inet|inet6 static' stanza.
 720
 721    *routes* are the routes of the stanza's family (the default one becomes 'gateway');
 722    *extra_routes* are routes of the other family when that family has no address on the
 723    interface (they can only go through 'post-up' lines).
 724    """
 725    default_net = _DEFAULT_NET_BY_VERSION[family]
 726    lines = [
 727        f'iface {interface} {"inet" if family == 4 else "inet6"} static',
 728        f'    address {addresses[0]}',
 729    ]
 730    for extra in addresses[1:]:
 731        lines.append(f'    post-up ip addr add {extra} dev {interface}')
 732    for net, via in routes:
 733        if net == default_net:
 734            lines.append(f'    gateway {_gw_ip(via)}')
 735    for net, via in routes:
 736        if net != default_net:
 737            lines.append(_route_line(interface, net, via))
 738    for net, via in extra_routes:
 739        lines.append(_route_line(interface, net, via))
 740    return lines
 741
 742
 743def render_persistent_net_config_entry(nc_entry: NetConfigEntry, machine_name: str = '') -> str:
 744    """Content of /etc/network/interfaces for *nc_entry* (*machine_name* only names the machine
 745    in the error raised for an interface without address).
 746
 747    One 'inet static' stanza per interface with IPv4 addresses and one 'inet6 static' stanza
 748    per interface with IPv6 addresses (both for a dual-stack entry): the first address goes
 749    to 'address', the others to 'post-up ip addr add', the default route to 'gateway' and
 750    the other routes to 'post-up ip [-6] route add'.
 751    """
 752    lines = ['auto lo', 'iface lo inet loopback', '']
 753    for i, entry in enumerate(nc_entry):
 754        interface = f'eth{i}'
 755        if entry is None:
 756            continue
 757        if entry == 'dhcp':
 758            lines += [
 759                f'auto {interface}',
 760                f'iface {interface} inet dhcp',
 761                '',
 762            ]
 763            continue
 764        iface_list, routes = entry
 765        addresses = {fam: [a for a in iface_list if a.version == fam] for fam in (4, 6)}
 766        fam_routes = {fam: [(net, via) for net, via in routes if net.version == fam] for fam in (4, 6)}
 767        families = [fam for fam in (4, 6) if addresses[fam]]
 768        if not families:
 769            raise ValueError(f"set_persistent_net_config_entry: {machine_name} {interface} has no address")
 770        lines.append(f'auto {interface}')
 771        for fam in families:
 772            other = 6 if fam == 4 else 4
 773            extra_routes = fam_routes[other] if other not in families else []
 774            lines += _static_stanza(interface, fam, addresses[fam], fam_routes[fam], extra_routes)
 775            lines.append('')
 776    return '\n'.join(lines)
 777
 778
 779def set_persistent_net_config_entry(net_scheme: NetScheme0, machine_name: str, nc_entry: NetConfigEntry):
 780    """Write /etc/network/interfaces on *machine_name* from *nc_entry* (content:
 781    render_persistent_net_config_entry())."""
 782    net_scheme.file(machine_name, '/etc/network/interfaces',
 783                    render_persistent_net_config_entry(nc_entry, machine_name))
 784
 785
 786def set_net_config_entry(net_scheme: NetScheme0, machine_name: str, nc_entry: NetConfigEntry):
 787    """Apply *nc_entry* on *machine_name* with 'ip link / ip addr add / ip route add' commands
 788    ('dhclient' for a 'dhcp' entry).  IPv4 and IPv6 addresses and routes are handled alike
 789    ('ip' infers the family); an IPv6 route through a link-local next hop gets 'dev ethN'."""
 790    for i, entry in enumerate(nc_entry):
 791        interface = f'eth{i}'
 792        if entry is None:
 793            continue
 794        if entry == 'dhcp':
 795            net_scheme.cmd(machine_name, f'ip link set {interface} up')
 796            net_scheme.cmd(machine_name, f'dhclient {interface}')
 797            continue
 798        iface_list, routes = entry
 799        net_scheme.cmd(machine_name, f'ip link set {interface} up')
 800        for iface in iface_list:
 801            net_scheme.cmd(machine_name, f'ip addr add {iface} dev {interface}')
 802        for net, via in routes:
 803            via_addr = _gw_ip(via)
 804            command = f'ip route add {net.compressed} via {str(via_addr)}'
 805            if net.version == 6 and via_addr.is_link_local:
 806                command += f' dev {interface}'
 807            net_scheme.cmd(machine_name, command)
 808
 809
 810def set_persistent_sysctl(net_scheme: NetScheme0, machine_name: str, sysctl_config: SysctlConfig):
 811    for name, value in sysctl_config.items():
 812        net_scheme.cmd(machine_name, f'echo "{name}={value}" >> /etc/sysctl.conf')
 813    if sysctl_config:
 814        remount_proc_sys(net_scheme, machine_name)
 815        net_scheme.cmd(machine_name, 'sysctl -p')
 816
 817# Docker bind-mounts /proc/sys read-only in a non-privileged container: it must be remounted
 818# before any sysctl write.  A privileged container has no such mount (Docker applies no read-only
 819# path there), and remounting a path that is not a mount point fails with exit status 32
 820# ("mount point not mounted or bad option"): the remount is therefore conditional.  As root,
 821# `test -w` on a sysctl *file* is false on a read-only bind mount (access(2) answers EROFS) and
 822# true on writable procfs; a sysctl *directory* would not do (0555, write refused to root too).
 823REMOUNT_PROC_SYS_CMD = 'test -w /proc/sys/net/ipv4/ip_forward || mount -o rw,remount /proc/sys'
 824
 825
 826def remount_proc_sys(net_scheme: NetScheme0, machine_name: str):
 827    """Make ``/proc/sys`` writable on *machine_name* when it is not (once per machine and
 828    NetScheme instance): ``REMOUNT_PROC_SYS_CMD``, a no-op on a privileged machine."""
 829    if not hasattr(net_scheme, "remount_proc_sys_done"):
 830        net_scheme.remount_proc_sys_done = {}
 831    if net_scheme.remount_proc_sys_done.get(machine_name):
 832        return
 833    net_scheme.cmd(machine_name, REMOUNT_PROC_SYS_CMD)
 834    net_scheme.remount_proc_sys_done[machine_name] = True
 835
 836
 837def set_sysctl(net_scheme: NetScheme0, machine_name: str, sysctl_config: SysctlConfig):
 838    if len(sysctl_config) > 0:
 839        remount_proc_sys(net_scheme, machine_name)
 840        for name, value in sysctl_config.items():
 841            net_scheme.cmd(machine_name, f'sysctl -w {name}={value}')
 842
 843
 844def set_ip_forward(net_scheme: NetScheme0, machine_name: str, ip_forward: bool, step: int = 1):
 845    value = 1 if ip_forward else 0
 846    remount_proc_sys(net_scheme, machine_name)
 847    net_scheme.cmd(machine_name, f'sysctl -w net.ipv4.ip_forward={value}', step=step)
 848
 849
 850def get_ip_forward(grade: Grade0, machine_name: str, step: int = 1) -> bool:
 851    output, code = grade.test(machine_name, 'cat /proc/sys/net/ipv4/ip_forward', step=step)
 852    if code != 0:
 853        return False
 854    return output.strip() == '1'
 855
 856
 857def set_ipv6_forward(net_scheme: NetScheme0, machine_name: str, ipv6_forward: bool, step: int = 1):
 858    """Set IPv6 forwarding on *machine_name* (``net.ipv6.conf.all.forwarding``, which the kernel
 859    propagates to every interface).  Kathara starts the machines of an IPv6 lab with forwarding
 860    enabled: call it with ``False`` on the hosts, as set_ip_forward() for IPv4."""
 861    value = 1 if ipv6_forward else 0
 862    remount_proc_sys(net_scheme, machine_name)
 863    net_scheme.cmd(machine_name, f'sysctl -w net.ipv6.conf.all.forwarding={value}', step=step)
 864
 865
 866def get_ipv6_forward(grade: Grade0, machine_name: str, step: int = 1) -> bool:
 867    """IPv6 forwarding state of *machine_name* (``/proc/sys/net/ipv6/conf/all/forwarding``)."""
 868    output, code = grade.test(machine_name, 'cat /proc/sys/net/ipv6/conf/all/forwarding', step=step)
 869    if code != 0:
 870        return False
 871    return output.strip() == '1'
 872
 873
 874def get_sys_parameter_bool(grade: Grade0, machine_name: str, parameter: str, step: int = 1) -> bool | None:
 875    """Read a boolean kernel parameter via sysctl and return its value.
 876
 877    Returns True for '1', False for '0', None if the parameter does not exist or the command fails.
 878    """
 879    value = get_sys_parameter(grade, machine_name, parameter, step=step)
 880    if value is None:
 881        return None
 882    if value == '1':
 883        return True
 884    if value == '0':
 885        return False
 886    return None
 887
 888
 889def get_net_config_from_topology(
 890        net_scheme: NetScheme0,
 891        topology=None,
 892        gateway: str = None,
 893        default_route: IPv4Address | IPv4Interface = IPv4Interface("172.17.0.1/24"),
 894        *,
 895        ipv4: bool = True,
 896        ipv6: bool = False,
 897        default_route6: IPv6Address | IPv6Interface | None = None,
 898) -> NetConfig:
 899    """Build a NetConfig for every machine in the topology so they can all reach each other.
 900
 901    Returns {machine_name: NetConfig}.
 902
 903    Each machine gets:
 904    - One interface entry per network it belongs to, with the IP from net_scheme.data.ips.
 905    - Static routes to every non-directly-connected network (found via BFS through
 906      multi-homed router machines).
 907
 908    If *gateway* is not None:
 909    - Every machine except the gateway gets a default route (0.0.0.0/0) toward the
 910      gateway machine via the appropriate next-hop IP.
 911    - The gateway itself gets a default route via *default_route* (typically the
 912      Docker host bridge, e.g. 172.17.0.1, reachable when the gateway is ``bridged=True``);
 913      ``default_route=None`` gives it none.
 914
 915    The interface indices (eth0, eth1, …) are computed with the same counter logic as
 916    NetScheme0.__init__, so they match the actual Kathara deployment.
 917
 918    IP names follow the convention set by random_ips_from_topology():
 919    - data.ips.m       when machine m is in exactly one network
 920    - data.ips.m_netX  when machine m is in multiple networks
 921
 922    IPv6: ``ipv6=True`` adds, in the same entries, the IPv6 addresses of ``data.ips6`` and
 923    routes to the IPv6 prefixes of ``data.nets6`` (same naming).  A network without a prefix
 924    in ``data.nets6``, or a machine without an address in ``data.ips6`` for a network (a
 925    SLAAC host, an IPv4-only host), simply has no IPv6 part there (no address, no IPv6
 926    route through it).  The gateway gets a ``::/0`` route only when *default_route6* is
 927    given (the Docker bridge has no IPv6 by default).  ``ipv4=False`` builds an IPv6-only
 928    configuration (``data.ips6`` / ``data.nets6`` are then mandatory like their IPv4
 929    counterparts).
 930    """
 931    if not ipv4 and not ipv6:
 932        raise ValueError("get_net_config_from_topology: at least one of ipv4/ipv6 must be True")
 933    if topology is None:
 934        topology = net_scheme.get_topology()
 935    data = net_scheme.data
 936    families = [fam for fam, enabled in ((4, ipv4), (6, ipv6)) if enabled]
 937    ips_containers = {4: data.ips, 6: getattr(data, 'ips6', None)}
 938    nets_containers = {4: data.nets, 6: getattr(data, 'nets6', None)}
 939
 940    # --- 1. Replicate NetScheme0 eth-index assignment ---
 941    # machine_iface: {machine: {net_name: eth_idx}}
 942    machine_iface: dict[str, dict[str, int]] = {}
 943    _ctr: dict[str, int] = {}
 944    for net_name, machines in topology.items():
 945        items = list(machines.items()) if isinstance(machines, dict) else [(m, None) for m in machines]
 946        for mname, iface_spec in items:
 947            iface = iface_spec[0] if isinstance(iface_spec, tuple) else iface_spec
 948            if iface is None:
 949                iface = _ctr.get(mname, 0)
 950            _ctr[mname] = max(_ctr.get(mname, 0), iface) + 1
 951            machine_iface.setdefault(mname, {})[net_name] = iface
 952
 953    machine_nets: dict[str, list[str]] = {m: list(d.keys()) for m, d in machine_iface.items()}
 954
 955    # --- 2. IP lookup (matches random_ips_from_topology naming) ---
 956    # IPv4 (or the only family): a missing entry is an error (AttributeError), as before.
 957    # IPv6 next to IPv4: a missing entry means "no IPv6 there" (SLAAC or IPv4-only host).
 958    def get_ip(machine: str, net_name: str, fam: int = 4) -> IPInterface | None:
 959        container = ips_containers[fam]
 960        attr = machine if len(machine_nets[machine]) == 1 else f"{machine}_{net_name}"
 961        if fam == 6 and ipv4:
 962            return getattr(container, attr, None)
 963        return getattr(container, attr)
 964
 965    def net_has(net_name: str, fam: int) -> bool:
 966        """Whether *net_name* has a prefix of family *fam* (the prefix of the only family is
 967        mandatory, as before)."""
 968        if fam == 4 or not ipv4:
 969            return True
 970        return nets_containers[6] is not None and hasattr(nets_containers[6], net_name)
 971
 972    # --- 3. Network routing graph ---
 973    # Multi-homed machines act as routers; each pair of their networks is an edge.
 974    net_graph: dict[str, list[tuple[str, str]]] = {n: [] for n in topology}
 975    for mname, nets in machine_nets.items():
 976        for i, n1 in enumerate(nets):
 977            for n2 in nets[i + 1:]:
 978                net_graph[n1].append((n2, mname))
 979                net_graph[n2].append((n1, mname))
 980
 981    # --- 4. BFS: find (via_start_net, first_router) from start_nets to a target ---
 982    def _bfs(start_nets: set[str], is_target) -> tuple[str, str] | None:
 983        visited = set(start_nets)
 984        q: deque = deque()
 985        for n in start_nets:
 986            for adj, router in net_graph.get(n, []):
 987                if adj not in visited:
 988                    visited.add(adj)
 989                    q.append((adj, n, router))
 990        while q:
 991            cur, via, router = q.popleft()
 992            if is_target(cur):
 993                return (via, router)
 994            for adj, r in net_graph.get(cur, []):
 995                if adj not in visited:
 996                    visited.add(adj)
 997                    q.append((adj, via, router))
 998        return None
 999
1000    def first_hop_to_net(start_nets: set[str], target_net: str) -> tuple[str, str] | None:
1001        """Return (via_net, router) for the first hop to reach target_net, or None if direct."""
1002        if target_net in start_nets:
1003            return None
1004        return _bfs(start_nets, lambda n: n == target_net)
1005
1006    def first_hop_to_machine(start_nets: set[str], target: str) -> tuple[str, str] | None:
1007        """Return (via_net, router) for the first hop toward target machine.
1008        When directly connected, router is the target machine itself."""
1009        target_nets = set(machine_nets.get(target, []))
1010        shared = start_nets & target_nets
1011        if shared:
1012            return (next(iter(shared)), target)
1013        return _bfs(start_nets, lambda n: n in target_nets)
1014
1015    # --- 5. external default routes as bare addresses (None: no external default route) ---
1016    external_hop = {
1017        4: None if default_route is None else _gw_ip(default_route),
1018        6: None if default_route6 is None else _gw_ip(default_route6),
1019    }
1020
1021    # --- 6. Assemble NetConfig per machine ---
1022    result: dict[str, NetConfigEntry] = {}
1023
1024    for machine, net_to_eth in machine_iface.items():
1025        start_nets = set(machine_nets[machine])
1026
1027        # Collect all candidate routes as (net, eth_idx, next_hop).
1028        route_triples: list[tuple[IPNetwork, int, IPAddress]] = []
1029
1030        for fam in families:
1031            if not any(net_has(n, fam) and get_ip(machine, n, fam) is not None for n in start_nets):
1032                continue  # no address of this family on the machine: no route of this family
1033
1034            # Specific routes to every non-directly-connected network
1035            for target_net in topology:
1036                if target_net in start_nets or not net_has(target_net, fam):
1037                    continue
1038                hop = first_hop_to_net(start_nets, target_net)
1039                if hop is None:
1040                    continue
1041                via_net, router = hop
1042                if not net_has(via_net, fam):
1043                    continue
1044                next_hop = get_ip(router, via_net, fam)
1045                if next_hop is None:
1046                    continue
1047                route_triples.append((
1048                    getattr(nets_containers[fam], target_net),
1049                    net_to_eth[via_net],
1050                    next_hop.ip,
1051                ))
1052
1053            # Default route
1054            if gateway is not None:
1055                default_net = _DEFAULT_NET_BY_VERSION[fam]
1056                if machine == gateway:
1057                    # External default route: on the interface of the network holding the hop when
1058                    # it is a lab network (a machine standing for the outside world), else on the
1059                    # lowest-index interface (the Docker bridge of a bridged gateway)
1060                    if external_hop[fam] is not None:
1061                        hop = external_hop[fam]
1062                        eth = next((net_to_eth[n] for n in sorted(start_nets, key=net_to_eth.get)
1063                                    if net_has(n, fam) and hop in getattr(nets_containers[fam], n)),
1064                                   min(net_to_eth.values()))
1065                        route_triples.append((default_net, eth, hop))
1066                else:
1067                    hop = first_hop_to_machine(start_nets, gateway)
1068                    if hop is not None:
1069                        via_net, router = hop
1070                        next_hop = get_ip(router, via_net, fam) if net_has(via_net, fam) else None
1071                        if next_hop is not None:
1072                            route_triples.append((
1073                                default_net,
1074                                net_to_eth[via_net],
1075                                next_hop.ip,
1076                            ))
1077
1078        # Drop redundant routes: a route (net, hop) is redundant when another route
1079        # (net2, hop2) exists with the same next-hop and net2 is a strict supernet of net
1080        # (i.e. net2 covers net entirely — the less-specific route already handles it).
1081        def _is_redundant(net: IPNetwork, hop: IPAddress) -> bool:
1082            return any(
1083                net2 != net and net2.version == net.version and net.subnet_of(net2) and hop == hop2
1084                for net2, _, hop2 in route_triples
1085            )
1086
1087        eth_routes: dict[int, list] = {eth: [] for eth in net_to_eth.values()}
1088        for net, eth, hop in route_triples:
1089            if not _is_redundant(net, hop):
1090                eth_routes[eth].append((net, hop))
1091
1092        # Build the NetConfig list indexed by eth number
1093        max_eth = max(net_to_eth.values())
1094        nc: NetConfigEntry = [None] * (max_eth + 1)
1095        for net_name, eth_idx in net_to_eth.items():
1096            addresses = [ip for fam in families if net_has(net_name, fam)
1097                         for ip in [get_ip(machine, net_name, fam)] if ip is not None]
1098            nc[eth_idx] = (addresses, eth_routes[eth_idx])
1099        result[machine] = nc
1100
1101    return result
1102
1103
1104def get_sys_parameter(grade: Grade0, machine_name: str, parameter: str, step: int = 1) -> str | None:
1105    """Read a kernel parameter via sysctl and return its value as a string.
1106
1107    Returns None if the parameter does not exist or the command fails.
1108    """
1109    output, code = grade.test(machine_name, f'sysctl -n {parameter}', step=step)
1110    if code != 0:
1111        return None
1112    return output.strip() or None
IPAddress: TypeAlias = ipaddress.IPv4Address | ipaddress.IPv6Address
IPInterface: TypeAlias = ipaddress.IPv4Interface | ipaddress.IPv6Interface
IPNetwork: TypeAlias = ipaddress.IPv4Network | ipaddress.IPv6Network
NetConfigInterface: TypeAlias = Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]
NetConfigEntry: TypeAlias = List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]
NetConfig: TypeAlias = Dict[str, List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]]
SysctlConfig = typing.Dict[str, typing.Any]
def get_ip_addresses( grade: SRE.lib_sre.Grade0, machine_name: str, step: int = 1, ipv6: bool = False, link_local: bool = False) -> Dict[str, List[Tuple[str, int]]]:
58def get_ip_addresses(grade: Grade0, machine_name: str, step: int = 1, ipv6: bool = False,
59                     link_local: bool = False) -> Dict[str, List[Tuple[str, int]]]:
60    """Run 'ip a' on machine_name and parse the output.
61
62    Returns a dict mapping interface name -> list of (address, prefix_len).
63
64    Only IPv4 addresses are returned unless ``ipv6=True``, which adds the IPv6 addresses
65    (``inet6`` lines) after the IPv4 ones: global addresses, SLAAC (``dynamic`` /
66    ``mngtmpaddr``) addresses and ``::1``; link-local addresses (``scope link``) only with
67    ``link_local=True``; temporary (privacy) addresses never.
68
69    Addresses for each interface are sorted by:
70      1. family (IPv4 before IPv6)
71      2. prefix length descending
72      3. IP address lexicographically ascending
73
74    Edge cases:
75      - virtual interfaces (e.g. eth0@if5) -> name stripped to 'eth0'
76      - non-zero exit code or empty output  -> {}
77    """
78    output, code = grade.test(machine_name, 'ip a', step=step)
79    if code != 0 or not output:
80        return {}
81    result: Dict[str, List[Tuple[str, int]]] = {}
82    current_iface = None
83    for line in output.splitlines():
84        m = re.match(r'^\d+:\s+(\S+?)(?:@\S+)?:', line)
85        if m:
86            current_iface = m.group(1)
87            result.setdefault(current_iface, [])
88            continue
89        m = _INET_RE.match(line)
90        if m and current_iface is not None:
91            result[current_iface].append((m.group(1), int(m.group(2))))
92            continue
93        if ipv6 and current_iface is not None:
94            m = _INET6_RE.match(line)
95            if m and not _skip_inet6_line(m.group(3), link_local):
96                result[current_iface].append((m.group(1), int(m.group(2))))
97    for iface in result:
98        result[iface].sort(key=lambda x: (6 if ':' in x[0] else 4, -x[1], x[0]))
99    return result

Run 'ip a' on machine_name and parse the output.

Returns a dict mapping interface name -> list of (address, prefix_len).

Only IPv4 addresses are returned unless ipv6=True, which adds the IPv6 addresses (inet6 lines) after the IPv4 ones: global addresses, SLAAC (dynamic / mngtmpaddr) addresses and ::1; link-local addresses (scope link) only with link_local=True; temporary (privacy) addresses never.

Addresses for each interface are sorted by:
  1. family (IPv4 before IPv6)
  2. prefix length descending
  3. IP address lexicographically ascending
Edge cases:
  • virtual interfaces (e.g. eth0@if5) -> name stripped to 'eth0'
  • non-zero exit code or empty output -> {}
def get_routes( grade: SRE.lib_sre.Grade0, machine_name: str, step: int = 1, ipv6: bool = False) -> Dict[Tuple[str, int], Tuple[str, str, int]]:
136def get_routes(grade: Grade0, machine_name: str, step: int = 1,
137               ipv6: bool = False) -> Dict[Tuple[str, int], Tuple[str, str, int]]:
138    """Run 'ip route' (and 'ip -6 route' with ``ipv6=True``) on machine_name and parse the output.
139
140    Returns a dict mapping (network, mask) -> (via, dev, metric).
141
142    Edge cases:
143      - 'default' route              -> key ('0.0.0.0', 0), or ('::', 0) in the IPv6 table
144      - host route without mask      -> mask 32 (128 in the IPv6 table)
145      - direct route (no via)        -> via ''
146      - no explicit metric           -> metric 0
147      - non-zero exit code or empty output -> {} (IPv4 table; the IPv6 routes are still
148        returned when the IPv4 command fails)
149      - IPv6 table: 'unreachable', 'blackhole', ... entries are skipped; the kernel's
150        connected routes (fd00::/64 dev eth0 proto kernel, fe80::/64) have no via, like
151        the IPv4 ones, and the default route of a router advertisement is kept
152        (default via fe80::... proto ra)
153    """
154    output, code = grade.test(machine_name, 'ip route', step=step)
155    if ipv6:
156        output6, code6 = grade.test(machine_name, 'ip -6 route', step=step)
157    result = {} if (code != 0 or not output) else _parse_ip_route(output, False)
158    if ipv6 and code6 == 0 and output6:
159        result.update(_parse_ip_route(output6, True))
160    return result

Run 'ip route' (and 'ip -6 route' with ipv6=True) on machine_name and parse the output.

Returns a dict mapping (network, mask) -> (via, dev, metric).

Edge cases:
  • 'default' route -> key ('0.0.0.0', 0), or ('::', 0) in the IPv6 table
  • host route without mask -> mask 32 (128 in the IPv6 table)
  • direct route (no via) -> via ''
  • no explicit metric -> metric 0
  • non-zero exit code or empty output -> {} (IPv4 table; the IPv6 routes are still returned when the IPv4 command fails)
  • IPv6 table: 'unreachable', 'blackhole', ... entries are skipped; the kernel's connected routes (fd00::/64 dev eth0 proto kernel, fe80::/64) have no via, like the IPv4 ones, and the default route of a router advertisement is kept (default via fe80::... proto ra)
def get_sysctl_conf( grade: SRE.lib_sre.Grade0, machine_name: str, step: int = 1) -> Dict[str, str]:
163def get_sysctl_conf(grade: Grade0, machine_name: str, step: int = 1) -> Dict[str, str]:
164    """Read /etc/sysctl.conf and all files under /etc/sysctl.d/ and parse kernel parameters.
165
166    Returns a dict mapping parameter name -> value (both as strings).
167
168    Parsing rules:
169      - lines starting with '#' or ';' are comments and ignored
170      - blank lines are ignored
171      - accepted formats: 'key = value' and 'key=value'
172      - later definitions override earlier ones (sysctl.d files override sysctl.conf)
173
174    Edge cases:
175      - non-zero exit code from any grade.test() -> {} immediately
176      - first-pass empty output                  -> {} and continues to next call
177    """
178    result: Dict[str, str] = {}
179
180    def _parse(text: str) -> None:
181        for line in text.splitlines():
182            line = line.strip()
183            if not line or line[0] in ('#', ';'):
184                continue
185            if '=' in line:
186                key, _, value = line.partition('=')
187                result[key.strip()] = value.strip()
188
189    output, code = grade.test(machine_name, 'cat /etc/sysctl.conf', step=step)
190    if code != 0:
191        return {}
192    _parse(output)
193
194    output2, code = grade.test(machine_name, 'cat /etc/sysctl.d/*', step=step)
195    if code != 0:
196        return {}
197    _parse(output2)
198
199    return result

Read /etc/sysctl.conf and all files under /etc/sysctl.d/ and parse kernel parameters.

Returns a dict mapping parameter name -> value (both as strings).

Parsing rules:
  • lines starting with '#' or ';' are comments and ignored
  • blank lines are ignored
  • accepted formats: 'key = value' and 'key=value'
  • later definitions override earlier ones (sysctl.d files override sysctl.conf)
Edge cases:
  • non-zero exit code from any grade.test() -> {} immediately
  • first-pass empty output -> {} and continues to next call
def get_net_config_entry( grade: SRE.lib_sre.Grade0, machine_name: str, step: int = 1, ipv6: bool = False) -> List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]:
202def get_net_config_entry(grade: Grade0, machine_name: str, step: int = 1, ipv6: bool = False) -> NetConfigEntry:
203    """Run 'ip a', 'ip route', and 'ip link show' on machine_name and return a NetConfig.
204
205    Each entry in the returned list corresponds to one interface (eth0, eth1, …)
206    in index order.
207
208    Entry types:
209      - Tuple (addresses, routes): statically configured interface.
210      - 'dhcp': interface whose address(es) were all assigned dynamically (DHCP).
211      - None: interface present in the kernel but carrying no IP address.
212
213    The list covers eth0 … ethN where N is the highest eth index reported by
214    'ip link show'.  Interfaces beyond the highest present index are not included.
215
216    ``ipv6=True`` adds the IPv6 addresses (see get_ip_addresses: no link-local, no
217    temporary address) and the routes of the IPv6 table to the same entries, IPv4 first.
218    A SLAAC address counts as dynamic: an interface with a static IPv4 address and a
219    SLAAC IPv6 address is a tuple holding both, an IPv6-only SLAAC interface is 'dhcp'.
220    The default route learnt from a router advertisement has a link-local next hop: it
221    is attached to the interface named by its 'dev'.
222
223    Edge cases:
224      - non-zero exit code or empty output from any command -> []
225      - interfaces whose name does not match eth\\d+  -> ignored
226      - routes with an unknown gateway -> attached to the first interface, or
227        dropped if there are no interfaces
228    """
229    raw_addrs = get_ip_addresses(grade, machine_name, step=step, ipv6=ipv6)
230    raw_routes = get_routes(grade, machine_name, step=step, ipv6=ipv6)
231
232    # Re-use the cached 'ip a' output to detect dynamic (DHCP) addresses.
233    ip_a_out, _ = grade.test(machine_name, 'ip a', step=step)
234
235    # Parse which interfaces have *all* their inet addresses marked dynamic.
236    dynamic_ifaces: set[str] = set()
237    current_iface: str | None = None
238    iface_addr_flags: dict[str, list[bool]] = {}  # iface -> [is_dynamic, ...]
239    addr_line_re = re.compile(r'^\s+inet6?\s+\S+') if ipv6 else re.compile(r'^\s+inet\s+\S+')
240    for line in (ip_a_out or '').splitlines():
241        m = re.match(r'^\d+:\s+(\S+?)(?:@\S+)?:', line)
242        if m:
243            current_iface = m.group(1)
244            iface_addr_flags.setdefault(current_iface, [])
245            continue
246        m = addr_line_re.match(line)
247        if m and current_iface is not None:
248            if ipv6 and line.lstrip().startswith('inet6') and _skip_inet6_line(line, link_local=False):
249                continue
250            iface_addr_flags.setdefault(current_iface, []).append('dynamic' in line)
251    for iface, flags in iface_addr_flags.items():
252        if flags and all(flags):
253            dynamic_ifaces.add(iface)
254
255    # Discover all present eth<N> interfaces via 'ip link show'.
256    ip_link_out, link_code = grade.test(machine_name, 'ip link show', step=step)
257    if link_code != 0 or not ip_link_out:
258        return []
259    present_eths: set[str] = set(
260        re.findall(r'^\d+:\s+(eth\d+)(?:@\S+)?:', ip_link_out, re.MULTILINE)
261    )
262    if not present_eths:
263        return []
264
265    max_n = max(int(name[3:]) for name in present_eths)
266
267    # Build interface → addresses lookup from raw_addrs (only non-empty entries).
268    eth_addrs: dict[str, list[tuple[str, int]]] = {
269        name: addrs
270        for name, addrs in raw_addrs.items()
271        if name.startswith('eth') and name[3:].isdigit() and addrs
272    }
273
274    # Collect static eth interfaces in order (for route distribution).
275    static_eth_ifaces: list[tuple[str, list[IPInterface]]] = []
276    for n in range(max_n + 1):
277        name = f'eth{n}'
278        if name not in present_eths:
279            continue
280        if name in eth_addrs and name not in dynamic_ifaces:
281            ifaces = [ip_interface(f"{addr}/{plen}") for addr, plen in eth_addrs[name]]
282            if ifaces:
283                static_eth_ifaces.append((name, ifaces))
284
285    # Distribute routes among static interfaces (existing logic).
286    iface_networks: list[list[IPNetwork]] = [
287        [iface.network for iface in ifaces]
288        for _, ifaces in static_eth_ifaces
289    ]
290    routes_per_iface: list[list[tuple[IPNetwork, IPAddress]]] = [
291        [] for _ in static_eth_ifaces
292    ]
293    for (net_str, mask), (via, dev, _metric) in raw_routes.items():
294        if not via:
295            continue
296        dest = ip_network(f"{net_str}/{mask}")
297        gw = ip_address(via)
298        matched = None
299        for idx, nets in enumerate(iface_networks):
300            if any(gw in net for net in nets):  # always False across families
301                matched = idx
302                break
303        if matched is None and ipv6:
304            # a router advertisement's default route: link-local next hop, in no configured prefix
305            matched = next((idx for idx, (name, _) in enumerate(static_eth_ifaces) if name == dev), None)
306        if matched is None:
307            matched = 0
308        if routes_per_iface:
309            routes_per_iface[matched].append((dest, gw))
310
311    static_map: dict[str, tuple[list[IPInterface], list[tuple[IPNetwork, IPAddress]]]] = {
312        name: (ifaces, routes_per_iface[idx])
313        for idx, (name, ifaces) in enumerate(static_eth_ifaces)
314    }
315
316    # Assemble final NetConfig list.
317    result: NetConfigEntry = []
318    for n in range(max_n + 1):
319        name = f'eth{n}'
320        if name not in present_eths:
321            result.append(None)
322            continue
323        if name in dynamic_ifaces:
324            result.append('dhcp')
325        elif name in static_map:
326            result.append(static_map[name])
327        else:
328            result.append(None)
329
330    return result

Run 'ip a', 'ip route', and 'ip link show' on machine_name and return a NetConfig.

Each entry in the returned list corresponds to one interface (eth0, eth1, …) in index order.

Entry types:
  • Tuple (addresses, routes): statically configured interface.
  • 'dhcp': interface whose address(es) were all assigned dynamically (DHCP).
  • None: interface present in the kernel but carrying no IP address.

The list covers eth0 … ethN where N is the highest eth index reported by 'ip link show'. Interfaces beyond the highest present index are not included.

ipv6=True adds the IPv6 addresses (see get_ip_addresses: no link-local, no temporary address) and the routes of the IPv6 table to the same entries, IPv4 first. A SLAAC address counts as dynamic: an interface with a static IPv4 address and a SLAAC IPv6 address is a tuple holding both, an IPv6-only SLAAC interface is 'dhcp'. The default route learnt from a router advertisement has a link-local next hop: it is attached to the interface named by its 'dev'.

Edge cases:
  • non-zero exit code or empty output from any command -> []
  • interfaces whose name does not match eth\d+ -> ignored
  • routes with an unknown gateway -> attached to the first interface, or dropped if there are no interfaces
def get_persistent_net_config_entry( grade: SRE.lib_sre.Grade0, machine_name: str, step: int = 1, ipv6: bool = False) -> tuple[typing.List[typing.Union[typing.Tuple[typing.List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], typing.List[typing.Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], typing.Literal['dhcp'], NoneType]], int]:
333def get_persistent_net_config_entry(grade: Grade0, machine_name: str, step: int = 1,
334                                    ipv6: bool = False) -> tuple[NetConfigEntry, int]:
335    """Parse /etc/network/interfaces (+ interfaces.d/) and return (NetConfig, error_count).
336
337    Reconstructs the network configuration that would result from
338    'systemctl networking start' (i.e. ifup -a).
339
340    Entry types in the returned NetConfig:
341      - Tuple (addresses, routes): inet-static stanza for eth<N>.
342      - 'dhcp': inet-dhcp stanza for eth<N>.
343      - None: eth<N> mentioned in an auto/allow-hotplug line but with no stanza.
344
345    Only eth<N> interfaces are included, in index order.
346
347    error_count counts lines that are syntactically wrong or have invalid
348    values (bad IP/network, unknown stanza keywords, malformed iface lines…).
349
350    Parsing rules:
351      - 'address <ip>[/<prefix>]' — primary address; prefix may be absent
352      - 'netmask <mask>'          — applied to primary address when it has no prefix
353      - 'gateway <ip>'            — inserted as a 0.0.0.0/0 route
354      - 'post-up / up ip addr add <addr> dev <iface>' — extra addresses
355      - 'post-up / up ip route add <net> via <gw>'    — static routes
356      - Other post-up/up/down commands are silently ignored
357
358    ``ipv6=False`` (default): 'inet6' stanzas are ignored like 'manual' ones (not parsed,
359    not counted).  ``ipv6=True``: 'iface ethN inet6 static' stanzas are parsed too
360    ('address' with a prefix or 'netmask <prefixlen>', 'gateway' -> ::/0 route, 'post-up ip
361    [-6] route add' / 'ip [-6] addr add'); the IPv4 and IPv6 parts of an interface are merged
362    into one tuple (IPv4 first); 'inet6 dhcp' / 'inet6 auto' count as dynamic, so an
363    interface that is dynamic in both families is 'dhcp'.
364    """
365    out1, code1 = grade.test(machine_name, 'cat /etc/network/interfaces', step=step, allow_error=True)
366    out2, code2 = grade.test(machine_name, 'cat /etc/network/interfaces.d/*', step=step, allow_error=True)
367
368    lines: list[str] = []
369    include_interfaces_d = False
370    if code1 == 0 and out1:
371        lines.extend(out1.splitlines())
372        for raw_line in out1.splitlines():
373            stripped = raw_line.strip()
374            if not stripped or stripped.startswith('#'):
375                continue
376            parts = stripped.split()
377            if (parts[0] in ('source', 'source-directory')
378                    and len(parts) >= 2
379                    and '/etc/network/interfaces.d' in parts[1]):
380                include_interfaces_d = True
381                break
382    if include_interfaces_d and code2 == 0 and out2:
383        lines.extend(out2.splitlines())
384
385    errors = 0
386
387    # Known stanza-level options that ifup accepts — won't be counted as errors.
388    _KNOWN_OPTS = {
389        'address', 'netmask', 'broadcast', 'network', 'gateway',
390        'metric', 'hwaddress', 'mtu', 'scope',
391        'pre-up', 'up', 'post-up', 'down', 'pre-down', 'post-down',
392        'dns-nameservers', 'dns-search', 'dns-domain',
393        'vlan-raw-device', 'bridge_ports', 'bridge_stp', 'bridge_fd',
394        'bond-master', 'bond-slaves', 'bond-mode', 'bond-miimon',
395        'wpa-ssid', 'wpa-psk', 'wpa-conf',
396    }
397    # inet6-specific options (ifupdown's inet6.defn)
398    _KNOWN_OPTS6 = _KNOWN_OPTS | {
399        'accept_ra', 'autoconf', 'privext', 'dad-attempts', 'dad-interval', 'preferred-lifetime',
400        'media',
401    }
402
403    def _new_bucket():
404        return {
405            'primary': None, 'has_prefix': False,
406            'netmask': None, 'extras': [],
407            'routes': [], 'gateway': None,
408        }
409
410    # Accumulate per-interface data keyed by (interface name, family).
411    iface_data: dict[tuple[str, int], dict] = {}
412    dhcp_ifaces: set[str] = set()  # interfaces with 'inet dhcp' stanza
413    dyn6_ifaces: set[str] = set()  # interfaces with 'inet6 dhcp' / 'inet6 auto' stanza (ipv6=True)
414    auto_ifaces: set[str] = set()  # interfaces seen in auto/allow-hotplug lines
415    current: tuple[str, int] | None = None  # (name, family) of the active static stanza
416    in_ignored_stanza: bool = False  # True inside loopback / dhcp / manual stanza
417
418    for raw_line in lines:
419        stripped = raw_line.strip()
420        if not stripped or stripped.startswith('#'):
421            continue
422
423        parts = stripped.split()
424        kw = parts[0]
425
426        if kw == 'iface':
427            if len(parts) < 4:
428                errors += 1
429                current = None
430                in_ignored_stanza = False
431                continue
432            iface_name, family, method = parts[1], parts[2], parts[3]
433            if family == 'inet' and method == 'static':
434                current = (iface_name, 4)
435                in_ignored_stanza = False
436                iface_data.setdefault(current, _new_bucket())
437            elif family == 'inet' and method == 'dhcp':
438                dhcp_ifaces.add(iface_name)
439                current = None
440                in_ignored_stanza = True
441            elif ipv6 and family == 'inet6' and method == 'static':
442                current = (iface_name, 6)
443                in_ignored_stanza = False
444                iface_data.setdefault(current, _new_bucket())
445            elif ipv6 and family == 'inet6' and method in ('dhcp', 'auto'):
446                dyn6_ifaces.add(iface_name)
447                current = None
448                in_ignored_stanza = True
449            else:
450                current = None  # loopback / manual / other — irrelevant
451                in_ignored_stanza = True
452
453        elif kw in ('auto', 'allow-hotplug', 'allow-auto'):
454            for iface_name in parts[1:]:
455                auto_ifaces.add(iface_name)
456
457        elif kw in ('source', 'source-directory', 'mapping',
458                    'no-auto-down', 'no-scripts'):
459            pass  # Valid top-level directives, nothing to record
460
461        else:
462            # Option line within a stanza (indentation not required by ifupdown).
463            # If outside any stanza entirely, count as an error.
464            directive = kw
465
466            if current is None:
467                if not in_ignored_stanza:
468                    errors += 1
469                continue
470
471            data = iface_data[current]
472            fam = current[1]
473            iface_cls = IPv4Interface if fam == 4 else IPv6Interface
474            addr_cls = IPv4Address if fam == 4 else IPv6Address
475
476            if directive == 'address':
477                if len(parts) < 2:
478                    errors += 1
479                else:
480                    try:
481                        iface = iface_cls(parts[1])
482                        data['primary'] = iface
483                        data['has_prefix'] = '/' in parts[1]
484                    except ValueError:
485                        errors += 1
486
487            elif directive == 'netmask':
488                if len(parts) < 2:
489                    errors += 1
490                else:
491                    data['netmask'] = parts[1]
492
493            elif directive == 'gateway':
494                if len(parts) < 2:
495                    errors += 1
496                else:
497                    try:
498                        data['gateway'] = addr_cls(parts[1])
499                    except ValueError:
500                        errors += 1
501
502            elif directive in ('post-up', 'up'):
503                cmd = stripped[len(directive):].strip()
504                if not ipv6:
505                    m = re.match(r'ip\s+route\s+add\s+(\S+)\s+via\s+(\S+)', cmd)
506                    if m:
507                        try:
508                            net = IPv4Network(m.group(1), strict=False)
509                            gw = IPv4Address(m.group(2))
510                            data['routes'].append((net, gw))
511                        except ValueError:
512                            errors += 1
513                        continue
514                    m = re.match(r'ip\s+addr\s+add\s+(\S+)\s+dev\s+\S+', cmd)
515                    if m:
516                        try:
517                            data['extras'].append(IPv4Interface(m.group(1)))
518                        except ValueError:
519                            errors += 1
520                else:
521                    m = re.match(r'ip\s+(?:-[46]\s+)?route\s+add\s+(\S+)\s+via\s+(\S+)', cmd)
522                    if m:
523                        try:
524                            net = ip_network(m.group(1), strict=False)
525                            gw = ip_address(m.group(2))
526                            if net.version != gw.version:
527                                raise ValueError("route and next hop of different families")
528                            data['routes'].append((net, gw))
529                        except ValueError:
530                            errors += 1
531                        continue
532                    m = re.match(r'ip\s+(?:-[46]\s+)?addr\s+add\s+(\S+)\s+dev\s+\S+', cmd)
533                    if m:
534                        try:
535                            data['extras'].append(ip_interface(m.group(1)))
536                        except ValueError:
537                            errors += 1
538                # Other post-up / up commands (iptables, arp…) are not errors
539
540            elif directive not in (_KNOWN_OPTS6 if fam == 6 else _KNOWN_OPTS):
541                errors += 1
542
543    # Collect all eth<N> interface names we know about, in index order.
544    all_known = (
545            {name for name, _ in iface_data} | dhcp_ifaces | dyn6_ifaces |
546            {n for n in auto_ifaces if n.startswith('eth') and n[3:].isdigit()}
547    )
548    eth_names = sorted(
549        [n for n in all_known if n.startswith('eth') and n[3:].isdigit()],
550        key=lambda n: int(n[3:])
551    )
552
553    def _finalize(data: dict, fam: int):
554        """(addresses, routes) of one family bucket, or None without a primary address."""
555        nonlocal errors
556        primary = data['primary']
557        if primary is None:
558            return None
559
560        # Combine bare IP with netmask when no CIDR prefix was given.
561        if not data['has_prefix'] and data['netmask']:
562            try:
563                if fam == 4:
564                    prefix = IPv4Network(f'0.0.0.0/{data["netmask"]}').prefixlen
565                    primary = IPv4Interface(f'{primary.ip}/{prefix}')
566                else:
567                    prefix = int(data['netmask'])
568                    primary = IPv6Interface(f'{primary.ip}/{prefix}')  # ValueError beyond 128
569            except ValueError:
570                errors += 1
571
572        addresses = [primary] + data['extras']
573
574        routes = list(data['routes'])
575        if data['gateway']:
576            routes.insert(0, (_DEFAULT_NET_BY_VERSION[fam], data['gateway']))
577
578        return addresses, routes
579
580    result: NetConfigEntry = []
581    for name in eth_names:
582        if not ipv6:
583            if name in dhcp_ifaces:
584                result.append('dhcp')
585            elif (name, 4) not in iface_data:
586                # Mentioned in auto but no inet stanza — unconfigured.
587                result.append(None)
588            else:
589                result.append(_finalize(iface_data[(name, 4)], 4))
590            continue
591
592        v4 = None if (name in dhcp_ifaces or (name, 4) not in iface_data) else _finalize(iface_data[(name, 4)], 4)
593        v6 = None if (name in dyn6_ifaces or (name, 6) not in iface_data) else _finalize(iface_data[(name, 6)], 6)
594        if v4 is None and v6 is None:
595            result.append('dhcp' if (name in dhcp_ifaces or name in dyn6_ifaces) else None)
596        else:
597            addresses = (v4[0] if v4 else []) + (v6[0] if v6 else [])
598            routes = (v4[1] if v4 else []) + (v6[1] if v6 else [])
599            result.append((addresses, routes))
600
601    return result, errors

Parse /etc/network/interfaces (+ interfaces.d/) and return (NetConfig, error_count).

Reconstructs the network configuration that would result from 'systemctl networking start' (i.e. ifup -a).

Entry types in the returned NetConfig:
  • Tuple (addresses, routes): inet-static stanza for eth.
  • 'dhcp': inet-dhcp stanza for eth.
  • None: eth mentioned in an auto/allow-hotplug line but with no stanza.

Only eth interfaces are included, in index order.

error_count counts lines that are syntactically wrong or have invalid values (bad IP/network, unknown stanza keywords, malformed iface lines…).

Parsing rules:
  • 'address [/]' — primary address; prefix may be absent
  • 'netmask ' — applied to primary address when it has no prefix
  • 'gateway ' — inserted as a 0.0.0.0/0 route
  • 'post-up / up ip addr add dev ' — extra addresses
  • 'post-up / up ip route add via ' — static routes
  • Other post-up/up/down commands are silently ignored

ipv6=False (default): 'inet6' stanzas are ignored like 'manual' ones (not parsed, not counted). ipv6=True: 'iface ethN inet6 static' stanzas are parsed too ('address' with a prefix or 'netmask ', 'gateway' -> ::/0 route, 'post-up ip [-6] route add' / 'ip [-6] addr add'); the IPv4 and IPv6 parts of an interface are merged into one tuple (IPv4 first); 'inet6 dhcp' / 'inet6 auto' count as dynamic, so an interface that is dynamic in both families is 'dhcp'.

def net_config_entry_family( nc_entry: List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]], version: int) -> List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]:
608def net_config_entry_family(nc_entry: NetConfigEntry, version: int) -> NetConfigEntry:
609    """Keep only the addresses and routes of IP *version* (4 or 6) in each tuple of
610    *nc_entry*; 'dhcp' and None entries are returned unchanged.  Use it to grade the two
611    families of a dual-stack configuration separately with eval_net_config()."""
612    result: NetConfigEntry = []
613    for entry in nc_entry:
614        if not isinstance(entry, tuple):
615            result.append(entry)
616            continue
617        ifaces, routes = entry
618        result.append(([i for i in ifaces if i.version == version],
619                       [(net, gw) for net, gw in routes if net.version == version]))
620    return result

Keep only the addresses and routes of IP version (4 or 6) in each tuple of nc_entry; 'dhcp' and None entries are returned unchanged. Use it to grade the two families of a dual-stack configuration separately with eval_net_config().

def eval_net_config( grade: SRE.lib_sre.Grade0, expected: List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]], machine_name: str = None, current: Optional[List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]] = None, step: int = 1) -> lib.net_config._AttrDict:
623def eval_net_config(grade: Grade0, expected: NetConfigEntry, machine_name: str = None,
624                    current: NetConfigEntry | None = None, step: int = 1) -> _AttrDict:
625    """Compare current and expected NetConfig; return a dict with 10 keys:
626
627    Existing keys (unchanged semantics — 'dhcp' and None entries are skipped):
628    - "ips"                   : number of IP addresses that are correct
629    - "default_route"         : 1 if the default route matches the expected one, 0 otherwise
630    - "other_routes"          : number of correct non-default static routes
631    - "wrong_routes"          : number of non-default static routes present in current but not in expected
632    - "ips_expected"          : number of IP addresses in expected
633    - "default_route_expected": 1 if expected has a default route, 0 otherwise
634    - "other_routes_expected" : number of non-default static routes in expected
635
636    New keys for DHCP/None tracking:
637    - "dhcp_interfaces"         : positions where both expected and current are 'dhcp'
638    - "dhcp_interfaces_expected": count of 'dhcp' entries in expected
639    - "none_interfaces_expected": count of None entries in expected
640
641    If *current* is None, get_net_config() is called first.
642    IP addresses are compared as interface strings (address + prefix), IPv4 and IPv6 alike.
643    Routes are compared as (network, gateway) pairs; 0.0.0.0/0 and ::/0 are both default
644    routes ("default_route" is 1 only when every expected default route is present and no
645    other one is: split the families with net_config_entry_family() to grade them apart).
646    """
647    if current is None:
648        if machine_name is None:
649            raise ValueError("eval_net_config: current and machine_name can't be both None")
650        current = get_net_config_entry(grade, machine_name, step=step)
651
652    def _collect_ips(nc: NetConfigEntry) -> set[str]:
653        result = set()
654        for entry in nc:
655            if not isinstance(entry, tuple):
656                continue
657            ifaces, _ = entry
658            for iface in ifaces:
659                result.add(str(iface))
660        return result
661
662    def _collect_routes(nc: NetConfigEntry) -> set[tuple[str, str]]:
663        result = set()
664        for entry in nc:
665            if not isinstance(entry, tuple):
666                continue
667            _, routes = entry
668            for net, gw in routes:
669                if gw is None:
670                    gw_str = ''
671                elif isinstance(gw, _IFACE_TYPES):
672                    gw_str = str(gw.ip)
673                else:
674                    gw_str = str(gw)
675                result.add((str(net), gw_str))
676        return result
677
678    exp_ips = _collect_ips(expected)
679    cur_ips = _collect_ips(current)
680
681    exp_routes = _collect_routes(expected)
682    cur_routes = _collect_routes(current)
683
684    exp_default = {r for r in exp_routes if r[0] in _DEFAULT_NETS}
685    cur_default = {r for r in cur_routes if r[0] in _DEFAULT_NETS}
686    exp_non_default = {r for r in exp_routes if r[0] not in _DEFAULT_NETS}
687    cur_non_default = {r for r in cur_routes if r[0] not in _DEFAULT_NETS}
688
689    dhcp_match = sum(
690        1 for e, c in zip(expected, current)
691        if e == 'dhcp' and c == 'dhcp'
692    )
693
694    return _AttrDict(
695        ips=len(exp_ips & cur_ips),
696        default_route=1 if exp_default == cur_default else 0,
697        other_routes=len(exp_non_default & cur_non_default),
698        wrong_routes=len(cur_non_default - exp_non_default),
699        ips_expected=len(exp_ips),
700        default_route_expected=1 if exp_default else 0,
701        other_routes_expected=len(exp_non_default),
702        dhcp_interfaces=dhcp_match,
703        dhcp_interfaces_expected=sum(1 for e in expected if e == 'dhcp'),
704        none_interfaces_expected=sum(1 for e in expected if e is None),
705    )

Compare current and expected NetConfig; return a dict with 10 keys:

Existing keys (unchanged semantics — 'dhcp' and None entries are skipped):

  • "ips" : number of IP addresses that are correct
  • "default_route" : 1 if the default route matches the expected one, 0 otherwise
  • "other_routes" : number of correct non-default static routes
  • "wrong_routes" : number of non-default static routes present in current but not in expected
  • "ips_expected" : number of IP addresses in expected
  • "default_route_expected": 1 if expected has a default route, 0 otherwise
  • "other_routes_expected" : number of non-default static routes in expected

New keys for DHCP/None tracking:

  • "dhcp_interfaces" : positions where both expected and current are 'dhcp'
  • "dhcp_interfaces_expected": count of 'dhcp' entries in expected
  • "none_interfaces_expected": count of None entries in expected

If current is None, get_net_config() is called first. IP addresses are compared as interface strings (address + prefix), IPv4 and IPv6 alike. Routes are compared as (network, gateway) pairs; 0.0.0.0/0 and ::/0 are both default routes ("default_route" is 1 only when every expected default route is present and no other one is: split the families with net_config_entry_family() to grade them apart).

def render_persistent_net_config_entry( nc_entry: List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]], machine_name: str = '') -> str:
744def render_persistent_net_config_entry(nc_entry: NetConfigEntry, machine_name: str = '') -> str:
745    """Content of /etc/network/interfaces for *nc_entry* (*machine_name* only names the machine
746    in the error raised for an interface without address).
747
748    One 'inet static' stanza per interface with IPv4 addresses and one 'inet6 static' stanza
749    per interface with IPv6 addresses (both for a dual-stack entry): the first address goes
750    to 'address', the others to 'post-up ip addr add', the default route to 'gateway' and
751    the other routes to 'post-up ip [-6] route add'.
752    """
753    lines = ['auto lo', 'iface lo inet loopback', '']
754    for i, entry in enumerate(nc_entry):
755        interface = f'eth{i}'
756        if entry is None:
757            continue
758        if entry == 'dhcp':
759            lines += [
760                f'auto {interface}',
761                f'iface {interface} inet dhcp',
762                '',
763            ]
764            continue
765        iface_list, routes = entry
766        addresses = {fam: [a for a in iface_list if a.version == fam] for fam in (4, 6)}
767        fam_routes = {fam: [(net, via) for net, via in routes if net.version == fam] for fam in (4, 6)}
768        families = [fam for fam in (4, 6) if addresses[fam]]
769        if not families:
770            raise ValueError(f"set_persistent_net_config_entry: {machine_name} {interface} has no address")
771        lines.append(f'auto {interface}')
772        for fam in families:
773            other = 6 if fam == 4 else 4
774            extra_routes = fam_routes[other] if other not in families else []
775            lines += _static_stanza(interface, fam, addresses[fam], fam_routes[fam], extra_routes)
776            lines.append('')
777    return '\n'.join(lines)

Content of /etc/network/interfaces for nc_entry (machine_name only names the machine in the error raised for an interface without address).

One 'inet static' stanza per interface with IPv4 addresses and one 'inet6 static' stanza per interface with IPv6 addresses (both for a dual-stack entry): the first address goes to 'address', the others to 'post-up ip addr add', the default route to 'gateway' and the other routes to 'post-up ip [-6] route add'.

def set_persistent_net_config_entry( net_scheme: SRE.lib_sre.NetScheme0, machine_name: str, nc_entry: List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]):
780def set_persistent_net_config_entry(net_scheme: NetScheme0, machine_name: str, nc_entry: NetConfigEntry):
781    """Write /etc/network/interfaces on *machine_name* from *nc_entry* (content:
782    render_persistent_net_config_entry())."""
783    net_scheme.file(machine_name, '/etc/network/interfaces',
784                    render_persistent_net_config_entry(nc_entry, machine_name))

Write /etc/network/interfaces on machine_name from nc_entry (content: render_persistent_net_config_entry()).

def set_net_config_entry( net_scheme: SRE.lib_sre.NetScheme0, machine_name: str, nc_entry: List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]):
787def set_net_config_entry(net_scheme: NetScheme0, machine_name: str, nc_entry: NetConfigEntry):
788    """Apply *nc_entry* on *machine_name* with 'ip link / ip addr add / ip route add' commands
789    ('dhclient' for a 'dhcp' entry).  IPv4 and IPv6 addresses and routes are handled alike
790    ('ip' infers the family); an IPv6 route through a link-local next hop gets 'dev ethN'."""
791    for i, entry in enumerate(nc_entry):
792        interface = f'eth{i}'
793        if entry is None:
794            continue
795        if entry == 'dhcp':
796            net_scheme.cmd(machine_name, f'ip link set {interface} up')
797            net_scheme.cmd(machine_name, f'dhclient {interface}')
798            continue
799        iface_list, routes = entry
800        net_scheme.cmd(machine_name, f'ip link set {interface} up')
801        for iface in iface_list:
802            net_scheme.cmd(machine_name, f'ip addr add {iface} dev {interface}')
803        for net, via in routes:
804            via_addr = _gw_ip(via)
805            command = f'ip route add {net.compressed} via {str(via_addr)}'
806            if net.version == 6 and via_addr.is_link_local:
807                command += f' dev {interface}'
808            net_scheme.cmd(machine_name, command)

Apply nc_entry on machine_name with 'ip link / ip addr add / ip route add' commands ('dhclient' for a 'dhcp' entry). IPv4 and IPv6 addresses and routes are handled alike ('ip' infers the family); an IPv6 route through a link-local next hop gets 'dev ethN'.

def set_persistent_sysctl( net_scheme: SRE.lib_sre.NetScheme0, machine_name: str, sysctl_config: Dict[str, Any]):
811def set_persistent_sysctl(net_scheme: NetScheme0, machine_name: str, sysctl_config: SysctlConfig):
812    for name, value in sysctl_config.items():
813        net_scheme.cmd(machine_name, f'echo "{name}={value}" >> /etc/sysctl.conf')
814    if sysctl_config:
815        remount_proc_sys(net_scheme, machine_name)
816        net_scheme.cmd(machine_name, 'sysctl -p')
REMOUNT_PROC_SYS_CMD = 'test -w /proc/sys/net/ipv4/ip_forward || mount -o rw,remount /proc/sys'
def remount_proc_sys(net_scheme: SRE.lib_sre.NetScheme0, machine_name: str):
827def remount_proc_sys(net_scheme: NetScheme0, machine_name: str):
828    """Make ``/proc/sys`` writable on *machine_name* when it is not (once per machine and
829    NetScheme instance): ``REMOUNT_PROC_SYS_CMD``, a no-op on a privileged machine."""
830    if not hasattr(net_scheme, "remount_proc_sys_done"):
831        net_scheme.remount_proc_sys_done = {}
832    if net_scheme.remount_proc_sys_done.get(machine_name):
833        return
834    net_scheme.cmd(machine_name, REMOUNT_PROC_SYS_CMD)
835    net_scheme.remount_proc_sys_done[machine_name] = True

Make /proc/sys writable on machine_name when it is not (once per machine and NetScheme instance): REMOUNT_PROC_SYS_CMD, a no-op on a privileged machine.

def set_sysctl( net_scheme: SRE.lib_sre.NetScheme0, machine_name: str, sysctl_config: Dict[str, Any]):
838def set_sysctl(net_scheme: NetScheme0, machine_name: str, sysctl_config: SysctlConfig):
839    if len(sysctl_config) > 0:
840        remount_proc_sys(net_scheme, machine_name)
841        for name, value in sysctl_config.items():
842            net_scheme.cmd(machine_name, f'sysctl -w {name}={value}')
def set_ip_forward( net_scheme: SRE.lib_sre.NetScheme0, machine_name: str, ip_forward: bool, step: int = 1):
845def set_ip_forward(net_scheme: NetScheme0, machine_name: str, ip_forward: bool, step: int = 1):
846    value = 1 if ip_forward else 0
847    remount_proc_sys(net_scheme, machine_name)
848    net_scheme.cmd(machine_name, f'sysctl -w net.ipv4.ip_forward={value}', step=step)
def get_ip_forward(grade: SRE.lib_sre.Grade0, machine_name: str, step: int = 1) -> bool:
851def get_ip_forward(grade: Grade0, machine_name: str, step: int = 1) -> bool:
852    output, code = grade.test(machine_name, 'cat /proc/sys/net/ipv4/ip_forward', step=step)
853    if code != 0:
854        return False
855    return output.strip() == '1'
def set_ipv6_forward( net_scheme: SRE.lib_sre.NetScheme0, machine_name: str, ipv6_forward: bool, step: int = 1):
858def set_ipv6_forward(net_scheme: NetScheme0, machine_name: str, ipv6_forward: bool, step: int = 1):
859    """Set IPv6 forwarding on *machine_name* (``net.ipv6.conf.all.forwarding``, which the kernel
860    propagates to every interface).  Kathara starts the machines of an IPv6 lab with forwarding
861    enabled: call it with ``False`` on the hosts, as set_ip_forward() for IPv4."""
862    value = 1 if ipv6_forward else 0
863    remount_proc_sys(net_scheme, machine_name)
864    net_scheme.cmd(machine_name, f'sysctl -w net.ipv6.conf.all.forwarding={value}', step=step)

Set IPv6 forwarding on machine_name (net.ipv6.conf.all.forwarding, which the kernel propagates to every interface). Kathara starts the machines of an IPv6 lab with forwarding enabled: call it with False on the hosts, as set_ip_forward() for IPv4.

def get_ipv6_forward(grade: SRE.lib_sre.Grade0, machine_name: str, step: int = 1) -> bool:
867def get_ipv6_forward(grade: Grade0, machine_name: str, step: int = 1) -> bool:
868    """IPv6 forwarding state of *machine_name* (``/proc/sys/net/ipv6/conf/all/forwarding``)."""
869    output, code = grade.test(machine_name, 'cat /proc/sys/net/ipv6/conf/all/forwarding', step=step)
870    if code != 0:
871        return False
872    return output.strip() == '1'

IPv6 forwarding state of machine_name (/proc/sys/net/ipv6/conf/all/forwarding).

def get_sys_parameter_bool( grade: SRE.lib_sre.Grade0, machine_name: str, parameter: str, step: int = 1) -> bool | None:
875def get_sys_parameter_bool(grade: Grade0, machine_name: str, parameter: str, step: int = 1) -> bool | None:
876    """Read a boolean kernel parameter via sysctl and return its value.
877
878    Returns True for '1', False for '0', None if the parameter does not exist or the command fails.
879    """
880    value = get_sys_parameter(grade, machine_name, parameter, step=step)
881    if value is None:
882        return None
883    if value == '1':
884        return True
885    if value == '0':
886        return False
887    return None

Read a boolean kernel parameter via sysctl and return its value.

Returns True for '1', False for '0', None if the parameter does not exist or the command fails.

def get_net_config_from_topology( net_scheme: SRE.lib_sre.NetScheme0, topology=None, gateway: str = None, default_route: ipaddress.IPv4Address | ipaddress.IPv4Interface = IPv4Interface('172.17.0.1/24'), *, ipv4: bool = True, ipv6: bool = False, default_route6: ipaddress.IPv6Address | ipaddress.IPv6Interface | None = None) -> Dict[str, List[Union[Tuple[List[ipaddress.IPv4Interface | ipaddress.IPv6Interface], List[Tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Interface | ipaddress.IPv6Interface]]], Literal['dhcp'], NoneType]]]:
 890def get_net_config_from_topology(
 891        net_scheme: NetScheme0,
 892        topology=None,
 893        gateway: str = None,
 894        default_route: IPv4Address | IPv4Interface = IPv4Interface("172.17.0.1/24"),
 895        *,
 896        ipv4: bool = True,
 897        ipv6: bool = False,
 898        default_route6: IPv6Address | IPv6Interface | None = None,
 899) -> NetConfig:
 900    """Build a NetConfig for every machine in the topology so they can all reach each other.
 901
 902    Returns {machine_name: NetConfig}.
 903
 904    Each machine gets:
 905    - One interface entry per network it belongs to, with the IP from net_scheme.data.ips.
 906    - Static routes to every non-directly-connected network (found via BFS through
 907      multi-homed router machines).
 908
 909    If *gateway* is not None:
 910    - Every machine except the gateway gets a default route (0.0.0.0/0) toward the
 911      gateway machine via the appropriate next-hop IP.
 912    - The gateway itself gets a default route via *default_route* (typically the
 913      Docker host bridge, e.g. 172.17.0.1, reachable when the gateway is ``bridged=True``);
 914      ``default_route=None`` gives it none.
 915
 916    The interface indices (eth0, eth1, …) are computed with the same counter logic as
 917    NetScheme0.__init__, so they match the actual Kathara deployment.
 918
 919    IP names follow the convention set by random_ips_from_topology():
 920    - data.ips.m       when machine m is in exactly one network
 921    - data.ips.m_netX  when machine m is in multiple networks
 922
 923    IPv6: ``ipv6=True`` adds, in the same entries, the IPv6 addresses of ``data.ips6`` and
 924    routes to the IPv6 prefixes of ``data.nets6`` (same naming).  A network without a prefix
 925    in ``data.nets6``, or a machine without an address in ``data.ips6`` for a network (a
 926    SLAAC host, an IPv4-only host), simply has no IPv6 part there (no address, no IPv6
 927    route through it).  The gateway gets a ``::/0`` route only when *default_route6* is
 928    given (the Docker bridge has no IPv6 by default).  ``ipv4=False`` builds an IPv6-only
 929    configuration (``data.ips6`` / ``data.nets6`` are then mandatory like their IPv4
 930    counterparts).
 931    """
 932    if not ipv4 and not ipv6:
 933        raise ValueError("get_net_config_from_topology: at least one of ipv4/ipv6 must be True")
 934    if topology is None:
 935        topology = net_scheme.get_topology()
 936    data = net_scheme.data
 937    families = [fam for fam, enabled in ((4, ipv4), (6, ipv6)) if enabled]
 938    ips_containers = {4: data.ips, 6: getattr(data, 'ips6', None)}
 939    nets_containers = {4: data.nets, 6: getattr(data, 'nets6', None)}
 940
 941    # --- 1. Replicate NetScheme0 eth-index assignment ---
 942    # machine_iface: {machine: {net_name: eth_idx}}
 943    machine_iface: dict[str, dict[str, int]] = {}
 944    _ctr: dict[str, int] = {}
 945    for net_name, machines in topology.items():
 946        items = list(machines.items()) if isinstance(machines, dict) else [(m, None) for m in machines]
 947        for mname, iface_spec in items:
 948            iface = iface_spec[0] if isinstance(iface_spec, tuple) else iface_spec
 949            if iface is None:
 950                iface = _ctr.get(mname, 0)
 951            _ctr[mname] = max(_ctr.get(mname, 0), iface) + 1
 952            machine_iface.setdefault(mname, {})[net_name] = iface
 953
 954    machine_nets: dict[str, list[str]] = {m: list(d.keys()) for m, d in machine_iface.items()}
 955
 956    # --- 2. IP lookup (matches random_ips_from_topology naming) ---
 957    # IPv4 (or the only family): a missing entry is an error (AttributeError), as before.
 958    # IPv6 next to IPv4: a missing entry means "no IPv6 there" (SLAAC or IPv4-only host).
 959    def get_ip(machine: str, net_name: str, fam: int = 4) -> IPInterface | None:
 960        container = ips_containers[fam]
 961        attr = machine if len(machine_nets[machine]) == 1 else f"{machine}_{net_name}"
 962        if fam == 6 and ipv4:
 963            return getattr(container, attr, None)
 964        return getattr(container, attr)
 965
 966    def net_has(net_name: str, fam: int) -> bool:
 967        """Whether *net_name* has a prefix of family *fam* (the prefix of the only family is
 968        mandatory, as before)."""
 969        if fam == 4 or not ipv4:
 970            return True
 971        return nets_containers[6] is not None and hasattr(nets_containers[6], net_name)
 972
 973    # --- 3. Network routing graph ---
 974    # Multi-homed machines act as routers; each pair of their networks is an edge.
 975    net_graph: dict[str, list[tuple[str, str]]] = {n: [] for n in topology}
 976    for mname, nets in machine_nets.items():
 977        for i, n1 in enumerate(nets):
 978            for n2 in nets[i + 1:]:
 979                net_graph[n1].append((n2, mname))
 980                net_graph[n2].append((n1, mname))
 981
 982    # --- 4. BFS: find (via_start_net, first_router) from start_nets to a target ---
 983    def _bfs(start_nets: set[str], is_target) -> tuple[str, str] | None:
 984        visited = set(start_nets)
 985        q: deque = deque()
 986        for n in start_nets:
 987            for adj, router in net_graph.get(n, []):
 988                if adj not in visited:
 989                    visited.add(adj)
 990                    q.append((adj, n, router))
 991        while q:
 992            cur, via, router = q.popleft()
 993            if is_target(cur):
 994                return (via, router)
 995            for adj, r in net_graph.get(cur, []):
 996                if adj not in visited:
 997                    visited.add(adj)
 998                    q.append((adj, via, router))
 999        return None
1000
1001    def first_hop_to_net(start_nets: set[str], target_net: str) -> tuple[str, str] | None:
1002        """Return (via_net, router) for the first hop to reach target_net, or None if direct."""
1003        if target_net in start_nets:
1004            return None
1005        return _bfs(start_nets, lambda n: n == target_net)
1006
1007    def first_hop_to_machine(start_nets: set[str], target: str) -> tuple[str, str] | None:
1008        """Return (via_net, router) for the first hop toward target machine.
1009        When directly connected, router is the target machine itself."""
1010        target_nets = set(machine_nets.get(target, []))
1011        shared = start_nets & target_nets
1012        if shared:
1013            return (next(iter(shared)), target)
1014        return _bfs(start_nets, lambda n: n in target_nets)
1015
1016    # --- 5. external default routes as bare addresses (None: no external default route) ---
1017    external_hop = {
1018        4: None if default_route is None else _gw_ip(default_route),
1019        6: None if default_route6 is None else _gw_ip(default_route6),
1020    }
1021
1022    # --- 6. Assemble NetConfig per machine ---
1023    result: dict[str, NetConfigEntry] = {}
1024
1025    for machine, net_to_eth in machine_iface.items():
1026        start_nets = set(machine_nets[machine])
1027
1028        # Collect all candidate routes as (net, eth_idx, next_hop).
1029        route_triples: list[tuple[IPNetwork, int, IPAddress]] = []
1030
1031        for fam in families:
1032            if not any(net_has(n, fam) and get_ip(machine, n, fam) is not None for n in start_nets):
1033                continue  # no address of this family on the machine: no route of this family
1034
1035            # Specific routes to every non-directly-connected network
1036            for target_net in topology:
1037                if target_net in start_nets or not net_has(target_net, fam):
1038                    continue
1039                hop = first_hop_to_net(start_nets, target_net)
1040                if hop is None:
1041                    continue
1042                via_net, router = hop
1043                if not net_has(via_net, fam):
1044                    continue
1045                next_hop = get_ip(router, via_net, fam)
1046                if next_hop is None:
1047                    continue
1048                route_triples.append((
1049                    getattr(nets_containers[fam], target_net),
1050                    net_to_eth[via_net],
1051                    next_hop.ip,
1052                ))
1053
1054            # Default route
1055            if gateway is not None:
1056                default_net = _DEFAULT_NET_BY_VERSION[fam]
1057                if machine == gateway:
1058                    # External default route: on the interface of the network holding the hop when
1059                    # it is a lab network (a machine standing for the outside world), else on the
1060                    # lowest-index interface (the Docker bridge of a bridged gateway)
1061                    if external_hop[fam] is not None:
1062                        hop = external_hop[fam]
1063                        eth = next((net_to_eth[n] for n in sorted(start_nets, key=net_to_eth.get)
1064                                    if net_has(n, fam) and hop in getattr(nets_containers[fam], n)),
1065                                   min(net_to_eth.values()))
1066                        route_triples.append((default_net, eth, hop))
1067                else:
1068                    hop = first_hop_to_machine(start_nets, gateway)
1069                    if hop is not None:
1070                        via_net, router = hop
1071                        next_hop = get_ip(router, via_net, fam) if net_has(via_net, fam) else None
1072                        if next_hop is not None:
1073                            route_triples.append((
1074                                default_net,
1075                                net_to_eth[via_net],
1076                                next_hop.ip,
1077                            ))
1078
1079        # Drop redundant routes: a route (net, hop) is redundant when another route
1080        # (net2, hop2) exists with the same next-hop and net2 is a strict supernet of net
1081        # (i.e. net2 covers net entirely — the less-specific route already handles it).
1082        def _is_redundant(net: IPNetwork, hop: IPAddress) -> bool:
1083            return any(
1084                net2 != net and net2.version == net.version and net.subnet_of(net2) and hop == hop2
1085                for net2, _, hop2 in route_triples
1086            )
1087
1088        eth_routes: dict[int, list] = {eth: [] for eth in net_to_eth.values()}
1089        for net, eth, hop in route_triples:
1090            if not _is_redundant(net, hop):
1091                eth_routes[eth].append((net, hop))
1092
1093        # Build the NetConfig list indexed by eth number
1094        max_eth = max(net_to_eth.values())
1095        nc: NetConfigEntry = [None] * (max_eth + 1)
1096        for net_name, eth_idx in net_to_eth.items():
1097            addresses = [ip for fam in families if net_has(net_name, fam)
1098                         for ip in [get_ip(machine, net_name, fam)] if ip is not None]
1099            nc[eth_idx] = (addresses, eth_routes[eth_idx])
1100        result[machine] = nc
1101
1102    return result

Build a NetConfig for every machine in the topology so they can all reach each other.

Returns {machine_name: NetConfig}.

Each machine gets:

  • One interface entry per network it belongs to, with the IP from net_scheme.data.ips.
  • Static routes to every non-directly-connected network (found via BFS through multi-homed router machines).

If gateway is not None:

  • Every machine except the gateway gets a default route (0.0.0.0/0) toward the gateway machine via the appropriate next-hop IP.
  • The gateway itself gets a default route via default_route (typically the Docker host bridge, e.g. 172.17.0.1, reachable when the gateway is bridged=True); default_route=None gives it none.

The interface indices (eth0, eth1, …) are computed with the same counter logic as NetScheme0.__init__, so they match the actual Kathara deployment.

IP names follow the convention set by random_ips_from_topology():

  • data.ips.m when machine m is in exactly one network
  • data.ips.m_netX when machine m is in multiple networks

IPv6: ipv6=True adds, in the same entries, the IPv6 addresses of data.ips6 and routes to the IPv6 prefixes of data.nets6 (same naming). A network without a prefix in data.nets6, or a machine without an address in data.ips6 for a network (a SLAAC host, an IPv4-only host), simply has no IPv6 part there (no address, no IPv6 route through it). The gateway gets a ::/0 route only when default_route6 is given (the Docker bridge has no IPv6 by default). ipv4=False builds an IPv6-only configuration (data.ips6 / data.nets6 are then mandatory like their IPv4 counterparts).

def get_sys_parameter( grade: SRE.lib_sre.Grade0, machine_name: str, parameter: str, step: int = 1) -> str | None:
1105def get_sys_parameter(grade: Grade0, machine_name: str, parameter: str, step: int = 1) -> str | None:
1106    """Read a kernel parameter via sysctl and return its value as a string.
1107
1108    Returns None if the parameter does not exist or the command fails.
1109    """
1110    output, code = grade.test(machine_name, f'sysctl -n {parameter}', step=step)
1111    if code != 0:
1112        return None
1113    return output.strip() or None

Read a kernel parameter via sysctl and return its value as a string.

Returns None if the parameter does not exist or the command fails.