File: daemon.py

package info (click to toggle)
freedombox 26.2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 82,976 kB
  • sloc: python: 48,504; javascript: 1,736; xml: 481; makefile: 290; sh: 167; php: 32
file content (313 lines) | stat: -rw-r--r-- 12,027 bytes parent folder | download | duplicates (2)
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
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Component for managing a background daemon or any systemd unit."""

import contextlib
import socket
import subprocess

import psutil
from django.utils.translation import gettext_noop

from plinth import action_utils, app, log
from plinth.diagnostic_check import (DiagnosticCheck,
                                     DiagnosticCheckParameters, Result)


class Daemon(app.LeaderComponent, log.LogEmitter):
    """Component to manage a background daemon or any systemd unit."""

    def __init__(self, component_id: str, unit: str,
                 strict_check: bool = False,
                 listen_ports: list[tuple[int, str]] | None = None,
                 alias: str | None = None):
        """Initialize a new daemon component.

        'component_id' must be a unique string across all apps and components
        of a app. Conventionally starts with 'daemon-'.

        'unit' must the name of systemd unit that this component should manage.

        'listen_ports' is a list of tuples. Each tuple contains the port number
        as integer followed by a string with one of the values 'tcp4', 'tcp6',
        'tcp', 'udp4', 'udp6', 'udp' indicating the protocol that the daemon
        listens on. This information is used to run diagnostic tests.

        'alias' is an alternate name for the same unit file. When a unit file
        is renamed, the new unit file usually contains an Alias= setting in
        [Install] section with value of old unit name. When the unit is
        enabled, a symlink with the name of the alias is created. All
        operations such as is-running and disable work with the alias along
        with the primary unit name. However, for the case of enabling the unit
        file or checking its enabled status, the alias does not work. To be
        able to provide management for multiple versions of the unit file with
        different names, specify an alias. Both the names are taken into
        consideration when enabling the unit file.

        """
        super().__init__(component_id)

        self.unit = unit
        self.strict_check = strict_check
        self.listen_ports = listen_ports or []
        self.alias = alias

    def is_enabled(self):
        """Return if the daemon/unit is enabled."""
        if self.alias:
            # XXX: Handling alias should not be done here. service_is_enabled()
            # should return True even for an alias. Currently, in addition to
            # return code we are also checking the printed value. This makes
            # the implementation less future-proof as new values could printed
            # by the command. A fixed systemd bug
            # https://github.com/systemd/systemd/issues/18134 also currently
            # gives incorrect exit code for 'alias' case. See:
            # https://salsa.debian.org/freedombox-team/freedombox/-/merge_requests/1980
            if action_utils.service_is_enabled(self.alias,
                                               strict_check=self.strict_check):
                return True

        return action_utils.service_is_enabled(self.unit,
                                               strict_check=self.strict_check)

    def enable(self):
        """Run operations to enable the daemon/unit."""
        from plinth.privileged import service as service_privileged
        service_privileged.enable(self.unit)
        if self.alias:
            service_privileged.enable(self.alias)

    def disable(self):
        """Run operations to disable the daemon/unit."""
        from plinth.privileged import service as service_privileged
        service_privileged.disable(self.unit)
        if self.alias:
            service_privileged.disable(self.alias)

    def is_running(self):
        """Return whether the daemon/unit is running."""
        return action_utils.service_is_running(self.unit)

    @contextlib.contextmanager
    def ensure_running(self):
        """Ensure a service is running and return to previous state."""
        from plinth.privileged import service as service_privileged

        if action_utils.service_show(self.unit)['LoadState'] == 'not-found':
            # The service's package not installed yet, don't try to start it
            # and later stop it after it is installed.
            yield False  # Not running
            return

        starting_state = self.is_running()
        if not starting_state:
            service_privileged.enable(self.unit)

        try:
            yield starting_state
        finally:
            if not starting_state:
                service_privileged.disable(self.unit)

    def diagnose(self) -> list[DiagnosticCheck]:
        """Check if the daemon is running and listening on expected ports.

        See :py:meth:`plinth.app.Component.diagnose`.

        """
        results = []
        results.append(self._diagnose_unit_is_running())
        for port in self.listen_ports:
            results.append(
                diagnose_port_listening(port[0], port[1], None,
                                        self.component_id))

        return results

    def _diagnose_unit_is_running(self) -> DiagnosticCheck:
        """Check if a daemon is running."""
        check_id = f'daemon-running-{self.unit}'
        result = Result.PASSED if self.is_running() else Result.FAILED

        description = gettext_noop('Service {service_name} is running')
        parameters: DiagnosticCheckParameters = {
            'service_name': str(self.unit)
        }

        return DiagnosticCheck(check_id, description, result, parameters,
                               self.component_id)


class RelatedDaemon(app.FollowerComponent, log.LogEmitter):
    """Component to hold information about additional systemd units handled.

    Unlike a daemon described by the Daemon component which is enabled/disabled
    when the app is enabled/disabled, the daemon described by this component is
    unaffected by the app's enabled/disabled status. The app only has an
    indirect interest in this daemon.

    This component primarily holds information about such daemon and does
    nothing else. This information is used to check if the app is allowed to
    perform operations on the daemon.
    """

    def __init__(self, component_id, unit):
        """Initialize a new related daemon component.

        'component_id' must be a unique string across all apps and components
        of a app. Conventionally starts with 'related-daemon-'.

        'unit' must the name of systemd unit.

        """
        super().__init__(component_id)

        self.unit = unit


class SharedDaemon(Daemon):
    """Component to manage a daemon that is used by multiple apps.

    Daemons such as a database server are a hard requirement for an app.
    However, there may be multiple apps using that server. This component
    ensures that server is enabled and running when app is enabled. It runs
    diagnostics on the daemon when app is diagnosed. The primary difference
    from the Daemon component is that when the app is disabled the daemon must
    only be disabled if there is no other app using this daemon.
    """

    # A shared daemon may be running even when an app is disabled because
    # another app might be using the daemon. Hence, the enabled/disabled state
    # of this component can't be used to determine the enabled/disabled state
    # of the app.
    is_leader = False

    def set_enabled(self, enabled):
        """Do nothing. Enabled state is still determined by unit status."""

    def disable(self):
        """Disable the daemon iff this is the last app using the daemon."""
        other_apps_enabled = False
        for other_app in app.App.list():
            if other_app.app_id == self.app_id:
                continue

            for component in other_app.get_components_of_type(SharedDaemon):
                if component.unit == self.unit and other_app.is_enabled():
                    other_apps_enabled = True

        if not other_apps_enabled:
            super().disable()


def app_is_running(app_):
    """Return whether all the daemons in the app are running."""
    for component in app_.components.values():
        if hasattr(component, 'is_running') and not component.is_running():
            return False

    return True


def diagnose_port_listening(
        port: int, kind: str = 'tcp', listen_address: str | None = None,
        component_id: str | None = None) -> DiagnosticCheck:
    """Run a diagnostic on whether a port is being listened on.

    Kind must be one of inet, inet4, inet6, tcp, tcp4, tcp6, udp,
    udp4, udp6, unix, all.  See psutil.net_connection() for more
    information.

    """
    result = _check_port(port, kind, listen_address)

    parameters: DiagnosticCheckParameters = {'kind': kind, 'port': port}
    if listen_address:
        parameters['listen_address'] = listen_address
        check_id = f'daemon-listening-address-{kind}-{port}-{listen_address}'
        description = gettext_noop(
            'Listening on {kind} port {listen_address}:{port}')
    else:
        check_id = f'daemon-listening-{kind}-{port}'
        description = gettext_noop('Listening on {kind} port {port}')

    return DiagnosticCheck(check_id, description,
                           Result.PASSED if result else Result.FAILED,
                           parameters, component_id)


def _check_port(port: int, kind: str = 'tcp',
                listen_address: str | None = None) -> bool:
    """Return whether a port is being listened on."""
    run_kind = kind

    if kind == 'tcp4':
        run_kind = 'tcp'

    if kind == 'udp4':
        run_kind = 'udp'

    for connection in psutil.net_connections(run_kind):
        # TCP connections must have status='listen'
        if kind in ('tcp', 'tcp4', 'tcp6') and \
           connection.status != psutil.CONN_LISTEN:
            continue

        # UDP connections must have empty remote address
        if kind in ('udp', 'udp4', 'udp6') and \
           connection.raddr != ():
            continue

        # Port should match
        if connection.laddr[1] != port:  # type: ignore[misc]
            continue

        # Listen address if requested should match
        if listen_address and connection.laddr[
                0] != listen_address:  # type: ignore[misc]
            continue

        # Special additional checks only for IPv4
        if kind not in ('tcp4', 'udp4'):
            return True

        # Found socket is IPv4
        if connection.family == socket.AF_INET:
            return True

        # Full IPv6 address range includes mapped IPv4 address also
        if connection.laddr[0] == '::':  # type: ignore[misc]
            return True

    return False


def diagnose_netcat(host: str, port: int, remote_input: str = '',
                    negate: bool = False,
                    component_id: str | None = None) -> DiagnosticCheck:
    """Run a diagnostic using netcat."""
    try:
        process = subprocess.Popen(['nc', host, str(port)],
                                   stdin=subprocess.PIPE,
                                   stdout=subprocess.PIPE,
                                   stderr=subprocess.PIPE)
        process.communicate(input=remote_input.encode())
        if process.returncode != 0:
            result = Result.FAILED if not negate else Result.PASSED
        else:
            result = Result.PASSED if not negate else Result.FAILED
    except Exception:
        result = Result.FAILED

    check_id = f'daemon-netcat-{host}-{port}'
    description = gettext_noop('Connect to {host}:{port}')
    parameters: DiagnosticCheckParameters = {
        'host': host,
        'port': port,
        'negate': negate
    }
    if negate:
        check_id = f'daemon-netcat-negate-{host}-{port}'
        description = gettext_noop('Cannot connect to {host}:{port}')

    return DiagnosticCheck(check_id, description, result, parameters,
                           component_id)