summaryrefslogtreecommitdiff
path: root/src/system/vyos-net-name-resolve.py
blob: e28f3b3c43ed9821c79e5ea2cc3d4ebcae7a0b53 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
#!/usr/bin/env python3
#
# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# Single authoritative pass that enforces "interfaces/{ethernet,wireless}/
# */hw-id" from config.boot onto the live system. It runs once, after udev
# has settled, and decides names from a fully-formed view of the hardware
# instead of reacting to individual "add" uevents.
#
# The old udev-time approach (src/udev/vyos_net_name, invoked straight from
# a udev rule) decided the final, hw-id based name from a single sysfs
# snapshot taken at the moment one interface's "add" event was evaluated.
# On systems with several different NIC vendors/drivers that snapshot is
# unreliable: the DRIVERS attribute walk-up and the interface's permanent
# MAC address are not guaranteed to be populated yet, and neither gets a
# second chance since each uevent is only evaluated once. This script
# sidesteps that by re-reading live state after hardware has had time to
# settle, and by verifying/repairing the outcome rather than guessing once.

import json
import logging
import logging.handlers
import re
import tempfile
import time
from pathlib import Path
from sys import exit

from vyos.configtree import ConfigTree
from vyos.defaults import directories
from vyos.migrate import ConfigMigrate
from vyos.system.image import is_running_as_container
from vyos.utils.process import rc_cmd
from vyos.utils.process import run

# Bounded wait for all configured hw-id hardware to appear. NICs needing a
# longer firmware/link-training window are rare enough that this has not
# been exposed as a CLI knob yet.
HARDWARE_WAIT_TIMEOUT = 10
HARDWARE_WAIT_POLL = 0.25

# Bounded wait for hardware without a configured hw-id to settle before
# bootstrap-naming it. There is no specific MAC to wait for here, so
# "N consecutive identical snapshots" stands in for "hardware has stopped
# registering/renaming interfaces".
HARDWARE_SETTLE_TIMEOUT = 10
HARDWARE_SETTLE_STABLE_POLLS = 3

config_path = '/opt/vyatta/etc/config/config.boot'
vyos_udev_dir = directories['vyos_udev_dir']
status_file = Path('/run/vyos-net-name-resolve.json')

logger = logging.getLogger()


def _load_config_boot() -> ConfigTree:
    """Parse config.boot into a ConfigTree, migrating stale syntax if
    needed. Returns None if there is no persisted config yet (livecd/ISO).
    """
    if not Path(config_path).is_file():
        return None

    try:
        config_file = Path(config_path).read_text()
    except OSError as e:
        logger.critical(f'OSError {e}')
        exit(1)

    try:
        return ConfigTree(config_file)
    except Exception:
        try:
            logger.debug('updating component version string syntax')
            # this will update the component version string syntax,
            # required for updates 1.2 --> 1.3/1.4
            with tempfile.NamedTemporaryFile() as fp:
                Path(fp.name).write_text(config_file)
                config_migrate = ConfigMigrate(fp.name)
                if config_migrate.syntax_update_needed():
                    config_migrate.update_syntax()
                    config_migrate.write_config()
                config_file = Path(fp.name).read_text()

            return ConfigTree(config_file)
        except Exception as e:
            logger.critical(f'ConfigTree error: {e}')
            exit(1)


def get_configfile_interfaces() -> dict:
    """Read hw-id -> name mapping for ethernet/wireless from config.boot"""
    interfaces: dict = {}

    config = _load_config_boot()
    if config is None:
        return interfaces

    for base in (['interfaces', 'ethernet'], ['interfaces', 'wireless']):
        if not config.exists(base):
            continue
        for intf in config.list_nodes(base):
            path = base + [intf, 'hw-id']
            if not config.exists(path):
                logger.warning(f"no 'hw-id' entry for {intf}")
                continue
            hwid = config.return_value(path).lower()
            if hwid in interfaces:
                logger.warning(
                    f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}'
                )
                continue
            interfaces[hwid] = intf

    logger.debug(f'config file entries: {interfaces}')
    return interfaces


