summaryrefslogtreecommitdiff
path: root/src/services/vyos-netlinkd
blob: d6cc3c37982cb3dcf416a1047e321480ba3e5383 (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
#!/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/>.

import importlib.util
import os
import re
import sys
import syslog
import signal
import select
import threading

from pyroute2 import IPRoute # pylint: disable = no-name-in-module
from pyroute2 import NetlinkError # pylint: disable = no-name-in-module
from pyroute2.netlink.rtnl import RTMGRP_LINK
from pyroute2.netlink.rtnl import RTMGRP_IPV4_IFADDR
from pyroute2.netlink.rtnl import RTMGRP_IPV6_IFADDR
from time import monotonic
from time import sleep
from typing import Optional

from vyos.configquery import op_mode_config_dict
from vyos.ifconfig import Section
from vyos.utils.boot import boot_configuration_complete
from vyos.utils.commit import commit_in_progress2
from vyos.utils.file import read_file
from vyos.utils.dict import dict_search
from vyos.utils.network import is_wwan_connected
from vyos.utils.process import cmdl
from vyos.utils.process import is_systemd_service_active
from vyos.utils.process import stop_systemd_unit
from vyos.utils.wwan import connect_options
from vyos.utils.wwan import is_admin_disconnected
from vyos.utils.wwan import modem_connect
from vyos.utils.wwan import modem_disconnect
from vyos.utils.wwan import start_modem_manager

def _load_conf_mode_qos():
    spec = importlib.util.spec_from_file_location(
        'conf_mode_qos', '/usr/libexec/vyos/conf_mode/qos.py')
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

_conf_mode_qos = _load_conf_mode_qos()

running = True

# compile regex once during startup for fast match
IFACE_RE = re.compile(r"^(?:eth|br|bond|wlan|pppoe|sstpc|wwan)")
_dynamic_qos_interfaces: set[str] = set()

# Per-interface previous operstate, used to suppress DHCP restarts on
# UP-to-UP re-notifications (e.g. post-migration gratuitous-ARP events), and
# to detect edges missed while netlink events were drained during a commit.
_iface_prev_operstate: dict[str, str] = {}

# True while the main loop is draining netlink events because a config
# commit holds the lock. When it clears we reconcile DHCP clients against
# live operstate so a transition that only happened inside the drain window
# is not lost (T9086).
_in_commit_skip = False

# WWAN sessions are re-dialled from a periodic reconcile pass rather than from
# netlink events. A modem that never connected in the first place produces no
# edge at all, and a cdc_mbim/qmi_wwan netdev sits at operstate "unknown" while
# it is up, so there is nothing to trigger on either way - only ModemManager
# knows whether a session exists.
WWAN_RECONCILE_INTERVAL = 30

# Per-interface re-dial backoff. A modem with no SIM, no coverage or a wrong
# APN would otherwise be re-dialled every reconcile pass forever.
WWAN_BACKOFF_MIN = 30
WWAN_BACKOFF_MAX = 900

_wwan_next_attempt: dict[str, float] = {}
_wwan_backoff: dict[str, int] = {}

# Interfaces with a dial in flight. Dialling blocks for tens of seconds, so it
# runs on a worker thread and must not be started twice for one interface.
_wwan_dialing: set[str] = set()
_wwan_lock = threading.Lock()

# Legacy re-dial cron job, superseded by _reconcile_wwan(). Only present on a
# system that ran an earlier version, where it was written at commit time.
WWAN_LEGACY_CRON = '/etc/cron.d/vyos-wwan'

def match_iface(ifname: str) -> bool:
    """ Helper function returning true if interface name is a match for further
    processing (e.g. restart of DHCP(v6) client)
    """
    return IFACE_RE.match(ifname) is not None

def _read_operstate(ifname: str) -> Optional[str]:
    """Read live kernel operstate from sysfs (uppercase), or None if unavailable.

    Values match IFLA_OPERSTATE names used by RTM_NEWLINK (UP, DOWN, ...).
    See Documentation/ABI/testing/sysfs-class-net.
    """
    fname = f'/sys/class/net/{ifname}/operstate'
    return read_file(fname, defaultonfailure='').strip().upper() or None

def _is_dynamic_qos_iface(ifname: str) -> bool:
    """ Helper function returning true if interface requires QoS re-apply
    after first address assignment.
    """
    return ifname.startswith(('pppoe', 'sstpc', 'wwan'))

def _handle_dynamic_qos_events(event: str, ifname: str,
                               operstate: Optional[str] = None) -> None:
    """ Re-apply QoS for dynamic interfaces after they receive an address. """
    if not _is_dynamic_qos_iface(ifname):
        return None

    match event:
        case 'RTM_NEWLINK' if operstate != 'DOWN':
            return None

        case 'RTM_NEWLINK' | 'RTM_DELLINK':
            if ifname in _dynamic_qos_interfaces:
                syslog.syslog(syslog.LOG_DEBUG,
                              f'Clearing QoS re-apply state on dynamic interface {ifname}...')
                _dynamic_qos_interfaces.discard(ifname)

        case 'RTM_NEWADDR':
            if ifname in _dynamic_qos_interfaces:
                syslog.syslog(syslog.LOG_DEBUG,
                              f'QoS already re-applied on dynamic interface {ifname}, skipping...')
                return None

            qos = _conf_mode_qos.get_config()
            if not qos or 'interface' not in qos or ifname not in qos['interface']:
                return None

            syslog.syslog(syslog.LOG_INFO,
                          f'Re-applying QoS on dynamic interface {ifname}...')
            _conf_mode_qos.apply_interface(qos, ifname)
            _dynamic_qos_interfaces.add(ifname)

def sigterm_handler(signo, frame):
    global running
    running = False
    sig = signal.Signals(signo)
    syslog.syslog(syslog.LOG_INFO, f'Received signal {sig.name} - shutting down...')

def _handle_dhcp_events(operstate: Optional[str], ifname: str) -> None:
    systemdV4_service = f'dhclient@{ifname}.service'
    systemdV6_service = f'dhcp6c@{ifname}.service'

    # Only handle explicit UP/DOWN state transitions; ignore other kernel states.
    if operstate not in ['UP', 'DOWN']:
        return None

    prev = _iface_prev_operstate.get(ifname)
    _iface_prev_operstate[ifname] = operstate

    if operstate == 'UP' and prev == 'UP':
        syslog.syslog(syslog.LOG_DEBUG,
                      f'Suppressing DHCP restart for {ifname}: already UP')
        return None

    if operstate == 'DOWN':
        # Interface moved state to down
        if is_systemd_service_active(systemdV4_service):
            syslog.syslog(syslog.LOG_DEBUG, f'Stopping {systemdV4_service}...')
            stop_systemd_unit(systemdV4_service, raise_on_failure=False)
        if is_systemd_service_active(systemdV6_service):
            syslog.syslog(syslog.LOG_DEBUG, f'Stopping {systemdV6_service}...')
            stop_systemd_unit(systemdV6_service, raise_on_failure=False)

    elif operstate == 'UP':
        v6_restart = False
        interface_path = Section.get_config_path(ifname, delimiter='.')

        config_dict = op_mode_config_dict(
            ['interfaces'], key_mangling=('-', '_'), get_first_key=True
        )

        if tmp := dict_search(f'{interface_path}.address', config_dict):
            # Always (re-)start the DHCP(v6) client service. If the DHCP(v6) client
            # is already running - which could happen if the interface is re-
            # configured in operational down state, it will have an exponential backoff
            # time increasing while not receiving a DHCP(v6) reply.
            #
            # To make the interface instantly available, and as for a DHCP(v6) lease
            # we will re-start the service and thus cancel the backoff time.
            if 'dhcp' in tmp:
                syslog.syslog(syslog.LOG_DEBUG, f'Restarting {systemdV4_service}...')
                cmdl(['systemctl', 'restart', systemdV4_service])
            if 'dhcpv6' in tmp:
                v6_restart = True

        if dict_search(f'{interface_path}.dhcpv6_options.pd', config_dict):
            v6_restart = True

        if v6_restart:
            syslog.syslog(syslog.LOG_DEBUG, f'Restarting {systemdV6_service}...')
            cmdl(['systemctl', 'restart', systemdV6_service])

    return None

def _reconcile_dhcp_operstate() -> None:
    """After a commit drain window, sync DHCP clients to live operstate (T9086).

    Events discarded while commit_in_progress2() was true may have included a
    real UP/DOWN edge. Compare each interface's live sysfs operstate against
    the last one we acted on and run the normal DHCP handler only where they
    disagree.

    Interfaces never seen before are seeded into the tracker without acting -
    conf_mode owns DHCP bring-up across commits for those.
    """
    try:
        ifnames = os.listdir('/sys/class/net')
    except OSError as e:
        syslog.syslog(syslog.LOG_ERR, f'Failed to list interfaces for reconcile: {e}')
        return

    for ifname in ifnames:
        if not match_iface(ifname):
            continue

        operstate = _read_operstate(ifname)
        if operstate not in ['UP', 'DOWN']:
            continue

        prev = _iface_prev_operstate.get(ifname)
        if prev is None:
            _iface_prev_operstate[ifname] = operstate
            continue
        if prev == operstate:
            continue

        syslog.syslog(syslog.LOG_DEBUG,
                      f'Reconcile {ifname}: prev={prev} current={operstate}')
        _handle_dhcp_events(operstate, ifname)

def _wwan_clear_backoff(ifname: str) -> None:
    """ Forget any re-dial backoff for an interface that no longer needs one """
    _wwan_next_attempt.pop(ifname, None)
    _wwan_backoff.pop(ifname, None)

def _wwan_defer(ifname: str, reason: str) -> None:
    """ Grow the re-dial backoff for an interface after a failed attempt """
    backoff = min(max(_wwan_backoff.get(ifname, 0) * 2, WWAN_BACKOFF_MIN),
                  WWAN_BACKOFF_MAX)
    _wwan_backoff[ifname] = backoff
    _wwan_next_attempt[ifname] = monotonic() + backoff
    syslog.syslog(syslog.LOG_WARNING, f'{reason}, retrying in {backoff}s')

def _wwan_dial_wanted(ifname: str) -> bool:
    """ True while re-dialling an interface is still the right thing to do.

    _reconcile_wwan() decides to dial, but the dial itself only starts once a
    worker thread picks it up and then blocks for as long as the modem takes to
    answer - up to a minute waiting for the modem alone. An operator disconnect
    or a commit that lands inside that window must not be undone by a dial that
    was scheduled before it, so the decision is taken again as late as possible
    rather than trusted from scheduling time.
    """
    if is_admin_disconnected(ifname):
        syslog.syslog(syslog.LOG_INFO,
                      f'WWAN interface {ifname} was disconnected while dialling, '
                      'abandoning the re-dial')
        return False

    if commit_in_progress2():
        syslog.syslog(syslog.LOG_INFO,
                      f'Config commit started while dialling WWAN interface {ifname}, '
                      'leaving the modem to conf_mode')
        return False

    return True

def _wwan_dial(ifname: str, config: dict) -> None:
    """ Re-establish a lost WWAN session. Runs on a worker thread - dialling
    blocks for as long as the modem takes to answer, and the event loop must
    stay responsive for every other interface while it does. """
    try:
        # A session cannot be dialled without ModemManager. It is normally
        # started by conf_mode, but recover here too in case it died.
        start_modem_manager(ifname)

        if not _wwan_dial_wanted(ifname):
            return None

        # Number of bearers is limited - always disconnect first
        modem_disconnect(ifname)

        if modem_connect(ifname, connect_options(config)):
            # The modem answering takes seconds of its own, so the same window
            # exists around the dial. A disconnect that arrived while it was
            # being answered must not be left holding a live bearer - a commit
            # is not undone here, conf_mode owns the modem from that point on.
            if is_admin_disconnected(ifname):
                syslog.syslog(syslog.LOG_INFO,
                              f'WWAN interface {ifname} was disconnected while dialling, '
                              'dropping the session again')
                modem_disconnect(ifname)
                return None

            syslog.syslog(syslog.LOG_INFO, f'Re-dialled WWAN interface {ifname}')
            _wwan_clear_backoff(ifname)
            return None

        _wwan_defer(ifname, f'Failed to re-dial WWAN interface {ifname}')
    except Exception as e:
        _wwan_defer(ifname, f'Error re-dialling WWAN interface {ifname}: {e}')
    finally:
        with _wwan_lock:
            _wwan_dialing.discard(ifname)

def _wwan_dial_async(ifname: str, config: dict) -> None:
    """ Hand a re-dial to a worker thread, one at a time per interface """
    with _wwan_lock:
        if ifname in _wwan_dialing:
            return None
        _wwan_dialing.add(ifname)

    threading.Thread(target=_wwan_dial, args=(ifname, config),
                     name=f'wwan-dial-{ifname}', daemon=True).start()

def _wwan_present() -> bool:
    """ True if the system has any WWAN interface at all.

    Every modem VyOS supports binds a driver that creates the netdev as soon as
    the device is probed - independently of whether a session is up - so this is
    a cheap way to keep systems without a modem from reading the config on every
    reconcile pass.
    """
    try:
        return any(name.startswith('wwan') for name in os.listdir('/sys/class/net'))
    except OSError:
        return False

def _reconcile_wwan() -> None:
    """ Re-dial every configured WWAN interface that has lost its session.

    This replaces the /etc/cron.d/vyos-wwan job that used to re-run the
    conf_mode script every 5 minutes. Running it here means it inherits the
    boot_configuration_complete() and commit_in_progress2() gates of the main
    loop, so a re-dial can no longer land on a half-applied configuration, and
    it dials ModemManager directly instead of re-entering conf_mode.
    """
    if not _wwan_present():
        return None

    config_dict = op_mode_config_dict(
        ['interfaces', 'wwan'], key_mangling=('-', '_'), get_first_key=True
    )
    if not config_dict:
        return None

    for ifname, config in config_dict.items():
        # Do not dial an interface the user has explicitly disabled, but let it
        # start from a clean slate once it is enabled again.
        if 'disable' in config:
            _wwan_clear_backoff(ifname)
            continue

        # Do not undo an explicit op-mode "disconnect interface wwanN" - the
        # session stays down until it is connected again or the configuration
        # is re-applied by a commit.
        if is_admin_disconnected(ifname):
            _wwan_clear_backoff(ifname)
            continue

        if is_wwan_connected(ifname):
            _wwan_clear_backoff(ifname)
            continue

        if monotonic() < _wwan_next_attempt.get(ifname, 0):
            continue

        syslog.syslog(syslog.LOG_INFO,
                      f'WWAN interface {ifname} is not connected, re-dialling...')
        _wwan_dial_async(ifname, config)

    return None

def main():
    global _in_commit_skip

    syslog.openlog(ident="vyos-netlinkd",
                   logoption=syslog.LOG_PID,
                   facility=syslog.LOG_DAEMON)
    syslog.syslog(syslog.LOG_INFO, "VyOS Netlink listener daemon started.")

    # Subscribe to link and address notifications only (not routes/rules/neigh/...).
    ipr = IPRoute()
    try:
        # newer pyroute2 versions support bind group in IPRoute() constructor
        ipr.bind(groups=RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR)
        syslog.syslog(syslog.LOG_INFO,
            'IPRoute.bind() using link and address RTNL subscriptions')
    except TypeError:
        syslog.syslog(syslog.LOG_WARNING,
            'IPRoute.bind() has no groups= support; using default RTNL subscriptions',
        )
        ipr.bind()

    # Drop the re-dial cron job left behind by an earlier version - WWAN
    # sessions are reconciled from this daemon now.
    try:
        os.unlink(WWAN_LEGACY_CRON)
        syslog.syslog(syslog.LOG_INFO,
                      f'Removed superseded WWAN re-dial cron job {WWAN_LEGACY_CRON}')
    except FileNotFoundError:
        pass
    except OSError as e:
        syslog.syslog(syslog.LOG_ERR,
                      f'Failed to remove {WWAN_LEGACY_CRON}: {e}')

    # Establish a baseline so the first UP re-notification is not mistaken
    # for a real interface transition.
    for link in ipr.get_links():
        attrs = dict(link.get('attrs', []))
        ifname = attrs.get('IFLA_IFNAME')
        operstate = attrs.get('IFLA_OPERSTATE')
        if ifname and match_iface(ifname) and operstate in ['UP', 'DOWN']:
            _iface_prev_operstate[ifname] = operstate

    fd = ipr.fileno()

    wwan_next_reconcile = 0.0

    global running
    while running:
        if not boot_configuration_complete():
            syslog.syslog(syslog.LOG_INFO, 'System bootup not yet finished...')
            sleep(5)
            continue

        try:
            # Wait for up to 1 second for a netlink message. The timeout also
            # lets us notice a commit ending even with no further netlink
            # traffic, so we can reconcile (T9086).
            rlist, _, _ = select.select([fd], [], [], 1.0)

            if commit_in_progress2():
                # Drain the socket instead of leaving messages queued, so a
                # stale event (e.g. a DOWN from a disable that was already
                # re-enabled) can't be processed as fresh once the commit
                # ends (T9086).
                if not _in_commit_skip:
                    syslog.syslog(syslog.LOG_DEBUG,
                                  'Config commit in progress, draining netlink events without acting')
                    _in_commit_skip = True
                if rlist:
                    try:
                        ipr.get()
                    except NetlinkError as e:
                        syslog.syslog(syslog.LOG_ERR,
                                      f'Netlink error while draining during commit: {e}')
                continue

            if _in_commit_skip:
                _in_commit_skip = False
                syslog.syslog(syslog.LOG_INFO,
                              'Config commit finished, reconciling DHCP client state with operstate')
                _reconcile_dhcp_operstate()

            # Re-dial WWAN sessions that were lost. Placed after the commit
            # gate above so conf_mode always owns the modem while a commit is
            # running, and reached on the select() timeout too, so it does not
            # depend on netlink traffic arriving.
            if monotonic() >= wwan_next_reconcile:
                wwan_next_reconcile = monotonic() + WWAN_RECONCILE_INTERVAL
                _reconcile_wwan()

            if not rlist:
                # timeout - retry
                continue

            # Receive and process any messages
            for message in ipr.get():
                # Parse NETLINK message
                event = message['event']
                attrs = dict(message.get('attrs', []))
                ifname = attrs.get('IFLA_IFNAME', None) or attrs.get('IFA_LABEL', None)

                if not ifname and 'index' in message:
                    try:
                        links = ipr.get_links(message['index'])
                        if links:
                            ifname = dict(links[0].get('attrs', [])).get('IFLA_IFNAME', None)
                    except NetlinkError:
                        # Interface can disappear between the netlink event
                        # arriving and this get_links() lookup (e.g. pppoe
                        # teardown). Skip the message rather than breaking
                        # the entire ipr.get() batch with an ENODEV error.
                        continue

                # Bail out early - no interface name in the message
                if not ifname:
                    continue
                # Bail out early - not interested in interface type
                if not match_iface(ifname):
                    continue

                match event:
                    # Message received during interface creation or modification
                    # e.g. link up/down.
                    case 'RTM_NEWLINK':
                        mac = attrs.get('IFLA_ADDRESS', '<unknown>')
                        operstate = attrs.get('IFLA_OPERSTATE', None)
                        syslog.syslog(syslog.LOG_DEBUG,
                                      f'RTM_NEWLINK -> {ifname}, state={operstate}, mac={mac}')

                        _handle_dynamic_qos_events(event, ifname, operstate)
                        _handle_dhcp_events(operstate, ifname)

                    # Address added to an interface. For dynamic interfaces, this
                    # means the connection completed and QoS can be re-applied.
                    case 'RTM_NEWADDR':
                        addr = attrs.get('IFA_ADDRESS', '<unknown>')
                        prefixlen = message.get('prefixlen', '')
                        syslog.syslog(syslog.LOG_DEBUG,
                                      f'RTM_NEWADDR -> {ifname}, addr={addr}/{prefixlen}')
                        _handle_dynamic_qos_events(event, ifname)

                    # Deletion of a network link which has been previously added to the kernel
                    case 'RTM_DELLINK':
                        attrs = dict(message.get('attrs', []))
                        ifname = attrs.get('IFLA_IFNAME', None)
                        if ifname:
                            _iface_prev_operstate.pop(ifname, None)
                            _handle_dynamic_qos_events(event, ifname)
                    case _:
                        pass

        except NetlinkError as e:
            syslog.syslog(syslog.LOG_ERR, f'Netlink error: {e}')
        except Exception as e:
            syslog.syslog(syslog.LOG_ERR, f'Unhandled exception: {e}')
        except KeyboardInterrupt:
            break

    ipr.close()
    syslog.syslog(syslog.LOG_INFO, 'Netlink listener daemon stopped.')
    sys.exit(0)

if __name__ == "__main__":
    signal.signal(signal.SIGTERM, sigterm_handler)
    signal.signal(signal.SIGINT, sigterm_handler)
    main()