def get_pending_hwid_nodes() -> dict:
    """Interface nodes that exist under interfaces/{ethernet,wireless} in
    config.boot but have no 'hw-id' leaf - e.g. the documented "NIC
    replaced" remediation (`delete interfaces ethernet eth1 hw-id`)
    intentionally leaves the node (and its other settings, like address)
    in place, expecting the SAME node to receive a fresh hw-id. These
    names have no known MAC yet - that is the point - so they cannot be
    folded into get_configfile_interfaces()'s MAC-keyed dict; they are
    surfaced here, grouped by type, so main() can try to match them to
    this boot's unconfigured hardware (see match_pending_nodes()).
    """
    pending = {'ethernet': set(), 'wireless': set()}

    config = _load_config_boot()
    if config is None:
        return pending

    for intf_type, base in (('ethernet', ['interfaces', 'ethernet']),
                             ('wireless', ['interfaces', 'wireless'])):
        if not config.exists(base):
            continue
        for intf in config.list_nodes(base):
            if not config.exists(base + [intf, 'hw-id']):
                pending[intf_type].add(intf)

    return pending


def get_permanent_mac(ifname: str, sys_class_net: str = '/sys/class/net') -> str:
    """Best-effort permanent hardware address of an interface.

    Falls back to the interface's current address if the driver does not
    support reporting a permanent address separately (ethtool -P).

    sys_class_net is overridable for testing against a fake sysfs tree.
    """
    code, out = rc_cmd(f'ethtool -P {ifname}')
    if code == 0:
        m = re.search(r'([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})', out)
        if m and m.group(1) != '00:00:00:00:00:00':
            return m.group(1).lower()

    try:
        return Path(f'{sys_class_net}/{ifname}/address').read_text().strip().lower()
    except OSError:
        return ''


def discover_physical_interfaces(sys_class_net: str = '/sys/class/net') -> dict:
    """Return {kernel_name: mac} for every interface backed by a real bus
    device - excludes lo, bridges, bonds, VLANs, veth, tunnels, etc.

    Also excludes interfaces enslaved to another netdev (master symlink
    present), which covers Azure VF datapath interfaces bound under their
    synthetic parent. Those are acceleration children, not independent
    primary interfaces, and must not be candidates for hw-id naming.

    sys_class_net is overridable for testing against a fake sysfs tree.
    """
    interfaces = {}
    net_dir = Path(sys_class_net)
    if not net_dir.is_dir():
        return interfaces

    for entry in net_dir.iterdir():
        if not (entry / 'device').exists():
            logger.debug(
                f"skipping '{entry.name}': no backing device in sysfs"
            )
            continue
        if (entry / 'master').exists():
            logger.debug(
                f"skipping '{entry.name}': interface is enslaved via master link"
            )
            continue
        mac = get_permanent_mac(entry.name, sys_class_net)
        if mac:
            interfaces[entry.name] = mac

    return interfaces


def wait_for_hardware(configured_macs: set, timeout: float = HARDWARE_WAIT_TIMEOUT,
                       poll: float = HARDWARE_WAIT_POLL) -> tuple:
    """Poll until every configured hw-id has shown up, or until timeout"""
    deadline = time.monotonic() + timeout
    current = discover_physical_interfaces()
    while True:
        missing = configured_macs - set(current.values())
        if not missing or time.monotonic() >= deadline:
            return current, missing
        time.sleep(poll)
        current = discover_physical_interfaces()


def wait_for_settle(initial: dict, timeout: float = HARDWARE_SETTLE_TIMEOUT,
                     poll: float = HARDWARE_WAIT_POLL,
                     stable_polls: int = HARDWARE_SETTLE_STABLE_POLLS) -> dict:
    """Poll until discover_physical_interfaces() returns the same snapshot
    stable_polls times in a row, or until timeout. Used before bootstrap-
    naming hardware that has no configured hw-id to wait for by MAC.
    """
    deadline = time.monotonic() + timeout
    current = initial
    stable = 1 if current else 0
    while stable < stable_polls and time.monotonic() < deadline:
        time.sleep(poll)
        seen = discover_physical_interfaces()
        stable = stable + 1 if seen == current else 1
        current = seen
    return current


def is_wireless_interface(name: str, sys_class_net: str = '/sys/class/net') -> bool:
    """Race-free wireless check: existence of the phy80211 symlink, not the
    interface's current (possibly still cosmetic/pre-bootstrap) name.
    """
    return (Path(sys_class_net) / name / 'phy80211').exists()


PCI_BDF_RE = re.compile(r'^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]$')

# No real hop-count can reach this - reserves it as a sort-last sentinel
# for interfaces whose bus topology can't be determined (USB NICs, or a
# device symlink that doesn't exist/resolve).
PCIE_DISTANCE_UNKNOWN = 999


def pcie_distance(name: str, sys_class_net: str = '/sys/class/net') -> int:
    """Approximate PCIe bus distance from the root complex - counts PCI
    domain:bus:device.function segments (e.g. 0000:00:1f.6) in the fully
    resolved sysfs path of the interface's 'device' symlink. Non-PCI hops
    (virtioN, usbN, the net/<ifname> tail, ...) simply don't match and are
    skipped, so virtio's device->virtioN->real-PCI-parent indirection
    needs no special-casing. Multi-function siblings at the same slot
    contribute exactly one matching segment each, so they are not
    double-counted relative to their shared depth.

    Returns PCIE_DISTANCE_UNKNOWN (sorts after every real hop-count) if
    the 'device' symlink is missing/unresolvable, or the resolved path
    has no PCI BDF segment at all (e.g. a USB NIC).
    """
    device_link = Path(sys_class_net) / name / 'device'
    try:
        resolved = device_link.resolve(strict=True)
    except (OSError, RuntimeError):
        return PCIE_DISTANCE_UNKNOWN

    hops = sum(1 for part in resolved.parts if PCI_BDF_RE.match(part))
    return hops if hops > 0 else PCIE_DISTANCE_UNKNOWN


def find_available(names: set, prefix: str) -> str:
    """Find the lowest free index for a given interface name prefix"""
    index_list = []
    for name in names:
        if not name.startswith(prefix):
            continue
        suffix = name[len(prefix):]
        if suffix.isdigit():
            index_list.append(int(suffix))

    if not index_list:
        return f'{prefix}0'

    index_list.sort()
    # find 'holes' in list, if any
    missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list))
    if missing:
        return f'{prefix}{missing[0]}'

    return f'{prefix}{index_list[-1] + 1}'


def find_next_available(names: set, prefix: str, floor: int = 0) -> str:
    """Find the lowest free index for a prefix, scanning up from floor.

    Unlike find_available(), this always starts the scan at floor (0 by
    default) rather than at the lowest index already present in `names` -
    used for bootstrap naming, where a gap must be backfillable even when
    it sits below every other index currently in `names` (e.g. `names`
    contains only 'eth5' and 'eth9', but 'eth0'..'eth4' and 'eth6'..'eth8'
    are genuinely free). A slot only stays unavailable here because it is
    explicitly present in `names` - the caller decides what belongs there
    (configured hw-id targets, pending nodes, ...), not this function.
    """
    n = floor
    while f'{prefix}{n}' in names:
        n += 1
    return f'{prefix}{n}'


def compute_rename_plan(configured: dict, current: dict, pending: dict = None) -> dict:
    """Build {from_name: to_name} for every interface that needs to move to
    its CLI hw-id name. Also relocates any interface that currently squats
    on a name owned by a different hw-id, so the rightful owner can take it.
    """
    plan = {}
    current_by_mac = {mac: name for name, mac in current.items()}
    target_owner_mac = {name: mac for mac, name in configured.items()}

    for mac, target in configured.items():
        source = current_by_mac.get(mac)
        if source and source != target:
            plan[source] = target

    unchanged = set(current) - set(plan)
    # every configured target is reserved, even one whose hw-id hasn't
    # shown up yet this boot (missing/faulty/slow driver) - a squatter must
    # never be relocated onto a name that hardware still owns, or it would
    # just need relocating again the moment that hardware actually appears.
    # A pending (hw-id-less but still-configured) node name is reserved the
    # same way: it may be about to be reclaimed by the exact NIC that just
    # vacated it (see match_pending_nodes()), and even when it isn't, its
    # other settings (address, description, ...) are still live in
    # config.boot and must not be handed to an unrelated squatter.
    reserved = set(configured.values())
    if pending:
        reserved |= pending.get('ethernet', set()) | pending.get('wireless', set())

    for name, mac in current.items():
        if name in plan:
            continue
        owner_mac = target_owner_mac.get(name)
        if owner_mac is not None and owner_mac != mac:
            prefix = re.sub(r'\d+$', '', name) or name
            taken = unchanged | set(plan.values()) | set(plan.keys()) | reserved
            new_name = find_available(taken, prefix)
            plan[name] = new_name
            unchanged.discard(name)

    return plan


def unmatched_candidates(configured: dict, current: dict, existing_plan: dict) -> list:
    """Physical interfaces this boot with no configured hw-id - the pool
    both ordinary bootstrap naming and pending-node reclaim matching draw
    from. This deliberately INCLUDES squatters compute_rename_plan() is
    already evicting from a configured target name: excluding them here
    let their mac skip match_pending_nodes() entirely, so an unconfigured
    NIC that happened to be squatting on someone else's hw-id slot could
    silently get a permanent, possibly-ambiguous hw-id via ordinary
    bootstrap naming instead of being reclaimed or held back like any
    other unconfigured candidate. Their eviction destination from
    existing_plan still stands as the fallback - match_pending_nodes()
    (via main()'s reclaim loop) or compute_bootstrap_plan() only override
    it, they never leave a squatter un-evicted.
    """
    return [(mac, name) for name, mac in current.items()
            if mac not in configured]


def match_pending_nodes(pending: dict, candidates: list) -> dict:
    """Match this boot's unconfigured candidates to pending (hw-id-less)
    config nodes, one type (ethernet/wireless) at a time. Conservative by
    design: only matches when there is EXACTLY ONE pending node and
    EXACTLY ONE candidate of that type this boot - any other cardinality
    is left alone rather than guessed.

    A pending node still carries its OTHER settings (address,
    description, ...) - a wrong guess would silently apply one physical
    port's configuration to a DIFFERENT port. Confirmed in the field: on
    a real, already-provisioned box (whose hw-id came from historical
    probe-order rescan, not from any PCIe/MAC sort), a second, completely
    unrelated candidate freed in the same boot - e.g. a different
    interface's config being fully deleted - is enough for a naive
    ascending-sort fill to swap the two, binding a configured node's
    address to the wrong wire. There is no way to tell, from MAC and
    PCIe position alone, which candidate is genuinely the pending node's
    own hardware once its hw-id is gone; leaving the node unresolved and
    reported is safer than guessing. A candidate that isn't reclaimed
    here is not otherwise held back - it still proceeds to ordinary
    bootstrap naming (see compute_bootstrap_plan()) and gets its own
    fresh, settings-free name instead.

    Returns {mac: node_name} for every unambiguous match this boot.
    """
    grouped = {'ethernet': [], 'wireless': []}
    for mac, name in candidates:
        group = 'wireless' if is_wireless_interface(name) else 'ethernet'
        grouped[group].append((mac, name))

    matched = {}
    for intf_type, nodes in pending.items():
        if not nodes:
            continue
        cands = grouped.get(intf_type, [])
        if len(nodes) == 1 and len(cands) == 1:
            (mac, _name) = cands[0]
            (target,) = nodes
            matched[mac] = target
        else:
            cand_desc = ', '.join(f"'{name}' ({mac})" for mac, name in cands) or 'none'
            logger.warning(
                f'{len(nodes)} pending {intf_type} node(s) '
                f"({', '.join(sorted(nodes))}) and {len(cands)} unconfigured "
                f'{intf_type} candidate(s) this boot ({cand_desc}) - not '
                'unambiguous, leaving pending rather than guessing'
            )

    return matched


def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict,
                            pending: dict = None,
                            reclaimed_macs: frozenset = frozenset()) -> dict:
    """Build {from_name: to_name} for physical interfaces that have no
    configured hw-id at all, assigning them a canonical name within their
    type group (ethernet/wireless) ordered by PCIe distance from the root
    complex first and MAC address as a tie-break, instead of leaving them
    at whatever name the racy udev-time fast path produced. Ordering by
    topology rather than raw MAC magnitude means an onboard/directly
    CPU-attached NIC isn't sorted after add-in cards just because its MAC
    happens to be numerically higher. This is what makes a box's very
    first boot - before any hw-id exists - just as deterministic as every
    boot after hw-id is written (PCIe wiring and MAC are both static
    hardware properties), since the name assigned here gets frozen into
    config.boot by vyos-interface-rescan.py the same way a real hw-id
    match would.

    existing_plan is the hw-id based plan already computed by
    compute_rename_plan(): its RIGHTFUL-OWNER targets (an interface moving
    to its own configured hw-id name) are reserved so a bootstrap name can
    never collide with a configured one. A squatter compute_rename_plan()
    is evicting from a configured target IS still bootstrap candidacy here
    (see unmatched_candidates()) - its OWN eviction destination is only a
    provisional fallback that this function is about to recompute for it
    from scratch, so that value must not also count as "taken" (it would
    needlessly block a lower slot this function might otherwise give it,
    or another candidate, once real).

    pending node names are reserved the same way a real hw-id target is -
    a candidate main() could not unambiguously match to one via
    match_pending_nodes() must never squat there instead, since that name
    still carries the node's other settings (address, description, ...).
    reclaimed_macs are candidates main() already matched to a pending
    node - excluded here so this function never reassigns them elsewhere.

    A numeric slot is only ever off-limits here because something still
    occupies or reserves it - a real hw-id target or a pending node. A gap
    left by fully deleting an interface's config (hw-id and node both
    gone, nothing in `pending` either) carries no such reservation and is
    freely backfilled, exactly as bootstrap naming would treat it on a
    system that never had any config for it at all - there is no
    remaining signal in config.boot to tell those two cases apart, and
    deleting the whole node is the admin's explicit way of saying so.
    """
    plan = {}
    candidates = unmatched_candidates(configured, current, existing_plan)
    candidates = [(mac, name) for mac, name in candidates
                  if mac not in reclaimed_macs]
    if not candidates:
        return plan

    candidate_names = {name for _, name in candidates}
    # a plan entry whose source IS one of this function's own candidates
    # is a squatter's provisional eviction fallback, about to be
    # recomputed below - only a rightful-owner move's target is a real,
    # authoritative reservation
    rightful_movers = {src: target for src, target in existing_plan.items()
                        if src not in candidate_names}
    # a rightful mover's CURRENT (source) name looks occupied right now,
    # but safe_bulk_rename()'s two-phase scratch-name staging vacates it
    # before any target name is actually claimed - so it must not count
    # as taken here either.
    reserved_pending = set()
    if pending:
        reserved_pending = pending.get('ethernet', set()) | pending.get('wireless', set())
    taken = (set(current) - candidate_names - set(rightful_movers)) \
        | set(rightful_movers.values()) | reserved_pending

    for mac, name in sorted(candidates, key=lambda c: (pcie_distance(c[1]), c[0])):
        prefix = 'wlan' if is_wireless_interface(name) else 'eth'
        new_name = find_next_available(taken, prefix)
        taken.add(new_name)
        if new_name != name:
            plan[name] = new_name

    return plan


def get_ifindex(name: str) -> str:
    try:
        return Path(f'/sys/class/net/{name}/ifindex').read_text().strip()
    except OSError:
        return name


def rename_interface(old: str, new: str) -> bool:
    run(f'ip link set dev {old} down')
    code = run(f'ip link set dev {old} name {new}')
    if code != 0:
        logger.error(f"failed to rename '{old}' -> '{new}' (exit {code})")
        return False
    logger.info(f"renamed '{old}' -> '{new}'")
    return True


def safe_bulk_rename(plan: dict) -> dict:
    """Two-phase rename: stage every interface via a unique scratch name
    (derived from its ifindex, which is always unique) before assigning
    final names. This makes the whole batch collision-proof regardless of
    permutations/cycles between current and target names - a straight
    from->to rename can fail if the target name is still held by another
    interface earlier/later in the same plan.
    """
    if not plan:
        return {}

    applied = {}
    scratch = {}
    for old, target in plan.items():
        tmp = f'vyeth{get_ifindex(old)}'
        if rename_interface(old, tmp):
            scratch[tmp] = (old, target)
            applied[old] = target

    for tmp, (old, target) in scratch.items():
        if rename_interface(tmp, target):
            run(f'ip link set dev {target} up')
        else:
            # still sitting under the scratch name, not the target - don't
            # report it as renamed, and don't leave it down indefinitely
            del applied[old]
            run(f'ip link set dev {tmp} up')

    return applied


def sync_rescan_hints(current_state: dict, configured: dict,
                       stale_names: set = frozenset()) -> None:
    """Leave a hint under vyos_udev_dir for every interface that has no
    hw-id configured yet, so vyos-interface-rescan.py can auto-populate
    config.boot. Remove stale/no-longer-hw-id-needed hints.
    """
    try:
        Path(vyos_udev_dir).mkdir(parents=True, exist_ok=True)
    except OSError as e:
        logger.critical(f'error creating rescan hint directory: {e}')
        return

    for name, mac in current_state.items():
        hint = Path(vyos_udev_dir) / name
        if mac in configured:
            hint.unlink(missing_ok=True)
        else:
            try:
                hint.write_text(mac)
            except OSError as e:
                logger.error(f'could not write rescan hint for {name}: {e}')

    for old_name in stale_names:
        if old_name not in current_state:
            (Path(vyos_udev_dir) / old_name).unlink(missing_ok=True)


def write_status(configured: dict, found: dict, missing: set, plan: dict,
                  pending: dict = None, reclaimed: dict = None,
                  candidates: list = None) -> None:
    """candidates is the full unmatched_candidates() list evaluated this
    boot (before reclaim/bootstrap assignment) - surfaced here so a
    pending node left unresolved for lack of hardware is self-diagnosable
    from this one file: 'pending_unresolved' names what needed a match,
    'unconfigured_candidates' names every physical interface that was in
    the running for one, without needing a separate `ip -br link show` or
    `show configuration commands` to reconstruct the same picture by hand.
    """
    pending = pending or {}
    reclaimed = reclaimed or {}
    candidates = candidates or []
    all_pending = pending.get('ethernet', set()) | pending.get('wireless', set())
    status = {
        'configured': configured,
        'found': sorted(found.values()),
        'missing': {mac: configured[mac] for mac in sorted(missing)},
        'renamed': plan,
        'pending_unresolved': sorted(all_pending - set(reclaimed.values())),
        'reclaimed': reclaimed,
        'unconfigured_candidates': {name: mac for mac, name in candidates},
    }
    try:
        status_file.write_text(json.dumps(status, indent=2))
    except OSError as e:
        logger.error(f'could not write status file: {e}')


def main():
    if is_running_as_container():
        # A container has no NIC of its own - its interfaces are veth pairs
        # created by the container runtime. They have no backing bus device in
        # sysfs and their MAC is assigned by the host and regenerated on every
        # start, so there is nothing to wait for and no hw-id worth binding.
        logger.info('running inside a container - skipping hw-id naming pass')
        return

    configured = get_configfile_interfaces()
    pending = get_pending_hwid_nodes()

    if configured:
        current, missing = wait_for_hardware(set(configured))
        for mac in sorted(missing):
            logger.warning(
                f"hw-id '{mac}' configured as '{configured[mac]}' was not found "
                f'within {HARDWARE_WAIT_TIMEOUT}s - hardware may be missing, '
                'still initializing, or its driver failed to load'
            )
        plan = compute_rename_plan(configured, current, pending)
    else:
        logger.info('no hw-id configured yet')
        current, missing, plan = discover_physical_interfaces(), set(), {}

    all_pending = pending.get('ethernet', set()) | pending.get('wireless', set())

    reclaimed = {}
    candidates = []
    if all_pending or any(mac not in configured for mac in current.values()):
        # The `all_pending` half of this condition matters even when
        # `current` (from wait_for_hardware() above, bounded only on
        # already-CONFIGURED macs) shows nothing unconfigured yet: on a
        # system with several different NIC vendors/drivers, the exact
        # hardware a pending node needs can simply not have finished
        # probing at this snapshot. Without this, wait_for_settle() below
        # would never even run, giving that slower hardware zero extra
        # time to appear before this boot gives up on the pending node.
        current = wait_for_settle(current)
        if configured:
            # a configured hw-id whose driver was too slow to show up
            # within wait_for_hardware()'s timeout can still appear during
            # the extra time wait_for_settle() just spent waiting for
            # unconfigured hardware to stabilize - recompute against the
            # settled snapshot so it still gets renamed (and drops out of
            # `missing`) this boot instead of being silently skipped.
            missing = set(configured) - set(current.values())
            plan = compute_rename_plan(configured, current, pending)

        # let a NIC that just lost its hw-id (the documented "delete
        # hw-id to force regeneration" remediation) reclaim the exact
        # node its other settings (address, description, ...) still live
        # under, rather than bootstrap-naming it to a new bare node and
        # orphaning that config - only when unambiguous, see
        # match_pending_nodes()
        candidates = unmatched_candidates(configured, current, plan)
        reclaimed = match_pending_nodes(pending, candidates)
        candidate_by_mac = dict(candidates)
        for mac, target in reclaimed.items():
            name = candidate_by_mac[mac]
            if name != target:
                plan[name] = target
            logger.info(
                f"reclaiming pending node '{target}' for hw-id '{mac}' "
                'this boot'
            )

        # bootstrap-name whatever is left, deterministically by PCIe
        # distance and MAC, instead of leaving it at its racy cosmetic
        # udev-time name - this is what vyos-interface-rescan.py will
        # freeze into config.boot. Pending node names stay reserved here
        # (see compute_bootstrap_plan()) so an ambiguous leftover
        # candidate can never squat on one and inherit its settings.
        plan.update(compute_bootstrap_plan(
            configured, current, plan, pending=pending,
            reclaimed_macs=set(reclaimed)))

    for name in sorted(all_pending - set(reclaimed.values())):
        logger.warning(
            f"pending node '{name}' still has no hw-id after this boot's "
            'naming pass'
        )

    applied = safe_bulk_rename(plan)

    final_current = discover_physical_interfaces()
    # configured is passed unmutated (not merged with `reclaimed`): the
    # reclaimed mac still has no real hw-id in config.boot yet, so it must
    # still get a rescan hint under its (now reclaimed) name, letting
    # vyos-interface-rescan.py write the hw-id into the existing node.
    sync_rescan_hints(final_current, configured, set(applied.keys()))
    write_status(configured, current, missing, applied,
                 pending=pending, reclaimed=reclaimed, candidates=candidates)


if __name__ == '__main__':
    syslog_handler = logging.handlers.SysLogHandler(address='/dev/log')
    formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s')
    syslog_handler.setFormatter(formatter)

    logger.addHandler(syslog_handler)
    logger.setLevel(logging.DEBUG)

    main()