--- /srv/reproducible-results/rbuild-debian/r-b-build.CgFvhNOk/b1/cockpit_316-1_armhf.changes
+++ /srv/reproducible-results/rbuild-debian/r-b-build.CgFvhNOk/b2/cockpit_316-1_armhf.changes
├── Files
│ @@ -1,10 +1,10 @@
│
│ 760242981c5b49ff96bf84495fa91aa1 138280 debug optional cockpit-bridge-dbgsym_316-1_armhf.deb
│ - a12ed1b47518f1e6508d7cd49f40f947 354160 admin optional cockpit-bridge_316-1_armhf.deb
│ + 567d14ea09e664a5190dcb5693fd82e8 354536 admin optional cockpit-bridge_316-1_armhf.deb
│ b244333d93e755b8795d5de9bfd74a75 131516 doc optional cockpit-doc_316-1_all.deb
│ 5f5f51363cccd25ac69165e1bfe089be 829340 admin optional cockpit-networkmanager_316-1_all.deb
│ b259a5a3925e9a8e04317b97aff99eea 941992 admin optional cockpit-packagekit_316-1_all.deb
│ 4dbce21d388fa24e5bed69be2cddd7a5 231296 debug optional cockpit-pcp-dbgsym_316-1_armhf.deb
│ a4aa6122dd089728e6df0468cf433908 71148 admin optional cockpit-pcp_316-1_armhf.deb
│ ca337edae770f17961c72b2ed6a3a2e1 558888 admin optional cockpit-sosreport_316-1_all.deb
│ 510e4876abdbe0222c7a9eaf8eed0969 879356 admin optional cockpit-storaged_316-1_all.deb
├── cockpit-bridge_316-1_armhf.deb
│ ├── file list
│ │ @@ -1,3 +1,3 @@
│ │ -rw-r--r-- 0 0 0 4 2024-04-26 05:46:21.000000 debian-binary
│ │ --rw-r--r-- 0 0 0 3880 2024-04-26 05:46:21.000000 control.tar.xz
│ │ --rw-r--r-- 0 0 0 350088 2024-04-26 05:46:21.000000 data.tar.xz
│ │ +-rw-r--r-- 0 0 0 3884 2024-04-26 05:46:21.000000 control.tar.xz
│ │ +-rw-r--r-- 0 0 0 350460 2024-04-26 05:46:21.000000 data.tar.xz
│ ├── control.tar.xz
│ │ ├── control.tar
│ │ │ ├── ./md5sums
│ │ │ │ ├── ./md5sums
│ │ │ │ │┄ Files differ
│ ├── data.tar.xz
│ │ ├── data.tar
│ │ │ ├── file list
│ │ │ │ @@ -60,15 +60,15 @@
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 6653 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/channels/metrics.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 4058 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/channels/packages.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 4872 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/channels/stream.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 1171 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/channels/trivial.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 3188 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/config.py
│ │ │ │ drwxr-xr-x 0 root (0) root (0) 0 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/data/
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 574 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/data/__init__.py
│ │ │ │ --rw-r--r-- 0 root (0) root (0) 86616 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz
│ │ │ │ +-rw-r--r-- 0 root (0) root (0) 87016 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 3212 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/data/fail.html
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 5517 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/internal_endpoints.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 7242 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/jsonutil.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 21539 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/packages.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 12729 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/peer.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 7580 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/polkit.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 2031 2024-04-26 05:46:21.000000 ./usr/lib/python3/dist-packages/cockpit/polyfills.py
│ │ │ ├── ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz
│ │ │ │ ├── cockpit-bridge.beipack
│ │ │ │ │┄ Ordering differences only
│ │ │ │ │ @@ -64,15 +64,15 @@
│ │ │ │ │ ) -> Optional[importlib.machinery.ModuleSpec]:
│ │ │ │ │ if fullname not in self.modules:
│ │ │ │ │ return None
│ │ │ │ │ return importlib.util.spec_from_loader(fullname, self)
│ │ │ │ │
│ │ │ │ │ import sys
│ │ │ │ │ sys.meta_path.insert(0, BeipackLoader({
│ │ │ │ │ - 'cockpit/samples.py': br'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/remote.py': r'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -81,436 +81,989 @@
│ │ │ │ │ # 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 .
│ │ │ │ │
│ │ │ │ │ -import errno
│ │ │ │ │ +import getpass
│ │ │ │ │ import logging
│ │ │ │ │ -import os
│ │ │ │ │ import re
│ │ │ │ │ -from typing import Any, DefaultDict, Iterable, List, NamedTuple, Optional, Tuple
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Handle
│ │ │ │ │ +import socket
│ │ │ │ │ +from typing import Dict, List, Optional, Tuple
│ │ │ │ │
│ │ │ │ │ -USER_HZ = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
│ │ │ │ │ -MS_PER_JIFFY = 1000 / (USER_HZ if (USER_HZ > 0) else 100)
│ │ │ │ │ -HWMON_PATH = '/sys/class/hwmon'
│ │ │ │ │ +from cockpit._vendor import ferny
│ │ │ │ │
│ │ │ │ │ -# we would like to do this, but mypy complains; https://github.com/python/mypy/issues/2900
│ │ │ │ │ -# Samples = collections.defaultdict[str, Union[float, Dict[str, Union[float, None]]]]
│ │ │ │ │ -Samples = DefaultDict[str, Any]
│ │ │ │ │ +from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none
│ │ │ │ │ +from .peer import Peer, PeerError
│ │ │ │ │ +from .router import Router, RoutingRule
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def read_int_file(rootfd: int, statfile: str, default: Optional[int] = None, key: bytes = b'') -> Optional[int]:
│ │ │ │ │ - # Not every stat is available, such as cpu.weight
│ │ │ │ │ - try:
│ │ │ │ │ - fd = os.open(statfile, os.O_RDONLY, dir_fd=rootfd)
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - return None
│ │ │ │ │ +class PasswordResponder(ferny.AskpassHandler):
│ │ │ │ │ + PASSPHRASE_RE = re.compile(r"Enter passphrase for key '(.*)': ")
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - data = os.read(fd, 1024)
│ │ │ │ │ - except OSError as e:
│ │ │ │ │ - # cgroups can disappear between the open and read
│ │ │ │ │ - if e.errno != errno.ENODEV:
│ │ │ │ │ - logger.warning('Failed to read %s: %s', statfile, e)
│ │ │ │ │ - return None
│ │ │ │ │ - finally:
│ │ │ │ │ - os.close(fd)
│ │ │ │ │ + password: Optional[str]
│ │ │ │ │
│ │ │ │ │ - if key:
│ │ │ │ │ - start = data.index(key) + len(key)
│ │ │ │ │ - end = data.index(b'\n', start)
│ │ │ │ │ - data = data[start:end]
│ │ │ │ │ + hostkeys_seen: List[Tuple[str, str, str, str, str]]
│ │ │ │ │ + error_message: Optional[str]
│ │ │ │ │ + password_attempts: int
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - # 0 often means "none", so replace it with default value
│ │ │ │ │ - return int(data) or default
│ │ │ │ │ - except ValueError:
│ │ │ │ │ - # Some samples such as "memory.max" contains "max" when there is a no limit
│ │ │ │ │ - return None
│ │ │ │ │ + def __init__(self, password: Optional[str]):
│ │ │ │ │ + self.password = password
│ │ │ │ │
│ │ │ │ │ + self.hostkeys_seen = []
│ │ │ │ │ + self.error_message = None
│ │ │ │ │ + self.password_attempts = 0
│ │ │ │ │
│ │ │ │ │ -class SampleDescription(NamedTuple):
│ │ │ │ │ - name: str
│ │ │ │ │ - units: str
│ │ │ │ │ - semantics: str
│ │ │ │ │ - instanced: bool
│ │ │ │ │ + async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:
│ │ │ │ │ + self.hostkeys_seen.append((reason, host, algorithm, key, fingerprint))
│ │ │ │ │ + return False
│ │ │ │ │
│ │ │ │ │ + async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:
│ │ │ │ │ + logger.debug('Got askpass(%s): %s', hint, prompt)
│ │ │ │ │
│ │ │ │ │ -class Sampler:
│ │ │ │ │ - descriptions: List[SampleDescription]
│ │ │ │ │ + match = PasswordResponder.PASSPHRASE_RE.fullmatch(prompt)
│ │ │ │ │ + if match is not None:
│ │ │ │ │ + # We never unlock private keys — we rather need to throw a
│ │ │ │ │ + # specially-formatted error message which will cause the frontend
│ │ │ │ │ + # to load the named key into the agent for us and try again.
│ │ │ │ │ + path = match.group(1)
│ │ │ │ │ + logger.debug("This is a passphrase request for %s, but we don't do those. Abort.", path)
│ │ │ │ │ + self.error_message = f'locked identity: {path}'
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + assert self.password is not None
│ │ │ │ │ + assert self.password_attempts == 0
│ │ │ │ │ + self.password_attempts += 1
│ │ │ │ │ + return self.password
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class CPUSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('cpu.basic.nice', 'millisec', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('cpu.basic.user', 'millisec', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('cpu.basic.system', 'millisec', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('cpu.basic.iowait', 'millisec', 'counter', instanced=False),
│ │ │ │ │ +class SshPeer(Peer):
│ │ │ │ │ + session: Optional[ferny.Session] = None
│ │ │ │ │ + host: str
│ │ │ │ │ + user: Optional[str]
│ │ │ │ │ + password: Optional[str]
│ │ │ │ │ + private: bool
│ │ │ │ │
│ │ │ │ │ - SampleDescription('cpu.core.nice', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cpu.core.user', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cpu.core.system', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cpu.core.iowait', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + async def do_connect_transport(self) -> None:
│ │ │ │ │ + assert self.session is not None
│ │ │ │ │ + logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private)
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/stat') as stat:
│ │ │ │ │ - for line in stat:
│ │ │ │ │ - if not line.startswith('cpu'):
│ │ │ │ │ - continue
│ │ │ │ │ - cpu, user, nice, system, _idle, iowait = line.split()[:6]
│ │ │ │ │ - core = cpu[3:] or None
│ │ │ │ │ - if core:
│ │ │ │ │ - prefix = 'cpu.core'
│ │ │ │ │ - samples[f'{prefix}.nice'][core] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.user'][core] = int(user) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.system'][core] = int(system) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.iowait'][core] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │ + basename, colon, portstr = self.host.rpartition(':')
│ │ │ │ │ + if colon and portstr.isdigit():
│ │ │ │ │ + host = basename
│ │ │ │ │ + port = int(portstr)
│ │ │ │ │ + else:
│ │ │ │ │ + host = self.host
│ │ │ │ │ + port = None
│ │ │ │ │ +
│ │ │ │ │ + responder = PasswordResponder(self.password)
│ │ │ │ │ + options = {"StrictHostKeyChecking": 'yes'}
│ │ │ │ │ +
│ │ │ │ │ + if self.password is not None:
│ │ │ │ │ + options.update(NumberOfPasswordPrompts='1')
│ │ │ │ │ + else:
│ │ │ │ │ + options.update(PasswordAuthentication="no", KbdInteractiveAuthentication="no")
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + await self.session.connect(host, login_name=self.user, port=port,
│ │ │ │ │ + handle_host_key=self.private, options=options,
│ │ │ │ │ + interaction_responder=responder)
│ │ │ │ │ + except (OSError, socket.gaierror) as exc:
│ │ │ │ │ + logger.debug('connecting to host %s failed: %s', host, exc)
│ │ │ │ │ + raise PeerError('no-host', error='no-host', message=str(exc)) from exc
│ │ │ │ │ +
│ │ │ │ │ + except ferny.SshHostKeyError as exc:
│ │ │ │ │ + if responder.hostkeys_seen:
│ │ │ │ │ + # If we saw a hostkey then we can issue a detailed error message
│ │ │ │ │ + # containing the key that would need to be accepted. That will
│ │ │ │ │ + # cause the front-end to present a dialog.
│ │ │ │ │ + _reason, host, algorithm, key, fingerprint = responder.hostkeys_seen[0]
│ │ │ │ │ + error_args = {'host-key': f'{host} {algorithm} {key}', 'host-fingerprint': fingerprint}
│ │ │ │ │ + else:
│ │ │ │ │ + error_args = {}
│ │ │ │ │ +
│ │ │ │ │ + if isinstance(exc, ferny.SshChangedHostKeyError):
│ │ │ │ │ + error = 'invalid-hostkey'
│ │ │ │ │ + elif self.private:
│ │ │ │ │ + error = 'unknown-hostkey'
│ │ │ │ │ + else:
│ │ │ │ │ + # non-private session case. throw a generic error.
│ │ │ │ │ + error = 'unknown-host'
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('SshPeer got a %s %s; private %s, seen hostkeys %r; raising %s with extra args %r',
│ │ │ │ │ + type(exc), exc, self.private, responder.hostkeys_seen, error, error_args)
│ │ │ │ │ + raise PeerError(error, error_args, error=error, auth_method_results={}) from exc
│ │ │ │ │ +
│ │ │ │ │ + except ferny.SshAuthenticationError as exc:
│ │ │ │ │ + logger.debug('authentication to host %s failed: %s', host, exc)
│ │ │ │ │ +
│ │ │ │ │ + results = dict.fromkeys(exc.methods, "not-provided")
│ │ │ │ │ + if 'password' in results and self.password is not None:
│ │ │ │ │ + if responder.password_attempts == 0:
│ │ │ │ │ + results['password'] = 'not-tried'
│ │ │ │ │ else:
│ │ │ │ │ - prefix = 'cpu.basic'
│ │ │ │ │ - samples[f'{prefix}.nice'] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.user'] = int(user) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.system'] = int(system) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.iowait'] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │ + results['password'] = 'denied'
│ │ │ │ │
│ │ │ │ │ + raise PeerError('authentication-failed',
│ │ │ │ │ + error=responder.error_message or 'authentication-failed',
│ │ │ │ │ + auth_method_results=results) from exc
│ │ │ │ │
│ │ │ │ │ -class MemorySampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('memory.free', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - SampleDescription('memory.used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - SampleDescription('memory.cached', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - SampleDescription('memory.swap-used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - ]
│ │ │ │ │ + except ferny.SshError as exc:
│ │ │ │ │ + logger.debug('unknown failure connecting to host %s: %s', host, exc)
│ │ │ │ │ + raise PeerError('internal-error', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/meminfo') as meminfo:
│ │ │ │ │ - items = {k: int(v.strip(' kB\n')) for line in meminfo for k, v in [line.split(':', 1)]}
│ │ │ │ │ + args = self.session.wrap_subprocess_args(['cockpit-bridge'])
│ │ │ │ │ + await self.spawn(args, [])
│ │ │ │ │
│ │ │ │ │ - samples['memory.free'] = 1024 * items['MemFree']
│ │ │ │ │ - samples['memory.used'] = 1024 * (items['MemTotal'] - items['MemAvailable'])
│ │ │ │ │ - samples['memory.cached'] = 1024 * (items['Buffers'] + items['Cached'])
│ │ │ │ │ - samples['memory.swap-used'] = 1024 * (items['SwapTotal'] - items['SwapFree'])
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + if host == self.host:
│ │ │ │ │ + self.close()
│ │ │ │ │ + elif host is None:
│ │ │ │ │ + super().do_kill(host, group, message)
│ │ │ │ │
│ │ │ │ │ + def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ + if get_str(message, 'challenge').startswith('plain1:'):
│ │ │ │ │ + cookie = get_str(message, 'cookie')
│ │ │ │ │ + self.write_control(command='authorize', cookie=cookie, response=self.password or '')
│ │ │ │ │ + self.password = None # once is enough...
│ │ │ │ │
│ │ │ │ │ -class CPUTemperatureSampler(Sampler):
│ │ │ │ │ - # Cache found sensors, as they can't be hotplugged.
│ │ │ │ │ - sensors: Optional[List[str]] = None
│ │ │ │ │ + def do_superuser_init_done(self) -> None:
│ │ │ │ │ + self.password = None
│ │ │ │ │
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('cpu.temperature', 'celsius', 'instant', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None:
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │ + self.host = host
│ │ │ │ │ + self.user = user
│ │ │ │ │ + self.password = get_str(options, 'password', None)
│ │ │ │ │ + self.private = private
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def detect_cpu_sensors(dir_fd: int) -> Iterable[str]:
│ │ │ │ │ - # Read the name file to decide what to do with this directory
│ │ │ │ │ - try:
│ │ │ │ │ - with Handle.open('name', os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ - name = os.read(fd, 1024).decode().strip()
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - return
│ │ │ │ │ + self.session = ferny.Session()
│ │ │ │ │
│ │ │ │ │ - if name == 'atk0110':
│ │ │ │ │ - # only sample 'CPU Temperature' in atk0110
│ │ │ │ │ - predicate = (lambda label: label == 'CPU Temperature')
│ │ │ │ │ - elif name == 'cpu_thermal':
│ │ │ │ │ - # labels are not used on ARM
│ │ │ │ │ - predicate = None
│ │ │ │ │ - elif name == 'coretemp':
│ │ │ │ │ - # accept all labels on Intel
│ │ │ │ │ - predicate = None
│ │ │ │ │ - elif name in ['k8temp', 'k10temp']:
│ │ │ │ │ - predicate = None
│ │ │ │ │ + superuser: JsonValue
│ │ │ │ │ + init_superuser = get_str_or_none(options, 'init-superuser', None)
│ │ │ │ │ + if init_superuser in (None, 'none'):
│ │ │ │ │ + superuser = False
│ │ │ │ │ else:
│ │ │ │ │ - # Not a CPU sensor
│ │ │ │ │ + superuser = {'id': init_superuser}
│ │ │ │ │ +
│ │ │ │ │ + self.start_in_background(init_host=host, superuser=superuser)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class HostRoutingRule(RoutingRule):
│ │ │ │ │ + remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer]
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, router):
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │ + self.remotes = {}
│ │ │ │ │ +
│ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Peer]:
│ │ │ │ │ + assert self.router is not None
│ │ │ │ │ + assert self.router.init_host is not None
│ │ │ │ │ +
│ │ │ │ │ + host = get_str(options, 'host', self.router.init_host)
│ │ │ │ │ + if host == self.router.init_host:
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ + user = get_str(options, 'user', None)
│ │ │ │ │ + # HACK: the front-end relies on this for tracking connections without an explicit user name;
│ │ │ │ │ + # the user will then be determined by SSH (`User` in the config or the current user)
│ │ │ │ │ + # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c
│ │ │ │ │ + if user == getpass.getuser():
│ │ │ │ │ + user = None
│ │ │ │ │ + if not user:
│ │ │ │ │ + user_from_host, _, _ = host.rpartition('@')
│ │ │ │ │ + user = user_from_host or None # avoid ''
│ │ │ │ │ +
│ │ │ │ │ + if get_str(options, 'session', None) == 'private':
│ │ │ │ │ + nonce = get_str(options, 'channel')
│ │ │ │ │ + else:
│ │ │ │ │ + nonce = None
│ │ │ │ │ +
│ │ │ │ │ + assert isinstance(host, str)
│ │ │ │ │ + assert user is None or isinstance(user, str)
│ │ │ │ │ + assert nonce is None or isinstance(nonce, str)
│ │ │ │ │ +
│ │ │ │ │ + key = host, user, nonce
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Request for channel %s is remote.', options)
│ │ │ │ │ + logger.debug('key=%s', key)
│ │ │ │ │ +
│ │ │ │ │ + if key not in self.remotes:
│ │ │ │ │ + logger.debug('%s is not among the existing remotes %s. Opening a new connection.', key, self.remotes)
│ │ │ │ │ + peer = SshPeer(self.router, host, user, options, private=nonce is not None)
│ │ │ │ │ + peer.add_done_callback(lambda: self.remotes.__delitem__(key))
│ │ │ │ │ + self.remotes[key] = peer
│ │ │ │ │ +
│ │ │ │ │ + return self.remotes[key]
│ │ │ │ │ +
│ │ │ │ │ + def shutdown(self):
│ │ │ │ │ + for peer in set(self.remotes.values()):
│ │ │ │ │ + peer.close()
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ + 'cockpit/internal_endpoints.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import glob
│ │ │ │ │ +import grp
│ │ │ │ │ +import json
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import pwd
│ │ │ │ │ +from pathlib import Path
│ │ │ │ │ +from typing import Dict, Optional
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch
│ │ │ │ │ +
│ │ │ │ │ +from . import config
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class cockpit_LoginMessages(bus.Object):
│ │ │ │ │ + messages: Optional[str] = None
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + fdstr = os.environ.pop('COCKPIT_LOGIN_MESSAGES_MEMFD', None)
│ │ │ │ │ + if fdstr is None:
│ │ │ │ │ + logger.debug("COCKPIT_LOGIN_MESSAGES_MEMFD wasn't set. No login messages today.")
│ │ │ │ │ return
│ │ │ │ │
│ │ │ │ │ - # Now scan the directory for inputs
│ │ │ │ │ - for input_filename in os.listdir(dir_fd):
│ │ │ │ │ - if not input_filename.endswith('_input'):
│ │ │ │ │ - continue
│ │ │ │ │ + logger.debug("Trying to read login messages from fd %s", fdstr)
│ │ │ │ │ + try:
│ │ │ │ │ + with open(int(fdstr), 'r') as login_messages:
│ │ │ │ │ + login_messages.seek(0)
│ │ │ │ │ + self.messages = login_messages.read()
│ │ │ │ │ + except (ValueError, OSError, UnicodeDecodeError) as exc:
│ │ │ │ │ + # ValueError - the envvar wasn't an int
│ │ │ │ │ + # OSError - the fd wasn't open, or other read failure
│ │ │ │ │ + # UnicodeDecodeError - didn't contain utf-8
│ │ │ │ │ + # For all of these, we simply failed to get the message.
│ │ │ │ │ + logger.debug("Reading login messages failed: %s", exc)
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug("Successfully read login messages: %s", self.messages)
│ │ │ │ │
│ │ │ │ │ - if predicate:
│ │ │ │ │ - # We need to check the label
│ │ │ │ │ - try:
│ │ │ │ │ - label_filename = input_filename.replace('_input', '_label')
│ │ │ │ │ - with Handle.open(label_filename, os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ - label = os.read(fd, 1024).decode().strip()
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - continue
│ │ │ │ │ + @bus.Interface.Method(out_types=['s'])
│ │ │ │ │ + def get(self):
│ │ │ │ │ + return self.messages or '{}'
│ │ │ │ │
│ │ │ │ │ - if not predicate(label):
│ │ │ │ │ + @bus.Interface.Method(out_types=[])
│ │ │ │ │ + def dismiss(self):
│ │ │ │ │ + self.messages = None
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class cockpit_Machines(bus.Object):
│ │ │ │ │ + path: Path
│ │ │ │ │ + watch: pathwatch.PathWatch
│ │ │ │ │ + pending_notify: Optional[asyncio.Handle]
│ │ │ │ │ +
│ │ │ │ │ + # D-Bus implementation
│ │ │ │ │ + machines = bus.Interface.Property('a{sa{sv}}')
│ │ │ │ │ +
│ │ │ │ │ + @machines.getter
│ │ │ │ │ + def get_machines(self) -> Dict[str, Dict[str, Variant]]:
│ │ │ │ │ + results: Dict[str, Dict[str, Variant]] = {}
│ │ │ │ │ +
│ │ │ │ │ + for filename in glob.glob(f'{self.path}/*.json'):
│ │ │ │ │ + with open(filename) as fp:
│ │ │ │ │ + try:
│ │ │ │ │ + contents = json.load(fp)
│ │ │ │ │ + except json.JSONDecodeError:
│ │ │ │ │ + logger.warning('Invalid JSON in file %s. Ignoring.', filename)
│ │ │ │ │ continue
│ │ │ │ │ + # merge
│ │ │ │ │ + for hostname, attrs in contents.items():
│ │ │ │ │ + results[hostname] = {key: Variant(value) for key, value in attrs.items()}
│ │ │ │ │
│ │ │ │ │ - yield input_filename
│ │ │ │ │ + return results
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def scan_sensors() -> Iterable[str]:
│ │ │ │ │ + @bus.Interface.Method(in_types=['s', 's', 'a{sv}'])
│ │ │ │ │ + def update(self, filename: str, hostname: str, attrs: Dict[str, Variant]) -> None:
│ │ │ │ │ try:
│ │ │ │ │ - top_fd = Handle.open(HWMON_PATH, os.O_RDONLY | os.O_DIRECTORY)
│ │ │ │ │ + with self.path.joinpath(filename).open() as fp:
│ │ │ │ │ + contents = json.load(fp)
│ │ │ │ │ + except json.JSONDecodeError as exc:
│ │ │ │ │ + # Refuse to replace corrupted file
│ │ │ │ │ + raise bus.BusError('cockpit.Machines.Error', f'File {filename} is in invalid format: {exc}.') from exc
│ │ │ │ │ except FileNotFoundError:
│ │ │ │ │ + # But an empty file is an expected case
│ │ │ │ │ + contents = {}
│ │ │ │ │ +
│ │ │ │ │ + contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()})
│ │ │ │ │ +
│ │ │ │ │ + self.path.mkdir(parents=True, exist_ok=True)
│ │ │ │ │ + with open(self.path.joinpath(filename), 'w') as fp:
│ │ │ │ │ + json.dump(contents, fp, indent=2)
│ │ │ │ │ +
│ │ │ │ │ + def notify(self):
│ │ │ │ │ + def _notify_now():
│ │ │ │ │ + self.properties_changed('cockpit.Machines', {}, ['Machines'])
│ │ │ │ │ + self.pending_notify = None
│ │ │ │ │ +
│ │ │ │ │ + # avoid a flurry of update notifications
│ │ │ │ │ + if self.pending_notify is None:
│ │ │ │ │ + self.pending_notify = asyncio.get_running_loop().call_later(1.0, _notify_now)
│ │ │ │ │ +
│ │ │ │ │ + # inotify events
│ │ │ │ │ + def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None:
│ │ │ │ │ + self.notify()
│ │ │ │ │ +
│ │ │ │ │ + def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None:
│ │ │ │ │ + self.notify()
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + self.path = config.lookup_config('machines.d')
│ │ │ │ │ +
│ │ │ │ │ + # ignore the first callback
│ │ │ │ │ + self.pending_notify = ...
│ │ │ │ │ + self.watch = pathwatch.PathWatch(str(self.path), self)
│ │ │ │ │ + self.pending_notify = None
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class cockpit_User(bus.Object):
│ │ │ │ │ + name = bus.Interface.Property('s', value='')
│ │ │ │ │ + full = bus.Interface.Property('s', value='')
│ │ │ │ │ + id = bus.Interface.Property('i', value=0)
│ │ │ │ │ + home = bus.Interface.Property('s', value='')
│ │ │ │ │ + shell = bus.Interface.Property('s', value='')
│ │ │ │ │ + groups = bus.Interface.Property('as', value=[])
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + user = pwd.getpwuid(os.getuid())
│ │ │ │ │ + self.name = user.pw_name
│ │ │ │ │ + self.full = user.pw_gecos
│ │ │ │ │ + self.id = user.pw_uid
│ │ │ │ │ + self.home = user.pw_dir
│ │ │ │ │ + self.shell = user.pw_shell
│ │ │ │ │ + self.groups = [gr.gr_name for gr in grp.getgrall() if user.pw_name in gr.gr_mem]
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +EXPORTS = [
│ │ │ │ │ + ('/LoginMessages', cockpit_LoginMessages),
│ │ │ │ │ + ('/machines', cockpit_Machines),
│ │ │ │ │ + ('/user', cockpit_User),
│ │ │ │ │ +]
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/router.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import collections
│ │ │ │ │ +import logging
│ │ │ │ │ +from typing import Dict, List, Optional
│ │ │ │ │ +
│ │ │ │ │ +from .jsonutil import JsonObject, JsonValue
│ │ │ │ │ +from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class ExecutionQueue:
│ │ │ │ │ + """Temporarily delay calls to a given set of class methods.
│ │ │ │ │ +
│ │ │ │ │ + Functions by replacing the named function at the instance __dict__
│ │ │ │ │ + level, effectively providing an override for exactly one instance
│ │ │ │ │ + of `method`'s object.
│ │ │ │ │ + Queues the invocations. Run them later with .run(), which also reverses
│ │ │ │ │ + the redirection by deleting the named methods from the instance.
│ │ │ │ │ + """
│ │ │ │ │ + def __init__(self, methods):
│ │ │ │ │ + self.queue = collections.deque()
│ │ │ │ │ + self.methods = methods
│ │ │ │ │ +
│ │ │ │ │ + for method in self.methods:
│ │ │ │ │ + self._wrap(method)
│ │ │ │ │ +
│ │ │ │ │ + def _wrap(self, method):
│ │ │ │ │ + # NB: this function is stored in the instance dict and therefore
│ │ │ │ │ + # doesn't function as a descriptor, isn't a method, doesn't get bound,
│ │ │ │ │ + # and therefore doesn't receive a self parameter
│ │ │ │ │ + setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args)))
│ │ │ │ │ +
│ │ │ │ │ + def run(self):
│ │ │ │ │ + logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue))
│ │ │ │ │ + for method, args in self.queue:
│ │ │ │ │ + method(*args)
│ │ │ │ │ +
│ │ │ │ │ + for method in self.methods:
│ │ │ │ │ + delattr(method.__self__, method.__func__.__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Endpoint:
│ │ │ │ │ + router: 'Router'
│ │ │ │ │ + __endpoint_frozen_queue: Optional[ExecutionQueue] = None
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, router: 'Router'):
│ │ │ │ │ + router.add_endpoint(self)
│ │ │ │ │ + self.router = router
│ │ │ │ │ +
│ │ │ │ │ + def freeze_endpoint(self):
│ │ │ │ │ + assert self.__endpoint_frozen_queue is None
│ │ │ │ │ + logger.debug('Freezing endpoint %s', self)
│ │ │ │ │ + self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill})
│ │ │ │ │ +
│ │ │ │ │ + def thaw_endpoint(self):
│ │ │ │ │ + assert self.__endpoint_frozen_queue is not None
│ │ │ │ │ + logger.debug('Thawing endpoint %s', self)
│ │ │ │ │ + self.__endpoint_frozen_queue.run()
│ │ │ │ │ + self.__endpoint_frozen_queue = None
│ │ │ │ │ +
│ │ │ │ │ + # interface for receiving messages
│ │ │ │ │ + def do_close(self):
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + # interface for sending messages
│ │ │ │ │ + def send_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + self.router.write_channel_data(channel, data)
│ │ │ │ │ +
│ │ │ │ │ + def send_channel_control(
│ │ │ │ │ + self, channel: str, command: str, _msg: 'JsonObject | None', **kwargs: JsonValue
│ │ │ │ │ + ) -> None:
│ │ │ │ │ + self.router.write_control(_msg, channel=channel, command=command, **kwargs)
│ │ │ │ │ + if command == 'close':
│ │ │ │ │ + self.router.endpoints[self].remove(channel)
│ │ │ │ │ + self.router.drop_channel(channel)
│ │ │ │ │ +
│ │ │ │ │ + def shutdown_endpoint(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ + self.router.shutdown_endpoint(self, _msg, **kwargs)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class RoutingError(CockpitProblem):
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class RoutingRule:
│ │ │ │ │ + router: 'Router'
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, router: 'Router'):
│ │ │ │ │ + self.router = router
│ │ │ │ │ +
│ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:
│ │ │ │ │ + """Check if a routing rule applies to a given 'open' message.
│ │ │ │ │ +
│ │ │ │ │ + This should inspect the options dictionary and do one of the following three things:
│ │ │ │ │ +
│ │ │ │ │ + - return an Endpoint to handle this channel
│ │ │ │ │ + - raise a RoutingError to indicate that the open should be rejected
│ │ │ │ │ + - return None to let the next rule run
│ │ │ │ │ + """
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def shutdown(self):
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Router(CockpitProtocolServer):
│ │ │ │ │ + routing_rules: List[RoutingRule]
│ │ │ │ │ + open_channels: Dict[str, Endpoint]
│ │ │ │ │ + endpoints: 'dict[Endpoint, set[str]]'
│ │ │ │ │ + no_endpoints: asyncio.Event # set if endpoints dict is empty
│ │ │ │ │ + _eof: bool = False
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, routing_rules: List[RoutingRule]):
│ │ │ │ │ + for rule in routing_rules:
│ │ │ │ │ + rule.router = self
│ │ │ │ │ + self.routing_rules = routing_rules
│ │ │ │ │ + self.open_channels = {}
│ │ │ │ │ + self.endpoints = {}
│ │ │ │ │ + self.no_endpoints = asyncio.Event()
│ │ │ │ │ + self.no_endpoints.set() # at first there are no endpoints
│ │ │ │ │ +
│ │ │ │ │ + def check_rules(self, options: JsonObject) -> Endpoint:
│ │ │ │ │ + for rule in self.routing_rules:
│ │ │ │ │ + logger.debug(' applying rule %s', rule)
│ │ │ │ │ + endpoint = rule.apply_rule(options)
│ │ │ │ │ + if endpoint is not None:
│ │ │ │ │ + logger.debug(' resulting endpoint is %s', endpoint)
│ │ │ │ │ + return endpoint
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug(' No rules matched')
│ │ │ │ │ + raise RoutingError('not-supported')
│ │ │ │ │ +
│ │ │ │ │ + def drop_channel(self, channel: str) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + self.open_channels.pop(channel)
│ │ │ │ │ + logger.debug('router dropped channel %s', channel)
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels)
│ │ │ │ │ +
│ │ │ │ │ + def add_endpoint(self, endpoint: Endpoint) -> None:
│ │ │ │ │ + self.endpoints[endpoint] = set()
│ │ │ │ │ + self.no_endpoints.clear()
│ │ │ │ │ +
│ │ │ │ │ + def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ + channels = self.endpoints.pop(endpoint)
│ │ │ │ │ + logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels)
│ │ │ │ │ + for channel in channels:
│ │ │ │ │ + self.write_control(_msg, command='close', channel=channel, **kwargs)
│ │ │ │ │ + self.drop_channel(channel)
│ │ │ │ │ +
│ │ │ │ │ + if not self.endpoints:
│ │ │ │ │ + self.no_endpoints.set()
│ │ │ │ │ +
│ │ │ │ │ + # were we waiting to exit?
│ │ │ │ │ + if self._eof:
│ │ │ │ │ + logger.debug(' endpoints remaining: %r', self.endpoints)
│ │ │ │ │ + if not self.endpoints and self.transport:
│ │ │ │ │ + logger.debug(' close transport')
│ │ │ │ │ + self.transport.close()
│ │ │ │ │ +
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + endpoints = set(self.endpoints)
│ │ │ │ │ + logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints))
│ │ │ │ │ + for endpoint in endpoints:
│ │ │ │ │ + endpoint.do_kill(host, group, message)
│ │ │ │ │ +
│ │ │ │ │ + def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + # If this is an open message then we need to apply the routing rules to
│ │ │ │ │ + # figure out the correct endpoint to connect. If it's not an open
│ │ │ │ │ + # message, then we expect the endpoint to already exist.
│ │ │ │ │ + if command == 'open':
│ │ │ │ │ + if channel in self.open_channels:
│ │ │ │ │ + raise CockpitProtocolError('channel is already open')
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload'))
│ │ │ │ │ + endpoint = self.check_rules(message)
│ │ │ │ │ + except RoutingError as exc:
│ │ │ │ │ + self.write_control(exc.get_attrs(), command='close', channel=channel)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + self.open_channels[channel] = endpoint
│ │ │ │ │ + self.endpoints[endpoint].add(channel)
│ │ │ │ │ + else:
│ │ │ │ │ + try:
│ │ │ │ │ + endpoint = self.open_channels[channel]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + # sending to a non-existent channel can happen due to races and is not an error
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + # At this point, we have the endpoint. Route the message.
│ │ │ │ │ + endpoint.do_channel_control(channel, command, message)
│ │ │ │ │ +
│ │ │ │ │ + def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + endpoint = self.open_channels[channel]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ return
│ │ │ │ │
│ │ │ │ │ - with top_fd:
│ │ │ │ │ - for hwmon_name in os.listdir(top_fd):
│ │ │ │ │ - with Handle.open(hwmon_name, os.O_RDONLY | os.O_DIRECTORY, dir_fd=top_fd) as subdir_fd:
│ │ │ │ │ - for sensor in CPUTemperatureSampler.detect_cpu_sensors(subdir_fd):
│ │ │ │ │ - yield f'{HWMON_PATH}/{hwmon_name}/{sensor}'
│ │ │ │ │ + endpoint.do_channel_data(channel, data)
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - if self.sensors is None:
│ │ │ │ │ - self.sensors = list(CPUTemperatureSampler.scan_sensors())
│ │ │ │ │ + def eof_received(self) -> bool:
│ │ │ │ │ + logger.debug('eof_received(%r)', self)
│ │ │ │ │
│ │ │ │ │ - for sensor_path in self.sensors:
│ │ │ │ │ - with open(sensor_path) as sensor:
│ │ │ │ │ - temperature = int(sensor.read().strip())
│ │ │ │ │ - if temperature == 0:
│ │ │ │ │ - return
│ │ │ │ │ + endpoints = set(self.endpoints)
│ │ │ │ │ + for endpoint in endpoints:
│ │ │ │ │ + endpoint.do_close()
│ │ │ │ │
│ │ │ │ │ - samples['cpu.temperature'][sensor_path] = temperature / 1000
│ │ │ │ │ + self._eof = True
│ │ │ │ │ + logger.debug(' endpoints remaining: %r', self.endpoints)
│ │ │ │ │ + return bool(self.endpoints)
│ │ │ │ │
│ │ │ │ │ + _communication_done: Optional[asyncio.Future] = None
│ │ │ │ │
│ │ │ │ │ -class DiskSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('disk.all.read', 'bytes', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('disk.all.written', 'bytes', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('disk.dev.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('disk.dev.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + def do_closed(self, exc: Optional[Exception]) -> None:
│ │ │ │ │ + # If we didn't send EOF yet, do it now.
│ │ │ │ │ + if not self._eof:
│ │ │ │ │ + self.eof_received()
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/diskstats') as diskstats:
│ │ │ │ │ - all_read_bytes = 0
│ │ │ │ │ - all_written_bytes = 0
│ │ │ │ │ + if self._communication_done is not None:
│ │ │ │ │ + if exc is None:
│ │ │ │ │ + self._communication_done.set_result(None)
│ │ │ │ │ + else:
│ │ │ │ │ + self._communication_done.set_exception(exc)
│ │ │ │ │
│ │ │ │ │ - for line in diskstats:
│ │ │ │ │ - # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ - fields = line.strip().split()
│ │ │ │ │ - dev_major = fields[0]
│ │ │ │ │ - dev_name = fields[2]
│ │ │ │ │ - num_sectors_read = fields[5]
│ │ │ │ │ - num_sectors_written = fields[9]
│ │ │ │ │ + async def communicate(self) -> None:
│ │ │ │ │ + """Wait until communication is complete on the router and all endpoints are done."""
│ │ │ │ │ + assert self._communication_done is None
│ │ │ │ │ + self._communication_done = asyncio.get_running_loop().create_future()
│ │ │ │ │ + try:
│ │ │ │ │ + await self._communication_done
│ │ │ │ │ + except (BrokenPipeError, ConnectionResetError):
│ │ │ │ │ + pass # these are normal occurrences when closed from the other side
│ │ │ │ │ + finally:
│ │ │ │ │ + self._communication_done = None
│ │ │ │ │
│ │ │ │ │ - # ignore mdraid
│ │ │ │ │ - if dev_major == '9':
│ │ │ │ │ - continue
│ │ │ │ │ + # In an orderly exit, this is already done, but in case it wasn't
│ │ │ │ │ + # orderly, we need to make sure the endpoints shut down anyway...
│ │ │ │ │ + await self.no_endpoints.wait()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_version.py': br'''__version__ = '316'
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/peer.py': r'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │
│ │ │ │ │ - # ignore device-mapper
│ │ │ │ │ - if dev_name.startswith('dm-'):
│ │ │ │ │ - continue
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +from typing import Callable, List, Optional, Sequence
│ │ │ │ │
│ │ │ │ │ - # Skip partitions
│ │ │ │ │ - if dev_name[:2] in ['sd', 'hd', 'vd'] and dev_name[-1].isdigit():
│ │ │ │ │ - continue
│ │ │ │ │ +from .jsonutil import JsonObject, JsonValue
│ │ │ │ │ +from .packages import BridgeConfig
│ │ │ │ │ +from .protocol import CockpitProblem, CockpitProtocol, CockpitProtocolError
│ │ │ │ │ +from .router import Endpoint, Router, RoutingRule
│ │ │ │ │ +from .transports import SubprocessProtocol, SubprocessTransport
│ │ │ │ │
│ │ │ │ │ - # Ignore nvme partitions
│ │ │ │ │ - if dev_name.startswith('nvme') and 'p' in dev_name:
│ │ │ │ │ - continue
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ - read_bytes = int(num_sectors_read) * 512
│ │ │ │ │ - written_bytes = int(num_sectors_written) * 512
│ │ │ │ │
│ │ │ │ │ - all_read_bytes += read_bytes
│ │ │ │ │ - all_written_bytes += written_bytes
│ │ │ │ │ +class PeerError(CockpitProblem):
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - samples['disk.dev.read'][dev_name] = read_bytes
│ │ │ │ │ - samples['disk.dev.written'][dev_name] = written_bytes
│ │ │ │ │
│ │ │ │ │ - samples['disk.all.read'] = all_read_bytes
│ │ │ │ │ - samples['disk.all.written'] = all_written_bytes
│ │ │ │ │ +class PeerExited(Exception):
│ │ │ │ │ + def __init__(self, exit_code: int):
│ │ │ │ │ + self.exit_code = exit_code
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class CGroupSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('cgroup.memory.usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.memory.limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.memory.sw-usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.memory.sw-limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.cpu.usage', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.cpu.shares', 'count', 'instant', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ +class Peer(CockpitProtocol, SubprocessProtocol, Endpoint):
│ │ │ │ │ + done_callbacks: List[Callable[[], None]]
│ │ │ │ │ + init_future: Optional[asyncio.Future]
│ │ │ │ │
│ │ │ │ │ - cgroups_v2: Optional[bool] = None
│ │ │ │ │ + def __init__(self, router: Router):
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - if self.cgroups_v2 is None:
│ │ │ │ │ - self.cgroups_v2 = os.path.exists('/sys/fs/cgroup/cgroup.controllers')
│ │ │ │ │ + # All Peers start out frozen — we only unfreeze after we see the first 'init' message
│ │ │ │ │ + self.freeze_endpoint()
│ │ │ │ │
│ │ │ │ │ - if self.cgroups_v2:
│ │ │ │ │ - cgroups_v2_path = '/sys/fs/cgroup/'
│ │ │ │ │ - for path, _, _, rootfd in os.fwalk(cgroups_v2_path):
│ │ │ │ │ - cgroup = path.replace(cgroups_v2_path, '')
│ │ │ │ │ + self.init_future = asyncio.get_running_loop().create_future()
│ │ │ │ │ + self.done_callbacks = []
│ │ │ │ │
│ │ │ │ │ - if not cgroup:
│ │ │ │ │ - continue
│ │ │ │ │ + # Initialization
│ │ │ │ │ + async def do_connect_transport(self) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ - samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.current', 0)
│ │ │ │ │ - samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.max')
│ │ │ │ │ - samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.swap.current', 0)
│ │ │ │ │ - samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.swap.max')
│ │ │ │ │ - samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.weight')
│ │ │ │ │ - usage_usec = read_int_file(rootfd, 'cpu.stat', 0, key=b'usage_usec')
│ │ │ │ │ - if usage_usec:
│ │ │ │ │ - samples['cgroup.cpu.usage'][cgroup] = usage_usec / 1000
│ │ │ │ │ + async def spawn(self, argv: Sequence[str], env: Sequence[str], **kwargs) -> asyncio.Transport:
│ │ │ │ │ + # Not actually async...
│ │ │ │ │ + loop = asyncio.get_running_loop()
│ │ │ │ │ + user_env = dict(e.split('=', 1) for e in env)
│ │ │ │ │ + return SubprocessTransport(loop, self, argv, env=dict(os.environ, **user_env), **kwargs)
│ │ │ │ │ +
│ │ │ │ │ + async def start(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> JsonObject:
│ │ │ │ │ + """Request that the Peer is started and connected to the router.
│ │ │ │ │ +
│ │ │ │ │ + Creates the transport, connects it to the protocol, and participates in
│ │ │ │ │ + exchanging of init messages. If anything goes wrong, the connection
│ │ │ │ │ + will be closed and an exception will be raised.
│ │ │ │ │ +
│ │ │ │ │ + The Peer starts out in a frozen state (ie: attempts to send messages to
│ │ │ │ │ + it will initially be queued). If init_host is not None then an init
│ │ │ │ │ + message is sent with the given 'host' field, plus any extra kwargs, and
│ │ │ │ │ + the queue is thawed. Otherwise, the caller is responsible for sending
│ │ │ │ │ + the init message and thawing the peer.
│ │ │ │ │ +
│ │ │ │ │ + In any case, the return value is the init message from the peer.
│ │ │ │ │ + """
│ │ │ │ │ + assert self.init_future is not None
│ │ │ │ │ +
│ │ │ │ │ + def _connect_task_done(task: asyncio.Task) -> None:
│ │ │ │ │ + assert task is connect_task
│ │ │ │ │ + try:
│ │ │ │ │ + task.result()
│ │ │ │ │ + except asyncio.CancelledError: # we did that (below)
│ │ │ │ │ + pass # we want to ignore it
│ │ │ │ │ + except Exception as exc:
│ │ │ │ │ + self.close(exc)
│ │ │ │ │ +
│ │ │ │ │ + connect_task = asyncio.create_task(self.do_connect_transport())
│ │ │ │ │ + connect_task.add_done_callback(_connect_task_done)
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + # Wait for something to happen:
│ │ │ │ │ + # - exception from our connection function
│ │ │ │ │ + # - receiving "init" from the other side
│ │ │ │ │ + # - receiving EOF from the other side
│ │ │ │ │ + # - .close() was called
│ │ │ │ │ + # - other transport exception
│ │ │ │ │ + init_message = await self.init_future
│ │ │ │ │ +
│ │ │ │ │ + except (PeerExited, BrokenPipeError):
│ │ │ │ │ + # These are fairly generic errors. PeerExited means that we observed the process exiting.
│ │ │ │ │ + # BrokenPipeError means that we got EPIPE when attempting to write() to it. In both cases,
│ │ │ │ │ + # the process is gone, but it's not clear why. If the connection process is still running,
│ │ │ │ │ + # perhaps we'd get a better error message from it.
│ │ │ │ │ + await connect_task
│ │ │ │ │ + # Otherwise, re-raise
│ │ │ │ │ + raise
│ │ │ │ │ +
│ │ │ │ │ + finally:
│ │ │ │ │ + self.init_future = None
│ │ │ │ │ +
│ │ │ │ │ + # In any case (failure or success) make sure this is done.
│ │ │ │ │ + if not connect_task.done():
│ │ │ │ │ + connect_task.cancel()
│ │ │ │ │ +
│ │ │ │ │ + if init_host is not None:
│ │ │ │ │ + logger.debug(' sending init message back, host %s', init_host)
│ │ │ │ │ + # Send "init" back
│ │ │ │ │ + self.write_control(None, command='init', version=1, host=init_host, **kwargs)
│ │ │ │ │ +
│ │ │ │ │ + # Thaw the queued messages
│ │ │ │ │ + self.thaw_endpoint()
│ │ │ │ │ +
│ │ │ │ │ + return init_message
│ │ │ │ │ +
│ │ │ │ │ + # Background initialization
│ │ │ │ │ + def start_in_background(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ + def _start_task_done(task: asyncio.Task) -> None:
│ │ │ │ │ + assert task is start_task
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + task.result()
│ │ │ │ │ + except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError):
│ │ │ │ │ + pass # Those are expected. Others will throw.
│ │ │ │ │ +
│ │ │ │ │ + start_task = asyncio.create_task(self.start(init_host, **kwargs))
│ │ │ │ │ + start_task.add_done_callback(_start_task_done)
│ │ │ │ │ +
│ │ │ │ │ + # Shutdown
│ │ │ │ │ + def add_done_callback(self, callback: Callable[[], None]) -> None:
│ │ │ │ │ + self.done_callbacks.append(callback)
│ │ │ │ │ +
│ │ │ │ │ + # Handling of interesting events
│ │ │ │ │ + def do_superuser_init_done(self) -> None:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + def transport_control_received(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ + if command == 'init' and self.init_future is not None:
│ │ │ │ │ + logger.debug('Got init message with active init_future. Setting result.')
│ │ │ │ │ + self.init_future.set_result(message)
│ │ │ │ │ + elif command == 'authorize':
│ │ │ │ │ + self.do_authorize(message)
│ │ │ │ │ + elif command == 'superuser-init-done':
│ │ │ │ │ + self.do_superuser_init_done()
│ │ │ │ │ else:
│ │ │ │ │ - memory_path = '/sys/fs/cgroup/memory/'
│ │ │ │ │ - for path, _, _, rootfd in os.fwalk(memory_path):
│ │ │ │ │ - cgroup = path.replace(memory_path, '')
│ │ │ │ │ + raise CockpitProtocolError(f'Received unexpected control message {command}')
│ │ │ │ │
│ │ │ │ │ - if not cgroup:
│ │ │ │ │ - continue
│ │ │ │ │ + def eof_received(self) -> bool:
│ │ │ │ │ + # We always expect to be the ones to close the connection, so if we get
│ │ │ │ │ + # an EOF, then we consider it to be an error. This allows us to
│ │ │ │ │ + # distinguish close caused by unexpected EOF (but no errno from a
│ │ │ │ │ + # syscall failure) vs. close caused by calling .close() on our side.
│ │ │ │ │ + # The process is still running at this point, so keep it and handle
│ │ │ │ │ + # the error in process_exited().
│ │ │ │ │ + logger.debug('Peer %s received unexpected EOF', self.__class__.__name__)
│ │ │ │ │ + return True
│ │ │ │ │
│ │ │ │ │ - samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.usage_in_bytes', 0)
│ │ │ │ │ - samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.limit_in_bytes')
│ │ │ │ │ - samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.memsw.usage_in_bytes', 0)
│ │ │ │ │ - samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.memsw.limit_in_bytes')
│ │ │ │ │ + def do_closed(self, exc: Optional[Exception]) -> None:
│ │ │ │ │ + logger.debug('Peer %s connection lost %s %s', self.__class__.__name__, type(exc), exc)
│ │ │ │ │
│ │ │ │ │ - cpu_path = '/sys/fs/cgroup/cpu/'
│ │ │ │ │ - for path, _, _, rootfd in os.fwalk(cpu_path):
│ │ │ │ │ - cgroup = path.replace(cpu_path, '')
│ │ │ │ │ + if exc is None:
│ │ │ │ │ + self.shutdown_endpoint(problem='terminated')
│ │ │ │ │ + elif isinstance(exc, PeerExited):
│ │ │ │ │ + # a common case is that the called peer does not exist
│ │ │ │ │ + if exc.exit_code == 127:
│ │ │ │ │ + self.shutdown_endpoint(problem='no-cockpit')
│ │ │ │ │ + else:
│ │ │ │ │ + self.shutdown_endpoint(problem='terminated', message=f'Peer exited with status {exc.exit_code}')
│ │ │ │ │ + elif isinstance(exc, CockpitProblem):
│ │ │ │ │ + self.shutdown_endpoint(exc.attrs)
│ │ │ │ │ + else:
│ │ │ │ │ + self.shutdown_endpoint(problem='internal-error',
│ │ │ │ │ + message=f"[{exc.__class__.__name__}] {exc!s}")
│ │ │ │ │
│ │ │ │ │ - if not cgroup:
│ │ │ │ │ - continue
│ │ │ │ │ + # If .start() is running, we need to make sure it stops running,
│ │ │ │ │ + # raising the correct exception.
│ │ │ │ │ + if self.init_future is not None and not self.init_future.done():
│ │ │ │ │ + if exc is not None:
│ │ │ │ │ + self.init_future.set_exception(exc)
│ │ │ │ │ + else:
│ │ │ │ │ + self.init_future.cancel()
│ │ │ │ │
│ │ │ │ │ - samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.shares')
│ │ │ │ │ - usage_nsec = read_int_file(rootfd, 'cpuacct.usage')
│ │ │ │ │ - if usage_nsec:
│ │ │ │ │ - samples['cgroup.cpu.usage'][cgroup] = usage_nsec / 1000000
│ │ │ │ │ + for callback in self.done_callbacks:
│ │ │ │ │ + callback()
│ │ │ │ │
│ │ │ │ │ + def process_exited(self) -> None:
│ │ │ │ │ + assert isinstance(self.transport, SubprocessTransport)
│ │ │ │ │ + logger.debug('Peer %s exited, status %d', self.__class__.__name__, self.transport.get_returncode())
│ │ │ │ │ + returncode = self.transport.get_returncode()
│ │ │ │ │ + assert isinstance(returncode, int)
│ │ │ │ │ + self.close(PeerExited(returncode))
│ │ │ │ │
│ │ │ │ │ -class CGroupDiskIO(Sampler):
│ │ │ │ │ - IO_RE = re.compile(rb'\bread_bytes: (?P\d+).*\nwrite_bytes: (?P\d+)', flags=re.S)
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('disk.cgroup.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('disk.cgroup.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + # Forwarding data: from the peer to the router
│ │ │ │ │ + def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + if self.init_future is not None:
│ │ │ │ │ + raise CockpitProtocolError('Received unexpected channel control message before init')
│ │ │ │ │ + self.send_channel_control(channel, command, message)
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_cgroup_name(fd: int) -> str:
│ │ │ │ │ - with Handle.open('cgroup', os.O_RDONLY, dir_fd=fd) as cgroup_fd:
│ │ │ │ │ - cgroup_name = os.read(cgroup_fd, 2048).decode().strip()
│ │ │ │ │ + def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + if self.init_future is not None:
│ │ │ │ │ + raise CockpitProtocolError('Received unexpected channel data before init')
│ │ │ │ │ + self.send_channel_data(channel, data)
│ │ │ │ │
│ │ │ │ │ - # Skip leading ::0/
│ │ │ │ │ - return cgroup_name[4:]
│ │ │ │ │ + # Forwarding data: from the router to the peer
│ │ │ │ │ + def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + assert self.init_future is None
│ │ │ │ │ + self.write_control(message)
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_proc_io(fd: int) -> Tuple[int, int]:
│ │ │ │ │ - with Handle.open('io', os.O_RDONLY, dir_fd=fd) as io_fd:
│ │ │ │ │ - data = os.read(io_fd, 4096)
│ │ │ │ │ + def do_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + assert self.init_future is None
│ │ │ │ │ + self.write_channel_data(channel, data)
│ │ │ │ │
│ │ │ │ │ - match = re.search(CGroupDiskIO.IO_RE, data)
│ │ │ │ │ - if match:
│ │ │ │ │ - proc_read = int(match.group('read'))
│ │ │ │ │ - proc_write = int(match.group('write'))
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + assert self.init_future is None
│ │ │ │ │ + self.write_control(message)
│ │ │ │ │
│ │ │ │ │ - return proc_read, proc_write
│ │ │ │ │ + def do_close(self) -> None:
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - return 0, 0
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples):
│ │ │ │ │ - with Handle.open('/proc', os.O_RDONLY | os.O_DIRECTORY) as proc_fd:
│ │ │ │ │ - reads = samples['disk.cgroup.read']
│ │ │ │ │ - writes = samples['disk.cgroup.written']
│ │ │ │ │ +class ConfiguredPeer(Peer):
│ │ │ │ │ + config: BridgeConfig
│ │ │ │ │ + args: Sequence[str]
│ │ │ │ │ + env: Sequence[str]
│ │ │ │ │
│ │ │ │ │ - for path in os.listdir(proc_fd):
│ │ │ │ │ - # non-pid entries in proc are guaranteed to start with a character a-z
│ │ │ │ │ - if path[0] < '0' or path[0] > '9':
│ │ │ │ │ - continue
│ │ │ │ │ + def __init__(self, router: Router, config: BridgeConfig):
│ │ │ │ │ + self.config = config
│ │ │ │ │ + self.args = config.spawn
│ │ │ │ │ + self.env = config.environ
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - with Handle.open(path, os.O_PATH, dir_fd=proc_fd) as pid_fd:
│ │ │ │ │ - cgroup_name = self.get_cgroup_name(pid_fd)
│ │ │ │ │ - proc_read, proc_write = self.get_proc_io(pid_fd)
│ │ │ │ │ - except (FileNotFoundError, PermissionError, ProcessLookupError):
│ │ │ │ │ - continue
│ │ │ │ │ + async def do_connect_transport(self) -> None:
│ │ │ │ │ + await self.spawn(self.args, self.env)
│ │ │ │ │
│ │ │ │ │ - reads[cgroup_name] = reads.get(cgroup_name, 0) + proc_read
│ │ │ │ │ - writes[cgroup_name] = writes.get(cgroup_name, 0) + proc_write
│ │ │ │ │
│ │ │ │ │ +class PeerRoutingRule(RoutingRule):
│ │ │ │ │ + config: BridgeConfig
│ │ │ │ │ + match: JsonObject
│ │ │ │ │ + peer: Optional[Peer]
│ │ │ │ │
│ │ │ │ │ -class NetworkSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('network.interface.tx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('network.interface.rx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + def __init__(self, router: Router, config: BridgeConfig):
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │ + self.config = config
│ │ │ │ │ + self.match = config.match
│ │ │ │ │ + self.peer = None
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open("/proc/net/dev") as network_samples:
│ │ │ │ │ - for line in network_samples:
│ │ │ │ │ - fields = line.split()
│ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Peer]:
│ │ │ │ │ + # Check that we match
│ │ │ │ │
│ │ │ │ │ - # Skip header line
│ │ │ │ │ - if fields[0][-1] != ':':
│ │ │ │ │ - continue
│ │ │ │ │ + for key, value in self.match.items():
│ │ │ │ │ + if key not in options:
│ │ │ │ │ + logger.debug(' rejecting because key %s is missing', key)
│ │ │ │ │ + return None
│ │ │ │ │ + if value is not None and options[key] != value:
│ │ │ │ │ + logger.debug(' rejecting because key %s has wrong value %s (vs %s)', key, options[key], value)
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - iface = fields[0][:-1]
│ │ │ │ │ - samples['network.interface.rx'][iface] = int(fields[1])
│ │ │ │ │ - samples['network.interface.tx'][iface] = int(fields[9])
│ │ │ │ │ + # Start the peer if it's not running already
│ │ │ │ │ + if self.peer is None:
│ │ │ │ │ + self.peer = ConfiguredPeer(self.router, self.config)
│ │ │ │ │ + self.peer.add_done_callback(self.peer_closed)
│ │ │ │ │ + assert self.router.init_host
│ │ │ │ │ + self.peer.start_in_background(init_host=self.router.init_host)
│ │ │ │ │
│ │ │ │ │ + return self.peer
│ │ │ │ │
│ │ │ │ │ -class MountSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('mount.total', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('mount.used', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + def peer_closed(self):
│ │ │ │ │ + self.peer = None
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/mounts') as mounts:
│ │ │ │ │ - for line in mounts:
│ │ │ │ │ - # Only look at real devices
│ │ │ │ │ - if line[0] != '/':
│ │ │ │ │ - continue
│ │ │ │ │ + def shutdown(self):
│ │ │ │ │ + if self.peer is not None:
│ │ │ │ │ + self.peer.close()
│ │ │ │ │
│ │ │ │ │ - path = line.split()[1]
│ │ │ │ │ - try:
│ │ │ │ │ - res = os.statvfs(path)
│ │ │ │ │ - except OSError:
│ │ │ │ │ - continue
│ │ │ │ │ - frsize = res.f_frsize
│ │ │ │ │ - total = frsize * res.f_blocks
│ │ │ │ │ - samples['mount.total'][path] = total
│ │ │ │ │ - samples['mount.used'][path] = total - frsize * res.f_bfree
│ │ │ │ │
│ │ │ │ │ +class PeersRoutingRule(RoutingRule):
│ │ │ │ │ + rules: List[PeerRoutingRule] = []
│ │ │ │ │
│ │ │ │ │ -class BlockSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('block.device.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('block.device.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:
│ │ │ │ │ + logger.debug(' considering %d rules', len(self.rules))
│ │ │ │ │ + for rule in self.rules:
│ │ │ │ │ + logger.debug(' considering %s', rule.config.name)
│ │ │ │ │ + endpoint = rule.apply_rule(options)
│ │ │ │ │ + if endpoint is not None:
│ │ │ │ │ + logger.debug(' selected')
│ │ │ │ │ + return endpoint
│ │ │ │ │ + logger.debug(' no peer rules matched')
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/diskstats') as diskstats:
│ │ │ │ │ - for line in diskstats:
│ │ │ │ │ - # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ - [_, _, dev_name, _, _, sectors_read, _, _, _, sectors_written, *_] = line.strip().split()
│ │ │ │ │ + def set_configs(self, bridge_configs: Sequence[BridgeConfig]) -> None:
│ │ │ │ │ + old_rules = self.rules
│ │ │ │ │ + self.rules = []
│ │ │ │ │
│ │ │ │ │ - samples['block.device.read'][dev_name] = int(sectors_read) * 512
│ │ │ │ │ - samples['block.device.written'][dev_name] = int(sectors_written) * 512
│ │ │ │ │ + for config in bridge_configs:
│ │ │ │ │ + # Those are handled elsewhere...
│ │ │ │ │ + if config.privileged or 'host' in config.match:
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ + # Try to reuse an existing rule, if one exists...
│ │ │ │ │ + for rule in list(old_rules):
│ │ │ │ │ + if rule.config == config:
│ │ │ │ │ + old_rules.remove(rule)
│ │ │ │ │ + break
│ │ │ │ │ + else:
│ │ │ │ │ + # ... otherwise, create a new one.
│ │ │ │ │ + rule = PeerRoutingRule(self.router, config)
│ │ │ │ │
│ │ │ │ │ -SAMPLERS = [
│ │ │ │ │ - BlockSampler,
│ │ │ │ │ - CGroupSampler,
│ │ │ │ │ - CGroupDiskIO,
│ │ │ │ │ - CPUSampler,
│ │ │ │ │ - CPUTemperatureSampler,
│ │ │ │ │ - DiskSampler,
│ │ │ │ │ - MemorySampler,
│ │ │ │ │ - MountSampler,
│ │ │ │ │ - NetworkSampler,
│ │ │ │ │ -]
│ │ │ │ │ -''',
│ │ │ │ │ + self.rules.append(rule)
│ │ │ │ │ +
│ │ │ │ │ + # close down the old rules that didn't get reclaimed
│ │ │ │ │ + for rule in old_rules:
│ │ │ │ │ + rule.shutdown()
│ │ │ │ │ +
│ │ │ │ │ + def shutdown(self):
│ │ │ │ │ + for rule in self.rules:
│ │ │ │ │ + rule.shutdown()
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ 'cockpit/superuser.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -749,14 +1302,749 @@
│ │ │ │ │ def answer(self, reply: str) -> None:
│ │ │ │ │ if self.pending_prompt is not None:
│ │ │ │ │ logger.debug('responding to pending prompt')
│ │ │ │ │ self.pending_prompt.set_result(reply)
│ │ │ │ │ else:
│ │ │ │ │ logger.debug('got Answer, but no prompt pending')
│ │ │ │ │ ''',
│ │ │ │ │ + 'cockpit/config.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import configparser
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +from pathlib import Path
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import bus
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'))
│ │ │ │ │ +DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit'
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def lookup_config(filename: str) -> Path:
│ │ │ │ │ + config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc').split(':')
│ │ │ │ │ + fallback = None
│ │ │ │ │ + for config_dir in config_dirs:
│ │ │ │ │ + config_path = Path(config_dir, 'cockpit', filename)
│ │ │ │ │ + if not fallback:
│ │ │ │ │ + fallback = config_path
│ │ │ │ │ + if config_path.exists():
│ │ │ │ │ + logger.debug('lookup_config(%s): found %s', filename, config_path)
│ │ │ │ │ + return config_path
│ │ │ │ │ +
│ │ │ │ │ + # default to the first entry in XDG_CONFIG_DIRS; that's not according to the spec,
│ │ │ │ │ + # but what Cockpit has done for years
│ │ │ │ │ + logger.debug('lookup_config(%s): defaulting to %s', filename, fallback)
│ │ │ │ │ + assert fallback # mypy; config_dirs always has at least one string
│ │ │ │ │ + return fallback
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Config(bus.Object, interface='cockpit.Config'):
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + self.reload()
│ │ │ │ │ +
│ │ │ │ │ + @bus.Interface.Method(out_types='s', in_types='ss')
│ │ │ │ │ + def get_string(self, section, key):
│ │ │ │ │ + try:
│ │ │ │ │ + return self.config[section][key]
│ │ │ │ │ + except KeyError as exc:
│ │ │ │ │ + raise bus.BusError('cockpit.Config.KeyError', f'key {key} in section {section} does not exist') from exc
│ │ │ │ │ +
│ │ │ │ │ + @bus.Interface.Method(out_types='u', in_types='ssuuu')
│ │ │ │ │ + def get_u_int(self, section, key, default, maximum, minimum):
│ │ │ │ │ + try:
│ │ │ │ │ + value = self.config[section][key]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + return default
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + int_val = int(value)
│ │ │ │ │ + except ValueError:
│ │ │ │ │ + logger.warning('cockpit.conf: [%s] %s is not an integer', section, key)
│ │ │ │ │ + return default
│ │ │ │ │ +
│ │ │ │ │ + return min(max(int_val, minimum), maximum)
│ │ │ │ │ +
│ │ │ │ │ + @bus.Interface.Method()
│ │ │ │ │ + def reload(self):
│ │ │ │ │ + self.config = configparser.ConfigParser(interpolation=None)
│ │ │ │ │ + cockpit_conf = lookup_config('cockpit.conf')
│ │ │ │ │ + logger.debug("cockpit.Config: loading %s", cockpit_conf)
│ │ │ │ │ + # this may not exist, but it's ok to not have a config file and thus leave self.config empty
│ │ │ │ │ + self.config.read(cockpit_conf)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Environment(bus.Object, interface='cockpit.Environment'):
│ │ │ │ │ + variables = bus.Interface.Property('a{ss}')
│ │ │ │ │ +
│ │ │ │ │ + @variables.getter
│ │ │ │ │ + def get_variables(self):
│ │ │ │ │ + return os.environ.copy()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/__init__.py': br'''from ._version import __version__
│ │ │ │ │ +
│ │ │ │ │ +__all__ = (
│ │ │ │ │ + '__version__',
│ │ │ │ │ +)
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/packages.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import collections
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import functools
│ │ │ │ │ +import gzip
│ │ │ │ │ +import io
│ │ │ │ │ +import itertools
│ │ │ │ │ +import json
│ │ │ │ │ +import logging
│ │ │ │ │ +import mimetypes
│ │ │ │ │ +import os
│ │ │ │ │ +import re
│ │ │ │ │ +import shutil
│ │ │ │ │ +from pathlib import Path
│ │ │ │ │ +from typing import (
│ │ │ │ │ + BinaryIO,
│ │ │ │ │ + Callable,
│ │ │ │ │ + ClassVar,
│ │ │ │ │ + Dict,
│ │ │ │ │ + Iterable,
│ │ │ │ │ + List,
│ │ │ │ │ + NamedTuple,
│ │ │ │ │ + Optional,
│ │ │ │ │ + Pattern,
│ │ │ │ │ + Sequence,
│ │ │ │ │ + Tuple,
│ │ │ │ │ + TypeVar,
│ │ │ │ │ +)
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import bus
│ │ │ │ │ +
│ │ │ │ │ +from . import config
│ │ │ │ │ +from ._version import __version__
│ │ │ │ │ +from .jsonutil import (
│ │ │ │ │ + JsonError,
│ │ │ │ │ + JsonObject,
│ │ │ │ │ + JsonValue,
│ │ │ │ │ + get_bool,
│ │ │ │ │ + get_dict,
│ │ │ │ │ + get_int,
│ │ │ │ │ + get_objv,
│ │ │ │ │ + get_str,
│ │ │ │ │ + get_strv,
│ │ │ │ │ + json_merge_patch,
│ │ │ │ │ + typechecked,
│ │ │ │ │ +)
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# In practice, this is going to get called over and over again with exactly the
│ │ │ │ │ +# same list. Let's try to cache the result.
│ │ │ │ │ +@functools.lru_cache()
│ │ │ │ │ +def parse_accept_language(accept_language: str) -> Sequence[str]:
│ │ │ │ │ + """Parse the Accept-Language header, if it exists.
│ │ │ │ │ +
│ │ │ │ │ + Returns an ordered list of languages, with fallbacks inserted, and
│ │ │ │ │ + truncated to the position where 'en' would have otherwise appeared, if
│ │ │ │ │ + applicable.
│ │ │ │ │ +
│ │ │ │ │ + https://tools.ietf.org/html/rfc7231#section-5.3.5
│ │ │ │ │ + https://datatracker.ietf.org/doc/html/rfc4647#section-3.4
│ │ │ │ │ + """
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('parse_accept_language(%r)', accept_language)
│ │ │ │ │ + locales_with_q = []
│ │ │ │ │ + for entry in accept_language.split(','):
│ │ │ │ │ + entry = entry.strip().lower()
│ │ │ │ │ + logger.debug(' entry %r', entry)
│ │ │ │ │ + locale, _, qstr = entry.partition(';q=')
│ │ │ │ │ + try:
│ │ │ │ │ + q = float(qstr or 1.0)
│ │ │ │ │ + except ValueError:
│ │ │ │ │ + continue # ignore malformed entry
│ │ │ │ │ +
│ │ │ │ │ + while locale:
│ │ │ │ │ + logger.debug(' adding %r q=%r', locale, q)
│ │ │ │ │ + locales_with_q.append((locale, q))
│ │ │ │ │ + # strip off '-detail' suffixes until there's nothing left
│ │ │ │ │ + locale, _, _region = locale.rpartition('-')
│ │ │ │ │ +
│ │ │ │ │ + # Sort the list by highest q value. Otherwise, this is a stable sort.
│ │ │ │ │ + locales_with_q.sort(key=lambda pair: pair[1], reverse=True)
│ │ │ │ │ + logger.debug(' sorted list is %r', locales_with_q)
│ │ │ │ │ +
│ │ │ │ │ + # If we have 'en' anywhere in our list, ignore it and all items after it.
│ │ │ │ │ + # This will result in us getting an untranslated (ie: English) version if
│ │ │ │ │ + # none of the more-preferred languages are found, which is what we want.
│ │ │ │ │ + # We also take the chance to drop duplicate items. Note: both of these
│ │ │ │ │ + # things need to happen after sorting.
│ │ │ │ │ + results = []
│ │ │ │ │ + for locale, _q in locales_with_q:
│ │ │ │ │ + if locale == 'en':
│ │ │ │ │ + break
│ │ │ │ │ + if locale not in results:
│ │ │ │ │ + results.append(locale)
│ │ │ │ │ +
│ │ │ │ │ + logger.debug(' results list is %r', results)
│ │ │ │ │ + return tuple(results)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def sortify_version(version: str) -> str:
│ │ │ │ │ + """Convert a version string to a form that can be compared"""
│ │ │ │ │ + # 0-pad each numeric component. Only supports numeric versions like 1.2.3.
│ │ │ │ │ + return '.'.join(part.zfill(8) for part in version.split('.'))
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +@functools.lru_cache()
│ │ │ │ │ +def get_libexecdir() -> str:
│ │ │ │ │ + """Detect libexecdir on current machine
│ │ │ │ │ +
│ │ │ │ │ + This only works for systems which have cockpit-ws installed.
│ │ │ │ │ + """
│ │ │ │ │ + for candidate in ['/usr/local/libexec', '/usr/libexec', '/usr/local/lib/cockpit', '/usr/lib/cockpit']:
│ │ │ │ │ + if os.path.exists(os.path.join(candidate, 'cockpit-askpass')):
│ │ │ │ │ + return candidate
│ │ │ │ │ + else:
│ │ │ │ │ + logger.warning('Could not detect libexecdir')
│ │ │ │ │ + # give readable error messages
│ │ │ │ │ + return '/nonexistent/libexec'
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# HACK: Type narrowing over Union types is not supported in the general case,
│ │ │ │ │ +# but this works for the case we care about: knowing that when we pass in an
│ │ │ │ │ +# JsonObject, we'll get an JsonObject back.
│ │ │ │ │ +J = TypeVar('J', JsonObject, JsonValue)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def patch_libexecdir(obj: J) -> J:
│ │ │ │ │ + if isinstance(obj, str):
│ │ │ │ │ + if '${libexecdir}/cockpit-askpass' in obj:
│ │ │ │ │ + # extra-special case: we handle this internally
│ │ │ │ │ + abs_askpass = shutil.which('cockpit-askpass')
│ │ │ │ │ + if abs_askpass is not None:
│ │ │ │ │ + return obj.replace('${libexecdir}/cockpit-askpass', abs_askpass)
│ │ │ │ │ + return obj.replace('${libexecdir}', get_libexecdir())
│ │ │ │ │ + elif isinstance(obj, dict):
│ │ │ │ │ + return {key: patch_libexecdir(value) for key, value in obj.items()}
│ │ │ │ │ + elif isinstance(obj, list):
│ │ │ │ │ + return [patch_libexecdir(item) for item in obj]
│ │ │ │ │ + else:
│ │ │ │ │ + return obj
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# A document is a binary stream with a Content-Type, optional Content-Encoding,
│ │ │ │ │ +# and optional Content-Security-Policy
│ │ │ │ │ +class Document(NamedTuple):
│ │ │ │ │ + data: BinaryIO
│ │ │ │ │ + content_type: str
│ │ │ │ │ + content_encoding: Optional[str] = None
│ │ │ │ │ + content_security_policy: Optional[str] = None
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class PackagesListener:
│ │ │ │ │ + def packages_loaded(self) -> None:
│ │ │ │ │ + """Called when the packages have been reloaded"""
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class BridgeConfig(dict, JsonObject):
│ │ │ │ │ + def __init__(self, value: JsonObject):
│ │ │ │ │ + super().__init__(value)
│ │ │ │ │ +
│ │ │ │ │ + self.label = get_str(self, 'label', None)
│ │ │ │ │ +
│ │ │ │ │ + self.privileged = get_bool(self, 'privileged', default=False)
│ │ │ │ │ + self.match = get_dict(self, 'match', {})
│ │ │ │ │ + if not self.privileged and not self.match:
│ │ │ │ │ + raise JsonError(value, 'must have match rules or be privileged')
│ │ │ │ │ +
│ │ │ │ │ + self.environ = get_strv(self, 'environ', ())
│ │ │ │ │ + self.spawn = get_strv(self, 'spawn')
│ │ │ │ │ + if not self.spawn:
│ │ │ │ │ + raise JsonError(value, 'spawn vector must be non-empty')
│ │ │ │ │ +
│ │ │ │ │ + self.name = self.label or self.spawn[0]
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Condition:
│ │ │ │ │ + def __init__(self, value: JsonObject):
│ │ │ │ │ + try:
│ │ │ │ │ + (self.name, self.value), = value.items()
│ │ │ │ │ + except ValueError as exc:
│ │ │ │ │ + raise JsonError(value, 'must contain exactly one key/value pair') from exc
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Manifest(dict, JsonObject):
│ │ │ │ │ + # Skip version check when running out of the git checkout (__version__ is None)
│ │ │ │ │ + COCKPIT_VERSION = __version__ and sortify_version(__version__)
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, path: Path, value: JsonObject):
│ │ │ │ │ + super().__init__(value)
│ │ │ │ │ + self.path = path
│ │ │ │ │ + self.name = get_str(self, 'name', self.path.name)
│ │ │ │ │ + self.bridges = get_objv(self, 'bridges', BridgeConfig)
│ │ │ │ │ + self.priority = get_int(self, 'priority', 1)
│ │ │ │ │ + self.csp = get_str(self, 'content-security-policy', '')
│ │ │ │ │ + self.conditions = get_objv(self, 'conditions', Condition)
│ │ │ │ │ +
│ │ │ │ │ + # Skip version check when running out of the git checkout (COCKPIT_VERSION is None)
│ │ │ │ │ + if self.COCKPIT_VERSION is not None:
│ │ │ │ │ + requires = get_dict(self, 'requires', {})
│ │ │ │ │ + for name, version in requires.items():
│ │ │ │ │ + if name != 'cockpit':
│ │ │ │ │ + raise JsonError(name, 'non-cockpit requirement listed')
│ │ │ │ │ + if sortify_version(typechecked(version, str)) > self.COCKPIT_VERSION:
│ │ │ │ │ + raise JsonError(version, f'required cockpit version ({version}) not met')
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Package:
│ │ │ │ │ + # For po{,.manifest}.js files, the interesting part is the locale name
│ │ │ │ │ + PO_JS_RE: ClassVar[Pattern] = re.compile(r'(po|po\.manifest)\.([^.]+)\.js(\.gz)?')
│ │ │ │ │ +
│ │ │ │ │ + # immutable after __init__
│ │ │ │ │ + manifest: Manifest
│ │ │ │ │ + name: str
│ │ │ │ │ + path: Path
│ │ │ │ │ + priority: int
│ │ │ │ │ +
│ │ │ │ │ + # computed later
│ │ │ │ │ + translations: Optional[Dict[str, Dict[str, str]]] = None
│ │ │ │ │ + files: Optional[Dict[str, str]] = None
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, manifest: Manifest):
│ │ │ │ │ + self.manifest = manifest
│ │ │ │ │ + self.name = manifest.name
│ │ │ │ │ + self.path = manifest.path
│ │ │ │ │ + self.priority = manifest.priority
│ │ │ │ │ +
│ │ │ │ │ + def ensure_scanned(self) -> None:
│ │ │ │ │ + """Ensure that the package has been scanned.
│ │ │ │ │ +
│ │ │ │ │ + This allows us to defer scanning the files of the package until we know
│ │ │ │ │ + that we'll actually use it.
│ │ │ │ │ + """
│ │ │ │ │ +
│ │ │ │ │ + if self.files is not None:
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + self.files = {}
│ │ │ │ │ + self.translations = {'po.js': {}, 'po.manifest.js': {}}
│ │ │ │ │ +
│ │ │ │ │ + for file in self.path.rglob('*'):
│ │ │ │ │ + name = str(file.relative_to(self.path))
│ │ │ │ │ + if name in ['.', '..', 'manifest.json']:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + po_match = Package.PO_JS_RE.fullmatch(name)
│ │ │ │ │ + if po_match:
│ │ │ │ │ + basename = po_match.group(1)
│ │ │ │ │ + locale = po_match.group(2)
│ │ │ │ │ + # Accept-Language is case-insensitive and uses '-' to separate variants
│ │ │ │ │ + lower_locale = locale.lower().replace('_', '-')
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Adding translation %r %r -> %r', basename, lower_locale, name)
│ │ │ │ │ + self.translations[f'{basename}.js'][lower_locale] = name
│ │ │ │ │ + else:
│ │ │ │ │ + # strip out trailing '.gz' components
│ │ │ │ │ + basename = re.sub('.gz$', '', name)
│ │ │ │ │ + logger.debug('Adding content %r -> %r', basename, name)
│ │ │ │ │ + self.files[basename] = name
│ │ │ │ │ +
│ │ │ │ │ + # If we see a filename like `x.min.js` we want to also offer it
│ │ │ │ │ + # at `x.js`, but only if `x.js(.gz)` itself is not present.
│ │ │ │ │ + # Note: this works for both the case where we found the `x.js`
│ │ │ │ │ + # first (it's already in the map) and also if we find it second
│ │ │ │ │ + # (it will be replaced in the map by the line just above).
│ │ │ │ │ + # See https://github.com/cockpit-project/cockpit/pull/19716
│ │ │ │ │ + self.files.setdefault(basename.replace('.min.', '.'), name)
│ │ │ │ │ +
│ │ │ │ │ + # support old cockpit-po-plugin which didn't write po.manifest.??.js
│ │ │ │ │ + if not self.translations['po.manifest.js']:
│ │ │ │ │ + self.translations['po.manifest.js'] = self.translations['po.js']
│ │ │ │ │ +
│ │ │ │ │ + def get_content_security_policy(self) -> str:
│ │ │ │ │ + policy = {
│ │ │ │ │ + "default-src": "'self'",
│ │ │ │ │ + "connect-src": "'self'",
│ │ │ │ │ + "form-action": "'self'",
│ │ │ │ │ + "base-uri": "'self'",
│ │ │ │ │ + "object-src": "'none'",
│ │ │ │ │ + "font-src": "'self' data:",
│ │ │ │ │ + "img-src": "'self' data:",
│ │ │ │ │ + }
│ │ │ │ │ +
│ │ │ │ │ + for item in self.manifest.csp.split(';'):
│ │ │ │ │ + item = item.strip()
│ │ │ │ │ + if item:
│ │ │ │ │ + key, _, value = item.strip().partition(' ')
│ │ │ │ │ + policy[key] = value
│ │ │ │ │ +
│ │ │ │ │ + return ' '.join(f'{k} {v};' for k, v in policy.items()) + ' block-all-mixed-content'
│ │ │ │ │ +
│ │ │ │ │ + def load_file(self, filename: str) -> Document:
│ │ │ │ │ + content_type, content_encoding = mimetypes.guess_type(filename)
│ │ │ │ │ + content_security_policy = None
│ │ │ │ │ +
│ │ │ │ │ + if content_type is None:
│ │ │ │ │ + content_type = 'text/plain'
│ │ │ │ │ + elif content_type.startswith('text/html'):
│ │ │ │ │ + content_security_policy = self.get_content_security_policy()
│ │ │ │ │ +
│ │ │ │ │ + path = self.path / filename
│ │ │ │ │ + logger.debug(' loading data from %s', path)
│ │ │ │ │ +
│ │ │ │ │ + return Document(path.open('rb'), content_type, content_encoding, content_security_policy)
│ │ │ │ │ +
│ │ │ │ │ + def load_translation(self, path: str, locales: Sequence[str]) -> Document:
│ │ │ │ │ + self.ensure_scanned()
│ │ │ │ │ + assert self.translations is not None
│ │ │ │ │ +
│ │ │ │ │ + # First match wins
│ │ │ │ │ + for locale in locales:
│ │ │ │ │ + with contextlib.suppress(KeyError):
│ │ │ │ │ + return self.load_file(self.translations[path][locale])
│ │ │ │ │ +
│ │ │ │ │ + # We prefer to return an empty document than 404 in order to avoid
│ │ │ │ │ + # errors in the console when a translation can't be found
│ │ │ │ │ + return Document(io.BytesIO(), 'text/javascript')
│ │ │ │ │ +
│ │ │ │ │ + def load_path(self, path: str, headers: JsonObject) -> Document:
│ │ │ │ │ + self.ensure_scanned()
│ │ │ │ │ + assert self.files is not None
│ │ │ │ │ + assert self.translations is not None
│ │ │ │ │ +
│ │ │ │ │ + if path in self.translations:
│ │ │ │ │ + locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))
│ │ │ │ │ + return self.load_translation(path, locales)
│ │ │ │ │ + else:
│ │ │ │ │ + return self.load_file(self.files[path])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class PackagesLoader:
│ │ │ │ │ + CONDITIONS: ClassVar[Dict[str, Callable[[str], bool]]] = {
│ │ │ │ │ + 'path-exists': os.path.exists,
│ │ │ │ │ + 'path-not-exists': lambda p: not os.path.exists(p),
│ │ │ │ │ + }
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def get_xdg_data_dirs(cls) -> Iterable[str]:
│ │ │ │ │ + try:
│ │ │ │ │ + yield os.environ['XDG_DATA_HOME']
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + yield os.path.expanduser('~/.local/share')
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + yield from os.environ['XDG_DATA_DIRS'].split(':')
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + yield from ('/usr/local/share', '/usr/share')
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def patch_manifest(cls, manifest: JsonObject, parent: Path) -> JsonObject:
│ │ │ │ │ + override_files = [
│ │ │ │ │ + parent / 'override.json',
│ │ │ │ │ + config.lookup_config(f'{parent.name}.override.json'),
│ │ │ │ │ + config.DOT_CONFIG_COCKPIT / f'{parent.name}.override.json',
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + for override_file in override_files:
│ │ │ │ │ + try:
│ │ │ │ │ + override: JsonValue = json.loads(override_file.read_bytes())
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + continue
│ │ │ │ │ + except json.JSONDecodeError as exc:
│ │ │ │ │ + # User input error: report a warning
│ │ │ │ │ + logger.warning('%s: %s', override_file, exc)
│ │ │ │ │ +
│ │ │ │ │ + if not isinstance(override, dict):
│ │ │ │ │ + logger.warning('%s: override file is not a dictionary', override_file)
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + manifest = json_merge_patch(manifest, override)
│ │ │ │ │ +
│ │ │ │ │ + return patch_libexecdir(manifest)
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def load_manifests(cls) -> Iterable[Manifest]:
│ │ │ │ │ + for datadir in cls.get_xdg_data_dirs():
│ │ │ │ │ + logger.debug("Scanning for manifest files under %s", datadir)
│ │ │ │ │ + for file in Path(datadir).glob('cockpit/*/manifest.json'):
│ │ │ │ │ + logger.debug("Considering file %s", file)
│ │ │ │ │ + try:
│ │ │ │ │ + manifest = json.loads(file.read_text())
│ │ │ │ │ + except json.JSONDecodeError as exc:
│ │ │ │ │ + logger.error("%s: %s", file, exc)
│ │ │ │ │ + continue
│ │ │ │ │ + if not isinstance(manifest, dict):
│ │ │ │ │ + logger.error("%s: json document isn't an object", file)
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + parent = file.parent
│ │ │ │ │ + manifest = cls.patch_manifest(manifest, parent)
│ │ │ │ │ + try:
│ │ │ │ │ + yield Manifest(parent, manifest)
│ │ │ │ │ + except JsonError as exc:
│ │ │ │ │ + logger.warning('%s %s', file, exc)
│ │ │ │ │ +
│ │ │ │ │ + def check_condition(self, condition: str, value: object) -> bool:
│ │ │ │ │ + check_fn = self.CONDITIONS[condition]
│ │ │ │ │ +
│ │ │ │ │ + # All known predicates currently only work on strings
│ │ │ │ │ + if not isinstance(value, str):
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + return check_fn(value)
│ │ │ │ │ +
│ │ │ │ │ + def check_conditions(self, manifest: Manifest) -> bool:
│ │ │ │ │ + for condition in manifest.conditions:
│ │ │ │ │ + try:
│ │ │ │ │ + okay = self.check_condition(condition.name, condition.value)
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + # do *not* ignore manifests with unknown predicates, for forward compatibility
│ │ │ │ │ + logger.warning(' %s: ignoring unknown predicate in manifest: %s', manifest.path, condition.name)
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + if not okay:
│ │ │ │ │ + logger.debug(' hiding package %s as its %s condition is not met', manifest.path, condition)
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + return True
│ │ │ │ │ +
│ │ │ │ │ + def load_packages(self) -> Iterable[Tuple[str, Package]]:
│ │ │ │ │ + logger.debug('Scanning for available package manifests:')
│ │ │ │ │ + # Sort all available packages into buckets by to their claimed name
│ │ │ │ │ + names: Dict[str, List[Manifest]] = collections.defaultdict(list)
│ │ │ │ │ + for manifest in self.load_manifests():
│ │ │ │ │ + logger.debug(' %s/manifest.json', manifest.path)
│ │ │ │ │ + names[manifest.name].append(manifest)
│ │ │ │ │ + logger.debug('done.')
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Selecting packages to serve:')
│ │ │ │ │ + for name, candidates in names.items():
│ │ │ │ │ + # For each package name, iterate the candidates in descending
│ │ │ │ │ + # priority order and select the first one which passes all checks
│ │ │ │ │ + for candidate in sorted(candidates, key=lambda manifest: manifest.priority, reverse=True):
│ │ │ │ │ + try:
│ │ │ │ │ + if self.check_conditions(candidate):
│ │ │ │ │ + logger.debug(' creating package %s -> %s', name, candidate.path)
│ │ │ │ │ + yield name, Package(candidate)
│ │ │ │ │ + break
│ │ │ │ │ + except JsonError:
│ │ │ │ │ + logger.warning(' %s: ignoring package with invalid manifest file', candidate.path)
│ │ │ │ │ +
│ │ │ │ │ + logger.debug(' ignoring %s: unmet conditions', candidate.path)
│ │ │ │ │ + logger.debug('done.')
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Packages(bus.Object, interface='cockpit.Packages'):
│ │ │ │ │ + loader: PackagesLoader
│ │ │ │ │ + listener: Optional[PackagesListener]
│ │ │ │ │ + packages: Dict[str, Package]
│ │ │ │ │ + saw_first_reload_hint: bool
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, listener: Optional[PackagesListener] = None, loader: Optional[PackagesLoader] = None):
│ │ │ │ │ + self.listener = listener
│ │ │ │ │ + self.loader = loader or PackagesLoader()
│ │ │ │ │ + self.load()
│ │ │ │ │ +
│ │ │ │ │ + # Reloading the Shell in the browser should reload the
│ │ │ │ │ + # packages. This is implemented by having the Shell call
│ │ │ │ │ + # reload_hint whenever it starts. The first call of this
│ │ │ │ │ + # method in each session is ignored so that packages are not
│ │ │ │ │ + # loaded twice right after logging in.
│ │ │ │ │ + #
│ │ │ │ │ + self.saw_first_reload_hint = False
│ │ │ │ │ +
│ │ │ │ │ + def load(self) -> None:
│ │ │ │ │ + self.packages = dict(self.loader.load_packages())
│ │ │ │ │ + self.manifests = json.dumps({name: dict(package.manifest) for name, package in self.packages.items()})
│ │ │ │ │ + logger.debug('Packages loaded: %s', list(self.packages))
│ │ │ │ │ +
│ │ │ │ │ + def show(self):
│ │ │ │ │ + for name in sorted(self.packages):
│ │ │ │ │ + package = self.packages[name]
│ │ │ │ │ + menuitems = []
│ │ │ │ │ + for entry in itertools.chain(
│ │ │ │ │ + package.manifest.get('menu', {}).values(),
│ │ │ │ │ + package.manifest.get('tools', {}).values()):
│ │ │ │ │ + with contextlib.suppress(KeyError):
│ │ │ │ │ + menuitems.append(entry['label'])
│ │ │ │ │ + print(f'{name:20} {", ".join(menuitems):40} {package.path}')
│ │ │ │ │ +
│ │ │ │ │ + def get_bridge_configs(self) -> Sequence[BridgeConfig]:
│ │ │ │ │ + def yield_configs():
│ │ │ │ │ + for package in sorted(self.packages.values(), key=lambda package: -package.priority):
│ │ │ │ │ + yield from package.manifest.bridges
│ │ │ │ │ + return tuple(yield_configs())
│ │ │ │ │ +
│ │ │ │ │ + # D-Bus Interface
│ │ │ │ │ + manifests = bus.Interface.Property('s', value="{}")
│ │ │ │ │ +
│ │ │ │ │ + @bus.Interface.Method()
│ │ │ │ │ + def reload(self):
│ │ │ │ │ + self.load()
│ │ │ │ │ + if self.listener is not None:
│ │ │ │ │ + self.listener.packages_loaded()
│ │ │ │ │ +
│ │ │ │ │ + @bus.Interface.Method()
│ │ │ │ │ + def reload_hint(self):
│ │ │ │ │ + if self.saw_first_reload_hint:
│ │ │ │ │ + self.reload()
│ │ │ │ │ + self.saw_first_reload_hint = True
│ │ │ │ │ +
│ │ │ │ │ + def load_manifests_js(self, headers: JsonObject) -> Document:
│ │ │ │ │ + logger.debug('Serving /manifests.js')
│ │ │ │ │ +
│ │ │ │ │ + chunks: List[bytes] = []
│ │ │ │ │ +
│ │ │ │ │ + # Send the translations required for the manifest files, from each package
│ │ │ │ │ + locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))
│ │ │ │ │ + for name, package in self.packages.items():
│ │ │ │ │ + if name in ['static', 'base1']:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + # find_translation will always find at least 'en'
│ │ │ │ │ + translation = package.load_translation('po.manifest.js', locales)
│ │ │ │ │ + with translation.data:
│ │ │ │ │ + if translation.content_encoding == 'gzip':
│ │ │ │ │ + data = gzip.decompress(translation.data.read())
│ │ │ │ │ + else:
│ │ │ │ │ + data = translation.data.read()
│ │ │ │ │ +
│ │ │ │ │ + chunks.append(data)
│ │ │ │ │ +
│ │ │ │ │ + chunks.append(b"""
│ │ │ │ │ + (function (root, data) {
│ │ │ │ │ + if (typeof define === 'function' && define.amd) {
│ │ │ │ │ + define(data);
│ │ │ │ │ + }
│ │ │ │ │ +
│ │ │ │ │ + if (typeof cockpit === 'object') {
│ │ │ │ │ + cockpit.manifests = data;
│ │ │ │ │ + } else {
│ │ │ │ │ + root.manifests = data;
│ │ │ │ │ + }
│ │ │ │ │ + }(this, """ + self.manifests.encode() + b"""))""")
│ │ │ │ │ +
│ │ │ │ │ + return Document(io.BytesIO(b'\n'.join(chunks)), 'text/javascript')
│ │ │ │ │ +
│ │ │ │ │ + def load_manifests_json(self) -> Document:
│ │ │ │ │ + logger.debug('Serving /manifests.json')
│ │ │ │ │ + return Document(io.BytesIO(self.manifests.encode()), 'application/json')
│ │ │ │ │ +
│ │ │ │ │ + PATH_RE = re.compile(
│ │ │ │ │ + r'/' # leading '/'
│ │ │ │ │ + r'(?:([^/]+)/)?' # optional leading path component
│ │ │ │ │ + r'((?:[^/]+/)*[^/]+)' # remaining path components
│ │ │ │ │ + )
│ │ │ │ │ +
│ │ │ │ │ + def load_path(self, path: str, headers: JsonObject) -> Document:
│ │ │ │ │ + logger.debug('packages: serving %s', path)
│ │ │ │ │ +
│ │ │ │ │ + match = self.PATH_RE.fullmatch(path)
│ │ │ │ │ + if match is None:
│ │ │ │ │ + raise ValueError(f'Invalid HTTP path {path}')
│ │ │ │ │ + packagename, filename = match.groups()
│ │ │ │ │ +
│ │ │ │ │ + if packagename is not None:
│ │ │ │ │ + return self.packages[packagename].load_path(filename, headers)
│ │ │ │ │ + elif filename == 'manifests.js':
│ │ │ │ │ + return self.load_manifests_js(headers)
│ │ │ │ │ + elif filename == 'manifests.json':
│ │ │ │ │ + return self.load_manifests_json()
│ │ │ │ │ + else:
│ │ │ │ │ + raise KeyError
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/polyfills.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import socket
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def install():
│ │ │ │ │ + """Add shims for older Python versions"""
│ │ │ │ │ +
│ │ │ │ │ + # introduced in 3.9
│ │ │ │ │ + if not hasattr(socket, 'recv_fds'):
│ │ │ │ │ + import array
│ │ │ │ │ +
│ │ │ │ │ + import _socket
│ │ │ │ │ +
│ │ │ │ │ + def recv_fds(sock, bufsize, maxfds, flags=0):
│ │ │ │ │ + fds = array.array("i")
│ │ │ │ │ + msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize))
│ │ │ │ │ + for cmsg_level, cmsg_type, cmsg_data in ancdata:
│ │ │ │ │ + if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS):
│ │ │ │ │ + fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
│ │ │ │ │ + return msg, list(fds), flags, addr
│ │ │ │ │ +
│ │ │ │ │ + socket.recv_fds = recv_fds
│ │ │ │ │ +
│ │ │ │ │ + # introduced in 3.7
│ │ │ │ │ + if not hasattr(contextlib, 'AsyncExitStack'):
│ │ │ │ │ + class AsyncExitStack:
│ │ │ │ │ + async def __aenter__(self):
│ │ │ │ │ + self.cms = []
│ │ │ │ │ + return self
│ │ │ │ │ +
│ │ │ │ │ + async def enter_async_context(self, cm):
│ │ │ │ │ + result = await cm.__aenter__()
│ │ │ │ │ + self.cms.append(cm)
│ │ │ │ │ + return result
│ │ │ │ │ +
│ │ │ │ │ + async def __aexit__(self, exc_type, exc_value, traceback):
│ │ │ │ │ + for cm in self.cms:
│ │ │ │ │ + cm.__aexit__(exc_type, exc_value, traceback)
│ │ │ │ │ +
│ │ │ │ │ + contextlib.AsyncExitStack = AsyncExitStack
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/channel.py': r'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -1300,172 +2588,14 @@
│ │ │ │ │ try:
│ │ │ │ │ while self.send_data(next(self.__generator)):
│ │ │ │ │ pass
│ │ │ │ │ except StopIteration as stop:
│ │ │ │ │ self.done()
│ │ │ │ │ self.close(stop.value)
│ │ │ │ │ '''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/internal_endpoints.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import glob
│ │ │ │ │ -import grp
│ │ │ │ │ -import json
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import pwd
│ │ │ │ │ -from pathlib import Path
│ │ │ │ │ -from typing import Dict, Optional
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch
│ │ │ │ │ -
│ │ │ │ │ -from . import config
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class cockpit_LoginMessages(bus.Object):
│ │ │ │ │ - messages: Optional[str] = None
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - fdstr = os.environ.pop('COCKPIT_LOGIN_MESSAGES_MEMFD', None)
│ │ │ │ │ - if fdstr is None:
│ │ │ │ │ - logger.debug("COCKPIT_LOGIN_MESSAGES_MEMFD wasn't set. No login messages today.")
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - logger.debug("Trying to read login messages from fd %s", fdstr)
│ │ │ │ │ - try:
│ │ │ │ │ - with open(int(fdstr), 'r') as login_messages:
│ │ │ │ │ - login_messages.seek(0)
│ │ │ │ │ - self.messages = login_messages.read()
│ │ │ │ │ - except (ValueError, OSError, UnicodeDecodeError) as exc:
│ │ │ │ │ - # ValueError - the envvar wasn't an int
│ │ │ │ │ - # OSError - the fd wasn't open, or other read failure
│ │ │ │ │ - # UnicodeDecodeError - didn't contain utf-8
│ │ │ │ │ - # For all of these, we simply failed to get the message.
│ │ │ │ │ - logger.debug("Reading login messages failed: %s", exc)
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug("Successfully read login messages: %s", self.messages)
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method(out_types=['s'])
│ │ │ │ │ - def get(self):
│ │ │ │ │ - return self.messages or '{}'
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method(out_types=[])
│ │ │ │ │ - def dismiss(self):
│ │ │ │ │ - self.messages = None
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class cockpit_Machines(bus.Object):
│ │ │ │ │ - path: Path
│ │ │ │ │ - watch: pathwatch.PathWatch
│ │ │ │ │ - pending_notify: Optional[asyncio.Handle]
│ │ │ │ │ -
│ │ │ │ │ - # D-Bus implementation
│ │ │ │ │ - machines = bus.Interface.Property('a{sa{sv}}')
│ │ │ │ │ -
│ │ │ │ │ - @machines.getter
│ │ │ │ │ - def get_machines(self) -> Dict[str, Dict[str, Variant]]:
│ │ │ │ │ - results: Dict[str, Dict[str, Variant]] = {}
│ │ │ │ │ -
│ │ │ │ │ - for filename in glob.glob(f'{self.path}/*.json'):
│ │ │ │ │ - with open(filename) as fp:
│ │ │ │ │ - try:
│ │ │ │ │ - contents = json.load(fp)
│ │ │ │ │ - except json.JSONDecodeError:
│ │ │ │ │ - logger.warning('Invalid JSON in file %s. Ignoring.', filename)
│ │ │ │ │ - continue
│ │ │ │ │ - # merge
│ │ │ │ │ - for hostname, attrs in contents.items():
│ │ │ │ │ - results[hostname] = {key: Variant(value) for key, value in attrs.items()}
│ │ │ │ │ -
│ │ │ │ │ - return results
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method(in_types=['s', 's', 'a{sv}'])
│ │ │ │ │ - def update(self, filename: str, hostname: str, attrs: Dict[str, Variant]) -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - with self.path.joinpath(filename).open() as fp:
│ │ │ │ │ - contents = json.load(fp)
│ │ │ │ │ - except json.JSONDecodeError as exc:
│ │ │ │ │ - # Refuse to replace corrupted file
│ │ │ │ │ - raise bus.BusError('cockpit.Machines.Error', f'File {filename} is in invalid format: {exc}.') from exc
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - # But an empty file is an expected case
│ │ │ │ │ - contents = {}
│ │ │ │ │ -
│ │ │ │ │ - contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()})
│ │ │ │ │ -
│ │ │ │ │ - self.path.mkdir(parents=True, exist_ok=True)
│ │ │ │ │ - with open(self.path.joinpath(filename), 'w') as fp:
│ │ │ │ │ - json.dump(contents, fp, indent=2)
│ │ │ │ │ -
│ │ │ │ │ - def notify(self):
│ │ │ │ │ - def _notify_now():
│ │ │ │ │ - self.properties_changed('cockpit.Machines', {}, ['Machines'])
│ │ │ │ │ - self.pending_notify = None
│ │ │ │ │ -
│ │ │ │ │ - # avoid a flurry of update notifications
│ │ │ │ │ - if self.pending_notify is None:
│ │ │ │ │ - self.pending_notify = asyncio.get_running_loop().call_later(1.0, _notify_now)
│ │ │ │ │ -
│ │ │ │ │ - # inotify events
│ │ │ │ │ - def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None:
│ │ │ │ │ - self.notify()
│ │ │ │ │ -
│ │ │ │ │ - def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None:
│ │ │ │ │ - self.notify()
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - self.path = config.lookup_config('machines.d')
│ │ │ │ │ -
│ │ │ │ │ - # ignore the first callback
│ │ │ │ │ - self.pending_notify = ...
│ │ │ │ │ - self.watch = pathwatch.PathWatch(str(self.path), self)
│ │ │ │ │ - self.pending_notify = None
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class cockpit_User(bus.Object):
│ │ │ │ │ - name = bus.Interface.Property('s', value='')
│ │ │ │ │ - full = bus.Interface.Property('s', value='')
│ │ │ │ │ - id = bus.Interface.Property('i', value=0)
│ │ │ │ │ - home = bus.Interface.Property('s', value='')
│ │ │ │ │ - shell = bus.Interface.Property('s', value='')
│ │ │ │ │ - groups = bus.Interface.Property('as', value=[])
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - user = pwd.getpwuid(os.getuid())
│ │ │ │ │ - self.name = user.pw_name
│ │ │ │ │ - self.full = user.pw_gecos
│ │ │ │ │ - self.id = user.pw_uid
│ │ │ │ │ - self.home = user.pw_dir
│ │ │ │ │ - self.shell = user.pw_shell
│ │ │ │ │ - self.groups = [gr.gr_name for gr in grp.getgrall() if user.pw_name in gr.gr_mem]
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -EXPORTS = [
│ │ │ │ │ - ('/LoginMessages', cockpit_LoginMessages),
│ │ │ │ │ - ('/machines', cockpit_Machines),
│ │ │ │ │ - ('/user', cockpit_User),
│ │ │ │ │ -]
│ │ │ │ │ -''',
│ │ │ │ │ 'cockpit/beiboot.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -1811,699 +2941,207 @@
│ │ │ │ │
│ │ │ │ │ asyncio.run(run(args), debug=args.debug)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ if __name__ == '__main__':
│ │ │ │ │ main()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/bridge.py': r'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/jsonutil.py': r'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ #
│ │ │ │ │ # 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 .
│ │ │ │ │
│ │ │ │ │ -import argparse
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import contextlib
│ │ │ │ │ -import json
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import pwd
│ │ │ │ │ -import shlex
│ │ │ │ │ -import socket
│ │ │ │ │ -import stat
│ │ │ │ │ -import subprocess
│ │ │ │ │ -from typing import Iterable, List, Optional, Sequence, Tuple, Type
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor.ferny import interaction_client
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import bus, run_async
│ │ │ │ │ -
│ │ │ │ │ -from . import polyfills
│ │ │ │ │ -from ._version import __version__
│ │ │ │ │ -from .channel import ChannelRoutingRule
│ │ │ │ │ -from .channels import CHANNEL_TYPES
│ │ │ │ │ -from .config import Config, Environment
│ │ │ │ │ -from .internal_endpoints import EXPORTS
│ │ │ │ │ -from .jsonutil import JsonError, JsonObject, get_dict
│ │ │ │ │ -from .packages import BridgeConfig, Packages, PackagesListener
│ │ │ │ │ -from .peer import PeersRoutingRule
│ │ │ │ │ -from .remote import HostRoutingRule
│ │ │ │ │ -from .router import Router
│ │ │ │ │ -from .superuser import SuperuserRoutingRule
│ │ │ │ │ -from .transports import StdioTransport
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class InternalBus:
│ │ │ │ │ - exportees: List[bus.Slot]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]):
│ │ │ │ │ - client_socket, server_socket = socket.socketpair()
│ │ │ │ │ - self.client = bus.Bus.new(fd=client_socket.detach())
│ │ │ │ │ - self.server = bus.Bus.new(fd=server_socket.detach(), server=True)
│ │ │ │ │ - self.exportees = [self.server.add_object(path, cls()) for path, cls in exports]
│ │ │ │ │ -
│ │ │ │ │ - def export(self, path: str, obj: bus.BaseObject) -> None:
│ │ │ │ │ - self.exportees.append(self.server.add_object(path, obj))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Bridge(Router, PackagesListener):
│ │ │ │ │ - internal_bus: InternalBus
│ │ │ │ │ - packages: Optional[Packages]
│ │ │ │ │ - bridge_configs: Sequence[BridgeConfig]
│ │ │ │ │ - args: argparse.Namespace
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, args: argparse.Namespace):
│ │ │ │ │ - self.internal_bus = InternalBus(EXPORTS)
│ │ │ │ │ - self.bridge_configs = []
│ │ │ │ │ - self.args = args
│ │ │ │ │ -
│ │ │ │ │ - self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged)
│ │ │ │ │ - self.internal_bus.export('/superuser', self.superuser_rule)
│ │ │ │ │ -
│ │ │ │ │ - self.internal_bus.export('/config', Config())
│ │ │ │ │ - self.internal_bus.export('/environment', Environment())
│ │ │ │ │ -
│ │ │ │ │ - self.peers_rule = PeersRoutingRule(self)
│ │ │ │ │ -
│ │ │ │ │ - if args.beipack:
│ │ │ │ │ - # Some special stuff for beipack
│ │ │ │ │ - self.superuser_rule.set_configs((
│ │ │ │ │ - BridgeConfig({
│ │ │ │ │ - "privileged": True,
│ │ │ │ │ - "spawn": ["sudo", "-k", "-A", "python3", "-ic", "# cockpit-bridge", "--privileged"],
│ │ │ │ │ - "environ": ["SUDO_ASKPASS=ferny-askpass"],
│ │ │ │ │ - }),
│ │ │ │ │ - ))
│ │ │ │ │ - self.packages = None
│ │ │ │ │ - elif args.privileged:
│ │ │ │ │ - self.packages = None
│ │ │ │ │ - else:
│ │ │ │ │ - self.packages = Packages(self)
│ │ │ │ │ - self.internal_bus.export('/packages', self.packages)
│ │ │ │ │ - self.packages_loaded()
│ │ │ │ │ -
│ │ │ │ │ - super().__init__([
│ │ │ │ │ - HostRoutingRule(self),
│ │ │ │ │ - self.superuser_rule,
│ │ │ │ │ - ChannelRoutingRule(self, CHANNEL_TYPES),
│ │ │ │ │ - self.peers_rule,
│ │ │ │ │ - ])
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_os_release():
│ │ │ │ │ - try:
│ │ │ │ │ - file = open('/etc/os-release', encoding='utf-8')
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - try:
│ │ │ │ │ - file = open('/usr/lib/os-release', encoding='utf-8')
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - logger.warning("Neither /etc/os-release nor /usr/lib/os-release exists")
│ │ │ │ │ - return {}
│ │ │ │ │ -
│ │ │ │ │ - os_release = {}
│ │ │ │ │ - for line in file.readlines():
│ │ │ │ │ - line = line.strip()
│ │ │ │ │ - if not line or line.startswith('#'):
│ │ │ │ │ - continue
│ │ │ │ │ - try:
│ │ │ │ │ - k, v = line.split('=')
│ │ │ │ │ - (v_parsed, ) = shlex.split(v) # expect exactly one token
│ │ │ │ │ - except ValueError:
│ │ │ │ │ - logger.warning('Ignoring invalid line in os-release: %r', line)
│ │ │ │ │ - continue
│ │ │ │ │ - os_release[k] = v_parsed
│ │ │ │ │ - return os_release
│ │ │ │ │ -
│ │ │ │ │ - def do_init(self, message: JsonObject) -> None:
│ │ │ │ │ - # we're only interested in the case where this is a dict, but
│ │ │ │ │ - # 'superuser' may well be `False` and that's not an error
│ │ │ │ │ - with contextlib.suppress(JsonError):
│ │ │ │ │ - superuser = get_dict(message, 'superuser')
│ │ │ │ │ - self.superuser_rule.init(superuser)
│ │ │ │ │ -
│ │ │ │ │ - def do_send_init(self) -> None:
│ │ │ │ │ - init_args = {
│ │ │ │ │ - 'capabilities': {'explicit-superuser': True},
│ │ │ │ │ - 'command': 'init',
│ │ │ │ │ - 'os-release': self.get_os_release(),
│ │ │ │ │ - 'version': 1,
│ │ │ │ │ - }
│ │ │ │ │ -
│ │ │ │ │ - if self.packages is not None:
│ │ │ │ │ - init_args['packages'] = dict.fromkeys(self.packages.packages)
│ │ │ │ │ -
│ │ │ │ │ - self.write_control(init_args)
│ │ │ │ │ -
│ │ │ │ │ - # PackagesListener interface
│ │ │ │ │ - def packages_loaded(self) -> None:
│ │ │ │ │ - assert self.packages
│ │ │ │ │ - bridge_configs = self.packages.get_bridge_configs()
│ │ │ │ │ - if self.bridge_configs != bridge_configs:
│ │ │ │ │ - self.superuser_rule.set_configs(bridge_configs)
│ │ │ │ │ - self.peers_rule.set_configs(bridge_configs)
│ │ │ │ │ - self.bridge_configs = bridge_configs
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -async def run(args) -> None:
│ │ │ │ │ - logger.debug("Hi. How are you today?")
│ │ │ │ │ -
│ │ │ │ │ - # Unit tests require this
│ │ │ │ │ - me = pwd.getpwuid(os.getuid())
│ │ │ │ │ - os.environ['HOME'] = me.pw_dir
│ │ │ │ │ - os.environ['SHELL'] = me.pw_shell
│ │ │ │ │ - os.environ['USER'] = me.pw_name
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('Starting the router.')
│ │ │ │ │ - router = Bridge(args)
│ │ │ │ │ - StdioTransport(asyncio.get_running_loop(), router)
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('Startup done. Looping until connection closes.')
│ │ │ │ │ +from enum import Enum
│ │ │ │ │ +from typing import Callable, Container, Dict, List, Mapping, Optional, Sequence, Type, TypeVar, Union
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - await router.communicate()
│ │ │ │ │ - except (BrokenPipeError, ConnectionResetError):
│ │ │ │ │ - # not unexpected if the peer doesn't hang up cleanly
│ │ │ │ │ - pass
│ │ │ │ │ +JsonLiteral = Union[str, float, bool, None]
│ │ │ │ │
│ │ │ │ │ +# immutable
│ │ │ │ │ +JsonValue = Union['JsonObject', Sequence['JsonValue'], JsonLiteral]
│ │ │ │ │ +JsonObject = Mapping[str, JsonValue]
│ │ │ │ │
│ │ │ │ │ -def try_to_receive_stderr():
│ │ │ │ │ - try:
│ │ │ │ │ - ours, theirs = socket.socketpair()
│ │ │ │ │ - with ours:
│ │ │ │ │ - with theirs:
│ │ │ │ │ - interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()])
│ │ │ │ │ - _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1)
│ │ │ │ │ - except OSError:
│ │ │ │ │ - return
│ │ │ │ │ +# mutable
│ │ │ │ │ +JsonDocument = Union['JsonDict', 'JsonList', JsonLiteral]
│ │ │ │ │ +JsonDict = Dict[str, JsonDocument]
│ │ │ │ │ +JsonList = List[JsonDocument]
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - stderr_fd, = fds
│ │ │ │ │ - # We're about to abruptly drop our end of the stderr socketpair that we
│ │ │ │ │ - # share with the ferny agent. ferny would normally treat that as an
│ │ │ │ │ - # unexpected error. Instruct it to do a clean exit, instead.
│ │ │ │ │ - interaction_client.command(2, 'ferny.end')
│ │ │ │ │ - os.dup2(stderr_fd, 2)
│ │ │ │ │ - finally:
│ │ │ │ │ - for fd in fds:
│ │ │ │ │ - os.close(fd)
│ │ │ │ │
│ │ │ │ │ +DT = TypeVar('DT')
│ │ │ │ │ +T = TypeVar('T')
│ │ │ │ │
│ │ │ │ │ -def setup_journald() -> bool:
│ │ │ │ │ - # If stderr is a socket, prefer systemd-journal logging. This covers the
│ │ │ │ │ - # case we're already connected to the journal but also the case where we're
│ │ │ │ │ - # talking to the ferny agent, while leaving logging to file or terminal
│ │ │ │ │ - # unaffected.
│ │ │ │ │ - if not stat.S_ISSOCK(os.fstat(2).st_mode):
│ │ │ │ │ - # not a socket? Don't redirect.
│ │ │ │ │ - return False
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - import systemd.journal # type: ignore[import]
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - # No python3-systemd? Don't redirect.
│ │ │ │ │ - return False
│ │ │ │ │ +class JsonError(Exception):
│ │ │ │ │ + value: object
│ │ │ │ │
│ │ │ │ │ - logging.root.addHandler(systemd.journal.JournalHandler())
│ │ │ │ │ - return True
│ │ │ │ │ + def __init__(self, value: object, msg: str):
│ │ │ │ │ + super().__init__(msg)
│ │ │ │ │ + self.value = value
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def setup_logging(*, debug: bool) -> None:
│ │ │ │ │ - """Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set"""
│ │ │ │ │ +def typechecked(value: JsonValue, expected_type: Type[T]) -> T:
│ │ │ │ │ + """Ensure a JSON value has the expected type, returning it if so."""
│ │ │ │ │ + if not isinstance(value, expected_type):
│ │ │ │ │ + raise JsonError(value, f'must have type {expected_type.__name__}')
│ │ │ │ │ + return value
│ │ │ │ │
│ │ │ │ │ - modules = os.getenv('COCKPIT_DEBUG', '')
│ │ │ │ │
│ │ │ │ │ - # Either setup logging via journal or via formatted messages to stderr
│ │ │ │ │ - if not setup_journald():
│ │ │ │ │ - logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s')
│ │ │ │ │ +# We can't use None as a sentinel because it's often the actual default value
│ │ │ │ │ +# EllipsisType is difficult because it's not available before 3.10.
│ │ │ │ │ +# See https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions
│ │ │ │ │ +class _Empty(Enum):
│ │ │ │ │ + TOKEN = 0
│ │ │ │ │
│ │ │ │ │ - if debug or modules == 'all':
│ │ │ │ │ - logging.getLogger().setLevel(level=logging.DEBUG)
│ │ │ │ │ - elif modules:
│ │ │ │ │ - for module in modules.split(','):
│ │ │ │ │ - module = module.strip()
│ │ │ │ │ - if not module:
│ │ │ │ │ - continue
│ │ │ │ │
│ │ │ │ │ - logging.getLogger(module).setLevel(logging.DEBUG)
│ │ │ │ │ +_empty = _Empty.TOKEN
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def start_ssh_agent() -> None:
│ │ │ │ │ - # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust,
│ │ │ │ │ - # but it gets cleared on setgid ssh-agent, which some distros still do
│ │ │ │ │ +def _get(obj: JsonObject, cast: Callable[[JsonValue], T], key: str, default: Union[DT, _Empty]) -> Union[T, DT]:
│ │ │ │ │ try:
│ │ │ │ │ - proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'],
│ │ │ │ │ - stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
│ │ │ │ │ - assert proc.stdout is not None
│ │ │ │ │ -
│ │ │ │ │ - # Wait for the agent to write at least one line and look for the
│ │ │ │ │ - # listener socket. If we fail to find it, kill the agent — something
│ │ │ │ │ - # went wrong.
│ │ │ │ │ - for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True):
│ │ │ │ │ - if token.startswith('SSH_AUTH_SOCK='):
│ │ │ │ │ - os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1)
│ │ │ │ │ - break
│ │ │ │ │ - else:
│ │ │ │ │ - proc.terminate()
│ │ │ │ │ - proc.wait()
│ │ │ │ │ -
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - logger.debug("Couldn't start ssh-agent (FileNotFoundError)")
│ │ │ │ │ -
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - logger.warning("Could not start ssh-agent: %s", exc)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def main(*, beipack: bool = False) -> None:
│ │ │ │ │ - polyfills.install()
│ │ │ │ │ -
│ │ │ │ │ - parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')
│ │ │ │ │ - parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge')
│ │ │ │ │ - parser.add_argument('--packages', action='store_true', help='Show Cockpit package information')
│ │ │ │ │ - parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information')
│ │ │ │ │ - parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)')
│ │ │ │ │ - parser.add_argument('--version', action='store_true', help='Show Cockpit version information')
│ │ │ │ │ - args = parser.parse_args()
│ │ │ │ │ -
│ │ │ │ │ - # This is determined by who calls us
│ │ │ │ │ - args.beipack = beipack
│ │ │ │ │ -
│ │ │ │ │ - # If we were run with --privileged then our stderr is currently being
│ │ │ │ │ - # consumed by the main bridge looking for startup-related error messages.
│ │ │ │ │ - # Let's switch back to the original stderr stream, which has a side-effect
│ │ │ │ │ - # of indicating that our startup is more or less complete. Any errors
│ │ │ │ │ - # after this point will land in the journal.
│ │ │ │ │ - if args.privileged:
│ │ │ │ │ - try_to_receive_stderr()
│ │ │ │ │ -
│ │ │ │ │ - setup_logging(debug=args.debug)
│ │ │ │ │ -
│ │ │ │ │ - # Special modes
│ │ │ │ │ - if args.packages:
│ │ │ │ │ - Packages().show()
│ │ │ │ │ - return
│ │ │ │ │ - elif args.version:
│ │ │ │ │ - print(f'Version: {__version__}\nProtocol: 1')
│ │ │ │ │ - return
│ │ │ │ │ - elif args.bridges:
│ │ │ │ │ - print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2))
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - # The privileged bridge doesn't need ssh-agent, but the main one does
│ │ │ │ │ - if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged:
│ │ │ │ │ - start_ssh_agent()
│ │ │ │ │ -
│ │ │ │ │ - # asyncio.run() shim for Python 3.6 support
│ │ │ │ │ - run_async(run(args), debug=args.debug)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/polyfills.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import contextlib
│ │ │ │ │ -import socket
│ │ │ │ │ -
│ │ │ │ │ + return cast(obj[key])
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + if default is not _empty:
│ │ │ │ │ + return default
│ │ │ │ │ + raise JsonError(obj, f"attribute '{key}' required") from None
│ │ │ │ │ + except JsonError as exc:
│ │ │ │ │ + target = f"attribute '{key}'" + (' elements:' if exc.value is not obj[key] else ':')
│ │ │ │ │ + raise JsonError(obj, f"{target} {exc!s}") from exc
│ │ │ │ │
│ │ │ │ │ -def install():
│ │ │ │ │ - """Add shims for older Python versions"""
│ │ │ │ │
│ │ │ │ │ - # introduced in 3.9
│ │ │ │ │ - if not hasattr(socket, 'recv_fds'):
│ │ │ │ │ - import array
│ │ │ │ │ +def get_bool(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, bool]:
│ │ │ │ │ + return _get(obj, lambda v: typechecked(v, bool), key, default)
│ │ │ │ │
│ │ │ │ │ - import _socket
│ │ │ │ │
│ │ │ │ │ - def recv_fds(sock, bufsize, maxfds, flags=0):
│ │ │ │ │ - fds = array.array("i")
│ │ │ │ │ - msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize))
│ │ │ │ │ - for cmsg_level, cmsg_type, cmsg_data in ancdata:
│ │ │ │ │ - if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS):
│ │ │ │ │ - fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
│ │ │ │ │ - return msg, list(fds), flags, addr
│ │ │ │ │ +def get_int(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, int]:
│ │ │ │ │ + return _get(obj, lambda v: typechecked(v, int), key, default)
│ │ │ │ │
│ │ │ │ │ - socket.recv_fds = recv_fds
│ │ │ │ │
│ │ │ │ │ - # introduced in 3.7
│ │ │ │ │ - if not hasattr(contextlib, 'AsyncExitStack'):
│ │ │ │ │ - class AsyncExitStack:
│ │ │ │ │ - async def __aenter__(self):
│ │ │ │ │ - self.cms = []
│ │ │ │ │ - return self
│ │ │ │ │ +def get_str(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, str]:
│ │ │ │ │ + return _get(obj, lambda v: typechecked(v, str), key, default)
│ │ │ │ │
│ │ │ │ │ - async def enter_async_context(self, cm):
│ │ │ │ │ - result = await cm.__aenter__()
│ │ │ │ │ - self.cms.append(cm)
│ │ │ │ │ - return result
│ │ │ │ │
│ │ │ │ │ - async def __aexit__(self, exc_type, exc_value, traceback):
│ │ │ │ │ - for cm in self.cms:
│ │ │ │ │ - cm.__aexit__(exc_type, exc_value, traceback)
│ │ │ │ │ +def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Optional[str]:
│ │ │ │ │ + return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default)
│ │ │ │ │
│ │ │ │ │ - contextlib.AsyncExitStack = AsyncExitStack
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/remote.py': r'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │
│ │ │ │ │ -import getpass
│ │ │ │ │ -import logging
│ │ │ │ │ -import re
│ │ │ │ │ -import socket
│ │ │ │ │ -from typing import Dict, List, Optional, Tuple
│ │ │ │ │ +def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]:
│ │ │ │ │ + return _get(obj, lambda v: typechecked(v, dict), key, default)
│ │ │ │ │
│ │ │ │ │ -from cockpit._vendor import ferny
│ │ │ │ │
│ │ │ │ │ -from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none
│ │ │ │ │ -from .peer import Peer, PeerError
│ │ │ │ │ -from .router import Router, RoutingRule
│ │ │ │ │ +def get_object(
│ │ │ │ │ + obj: JsonObject,
│ │ │ │ │ + key: str,
│ │ │ │ │ + constructor: Callable[[JsonObject], T],
│ │ │ │ │ + default: Union[DT, _Empty] = _empty
│ │ │ │ │ +) -> Union[DT, T]:
│ │ │ │ │ + return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default)
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ +def get_strv(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, Sequence[str]]:
│ │ │ │ │ + def as_strv(value: JsonValue) -> Sequence[str]:
│ │ │ │ │ + return tuple(typechecked(item, str) for item in typechecked(value, list))
│ │ │ │ │ + return _get(obj, as_strv, key, default)
│ │ │ │ │
│ │ │ │ │ -class PasswordResponder(ferny.AskpassHandler):
│ │ │ │ │ - PASSPHRASE_RE = re.compile(r"Enter passphrase for key '(.*)': ")
│ │ │ │ │
│ │ │ │ │ - password: Optional[str]
│ │ │ │ │ +def get_enum(
│ │ │ │ │ + obj: JsonObject, key: str, choices: Container[str], default: Union[DT, _Empty] = _empty
│ │ │ │ │ +) -> Union[DT, str]:
│ │ │ │ │ + def as_choice(value: JsonValue) -> str:
│ │ │ │ │ + # mypy can't do `__eq__()`-based type narrowing...
│ │ │ │ │ + # https://github.com/python/mypy/issues/17101
│ │ │ │ │ + if isinstance(value, str) and value in choices:
│ │ │ │ │ + return value
│ │ │ │ │ + raise JsonError(value, f'invalid value "{value}" not in {choices}')
│ │ │ │ │ + return _get(obj, as_choice, key, default)
│ │ │ │ │
│ │ │ │ │ - hostkeys_seen: List[Tuple[str, str, str, str, str]]
│ │ │ │ │ - error_message: Optional[str]
│ │ │ │ │ - password_attempts: int
│ │ │ │ │
│ │ │ │ │ - def __init__(self, password: Optional[str]):
│ │ │ │ │ - self.password = password
│ │ │ │ │ +def get_objv(obj: JsonObject, key: str, constructor: Callable[[JsonObject], T]) -> Union[DT, Sequence[T]]:
│ │ │ │ │ + def as_objv(value: JsonValue) -> Sequence[T]:
│ │ │ │ │ + return tuple(constructor(typechecked(item, dict)) for item in typechecked(value, list))
│ │ │ │ │ + return _get(obj, as_objv, key, ())
│ │ │ │ │
│ │ │ │ │ - self.hostkeys_seen = []
│ │ │ │ │ - self.error_message = None
│ │ │ │ │ - self.password_attempts = 0
│ │ │ │ │
│ │ │ │ │ - async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:
│ │ │ │ │ - self.hostkeys_seen.append((reason, host, algorithm, key, fingerprint))
│ │ │ │ │ - return False
│ │ │ │ │ +def create_object(message: 'JsonObject | None', kwargs: JsonObject) -> JsonObject:
│ │ │ │ │ + """Constructs a JSON object based on message and kwargs.
│ │ │ │ │
│ │ │ │ │ - async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:
│ │ │ │ │ - logger.debug('Got askpass(%s): %s', hint, prompt)
│ │ │ │ │ + If only message is given, it is returned, unmodified. If message is None,
│ │ │ │ │ + it is equivalent to an empty dictionary. A copy is always made.
│ │ │ │ │
│ │ │ │ │ - match = PasswordResponder.PASSPHRASE_RE.fullmatch(prompt)
│ │ │ │ │ - if match is not None:
│ │ │ │ │ - # We never unlock private keys — we rather need to throw a
│ │ │ │ │ - # specially-formatted error message which will cause the frontend
│ │ │ │ │ - # to load the named key into the agent for us and try again.
│ │ │ │ │ - path = match.group(1)
│ │ │ │ │ - logger.debug("This is a passphrase request for %s, but we don't do those. Abort.", path)
│ │ │ │ │ - self.error_message = f'locked identity: {path}'
│ │ │ │ │ - return None
│ │ │ │ │ + If kwargs are present, then any underscore ('_') present in a key name is
│ │ │ │ │ + rewritten to a dash ('-'). This is intended to bridge between the required
│ │ │ │ │ + Python syntax when providing kwargs and idiomatic JSON (which uses '-' for
│ │ │ │ │ + attributes). These values override values in message.
│ │ │ │ │
│ │ │ │ │ - assert self.password is not None
│ │ │ │ │ - assert self.password_attempts == 0
│ │ │ │ │ - self.password_attempts += 1
│ │ │ │ │ - return self.password
│ │ │ │ │ + The idea is that `message` should be used for passing data along, and
│ │ │ │ │ + kwargs used for data originating at a given call site, possibly including
│ │ │ │ │ + modifications to an original message.
│ │ │ │ │ + """
│ │ │ │ │ + result = dict(message or {})
│ │ │ │ │
│ │ │ │ │ + for key, value in kwargs.items():
│ │ │ │ │ + # rewrite '_' (necessary in Python syntax kwargs list) to '-' (idiomatic JSON)
│ │ │ │ │ + json_key = key.replace('_', '-')
│ │ │ │ │ + result[json_key] = value
│ │ │ │ │
│ │ │ │ │ -class SshPeer(Peer):
│ │ │ │ │ - session: Optional[ferny.Session] = None
│ │ │ │ │ - host: str
│ │ │ │ │ - user: Optional[str]
│ │ │ │ │ - password: Optional[str]
│ │ │ │ │ - private: bool
│ │ │ │ │ + return result
│ │ │ │ │
│ │ │ │ │ - async def do_connect_transport(self) -> None:
│ │ │ │ │ - assert self.session is not None
│ │ │ │ │ - logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private)
│ │ │ │ │
│ │ │ │ │ - basename, colon, portstr = self.host.rpartition(':')
│ │ │ │ │ - if colon and portstr.isdigit():
│ │ │ │ │ - host = basename
│ │ │ │ │ - port = int(portstr)
│ │ │ │ │ +def json_merge_patch(current: JsonObject, patch: JsonObject) -> JsonObject:
│ │ │ │ │ + """Perform a JSON merge patch (RFC 7396) using 'current' and 'patch'.
│ │ │ │ │ + Neither of the original dictionaries is modified — the result is returned.
│ │ │ │ │ + """
│ │ │ │ │ + # Always take a copy ('result') — we never modify the input ('current')
│ │ │ │ │ + result = dict(current)
│ │ │ │ │ + for key, patch_value in patch.items():
│ │ │ │ │ + if isinstance(patch_value, Mapping):
│ │ │ │ │ + current_value = current.get(key, None)
│ │ │ │ │ + if not isinstance(current_value, Mapping):
│ │ │ │ │ + current_value = {}
│ │ │ │ │ + result[key] = json_merge_patch(current_value, patch_value)
│ │ │ │ │ + elif patch_value is not None:
│ │ │ │ │ + result[key] = patch_value
│ │ │ │ │ else:
│ │ │ │ │ - host = self.host
│ │ │ │ │ - port = None
│ │ │ │ │ -
│ │ │ │ │ - responder = PasswordResponder(self.password)
│ │ │ │ │ - options = {"StrictHostKeyChecking": 'yes'}
│ │ │ │ │ + result.pop(key, None)
│ │ │ │ │
│ │ │ │ │ - if self.password is not None:
│ │ │ │ │ - options.update(NumberOfPasswordPrompts='1')
│ │ │ │ │ - else:
│ │ │ │ │ - options.update(PasswordAuthentication="no", KbdInteractiveAuthentication="no")
│ │ │ │ │ + return result
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - await self.session.connect(host, login_name=self.user, port=port,
│ │ │ │ │ - handle_host_key=self.private, options=options,
│ │ │ │ │ - interaction_responder=responder)
│ │ │ │ │ - except (OSError, socket.gaierror) as exc:
│ │ │ │ │ - logger.debug('connecting to host %s failed: %s', host, exc)
│ │ │ │ │ - raise PeerError('no-host', error='no-host', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ - except ferny.SshHostKeyError as exc:
│ │ │ │ │ - if responder.hostkeys_seen:
│ │ │ │ │ - # If we saw a hostkey then we can issue a detailed error message
│ │ │ │ │ - # containing the key that would need to be accepted. That will
│ │ │ │ │ - # cause the front-end to present a dialog.
│ │ │ │ │ - _reason, host, algorithm, key, fingerprint = responder.hostkeys_seen[0]
│ │ │ │ │ - error_args = {'host-key': f'{host} {algorithm} {key}', 'host-fingerprint': fingerprint}
│ │ │ │ │ - else:
│ │ │ │ │ - error_args = {}
│ │ │ │ │ +def json_merge_and_filter_patch(current: JsonDict, patch: JsonDict) -> None:
│ │ │ │ │ + """Perform a JSON merge patch (RFC 7396) modifying 'current' with 'patch'.
│ │ │ │ │ + Also modifies 'patch' to remove redundant operations.
│ │ │ │ │ + """
│ │ │ │ │ + for key, patch_value in tuple(patch.items()):
│ │ │ │ │ + current_value = current.get(key, None)
│ │ │ │ │
│ │ │ │ │ - if isinstance(exc, ferny.SshChangedHostKeyError):
│ │ │ │ │ - error = 'invalid-hostkey'
│ │ │ │ │ - elif self.private:
│ │ │ │ │ - error = 'unknown-hostkey'
│ │ │ │ │ + if isinstance(patch_value, dict):
│ │ │ │ │ + if not isinstance(current_value, dict):
│ │ │ │ │ + current[key] = current_value = {}
│ │ │ │ │ + json_merge_and_filter_patch(current_value, patch_value)
│ │ │ │ │ else:
│ │ │ │ │ - # non-private session case. throw a generic error.
│ │ │ │ │ - error = 'unknown-host'
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('SshPeer got a %s %s; private %s, seen hostkeys %r; raising %s with extra args %r',
│ │ │ │ │ - type(exc), exc, self.private, responder.hostkeys_seen, error, error_args)
│ │ │ │ │ - raise PeerError(error, error_args, error=error, auth_method_results={}) from exc
│ │ │ │ │ -
│ │ │ │ │ - except ferny.SshAuthenticationError as exc:
│ │ │ │ │ - logger.debug('authentication to host %s failed: %s', host, exc)
│ │ │ │ │ -
│ │ │ │ │ - results = dict.fromkeys(exc.methods, "not-provided")
│ │ │ │ │ - if 'password' in results and self.password is not None:
│ │ │ │ │ - if responder.password_attempts == 0:
│ │ │ │ │ - results['password'] = 'not-tried'
│ │ │ │ │ - else:
│ │ │ │ │ - results['password'] = 'denied'
│ │ │ │ │ -
│ │ │ │ │ - raise PeerError('authentication-failed',
│ │ │ │ │ - error=responder.error_message or 'authentication-failed',
│ │ │ │ │ - auth_method_results=results) from exc
│ │ │ │ │ -
│ │ │ │ │ - except ferny.SshError as exc:
│ │ │ │ │ - logger.debug('unknown failure connecting to host %s: %s', host, exc)
│ │ │ │ │ - raise PeerError('internal-error', message=str(exc)) from exc
│ │ │ │ │ -
│ │ │ │ │ - args = self.session.wrap_subprocess_args(['cockpit-bridge'])
│ │ │ │ │ - await self.spawn(args, [])
│ │ │ │ │ -
│ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ - if host == self.host:
│ │ │ │ │ - self.close()
│ │ │ │ │ - elif host is None:
│ │ │ │ │ - super().do_kill(host, group, message)
│ │ │ │ │ -
│ │ │ │ │ - def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ - if get_str(message, 'challenge').startswith('plain1:'):
│ │ │ │ │ - cookie = get_str(message, 'cookie')
│ │ │ │ │ - self.write_control(command='authorize', cookie=cookie, response=self.password or '')
│ │ │ │ │ - self.password = None # once is enough...
│ │ │ │ │ -
│ │ │ │ │ - def do_superuser_init_done(self) -> None:
│ │ │ │ │ - self.password = None
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None:
│ │ │ │ │ - super().__init__(router)
│ │ │ │ │ - self.host = host
│ │ │ │ │ - self.user = user
│ │ │ │ │ - self.password = get_str(options, 'password', None)
│ │ │ │ │ - self.private = private
│ │ │ │ │ -
│ │ │ │ │ - self.session = ferny.Session()
│ │ │ │ │ -
│ │ │ │ │ - superuser: JsonValue
│ │ │ │ │ - init_superuser = get_str_or_none(options, 'init-superuser', None)
│ │ │ │ │ - if init_superuser in (None, 'none'):
│ │ │ │ │ - superuser = False
│ │ │ │ │ - else:
│ │ │ │ │ - superuser = {'id': init_superuser}
│ │ │ │ │ -
│ │ │ │ │ - self.start_in_background(init_host=host, superuser=superuser)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class HostRoutingRule(RoutingRule):
│ │ │ │ │ - remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router):
│ │ │ │ │ - super().__init__(router)
│ │ │ │ │ - self.remotes = {}
│ │ │ │ │ -
│ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Peer]:
│ │ │ │ │ - assert self.router is not None
│ │ │ │ │ - assert self.router.init_host is not None
│ │ │ │ │ -
│ │ │ │ │ - host = get_str(options, 'host', self.router.init_host)
│ │ │ │ │ - if host == self.router.init_host:
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - user = get_str(options, 'user', None)
│ │ │ │ │ - # HACK: the front-end relies on this for tracking connections without an explicit user name;
│ │ │ │ │ - # the user will then be determined by SSH (`User` in the config or the current user)
│ │ │ │ │ - # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c
│ │ │ │ │ - if user == getpass.getuser():
│ │ │ │ │ - user = None
│ │ │ │ │ - if not user:
│ │ │ │ │ - user_from_host, _, _ = host.rpartition('@')
│ │ │ │ │ - user = user_from_host or None # avoid ''
│ │ │ │ │ -
│ │ │ │ │ - if get_str(options, 'session', None) == 'private':
│ │ │ │ │ - nonce = get_str(options, 'channel')
│ │ │ │ │ + json_merge_and_filter_patch(current_value, patch_value)
│ │ │ │ │ + if not patch_value:
│ │ │ │ │ + del patch[key]
│ │ │ │ │ + elif current_value == patch_value:
│ │ │ │ │ + del patch[key]
│ │ │ │ │ + elif patch_value is not None:
│ │ │ │ │ + current[key] = patch_value
│ │ │ │ │ else:
│ │ │ │ │ - nonce = None
│ │ │ │ │ -
│ │ │ │ │ - assert isinstance(host, str)
│ │ │ │ │ - assert user is None or isinstance(user, str)
│ │ │ │ │ - assert nonce is None or isinstance(nonce, str)
│ │ │ │ │ -
│ │ │ │ │ - key = host, user, nonce
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('Request for channel %s is remote.', options)
│ │ │ │ │ - logger.debug('key=%s', key)
│ │ │ │ │ -
│ │ │ │ │ - if key not in self.remotes:
│ │ │ │ │ - logger.debug('%s is not among the existing remotes %s. Opening a new connection.', key, self.remotes)
│ │ │ │ │ - peer = SshPeer(self.router, host, user, options, private=nonce is not None)
│ │ │ │ │ - peer.add_done_callback(lambda: self.remotes.__delitem__(key))
│ │ │ │ │ - self.remotes[key] = peer
│ │ │ │ │ -
│ │ │ │ │ - return self.remotes[key]
│ │ │ │ │ -
│ │ │ │ │ - def shutdown(self):
│ │ │ │ │ - for peer in set(self.remotes.values()):
│ │ │ │ │ - peer.close()
│ │ │ │ │ + del current[key]
│ │ │ │ │ '''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/beipack.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import logging
│ │ │ │ │ -import lzma
│ │ │ │ │ -from typing import List, Sequence, Tuple
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor import ferny
│ │ │ │ │ -from cockpit._vendor.bei import beipack
│ │ │ │ │ -
│ │ │ │ │ -from .data import read_cockpit_data_file
│ │ │ │ │ -from .peer import Peer, PeerError
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_bridge_beipack_xz() -> Tuple[str, bytes]:
│ │ │ │ │ - try:
│ │ │ │ │ - bridge_beipack_xz = read_cockpit_data_file('cockpit-bridge.beipack.xz')
│ │ │ │ │ - logger.debug('Got pre-built cockpit-bridge.beipack.xz')
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - logger.debug('Pre-built cockpit-bridge.beipack.xz; building our own.')
│ │ │ │ │ - # beipack ourselves
│ │ │ │ │ - cockpit_contents = beipack.collect_module('cockpit', recursive=True)
│ │ │ │ │ - bridge_beipack = beipack.pack(cockpit_contents, entrypoint='cockpit.bridge:main', args='beipack=True')
│ │ │ │ │ - bridge_beipack_xz = lzma.compress(bridge_beipack.encode())
│ │ │ │ │ - logger.debug(' ... done!')
│ │ │ │ │ -
│ │ │ │ │ - return 'cockpit/data/cockpit-bridge.beipack.xz', bridge_beipack_xz
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class BridgeBeibootHelper(ferny.InteractionHandler):
│ │ │ │ │ - # ferny.InteractionHandler ClassVar
│ │ │ │ │ - commands = ['beiboot.provide', 'beiboot.exc']
│ │ │ │ │ -
│ │ │ │ │ - peer: Peer
│ │ │ │ │ - payload: bytes
│ │ │ │ │ - steps: Sequence[Tuple[str, Sequence[object]]]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, peer: Peer, args: Sequence[str] = ()) -> None:
│ │ │ │ │ - filename, payload = get_bridge_beipack_xz()
│ │ │ │ │ -
│ │ │ │ │ - self.peer = peer
│ │ │ │ │ - self.payload = payload
│ │ │ │ │ - self.steps = (('boot_xz', (filename, len(payload), tuple(args))),)
│ │ │ │ │ -
│ │ │ │ │ - async def run_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None:
│ │ │ │ │ - logger.debug('Got ferny request %s %s %s %s', command, args, fds, stderr)
│ │ │ │ │ - if command == 'beiboot.provide':
│ │ │ │ │ - try:
│ │ │ │ │ - size, = args
│ │ │ │ │ - assert size == len(self.payload)
│ │ │ │ │ - except (AssertionError, ValueError) as exc:
│ │ │ │ │ - raise PeerError('internal-error', message=f'ferny interaction error {exc!s}') from exc
│ │ │ │ │ -
│ │ │ │ │ - assert self.peer.transport is not None
│ │ │ │ │ - logger.debug('Writing %d bytes of payload', len(self.payload))
│ │ │ │ │ - self.peer.transport.write(self.payload)
│ │ │ │ │ - elif command == 'beiboot.exc':
│ │ │ │ │ - raise PeerError('internal-error', message=f'Remote exception: {args[0]}')
│ │ │ │ │ - else:
│ │ │ │ │ - raise PeerError('internal-error', message=f'Unexpected ferny interaction command {command}')
│ │ │ │ │ -''',
│ │ │ │ │ 'cockpit/protocol.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -2754,1484 +3392,91 @@
│ │ │ │ │
│ │ │ │ │ if self.authorizations is None or cookie not in self.authorizations:
│ │ │ │ │ logger.warning('no matching authorize request')
│ │ │ │ │ return
│ │ │ │ │
│ │ │ │ │ self.authorizations[cookie].set_result(response)
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/packages.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import collections
│ │ │ │ │ -import contextlib
│ │ │ │ │ -import functools
│ │ │ │ │ -import gzip
│ │ │ │ │ -import io
│ │ │ │ │ -import itertools
│ │ │ │ │ -import json
│ │ │ │ │ -import logging
│ │ │ │ │ -import mimetypes
│ │ │ │ │ -import os
│ │ │ │ │ -import re
│ │ │ │ │ -import shutil
│ │ │ │ │ -from pathlib import Path
│ │ │ │ │ -from typing import (
│ │ │ │ │ - BinaryIO,
│ │ │ │ │ - Callable,
│ │ │ │ │ - ClassVar,
│ │ │ │ │ - Dict,
│ │ │ │ │ - Iterable,
│ │ │ │ │ - List,
│ │ │ │ │ - NamedTuple,
│ │ │ │ │ - Optional,
│ │ │ │ │ - Pattern,
│ │ │ │ │ - Sequence,
│ │ │ │ │ - Tuple,
│ │ │ │ │ - TypeVar,
│ │ │ │ │ -)
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import bus
│ │ │ │ │ -
│ │ │ │ │ -from . import config
│ │ │ │ │ -from ._version import __version__
│ │ │ │ │ -from .jsonutil import (
│ │ │ │ │ - JsonError,
│ │ │ │ │ - JsonObject,
│ │ │ │ │ - JsonValue,
│ │ │ │ │ - get_bool,
│ │ │ │ │ - get_dict,
│ │ │ │ │ - get_int,
│ │ │ │ │ - get_objv,
│ │ │ │ │ - get_str,
│ │ │ │ │ - get_strv,
│ │ │ │ │ - json_merge_patch,
│ │ │ │ │ - typechecked,
│ │ │ │ │ -)
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# In practice, this is going to get called over and over again with exactly the
│ │ │ │ │ -# same list. Let's try to cache the result.
│ │ │ │ │ -@functools.lru_cache()
│ │ │ │ │ -def parse_accept_language(accept_language: str) -> Sequence[str]:
│ │ │ │ │ - """Parse the Accept-Language header, if it exists.
│ │ │ │ │ -
│ │ │ │ │ - Returns an ordered list of languages, with fallbacks inserted, and
│ │ │ │ │ - truncated to the position where 'en' would have otherwise appeared, if
│ │ │ │ │ - applicable.
│ │ │ │ │ -
│ │ │ │ │ - https://tools.ietf.org/html/rfc7231#section-5.3.5
│ │ │ │ │ - https://datatracker.ietf.org/doc/html/rfc4647#section-3.4
│ │ │ │ │ - """
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('parse_accept_language(%r)', accept_language)
│ │ │ │ │ - locales_with_q = []
│ │ │ │ │ - for entry in accept_language.split(','):
│ │ │ │ │ - entry = entry.strip().lower()
│ │ │ │ │ - logger.debug(' entry %r', entry)
│ │ │ │ │ - locale, _, qstr = entry.partition(';q=')
│ │ │ │ │ - try:
│ │ │ │ │ - q = float(qstr or 1.0)
│ │ │ │ │ - except ValueError:
│ │ │ │ │ - continue # ignore malformed entry
│ │ │ │ │ -
│ │ │ │ │ - while locale:
│ │ │ │ │ - logger.debug(' adding %r q=%r', locale, q)
│ │ │ │ │ - locales_with_q.append((locale, q))
│ │ │ │ │ - # strip off '-detail' suffixes until there's nothing left
│ │ │ │ │ - locale, _, _region = locale.rpartition('-')
│ │ │ │ │ -
│ │ │ │ │ - # Sort the list by highest q value. Otherwise, this is a stable sort.
│ │ │ │ │ - locales_with_q.sort(key=lambda pair: pair[1], reverse=True)
│ │ │ │ │ - logger.debug(' sorted list is %r', locales_with_q)
│ │ │ │ │ -
│ │ │ │ │ - # If we have 'en' anywhere in our list, ignore it and all items after it.
│ │ │ │ │ - # This will result in us getting an untranslated (ie: English) version if
│ │ │ │ │ - # none of the more-preferred languages are found, which is what we want.
│ │ │ │ │ - # We also take the chance to drop duplicate items. Note: both of these
│ │ │ │ │ - # things need to happen after sorting.
│ │ │ │ │ - results = []
│ │ │ │ │ - for locale, _q in locales_with_q:
│ │ │ │ │ - if locale == 'en':
│ │ │ │ │ - break
│ │ │ │ │ - if locale not in results:
│ │ │ │ │ - results.append(locale)
│ │ │ │ │ -
│ │ │ │ │ - logger.debug(' results list is %r', results)
│ │ │ │ │ - return tuple(results)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def sortify_version(version: str) -> str:
│ │ │ │ │ - """Convert a version string to a form that can be compared"""
│ │ │ │ │ - # 0-pad each numeric component. Only supports numeric versions like 1.2.3.
│ │ │ │ │ - return '.'.join(part.zfill(8) for part in version.split('.'))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -@functools.lru_cache()
│ │ │ │ │ -def get_libexecdir() -> str:
│ │ │ │ │ - """Detect libexecdir on current machine
│ │ │ │ │ -
│ │ │ │ │ - This only works for systems which have cockpit-ws installed.
│ │ │ │ │ - """
│ │ │ │ │ - for candidate in ['/usr/local/libexec', '/usr/libexec', '/usr/local/lib/cockpit', '/usr/lib/cockpit']:
│ │ │ │ │ - if os.path.exists(os.path.join(candidate, 'cockpit-askpass')):
│ │ │ │ │ - return candidate
│ │ │ │ │ - else:
│ │ │ │ │ - logger.warning('Could not detect libexecdir')
│ │ │ │ │ - # give readable error messages
│ │ │ │ │ - return '/nonexistent/libexec'
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# HACK: Type narrowing over Union types is not supported in the general case,
│ │ │ │ │ -# but this works for the case we care about: knowing that when we pass in an
│ │ │ │ │ -# JsonObject, we'll get an JsonObject back.
│ │ │ │ │ -J = TypeVar('J', JsonObject, JsonValue)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def patch_libexecdir(obj: J) -> J:
│ │ │ │ │ - if isinstance(obj, str):
│ │ │ │ │ - if '${libexecdir}/cockpit-askpass' in obj:
│ │ │ │ │ - # extra-special case: we handle this internally
│ │ │ │ │ - abs_askpass = shutil.which('cockpit-askpass')
│ │ │ │ │ - if abs_askpass is not None:
│ │ │ │ │ - return obj.replace('${libexecdir}/cockpit-askpass', abs_askpass)
│ │ │ │ │ - return obj.replace('${libexecdir}', get_libexecdir())
│ │ │ │ │ - elif isinstance(obj, dict):
│ │ │ │ │ - return {key: patch_libexecdir(value) for key, value in obj.items()}
│ │ │ │ │ - elif isinstance(obj, list):
│ │ │ │ │ - return [patch_libexecdir(item) for item in obj]
│ │ │ │ │ - else:
│ │ │ │ │ - return obj
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# A document is a binary stream with a Content-Type, optional Content-Encoding,
│ │ │ │ │ -# and optional Content-Security-Policy
│ │ │ │ │ -class Document(NamedTuple):
│ │ │ │ │ - data: BinaryIO
│ │ │ │ │ - content_type: str
│ │ │ │ │ - content_encoding: Optional[str] = None
│ │ │ │ │ - content_security_policy: Optional[str] = None
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PackagesListener:
│ │ │ │ │ - def packages_loaded(self) -> None:
│ │ │ │ │ - """Called when the packages have been reloaded"""
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class BridgeConfig(dict, JsonObject):
│ │ │ │ │ - def __init__(self, value: JsonObject):
│ │ │ │ │ - super().__init__(value)
│ │ │ │ │ -
│ │ │ │ │ - self.label = get_str(self, 'label', None)
│ │ │ │ │ -
│ │ │ │ │ - self.privileged = get_bool(self, 'privileged', default=False)
│ │ │ │ │ - self.match = get_dict(self, 'match', {})
│ │ │ │ │ - if not self.privileged and not self.match:
│ │ │ │ │ - raise JsonError(value, 'must have match rules or be privileged')
│ │ │ │ │ -
│ │ │ │ │ - self.environ = get_strv(self, 'environ', ())
│ │ │ │ │ - self.spawn = get_strv(self, 'spawn')
│ │ │ │ │ - if not self.spawn:
│ │ │ │ │ - raise JsonError(value, 'spawn vector must be non-empty')
│ │ │ │ │ -
│ │ │ │ │ - self.name = self.label or self.spawn[0]
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Condition:
│ │ │ │ │ - def __init__(self, value: JsonObject):
│ │ │ │ │ - try:
│ │ │ │ │ - (self.name, self.value), = value.items()
│ │ │ │ │ - except ValueError as exc:
│ │ │ │ │ - raise JsonError(value, 'must contain exactly one key/value pair') from exc
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Manifest(dict, JsonObject):
│ │ │ │ │ - # Skip version check when running out of the git checkout (__version__ is None)
│ │ │ │ │ - COCKPIT_VERSION = __version__ and sortify_version(__version__)
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, path: Path, value: JsonObject):
│ │ │ │ │ - super().__init__(value)
│ │ │ │ │ - self.path = path
│ │ │ │ │ - self.name = get_str(self, 'name', self.path.name)
│ │ │ │ │ - self.bridges = get_objv(self, 'bridges', BridgeConfig)
│ │ │ │ │ - self.priority = get_int(self, 'priority', 1)
│ │ │ │ │ - self.csp = get_str(self, 'content-security-policy', '')
│ │ │ │ │ - self.conditions = get_objv(self, 'conditions', Condition)
│ │ │ │ │ -
│ │ │ │ │ - # Skip version check when running out of the git checkout (COCKPIT_VERSION is None)
│ │ │ │ │ - if self.COCKPIT_VERSION is not None:
│ │ │ │ │ - requires = get_dict(self, 'requires', {})
│ │ │ │ │ - for name, version in requires.items():
│ │ │ │ │ - if name != 'cockpit':
│ │ │ │ │ - raise JsonError(name, 'non-cockpit requirement listed')
│ │ │ │ │ - if sortify_version(typechecked(version, str)) > self.COCKPIT_VERSION:
│ │ │ │ │ - raise JsonError(version, f'required cockpit version ({version}) not met')
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Package:
│ │ │ │ │ - # For po{,.manifest}.js files, the interesting part is the locale name
│ │ │ │ │ - PO_JS_RE: ClassVar[Pattern] = re.compile(r'(po|po\.manifest)\.([^.]+)\.js(\.gz)?')
│ │ │ │ │ -
│ │ │ │ │ - # immutable after __init__
│ │ │ │ │ - manifest: Manifest
│ │ │ │ │ - name: str
│ │ │ │ │ - path: Path
│ │ │ │ │ - priority: int
│ │ │ │ │ -
│ │ │ │ │ - # computed later
│ │ │ │ │ - translations: Optional[Dict[str, Dict[str, str]]] = None
│ │ │ │ │ - files: Optional[Dict[str, str]] = None
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, manifest: Manifest):
│ │ │ │ │ - self.manifest = manifest
│ │ │ │ │ - self.name = manifest.name
│ │ │ │ │ - self.path = manifest.path
│ │ │ │ │ - self.priority = manifest.priority
│ │ │ │ │ -
│ │ │ │ │ - def ensure_scanned(self) -> None:
│ │ │ │ │ - """Ensure that the package has been scanned.
│ │ │ │ │ -
│ │ │ │ │ - This allows us to defer scanning the files of the package until we know
│ │ │ │ │ - that we'll actually use it.
│ │ │ │ │ - """
│ │ │ │ │ -
│ │ │ │ │ - if self.files is not None:
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - self.files = {}
│ │ │ │ │ - self.translations = {'po.js': {}, 'po.manifest.js': {}}
│ │ │ │ │ -
│ │ │ │ │ - for file in self.path.rglob('*'):
│ │ │ │ │ - name = str(file.relative_to(self.path))
│ │ │ │ │ - if name in ['.', '..', 'manifest.json']:
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - po_match = Package.PO_JS_RE.fullmatch(name)
│ │ │ │ │ - if po_match:
│ │ │ │ │ - basename = po_match.group(1)
│ │ │ │ │ - locale = po_match.group(2)
│ │ │ │ │ - # Accept-Language is case-insensitive and uses '-' to separate variants
│ │ │ │ │ - lower_locale = locale.lower().replace('_', '-')
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('Adding translation %r %r -> %r', basename, lower_locale, name)
│ │ │ │ │ - self.translations[f'{basename}.js'][lower_locale] = name
│ │ │ │ │ - else:
│ │ │ │ │ - # strip out trailing '.gz' components
│ │ │ │ │ - basename = re.sub('.gz$', '', name)
│ │ │ │ │ - logger.debug('Adding content %r -> %r', basename, name)
│ │ │ │ │ - self.files[basename] = name
│ │ │ │ │ -
│ │ │ │ │ - # If we see a filename like `x.min.js` we want to also offer it
│ │ │ │ │ - # at `x.js`, but only if `x.js(.gz)` itself is not present.
│ │ │ │ │ - # Note: this works for both the case where we found the `x.js`
│ │ │ │ │ - # first (it's already in the map) and also if we find it second
│ │ │ │ │ - # (it will be replaced in the map by the line just above).
│ │ │ │ │ - # See https://github.com/cockpit-project/cockpit/pull/19716
│ │ │ │ │ - self.files.setdefault(basename.replace('.min.', '.'), name)
│ │ │ │ │ -
│ │ │ │ │ - # support old cockpit-po-plugin which didn't write po.manifest.??.js
│ │ │ │ │ - if not self.translations['po.manifest.js']:
│ │ │ │ │ - self.translations['po.manifest.js'] = self.translations['po.js']
│ │ │ │ │ -
│ │ │ │ │ - def get_content_security_policy(self) -> str:
│ │ │ │ │ - policy = {
│ │ │ │ │ - "default-src": "'self'",
│ │ │ │ │ - "connect-src": "'self'",
│ │ │ │ │ - "form-action": "'self'",
│ │ │ │ │ - "base-uri": "'self'",
│ │ │ │ │ - "object-src": "'none'",
│ │ │ │ │ - "font-src": "'self' data:",
│ │ │ │ │ - "img-src": "'self' data:",
│ │ │ │ │ - }
│ │ │ │ │ -
│ │ │ │ │ - for item in self.manifest.csp.split(';'):
│ │ │ │ │ - item = item.strip()
│ │ │ │ │ - if item:
│ │ │ │ │ - key, _, value = item.strip().partition(' ')
│ │ │ │ │ - policy[key] = value
│ │ │ │ │ -
│ │ │ │ │ - return ' '.join(f'{k} {v};' for k, v in policy.items()) + ' block-all-mixed-content'
│ │ │ │ │ -
│ │ │ │ │ - def load_file(self, filename: str) -> Document:
│ │ │ │ │ - content_type, content_encoding = mimetypes.guess_type(filename)
│ │ │ │ │ - content_security_policy = None
│ │ │ │ │ -
│ │ │ │ │ - if content_type is None:
│ │ │ │ │ - content_type = 'text/plain'
│ │ │ │ │ - elif content_type.startswith('text/html'):
│ │ │ │ │ - content_security_policy = self.get_content_security_policy()
│ │ │ │ │ -
│ │ │ │ │ - path = self.path / filename
│ │ │ │ │ - logger.debug(' loading data from %s', path)
│ │ │ │ │ -
│ │ │ │ │ - return Document(path.open('rb'), content_type, content_encoding, content_security_policy)
│ │ │ │ │ -
│ │ │ │ │ - def load_translation(self, path: str, locales: Sequence[str]) -> Document:
│ │ │ │ │ - self.ensure_scanned()
│ │ │ │ │ - assert self.translations is not None
│ │ │ │ │ -
│ │ │ │ │ - # First match wins
│ │ │ │ │ - for locale in locales:
│ │ │ │ │ - with contextlib.suppress(KeyError):
│ │ │ │ │ - return self.load_file(self.translations[path][locale])
│ │ │ │ │ -
│ │ │ │ │ - # We prefer to return an empty document than 404 in order to avoid
│ │ │ │ │ - # errors in the console when a translation can't be found
│ │ │ │ │ - return Document(io.BytesIO(), 'text/javascript')
│ │ │ │ │ -
│ │ │ │ │ - def load_path(self, path: str, headers: JsonObject) -> Document:
│ │ │ │ │ - self.ensure_scanned()
│ │ │ │ │ - assert self.files is not None
│ │ │ │ │ - assert self.translations is not None
│ │ │ │ │ -
│ │ │ │ │ - if path in self.translations:
│ │ │ │ │ - locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))
│ │ │ │ │ - return self.load_translation(path, locales)
│ │ │ │ │ - else:
│ │ │ │ │ - return self.load_file(self.files[path])
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PackagesLoader:
│ │ │ │ │ - CONDITIONS: ClassVar[Dict[str, Callable[[str], bool]]] = {
│ │ │ │ │ - 'path-exists': os.path.exists,
│ │ │ │ │ - 'path-not-exists': lambda p: not os.path.exists(p),
│ │ │ │ │ - }
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def get_xdg_data_dirs(cls) -> Iterable[str]:
│ │ │ │ │ - try:
│ │ │ │ │ - yield os.environ['XDG_DATA_HOME']
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - yield os.path.expanduser('~/.local/share')
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - yield from os.environ['XDG_DATA_DIRS'].split(':')
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - yield from ('/usr/local/share', '/usr/share')
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def patch_manifest(cls, manifest: JsonObject, parent: Path) -> JsonObject:
│ │ │ │ │ - override_files = [
│ │ │ │ │ - parent / 'override.json',
│ │ │ │ │ - config.lookup_config(f'{parent.name}.override.json'),
│ │ │ │ │ - config.DOT_CONFIG_COCKPIT / f'{parent.name}.override.json',
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - for override_file in override_files:
│ │ │ │ │ - try:
│ │ │ │ │ - override: JsonValue = json.loads(override_file.read_bytes())
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - continue
│ │ │ │ │ - except json.JSONDecodeError as exc:
│ │ │ │ │ - # User input error: report a warning
│ │ │ │ │ - logger.warning('%s: %s', override_file, exc)
│ │ │ │ │ -
│ │ │ │ │ - if not isinstance(override, dict):
│ │ │ │ │ - logger.warning('%s: override file is not a dictionary', override_file)
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - manifest = json_merge_patch(manifest, override)
│ │ │ │ │ -
│ │ │ │ │ - return patch_libexecdir(manifest)
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def load_manifests(cls) -> Iterable[Manifest]:
│ │ │ │ │ - for datadir in cls.get_xdg_data_dirs():
│ │ │ │ │ - logger.debug("Scanning for manifest files under %s", datadir)
│ │ │ │ │ - for file in Path(datadir).glob('cockpit/*/manifest.json'):
│ │ │ │ │ - logger.debug("Considering file %s", file)
│ │ │ │ │ - try:
│ │ │ │ │ - manifest = json.loads(file.read_text())
│ │ │ │ │ - except json.JSONDecodeError as exc:
│ │ │ │ │ - logger.error("%s: %s", file, exc)
│ │ │ │ │ - continue
│ │ │ │ │ - if not isinstance(manifest, dict):
│ │ │ │ │ - logger.error("%s: json document isn't an object", file)
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - parent = file.parent
│ │ │ │ │ - manifest = cls.patch_manifest(manifest, parent)
│ │ │ │ │ - try:
│ │ │ │ │ - yield Manifest(parent, manifest)
│ │ │ │ │ - except JsonError as exc:
│ │ │ │ │ - logger.warning('%s %s', file, exc)
│ │ │ │ │ -
│ │ │ │ │ - def check_condition(self, condition: str, value: object) -> bool:
│ │ │ │ │ - check_fn = self.CONDITIONS[condition]
│ │ │ │ │ -
│ │ │ │ │ - # All known predicates currently only work on strings
│ │ │ │ │ - if not isinstance(value, str):
│ │ │ │ │ - return False
│ │ │ │ │ -
│ │ │ │ │ - return check_fn(value)
│ │ │ │ │ -
│ │ │ │ │ - def check_conditions(self, manifest: Manifest) -> bool:
│ │ │ │ │ - for condition in manifest.conditions:
│ │ │ │ │ - try:
│ │ │ │ │ - okay = self.check_condition(condition.name, condition.value)
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - # do *not* ignore manifests with unknown predicates, for forward compatibility
│ │ │ │ │ - logger.warning(' %s: ignoring unknown predicate in manifest: %s', manifest.path, condition.name)
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - if not okay:
│ │ │ │ │ - logger.debug(' hiding package %s as its %s condition is not met', manifest.path, condition)
│ │ │ │ │ - return False
│ │ │ │ │ -
│ │ │ │ │ - return True
│ │ │ │ │ -
│ │ │ │ │ - def load_packages(self) -> Iterable[Tuple[str, Package]]:
│ │ │ │ │ - logger.debug('Scanning for available package manifests:')
│ │ │ │ │ - # Sort all available packages into buckets by to their claimed name
│ │ │ │ │ - names: Dict[str, List[Manifest]] = collections.defaultdict(list)
│ │ │ │ │ - for manifest in self.load_manifests():
│ │ │ │ │ - logger.debug(' %s/manifest.json', manifest.path)
│ │ │ │ │ - names[manifest.name].append(manifest)
│ │ │ │ │ - logger.debug('done.')
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('Selecting packages to serve:')
│ │ │ │ │ - for name, candidates in names.items():
│ │ │ │ │ - # For each package name, iterate the candidates in descending
│ │ │ │ │ - # priority order and select the first one which passes all checks
│ │ │ │ │ - for candidate in sorted(candidates, key=lambda manifest: manifest.priority, reverse=True):
│ │ │ │ │ - try:
│ │ │ │ │ - if self.check_conditions(candidate):
│ │ │ │ │ - logger.debug(' creating package %s -> %s', name, candidate.path)
│ │ │ │ │ - yield name, Package(candidate)
│ │ │ │ │ - break
│ │ │ │ │ - except JsonError:
│ │ │ │ │ - logger.warning(' %s: ignoring package with invalid manifest file', candidate.path)
│ │ │ │ │ -
│ │ │ │ │ - logger.debug(' ignoring %s: unmet conditions', candidate.path)
│ │ │ │ │ - logger.debug('done.')
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Packages(bus.Object, interface='cockpit.Packages'):
│ │ │ │ │ - loader: PackagesLoader
│ │ │ │ │ - listener: Optional[PackagesListener]
│ │ │ │ │ - packages: Dict[str, Package]
│ │ │ │ │ - saw_first_reload_hint: bool
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, listener: Optional[PackagesListener] = None, loader: Optional[PackagesLoader] = None):
│ │ │ │ │ - self.listener = listener
│ │ │ │ │ - self.loader = loader or PackagesLoader()
│ │ │ │ │ - self.load()
│ │ │ │ │ -
│ │ │ │ │ - # Reloading the Shell in the browser should reload the
│ │ │ │ │ - # packages. This is implemented by having the Shell call
│ │ │ │ │ - # reload_hint whenever it starts. The first call of this
│ │ │ │ │ - # method in each session is ignored so that packages are not
│ │ │ │ │ - # loaded twice right after logging in.
│ │ │ │ │ - #
│ │ │ │ │ - self.saw_first_reload_hint = False
│ │ │ │ │ -
│ │ │ │ │ - def load(self) -> None:
│ │ │ │ │ - self.packages = dict(self.loader.load_packages())
│ │ │ │ │ - self.manifests = json.dumps({name: dict(package.manifest) for name, package in self.packages.items()})
│ │ │ │ │ - logger.debug('Packages loaded: %s', list(self.packages))
│ │ │ │ │ -
│ │ │ │ │ - def show(self):
│ │ │ │ │ - for name in sorted(self.packages):
│ │ │ │ │ - package = self.packages[name]
│ │ │ │ │ - menuitems = []
│ │ │ │ │ - for entry in itertools.chain(
│ │ │ │ │ - package.manifest.get('menu', {}).values(),
│ │ │ │ │ - package.manifest.get('tools', {}).values()):
│ │ │ │ │ - with contextlib.suppress(KeyError):
│ │ │ │ │ - menuitems.append(entry['label'])
│ │ │ │ │ - print(f'{name:20} {", ".join(menuitems):40} {package.path}')
│ │ │ │ │ -
│ │ │ │ │ - def get_bridge_configs(self) -> Sequence[BridgeConfig]:
│ │ │ │ │ - def yield_configs():
│ │ │ │ │ - for package in sorted(self.packages.values(), key=lambda package: -package.priority):
│ │ │ │ │ - yield from package.manifest.bridges
│ │ │ │ │ - return tuple(yield_configs())
│ │ │ │ │ -
│ │ │ │ │ - # D-Bus Interface
│ │ │ │ │ - manifests = bus.Interface.Property('s', value="{}")
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method()
│ │ │ │ │ - def reload(self):
│ │ │ │ │ - self.load()
│ │ │ │ │ - if self.listener is not None:
│ │ │ │ │ - self.listener.packages_loaded()
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method()
│ │ │ │ │ - def reload_hint(self):
│ │ │ │ │ - if self.saw_first_reload_hint:
│ │ │ │ │ - self.reload()
│ │ │ │ │ - self.saw_first_reload_hint = True
│ │ │ │ │ -
│ │ │ │ │ - def load_manifests_js(self, headers: JsonObject) -> Document:
│ │ │ │ │ - logger.debug('Serving /manifests.js')
│ │ │ │ │ -
│ │ │ │ │ - chunks: List[bytes] = []
│ │ │ │ │ -
│ │ │ │ │ - # Send the translations required for the manifest files, from each package
│ │ │ │ │ - locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))
│ │ │ │ │ - for name, package in self.packages.items():
│ │ │ │ │ - if name in ['static', 'base1']:
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - # find_translation will always find at least 'en'
│ │ │ │ │ - translation = package.load_translation('po.manifest.js', locales)
│ │ │ │ │ - with translation.data:
│ │ │ │ │ - if translation.content_encoding == 'gzip':
│ │ │ │ │ - data = gzip.decompress(translation.data.read())
│ │ │ │ │ - else:
│ │ │ │ │ - data = translation.data.read()
│ │ │ │ │ -
│ │ │ │ │ - chunks.append(data)
│ │ │ │ │ -
│ │ │ │ │ - chunks.append(b"""
│ │ │ │ │ - (function (root, data) {
│ │ │ │ │ - if (typeof define === 'function' && define.amd) {
│ │ │ │ │ - define(data);
│ │ │ │ │ - }
│ │ │ │ │ -
│ │ │ │ │ - if (typeof cockpit === 'object') {
│ │ │ │ │ - cockpit.manifests = data;
│ │ │ │ │ - } else {
│ │ │ │ │ - root.manifests = data;
│ │ │ │ │ - }
│ │ │ │ │ - }(this, """ + self.manifests.encode() + b"""))""")
│ │ │ │ │ -
│ │ │ │ │ - return Document(io.BytesIO(b'\n'.join(chunks)), 'text/javascript')
│ │ │ │ │ -
│ │ │ │ │ - def load_manifests_json(self) -> Document:
│ │ │ │ │ - logger.debug('Serving /manifests.json')
│ │ │ │ │ - return Document(io.BytesIO(self.manifests.encode()), 'application/json')
│ │ │ │ │ -
│ │ │ │ │ - PATH_RE = re.compile(
│ │ │ │ │ - r'/' # leading '/'
│ │ │ │ │ - r'(?:([^/]+)/)?' # optional leading path component
│ │ │ │ │ - r'((?:[^/]+/)*[^/]+)' # remaining path components
│ │ │ │ │ - )
│ │ │ │ │ -
│ │ │ │ │ - def load_path(self, path: str, headers: JsonObject) -> Document:
│ │ │ │ │ - logger.debug('packages: serving %s', path)
│ │ │ │ │ -
│ │ │ │ │ - match = self.PATH_RE.fullmatch(path)
│ │ │ │ │ - if match is None:
│ │ │ │ │ - raise ValueError(f'Invalid HTTP path {path}')
│ │ │ │ │ - packagename, filename = match.groups()
│ │ │ │ │ -
│ │ │ │ │ - if packagename is not None:
│ │ │ │ │ - return self.packages[packagename].load_path(filename, headers)
│ │ │ │ │ - elif filename == 'manifests.js':
│ │ │ │ │ - return self.load_manifests_js(headers)
│ │ │ │ │ - elif filename == 'manifests.json':
│ │ │ │ │ - return self.load_manifests_json()
│ │ │ │ │ - else:
│ │ │ │ │ - raise KeyError
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_version.py': br'''__version__ = '316'
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/peer.py': r'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/beipack.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ #
│ │ │ │ │ # 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 .
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ import logging
│ │ │ │ │ -import os
│ │ │ │ │ -from typing import Callable, List, Optional, Sequence
│ │ │ │ │ -
│ │ │ │ │ -from .jsonutil import JsonObject, JsonValue
│ │ │ │ │ -from .packages import BridgeConfig
│ │ │ │ │ -from .protocol import CockpitProblem, CockpitProtocol, CockpitProtocolError
│ │ │ │ │ -from .router import Endpoint, Router, RoutingRule
│ │ │ │ │ -from .transports import SubprocessProtocol, SubprocessTransport
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PeerError(CockpitProblem):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PeerExited(Exception):
│ │ │ │ │ - def __init__(self, exit_code: int):
│ │ │ │ │ - self.exit_code = exit_code
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Peer(CockpitProtocol, SubprocessProtocol, Endpoint):
│ │ │ │ │ - done_callbacks: List[Callable[[], None]]
│ │ │ │ │ - init_future: Optional[asyncio.Future]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router: Router):
│ │ │ │ │ - super().__init__(router)
│ │ │ │ │ -
│ │ │ │ │ - # All Peers start out frozen — we only unfreeze after we see the first 'init' message
│ │ │ │ │ - self.freeze_endpoint()
│ │ │ │ │ -
│ │ │ │ │ - self.init_future = asyncio.get_running_loop().create_future()
│ │ │ │ │ - self.done_callbacks = []
│ │ │ │ │ -
│ │ │ │ │ - # Initialization
│ │ │ │ │ - async def do_connect_transport(self) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ - async def spawn(self, argv: Sequence[str], env: Sequence[str], **kwargs) -> asyncio.Transport:
│ │ │ │ │ - # Not actually async...
│ │ │ │ │ - loop = asyncio.get_running_loop()
│ │ │ │ │ - user_env = dict(e.split('=', 1) for e in env)
│ │ │ │ │ - return SubprocessTransport(loop, self, argv, env=dict(os.environ, **user_env), **kwargs)
│ │ │ │ │ -
│ │ │ │ │ - async def start(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> JsonObject:
│ │ │ │ │ - """Request that the Peer is started and connected to the router.
│ │ │ │ │ -
│ │ │ │ │ - Creates the transport, connects it to the protocol, and participates in
│ │ │ │ │ - exchanging of init messages. If anything goes wrong, the connection
│ │ │ │ │ - will be closed and an exception will be raised.
│ │ │ │ │ -
│ │ │ │ │ - The Peer starts out in a frozen state (ie: attempts to send messages to
│ │ │ │ │ - it will initially be queued). If init_host is not None then an init
│ │ │ │ │ - message is sent with the given 'host' field, plus any extra kwargs, and
│ │ │ │ │ - the queue is thawed. Otherwise, the caller is responsible for sending
│ │ │ │ │ - the init message and thawing the peer.
│ │ │ │ │ -
│ │ │ │ │ - In any case, the return value is the init message from the peer.
│ │ │ │ │ - """
│ │ │ │ │ - assert self.init_future is not None
│ │ │ │ │ -
│ │ │ │ │ - def _connect_task_done(task: asyncio.Task) -> None:
│ │ │ │ │ - assert task is connect_task
│ │ │ │ │ - try:
│ │ │ │ │ - task.result()
│ │ │ │ │ - except asyncio.CancelledError: # we did that (below)
│ │ │ │ │ - pass # we want to ignore it
│ │ │ │ │ - except Exception as exc:
│ │ │ │ │ - self.close(exc)
│ │ │ │ │ -
│ │ │ │ │ - connect_task = asyncio.create_task(self.do_connect_transport())
│ │ │ │ │ - connect_task.add_done_callback(_connect_task_done)
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - # Wait for something to happen:
│ │ │ │ │ - # - exception from our connection function
│ │ │ │ │ - # - receiving "init" from the other side
│ │ │ │ │ - # - receiving EOF from the other side
│ │ │ │ │ - # - .close() was called
│ │ │ │ │ - # - other transport exception
│ │ │ │ │ - init_message = await self.init_future
│ │ │ │ │ -
│ │ │ │ │ - except (PeerExited, BrokenPipeError):
│ │ │ │ │ - # These are fairly generic errors. PeerExited means that we observed the process exiting.
│ │ │ │ │ - # BrokenPipeError means that we got EPIPE when attempting to write() to it. In both cases,
│ │ │ │ │ - # the process is gone, but it's not clear why. If the connection process is still running,
│ │ │ │ │ - # perhaps we'd get a better error message from it.
│ │ │ │ │ - await connect_task
│ │ │ │ │ - # Otherwise, re-raise
│ │ │ │ │ - raise
│ │ │ │ │ -
│ │ │ │ │ - finally:
│ │ │ │ │ - self.init_future = None
│ │ │ │ │ -
│ │ │ │ │ - # In any case (failure or success) make sure this is done.
│ │ │ │ │ - if not connect_task.done():
│ │ │ │ │ - connect_task.cancel()
│ │ │ │ │ -
│ │ │ │ │ - if init_host is not None:
│ │ │ │ │ - logger.debug(' sending init message back, host %s', init_host)
│ │ │ │ │ - # Send "init" back
│ │ │ │ │ - self.write_control(None, command='init', version=1, host=init_host, **kwargs)
│ │ │ │ │ -
│ │ │ │ │ - # Thaw the queued messages
│ │ │ │ │ - self.thaw_endpoint()
│ │ │ │ │ -
│ │ │ │ │ - return init_message
│ │ │ │ │ -
│ │ │ │ │ - # Background initialization
│ │ │ │ │ - def start_in_background(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ - def _start_task_done(task: asyncio.Task) -> None:
│ │ │ │ │ - assert task is start_task
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - task.result()
│ │ │ │ │ - except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError):
│ │ │ │ │ - pass # Those are expected. Others will throw.
│ │ │ │ │ -
│ │ │ │ │ - start_task = asyncio.create_task(self.start(init_host, **kwargs))
│ │ │ │ │ - start_task.add_done_callback(_start_task_done)
│ │ │ │ │ -
│ │ │ │ │ - # Shutdown
│ │ │ │ │ - def add_done_callback(self, callback: Callable[[], None]) -> None:
│ │ │ │ │ - self.done_callbacks.append(callback)
│ │ │ │ │ -
│ │ │ │ │ - # Handling of interesting events
│ │ │ │ │ - def do_superuser_init_done(self) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - def transport_control_received(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ - if command == 'init' and self.init_future is not None:
│ │ │ │ │ - logger.debug('Got init message with active init_future. Setting result.')
│ │ │ │ │ - self.init_future.set_result(message)
│ │ │ │ │ - elif command == 'authorize':
│ │ │ │ │ - self.do_authorize(message)
│ │ │ │ │ - elif command == 'superuser-init-done':
│ │ │ │ │ - self.do_superuser_init_done()
│ │ │ │ │ - else:
│ │ │ │ │ - raise CockpitProtocolError(f'Received unexpected control message {command}')
│ │ │ │ │ -
│ │ │ │ │ - def eof_received(self) -> bool:
│ │ │ │ │ - # We always expect to be the ones to close the connection, so if we get
│ │ │ │ │ - # an EOF, then we consider it to be an error. This allows us to
│ │ │ │ │ - # distinguish close caused by unexpected EOF (but no errno from a
│ │ │ │ │ - # syscall failure) vs. close caused by calling .close() on our side.
│ │ │ │ │ - # The process is still running at this point, so keep it and handle
│ │ │ │ │ - # the error in process_exited().
│ │ │ │ │ - logger.debug('Peer %s received unexpected EOF', self.__class__.__name__)
│ │ │ │ │ - return True
│ │ │ │ │ -
│ │ │ │ │ - def do_closed(self, exc: Optional[Exception]) -> None:
│ │ │ │ │ - logger.debug('Peer %s connection lost %s %s', self.__class__.__name__, type(exc), exc)
│ │ │ │ │ -
│ │ │ │ │ - if exc is None:
│ │ │ │ │ - self.shutdown_endpoint(problem='terminated')
│ │ │ │ │ - elif isinstance(exc, PeerExited):
│ │ │ │ │ - # a common case is that the called peer does not exist
│ │ │ │ │ - if exc.exit_code == 127:
│ │ │ │ │ - self.shutdown_endpoint(problem='no-cockpit')
│ │ │ │ │ - else:
│ │ │ │ │ - self.shutdown_endpoint(problem='terminated', message=f'Peer exited with status {exc.exit_code}')
│ │ │ │ │ - elif isinstance(exc, CockpitProblem):
│ │ │ │ │ - self.shutdown_endpoint(exc.attrs)
│ │ │ │ │ - else:
│ │ │ │ │ - self.shutdown_endpoint(problem='internal-error',
│ │ │ │ │ - message=f"[{exc.__class__.__name__}] {exc!s}")
│ │ │ │ │ -
│ │ │ │ │ - # If .start() is running, we need to make sure it stops running,
│ │ │ │ │ - # raising the correct exception.
│ │ │ │ │ - if self.init_future is not None and not self.init_future.done():
│ │ │ │ │ - if exc is not None:
│ │ │ │ │ - self.init_future.set_exception(exc)
│ │ │ │ │ - else:
│ │ │ │ │ - self.init_future.cancel()
│ │ │ │ │ -
│ │ │ │ │ - for callback in self.done_callbacks:
│ │ │ │ │ - callback()
│ │ │ │ │ -
│ │ │ │ │ - def process_exited(self) -> None:
│ │ │ │ │ - assert isinstance(self.transport, SubprocessTransport)
│ │ │ │ │ - logger.debug('Peer %s exited, status %d', self.__class__.__name__, self.transport.get_returncode())
│ │ │ │ │ - returncode = self.transport.get_returncode()
│ │ │ │ │ - assert isinstance(returncode, int)
│ │ │ │ │ - self.close(PeerExited(returncode))
│ │ │ │ │ -
│ │ │ │ │ - # Forwarding data: from the peer to the router
│ │ │ │ │ - def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ - if self.init_future is not None:
│ │ │ │ │ - raise CockpitProtocolError('Received unexpected channel control message before init')
│ │ │ │ │ - self.send_channel_control(channel, command, message)
│ │ │ │ │ -
│ │ │ │ │ - def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ - if self.init_future is not None:
│ │ │ │ │ - raise CockpitProtocolError('Received unexpected channel data before init')
│ │ │ │ │ - self.send_channel_data(channel, data)
│ │ │ │ │ -
│ │ │ │ │ - # Forwarding data: from the router to the peer
│ │ │ │ │ - def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ - assert self.init_future is None
│ │ │ │ │ - self.write_control(message)
│ │ │ │ │ -
│ │ │ │ │ - def do_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ - assert self.init_future is None
│ │ │ │ │ - self.write_channel_data(channel, data)
│ │ │ │ │ -
│ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ - assert self.init_future is None
│ │ │ │ │ - self.write_control(message)
│ │ │ │ │ -
│ │ │ │ │ - def do_close(self) -> None:
│ │ │ │ │ - self.close()
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class ConfiguredPeer(Peer):
│ │ │ │ │ - config: BridgeConfig
│ │ │ │ │ - args: Sequence[str]
│ │ │ │ │ - env: Sequence[str]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router: Router, config: BridgeConfig):
│ │ │ │ │ - self.config = config
│ │ │ │ │ - self.args = config.spawn
│ │ │ │ │ - self.env = config.environ
│ │ │ │ │ - super().__init__(router)
│ │ │ │ │ -
│ │ │ │ │ - async def do_connect_transport(self) -> None:
│ │ │ │ │ - await self.spawn(self.args, self.env)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PeerRoutingRule(RoutingRule):
│ │ │ │ │ - config: BridgeConfig
│ │ │ │ │ - match: JsonObject
│ │ │ │ │ - peer: Optional[Peer]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router: Router, config: BridgeConfig):
│ │ │ │ │ - super().__init__(router)
│ │ │ │ │ - self.config = config
│ │ │ │ │ - self.match = config.match
│ │ │ │ │ - self.peer = None
│ │ │ │ │ -
│ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Peer]:
│ │ │ │ │ - # Check that we match
│ │ │ │ │ -
│ │ │ │ │ - for key, value in self.match.items():
│ │ │ │ │ - if key not in options:
│ │ │ │ │ - logger.debug(' rejecting because key %s is missing', key)
│ │ │ │ │ - return None
│ │ │ │ │ - if value is not None and options[key] != value:
│ │ │ │ │ - logger.debug(' rejecting because key %s has wrong value %s (vs %s)', key, options[key], value)
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - # Start the peer if it's not running already
│ │ │ │ │ - if self.peer is None:
│ │ │ │ │ - self.peer = ConfiguredPeer(self.router, self.config)
│ │ │ │ │ - self.peer.add_done_callback(self.peer_closed)
│ │ │ │ │ - assert self.router.init_host
│ │ │ │ │ - self.peer.start_in_background(init_host=self.router.init_host)
│ │ │ │ │ -
│ │ │ │ │ - return self.peer
│ │ │ │ │ -
│ │ │ │ │ - def peer_closed(self):
│ │ │ │ │ - self.peer = None
│ │ │ │ │ -
│ │ │ │ │ - def shutdown(self):
│ │ │ │ │ - if self.peer is not None:
│ │ │ │ │ - self.peer.close()
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PeersRoutingRule(RoutingRule):
│ │ │ │ │ - rules: List[PeerRoutingRule] = []
│ │ │ │ │ -
│ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:
│ │ │ │ │ - logger.debug(' considering %d rules', len(self.rules))
│ │ │ │ │ - for rule in self.rules:
│ │ │ │ │ - logger.debug(' considering %s', rule.config.name)
│ │ │ │ │ - endpoint = rule.apply_rule(options)
│ │ │ │ │ - if endpoint is not None:
│ │ │ │ │ - logger.debug(' selected')
│ │ │ │ │ - return endpoint
│ │ │ │ │ - logger.debug(' no peer rules matched')
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - def set_configs(self, bridge_configs: Sequence[BridgeConfig]) -> None:
│ │ │ │ │ - old_rules = self.rules
│ │ │ │ │ - self.rules = []
│ │ │ │ │ -
│ │ │ │ │ - for config in bridge_configs:
│ │ │ │ │ - # Those are handled elsewhere...
│ │ │ │ │ - if config.privileged or 'host' in config.match:
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - # Try to reuse an existing rule, if one exists...
│ │ │ │ │ - for rule in list(old_rules):
│ │ │ │ │ - if rule.config == config:
│ │ │ │ │ - old_rules.remove(rule)
│ │ │ │ │ - break
│ │ │ │ │ - else:
│ │ │ │ │ - # ... otherwise, create a new one.
│ │ │ │ │ - rule = PeerRoutingRule(self.router, config)
│ │ │ │ │ -
│ │ │ │ │ - self.rules.append(rule)
│ │ │ │ │ -
│ │ │ │ │ - # close down the old rules that didn't get reclaimed
│ │ │ │ │ - for rule in old_rules:
│ │ │ │ │ - rule.shutdown()
│ │ │ │ │ -
│ │ │ │ │ - def shutdown(self):
│ │ │ │ │ - for rule in self.rules:
│ │ │ │ │ - rule.shutdown()
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/router.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ +import lzma
│ │ │ │ │ +from typing import List, Sequence, Tuple
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import collections
│ │ │ │ │ -import logging
│ │ │ │ │ -from typing import Dict, List, Optional
│ │ │ │ │ +from cockpit._vendor import ferny
│ │ │ │ │ +from cockpit._vendor.bei import beipack
│ │ │ │ │
│ │ │ │ │ -from .jsonutil import JsonObject, JsonValue
│ │ │ │ │ -from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer
│ │ │ │ │ +from .data import read_cockpit_data_file
│ │ │ │ │ +from .peer import Peer, PeerError
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class ExecutionQueue:
│ │ │ │ │ - """Temporarily delay calls to a given set of class methods.
│ │ │ │ │ -
│ │ │ │ │ - Functions by replacing the named function at the instance __dict__
│ │ │ │ │ - level, effectively providing an override for exactly one instance
│ │ │ │ │ - of `method`'s object.
│ │ │ │ │ - Queues the invocations. Run them later with .run(), which also reverses
│ │ │ │ │ - the redirection by deleting the named methods from the instance.
│ │ │ │ │ - """
│ │ │ │ │ - def __init__(self, methods):
│ │ │ │ │ - self.queue = collections.deque()
│ │ │ │ │ - self.methods = methods
│ │ │ │ │ -
│ │ │ │ │ - for method in self.methods:
│ │ │ │ │ - self._wrap(method)
│ │ │ │ │ -
│ │ │ │ │ - def _wrap(self, method):
│ │ │ │ │ - # NB: this function is stored in the instance dict and therefore
│ │ │ │ │ - # doesn't function as a descriptor, isn't a method, doesn't get bound,
│ │ │ │ │ - # and therefore doesn't receive a self parameter
│ │ │ │ │ - setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args)))
│ │ │ │ │ -
│ │ │ │ │ - def run(self):
│ │ │ │ │ - logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue))
│ │ │ │ │ - for method, args in self.queue:
│ │ │ │ │ - method(*args)
│ │ │ │ │ -
│ │ │ │ │ - for method in self.methods:
│ │ │ │ │ - delattr(method.__self__, method.__func__.__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Endpoint:
│ │ │ │ │ - router: 'Router'
│ │ │ │ │ - __endpoint_frozen_queue: Optional[ExecutionQueue] = None
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router: 'Router'):
│ │ │ │ │ - router.add_endpoint(self)
│ │ │ │ │ - self.router = router
│ │ │ │ │ -
│ │ │ │ │ - def freeze_endpoint(self):
│ │ │ │ │ - assert self.__endpoint_frozen_queue is None
│ │ │ │ │ - logger.debug('Freezing endpoint %s', self)
│ │ │ │ │ - self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill})
│ │ │ │ │ -
│ │ │ │ │ - def thaw_endpoint(self):
│ │ │ │ │ - assert self.__endpoint_frozen_queue is not None
│ │ │ │ │ - logger.debug('Thawing endpoint %s', self)
│ │ │ │ │ - self.__endpoint_frozen_queue.run()
│ │ │ │ │ - self.__endpoint_frozen_queue = None
│ │ │ │ │ -
│ │ │ │ │ - # interface for receiving messages
│ │ │ │ │ - def do_close(self):
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ - def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ - def do_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ - # interface for sending messages
│ │ │ │ │ - def send_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ - self.router.write_channel_data(channel, data)
│ │ │ │ │ -
│ │ │ │ │ - def send_channel_control(
│ │ │ │ │ - self, channel: str, command: str, _msg: 'JsonObject | None', **kwargs: JsonValue
│ │ │ │ │ - ) -> None:
│ │ │ │ │ - self.router.write_control(_msg, channel=channel, command=command, **kwargs)
│ │ │ │ │ - if command == 'close':
│ │ │ │ │ - self.router.endpoints[self].remove(channel)
│ │ │ │ │ - self.router.drop_channel(channel)
│ │ │ │ │ -
│ │ │ │ │ - def shutdown_endpoint(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ - self.router.shutdown_endpoint(self, _msg, **kwargs)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class RoutingError(CockpitProblem):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class RoutingRule:
│ │ │ │ │ - router: 'Router'
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router: 'Router'):
│ │ │ │ │ - self.router = router
│ │ │ │ │ -
│ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:
│ │ │ │ │ - """Check if a routing rule applies to a given 'open' message.
│ │ │ │ │ -
│ │ │ │ │ - This should inspect the options dictionary and do one of the following three things:
│ │ │ │ │ -
│ │ │ │ │ - - return an Endpoint to handle this channel
│ │ │ │ │ - - raise a RoutingError to indicate that the open should be rejected
│ │ │ │ │ - - return None to let the next rule run
│ │ │ │ │ - """
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ - def shutdown(self):
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Router(CockpitProtocolServer):
│ │ │ │ │ - routing_rules: List[RoutingRule]
│ │ │ │ │ - open_channels: Dict[str, Endpoint]
│ │ │ │ │ - endpoints: 'dict[Endpoint, set[str]]'
│ │ │ │ │ - no_endpoints: asyncio.Event # set if endpoints dict is empty
│ │ │ │ │ - _eof: bool = False
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, routing_rules: List[RoutingRule]):
│ │ │ │ │ - for rule in routing_rules:
│ │ │ │ │ - rule.router = self
│ │ │ │ │ - self.routing_rules = routing_rules
│ │ │ │ │ - self.open_channels = {}
│ │ │ │ │ - self.endpoints = {}
│ │ │ │ │ - self.no_endpoints = asyncio.Event()
│ │ │ │ │ - self.no_endpoints.set() # at first there are no endpoints
│ │ │ │ │ -
│ │ │ │ │ - def check_rules(self, options: JsonObject) -> Endpoint:
│ │ │ │ │ - for rule in self.routing_rules:
│ │ │ │ │ - logger.debug(' applying rule %s', rule)
│ │ │ │ │ - endpoint = rule.apply_rule(options)
│ │ │ │ │ - if endpoint is not None:
│ │ │ │ │ - logger.debug(' resulting endpoint is %s', endpoint)
│ │ │ │ │ - return endpoint
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug(' No rules matched')
│ │ │ │ │ - raise RoutingError('not-supported')
│ │ │ │ │ -
│ │ │ │ │ - def drop_channel(self, channel: str) -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - self.open_channels.pop(channel)
│ │ │ │ │ - logger.debug('router dropped channel %s', channel)
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels)
│ │ │ │ │ +def get_bridge_beipack_xz() -> Tuple[str, bytes]:
│ │ │ │ │ + try:
│ │ │ │ │ + bridge_beipack_xz = read_cockpit_data_file('cockpit-bridge.beipack.xz')
│ │ │ │ │ + logger.debug('Got pre-built cockpit-bridge.beipack.xz')
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + logger.debug('Pre-built cockpit-bridge.beipack.xz; building our own.')
│ │ │ │ │ + # beipack ourselves
│ │ │ │ │ + cockpit_contents = beipack.collect_module('cockpit', recursive=True)
│ │ │ │ │ + bridge_beipack = beipack.pack(cockpit_contents, entrypoint='cockpit.bridge:main', args='beipack=True')
│ │ │ │ │ + bridge_beipack_xz = lzma.compress(bridge_beipack.encode())
│ │ │ │ │ + logger.debug(' ... done!')
│ │ │ │ │
│ │ │ │ │ - def add_endpoint(self, endpoint: Endpoint) -> None:
│ │ │ │ │ - self.endpoints[endpoint] = set()
│ │ │ │ │ - self.no_endpoints.clear()
│ │ │ │ │ + return 'cockpit/data/cockpit-bridge.beipack.xz', bridge_beipack_xz
│ │ │ │ │
│ │ │ │ │ - def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ - channels = self.endpoints.pop(endpoint)
│ │ │ │ │ - logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels)
│ │ │ │ │ - for channel in channels:
│ │ │ │ │ - self.write_control(_msg, command='close', channel=channel, **kwargs)
│ │ │ │ │ - self.drop_channel(channel)
│ │ │ │ │
│ │ │ │ │ - if not self.endpoints:
│ │ │ │ │ - self.no_endpoints.set()
│ │ │ │ │ +class BridgeBeibootHelper(ferny.InteractionHandler):
│ │ │ │ │ + # ferny.InteractionHandler ClassVar
│ │ │ │ │ + commands = ['beiboot.provide', 'beiboot.exc']
│ │ │ │ │
│ │ │ │ │ - # were we waiting to exit?
│ │ │ │ │ - if self._eof:
│ │ │ │ │ - logger.debug(' endpoints remaining: %r', self.endpoints)
│ │ │ │ │ - if not self.endpoints and self.transport:
│ │ │ │ │ - logger.debug(' close transport')
│ │ │ │ │ - self.transport.close()
│ │ │ │ │ + peer: Peer
│ │ │ │ │ + payload: bytes
│ │ │ │ │ + steps: Sequence[Tuple[str, Sequence[object]]]
│ │ │ │ │
│ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ - endpoints = set(self.endpoints)
│ │ │ │ │ - logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints))
│ │ │ │ │ - for endpoint in endpoints:
│ │ │ │ │ - endpoint.do_kill(host, group, message)
│ │ │ │ │ + def __init__(self, peer: Peer, args: Sequence[str] = ()) -> None:
│ │ │ │ │ + filename, payload = get_bridge_beipack_xz()
│ │ │ │ │
│ │ │ │ │ - def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ - # If this is an open message then we need to apply the routing rules to
│ │ │ │ │ - # figure out the correct endpoint to connect. If it's not an open
│ │ │ │ │ - # message, then we expect the endpoint to already exist.
│ │ │ │ │ - if command == 'open':
│ │ │ │ │ - if channel in self.open_channels:
│ │ │ │ │ - raise CockpitProtocolError('channel is already open')
│ │ │ │ │ + self.peer = peer
│ │ │ │ │ + self.payload = payload
│ │ │ │ │ + self.steps = (('boot_xz', (filename, len(payload), tuple(args))),)
│ │ │ │ │
│ │ │ │ │ + async def run_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None:
│ │ │ │ │ + logger.debug('Got ferny request %s %s %s %s', command, args, fds, stderr)
│ │ │ │ │ + if command == 'beiboot.provide':
│ │ │ │ │ try:
│ │ │ │ │ - logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload'))
│ │ │ │ │ - endpoint = self.check_rules(message)
│ │ │ │ │ - except RoutingError as exc:
│ │ │ │ │ - self.write_control(exc.get_attrs(), command='close', channel=channel)
│ │ │ │ │ - return
│ │ │ │ │ + size, = args
│ │ │ │ │ + assert size == len(self.payload)
│ │ │ │ │ + except (AssertionError, ValueError) as exc:
│ │ │ │ │ + raise PeerError('internal-error', message=f'ferny interaction error {exc!s}') from exc
│ │ │ │ │
│ │ │ │ │ - self.open_channels[channel] = endpoint
│ │ │ │ │ - self.endpoints[endpoint].add(channel)
│ │ │ │ │ + assert self.peer.transport is not None
│ │ │ │ │ + logger.debug('Writing %d bytes of payload', len(self.payload))
│ │ │ │ │ + self.peer.transport.write(self.payload)
│ │ │ │ │ + elif command == 'beiboot.exc':
│ │ │ │ │ + raise PeerError('internal-error', message=f'Remote exception: {args[0]}')
│ │ │ │ │ else:
│ │ │ │ │ - try:
│ │ │ │ │ - endpoint = self.open_channels[channel]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - # sending to a non-existent channel can happen due to races and is not an error
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - # At this point, we have the endpoint. Route the message.
│ │ │ │ │ - endpoint.do_channel_control(channel, command, message)
│ │ │ │ │ -
│ │ │ │ │ - def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - endpoint = self.open_channels[channel]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - endpoint.do_channel_data(channel, data)
│ │ │ │ │ -
│ │ │ │ │ - def eof_received(self) -> bool:
│ │ │ │ │ - logger.debug('eof_received(%r)', self)
│ │ │ │ │ -
│ │ │ │ │ - endpoints = set(self.endpoints)
│ │ │ │ │ - for endpoint in endpoints:
│ │ │ │ │ - endpoint.do_close()
│ │ │ │ │ -
│ │ │ │ │ - self._eof = True
│ │ │ │ │ - logger.debug(' endpoints remaining: %r', self.endpoints)
│ │ │ │ │ - return bool(self.endpoints)
│ │ │ │ │ -
│ │ │ │ │ - _communication_done: Optional[asyncio.Future] = None
│ │ │ │ │ -
│ │ │ │ │ - def do_closed(self, exc: Optional[Exception]) -> None:
│ │ │ │ │ - # If we didn't send EOF yet, do it now.
│ │ │ │ │ - if not self._eof:
│ │ │ │ │ - self.eof_received()
│ │ │ │ │ -
│ │ │ │ │ - if self._communication_done is not None:
│ │ │ │ │ - if exc is None:
│ │ │ │ │ - self._communication_done.set_result(None)
│ │ │ │ │ - else:
│ │ │ │ │ - self._communication_done.set_exception(exc)
│ │ │ │ │ -
│ │ │ │ │ - async def communicate(self) -> None:
│ │ │ │ │ - """Wait until communication is complete on the router and all endpoints are done."""
│ │ │ │ │ - assert self._communication_done is None
│ │ │ │ │ - self._communication_done = asyncio.get_running_loop().create_future()
│ │ │ │ │ - try:
│ │ │ │ │ - await self._communication_done
│ │ │ │ │ - except (BrokenPipeError, ConnectionResetError):
│ │ │ │ │ - pass # these are normal occurrences when closed from the other side
│ │ │ │ │ - finally:
│ │ │ │ │ - self._communication_done = None
│ │ │ │ │ -
│ │ │ │ │ - # In an orderly exit, this is already done, but in case it wasn't
│ │ │ │ │ - # orderly, we need to make sure the endpoints shut down anyway...
│ │ │ │ │ - await self.no_endpoints.wait()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/config.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import configparser
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -from pathlib import Path
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import bus
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'))
│ │ │ │ │ -DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit'
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def lookup_config(filename: str) -> Path:
│ │ │ │ │ - config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc').split(':')
│ │ │ │ │ - fallback = None
│ │ │ │ │ - for config_dir in config_dirs:
│ │ │ │ │ - config_path = Path(config_dir, 'cockpit', filename)
│ │ │ │ │ - if not fallback:
│ │ │ │ │ - fallback = config_path
│ │ │ │ │ - if config_path.exists():
│ │ │ │ │ - logger.debug('lookup_config(%s): found %s', filename, config_path)
│ │ │ │ │ - return config_path
│ │ │ │ │ -
│ │ │ │ │ - # default to the first entry in XDG_CONFIG_DIRS; that's not according to the spec,
│ │ │ │ │ - # but what Cockpit has done for years
│ │ │ │ │ - logger.debug('lookup_config(%s): defaulting to %s', filename, fallback)
│ │ │ │ │ - assert fallback # mypy; config_dirs always has at least one string
│ │ │ │ │ - return fallback
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Config(bus.Object, interface='cockpit.Config'):
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - self.reload()
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method(out_types='s', in_types='ss')
│ │ │ │ │ - def get_string(self, section, key):
│ │ │ │ │ - try:
│ │ │ │ │ - return self.config[section][key]
│ │ │ │ │ - except KeyError as exc:
│ │ │ │ │ - raise bus.BusError('cockpit.Config.KeyError', f'key {key} in section {section} does not exist') from exc
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method(out_types='u', in_types='ssuuu')
│ │ │ │ │ - def get_u_int(self, section, key, default, maximum, minimum):
│ │ │ │ │ - try:
│ │ │ │ │ - value = self.config[section][key]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - return default
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - int_val = int(value)
│ │ │ │ │ - except ValueError:
│ │ │ │ │ - logger.warning('cockpit.conf: [%s] %s is not an integer', section, key)
│ │ │ │ │ - return default
│ │ │ │ │ -
│ │ │ │ │ - return min(max(int_val, minimum), maximum)
│ │ │ │ │ -
│ │ │ │ │ - @bus.Interface.Method()
│ │ │ │ │ - def reload(self):
│ │ │ │ │ - self.config = configparser.ConfigParser(interpolation=None)
│ │ │ │ │ - cockpit_conf = lookup_config('cockpit.conf')
│ │ │ │ │ - logger.debug("cockpit.Config: loading %s", cockpit_conf)
│ │ │ │ │ - # this may not exist, but it's ok to not have a config file and thus leave self.config empty
│ │ │ │ │ - self.config.read(cockpit_conf)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Environment(bus.Object, interface='cockpit.Environment'):
│ │ │ │ │ - variables = bus.Interface.Property('a{ss}')
│ │ │ │ │ -
│ │ │ │ │ - @variables.getter
│ │ │ │ │ - def get_variables(self):
│ │ │ │ │ - return os.environ.copy()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/__init__.py': br'''from ._version import __version__
│ │ │ │ │ -
│ │ │ │ │ -__all__ = (
│ │ │ │ │ - '__version__',
│ │ │ │ │ -)
│ │ │ │ │ + raise PeerError('internal-error', message=f'Unexpected ferny interaction command {command}')
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/jsonutil.py': r'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -from enum import Enum
│ │ │ │ │ -from typing import Callable, Container, Dict, List, Mapping, Optional, Sequence, Type, TypeVar, Union
│ │ │ │ │ -
│ │ │ │ │ -JsonLiteral = Union[str, float, bool, None]
│ │ │ │ │ -
│ │ │ │ │ -# immutable
│ │ │ │ │ -JsonValue = Union['JsonObject', Sequence['JsonValue'], JsonLiteral]
│ │ │ │ │ -JsonObject = Mapping[str, JsonValue]
│ │ │ │ │ -
│ │ │ │ │ -# mutable
│ │ │ │ │ -JsonDocument = Union['JsonDict', 'JsonList', JsonLiteral]
│ │ │ │ │ -JsonDict = Dict[str, JsonDocument]
│ │ │ │ │ -JsonList = List[JsonDocument]
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -DT = TypeVar('DT')
│ │ │ │ │ -T = TypeVar('T')
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class JsonError(Exception):
│ │ │ │ │ - value: object
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, value: object, msg: str):
│ │ │ │ │ - super().__init__(msg)
│ │ │ │ │ - self.value = value
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def typechecked(value: JsonValue, expected_type: Type[T]) -> T:
│ │ │ │ │ - """Ensure a JSON value has the expected type, returning it if so."""
│ │ │ │ │ - if not isinstance(value, expected_type):
│ │ │ │ │ - raise JsonError(value, f'must have type {expected_type.__name__}')
│ │ │ │ │ - return value
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# We can't use None as a sentinel because it's often the actual default value
│ │ │ │ │ -# EllipsisType is difficult because it's not available before 3.10.
│ │ │ │ │ -# See https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions
│ │ │ │ │ -class _Empty(Enum):
│ │ │ │ │ - TOKEN = 0
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -_empty = _Empty.TOKEN
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def _get(obj: JsonObject, cast: Callable[[JsonValue], T], key: str, default: Union[DT, _Empty]) -> Union[T, DT]:
│ │ │ │ │ - try:
│ │ │ │ │ - return cast(obj[key])
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - if default is not _empty:
│ │ │ │ │ - return default
│ │ │ │ │ - raise JsonError(obj, f"attribute '{key}' required") from None
│ │ │ │ │ - except JsonError as exc:
│ │ │ │ │ - target = f"attribute '{key}'" + (' elements:' if exc.value is not obj[key] else ':')
│ │ │ │ │ - raise JsonError(obj, f"{target} {exc!s}") from exc
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_bool(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, bool]:
│ │ │ │ │ - return _get(obj, lambda v: typechecked(v, bool), key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_int(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, int]:
│ │ │ │ │ - return _get(obj, lambda v: typechecked(v, int), key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_str(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, str]:
│ │ │ │ │ - return _get(obj, lambda v: typechecked(v, str), key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Optional[str]:
│ │ │ │ │ - return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]:
│ │ │ │ │ - return _get(obj, lambda v: typechecked(v, dict), key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_object(
│ │ │ │ │ - obj: JsonObject,
│ │ │ │ │ - key: str,
│ │ │ │ │ - constructor: Callable[[JsonObject], T],
│ │ │ │ │ - default: Union[DT, _Empty] = _empty
│ │ │ │ │ -) -> Union[DT, T]:
│ │ │ │ │ - return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_strv(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, Sequence[str]]:
│ │ │ │ │ - def as_strv(value: JsonValue) -> Sequence[str]:
│ │ │ │ │ - return tuple(typechecked(item, str) for item in typechecked(value, list))
│ │ │ │ │ - return _get(obj, as_strv, key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_enum(
│ │ │ │ │ - obj: JsonObject, key: str, choices: Container[str], default: Union[DT, _Empty] = _empty
│ │ │ │ │ -) -> Union[DT, str]:
│ │ │ │ │ - def as_choice(value: JsonValue) -> str:
│ │ │ │ │ - # mypy can't do `__eq__()`-based type narrowing...
│ │ │ │ │ - # https://github.com/python/mypy/issues/17101
│ │ │ │ │ - if isinstance(value, str) and value in choices:
│ │ │ │ │ - return value
│ │ │ │ │ - raise JsonError(value, f'invalid value "{value}" not in {choices}')
│ │ │ │ │ - return _get(obj, as_choice, key, default)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_objv(obj: JsonObject, key: str, constructor: Callable[[JsonObject], T]) -> Union[DT, Sequence[T]]:
│ │ │ │ │ - def as_objv(value: JsonValue) -> Sequence[T]:
│ │ │ │ │ - return tuple(constructor(typechecked(item, dict)) for item in typechecked(value, list))
│ │ │ │ │ - return _get(obj, as_objv, key, ())
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def create_object(message: 'JsonObject | None', kwargs: JsonObject) -> JsonObject:
│ │ │ │ │ - """Constructs a JSON object based on message and kwargs.
│ │ │ │ │ -
│ │ │ │ │ - If only message is given, it is returned, unmodified. If message is None,
│ │ │ │ │ - it is equivalent to an empty dictionary. A copy is always made.
│ │ │ │ │ -
│ │ │ │ │ - If kwargs are present, then any underscore ('_') present in a key name is
│ │ │ │ │ - rewritten to a dash ('-'). This is intended to bridge between the required
│ │ │ │ │ - Python syntax when providing kwargs and idiomatic JSON (which uses '-' for
│ │ │ │ │ - attributes). These values override values in message.
│ │ │ │ │ -
│ │ │ │ │ - The idea is that `message` should be used for passing data along, and
│ │ │ │ │ - kwargs used for data originating at a given call site, possibly including
│ │ │ │ │ - modifications to an original message.
│ │ │ │ │ - """
│ │ │ │ │ - result = dict(message or {})
│ │ │ │ │ -
│ │ │ │ │ - for key, value in kwargs.items():
│ │ │ │ │ - # rewrite '_' (necessary in Python syntax kwargs list) to '-' (idiomatic JSON)
│ │ │ │ │ - json_key = key.replace('_', '-')
│ │ │ │ │ - result[json_key] = value
│ │ │ │ │ -
│ │ │ │ │ - return result
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def json_merge_patch(current: JsonObject, patch: JsonObject) -> JsonObject:
│ │ │ │ │ - """Perform a JSON merge patch (RFC 7396) using 'current' and 'patch'.
│ │ │ │ │ - Neither of the original dictionaries is modified — the result is returned.
│ │ │ │ │ - """
│ │ │ │ │ - # Always take a copy ('result') — we never modify the input ('current')
│ │ │ │ │ - result = dict(current)
│ │ │ │ │ - for key, patch_value in patch.items():
│ │ │ │ │ - if isinstance(patch_value, Mapping):
│ │ │ │ │ - current_value = current.get(key, None)
│ │ │ │ │ - if not isinstance(current_value, Mapping):
│ │ │ │ │ - current_value = {}
│ │ │ │ │ - result[key] = json_merge_patch(current_value, patch_value)
│ │ │ │ │ - elif patch_value is not None:
│ │ │ │ │ - result[key] = patch_value
│ │ │ │ │ - else:
│ │ │ │ │ - result.pop(key, None)
│ │ │ │ │ -
│ │ │ │ │ - return result
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def json_merge_and_filter_patch(current: JsonDict, patch: JsonDict) -> None:
│ │ │ │ │ - """Perform a JSON merge patch (RFC 7396) modifying 'current' with 'patch'.
│ │ │ │ │ - Also modifies 'patch' to remove redundant operations.
│ │ │ │ │ - """
│ │ │ │ │ - for key, patch_value in tuple(patch.items()):
│ │ │ │ │ - current_value = current.get(key, None)
│ │ │ │ │ -
│ │ │ │ │ - if isinstance(patch_value, dict):
│ │ │ │ │ - if not isinstance(current_value, dict):
│ │ │ │ │ - current[key] = current_value = {}
│ │ │ │ │ - json_merge_and_filter_patch(current_value, patch_value)
│ │ │ │ │ - else:
│ │ │ │ │ - json_merge_and_filter_patch(current_value, patch_value)
│ │ │ │ │ - if not patch_value:
│ │ │ │ │ - del patch[key]
│ │ │ │ │ - elif current_value == patch_value:
│ │ │ │ │ - del patch[key]
│ │ │ │ │ - elif patch_value is not None:
│ │ │ │ │ - current[key] = patch_value
│ │ │ │ │ - else:
│ │ │ │ │ - del current[key]
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ 'cockpit/transports.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -4777,14 +4022,330 @@
│ │ │ │ │ self._loop.remove_reader(self._fd)
│ │ │ │ │ os.close(self._fd)
│ │ │ │ │ self._fd = -1
│ │ │ │ │
│ │ │ │ │ def __del__(self) -> None:
│ │ │ │ │ self.close()
│ │ │ │ │ ''',
│ │ │ │ │ + 'cockpit/bridge.py': r'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import argparse
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import json
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import pwd
│ │ │ │ │ +import shlex
│ │ │ │ │ +import socket
│ │ │ │ │ +import stat
│ │ │ │ │ +import subprocess
│ │ │ │ │ +from typing import Iterable, List, Optional, Sequence, Tuple, Type
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor.ferny import interaction_client
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import bus, run_async
│ │ │ │ │ +
│ │ │ │ │ +from . import polyfills
│ │ │ │ │ +from ._version import __version__
│ │ │ │ │ +from .channel import ChannelRoutingRule
│ │ │ │ │ +from .channels import CHANNEL_TYPES
│ │ │ │ │ +from .config import Config, Environment
│ │ │ │ │ +from .internal_endpoints import EXPORTS
│ │ │ │ │ +from .jsonutil import JsonError, JsonObject, get_dict
│ │ │ │ │ +from .packages import BridgeConfig, Packages, PackagesListener
│ │ │ │ │ +from .peer import PeersRoutingRule
│ │ │ │ │ +from .remote import HostRoutingRule
│ │ │ │ │ +from .router import Router
│ │ │ │ │ +from .superuser import SuperuserRoutingRule
│ │ │ │ │ +from .transports import StdioTransport
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class InternalBus:
│ │ │ │ │ + exportees: List[bus.Slot]
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]):
│ │ │ │ │ + client_socket, server_socket = socket.socketpair()
│ │ │ │ │ + self.client = bus.Bus.new(fd=client_socket.detach())
│ │ │ │ │ + self.server = bus.Bus.new(fd=server_socket.detach(), server=True)
│ │ │ │ │ + self.exportees = [self.server.add_object(path, cls()) for path, cls in exports]
│ │ │ │ │ +
│ │ │ │ │ + def export(self, path: str, obj: bus.BaseObject) -> None:
│ │ │ │ │ + self.exportees.append(self.server.add_object(path, obj))
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Bridge(Router, PackagesListener):
│ │ │ │ │ + internal_bus: InternalBus
│ │ │ │ │ + packages: Optional[Packages]
│ │ │ │ │ + bridge_configs: Sequence[BridgeConfig]
│ │ │ │ │ + args: argparse.Namespace
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, args: argparse.Namespace):
│ │ │ │ │ + self.internal_bus = InternalBus(EXPORTS)
│ │ │ │ │ + self.bridge_configs = []
│ │ │ │ │ + self.args = args
│ │ │ │ │ +
│ │ │ │ │ + self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged)
│ │ │ │ │ + self.internal_bus.export('/superuser', self.superuser_rule)
│ │ │ │ │ +
│ │ │ │ │ + self.internal_bus.export('/config', Config())
│ │ │ │ │ + self.internal_bus.export('/environment', Environment())
│ │ │ │ │ +
│ │ │ │ │ + self.peers_rule = PeersRoutingRule(self)
│ │ │ │ │ +
│ │ │ │ │ + if args.beipack:
│ │ │ │ │ + # Some special stuff for beipack
│ │ │ │ │ + self.superuser_rule.set_configs((
│ │ │ │ │ + BridgeConfig({
│ │ │ │ │ + "privileged": True,
│ │ │ │ │ + "spawn": ["sudo", "-k", "-A", "python3", "-ic", "# cockpit-bridge", "--privileged"],
│ │ │ │ │ + "environ": ["SUDO_ASKPASS=ferny-askpass"],
│ │ │ │ │ + }),
│ │ │ │ │ + ))
│ │ │ │ │ + self.packages = None
│ │ │ │ │ + elif args.privileged:
│ │ │ │ │ + self.packages = None
│ │ │ │ │ + else:
│ │ │ │ │ + self.packages = Packages(self)
│ │ │ │ │ + self.internal_bus.export('/packages', self.packages)
│ │ │ │ │ + self.packages_loaded()
│ │ │ │ │ +
│ │ │ │ │ + super().__init__([
│ │ │ │ │ + HostRoutingRule(self),
│ │ │ │ │ + self.superuser_rule,
│ │ │ │ │ + ChannelRoutingRule(self, CHANNEL_TYPES),
│ │ │ │ │ + self.peers_rule,
│ │ │ │ │ + ])
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_os_release():
│ │ │ │ │ + try:
│ │ │ │ │ + file = open('/etc/os-release', encoding='utf-8')
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + try:
│ │ │ │ │ + file = open('/usr/lib/os-release', encoding='utf-8')
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + logger.warning("Neither /etc/os-release nor /usr/lib/os-release exists")
│ │ │ │ │ + return {}
│ │ │ │ │ +
│ │ │ │ │ + os_release = {}
│ │ │ │ │ + for line in file.readlines():
│ │ │ │ │ + line = line.strip()
│ │ │ │ │ + if not line or line.startswith('#'):
│ │ │ │ │ + continue
│ │ │ │ │ + try:
│ │ │ │ │ + k, v = line.split('=')
│ │ │ │ │ + (v_parsed, ) = shlex.split(v) # expect exactly one token
│ │ │ │ │ + except ValueError:
│ │ │ │ │ + logger.warning('Ignoring invalid line in os-release: %r', line)
│ │ │ │ │ + continue
│ │ │ │ │ + os_release[k] = v_parsed
│ │ │ │ │ + return os_release
│ │ │ │ │ +
│ │ │ │ │ + def do_init(self, message: JsonObject) -> None:
│ │ │ │ │ + # we're only interested in the case where this is a dict, but
│ │ │ │ │ + # 'superuser' may well be `False` and that's not an error
│ │ │ │ │ + with contextlib.suppress(JsonError):
│ │ │ │ │ + superuser = get_dict(message, 'superuser')
│ │ │ │ │ + self.superuser_rule.init(superuser)
│ │ │ │ │ +
│ │ │ │ │ + def do_send_init(self) -> None:
│ │ │ │ │ + init_args = {
│ │ │ │ │ + 'capabilities': {'explicit-superuser': True},
│ │ │ │ │ + 'command': 'init',
│ │ │ │ │ + 'os-release': self.get_os_release(),
│ │ │ │ │ + 'version': 1,
│ │ │ │ │ + }
│ │ │ │ │ +
│ │ │ │ │ + if self.packages is not None:
│ │ │ │ │ + init_args['packages'] = dict.fromkeys(self.packages.packages)
│ │ │ │ │ +
│ │ │ │ │ + self.write_control(init_args)
│ │ │ │ │ +
│ │ │ │ │ + # PackagesListener interface
│ │ │ │ │ + def packages_loaded(self) -> None:
│ │ │ │ │ + assert self.packages
│ │ │ │ │ + bridge_configs = self.packages.get_bridge_configs()
│ │ │ │ │ + if self.bridge_configs != bridge_configs:
│ │ │ │ │ + self.superuser_rule.set_configs(bridge_configs)
│ │ │ │ │ + self.peers_rule.set_configs(bridge_configs)
│ │ │ │ │ + self.bridge_configs = bridge_configs
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +async def run(args) -> None:
│ │ │ │ │ + logger.debug("Hi. How are you today?")
│ │ │ │ │ +
│ │ │ │ │ + # Unit tests require this
│ │ │ │ │ + me = pwd.getpwuid(os.getuid())
│ │ │ │ │ + os.environ['HOME'] = me.pw_dir
│ │ │ │ │ + os.environ['SHELL'] = me.pw_shell
│ │ │ │ │ + os.environ['USER'] = me.pw_name
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Starting the router.')
│ │ │ │ │ + router = Bridge(args)
│ │ │ │ │ + StdioTransport(asyncio.get_running_loop(), router)
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Startup done. Looping until connection closes.')
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + await router.communicate()
│ │ │ │ │ + except (BrokenPipeError, ConnectionResetError):
│ │ │ │ │ + # not unexpected if the peer doesn't hang up cleanly
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def try_to_receive_stderr():
│ │ │ │ │ + try:
│ │ │ │ │ + ours, theirs = socket.socketpair()
│ │ │ │ │ + with ours:
│ │ │ │ │ + with theirs:
│ │ │ │ │ + interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()])
│ │ │ │ │ + _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1)
│ │ │ │ │ + except OSError:
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + stderr_fd, = fds
│ │ │ │ │ + # We're about to abruptly drop our end of the stderr socketpair that we
│ │ │ │ │ + # share with the ferny agent. ferny would normally treat that as an
│ │ │ │ │ + # unexpected error. Instruct it to do a clean exit, instead.
│ │ │ │ │ + interaction_client.command(2, 'ferny.end')
│ │ │ │ │ + os.dup2(stderr_fd, 2)
│ │ │ │ │ + finally:
│ │ │ │ │ + for fd in fds:
│ │ │ │ │ + os.close(fd)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def setup_journald() -> bool:
│ │ │ │ │ + # If stderr is a socket, prefer systemd-journal logging. This covers the
│ │ │ │ │ + # case we're already connected to the journal but also the case where we're
│ │ │ │ │ + # talking to the ferny agent, while leaving logging to file or terminal
│ │ │ │ │ + # unaffected.
│ │ │ │ │ + if not stat.S_ISSOCK(os.fstat(2).st_mode):
│ │ │ │ │ + # not a socket? Don't redirect.
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + import systemd.journal # type: ignore[import]
│ │ │ │ │ + except ImportError:
│ │ │ │ │ + # No python3-systemd? Don't redirect.
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + logging.root.addHandler(systemd.journal.JournalHandler())
│ │ │ │ │ + return True
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def setup_logging(*, debug: bool) -> None:
│ │ │ │ │ + """Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set"""
│ │ │ │ │ +
│ │ │ │ │ + modules = os.getenv('COCKPIT_DEBUG', '')
│ │ │ │ │ +
│ │ │ │ │ + # Either setup logging via journal or via formatted messages to stderr
│ │ │ │ │ + if not setup_journald():
│ │ │ │ │ + logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s')
│ │ │ │ │ +
│ │ │ │ │ + if debug or modules == 'all':
│ │ │ │ │ + logging.getLogger().setLevel(level=logging.DEBUG)
│ │ │ │ │ + elif modules:
│ │ │ │ │ + for module in modules.split(','):
│ │ │ │ │ + module = module.strip()
│ │ │ │ │ + if not module:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + logging.getLogger(module).setLevel(logging.DEBUG)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def start_ssh_agent() -> None:
│ │ │ │ │ + # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust,
│ │ │ │ │ + # but it gets cleared on setgid ssh-agent, which some distros still do
│ │ │ │ │ + try:
│ │ │ │ │ + proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'],
│ │ │ │ │ + stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
│ │ │ │ │ + assert proc.stdout is not None
│ │ │ │ │ +
│ │ │ │ │ + # Wait for the agent to write at least one line and look for the
│ │ │ │ │ + # listener socket. If we fail to find it, kill the agent — something
│ │ │ │ │ + # went wrong.
│ │ │ │ │ + for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True):
│ │ │ │ │ + if token.startswith('SSH_AUTH_SOCK='):
│ │ │ │ │ + os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1)
│ │ │ │ │ + break
│ │ │ │ │ + else:
│ │ │ │ │ + proc.terminate()
│ │ │ │ │ + proc.wait()
│ │ │ │ │ +
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + logger.debug("Couldn't start ssh-agent (FileNotFoundError)")
│ │ │ │ │ +
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + logger.warning("Could not start ssh-agent: %s", exc)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def main(*, beipack: bool = False) -> None:
│ │ │ │ │ + polyfills.install()
│ │ │ │ │ +
│ │ │ │ │ + parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')
│ │ │ │ │ + parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge')
│ │ │ │ │ + parser.add_argument('--packages', action='store_true', help='Show Cockpit package information')
│ │ │ │ │ + parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information')
│ │ │ │ │ + parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)')
│ │ │ │ │ + parser.add_argument('--version', action='store_true', help='Show Cockpit version information')
│ │ │ │ │ + args = parser.parse_args()
│ │ │ │ │ +
│ │ │ │ │ + # This is determined by who calls us
│ │ │ │ │ + args.beipack = beipack
│ │ │ │ │ +
│ │ │ │ │ + # If we were run with --privileged then our stderr is currently being
│ │ │ │ │ + # consumed by the main bridge looking for startup-related error messages.
│ │ │ │ │ + # Let's switch back to the original stderr stream, which has a side-effect
│ │ │ │ │ + # of indicating that our startup is more or less complete. Any errors
│ │ │ │ │ + # after this point will land in the journal.
│ │ │ │ │ + if args.privileged:
│ │ │ │ │ + try_to_receive_stderr()
│ │ │ │ │ +
│ │ │ │ │ + setup_logging(debug=args.debug)
│ │ │ │ │ +
│ │ │ │ │ + # Special modes
│ │ │ │ │ + if args.packages:
│ │ │ │ │ + Packages().show()
│ │ │ │ │ + return
│ │ │ │ │ + elif args.version:
│ │ │ │ │ + print(f'Version: {__version__}\nProtocol: 1')
│ │ │ │ │ + return
│ │ │ │ │ + elif args.bridges:
│ │ │ │ │ + print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2))
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + # The privileged bridge doesn't need ssh-agent, but the main one does
│ │ │ │ │ + if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged:
│ │ │ │ │ + start_ssh_agent()
│ │ │ │ │ +
│ │ │ │ │ + # asyncio.run() shim for Python 3.6 support
│ │ │ │ │ + run_async(run(args), debug=args.debug)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ 'cockpit/polkit.py': r'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -4949,15 +4510,15 @@
│ │ │ │ │ 'org.freedesktop.PolicyKit1.Authority',
│ │ │ │ │ 'UnregisterAuthenticationAgent',
│ │ │ │ │ '(sa{sv})s',
│ │ │ │ │ self.subject, AGENT_DBUS_PATH)
│ │ │ │ │ self.agent_slot.cancel()
│ │ │ │ │ logger.debug('Unregistered agent for %r', self.subject)
│ │ │ │ │ '''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/samples.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -4966,698 +4527,500 @@
│ │ │ │ │ # 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 .
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import json
│ │ │ │ │ +import errno
│ │ │ │ │ import logging
│ │ │ │ │ -import sys
│ │ │ │ │ -import time
│ │ │ │ │ -from collections import defaultdict
│ │ │ │ │ -from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union
│ │ │ │ │ +import os
│ │ │ │ │ +import re
│ │ │ │ │ +from typing import Any, DefaultDict, Iterable, List, NamedTuple, Optional, Tuple
│ │ │ │ │
│ │ │ │ │ -from ..channel import AsyncChannel, ChannelError
│ │ │ │ │ -from ..jsonutil import JsonList
│ │ │ │ │ -from ..samples import SAMPLERS, SampleDescription, Sampler, Samples
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Handle
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ +USER_HZ = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
│ │ │ │ │ +MS_PER_JIFFY = 1000 / (USER_HZ if (USER_HZ > 0) else 100)
│ │ │ │ │ +HWMON_PATH = '/sys/class/hwmon'
│ │ │ │ │
│ │ │ │ │ +# we would like to do this, but mypy complains; https://github.com/python/mypy/issues/2900
│ │ │ │ │ +# Samples = collections.defaultdict[str, Union[float, Dict[str, Union[float, None]]]]
│ │ │ │ │ +Samples = DefaultDict[str, Any]
│ │ │ │ │
│ │ │ │ │ -class MetricInfo(NamedTuple):
│ │ │ │ │ - derive: Optional[str]
│ │ │ │ │ - desc: SampleDescription
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class InternalMetricsChannel(AsyncChannel):
│ │ │ │ │ - payload = 'metrics1'
│ │ │ │ │ - restrictions = [('source', 'internal')]
│ │ │ │ │ +def read_int_file(rootfd: int, statfile: str, default: Optional[int] = None, key: bytes = b'') -> Optional[int]:
│ │ │ │ │ + # Not every stat is available, such as cpu.weight
│ │ │ │ │ + try:
│ │ │ │ │ + fd = os.open(statfile, os.O_RDONLY, dir_fd=rootfd)
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - metrics: List[MetricInfo]
│ │ │ │ │ - samplers: Set
│ │ │ │ │ - samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None
│ │ │ │ │ + try:
│ │ │ │ │ + data = os.read(fd, 1024)
│ │ │ │ │ + except OSError as e:
│ │ │ │ │ + # cgroups can disappear between the open and read
│ │ │ │ │ + if e.errno != errno.ENODEV:
│ │ │ │ │ + logger.warning('Failed to read %s: %s', statfile, e)
│ │ │ │ │ + return None
│ │ │ │ │ + finally:
│ │ │ │ │ + os.close(fd)
│ │ │ │ │
│ │ │ │ │ - interval: int = 1000
│ │ │ │ │ - need_meta: bool = True
│ │ │ │ │ - last_timestamp: float = 0
│ │ │ │ │ - next_timestamp: float = 0
│ │ │ │ │ + if key:
│ │ │ │ │ + start = data.index(key) + len(key)
│ │ │ │ │ + end = data.index(b'\n', start)
│ │ │ │ │ + data = data[start:end]
│ │ │ │ │
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def ensure_samplers(cls):
│ │ │ │ │ - if cls.samplers_cache is None:
│ │ │ │ │ - cls.samplers_cache = {desc.name: (sampler, desc) for sampler in SAMPLERS for desc in sampler.descriptions}
│ │ │ │ │ + try:
│ │ │ │ │ + # 0 often means "none", so replace it with default value
│ │ │ │ │ + return int(data) or default
│ │ │ │ │ + except ValueError:
│ │ │ │ │ + # Some samples such as "memory.max" contains "max" when there is a no limit
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - def parse_options(self, options):
│ │ │ │ │ - logger.debug('metrics internal open: %s, channel: %s', options, self.channel)
│ │ │ │ │
│ │ │ │ │ - interval = options.get('interval', self.interval)
│ │ │ │ │ - if not isinstance(interval, int) or interval <= 0 or interval > sys.maxsize:
│ │ │ │ │ - raise ChannelError('protocol-error', message=f'invalid "interval" value: {interval}')
│ │ │ │ │ +class SampleDescription(NamedTuple):
│ │ │ │ │ + name: str
│ │ │ │ │ + units: str
│ │ │ │ │ + semantics: str
│ │ │ │ │ + instanced: bool
│ │ │ │ │
│ │ │ │ │ - self.interval = interval
│ │ │ │ │
│ │ │ │ │ - metrics = options.get('metrics')
│ │ │ │ │ - if not isinstance(metrics, list) or len(metrics) == 0:
│ │ │ │ │ - logger.error('invalid "metrics" value: %s', metrics)
│ │ │ │ │ - raise ChannelError('protocol-error', message='invalid "metrics" option was specified (not an array)')
│ │ │ │ │ +class Sampler:
│ │ │ │ │ + descriptions: List[SampleDescription]
│ │ │ │ │
│ │ │ │ │ - sampler_classes = set()
│ │ │ │ │ - for metric in metrics:
│ │ │ │ │ - # validate it's an object
│ │ │ │ │ - name = metric.get('name')
│ │ │ │ │ - units = metric.get('units')
│ │ │ │ │ - derive = metric.get('derive')
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - sampler, desc = self.samplers_cache[name]
│ │ │ │ │ - except KeyError as exc:
│ │ │ │ │ - logger.error('unsupported metric: %s', name)
│ │ │ │ │ - raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc
│ │ │ │ │
│ │ │ │ │ - if units and units != desc.units:
│ │ │ │ │ - raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}')
│ │ │ │ │ +class CPUSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('cpu.basic.nice', 'millisec', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('cpu.basic.user', 'millisec', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('cpu.basic.system', 'millisec', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('cpu.basic.iowait', 'millisec', 'counter', instanced=False),
│ │ │ │ │
│ │ │ │ │ - sampler_classes.add(sampler)
│ │ │ │ │ - self.metrics.append(MetricInfo(derive=derive, desc=desc))
│ │ │ │ │ + SampleDescription('cpu.core.nice', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cpu.core.user', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cpu.core.system', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cpu.core.iowait', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - self.samplers = {cls() for cls in sampler_classes}
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/stat') as stat:
│ │ │ │ │ + for line in stat:
│ │ │ │ │ + if not line.startswith('cpu'):
│ │ │ │ │ + continue
│ │ │ │ │ + cpu, user, nice, system, _idle, iowait = line.split()[:6]
│ │ │ │ │ + core = cpu[3:] or None
│ │ │ │ │ + if core:
│ │ │ │ │ + prefix = 'cpu.core'
│ │ │ │ │ + samples[f'{prefix}.nice'][core] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.user'][core] = int(user) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.system'][core] = int(system) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.iowait'][core] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │ + else:
│ │ │ │ │ + prefix = 'cpu.basic'
│ │ │ │ │ + samples[f'{prefix}.nice'] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.user'] = int(user) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.system'] = int(system) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.iowait'] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │
│ │ │ │ │ - def send_meta(self, samples: Samples, timestamp: float):
│ │ │ │ │ - metrics: JsonList = []
│ │ │ │ │ - for metricinfo in self.metrics:
│ │ │ │ │ - if metricinfo.desc.instanced:
│ │ │ │ │ - metrics.append({
│ │ │ │ │ - 'name': metricinfo.desc.name,
│ │ │ │ │ - 'units': metricinfo.desc.units,
│ │ │ │ │ - 'instances': list(samples[metricinfo.desc.name].keys()),
│ │ │ │ │ - 'semantics': metricinfo.desc.semantics
│ │ │ │ │ - })
│ │ │ │ │ - else:
│ │ │ │ │ - metrics.append({
│ │ │ │ │ - 'name': metricinfo.desc.name,
│ │ │ │ │ - 'derive': metricinfo.derive, # type: ignore[dict-item]
│ │ │ │ │ - 'units': metricinfo.desc.units,
│ │ │ │ │ - 'semantics': metricinfo.desc.semantics
│ │ │ │ │ - })
│ │ │ │ │
│ │ │ │ │ - self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics)
│ │ │ │ │ - self.need_meta = False
│ │ │ │ │ +class MemorySampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('memory.free', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + SampleDescription('memory.used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + SampleDescription('memory.cached', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + SampleDescription('memory.swap-used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - def sample(self):
│ │ │ │ │ - samples = defaultdict(dict)
│ │ │ │ │ - for sampler in self.samplers:
│ │ │ │ │ - sampler.sample(samples)
│ │ │ │ │ - return samples
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/meminfo') as meminfo:
│ │ │ │ │ + items = {k: int(v.strip(' kB\n')) for line in meminfo for k, v in [line.split(':', 1)]}
│ │ │ │ │
│ │ │ │ │ - def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Union[float, bool]:
│ │ │ │ │ - if old_value is not None and self.last_timestamp:
│ │ │ │ │ - return (value - old_value) / (self.next_timestamp - self.last_timestamp)
│ │ │ │ │ - else:
│ │ │ │ │ - return False
│ │ │ │ │ + samples['memory.free'] = 1024 * items['MemFree']
│ │ │ │ │ + samples['memory.used'] = 1024 * (items['MemTotal'] - items['MemAvailable'])
│ │ │ │ │ + samples['memory.cached'] = 1024 * (items['Buffers'] + items['Cached'])
│ │ │ │ │ + samples['memory.swap-used'] = 1024 * (items['SwapTotal'] - items['SwapFree'])
│ │ │ │ │
│ │ │ │ │ - def send_updates(self, samples: Samples, last_samples: Samples):
│ │ │ │ │ - data: List[Union[float, List[Optional[Union[float, bool]]]]] = []
│ │ │ │ │ - timestamp = time.time()
│ │ │ │ │ - self.next_timestamp = timestamp
│ │ │ │ │
│ │ │ │ │ - for metricinfo in self.metrics:
│ │ │ │ │ - value = samples[metricinfo.desc.name]
│ │ │ │ │ +class CPUTemperatureSampler(Sampler):
│ │ │ │ │ + # Cache found sensors, as they can't be hotplugged.
│ │ │ │ │ + sensors: Optional[List[str]] = None
│ │ │ │ │
│ │ │ │ │ - if metricinfo.desc.instanced:
│ │ │ │ │ - old_value = last_samples[metricinfo.desc.name]
│ │ │ │ │ - assert isinstance(value, dict)
│ │ │ │ │ - assert isinstance(old_value, dict)
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('cpu.temperature', 'celsius', 'instant', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - # If we have less or more keys the data changed, send a meta message.
│ │ │ │ │ - if value.keys() != old_value.keys():
│ │ │ │ │ - self.need_meta = True
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def detect_cpu_sensors(dir_fd: int) -> Iterable[str]:
│ │ │ │ │ + # Read the name file to decide what to do with this directory
│ │ │ │ │ + try:
│ │ │ │ │ + with Handle.open('name', os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ + name = os.read(fd, 1024).decode().strip()
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - if metricinfo.derive == 'rate':
│ │ │ │ │ - instances: List[Optional[Union[float, bool]]] = []
│ │ │ │ │ - for key, val in value.items():
│ │ │ │ │ - instances.append(self.calculate_sample_rate(val, old_value.get(key)))
│ │ │ │ │ + if name == 'atk0110':
│ │ │ │ │ + # only sample 'CPU Temperature' in atk0110
│ │ │ │ │ + predicate = (lambda label: label == 'CPU Temperature')
│ │ │ │ │ + elif name == 'cpu_thermal':
│ │ │ │ │ + # labels are not used on ARM
│ │ │ │ │ + predicate = None
│ │ │ │ │ + elif name == 'coretemp':
│ │ │ │ │ + # accept all labels on Intel
│ │ │ │ │ + predicate = None
│ │ │ │ │ + elif name in ['k8temp', 'k10temp']:
│ │ │ │ │ + predicate = None
│ │ │ │ │ + else:
│ │ │ │ │ + # Not a CPU sensor
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - data.append(instances)
│ │ │ │ │ - else:
│ │ │ │ │ - data.append(list(value.values()))
│ │ │ │ │ - else:
│ │ │ │ │ - old_value = last_samples.get(metricinfo.desc.name)
│ │ │ │ │ - assert not isinstance(value, dict)
│ │ │ │ │ - assert not isinstance(old_value, dict)
│ │ │ │ │ + # Now scan the directory for inputs
│ │ │ │ │ + for input_filename in os.listdir(dir_fd):
│ │ │ │ │ + if not input_filename.endswith('_input'):
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - if metricinfo.derive == 'rate':
│ │ │ │ │ - data.append(self.calculate_sample_rate(value, old_value))
│ │ │ │ │ - else:
│ │ │ │ │ - data.append(value)
│ │ │ │ │ + if predicate:
│ │ │ │ │ + # We need to check the label
│ │ │ │ │ + try:
│ │ │ │ │ + label_filename = input_filename.replace('_input', '_label')
│ │ │ │ │ + with Handle.open(label_filename, os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ + label = os.read(fd, 1024).decode().strip()
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - if self.need_meta:
│ │ │ │ │ - self.send_meta(samples, timestamp)
│ │ │ │ │ + if not predicate(label):
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - self.last_timestamp = self.next_timestamp
│ │ │ │ │ - self.send_data(json.dumps([data]).encode())
│ │ │ │ │ + yield input_filename
│ │ │ │ │
│ │ │ │ │ - async def run(self, options):
│ │ │ │ │ - self.metrics = []
│ │ │ │ │ - self.samplers = set()
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def scan_sensors() -> Iterable[str]:
│ │ │ │ │ + try:
│ │ │ │ │ + top_fd = Handle.open(HWMON_PATH, os.O_RDONLY | os.O_DIRECTORY)
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - InternalMetricsChannel.ensure_samplers()
│ │ │ │ │ + with top_fd:
│ │ │ │ │ + for hwmon_name in os.listdir(top_fd):
│ │ │ │ │ + with Handle.open(hwmon_name, os.O_RDONLY | os.O_DIRECTORY, dir_fd=top_fd) as subdir_fd:
│ │ │ │ │ + for sensor in CPUTemperatureSampler.detect_cpu_sensors(subdir_fd):
│ │ │ │ │ + yield f'{HWMON_PATH}/{hwmon_name}/{sensor}'
│ │ │ │ │
│ │ │ │ │ - self.parse_options(options)
│ │ │ │ │ - self.ready()
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + if self.sensors is None:
│ │ │ │ │ + self.sensors = list(CPUTemperatureSampler.scan_sensors())
│ │ │ │ │
│ │ │ │ │ - last_samples = defaultdict(dict)
│ │ │ │ │ - while True:
│ │ │ │ │ - samples = self.sample()
│ │ │ │ │ - self.send_updates(samples, last_samples)
│ │ │ │ │ - last_samples = samples
│ │ │ │ │ - await asyncio.sleep(self.interval / 1000)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ + for sensor_path in self.sensors:
│ │ │ │ │ + with open(sensor_path) as sensor:
│ │ │ │ │ + temperature = int(sensor.read().strip())
│ │ │ │ │ + if temperature == 0:
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ -# Missing stuff compared to the C bridge that we should probably add:
│ │ │ │ │ -#
│ │ │ │ │ -# - removing matches
│ │ │ │ │ -# - removing watches
│ │ │ │ │ -# - emitting of signals
│ │ │ │ │ -# - publishing of objects
│ │ │ │ │ -# - failing more gracefully in some cases (during open, etc)
│ │ │ │ │ -#
│ │ │ │ │ -# Stuff we might or might not do:
│ │ │ │ │ -#
│ │ │ │ │ -# - using non-default service names
│ │ │ │ │ -#
│ │ │ │ │ -# Stuff we should probably not do:
│ │ │ │ │ -#
│ │ │ │ │ -# - emulation of ObjectManager via recursive introspection
│ │ │ │ │ -# - automatic detection of ObjectManager below the given path_namespace
│ │ │ │ │ -# - recursive scraping of properties for new object paths
│ │ │ │ │ -# (for path_namespace watches that don't hit an ObjectManager)
│ │ │ │ │ + samples['cpu.temperature'][sensor_path] = temperature / 1000
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import errno
│ │ │ │ │ -import json
│ │ │ │ │ -import logging
│ │ │ │ │ -import traceback
│ │ │ │ │ -import xml.etree.ElementTree as ET
│ │ │ │ │
│ │ │ │ │ -from cockpit._vendor import systemd_ctypes
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection
│ │ │ │ │ +class DiskSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('disk.all.read', 'bytes', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('disk.all.written', 'bytes', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('disk.dev.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('disk.dev.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ -from ..channel import Channel, ChannelError
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/diskstats') as diskstats:
│ │ │ │ │ + all_read_bytes = 0
│ │ │ │ │ + all_written_bytes = 0
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ + for line in diskstats:
│ │ │ │ │ + # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ + fields = line.strip().split()
│ │ │ │ │ + dev_major = fields[0]
│ │ │ │ │ + dev_name = fields[2]
│ │ │ │ │ + num_sectors_read = fields[5]
│ │ │ │ │ + num_sectors_written = fields[9]
│ │ │ │ │
│ │ │ │ │ -# The dbusjson3 payload
│ │ │ │ │ -#
│ │ │ │ │ -# This channel payload type translates JSON encoded messages on a
│ │ │ │ │ -# Cockpit channel to D-Bus messages, in a mostly straightforward way.
│ │ │ │ │ -# See doc/protocol.md for a description of the basics.
│ │ │ │ │ -#
│ │ │ │ │ -# However, dbusjson3 offers some advanced features as well that are
│ │ │ │ │ -# meant to support the "magic" DBusProxy objects implemented by
│ │ │ │ │ -# cockpit.js. Those proxy objects "magically" expose all the methods
│ │ │ │ │ -# and properties of a D-Bus interface without requiring any explicit
│ │ │ │ │ -# binding code to be generated for a JavaScript client. A dbusjson3
│ │ │ │ │ -# channel does this by doing automatic introspection and property
│ │ │ │ │ -# retrieval without much direction from the JavaScript client.
│ │ │ │ │ -#
│ │ │ │ │ -# The details of what exactly is done is not specified very strictly,
│ │ │ │ │ -# and the Python bridge will likely differ from the C bridge
│ │ │ │ │ -# significantly. This will be informed by what existing code actually
│ │ │ │ │ -# needs, and we might end up with a more concrete description of what
│ │ │ │ │ -# a client can actually expect.
│ │ │ │ │ -#
│ │ │ │ │ -# Here is an example of a more complex scenario:
│ │ │ │ │ -#
│ │ │ │ │ -# - The client adds a "watch" for a path namespace. There is a
│ │ │ │ │ -# ObjectManager at the given path and the bridge emits "meta" and
│ │ │ │ │ -# "notify" messages to describe all interfaces and objects reported
│ │ │ │ │ -# by that ObjectManager.
│ │ │ │ │ -#
│ │ │ │ │ -# - The client makes a method call that causes a new object with a new
│ │ │ │ │ -# interface to appear at the ObjectManager. The bridge will send a
│ │ │ │ │ -# "meta" and "notify" message to describe this new object.
│ │ │ │ │ -#
│ │ │ │ │ -# - Since the InterfacesAdded signal was emitted before the method
│ │ │ │ │ -# reply, the bridge must send the "meta" and "notify" messages
│ │ │ │ │ -# before the method reply message.
│ │ │ │ │ -#
│ │ │ │ │ -# - However, in order to construct the "meta" message, the bridge must
│ │ │ │ │ -# perform a Introspect call, and consequently must delay sending the
│ │ │ │ │ -# method reply until that call has finished.
│ │ │ │ │ -#
│ │ │ │ │ -# The Python bridge implements this delaying of messages with
│ │ │ │ │ -# coroutines and a fair mutex. Every message coming from D-Bus will
│ │ │ │ │ -# wait on the mutex for its turn to send its message on the Cockpit
│ │ │ │ │ -# channel, and will keep that mutex locked until it is done with
│ │ │ │ │ -# sending. Since the mutex is fair, everyone will nicely wait in line
│ │ │ │ │ -# without messages getting re-ordered.
│ │ │ │ │ -#
│ │ │ │ │ -# The scenario above will play out like this:
│ │ │ │ │ -#
│ │ │ │ │ -# - While adding the initial "watch", the lock is held until the
│ │ │ │ │ -# "meta" and "notify" messages have been sent.
│ │ │ │ │ -#
│ │ │ │ │ -# - Later, when the InterfacesAdded signal comes in that has been
│ │ │ │ │ -# triggered by the method call, the mutex will be locked while the
│ │ │ │ │ -# necessary introspection is going on.
│ │ │ │ │ -#
│ │ │ │ │ -# - The method reply will likely come while the mutex is locked, and
│ │ │ │ │ -# the task for sending that reply on the Cockpit channel will enter
│ │ │ │ │ -# the wait queue of the mutex.
│ │ │ │ │ -#
│ │ │ │ │ -# - Once the introspection is done and the new "meta" and "notify"
│ │ │ │ │ -# messages have been sent, the mutex is unlocked, the method reply
│ │ │ │ │ -# task acquires it, and sends its message.
│ │ │ │ │ + # ignore mdraid
│ │ │ │ │ + if dev_major == '9':
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ + # ignore device-mapper
│ │ │ │ │ + if dev_name.startswith('dm-'):
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ -class InterfaceCache:
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - self.cache = {}
│ │ │ │ │ - self.old = set() # Interfaces already returned by get_interface_if_new
│ │ │ │ │ + # Skip partitions
│ │ │ │ │ + if dev_name[:2] in ['sd', 'hd', 'vd'] and dev_name[-1].isdigit():
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - def inject(self, interfaces):
│ │ │ │ │ - self.cache.update(interfaces)
│ │ │ │ │ + # Ignore nvme partitions
│ │ │ │ │ + if dev_name.startswith('nvme') and 'p' in dev_name:
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - async def introspect_path(self, bus, destination, object_path):
│ │ │ │ │ - xml, = await bus.call_method_async(destination, object_path,
│ │ │ │ │ - 'org.freedesktop.DBus.Introspectable',
│ │ │ │ │ - 'Introspect')
│ │ │ │ │ + read_bytes = int(num_sectors_read) * 512
│ │ │ │ │ + written_bytes = int(num_sectors_written) * 512
│ │ │ │ │
│ │ │ │ │ - et = ET.fromstring(xml)
│ │ │ │ │ + all_read_bytes += read_bytes
│ │ │ │ │ + all_written_bytes += written_bytes
│ │ │ │ │
│ │ │ │ │ - interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')}
│ │ │ │ │ + samples['disk.dev.read'][dev_name] = read_bytes
│ │ │ │ │ + samples['disk.dev.written'][dev_name] = written_bytes
│ │ │ │ │
│ │ │ │ │ - # Add all interfaces we found: we might use them later
│ │ │ │ │ - self.inject(interfaces)
│ │ │ │ │ + samples['disk.all.read'] = all_read_bytes
│ │ │ │ │ + samples['disk.all.written'] = all_written_bytes
│ │ │ │ │
│ │ │ │ │ - return interfaces
│ │ │ │ │
│ │ │ │ │ - async def get_interface(self, interface_name, bus=None, destination=None, object_path=None):
│ │ │ │ │ - try:
│ │ │ │ │ - return self.cache[interface_name]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - pass
│ │ │ │ │ +class CGroupSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('cgroup.memory.usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.memory.limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.memory.sw-usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.memory.sw-limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.cpu.usage', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.cpu.shares', 'count', 'instant', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - if bus and object_path:
│ │ │ │ │ - try:
│ │ │ │ │ - await self.introspect_path(bus, destination, object_path)
│ │ │ │ │ - except BusError:
│ │ │ │ │ - pass
│ │ │ │ │ + cgroups_v2: Optional[bool] = None
│ │ │ │ │
│ │ │ │ │ - return self.cache.get(interface_name)
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + if self.cgroups_v2 is None:
│ │ │ │ │ + self.cgroups_v2 = os.path.exists('/sys/fs/cgroup/cgroup.controllers')
│ │ │ │ │
│ │ │ │ │ - async def get_interface_if_new(self, interface_name, bus, destination, object_path):
│ │ │ │ │ - if interface_name in self.old:
│ │ │ │ │ - return None
│ │ │ │ │ - self.old.add(interface_name)
│ │ │ │ │ - return await self.get_interface(interface_name, bus, destination, object_path)
│ │ │ │ │ + if self.cgroups_v2:
│ │ │ │ │ + cgroups_v2_path = '/sys/fs/cgroup/'
│ │ │ │ │ + for path, _, _, rootfd in os.fwalk(cgroups_v2_path):
│ │ │ │ │ + cgroup = path.replace(cgroups_v2_path, '')
│ │ │ │ │
│ │ │ │ │ - async def get_signature(self, interface_name, method, bus=None, destination=None, object_path=None):
│ │ │ │ │ - interface = await self.get_interface(interface_name, bus, destination, object_path)
│ │ │ │ │ - if interface is None:
│ │ │ │ │ - raise KeyError(f'Interface {interface_name} is not found')
│ │ │ │ │ + if not cgroup:
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - return ''.join(interface['methods'][method]['in'])
│ │ │ │ │ + samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.current', 0)
│ │ │ │ │ + samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.max')
│ │ │ │ │ + samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.swap.current', 0)
│ │ │ │ │ + samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.swap.max')
│ │ │ │ │ + samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.weight')
│ │ │ │ │ + usage_usec = read_int_file(rootfd, 'cpu.stat', 0, key=b'usage_usec')
│ │ │ │ │ + if usage_usec:
│ │ │ │ │ + samples['cgroup.cpu.usage'][cgroup] = usage_usec / 1000
│ │ │ │ │ + else:
│ │ │ │ │ + memory_path = '/sys/fs/cgroup/memory/'
│ │ │ │ │ + for path, _, _, rootfd in os.fwalk(memory_path):
│ │ │ │ │ + cgroup = path.replace(memory_path, '')
│ │ │ │ │
│ │ │ │ │ + if not cgroup:
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ -def notify_update(notify, path, interface_name, props):
│ │ │ │ │ - notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()}
│ │ │ │ │ + samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.usage_in_bytes', 0)
│ │ │ │ │ + samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.limit_in_bytes')
│ │ │ │ │ + samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.memsw.usage_in_bytes', 0)
│ │ │ │ │ + samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.memsw.limit_in_bytes')
│ │ │ │ │
│ │ │ │ │ + cpu_path = '/sys/fs/cgroup/cpu/'
│ │ │ │ │ + for path, _, _, rootfd in os.fwalk(cpu_path):
│ │ │ │ │ + cgroup = path.replace(cpu_path, '')
│ │ │ │ │
│ │ │ │ │ -class DBusChannel(Channel):
│ │ │ │ │ - json_encoder = systemd_ctypes.JSONEncoder(indent=2)
│ │ │ │ │ - payload = 'dbus-json3'
│ │ │ │ │ + if not cgroup:
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - matches = None
│ │ │ │ │ - name = None
│ │ │ │ │ - bus = None
│ │ │ │ │ - owner = None
│ │ │ │ │ + samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.shares')
│ │ │ │ │ + usage_nsec = read_int_file(rootfd, 'cpuacct.usage')
│ │ │ │ │ + if usage_nsec:
│ │ │ │ │ + samples['cgroup.cpu.usage'][cgroup] = usage_nsec / 1000000
│ │ │ │ │
│ │ │ │ │ - async def setup_name_owner_tracking(self):
│ │ │ │ │ - def send_owner(owner):
│ │ │ │ │ - # We must be careful not to send duplicate owner
│ │ │ │ │ - # notifications. cockpit.js relies on that.
│ │ │ │ │ - if self.owner != owner:
│ │ │ │ │ - self.owner = owner
│ │ │ │ │ - self.send_json(owner=owner)
│ │ │ │ │
│ │ │ │ │ - def handler(message):
│ │ │ │ │ - _name, _old, new = message.get_body()
│ │ │ │ │ - send_owner(owner=new if new != "" else None)
│ │ │ │ │ - self.add_signal_handler(handler,
│ │ │ │ │ - sender='org.freedesktop.DBus',
│ │ │ │ │ - path='/org/freedesktop/DBus',
│ │ │ │ │ - interface='org.freedesktop.DBus',
│ │ │ │ │ - member='NameOwnerChanged',
│ │ │ │ │ - arg0=self.name)
│ │ │ │ │ - try:
│ │ │ │ │ - unique_name, = await self.bus.call_method_async("org.freedesktop.DBus",
│ │ │ │ │ - "/org/freedesktop/DBus",
│ │ │ │ │ - "org.freedesktop.DBus",
│ │ │ │ │ - "GetNameOwner", "s", self.name)
│ │ │ │ │ - except BusError as error:
│ │ │ │ │ - if error.name == "org.freedesktop.DBus.Error.NameHasNoOwner":
│ │ │ │ │ - # Try to start it. If it starts successfully, we will
│ │ │ │ │ - # get a NameOwnerChanged signal (which will set
│ │ │ │ │ - # self.owner) before StartServiceByName returns.
│ │ │ │ │ - try:
│ │ │ │ │ - await self.bus.call_method_async("org.freedesktop.DBus",
│ │ │ │ │ - "/org/freedesktop/DBus",
│ │ │ │ │ - "org.freedesktop.DBus",
│ │ │ │ │ - "StartServiceByName", "su", self.name, 0)
│ │ │ │ │ - except BusError as start_error:
│ │ │ │ │ - logger.debug("Failed to start service '%s': %s", self.name, start_error.message)
│ │ │ │ │ - self.send_json(owner=None)
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug("Failed to get owner of service '%s': %s", self.name, error.message)
│ │ │ │ │ - else:
│ │ │ │ │ - send_owner(unique_name)
│ │ │ │ │ +class CGroupDiskIO(Sampler):
│ │ │ │ │ + IO_RE = re.compile(rb'\bread_bytes: (?P\d+).*\nwrite_bytes: (?P\d+)', flags=re.S)
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('disk.cgroup.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('disk.cgroup.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - def do_open(self, options):
│ │ │ │ │ - self.cache = InterfaceCache()
│ │ │ │ │ - self.name = options.get('name')
│ │ │ │ │ - self.matches = []
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_cgroup_name(fd: int) -> str:
│ │ │ │ │ + with Handle.open('cgroup', os.O_RDONLY, dir_fd=fd) as cgroup_fd:
│ │ │ │ │ + cgroup_name = os.read(cgroup_fd, 2048).decode().strip()
│ │ │ │ │
│ │ │ │ │ - bus = options.get('bus')
│ │ │ │ │ - address = options.get('address')
│ │ │ │ │ + # Skip leading ::0/
│ │ │ │ │ + return cgroup_name[4:]
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - if address is not None:
│ │ │ │ │ - if bus is not None and bus != 'none':
│ │ │ │ │ - raise ChannelError('protocol-error', message='only one of "bus" and "address" can be specified')
│ │ │ │ │ - logger.debug('get bus with address %s for %s', address, self.name)
│ │ │ │ │ - self.bus = Bus.new(address=address, bus_client=self.name is not None)
│ │ │ │ │ - elif bus == 'internal':
│ │ │ │ │ - logger.debug('get internal bus for %s', self.name)
│ │ │ │ │ - self.bus = self.router.internal_bus.client
│ │ │ │ │ - else:
│ │ │ │ │ - if bus == 'session':
│ │ │ │ │ - logger.debug('get session bus for %s', self.name)
│ │ │ │ │ - self.bus = Bus.default_user()
│ │ │ │ │ - elif bus == 'system' or bus is None:
│ │ │ │ │ - logger.debug('get system bus for %s', self.name)
│ │ │ │ │ - self.bus = Bus.default_system()
│ │ │ │ │ - else:
│ │ │ │ │ - raise ChannelError('protocol-error', message=f'invalid bus "{bus}"')
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_proc_io(fd: int) -> Tuple[int, int]:
│ │ │ │ │ + with Handle.open('io', os.O_RDONLY, dir_fd=fd) as io_fd:
│ │ │ │ │ + data = os.read(io_fd, 4096)
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - self.bus.attach_event(None, 0)
│ │ │ │ │ - except OSError as err:
│ │ │ │ │ - if err.errno != errno.EBUSY:
│ │ │ │ │ - raise
│ │ │ │ │ + match = re.search(CGroupDiskIO.IO_RE, data)
│ │ │ │ │ + if match:
│ │ │ │ │ + proc_read = int(match.group('read'))
│ │ │ │ │ + proc_write = int(match.group('write'))
│ │ │ │ │
│ │ │ │ │ - # This needs to be a fair mutex so that outgoing messages don't
│ │ │ │ │ - # get re-ordered. asyncio.Lock is fair.
│ │ │ │ │ - self.watch_processing_lock = asyncio.Lock()
│ │ │ │ │ + return proc_read, proc_write
│ │ │ │ │
│ │ │ │ │ - if self.name is not None:
│ │ │ │ │ - async def get_ready():
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - await self.setup_name_owner_tracking()
│ │ │ │ │ - if self.owner:
│ │ │ │ │ - self.ready(unique_name=self.owner)
│ │ │ │ │ - else:
│ │ │ │ │ - self.close({'problem': 'not-found'})
│ │ │ │ │ - self.create_task(get_ready())
│ │ │ │ │ - else:
│ │ │ │ │ - self.ready()
│ │ │ │ │ + return 0, 0
│ │ │ │ │
│ │ │ │ │ - def add_signal_handler(self, handler, **kwargs):
│ │ │ │ │ - r = dict(**kwargs)
│ │ │ │ │ - r['type'] = 'signal'
│ │ │ │ │ - if 'sender' not in r and self.name is not None:
│ │ │ │ │ - r['sender'] = self.name
│ │ │ │ │ - # HACK - https://github.com/bus1/dbus-broker/issues/309
│ │ │ │ │ - # path_namespace='/' in a rule does not work.
│ │ │ │ │ - if r.get('path_namespace') == "/":
│ │ │ │ │ - del r['path_namespace']
│ │ │ │ │ + def sample(self, samples: Samples):
│ │ │ │ │ + with Handle.open('/proc', os.O_RDONLY | os.O_DIRECTORY) as proc_fd:
│ │ │ │ │ + reads = samples['disk.cgroup.read']
│ │ │ │ │ + writes = samples['disk.cgroup.written']
│ │ │ │ │
│ │ │ │ │ - def filter_owner(message):
│ │ │ │ │ - if self.owner is not None and self.owner == message.get_sender():
│ │ │ │ │ - handler(message)
│ │ │ │ │ + for path in os.listdir(proc_fd):
│ │ │ │ │ + # non-pid entries in proc are guaranteed to start with a character a-z
│ │ │ │ │ + if path[0] < '0' or path[0] > '9':
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - if self.name is not None and 'sender' in r and r['sender'] == self.name:
│ │ │ │ │ - func = filter_owner
│ │ │ │ │ - else:
│ │ │ │ │ - func = handler
│ │ │ │ │ - r_string = ','.join(f"{key}='{value}'" for key, value in r.items())
│ │ │ │ │ - if not self.is_closing():
│ │ │ │ │ - # this gets an EINTR very often especially on RHEL 8
│ │ │ │ │ - while True:
│ │ │ │ │ try:
│ │ │ │ │ - match = self.bus.add_match(r_string, func)
│ │ │ │ │ - break
│ │ │ │ │ - except InterruptedError:
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - self.matches.append(match)
│ │ │ │ │ -
│ │ │ │ │ - def add_async_signal_handler(self, handler, **kwargs):
│ │ │ │ │ - def sync_handler(message):
│ │ │ │ │ - self.create_task(handler(message))
│ │ │ │ │ - self.add_signal_handler(sync_handler, **kwargs)
│ │ │ │ │ -
│ │ │ │ │ - async def do_call(self, message):
│ │ │ │ │ - path, iface, method, args = message['call']
│ │ │ │ │ - cookie = message.get('id')
│ │ │ │ │ - flags = message.get('flags')
│ │ │ │ │ -
│ │ │ │ │ - timeout = message.get('timeout')
│ │ │ │ │ - if timeout is not None:
│ │ │ │ │ - # sd_bus timeout is μs, cockpit API timeout is ms
│ │ │ │ │ - timeout *= 1000
│ │ │ │ │ - else:
│ │ │ │ │ - # sd_bus has no "indefinite" timeout, so use MAX_UINT64
│ │ │ │ │ - timeout = 2 ** 64 - 1
│ │ │ │ │ -
│ │ │ │ │ - # We have to figure out the signature of the call. Either we got told it:
│ │ │ │ │ - signature = message.get('type')
│ │ │ │ │ + with Handle.open(path, os.O_PATH, dir_fd=proc_fd) as pid_fd:
│ │ │ │ │ + cgroup_name = self.get_cgroup_name(pid_fd)
│ │ │ │ │ + proc_read, proc_write = self.get_proc_io(pid_fd)
│ │ │ │ │ + except (FileNotFoundError, PermissionError, ProcessLookupError):
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - # ... or there aren't any arguments
│ │ │ │ │ - if signature is None and len(args) == 0:
│ │ │ │ │ - signature = ''
│ │ │ │ │ + reads[cgroup_name] = reads.get(cgroup_name, 0) + proc_read
│ │ │ │ │ + writes[cgroup_name] = writes.get(cgroup_name, 0) + proc_write
│ │ │ │ │
│ │ │ │ │ - # ... or we need to introspect
│ │ │ │ │ - if signature is None:
│ │ │ │ │ - try:
│ │ │ │ │ - logger.debug('Doing introspection request for %s %s', iface, method)
│ │ │ │ │ - signature = await self.cache.get_signature(iface, method, self.bus, self.name, path)
│ │ │ │ │ - except BusError as error:
│ │ │ │ │ - self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie)
│ │ │ │ │ - return
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - self.send_json(
│ │ │ │ │ - error=[
│ │ │ │ │ - "org.freedesktop.DBus.Error.UnknownMethod",
│ │ │ │ │ - [f"Introspection data for method {iface} {method} not available"]],
│ │ │ │ │ - id=cookie)
│ │ │ │ │ - return
│ │ │ │ │ - except Exception as exc:
│ │ │ │ │ - self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie)
│ │ │ │ │ - return
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args)
│ │ │ │ │ - reply = await self.bus.call_async(method_call, timeout=timeout)
│ │ │ │ │ - # If the method call has kicked off any signals related to
│ │ │ │ │ - # watch processing, wait for that to be done.
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - # TODO: stop hard-coding the endian flag here.
│ │ │ │ │ - self.send_json(
│ │ │ │ │ - reply=[reply.get_body()], id=cookie,
│ │ │ │ │ - flags="<" if flags is not None else None,
│ │ │ │ │ - type=reply.get_signature(True)) # noqa: FBT003
│ │ │ │ │ - except BusError as error:
│ │ │ │ │ - # actually, should send the fields from the message body
│ │ │ │ │ - self.send_json(error=[error.name, [error.message]], id=cookie)
│ │ │ │ │ - except Exception:
│ │ │ │ │ - logger.exception("do_call(%s): generic exception", message)
│ │ │ │ │ - self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie)
│ │ │ │ │ +class NetworkSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('network.interface.tx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('network.interface.rx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - async def do_add_match(self, message):
│ │ │ │ │ - add_match = message['add-match']
│ │ │ │ │ - logger.debug('adding match %s', add_match)
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open("/proc/net/dev") as network_samples:
│ │ │ │ │ + for line in network_samples:
│ │ │ │ │ + fields = line.split()
│ │ │ │ │
│ │ │ │ │ - async def match_hit(message):
│ │ │ │ │ - logger.debug('got match')
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - self.send_json(signal=[
│ │ │ │ │ - message.get_path(),
│ │ │ │ │ - message.get_interface(),
│ │ │ │ │ - message.get_member(),
│ │ │ │ │ - list(message.get_body())
│ │ │ │ │ - ])
│ │ │ │ │ + # Skip header line
│ │ │ │ │ + if fields[0][-1] != ':':
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - self.add_async_signal_handler(match_hit, **add_match)
│ │ │ │ │ + iface = fields[0][:-1]
│ │ │ │ │ + samples['network.interface.rx'][iface] = int(fields[1])
│ │ │ │ │ + samples['network.interface.tx'][iface] = int(fields[9])
│ │ │ │ │
│ │ │ │ │ - async def setup_objectmanager_watch(self, path, interface_name, meta, notify):
│ │ │ │ │ - # Watch the objects managed by the ObjectManager at "path".
│ │ │ │ │ - # Properties are not watched, that is done by setup_path_watch
│ │ │ │ │ - # below via recursive_props == True.
│ │ │ │ │
│ │ │ │ │ - async def handler(message):
│ │ │ │ │ - member = message.get_member()
│ │ │ │ │ - if member == "InterfacesAdded":
│ │ │ │ │ - (path, interface_props) = message.get_body()
│ │ │ │ │ - logger.debug('interfaces added %s %s', path, interface_props)
│ │ │ │ │ - meta = {}
│ │ │ │ │ - notify = {}
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - for name, props in interface_props.items():
│ │ │ │ │ - if interface_name is None or name == interface_name:
│ │ │ │ │ - mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path)
│ │ │ │ │ - if mm:
│ │ │ │ │ - meta.update({name: mm})
│ │ │ │ │ - notify_update(notify, path, name, props)
│ │ │ │ │ - self.send_json(meta=meta)
│ │ │ │ │ - self.send_json(notify=notify)
│ │ │ │ │ - elif member == "InterfacesRemoved":
│ │ │ │ │ - (path, interfaces) = message.get_body()
│ │ │ │ │ - logger.debug('interfaces removed %s %s', path, interfaces)
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - notify = {path: dict.fromkeys(interfaces)}
│ │ │ │ │ - self.send_json(notify=notify)
│ │ │ │ │ +class MountSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('mount.total', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('mount.used', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - self.add_async_signal_handler(handler,
│ │ │ │ │ - path=path,
│ │ │ │ │ - interface="org.freedesktop.DBus.ObjectManager")
│ │ │ │ │ - objects, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ - 'org.freedesktop.DBus.ObjectManager',
│ │ │ │ │ - 'GetManagedObjects')
│ │ │ │ │ - for p, ifaces in objects.items():
│ │ │ │ │ - for iface, props in ifaces.items():
│ │ │ │ │ - if interface_name is None or iface == interface_name:
│ │ │ │ │ - mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p)
│ │ │ │ │ - if mm:
│ │ │ │ │ - meta.update({iface: mm})
│ │ │ │ │ - notify_update(notify, p, iface, props)
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/mounts') as mounts:
│ │ │ │ │ + for line in mounts:
│ │ │ │ │ + # Only look at real devices
│ │ │ │ │ + if line[0] != '/':
│ │ │ │ │ + continue
│ │ │ │ │
│ │ │ │ │ - async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify):
│ │ │ │ │ - # Watch a single object at "path", but maybe also watch for
│ │ │ │ │ - # property changes for all objects below "path".
│ │ │ │ │ + path = line.split()[1]
│ │ │ │ │ + try:
│ │ │ │ │ + res = os.statvfs(path)
│ │ │ │ │ + except OSError:
│ │ │ │ │ + continue
│ │ │ │ │ + frsize = res.f_frsize
│ │ │ │ │ + total = frsize * res.f_blocks
│ │ │ │ │ + samples['mount.total'][path] = total
│ │ │ │ │ + samples['mount.used'][path] = total - frsize * res.f_bfree
│ │ │ │ │
│ │ │ │ │ - async def handler(message):
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - path = message.get_path()
│ │ │ │ │ - name, props, invalids = message.get_body()
│ │ │ │ │ - logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids)
│ │ │ │ │ - for inv in invalids:
│ │ │ │ │ - try:
│ │ │ │ │ - reply, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ - 'org.freedesktop.DBus.Properties', 'Get',
│ │ │ │ │ - 'ss', name, inv)
│ │ │ │ │ - except BusError as exc:
│ │ │ │ │ - logger.debug('failed to fetch property %s.%s on %s %s: %s',
│ │ │ │ │ - name, inv, self.name, path, str(exc))
│ │ │ │ │ - continue
│ │ │ │ │ - props[inv] = reply
│ │ │ │ │ - notify = {}
│ │ │ │ │ - notify_update(notify, path, name, props)
│ │ │ │ │ - self.send_json(notify=notify)
│ │ │ │ │
│ │ │ │ │ - this_meta = await self.cache.introspect_path(self.bus, self.name, path)
│ │ │ │ │ - if interface_name is not None:
│ │ │ │ │ - interface = this_meta.get(interface_name)
│ │ │ │ │ - this_meta = {interface_name: interface}
│ │ │ │ │ - meta.update(this_meta)
│ │ │ │ │ - if recursive_props:
│ │ │ │ │ - self.add_async_signal_handler(handler,
│ │ │ │ │ - interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ - path_namespace=path)
│ │ │ │ │ - else:
│ │ │ │ │ - self.add_async_signal_handler(handler,
│ │ │ │ │ - interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ - path=path)
│ │ │ │ │ +class BlockSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('block.device.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('block.device.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - for name in meta:
│ │ │ │ │ - if name.startswith("org.freedesktop.DBus."):
│ │ │ │ │ - continue
│ │ │ │ │ - try:
│ │ │ │ │ - props, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ - 'org.freedesktop.DBus.Properties',
│ │ │ │ │ - 'GetAll', 's', name)
│ │ │ │ │ - notify_update(notify, path, name, props)
│ │ │ │ │ - except BusError:
│ │ │ │ │ - pass
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/diskstats') as diskstats:
│ │ │ │ │ + for line in diskstats:
│ │ │ │ │ + # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ + [_, _, dev_name, _, _, sectors_read, _, _, _, sectors_written, *_] = line.strip().split()
│ │ │ │ │
│ │ │ │ │ - async def do_watch(self, message):
│ │ │ │ │ - watch = message['watch']
│ │ │ │ │ - path = watch.get('path')
│ │ │ │ │ - path_namespace = watch.get('path_namespace')
│ │ │ │ │ - interface_name = watch.get('interface')
│ │ │ │ │ - cookie = message.get('id')
│ │ │ │ │ + samples['block.device.read'][dev_name] = int(sectors_read) * 512
│ │ │ │ │ + samples['block.device.written'][dev_name] = int(sectors_written) * 512
│ │ │ │ │
│ │ │ │ │ - path = path or path_namespace
│ │ │ │ │ - recursive = path == path_namespace
│ │ │ │ │
│ │ │ │ │ - if path is None or cookie is None:
│ │ │ │ │ - logger.debug('ignored incomplete watch request %s', message)
│ │ │ │ │ - self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie)
│ │ │ │ │ - self.send_json(reply=[], id=cookie)
│ │ │ │ │ - return
│ │ │ │ │ +SAMPLERS = [
│ │ │ │ │ + BlockSampler,
│ │ │ │ │ + CGroupSampler,
│ │ │ │ │ + CGroupDiskIO,
│ │ │ │ │ + CPUSampler,
│ │ │ │ │ + CPUTemperatureSampler,
│ │ │ │ │ + DiskSampler,
│ │ │ │ │ + MemorySampler,
│ │ │ │ │ + MountSampler,
│ │ │ │ │ + NetworkSampler,
│ │ │ │ │ +]
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/data/fail.html': br'''
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ + @@message@@
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
@@message@@
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/data/__init__.py': br'''import sys
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - meta = {}
│ │ │ │ │ - notify = {}
│ │ │ │ │ - await self.setup_path_watch(path, interface_name, recursive, meta, notify)
│ │ │ │ │ - if recursive:
│ │ │ │ │ - await self.setup_objectmanager_watch(path, interface_name, meta, notify)
│ │ │ │ │ - self.send_json(meta=meta)
│ │ │ │ │ - self.send_json(notify=notify)
│ │ │ │ │ - self.send_json(reply=[], id=message['id'])
│ │ │ │ │ - except BusError as error:
│ │ │ │ │ - logger.debug("do_watch(%s) caught D-Bus error: %s", message, error.message)
│ │ │ │ │ - self.send_json(error=[error.name, [error.message]], id=cookie)
│ │ │ │ │ +if sys.version_info >= (3, 11):
│ │ │ │ │ + import importlib.resources
│ │ │ │ │
│ │ │ │ │ - async def do_meta(self, message):
│ │ │ │ │ - self.cache.inject(message['meta'])
│ │ │ │ │ + def read_cockpit_data_file(filename: str) -> bytes:
│ │ │ │ │ + return (importlib.resources.files('cockpit.data') / filename).read_bytes()
│ │ │ │ │
│ │ │ │ │ - def do_data(self, data):
│ │ │ │ │ - message = json.loads(data)
│ │ │ │ │ - logger.debug('receive dbus request %s %s', self.name, message)
│ │ │ │ │ +else:
│ │ │ │ │ + import importlib.abc
│ │ │ │ │
│ │ │ │ │ - if 'call' in message:
│ │ │ │ │ - self.create_task(self.do_call(message))
│ │ │ │ │ - elif 'add-match' in message:
│ │ │ │ │ - self.create_task(self.do_add_match(message))
│ │ │ │ │ - elif 'watch' in message:
│ │ │ │ │ - self.create_task(self.do_watch(message))
│ │ │ │ │ - elif 'meta' in message:
│ │ │ │ │ - self.create_task(self.do_meta(message))
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug('ignored dbus request %s', message)
│ │ │ │ │ - return
│ │ │ │ │ + def read_cockpit_data_file(filename: str) -> bytes:
│ │ │ │ │ + # https://github.com/python/mypy/issues/4182
│ │ │ │ │ + loader = __loader__ # type: ignore[name-defined]
│ │ │ │ │ + assert isinstance(loader, importlib.abc.ResourceLoader)
│ │ │ │ │
│ │ │ │ │ - def do_close(self):
│ │ │ │ │ - for slot in self.matches:
│ │ │ │ │ - slot.cancel()
│ │ │ │ │ - self.matches = []
│ │ │ │ │ - self.close()
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ + path = __file__.replace('__init__.py', filename)
│ │ │ │ │ + return loader.get_data(path)
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/channels/filesystem.py': r'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -6192,14 +5555,623 @@
│ │ │ │ │
│ │ │ │ │ else:
│ │ │ │ │ # PathWatch will call do_identity_changed(), which does the same as
│ │ │ │ │ # above: calls either report_initial_state() or report_error(),
│ │ │ │ │ # depending on if it was provided with an fd or an error code.
│ │ │ │ │ self.path_watch = PathWatch(self.path, self)
│ │ │ │ │ '''.encode('utf-8'),
│ │ │ │ │ + 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +# Missing stuff compared to the C bridge that we should probably add:
│ │ │ │ │ +#
│ │ │ │ │ +# - removing matches
│ │ │ │ │ +# - removing watches
│ │ │ │ │ +# - emitting of signals
│ │ │ │ │ +# - publishing of objects
│ │ │ │ │ +# - failing more gracefully in some cases (during open, etc)
│ │ │ │ │ +#
│ │ │ │ │ +# Stuff we might or might not do:
│ │ │ │ │ +#
│ │ │ │ │ +# - using non-default service names
│ │ │ │ │ +#
│ │ │ │ │ +# Stuff we should probably not do:
│ │ │ │ │ +#
│ │ │ │ │ +# - emulation of ObjectManager via recursive introspection
│ │ │ │ │ +# - automatic detection of ObjectManager below the given path_namespace
│ │ │ │ │ +# - recursive scraping of properties for new object paths
│ │ │ │ │ +# (for path_namespace watches that don't hit an ObjectManager)
│ │ │ │ │ +
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import errno
│ │ │ │ │ +import json
│ │ │ │ │ +import logging
│ │ │ │ │ +import traceback
│ │ │ │ │ +import xml.etree.ElementTree as ET
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor import systemd_ctypes
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection
│ │ │ │ │ +
│ │ │ │ │ +from ..channel import Channel, ChannelError
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +# The dbusjson3 payload
│ │ │ │ │ +#
│ │ │ │ │ +# This channel payload type translates JSON encoded messages on a
│ │ │ │ │ +# Cockpit channel to D-Bus messages, in a mostly straightforward way.
│ │ │ │ │ +# See doc/protocol.md for a description of the basics.
│ │ │ │ │ +#
│ │ │ │ │ +# However, dbusjson3 offers some advanced features as well that are
│ │ │ │ │ +# meant to support the "magic" DBusProxy objects implemented by
│ │ │ │ │ +# cockpit.js. Those proxy objects "magically" expose all the methods
│ │ │ │ │ +# and properties of a D-Bus interface without requiring any explicit
│ │ │ │ │ +# binding code to be generated for a JavaScript client. A dbusjson3
│ │ │ │ │ +# channel does this by doing automatic introspection and property
│ │ │ │ │ +# retrieval without much direction from the JavaScript client.
│ │ │ │ │ +#
│ │ │ │ │ +# The details of what exactly is done is not specified very strictly,
│ │ │ │ │ +# and the Python bridge will likely differ from the C bridge
│ │ │ │ │ +# significantly. This will be informed by what existing code actually
│ │ │ │ │ +# needs, and we might end up with a more concrete description of what
│ │ │ │ │ +# a client can actually expect.
│ │ │ │ │ +#
│ │ │ │ │ +# Here is an example of a more complex scenario:
│ │ │ │ │ +#
│ │ │ │ │ +# - The client adds a "watch" for a path namespace. There is a
│ │ │ │ │ +# ObjectManager at the given path and the bridge emits "meta" and
│ │ │ │ │ +# "notify" messages to describe all interfaces and objects reported
│ │ │ │ │ +# by that ObjectManager.
│ │ │ │ │ +#
│ │ │ │ │ +# - The client makes a method call that causes a new object with a new
│ │ │ │ │ +# interface to appear at the ObjectManager. The bridge will send a
│ │ │ │ │ +# "meta" and "notify" message to describe this new object.
│ │ │ │ │ +#
│ │ │ │ │ +# - Since the InterfacesAdded signal was emitted before the method
│ │ │ │ │ +# reply, the bridge must send the "meta" and "notify" messages
│ │ │ │ │ +# before the method reply message.
│ │ │ │ │ +#
│ │ │ │ │ +# - However, in order to construct the "meta" message, the bridge must
│ │ │ │ │ +# perform a Introspect call, and consequently must delay sending the
│ │ │ │ │ +# method reply until that call has finished.
│ │ │ │ │ +#
│ │ │ │ │ +# The Python bridge implements this delaying of messages with
│ │ │ │ │ +# coroutines and a fair mutex. Every message coming from D-Bus will
│ │ │ │ │ +# wait on the mutex for its turn to send its message on the Cockpit
│ │ │ │ │ +# channel, and will keep that mutex locked until it is done with
│ │ │ │ │ +# sending. Since the mutex is fair, everyone will nicely wait in line
│ │ │ │ │ +# without messages getting re-ordered.
│ │ │ │ │ +#
│ │ │ │ │ +# The scenario above will play out like this:
│ │ │ │ │ +#
│ │ │ │ │ +# - While adding the initial "watch", the lock is held until the
│ │ │ │ │ +# "meta" and "notify" messages have been sent.
│ │ │ │ │ +#
│ │ │ │ │ +# - Later, when the InterfacesAdded signal comes in that has been
│ │ │ │ │ +# triggered by the method call, the mutex will be locked while the
│ │ │ │ │ +# necessary introspection is going on.
│ │ │ │ │ +#
│ │ │ │ │ +# - The method reply will likely come while the mutex is locked, and
│ │ │ │ │ +# the task for sending that reply on the Cockpit channel will enter
│ │ │ │ │ +# the wait queue of the mutex.
│ │ │ │ │ +#
│ │ │ │ │ +# - Once the introspection is done and the new "meta" and "notify"
│ │ │ │ │ +# messages have been sent, the mutex is unlocked, the method reply
│ │ │ │ │ +# task acquires it, and sends its message.
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class InterfaceCache:
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + self.cache = {}
│ │ │ │ │ + self.old = set() # Interfaces already returned by get_interface_if_new
│ │ │ │ │ +
│ │ │ │ │ + def inject(self, interfaces):
│ │ │ │ │ + self.cache.update(interfaces)
│ │ │ │ │ +
│ │ │ │ │ + async def introspect_path(self, bus, destination, object_path):
│ │ │ │ │ + xml, = await bus.call_method_async(destination, object_path,
│ │ │ │ │ + 'org.freedesktop.DBus.Introspectable',
│ │ │ │ │ + 'Introspect')
│ │ │ │ │ +
│ │ │ │ │ + et = ET.fromstring(xml)
│ │ │ │ │ +
│ │ │ │ │ + interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')}
│ │ │ │ │ +
│ │ │ │ │ + # Add all interfaces we found: we might use them later
│ │ │ │ │ + self.inject(interfaces)
│ │ │ │ │ +
│ │ │ │ │ + return interfaces
│ │ │ │ │ +
│ │ │ │ │ + async def get_interface(self, interface_name, bus=None, destination=None, object_path=None):
│ │ │ │ │ + try:
│ │ │ │ │ + return self.cache[interface_name]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + if bus and object_path:
│ │ │ │ │ + try:
│ │ │ │ │ + await self.introspect_path(bus, destination, object_path)
│ │ │ │ │ + except BusError:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + return self.cache.get(interface_name)
│ │ │ │ │ +
│ │ │ │ │ + async def get_interface_if_new(self, interface_name, bus, destination, object_path):
│ │ │ │ │ + if interface_name in self.old:
│ │ │ │ │ + return None
│ │ │ │ │ + self.old.add(interface_name)
│ │ │ │ │ + return await self.get_interface(interface_name, bus, destination, object_path)
│ │ │ │ │ +
│ │ │ │ │ + async def get_signature(self, interface_name, method, bus=None, destination=None, object_path=None):
│ │ │ │ │ + interface = await self.get_interface(interface_name, bus, destination, object_path)
│ │ │ │ │ + if interface is None:
│ │ │ │ │ + raise KeyError(f'Interface {interface_name} is not found')
│ │ │ │ │ +
│ │ │ │ │ + return ''.join(interface['methods'][method]['in'])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def notify_update(notify, path, interface_name, props):
│ │ │ │ │ + notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()}
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class DBusChannel(Channel):
│ │ │ │ │ + json_encoder = systemd_ctypes.JSONEncoder(indent=2)
│ │ │ │ │ + payload = 'dbus-json3'
│ │ │ │ │ +
│ │ │ │ │ + matches = None
│ │ │ │ │ + name = None
│ │ │ │ │ + bus = None
│ │ │ │ │ + owner = None
│ │ │ │ │ +
│ │ │ │ │ + async def setup_name_owner_tracking(self):
│ │ │ │ │ + def send_owner(owner):
│ │ │ │ │ + # We must be careful not to send duplicate owner
│ │ │ │ │ + # notifications. cockpit.js relies on that.
│ │ │ │ │ + if self.owner != owner:
│ │ │ │ │ + self.owner = owner
│ │ │ │ │ + self.send_json(owner=owner)
│ │ │ │ │ +
│ │ │ │ │ + def handler(message):
│ │ │ │ │ + _name, _old, new = message.get_body()
│ │ │ │ │ + send_owner(owner=new if new != "" else None)
│ │ │ │ │ + self.add_signal_handler(handler,
│ │ │ │ │ + sender='org.freedesktop.DBus',
│ │ │ │ │ + path='/org/freedesktop/DBus',
│ │ │ │ │ + interface='org.freedesktop.DBus',
│ │ │ │ │ + member='NameOwnerChanged',
│ │ │ │ │ + arg0=self.name)
│ │ │ │ │ + try:
│ │ │ │ │ + unique_name, = await self.bus.call_method_async("org.freedesktop.DBus",
│ │ │ │ │ + "/org/freedesktop/DBus",
│ │ │ │ │ + "org.freedesktop.DBus",
│ │ │ │ │ + "GetNameOwner", "s", self.name)
│ │ │ │ │ + except BusError as error:
│ │ │ │ │ + if error.name == "org.freedesktop.DBus.Error.NameHasNoOwner":
│ │ │ │ │ + # Try to start it. If it starts successfully, we will
│ │ │ │ │ + # get a NameOwnerChanged signal (which will set
│ │ │ │ │ + # self.owner) before StartServiceByName returns.
│ │ │ │ │ + try:
│ │ │ │ │ + await self.bus.call_method_async("org.freedesktop.DBus",
│ │ │ │ │ + "/org/freedesktop/DBus",
│ │ │ │ │ + "org.freedesktop.DBus",
│ │ │ │ │ + "StartServiceByName", "su", self.name, 0)
│ │ │ │ │ + except BusError as start_error:
│ │ │ │ │ + logger.debug("Failed to start service '%s': %s", self.name, start_error.message)
│ │ │ │ │ + self.send_json(owner=None)
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug("Failed to get owner of service '%s': %s", self.name, error.message)
│ │ │ │ │ + else:
│ │ │ │ │ + send_owner(unique_name)
│ │ │ │ │ +
│ │ │ │ │ + def do_open(self, options):
│ │ │ │ │ + self.cache = InterfaceCache()
│ │ │ │ │ + self.name = options.get('name')
│ │ │ │ │ + self.matches = []
│ │ │ │ │ +
│ │ │ │ │ + bus = options.get('bus')
│ │ │ │ │ + address = options.get('address')
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + if address is not None:
│ │ │ │ │ + if bus is not None and bus != 'none':
│ │ │ │ │ + raise ChannelError('protocol-error', message='only one of "bus" and "address" can be specified')
│ │ │ │ │ + logger.debug('get bus with address %s for %s', address, self.name)
│ │ │ │ │ + self.bus = Bus.new(address=address, bus_client=self.name is not None)
│ │ │ │ │ + elif bus == 'internal':
│ │ │ │ │ + logger.debug('get internal bus for %s', self.name)
│ │ │ │ │ + self.bus = self.router.internal_bus.client
│ │ │ │ │ + else:
│ │ │ │ │ + if bus == 'session':
│ │ │ │ │ + logger.debug('get session bus for %s', self.name)
│ │ │ │ │ + self.bus = Bus.default_user()
│ │ │ │ │ + elif bus == 'system' or bus is None:
│ │ │ │ │ + logger.debug('get system bus for %s', self.name)
│ │ │ │ │ + self.bus = Bus.default_system()
│ │ │ │ │ + else:
│ │ │ │ │ + raise ChannelError('protocol-error', message=f'invalid bus "{bus}"')
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + self.bus.attach_event(None, 0)
│ │ │ │ │ + except OSError as err:
│ │ │ │ │ + if err.errno != errno.EBUSY:
│ │ │ │ │ + raise
│ │ │ │ │ +
│ │ │ │ │ + # This needs to be a fair mutex so that outgoing messages don't
│ │ │ │ │ + # get re-ordered. asyncio.Lock is fair.
│ │ │ │ │ + self.watch_processing_lock = asyncio.Lock()
│ │ │ │ │ +
│ │ │ │ │ + if self.name is not None:
│ │ │ │ │ + async def get_ready():
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + await self.setup_name_owner_tracking()
│ │ │ │ │ + if self.owner:
│ │ │ │ │ + self.ready(unique_name=self.owner)
│ │ │ │ │ + else:
│ │ │ │ │ + self.close({'problem': 'not-found'})
│ │ │ │ │ + self.create_task(get_ready())
│ │ │ │ │ + else:
│ │ │ │ │ + self.ready()
│ │ │ │ │ +
│ │ │ │ │ + def add_signal_handler(self, handler, **kwargs):
│ │ │ │ │ + r = dict(**kwargs)
│ │ │ │ │ + r['type'] = 'signal'
│ │ │ │ │ + if 'sender' not in r and self.name is not None:
│ │ │ │ │ + r['sender'] = self.name
│ │ │ │ │ + # HACK - https://github.com/bus1/dbus-broker/issues/309
│ │ │ │ │ + # path_namespace='/' in a rule does not work.
│ │ │ │ │ + if r.get('path_namespace') == "/":
│ │ │ │ │ + del r['path_namespace']
│ │ │ │ │ +
│ │ │ │ │ + def filter_owner(message):
│ │ │ │ │ + if self.owner is not None and self.owner == message.get_sender():
│ │ │ │ │ + handler(message)
│ │ │ │ │ +
│ │ │ │ │ + if self.name is not None and 'sender' in r and r['sender'] == self.name:
│ │ │ │ │ + func = filter_owner
│ │ │ │ │ + else:
│ │ │ │ │ + func = handler
│ │ │ │ │ + r_string = ','.join(f"{key}='{value}'" for key, value in r.items())
│ │ │ │ │ + if not self.is_closing():
│ │ │ │ │ + # this gets an EINTR very often especially on RHEL 8
│ │ │ │ │ + while True:
│ │ │ │ │ + try:
│ │ │ │ │ + match = self.bus.add_match(r_string, func)
│ │ │ │ │ + break
│ │ │ │ │ + except InterruptedError:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + self.matches.append(match)
│ │ │ │ │ +
│ │ │ │ │ + def add_async_signal_handler(self, handler, **kwargs):
│ │ │ │ │ + def sync_handler(message):
│ │ │ │ │ + self.create_task(handler(message))
│ │ │ │ │ + self.add_signal_handler(sync_handler, **kwargs)
│ │ │ │ │ +
│ │ │ │ │ + async def do_call(self, message):
│ │ │ │ │ + path, iface, method, args = message['call']
│ │ │ │ │ + cookie = message.get('id')
│ │ │ │ │ + flags = message.get('flags')
│ │ │ │ │ +
│ │ │ │ │ + timeout = message.get('timeout')
│ │ │ │ │ + if timeout is not None:
│ │ │ │ │ + # sd_bus timeout is μs, cockpit API timeout is ms
│ │ │ │ │ + timeout *= 1000
│ │ │ │ │ + else:
│ │ │ │ │ + # sd_bus has no "indefinite" timeout, so use MAX_UINT64
│ │ │ │ │ + timeout = 2 ** 64 - 1
│ │ │ │ │ +
│ │ │ │ │ + # We have to figure out the signature of the call. Either we got told it:
│ │ │ │ │ + signature = message.get('type')
│ │ │ │ │ +
│ │ │ │ │ + # ... or there aren't any arguments
│ │ │ │ │ + if signature is None and len(args) == 0:
│ │ │ │ │ + signature = ''
│ │ │ │ │ +
│ │ │ │ │ + # ... or we need to introspect
│ │ │ │ │ + if signature is None:
│ │ │ │ │ + try:
│ │ │ │ │ + logger.debug('Doing introspection request for %s %s', iface, method)
│ │ │ │ │ + signature = await self.cache.get_signature(iface, method, self.bus, self.name, path)
│ │ │ │ │ + except BusError as error:
│ │ │ │ │ + self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie)
│ │ │ │ │ + return
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + self.send_json(
│ │ │ │ │ + error=[
│ │ │ │ │ + "org.freedesktop.DBus.Error.UnknownMethod",
│ │ │ │ │ + [f"Introspection data for method {iface} {method} not available"]],
│ │ │ │ │ + id=cookie)
│ │ │ │ │ + return
│ │ │ │ │ + except Exception as exc:
│ │ │ │ │ + self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args)
│ │ │ │ │ + reply = await self.bus.call_async(method_call, timeout=timeout)
│ │ │ │ │ + # If the method call has kicked off any signals related to
│ │ │ │ │ + # watch processing, wait for that to be done.
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + # TODO: stop hard-coding the endian flag here.
│ │ │ │ │ + self.send_json(
│ │ │ │ │ + reply=[reply.get_body()], id=cookie,
│ │ │ │ │ + flags="<" if flags is not None else None,
│ │ │ │ │ + type=reply.get_signature(True)) # noqa: FBT003
│ │ │ │ │ + except BusError as error:
│ │ │ │ │ + # actually, should send the fields from the message body
│ │ │ │ │ + self.send_json(error=[error.name, [error.message]], id=cookie)
│ │ │ │ │ + except Exception:
│ │ │ │ │ + logger.exception("do_call(%s): generic exception", message)
│ │ │ │ │ + self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie)
│ │ │ │ │ +
│ │ │ │ │ + async def do_add_match(self, message):
│ │ │ │ │ + add_match = message['add-match']
│ │ │ │ │ + logger.debug('adding match %s', add_match)
│ │ │ │ │ +
│ │ │ │ │ + async def match_hit(message):
│ │ │ │ │ + logger.debug('got match')
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + self.send_json(signal=[
│ │ │ │ │ + message.get_path(),
│ │ │ │ │ + message.get_interface(),
│ │ │ │ │ + message.get_member(),
│ │ │ │ │ + list(message.get_body())
│ │ │ │ │ + ])
│ │ │ │ │ +
│ │ │ │ │ + self.add_async_signal_handler(match_hit, **add_match)
│ │ │ │ │ +
│ │ │ │ │ + async def setup_objectmanager_watch(self, path, interface_name, meta, notify):
│ │ │ │ │ + # Watch the objects managed by the ObjectManager at "path".
│ │ │ │ │ + # Properties are not watched, that is done by setup_path_watch
│ │ │ │ │ + # below via recursive_props == True.
│ │ │ │ │ +
│ │ │ │ │ + async def handler(message):
│ │ │ │ │ + member = message.get_member()
│ │ │ │ │ + if member == "InterfacesAdded":
│ │ │ │ │ + (path, interface_props) = message.get_body()
│ │ │ │ │ + logger.debug('interfaces added %s %s', path, interface_props)
│ │ │ │ │ + meta = {}
│ │ │ │ │ + notify = {}
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + for name, props in interface_props.items():
│ │ │ │ │ + if interface_name is None or name == interface_name:
│ │ │ │ │ + mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path)
│ │ │ │ │ + if mm:
│ │ │ │ │ + meta.update({name: mm})
│ │ │ │ │ + notify_update(notify, path, name, props)
│ │ │ │ │ + self.send_json(meta=meta)
│ │ │ │ │ + self.send_json(notify=notify)
│ │ │ │ │ + elif member == "InterfacesRemoved":
│ │ │ │ │ + (path, interfaces) = message.get_body()
│ │ │ │ │ + logger.debug('interfaces removed %s %s', path, interfaces)
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + notify = {path: dict.fromkeys(interfaces)}
│ │ │ │ │ + self.send_json(notify=notify)
│ │ │ │ │ +
│ │ │ │ │ + self.add_async_signal_handler(handler,
│ │ │ │ │ + path=path,
│ │ │ │ │ + interface="org.freedesktop.DBus.ObjectManager")
│ │ │ │ │ + objects, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ + 'org.freedesktop.DBus.ObjectManager',
│ │ │ │ │ + 'GetManagedObjects')
│ │ │ │ │ + for p, ifaces in objects.items():
│ │ │ │ │ + for iface, props in ifaces.items():
│ │ │ │ │ + if interface_name is None or iface == interface_name:
│ │ │ │ │ + mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p)
│ │ │ │ │ + if mm:
│ │ │ │ │ + meta.update({iface: mm})
│ │ │ │ │ + notify_update(notify, p, iface, props)
│ │ │ │ │ +
│ │ │ │ │ + async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify):
│ │ │ │ │ + # Watch a single object at "path", but maybe also watch for
│ │ │ │ │ + # property changes for all objects below "path".
│ │ │ │ │ +
│ │ │ │ │ + async def handler(message):
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + path = message.get_path()
│ │ │ │ │ + name, props, invalids = message.get_body()
│ │ │ │ │ + logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids)
│ │ │ │ │ + for inv in invalids:
│ │ │ │ │ + try:
│ │ │ │ │ + reply, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ + 'org.freedesktop.DBus.Properties', 'Get',
│ │ │ │ │ + 'ss', name, inv)
│ │ │ │ │ + except BusError as exc:
│ │ │ │ │ + logger.debug('failed to fetch property %s.%s on %s %s: %s',
│ │ │ │ │ + name, inv, self.name, path, str(exc))
│ │ │ │ │ + continue
│ │ │ │ │ + props[inv] = reply
│ │ │ │ │ + notify = {}
│ │ │ │ │ + notify_update(notify, path, name, props)
│ │ │ │ │ + self.send_json(notify=notify)
│ │ │ │ │ +
│ │ │ │ │ + this_meta = await self.cache.introspect_path(self.bus, self.name, path)
│ │ │ │ │ + if interface_name is not None:
│ │ │ │ │ + interface = this_meta.get(interface_name)
│ │ │ │ │ + this_meta = {interface_name: interface}
│ │ │ │ │ + meta.update(this_meta)
│ │ │ │ │ + if recursive_props:
│ │ │ │ │ + self.add_async_signal_handler(handler,
│ │ │ │ │ + interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ + path_namespace=path)
│ │ │ │ │ + else:
│ │ │ │ │ + self.add_async_signal_handler(handler,
│ │ │ │ │ + interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ + path=path)
│ │ │ │ │ +
│ │ │ │ │ + for name in meta:
│ │ │ │ │ + if name.startswith("org.freedesktop.DBus."):
│ │ │ │ │ + continue
│ │ │ │ │ + try:
│ │ │ │ │ + props, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ + 'org.freedesktop.DBus.Properties',
│ │ │ │ │ + 'GetAll', 's', name)
│ │ │ │ │ + notify_update(notify, path, name, props)
│ │ │ │ │ + except BusError:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + async def do_watch(self, message):
│ │ │ │ │ + watch = message['watch']
│ │ │ │ │ + path = watch.get('path')
│ │ │ │ │ + path_namespace = watch.get('path_namespace')
│ │ │ │ │ + interface_name = watch.get('interface')
│ │ │ │ │ + cookie = message.get('id')
│ │ │ │ │ +
│ │ │ │ │ + path = path or path_namespace
│ │ │ │ │ + recursive = path == path_namespace
│ │ │ │ │ +
│ │ │ │ │ + if path is None or cookie is None:
│ │ │ │ │ + logger.debug('ignored incomplete watch request %s', message)
│ │ │ │ │ + self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie)
│ │ │ │ │ + self.send_json(reply=[], id=cookie)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + meta = {}
│ │ │ │ │ + notify = {}
│ │ │ │ │ + await self.setup_path_watch(path, interface_name, recursive, meta, notify)
│ │ │ │ │ + if recursive:
│ │ │ │ │ + await self.setup_objectmanager_watch(path, interface_name, meta, notify)
│ │ │ │ │ + self.send_json(meta=meta)
│ │ │ │ │ + self.send_json(notify=notify)
│ │ │ │ │ + self.send_json(reply=[], id=message['id'])
│ │ │ │ │ + except BusError as error:
│ │ │ │ │ + logger.debug("do_watch(%s) caught D-Bus error: %s", message, error.message)
│ │ │ │ │ + self.send_json(error=[error.name, [error.message]], id=cookie)
│ │ │ │ │ +
│ │ │ │ │ + async def do_meta(self, message):
│ │ │ │ │ + self.cache.inject(message['meta'])
│ │ │ │ │ +
│ │ │ │ │ + def do_data(self, data):
│ │ │ │ │ + message = json.loads(data)
│ │ │ │ │ + logger.debug('receive dbus request %s %s', self.name, message)
│ │ │ │ │ +
│ │ │ │ │ + if 'call' in message:
│ │ │ │ │ + self.create_task(self.do_call(message))
│ │ │ │ │ + elif 'add-match' in message:
│ │ │ │ │ + self.create_task(self.do_add_match(message))
│ │ │ │ │ + elif 'watch' in message:
│ │ │ │ │ + self.create_task(self.do_watch(message))
│ │ │ │ │ + elif 'meta' in message:
│ │ │ │ │ + self.create_task(self.do_meta(message))
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug('ignored dbus request %s', message)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + def do_close(self):
│ │ │ │ │ + for slot in self.matches:
│ │ │ │ │ + slot.cancel()
│ │ │ │ │ + self.matches = []
│ │ │ │ │ + self.close()
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ + 'cockpit/channels/trivial.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import logging
│ │ │ │ │ +
│ │ │ │ │ +from ..channel import Channel
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class EchoChannel(Channel):
│ │ │ │ │ + payload = 'echo'
│ │ │ │ │ +
│ │ │ │ │ + def do_open(self, options):
│ │ │ │ │ + self.ready()
│ │ │ │ │ +
│ │ │ │ │ + def do_data(self, data):
│ │ │ │ │ + self.send_data(data)
│ │ │ │ │ +
│ │ │ │ │ + def do_done(self):
│ │ │ │ │ + self.done()
│ │ │ │ │ + self.close()
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class NullChannel(Channel):
│ │ │ │ │ + payload = 'null'
│ │ │ │ │ +
│ │ │ │ │ + def do_open(self, options):
│ │ │ │ │ + self.ready()
│ │ │ │ │ +
│ │ │ │ │ + def do_close(self):
│ │ │ │ │ + self.close()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/channels/__init__.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +from .dbus import DBusChannel
│ │ │ │ │ +from .filesystem import FsInfoChannel, FsListChannel, FsReadChannel, FsReplaceChannel, FsWatchChannel
│ │ │ │ │ +from .http import HttpChannel
│ │ │ │ │ +from .metrics import InternalMetricsChannel
│ │ │ │ │ +from .packages import PackagesChannel
│ │ │ │ │ +from .stream import SocketStreamChannel, SubprocessStreamChannel
│ │ │ │ │ +from .trivial import EchoChannel, NullChannel
│ │ │ │ │ +
│ │ │ │ │ +CHANNEL_TYPES = [
│ │ │ │ │ + DBusChannel,
│ │ │ │ │ + EchoChannel,
│ │ │ │ │ + FsInfoChannel,
│ │ │ │ │ + FsListChannel,
│ │ │ │ │ + FsReadChannel,
│ │ │ │ │ + FsReplaceChannel,
│ │ │ │ │ + FsWatchChannel,
│ │ │ │ │ + HttpChannel,
│ │ │ │ │ + InternalMetricsChannel,
│ │ │ │ │ + NullChannel,
│ │ │ │ │ + PackagesChannel,
│ │ │ │ │ + SubprocessStreamChannel,
│ │ │ │ │ + SocketStreamChannel,
│ │ │ │ │ +]
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/channels/packages.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -6415,102 +6387,14 @@
│ │ │ │ │ raise ChannelError('not-found') from error
│ │ │ │ │ except PermissionError as error:
│ │ │ │ │ raise ChannelError('access-denied') from error
│ │ │ │ │ except OSError as error:
│ │ │ │ │ logger.info("Failed to spawn %s: %s", args, str(error))
│ │ │ │ │ raise ChannelError('internal-error') from error
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/channels/trivial.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import logging
│ │ │ │ │ -
│ │ │ │ │ -from ..channel import Channel
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class EchoChannel(Channel):
│ │ │ │ │ - payload = 'echo'
│ │ │ │ │ -
│ │ │ │ │ - def do_open(self, options):
│ │ │ │ │ - self.ready()
│ │ │ │ │ -
│ │ │ │ │ - def do_data(self, data):
│ │ │ │ │ - self.send_data(data)
│ │ │ │ │ -
│ │ │ │ │ - def do_done(self):
│ │ │ │ │ - self.done()
│ │ │ │ │ - self.close()
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class NullChannel(Channel):
│ │ │ │ │ - payload = 'null'
│ │ │ │ │ -
│ │ │ │ │ - def do_open(self, options):
│ │ │ │ │ - self.ready()
│ │ │ │ │ -
│ │ │ │ │ - def do_close(self):
│ │ │ │ │ - self.close()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/channels/__init__.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -from .dbus import DBusChannel
│ │ │ │ │ -from .filesystem import FsInfoChannel, FsListChannel, FsReadChannel, FsReplaceChannel, FsWatchChannel
│ │ │ │ │ -from .http import HttpChannel
│ │ │ │ │ -from .metrics import InternalMetricsChannel
│ │ │ │ │ -from .packages import PackagesChannel
│ │ │ │ │ -from .stream import SocketStreamChannel, SubprocessStreamChannel
│ │ │ │ │ -from .trivial import EchoChannel, NullChannel
│ │ │ │ │ -
│ │ │ │ │ -CHANNEL_TYPES = [
│ │ │ │ │ - DBusChannel,
│ │ │ │ │ - EchoChannel,
│ │ │ │ │ - FsInfoChannel,
│ │ │ │ │ - FsListChannel,
│ │ │ │ │ - FsReadChannel,
│ │ │ │ │ - FsReplaceChannel,
│ │ │ │ │ - FsWatchChannel,
│ │ │ │ │ - HttpChannel,
│ │ │ │ │ - InternalMetricsChannel,
│ │ │ │ │ - NullChannel,
│ │ │ │ │ - PackagesChannel,
│ │ │ │ │ - SubprocessStreamChannel,
│ │ │ │ │ - SocketStreamChannel,
│ │ │ │ │ -]
│ │ │ │ │ -''',
│ │ │ │ │ 'cockpit/channels/http.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -6635,1562 +6519,222 @@
│ │ │ │ │ except (OSError, IOError) as exc:
│ │ │ │ │ raise ChannelError('not-found', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ # Submit request in a thread and handle errors
│ │ │ │ │ try:
│ │ │ │ │ response = await loop.run_in_executor(None, self.request, connection, method, path, headers or {}, body)
│ │ │ │ │ except (http.client.HTTPException, OSError) as exc:
│ │ │ │ │ - raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │ -
│ │ │ │ │ - self.send_control(command='response',
│ │ │ │ │ - status=response.status,
│ │ │ │ │ - reason=response.reason,
│ │ │ │ │ - headers=self.get_headers(response, binary=binary))
│ │ │ │ │ -
│ │ │ │ │ - # Receive the body and finish up
│ │ │ │ │ - try:
│ │ │ │ │ - while True:
│ │ │ │ │ - block = await loop.run_in_executor(None, response.read1, self.BLOCK_SIZE)
│ │ │ │ │ - if not block:
│ │ │ │ │ - break
│ │ │ │ │ - await self.write(block)
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('reading response done')
│ │ │ │ │ - # this returns immediately and does not read anything more, but updates the http.client's
│ │ │ │ │ - # internal state machine to "response done"
│ │ │ │ │ - block = response.read()
│ │ │ │ │ - assert block == b''
│ │ │ │ │ -
│ │ │ │ │ - await loop.run_in_executor(None, connection.close)
│ │ │ │ │ - except (http.client.HTTPException, OSError) as exc:
│ │ │ │ │ - raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │ -
│ │ │ │ │ - self.done()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/data/fail.html': br'''
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ - @@message@@
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
@@message@@
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/data/__init__.py': br'''import sys
│ │ │ │ │ -
│ │ │ │ │ -if sys.version_info >= (3, 11):
│ │ │ │ │ - import importlib.resources
│ │ │ │ │ -
│ │ │ │ │ - def read_cockpit_data_file(filename: str) -> bytes:
│ │ │ │ │ - return (importlib.resources.files('cockpit.data') / filename).read_bytes()
│ │ │ │ │ -
│ │ │ │ │ -else:
│ │ │ │ │ - import importlib.abc
│ │ │ │ │ -
│ │ │ │ │ - def read_cockpit_data_file(filename: str) -> bytes:
│ │ │ │ │ - # https://github.com/python/mypy/issues/4182
│ │ │ │ │ - loader = __loader__ # type: ignore[name-defined]
│ │ │ │ │ - assert isinstance(loader, importlib.abc.ResourceLoader)
│ │ │ │ │ -
│ │ │ │ │ - path = __file__.replace('__init__.py', filename)
│ │ │ │ │ - return loader.get_data(path)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/__init__.py': br'''''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import ctypes
│ │ │ │ │ -import errno
│ │ │ │ │ -import os
│ │ │ │ │ -import re
│ │ │ │ │ -import socket
│ │ │ │ │ -from typing import ClassVar, Iterable, Match, Pattern
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshError(Exception):
│ │ │ │ │ - PATTERN: ClassVar[Pattern]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, match: 'Match | None', stderr: str) -> None:
│ │ │ │ │ - super().__init__(match.group(0) if match is not None else stderr)
│ │ │ │ │ - self.stderr = stderr
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshAuthenticationError(SshError):
│ │ │ │ │ - PATTERN = re.compile(r'^([^:]+): Permission denied \(([^()]+)\)\.$', re.M)
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, match: Match, stderr: str) -> None:
│ │ │ │ │ - super().__init__(match, stderr)
│ │ │ │ │ - self.destination = match.group(1)
│ │ │ │ │ - self.methods = match.group(2).split(',')
│ │ │ │ │ - self.message = match.group(0)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# generic host key error for OSes without KnownHostsCommand support
│ │ │ │ │ -class SshHostKeyError(SshError):
│ │ │ │ │ - PATTERN = re.compile(r'^Host key verification failed.$', re.M)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# specific errors for OSes with KnownHostsCommand
│ │ │ │ │ -class SshUnknownHostKeyError(SshHostKeyError):
│ │ │ │ │ - PATTERN = re.compile(r'^No .* host key is known.*Host key verification failed.$', re.S | re.M)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshChangedHostKeyError(SshHostKeyError):
│ │ │ │ │ - PATTERN = re.compile(r'warning.*remote host identification has changed', re.I)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# Functionality for mapping getaddrinfo()-family error messages to their
│ │ │ │ │ -# equivalent Python exceptions.
│ │ │ │ │ -def make_gaierror_map() -> 'Iterable[tuple[str, int]]':
│ │ │ │ │ - libc = ctypes.CDLL(None)
│ │ │ │ │ - libc.gai_strerror.restype = ctypes.c_char_p
│ │ │ │ │ -
│ │ │ │ │ - for key in dir(socket):
│ │ │ │ │ - if key.startswith('EAI_'):
│ │ │ │ │ - errnum = getattr(socket, key)
│ │ │ │ │ - yield libc.gai_strerror(errnum).decode('utf-8'), errnum
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -gaierror_map = dict(make_gaierror_map())
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# Functionality for passing strerror() error messages to their equivalent
│ │ │ │ │ -# Python exceptions.
│ │ │ │ │ -# There doesn't seem to be an official API for turning an errno into the
│ │ │ │ │ -# correct subtype of OSError, and the list that cpython uses is hidden fairly
│ │ │ │ │ -# deeply inside of the implementation. This is basically copied from the
│ │ │ │ │ -# ADD_ERRNO() lines in _PyExc_InitState in cpython/Objects/exceptions.c
│ │ │ │ │ -oserror_subclass_map = dict((errnum, cls) for cls, errnum in [
│ │ │ │ │ - (BlockingIOError, errno.EAGAIN),
│ │ │ │ │ - (BlockingIOError, errno.EALREADY),
│ │ │ │ │ - (BlockingIOError, errno.EINPROGRESS),
│ │ │ │ │ - (BlockingIOError, errno.EWOULDBLOCK),
│ │ │ │ │ - (BrokenPipeError, errno.EPIPE),
│ │ │ │ │ - (BrokenPipeError, errno.ESHUTDOWN),
│ │ │ │ │ - (ChildProcessError, errno.ECHILD),
│ │ │ │ │ - (ConnectionAbortedError, errno.ECONNABORTED),
│ │ │ │ │ - (ConnectionRefusedError, errno.ECONNREFUSED),
│ │ │ │ │ - (ConnectionResetError, errno.ECONNRESET),
│ │ │ │ │ - (FileExistsError, errno.EEXIST),
│ │ │ │ │ - (FileNotFoundError, errno.ENOENT),
│ │ │ │ │ - (IsADirectoryError, errno.EISDIR),
│ │ │ │ │ - (NotADirectoryError, errno.ENOTDIR),
│ │ │ │ │ - (InterruptedError, errno.EINTR),
│ │ │ │ │ - (PermissionError, errno.EACCES),
│ │ │ │ │ - (PermissionError, errno.EPERM),
│ │ │ │ │ - (ProcessLookupError, errno.ESRCH),
│ │ │ │ │ - (TimeoutError, errno.ETIMEDOUT),
│ │ │ │ │ -])
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_exception_for_ssh_stderr(stderr: str) -> Exception:
│ │ │ │ │ - stderr = stderr.replace('\r\n', '\n') # fix line separators
│ │ │ │ │ -
│ │ │ │ │ - # check for the specific error messages first, then for generic SshHostKeyError
│ │ │ │ │ - for ssh_cls in [SshAuthenticationError, SshChangedHostKeyError, SshUnknownHostKeyError, SshHostKeyError]:
│ │ │ │ │ - match = ssh_cls.PATTERN.search(stderr)
│ │ │ │ │ - if match is not None:
│ │ │ │ │ - return ssh_cls(match, stderr)
│ │ │ │ │ -
│ │ │ │ │ - before, colon, after = stderr.rpartition(':')
│ │ │ │ │ - if colon and after:
│ │ │ │ │ - potential_strerror = after.strip()
│ │ │ │ │ -
│ │ │ │ │ - # DNS lookup errors
│ │ │ │ │ - if potential_strerror in gaierror_map:
│ │ │ │ │ - errnum = gaierror_map[potential_strerror]
│ │ │ │ │ - return socket.gaierror(errnum, stderr)
│ │ │ │ │ -
│ │ │ │ │ - # Network connect errors
│ │ │ │ │ - for errnum in errno.errorcode:
│ │ │ │ │ - if os.strerror(errnum) == potential_strerror:
│ │ │ │ │ - os_cls = oserror_subclass_map.get(errnum, OSError)
│ │ │ │ │ - return os_cls(errnum, stderr)
│ │ │ │ │ -
│ │ │ │ │ - # No match? Generic.
│ │ │ │ │ - return SshError(None, stderr)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging
│ │ │ │ │ -import re
│ │ │ │ │ -from typing import ClassVar, Match, Sequence
│ │ │ │ │ -
│ │ │ │ │ -from .interaction_agent import AskpassHandler
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class AskpassPrompt:
│ │ │ │ │ - """An askpass prompt resulting from a call to ferny-askpass.
│ │ │ │ │ -
│ │ │ │ │ - stderr: the contents of stderr from before ferny-askpass was called.
│ │ │ │ │ - Likely related to previous failed operations.
│ │ │ │ │ - messages: all but the last line of the prompt as handed to ferny-askpass.
│ │ │ │ │ - Usually contains context about the question.
│ │ │ │ │ - prompt: the last line handed to ferny-askpass. The prompt itself.
│ │ │ │ │ - """
│ │ │ │ │ - stderr: str
│ │ │ │ │ - messages: str
│ │ │ │ │ - prompt: str
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, prompt: str, messages: str, stderr: str) -> None:
│ │ │ │ │ - self.stderr = stderr
│ │ │ │ │ - self.messages = messages
│ │ │ │ │ - self.prompt = prompt
│ │ │ │ │ -
│ │ │ │ │ - def reply(self, response: str) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - async def handle_via(self, responder: 'SshAskpassResponder') -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - response = await self.dispatch(responder)
│ │ │ │ │ - if response is not None:
│ │ │ │ │ - self.reply(response)
│ │ │ │ │ - finally:
│ │ │ │ │ - self.close()
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SSHAskpassPrompt(AskpassPrompt):
│ │ │ │ │ - # The valid answers to prompts of this type. If this is None then any
│ │ │ │ │ - # answer is permitted. If it's a sequence then only answers from the
│ │ │ │ │ - # sequence are permitted. If it's an empty sequence, then no answer is
│ │ │ │ │ - # permitted (ie: the askpass callback should never return).
│ │ │ │ │ - answers: 'ClassVar[Sequence[str] | None]' = None
│ │ │ │ │ -
│ │ │ │ │ - # Patterns to capture. `_pattern` *must* match.
│ │ │ │ │ - _pattern: ClassVar[str]
│ │ │ │ │ - # `_extra_patterns` can fill in extra class attributes if they match.
│ │ │ │ │ - _extra_patterns: ClassVar[Sequence[str]] = ()
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None:
│ │ │ │ │ - super().__init__(prompt, messages, stderr)
│ │ │ │ │ - self.__dict__.update(match.groupdict())
│ │ │ │ │ -
│ │ │ │ │ - for pattern in self._extra_patterns:
│ │ │ │ │ - extra_match = re.search(with_helpers(pattern), messages, re.M)
│ │ │ │ │ - if extra_match is not None:
│ │ │ │ │ - self.__dict__.update(extra_match.groupdict())
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# Specific prompts
│ │ │ │ │ -HELPERS = {
│ │ │ │ │ - "%{algorithm}": r"(?P\b[-\w]+\b)",
│ │ │ │ │ - "%{filename}": r"(?P.+)",
│ │ │ │ │ - "%{fingerprint}": r"(?PSHA256:[0-9A-Za-z+/]{43})",
│ │ │ │ │ - "%{hostname}": r"(?P[^ @']+)",
│ │ │ │ │ - "%{pkcs11_id}": r"(?P.+)",
│ │ │ │ │ - "%{username}": r"(?P[^ @']+)",
│ │ │ │ │ -}
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshPasswordPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"%{username}@%{hostname}'s password: "
│ │ │ │ │ - username: 'str | None' = None
│ │ │ │ │ - hostname: 'str | None' = None
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_password_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshPassphrasePrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Enter passphrase for key '%{filename}': "
│ │ │ │ │ - filename: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_passphrase_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshFIDOPINPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Enter PIN for %{algorithm} key %{filename}: "
│ │ │ │ │ - algorithm: str
│ │ │ │ │ - filename: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_fido_pin_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshFIDOUserPresencePrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Confirm user presence for key %{algorithm} %{fingerprint}"
│ │ │ │ │ - answers = ()
│ │ │ │ │ - algorithm: str
│ │ │ │ │ - fingerprint: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_fido_user_presence_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshPKCS11PINPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Enter PIN for '%{pkcs11_id}': "
│ │ │ │ │ - pkcs11_id: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_pkcs11_pin_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshHostKeyPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Are you sure you want to continue connecting \(yes/no(/\[fingerprint\])?\)\? "
│ │ │ │ │ - _extra_patterns = [
│ │ │ │ │ - r"%{fingerprint}[.]$",
│ │ │ │ │ - r"^%{algorithm} key fingerprint is",
│ │ │ │ │ - r"^The fingerprint for the %{algorithm} key sent by the remote host is$"
│ │ │ │ │ - ]
│ │ │ │ │ - answers = ('yes', 'no')
│ │ │ │ │ - algorithm: str
│ │ │ │ │ - fingerprint: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_host_key_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def with_helpers(pattern: str) -> str:
│ │ │ │ │ - for name, helper in HELPERS.items():
│ │ │ │ │ - pattern = pattern.replace(name, helper)
│ │ │ │ │ -
│ │ │ │ │ - assert '%{' not in pattern
│ │ │ │ │ - return pattern
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt:
│ │ │ │ │ - classes = [
│ │ │ │ │ - SshFIDOPINPrompt,
│ │ │ │ │ - SshFIDOUserPresencePrompt,
│ │ │ │ │ - SshHostKeyPrompt,
│ │ │ │ │ - SshPKCS11PINPrompt,
│ │ │ │ │ - SshPassphrasePrompt,
│ │ │ │ │ - SshPasswordPrompt,
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - # The last line is the line after the last newline character, excluding the
│ │ │ │ │ - # optional final newline character. eg: "x\ny\nLAST\n" or "x\ny\nLAST"
│ │ │ │ │ - second_last_newline = string.rfind('\n', 0, -1)
│ │ │ │ │ - if second_last_newline >= 0:
│ │ │ │ │ - last_line = string[second_last_newline + 1:]
│ │ │ │ │ - extras = string[:second_last_newline + 1]
│ │ │ │ │ - else:
│ │ │ │ │ - last_line = string
│ │ │ │ │ - extras = ''
│ │ │ │ │ -
│ │ │ │ │ - for cls in classes:
│ │ │ │ │ - pattern = with_helpers(cls._pattern)
│ │ │ │ │ - match = re.fullmatch(pattern, last_line)
│ │ │ │ │ - if match is not None:
│ │ │ │ │ - return cls(last_line, extras, stderr, match)
│ │ │ │ │ -
│ │ │ │ │ - return AskpassPrompt(last_line, extras, stderr)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshAskpassResponder(AskpassHandler):
│ │ │ │ │ - async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None':
│ │ │ │ │ - return await categorize_ssh_prompt(prompt, stderr).dispatch(self)
│ │ │ │ │ -
│ │ │ │ │ - async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None':
│ │ │ │ │ - # Default fallback for unrecognised message types: unimplemented
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/session.py': r'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import ctypes
│ │ │ │ │ -import functools
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import shlex
│ │ │ │ │ -import signal
│ │ │ │ │ -import subprocess
│ │ │ │ │ -import tempfile
│ │ │ │ │ -from typing import Mapping, Sequence
│ │ │ │ │ -
│ │ │ │ │ -from . import ssh_errors
│ │ │ │ │ -from .interaction_agent import InteractionAgent, InteractionError, InteractionHandler, write_askpass_to_tmpdir
│ │ │ │ │ -
│ │ │ │ │ -prctl = ctypes.cdll.LoadLibrary('libc.so.6').prctl
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -PR_SET_PDEATHSIG = 1
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -@functools.lru_cache()
│ │ │ │ │ -def has_feature(feature: str, teststr: str = 'x') -> bool:
│ │ │ │ │ - try:
│ │ │ │ │ - subprocess.check_output(['ssh', f'-o{feature} {teststr}', '-G', 'nonexisting'], stderr=subprocess.DEVNULL)
│ │ │ │ │ - return True
│ │ │ │ │ - except subprocess.CalledProcessError:
│ │ │ │ │ - return False
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SubprocessContext:
│ │ │ │ │ - def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:
│ │ │ │ │ - """Return the args required to launch a process in the given context.
│ │ │ │ │ -
│ │ │ │ │ - For example, this might return a vector with
│ │ │ │ │ - ["sudo"]
│ │ │ │ │ - or
│ │ │ │ │ - ["flatpak-spawn", "--host"]
│ │ │ │ │ - prepended.
│ │ │ │ │ -
│ │ │ │ │ - It is also possible that more substantial changes may be performed.
│ │ │ │ │ -
│ │ │ │ │ - This function is not permitted to modify its argument, although it may
│ │ │ │ │ - (optionally) return it unmodified, if no changes are required.
│ │ │ │ │ - """
│ │ │ │ │ - return args
│ │ │ │ │ -
│ │ │ │ │ - def wrap_subprocess_env(self, env: Mapping[str, str]) -> Mapping[str, str]:
│ │ │ │ │ - """Return the envp required to launch a process in the given context.
│ │ │ │ │ -
│ │ │ │ │ - For example, this might set the "SUDO_ASKPASS" environment variable, if
│ │ │ │ │ - needed.
│ │ │ │ │ -
│ │ │ │ │ - As with wrap_subprocess_args(), this function is not permitted to
│ │ │ │ │ - modify its argument, although it may (optionally) return it unmodified
│ │ │ │ │ - if no changes are required.
│ │ │ │ │ - """
│ │ │ │ │ - return env
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Session(SubprocessContext, InteractionHandler):
│ │ │ │ │ - # Set after .connect() called, even if failed
│ │ │ │ │ - _controldir: 'tempfile.TemporaryDirectory | None' = None
│ │ │ │ │ - _controlsock: 'str | None' = None
│ │ │ │ │ -
│ │ │ │ │ - # Set if connected, else None
│ │ │ │ │ - _process: 'asyncio.subprocess.Process | None' = None
│ │ │ │ │ -
│ │ │ │ │ - async def connect(self,
│ │ │ │ │ - destination: str,
│ │ │ │ │ - handle_host_key: bool = False,
│ │ │ │ │ - configfile: 'str | None' = None,
│ │ │ │ │ - identity_file: 'str | None' = None,
│ │ │ │ │ - login_name: 'str | None' = None,
│ │ │ │ │ - options: 'Mapping[str, str] | None' = None,
│ │ │ │ │ - pkcs11: 'str | None' = None,
│ │ │ │ │ - port: 'int | None' = None,
│ │ │ │ │ - interaction_responder: 'InteractionHandler | None' = None) -> None:
│ │ │ │ │ - rundir = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/run'), 'ferny')
│ │ │ │ │ - os.makedirs(rundir, exist_ok=True)
│ │ │ │ │ - self._controldir = tempfile.TemporaryDirectory(dir=rundir)
│ │ │ │ │ - self._controlsock = f'{self._controldir.name}/socket'
│ │ │ │ │ -
│ │ │ │ │ - # In general, we can't guarantee an accessible and executable version
│ │ │ │ │ - # of this file, but since it's small and we're making a temporary
│ │ │ │ │ - # directory anyway, let's just copy it into place and use it from
│ │ │ │ │ - # there.
│ │ │ │ │ - askpass_path = write_askpass_to_tmpdir(self._controldir.name)
│ │ │ │ │ -
│ │ │ │ │ - env = dict(os.environ)
│ │ │ │ │ - env['SSH_ASKPASS'] = askpass_path
│ │ │ │ │ - env['SSH_ASKPASS_REQUIRE'] = 'force'
│ │ │ │ │ - # old SSH doesn't understand SSH_ASKPASS_REQUIRE and guesses based on DISPLAY instead
│ │ │ │ │ - env['DISPLAY'] = '-'
│ │ │ │ │ -
│ │ │ │ │ - args = [
│ │ │ │ │ - '-M',
│ │ │ │ │ - '-N',
│ │ │ │ │ - '-S', self._controlsock,
│ │ │ │ │ - '-o', 'PermitLocalCommand=yes',
│ │ │ │ │ - '-o', f'LocalCommand={askpass_path}',
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - if configfile is not None:
│ │ │ │ │ - args.append(f'-F{configfile}')
│ │ │ │ │ -
│ │ │ │ │ - if identity_file is not None:
│ │ │ │ │ - args.append(f'-i{identity_file}')
│ │ │ │ │ -
│ │ │ │ │ - if options is not None:
│ │ │ │ │ - for key in options: # Note: Mapping may not have .items()
│ │ │ │ │ - args.append(f'-o{key} {options[key]}')
│ │ │ │ │ -
│ │ │ │ │ - if pkcs11 is not None:
│ │ │ │ │ - args.append(f'-I{pkcs11}')
│ │ │ │ │ -
│ │ │ │ │ - if port is not None:
│ │ │ │ │ - args.append(f'-p{port}')
│ │ │ │ │ -
│ │ │ │ │ - if login_name is not None:
│ │ │ │ │ - args.append(f'-l{login_name}')
│ │ │ │ │ -
│ │ │ │ │ - if handle_host_key and has_feature('KnownHostsCommand'):
│ │ │ │ │ - args.extend([
│ │ │ │ │ - '-o', f'KnownHostsCommand={askpass_path} %I %H %t %K %f',
│ │ │ │ │ - '-o', 'StrictHostKeyChecking=yes',
│ │ │ │ │ - ])
│ │ │ │ │ -
│ │ │ │ │ - agent = InteractionAgent([interaction_responder] if interaction_responder is not None else [])
│ │ │ │ │ -
│ │ │ │ │ - # SSH_ASKPASS_REQUIRE is not generally available, so use setsid
│ │ │ │ │ - process = await asyncio.create_subprocess_exec(
│ │ │ │ │ - *('/usr/bin/ssh', *args, destination), env=env,
│ │ │ │ │ - start_new_session=True, stdin=asyncio.subprocess.DEVNULL,
│ │ │ │ │ - stdout=asyncio.subprocess.DEVNULL, stderr=agent, # type: ignore
│ │ │ │ │ - preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL))
│ │ │ │ │ -
│ │ │ │ │ - # This is tricky: we need to clean up the subprocess, but only in case
│ │ │ │ │ - # if failure. Otherwise, we keep it around.
│ │ │ │ │ - try:
│ │ │ │ │ - await agent.communicate()
│ │ │ │ │ - assert os.path.exists(self._controlsock)
│ │ │ │ │ - self._process = process
│ │ │ │ │ - except InteractionError as exc:
│ │ │ │ │ - await process.wait()
│ │ │ │ │ - raise ssh_errors.get_exception_for_ssh_stderr(str(exc)) from None
│ │ │ │ │ - except BaseException:
│ │ │ │ │ - # If we get here because the InteractionHandler raised an
│ │ │ │ │ - # exception then SSH might still be running, and may even attempt
│ │ │ │ │ - # further interactions (ie: 2nd attempt for password). We already
│ │ │ │ │ - # have our exception and don't need any more info. Kill it.
│ │ │ │ │ - try:
│ │ │ │ │ - process.kill()
│ │ │ │ │ - except ProcessLookupError:
│ │ │ │ │ - pass # already exited? good.
│ │ │ │ │ - await process.wait()
│ │ │ │ │ - raise
│ │ │ │ │ -
│ │ │ │ │ - def is_connected(self) -> bool:
│ │ │ │ │ - return self._process is not None
│ │ │ │ │ -
│ │ │ │ │ - async def wait(self) -> None:
│ │ │ │ │ - assert self._process is not None
│ │ │ │ │ - await self._process.wait()
│ │ │ │ │ -
│ │ │ │ │ - def exit(self) -> None:
│ │ │ │ │ - assert self._process is not None
│ │ │ │ │ - self._process.terminate()
│ │ │ │ │ -
│ │ │ │ │ - async def disconnect(self) -> None:
│ │ │ │ │ - self.exit()
│ │ │ │ │ - await self.wait()
│ │ │ │ │ -
│ │ │ │ │ - # Launching of processes
│ │ │ │ │ - def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:
│ │ │ │ │ - assert self._controlsock is not None
│ │ │ │ │ - # 1. We specify the hostname as the empty string: it will be ignored
│ │ │ │ │ - # when ssh is trying to use the control socket, but in case the
│ │ │ │ │ - # socket has stopped working, ssh will try to fall back to directly
│ │ │ │ │ - # connecting, in which case an empty hostname will prevent that.
│ │ │ │ │ - # 2. We need to quote the arguments — ssh will paste them together
│ │ │ │ │ - # using only spaces, executing the result using the user's shell.
│ │ │ │ │ - return ('ssh', '-S', self._controlsock, '', *map(shlex.quote, args))
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3
│ │ │ │ │ -
│ │ │ │ │ -import array
│ │ │ │ │ -import io
│ │ │ │ │ -import os
│ │ │ │ │ -import socket
│ │ │ │ │ -import sys
│ │ │ │ │ -from typing import Sequence
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None:
│ │ │ │ │ - cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')]
│ │ │ │ │ -
│ │ │ │ │ - with cmd_write:
│ │ │ │ │ - with cmd_read:
│ │ │ │ │ - with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock:
│ │ │ │ │ - fd_array = array.array('i', (cmd_read.fileno(), *fds))
│ │ │ │ │ - sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)])
│ │ │ │ │ -
│ │ │ │ │ - cmd_write.write(repr((command, args)))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int:
│ │ │ │ │ - ours, theirs = socket.socketpair()
│ │ │ │ │ -
│ │ │ │ │ - with theirs:
│ │ │ │ │ - command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd))
│ │ │ │ │ -
│ │ │ │ │ - with ours:
│ │ │ │ │ - return int(ours.recv(16) or b'1')
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def main() -> None:
│ │ │ │ │ - if len(sys.argv) == 1:
│ │ │ │ │ - command(2, 'ferny.end', [])
│ │ │ │ │ - else:
│ │ │ │ │ - sys.exit(askpass(2, 1, sys.argv, dict(os.environ)))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import contextlib
│ │ │ │ │ -import logging
│ │ │ │ │ -import typing
│ │ │ │ │ -from typing import Any, Callable, Iterable, Sequence, TypeVar
│ │ │ │ │ -
│ │ │ │ │ -from .interaction_agent import InteractionAgent, InteractionHandler, get_running_loop
│ │ │ │ │ -from .ssh_errors import get_exception_for_ssh_stderr
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -P = TypeVar('P', bound=asyncio.Protocol)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SubprocessError(Exception):
│ │ │ │ │ - returncode: int
│ │ │ │ │ - stderr: str
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, returncode: int, stderr: str) -> None:
│ │ │ │ │ - super().__init__(returncode, stderr)
│ │ │ │ │ - self.returncode = returncode
│ │ │ │ │ - self.stderr = stderr
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class FernyTransport(asyncio.Transport, asyncio.SubprocessProtocol):
│ │ │ │ │ - _agent: InteractionAgent
│ │ │ │ │ - _exec_task: 'asyncio.Task[tuple[asyncio.SubprocessTransport, FernyTransport]]'
│ │ │ │ │ - _is_ssh: bool
│ │ │ │ │ - _protocol: asyncio.Protocol
│ │ │ │ │ - _protocol_disconnected: bool = False
│ │ │ │ │ -
│ │ │ │ │ - # These get initialized in connection_made() and once set, never get unset.
│ │ │ │ │ - _subprocess_transport: 'asyncio.SubprocessTransport | None' = None
│ │ │ │ │ - _stdin_transport: 'asyncio.WriteTransport | None' = None
│ │ │ │ │ - _stdout_transport: 'asyncio.ReadTransport | None' = None
│ │ │ │ │ -
│ │ │ │ │ - # We record events that might build towards a connection termination here
│ │ │ │ │ - # and consider them from _consider_disconnect() in order to try to get the
│ │ │ │ │ - # best possible Exception for the protocol, rather than just taking the
│ │ │ │ │ - # first one (which is likely to be somewhat random).
│ │ │ │ │ - _exception: 'Exception | None' = None
│ │ │ │ │ - _stderr_output: 'str | None' = None
│ │ │ │ │ - _returncode: 'int | None' = None
│ │ │ │ │ - _transport_disconnected: bool = False
│ │ │ │ │ - _closed: bool = False
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def spawn(
│ │ │ │ │ - cls: 'type[typing.Self]',
│ │ │ │ │ - protocol_factory: Callable[[], P],
│ │ │ │ │ - args: Sequence[str],
│ │ │ │ │ - loop: 'asyncio.AbstractEventLoop | None' = None,
│ │ │ │ │ - interaction_handlers: Sequence[InteractionHandler] = (),
│ │ │ │ │ - is_ssh: bool = True,
│ │ │ │ │ - **kwargs: Any
│ │ │ │ │ - ) -> 'tuple[typing.Self, P]':
│ │ │ │ │ - """Connects a FernyTransport to a protocol, using the given command.
│ │ │ │ │ -
│ │ │ │ │ - This spawns an external command and connects the stdin and stdout of
│ │ │ │ │ - the command to the protocol returned by the factory.
│ │ │ │ │ -
│ │ │ │ │ - An instance of ferny.InteractionAgent is created and attached to the
│ │ │ │ │ - stderr of the spawned process, using the provided handlers. It is the
│ │ │ │ │ - responsibility of the caller to ensure that:
│ │ │ │ │ - - a `ferny-askpass` client program is installed somewhere; and
│ │ │ │ │ - - any relevant command-line arguments or environment variables are
│ │ │ │ │ - passed correctly to the program to be spawned
│ │ │ │ │ -
│ │ │ │ │ - This function returns immediately and never raises exceptions, assuming
│ │ │ │ │ - all preconditions are met.
│ │ │ │ │ -
│ │ │ │ │ - If spawning the process fails then connection_lost() will be
│ │ │ │ │ - called with the relevant OSError, even before connection_made() is
│ │ │ │ │ - called. This is somewhat non-standard behaviour, but is the easiest
│ │ │ │ │ - way to report these errors without making this function async.
│ │ │ │ │ -
│ │ │ │ │ - Once the process is successfully executed, connection_made() will be
│ │ │ │ │ - called and the transport can be used as normal. connection_lost() will
│ │ │ │ │ - be called if the process exits or another error occurs.
│ │ │ │ │ -
│ │ │ │ │ - The return value of this function is the transport, but it exists in a
│ │ │ │ │ - semi-initialized state. You can call .close() on it, but nothing else.
│ │ │ │ │ - Once .connection_made() is called, you can call all the other
│ │ │ │ │ - functions.
│ │ │ │ │ -
│ │ │ │ │ - After you call this function, `.connection_lost()` will be called on
│ │ │ │ │ - your Protocol, exactly once, no matter what. Until that happens, you
│ │ │ │ │ - are responsible for holding a reference to the returned transport.
│ │ │ │ │ -
│ │ │ │ │ - :param args: the full argv of the command to spawn
│ │ │ │ │ - :param loop: the event loop to use. If none is provided, we use the
│ │ │ │ │ - one which is (read: must be) currently running.
│ │ │ │ │ - :param interaction_handlers: the handlers passed to the
│ │ │ │ │ - InteractionAgent
│ │ │ │ │ - :param is_ssh: whether we should attempt to interpret stderr as ssh
│ │ │ │ │ - error messages
│ │ │ │ │ - :param kwargs: anything else is passed through to `subprocess_exec()`
│ │ │ │ │ - :returns: the usual `(Transport, Protocol)` pair
│ │ │ │ │ - """
│ │ │ │ │ - logger.debug('spawn(%r, %r, %r)', cls, protocol_factory, args)
│ │ │ │ │ -
│ │ │ │ │ - protocol = protocol_factory()
│ │ │ │ │ - self = cls(protocol)
│ │ │ │ │ - self._is_ssh = is_ssh
│ │ │ │ │ -
│ │ │ │ │ - if loop is None:
│ │ │ │ │ - loop = get_running_loop()
│ │ │ │ │ -
│ │ │ │ │ - self._agent = InteractionAgent(interaction_handlers, loop, self._interaction_completed)
│ │ │ │ │ - kwargs.setdefault('stderr', self._agent.fileno())
│ │ │ │ │ -
│ │ │ │ │ - # As of Python 3.12 this isn't really asynchronous (since it uses the
│ │ │ │ │ - # subprocess module, which blocks while waiting for the exec() to
│ │ │ │ │ - # complete in the child), but we have to deal with the complication of
│ │ │ │ │ - # the async interface anyway. Since we, ourselves, want to export a
│ │ │ │ │ - # non-async interface, that means that we need a task here and a
│ │ │ │ │ - # bottom-half handler below.
│ │ │ │ │ - self._exec_task = loop.create_task(loop.subprocess_exec(lambda: self, *args, **kwargs))
│ │ │ │ │ -
│ │ │ │ │ - def exec_completed(task: asyncio.Task) -> None:
│ │ │ │ │ - logger.debug('exec_completed(%r, %r)', self, task)
│ │ │ │ │ - assert task is self._exec_task
│ │ │ │ │ - try:
│ │ │ │ │ - transport, me = task.result()
│ │ │ │ │ - assert me is self
│ │ │ │ │ - logger.debug(' success.')
│ │ │ │ │ - except asyncio.CancelledError:
│ │ │ │ │ - return # in that case, do nothing
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - logger.debug(' OSError %r', exc)
│ │ │ │ │ - self.close(exc)
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - # Our own .connection_made() handler should have gotten called by
│ │ │ │ │ - # now. Make sure everything got filled in properly.
│ │ │ │ │ - assert self._subprocess_transport is transport
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - assert self._stdout_transport is not None
│ │ │ │ │ -
│ │ │ │ │ - # Ask the InteractionAgent to start processing stderr.
│ │ │ │ │ - self._agent.start()
│ │ │ │ │ -
│ │ │ │ │ - self._exec_task.add_done_callback(exec_completed)
│ │ │ │ │ -
│ │ │ │ │ - return self, protocol
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, protocol: asyncio.Protocol) -> None:
│ │ │ │ │ - self._protocol = protocol
│ │ │ │ │ -
│ │ │ │ │ - def _consider_disconnect(self) -> None:
│ │ │ │ │ - logger.debug('_consider_disconnect(%r)', self)
│ │ │ │ │ - # We cannot disconnect as long as any of these three things are happening
│ │ │ │ │ - if not self._exec_task.done():
│ │ │ │ │ - logger.debug(' exec_task still running %r', self._exec_task)
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - if self._subprocess_transport is not None and not self._transport_disconnected:
│ │ │ │ │ - logger.debug(' transport still connected %r', self._subprocess_transport)
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - if self._stderr_output is None:
│ │ │ │ │ - logger.debug(' agent still running')
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - # All conditions for disconnection are satisfied.
│ │ │ │ │ - if self._protocol_disconnected:
│ │ │ │ │ - logger.debug(' already disconnected')
│ │ │ │ │ - return
│ │ │ │ │ - self._protocol_disconnected = True
│ │ │ │ │ -
│ │ │ │ │ - # Now we just need to determine what we report to the protocol...
│ │ │ │ │ - if self._exception is not None:
│ │ │ │ │ - # If we got an exception reported, that's our reason for closing.
│ │ │ │ │ - logger.debug(' disconnect with exception %r', self._exception)
│ │ │ │ │ - self._protocol.connection_lost(self._exception)
│ │ │ │ │ - elif self._returncode == 0 or self._closed:
│ │ │ │ │ - # If we called close() or have a zero return status, that's a clean
│ │ │ │ │ - # exit, regardless of noise that might have landed in stderr.
│ │ │ │ │ - logger.debug(' clean disconnect')
│ │ │ │ │ - self._protocol.connection_lost(None)
│ │ │ │ │ - elif self._is_ssh and self._returncode == 255:
│ │ │ │ │ - # This is an error code due to an SSH failure. Try to interpret it.
│ │ │ │ │ - logger.debug(' disconnect with ssh error %r', self._stderr_output)
│ │ │ │ │ - self._protocol.connection_lost(get_exception_for_ssh_stderr(self._stderr_output))
│ │ │ │ │ - else:
│ │ │ │ │ - # Otherwise, report the stderr text and return code.
│ │ │ │ │ - logger.debug(' disconnect with exit code %r, stderr %r', self._returncode, self._stderr_output)
│ │ │ │ │ - # We surely have _returncode set here, since otherwise:
│ │ │ │ │ - # - exec_task failed with an exception (which we handle above); or
│ │ │ │ │ - # - we're still connected...
│ │ │ │ │ - assert self._returncode is not None
│ │ │ │ │ - self._protocol.connection_lost(SubprocessError(self._returncode, self._stderr_output))
│ │ │ │ │ -
│ │ │ │ │ - def _interaction_completed(self, future: 'asyncio.Future[str]') -> None:
│ │ │ │ │ - logger.debug('_interaction_completed(%r, %r)', self, future)
│ │ │ │ │ - try:
│ │ │ │ │ - self._stderr_output = future.result()
│ │ │ │ │ - logger.debug(' stderr: %r', self._stderr_output)
│ │ │ │ │ - except Exception as exc:
│ │ │ │ │ - logger.debug(' exception: %r', exc)
│ │ │ │ │ - self._stderr_output = '' # we need to set this in order to complete
│ │ │ │ │ - self.close(exc)
│ │ │ │ │ -
│ │ │ │ │ - self._consider_disconnect()
│ │ │ │ │ -
│ │ │ │ │ - # BaseProtocol implementation
│ │ │ │ │ - def connection_made(self, transport: asyncio.BaseTransport) -> None:
│ │ │ │ │ - logger.debug('connection_made(%r, %r)', self, transport)
│ │ │ │ │ - assert isinstance(transport, asyncio.SubprocessTransport)
│ │ │ │ │ - self._subprocess_transport = transport
│ │ │ │ │ -
│ │ │ │ │ - stdin_transport = transport.get_pipe_transport(0)
│ │ │ │ │ - assert isinstance(stdin_transport, asyncio.WriteTransport)
│ │ │ │ │ - self._stdin_transport = stdin_transport
│ │ │ │ │ -
│ │ │ │ │ - stdout_transport = transport.get_pipe_transport(1)
│ │ │ │ │ - assert isinstance(stdout_transport, asyncio.ReadTransport)
│ │ │ │ │ - self._stdout_transport = stdout_transport
│ │ │ │ │ -
│ │ │ │ │ - stderr_transport = transport.get_pipe_transport(2)
│ │ │ │ │ - assert stderr_transport is None
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('calling connection_made(%r, %r)', self, self._protocol)
│ │ │ │ │ - self._protocol.connection_made(self)
│ │ │ │ │ -
│ │ │ │ │ - def connection_lost(self, exc: 'Exception | None') -> None:
│ │ │ │ │ - logger.debug('connection_lost(%r, %r)', self, exc)
│ │ │ │ │ - if self._exception is None:
│ │ │ │ │ - self._exception = exc
│ │ │ │ │ - self._transport_disconnected = True
│ │ │ │ │ - self._consider_disconnect()
│ │ │ │ │ -
│ │ │ │ │ - # SubprocessProtocol implementation
│ │ │ │ │ - def pipe_data_received(self, fd: int, data: bytes) -> None:
│ │ │ │ │ - logger.debug('pipe_data_received(%r, %r, %r)', self, fd, len(data))
│ │ │ │ │ - assert fd == 1 # stderr is handled separately
│ │ │ │ │ - self._protocol.data_received(data)
│ │ │ │ │ -
│ │ │ │ │ - def pipe_connection_lost(self, fd: int, exc: 'Exception | None') -> None:
│ │ │ │ │ - logger.debug('pipe_connection_lost(%r, %r, %r)', self, fd, exc)
│ │ │ │ │ - assert fd in (0, 1) # stderr is handled separately
│ │ │ │ │ -
│ │ │ │ │ - # We treat this as a clean close
│ │ │ │ │ - if isinstance(exc, BrokenPipeError):
│ │ │ │ │ - exc = None
│ │ │ │ │ -
│ │ │ │ │ - # Record serious errors to propagate them to the protocol
│ │ │ │ │ - # If this is a clean exit on stdout, report an EOF
│ │ │ │ │ - if exc is not None:
│ │ │ │ │ - self.close(exc)
│ │ │ │ │ - elif fd == 1 and not self._closed:
│ │ │ │ │ - if not self._protocol.eof_received():
│ │ │ │ │ - self.close()
│ │ │ │ │ -
│ │ │ │ │ - def process_exited(self) -> None:
│ │ │ │ │ - logger.debug('process_exited(%r)', self)
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - self._returncode = self._subprocess_transport.get_returncode()
│ │ │ │ │ - logger.debug(' ._returncode = %r', self._returncode)
│ │ │ │ │ - self._agent.force_completion()
│ │ │ │ │ -
│ │ │ │ │ - def pause_writing(self) -> None:
│ │ │ │ │ - logger.debug('pause_writing(%r)', self)
│ │ │ │ │ - self._protocol.pause_writing()
│ │ │ │ │ -
│ │ │ │ │ - def resume_writing(self) -> None:
│ │ │ │ │ - logger.debug('resume_writing(%r)', self)
│ │ │ │ │ - self._protocol.resume_writing()
│ │ │ │ │ -
│ │ │ │ │ - # Transport implementation. Most of this is straight delegation.
│ │ │ │ │ - def close(self, exc: 'Exception | None' = None) -> None:
│ │ │ │ │ - logger.debug('close(%r, %r)', self, exc)
│ │ │ │ │ - self._closed = True
│ │ │ │ │ - if self._exception is None:
│ │ │ │ │ - logger.debug(' setting exception %r', exc)
│ │ │ │ │ - self._exception = exc
│ │ │ │ │ - if not self._exec_task.done():
│ │ │ │ │ - logger.debug(' cancelling _exec_task')
│ │ │ │ │ - self._exec_task.cancel()
│ │ │ │ │ - if self._subprocess_transport is not None:
│ │ │ │ │ - logger.debug(' closing _subprocess_transport')
│ │ │ │ │ - # https://github.com/python/cpython/issues/112800
│ │ │ │ │ - with contextlib.suppress(PermissionError):
│ │ │ │ │ - self._subprocess_transport.close()
│ │ │ │ │ - self._agent.force_completion()
│ │ │ │ │ -
│ │ │ │ │ - def is_closing(self) -> bool:
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - return self._subprocess_transport.is_closing()
│ │ │ │ │ -
│ │ │ │ │ - def get_extra_info(self, name: str, default: object = None) -> object:
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - return self._subprocess_transport.get_extra_info(name, default)
│ │ │ │ │ -
│ │ │ │ │ - def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:
│ │ │ │ │ - assert isinstance(protocol, asyncio.Protocol)
│ │ │ │ │ - self._protocol = protocol
│ │ │ │ │ -
│ │ │ │ │ - def get_protocol(self) -> asyncio.Protocol:
│ │ │ │ │ - return self._protocol
│ │ │ │ │ -
│ │ │ │ │ - def is_reading(self) -> bool:
│ │ │ │ │ - assert self._stdout_transport is not None
│ │ │ │ │ - try:
│ │ │ │ │ - return self._stdout_transport.is_reading()
│ │ │ │ │ - except NotImplementedError:
│ │ │ │ │ - # This is (incorrectly) unimplemented before Python 3.11
│ │ │ │ │ - return not self._stdout_transport._paused # type:ignore[attr-defined]
│ │ │ │ │ - except AttributeError:
│ │ │ │ │ - # ...and in Python 3.6 it's even worse
│ │ │ │ │ - try:
│ │ │ │ │ - selector = self._stdout_transport._loop._selector # type:ignore[attr-defined]
│ │ │ │ │ - selector.get_key(self._stdout_transport._fileno) # type:ignore[attr-defined]
│ │ │ │ │ - return True
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - return False
│ │ │ │ │ -
│ │ │ │ │ - def pause_reading(self) -> None:
│ │ │ │ │ - assert self._stdout_transport is not None
│ │ │ │ │ - self._stdout_transport.pause_reading()
│ │ │ │ │ -
│ │ │ │ │ - def resume_reading(self) -> None:
│ │ │ │ │ - assert self._stdout_transport is not None
│ │ │ │ │ - self._stdout_transport.resume_reading()
│ │ │ │ │ -
│ │ │ │ │ - def abort(self) -> None:
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - self._stdin_transport.abort()
│ │ │ │ │ - self._subprocess_transport.kill()
│ │ │ │ │ -
│ │ │ │ │ - def can_write_eof(self) -> bool:
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - return self._stdin_transport.can_write_eof() # will always be True
│ │ │ │ │ -
│ │ │ │ │ - def get_write_buffer_size(self) -> int:
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - return self._stdin_transport.get_write_buffer_size()
│ │ │ │ │ -
│ │ │ │ │ - def get_write_buffer_limits(self) -> 'tuple[int, int]':
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - return self._stdin_transport.get_write_buffer_limits()
│ │ │ │ │ -
│ │ │ │ │ - def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None:
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - return self._stdin_transport.set_write_buffer_limits(high, low)
│ │ │ │ │ -
│ │ │ │ │ - def write(self, data: bytes) -> None:
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - return self._stdin_transport.write(data)
│ │ │ │ │ -
│ │ │ │ │ - def writelines(self, list_of_data: Iterable[bytes]) -> None:
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - return self._stdin_transport.writelines(list_of_data)
│ │ │ │ │ -
│ │ │ │ │ - def write_eof(self) -> None:
│ │ │ │ │ - assert self._stdin_transport is not None
│ │ │ │ │ - return self._stdin_transport.write_eof()
│ │ │ │ │ -
│ │ │ │ │ - # We don't really implement SubprocessTransport, but provide these as
│ │ │ │ │ - # "extras" to our user.
│ │ │ │ │ - def get_pid(self) -> int:
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - return self._subprocess_transport.get_pid()
│ │ │ │ │ -
│ │ │ │ │ - def get_returncode(self) -> 'int | None':
│ │ │ │ │ - return self._returncode
│ │ │ │ │ -
│ │ │ │ │ - def kill(self) -> None:
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - self._subprocess_transport.kill()
│ │ │ │ │ -
│ │ │ │ │ - def send_signal(self, number: int) -> None:
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - self._subprocess_transport.send_signal(number)
│ │ │ │ │ -
│ │ │ │ │ - def terminate(self) -> None:
│ │ │ │ │ - assert self._subprocess_transport is not None
│ │ │ │ │ - self._subprocess_transport.terminate()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/py.typed': br'''''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import (
│ │ │ │ │ - BEIBOOT_GADGETS,
│ │ │ │ │ - COMMAND_TEMPLATE,
│ │ │ │ │ - AskpassHandler,
│ │ │ │ │ - InteractionAgent,
│ │ │ │ │ - InteractionError,
│ │ │ │ │ - InteractionHandler,
│ │ │ │ │ - temporary_askpass,
│ │ │ │ │ - write_askpass_to_tmpdir,
│ │ │ │ │ -)
│ │ │ │ │ -from .session import Session
│ │ │ │ │ -from .ssh_askpass import (
│ │ │ │ │ - AskpassPrompt,
│ │ │ │ │ - SshAskpassResponder,
│ │ │ │ │ - SshFIDOPINPrompt,
│ │ │ │ │ - SshFIDOUserPresencePrompt,
│ │ │ │ │ - SshHostKeyPrompt,
│ │ │ │ │ - SshPassphrasePrompt,
│ │ │ │ │ - SshPasswordPrompt,
│ │ │ │ │ - SshPKCS11PINPrompt,
│ │ │ │ │ -)
│ │ │ │ │ -from .ssh_errors import (
│ │ │ │ │ - SshAuthenticationError,
│ │ │ │ │ - SshChangedHostKeyError,
│ │ │ │ │ - SshError,
│ │ │ │ │ - SshHostKeyError,
│ │ │ │ │ - SshUnknownHostKeyError,
│ │ │ │ │ -)
│ │ │ │ │ -from .transport import FernyTransport, SubprocessError
│ │ │ │ │ -
│ │ │ │ │ -__all__ = [
│ │ │ │ │ - 'AskpassHandler',
│ │ │ │ │ - 'AskpassPrompt',
│ │ │ │ │ - 'AuthenticationError',
│ │ │ │ │ - 'BEIBOOT_GADGETS',
│ │ │ │ │ - 'COMMAND_TEMPLATE',
│ │ │ │ │ - 'ChangedHostKeyError',
│ │ │ │ │ - 'FernyTransport',
│ │ │ │ │ - 'HostKeyError',
│ │ │ │ │ - 'InteractionAgent',
│ │ │ │ │ - 'InteractionError',
│ │ │ │ │ - 'InteractionHandler',
│ │ │ │ │ - 'Session',
│ │ │ │ │ - 'SshAskpassResponder',
│ │ │ │ │ - 'SshAuthenticationError',
│ │ │ │ │ - 'SshChangedHostKeyError',
│ │ │ │ │ - 'SshError',
│ │ │ │ │ - 'SshFIDOPINPrompt',
│ │ │ │ │ - 'SshFIDOUserPresencePrompt',
│ │ │ │ │ - 'SshHostKeyError',
│ │ │ │ │ - 'SshHostKeyPrompt',
│ │ │ │ │ - 'SshPKCS11PINPrompt',
│ │ │ │ │ - 'SshPassphrasePrompt',
│ │ │ │ │ - 'SshPasswordPrompt',
│ │ │ │ │ - 'SshUnknownHostKeyError',
│ │ │ │ │ - 'SubprocessError',
│ │ │ │ │ - 'temporary_askpass',
│ │ │ │ │ - 'write_askpass_to_tmpdir',
│ │ │ │ │ -]
│ │ │ │ │ -
│ │ │ │ │ -__version__ = '0'
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main
│ │ │ │ │ -
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import array
│ │ │ │ │ -import ast
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import contextlib
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import re
│ │ │ │ │ -import socket
│ │ │ │ │ -import tempfile
│ │ │ │ │ -from typing import Any, Callable, ClassVar, Generator, Sequence
│ │ │ │ │ -
│ │ │ │ │ -from . import interaction_client
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -COMMAND_RE = re.compile(b'\0ferny\0([^\n]*)\0\0\n')
│ │ │ │ │ -COMMAND_TEMPLATE = '\0ferny\0{(command, args)!r}\0\0\n'
│ │ │ │ │ -
│ │ │ │ │ -BEIBOOT_GADGETS = {
│ │ │ │ │ - "command": fr"""
│ │ │ │ │ - import sys
│ │ │ │ │ - def command(command, *args):
│ │ │ │ │ - sys.stderr.write(f{COMMAND_TEMPLATE!r})
│ │ │ │ │ - sys.stderr.flush()
│ │ │ │ │ - """,
│ │ │ │ │ - "end": r"""
│ │ │ │ │ - def end():
│ │ │ │ │ - command('ferny.end')
│ │ │ │ │ - """,
│ │ │ │ │ -}
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class InteractionError(Exception):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -try:
│ │ │ │ │ - recv_fds = socket.recv_fds
│ │ │ │ │ -except AttributeError:
│ │ │ │ │ - # Python < 3.9
│ │ │ │ │ -
│ │ │ │ │ - def recv_fds(
│ │ │ │ │ - sock: socket.socket, bufsize: int, maxfds: int, flags: int = 0
│ │ │ │ │ - ) -> 'tuple[bytes, list[int], int, None]':
│ │ │ │ │ - fds = array.array("i")
│ │ │ │ │ - msg, ancdata, flags, addr = sock.recvmsg(bufsize, socket.CMSG_LEN(maxfds * fds.itemsize))
│ │ │ │ │ - for cmsg_level, cmsg_type, cmsg_data in ancdata:
│ │ │ │ │ - if (cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS):
│ │ │ │ │ - fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
│ │ │ │ │ - return msg, list(fds), flags, addr
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def get_running_loop() -> asyncio.AbstractEventLoop:
│ │ │ │ │ - try:
│ │ │ │ │ - return asyncio.get_running_loop()
│ │ │ │ │ - except AttributeError:
│ │ │ │ │ - # Python 3.6
│ │ │ │ │ - return asyncio.get_event_loop()
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class InteractionHandler:
│ │ │ │ │ - commands: ClassVar[Sequence[str]]
│ │ │ │ │ -
│ │ │ │ │ - async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class AskpassHandler(InteractionHandler):
│ │ │ │ │ - commands: ClassVar[Sequence[str]] = ('ferny.askpass',)
│ │ │ │ │ -
│ │ │ │ │ - async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None':
│ │ │ │ │ - """Prompt the user for an authentication or confirmation interaction.
│ │ │ │ │ -
│ │ │ │ │ - 'messages' is data that was sent to stderr before the interaction was requested.
│ │ │ │ │ - 'prompt' is the interaction prompt.
│ │ │ │ │ -
│ │ │ │ │ - The expected response type depends on hint:
│ │ │ │ │ -
│ │ │ │ │ - - "confirm": ask for permission, returning "yes" if accepted
│ │ │ │ │ - - example: authorizing agent operation
│ │ │ │ │ -
│ │ │ │ │ - - "none": show a request without need for a response
│ │ │ │ │ - - example: please touch your authentication token
│ │ │ │ │ -
│ │ │ │ │ - - otherwise: return a password or other form of text token
│ │ │ │ │ - - examples: enter password, unlock private key
│ │ │ │ │ -
│ │ │ │ │ - In any case, the function should properly handle cancellation. For the
│ │ │ │ │ - "none" case, this will be the normal way to dismiss the dialog.
│ │ │ │ │ - """
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:
│ │ │ │ │ - """Prompt the user for a decision regarding acceptance of a host key.
│ │ │ │ │ -
│ │ │ │ │ - The "reason" will be either "HOSTNAME" or "ADDRESS" (if `CheckHostIP` is enabled).
│ │ │ │ │ -
│ │ │ │ │ - The host, algorithm, and key parameters are the values in the form that
│ │ │ │ │ - they would appear one a single line in the known hosts file. The
│ │ │ │ │ - fingerprint is the key fingerprint in the format that ssh would
│ │ │ │ │ - normally present it to the user.
│ │ │ │ │ -
│ │ │ │ │ - In case the host key should be accepted, this function needs to return
│ │ │ │ │ - True. Returning False means that ssh implements its default logic. To
│ │ │ │ │ - interrupt the connection, raise an exception.
│ │ │ │ │ - """
│ │ │ │ │ - return False
│ │ │ │ │ -
│ │ │ │ │ - async def do_custom_command(
│ │ │ │ │ - self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str
│ │ │ │ │ - ) -> None:
│ │ │ │ │ - """Handle a custom command.
│ │ │ │ │ -
│ │ │ │ │ - The command name, its arguments, the passed fds, and the stderr leading
│ │ │ │ │ - up to the command invocation are all provided.
│ │ │ │ │ -
│ │ │ │ │ - See doc/interaction-protocol.md
│ │ │ │ │ - """
│ │ │ │ │ -
│ │ │ │ │ - async def _askpass_command(self, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
│ │ │ │ │ - logger.debug('_askpass_command(%s, %s, %s)', args, fds, stderr)
│ │ │ │ │ - try:
│ │ │ │ │ - argv, env = args
│ │ │ │ │ - assert isinstance(argv, list)
│ │ │ │ │ - assert all(isinstance(arg, str) for arg in argv)
│ │ │ │ │ - assert isinstance(env, dict)
│ │ │ │ │ - assert all(isinstance(key, str) and isinstance(val, str) for key, val in env.items())
│ │ │ │ │ - assert len(fds) == 2
│ │ │ │ │ - except (ValueError, TypeError, AssertionError) as exc:
│ │ │ │ │ - logger.error('Invalid arguments to askpass interaction: %s, %s: %s', args, fds, exc)
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - with open(fds.pop(0), 'w') as status, open(fds.pop(0), 'w') as stdout:
│ │ │ │ │ - try:
│ │ │ │ │ - loop = get_running_loop()
│ │ │ │ │ - try:
│ │ │ │ │ - task = asyncio.current_task()
│ │ │ │ │ - except AttributeError:
│ │ │ │ │ - task = asyncio.Task.current_task() # type:ignore[attr-defined] # (Python 3.6)
│ │ │ │ │ - assert task is not None
│ │ │ │ │ - loop.add_reader(status, task.cancel)
│ │ │ │ │ -
│ │ │ │ │ - if len(argv) == 2:
│ │ │ │ │ - # normal askpass
│ │ │ │ │ - prompt = argv[1]
│ │ │ │ │ - hint = env.get('SSH_ASKPASS_PROMPT', '')
│ │ │ │ │ - logger.debug('do_askpass(%r, %r, %r)', stderr, prompt, hint)
│ │ │ │ │ - answer = await self.do_askpass(stderr, prompt, hint)
│ │ │ │ │ - logger.debug('do_askpass answer %r', answer)
│ │ │ │ │ - if answer is not None:
│ │ │ │ │ - print(answer, file=stdout)
│ │ │ │ │ - print(0, file=status)
│ │ │ │ │ -
│ │ │ │ │ - elif len(argv) == 6:
│ │ │ │ │ - # KnownHostsCommand
│ │ │ │ │ - argv0, reason, host, algorithm, key, fingerprint = argv
│ │ │ │ │ - if reason in ['ADDRESS', 'HOSTNAME']:
│ │ │ │ │ - logger.debug('do_hostkey(%r, %r, %r, %r, %r)', reason, host, algorithm, key, fingerprint)
│ │ │ │ │ - if await self.do_hostkey(reason, host, algorithm, key, fingerprint):
│ │ │ │ │ - print(host, algorithm, key, file=stdout)
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug('ignoring KnownHostsCommand reason %r', reason)
│ │ │ │ │ -
│ │ │ │ │ - print(0, file=status)
│ │ │ │ │ -
│ │ │ │ │ - else:
│ │ │ │ │ - logger.error('Incorrect number of command-line arguments to ferny-askpass: %s', argv)
│ │ │ │ │ - finally:
│ │ │ │ │ - loop.remove_reader(status)
│ │ │ │ │ -
│ │ │ │ │ - async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
│ │ │ │ │ - logger.debug('run_command(%s, %s, %s, %s)', command, args, fds, stderr)
│ │ │ │ │ - if command == 'ferny.askpass':
│ │ │ │ │ - await self._askpass_command(args, fds, stderr)
│ │ │ │ │ - else:
│ │ │ │ │ - await self.do_custom_command(command, args, fds, stderr)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class InteractionAgent:
│ │ │ │ │ - _handlers: 'dict[str, InteractionHandler]'
│ │ │ │ │ + raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ - _loop: asyncio.AbstractEventLoop
│ │ │ │ │ + self.send_control(command='response',
│ │ │ │ │ + status=response.status,
│ │ │ │ │ + reason=response.reason,
│ │ │ │ │ + headers=self.get_headers(response, binary=binary))
│ │ │ │ │
│ │ │ │ │ - _tasks: 'set[asyncio.Task]'
│ │ │ │ │ + # Receive the body and finish up
│ │ │ │ │ + try:
│ │ │ │ │ + while True:
│ │ │ │ │ + block = await loop.run_in_executor(None, response.read1, self.BLOCK_SIZE)
│ │ │ │ │ + if not block:
│ │ │ │ │ + break
│ │ │ │ │ + await self.write(block)
│ │ │ │ │
│ │ │ │ │ - _buffer: bytearray
│ │ │ │ │ - _ours: socket.socket
│ │ │ │ │ - _theirs: socket.socket
│ │ │ │ │ + logger.debug('reading response done')
│ │ │ │ │ + # this returns immediately and does not read anything more, but updates the http.client's
│ │ │ │ │ + # internal state machine to "response done"
│ │ │ │ │ + block = response.read()
│ │ │ │ │ + assert block == b''
│ │ │ │ │
│ │ │ │ │ - _completion_future: 'asyncio.Future[str]'
│ │ │ │ │ - _pending_result: 'None | str | Exception' = None
│ │ │ │ │ - _end: bool = False
│ │ │ │ │ + await loop.run_in_executor(None, connection.close)
│ │ │ │ │ + except (http.client.HTTPException, OSError) as exc:
│ │ │ │ │ + raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ - def _consider_completion(self) -> None:
│ │ │ │ │ - logger.debug('_consider_completion(%r)', self)
│ │ │ │ │ + self.done()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │
│ │ │ │ │ - if self._pending_result is None or self._tasks:
│ │ │ │ │ - logger.debug(' but not ready yet')
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import json
│ │ │ │ │ +import logging
│ │ │ │ │ +import sys
│ │ │ │ │ +import time
│ │ │ │ │ +from collections import defaultdict
│ │ │ │ │ +from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union
│ │ │ │ │
│ │ │ │ │ - elif self._completion_future.done():
│ │ │ │ │ - logger.debug(' but already complete')
│ │ │ │ │ +from ..channel import AsyncChannel, ChannelError
│ │ │ │ │ +from ..jsonutil import JsonList
│ │ │ │ │ +from ..samples import SAMPLERS, SampleDescription, Sampler, Samples
│ │ │ │ │
│ │ │ │ │ - elif isinstance(self._pending_result, str):
│ │ │ │ │ - logger.debug(' submitting stderr (%r) to completion_future', self._pending_result)
│ │ │ │ │ - self._completion_future.set_result(self._pending_result)
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug(' submitting exception (%r) to completion_future')
│ │ │ │ │ - self._completion_future.set_exception(self._pending_result)
│ │ │ │ │
│ │ │ │ │ - def _result(self, result: 'str | Exception') -> None:
│ │ │ │ │ - logger.debug('_result(%r, %r)', self, result)
│ │ │ │ │ +class MetricInfo(NamedTuple):
│ │ │ │ │ + derive: Optional[str]
│ │ │ │ │ + desc: SampleDescription
│ │ │ │ │
│ │ │ │ │ - if self._pending_result is None:
│ │ │ │ │ - self._pending_result = result
│ │ │ │ │
│ │ │ │ │ - if self._ours.fileno() != -1:
│ │ │ │ │ - logger.debug(' remove_reader(%r)', self._ours)
│ │ │ │ │ - self._loop.remove_reader(self._ours.fileno())
│ │ │ │ │ +class InternalMetricsChannel(AsyncChannel):
│ │ │ │ │ + payload = 'metrics1'
│ │ │ │ │ + restrictions = [('source', 'internal')]
│ │ │ │ │
│ │ │ │ │ - for task in self._tasks:
│ │ │ │ │ - logger.debug(' cancel(%r)', task)
│ │ │ │ │ - task.cancel()
│ │ │ │ │ + metrics: List[MetricInfo]
│ │ │ │ │ + samplers: Set
│ │ │ │ │ + samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None
│ │ │ │ │
│ │ │ │ │ - logger.debug(' closing sockets')
│ │ │ │ │ - self._theirs.close() # idempotent
│ │ │ │ │ - self._ours.close()
│ │ │ │ │ + interval: int = 1000
│ │ │ │ │ + need_meta: bool = True
│ │ │ │ │ + last_timestamp: float = 0
│ │ │ │ │ + next_timestamp: float = 0
│ │ │ │ │
│ │ │ │ │ - self._consider_completion()
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def ensure_samplers(cls):
│ │ │ │ │ + if cls.samplers_cache is None:
│ │ │ │ │ + cls.samplers_cache = {desc.name: (sampler, desc) for sampler in SAMPLERS for desc in sampler.descriptions}
│ │ │ │ │
│ │ │ │ │ - def _invoke_command(self, stderr: bytes, command_blob: bytes, fds: 'list[int]') -> None:
│ │ │ │ │ - logger.debug('_invoke_command(%r, %r, %r)', stderr, command_blob, fds)
│ │ │ │ │ - try:
│ │ │ │ │ - command, args = ast.literal_eval(command_blob.decode())
│ │ │ │ │ - if not isinstance(command, str) or not isinstance(args, tuple):
│ │ │ │ │ - raise TypeError('Invalid argument types')
│ │ │ │ │ - except (UnicodeDecodeError, SyntaxError, ValueError, TypeError) as exc:
│ │ │ │ │ - logger.error('Received invalid ferny command: %s: %s', command_blob, exc)
│ │ │ │ │ - return
│ │ │ │ │ + def parse_options(self, options):
│ │ │ │ │ + logger.debug('metrics internal open: %s, channel: %s', options, self.channel)
│ │ │ │ │
│ │ │ │ │ - if command == 'ferny.end':
│ │ │ │ │ - self._end = True
│ │ │ │ │ - self._result(self._buffer.decode(errors='replace'))
│ │ │ │ │ - return
│ │ │ │ │ + interval = options.get('interval', self.interval)
│ │ │ │ │ + if not isinstance(interval, int) or interval <= 0 or interval > sys.maxsize:
│ │ │ │ │ + raise ChannelError('protocol-error', message=f'invalid "interval" value: {interval}')
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - handler = self._handlers[command]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - logger.error('Received unhandled ferny command: %s', command)
│ │ │ │ │ - return
│ │ │ │ │ + self.interval = interval
│ │ │ │ │
│ │ │ │ │ - # The task is responsible for the list of fds and removing itself
│ │ │ │ │ - # from the set.
│ │ │ │ │ - task_fds = list(fds)
│ │ │ │ │ - task = self._loop.create_task(handler.run_command(command, args, task_fds, stderr.decode()))
│ │ │ │ │ + metrics = options.get('metrics')
│ │ │ │ │ + if not isinstance(metrics, list) or len(metrics) == 0:
│ │ │ │ │ + logger.error('invalid "metrics" value: %s', metrics)
│ │ │ │ │ + raise ChannelError('protocol-error', message='invalid "metrics" option was specified (not an array)')
│ │ │ │ │
│ │ │ │ │ - def bottom_half(completed_task: asyncio.Task) -> None:
│ │ │ │ │ - assert completed_task is task
│ │ │ │ │ - while task_fds:
│ │ │ │ │ - os.close(task_fds.pop())
│ │ │ │ │ - self._tasks.remove(task)
│ │ │ │ │ + sampler_classes = set()
│ │ │ │ │ + for metric in metrics:
│ │ │ │ │ + # validate it's an object
│ │ │ │ │ + name = metric.get('name')
│ │ │ │ │ + units = metric.get('units')
│ │ │ │ │ + derive = metric.get('derive')
│ │ │ │ │
│ │ │ │ │ try:
│ │ │ │ │ - task.result()
│ │ │ │ │ - logger.debug('%r completed cleanly', handler)
│ │ │ │ │ - except asyncio.CancelledError:
│ │ │ │ │ - # this is not an error — it just means ferny-askpass exited via signal
│ │ │ │ │ - logger.debug('%r was cancelled', handler)
│ │ │ │ │ - except Exception as exc:
│ │ │ │ │ - logger.debug('%r raised %r', handler, exc)
│ │ │ │ │ - self._result(exc)
│ │ │ │ │ -
│ │ │ │ │ - self._consider_completion()
│ │ │ │ │ + sampler, desc = self.samplers_cache[name]
│ │ │ │ │ + except KeyError as exc:
│ │ │ │ │ + logger.error('unsupported metric: %s', name)
│ │ │ │ │ + raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc
│ │ │ │ │
│ │ │ │ │ - task.add_done_callback(bottom_half)
│ │ │ │ │ - self._tasks.add(task)
│ │ │ │ │ - fds[:] = []
│ │ │ │ │ + if units and units != desc.units:
│ │ │ │ │ + raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}')
│ │ │ │ │
│ │ │ │ │ - def _got_data(self, data: bytes, fds: 'list[int]') -> None:
│ │ │ │ │ - logger.debug('_got_data(%r, %r)', data, fds)
│ │ │ │ │ + sampler_classes.add(sampler)
│ │ │ │ │ + self.metrics.append(MetricInfo(derive=derive, desc=desc))
│ │ │ │ │
│ │ │ │ │ - if data == b'':
│ │ │ │ │ - self._result(self._buffer.decode(errors='replace'))
│ │ │ │ │ - return
│ │ │ │ │ + self.samplers = {cls() for cls in sampler_classes}
│ │ │ │ │
│ │ │ │ │ - self._buffer.extend(data)
│ │ │ │ │ + def send_meta(self, samples: Samples, timestamp: float):
│ │ │ │ │ + metrics: JsonList = []
│ │ │ │ │ + for metricinfo in self.metrics:
│ │ │ │ │ + if metricinfo.desc.instanced:
│ │ │ │ │ + metrics.append({
│ │ │ │ │ + 'name': metricinfo.desc.name,
│ │ │ │ │ + 'units': metricinfo.desc.units,
│ │ │ │ │ + 'instances': list(samples[metricinfo.desc.name].keys()),
│ │ │ │ │ + 'semantics': metricinfo.desc.semantics
│ │ │ │ │ + })
│ │ │ │ │ + else:
│ │ │ │ │ + metrics.append({
│ │ │ │ │ + 'name': metricinfo.desc.name,
│ │ │ │ │ + 'derive': metricinfo.derive, # type: ignore[dict-item]
│ │ │ │ │ + 'units': metricinfo.desc.units,
│ │ │ │ │ + 'semantics': metricinfo.desc.semantics
│ │ │ │ │ + })
│ │ │ │ │
│ │ │ │ │ - # Read zero or more "remote" messages
│ │ │ │ │ - chunks = COMMAND_RE.split(self._buffer)
│ │ │ │ │ - self._buffer = bytearray(chunks.pop())
│ │ │ │ │ - while len(chunks) > 1:
│ │ │ │ │ - self._invoke_command(chunks[0], chunks[1], [])
│ │ │ │ │ - chunks = chunks[2:]
│ │ │ │ │ + self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics)
│ │ │ │ │ + self.need_meta = False
│ │ │ │ │
│ │ │ │ │ - # Maybe read one "local" message
│ │ │ │ │ - if fds:
│ │ │ │ │ - assert self._buffer.endswith(b'\0'), self._buffer
│ │ │ │ │ - stderr = self._buffer[:-1]
│ │ │ │ │ - self._buffer = bytearray(b'')
│ │ │ │ │ - with open(fds.pop(0), 'rb') as command_channel:
│ │ │ │ │ - command = command_channel.read()
│ │ │ │ │ - self._invoke_command(stderr, command, fds)
│ │ │ │ │ + def sample(self):
│ │ │ │ │ + samples = defaultdict(dict)
│ │ │ │ │ + for sampler in self.samplers:
│ │ │ │ │ + sampler.sample(samples)
│ │ │ │ │ + return samples
│ │ │ │ │
│ │ │ │ │ - def _read_ready(self) -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - data, fds, _flags, _addr = recv_fds(self._ours, 4096, 10, flags=socket.MSG_DONTWAIT)
│ │ │ │ │ - except BlockingIOError:
│ │ │ │ │ - return
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - self._result(exc)
│ │ │ │ │ + def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Union[float, bool]:
│ │ │ │ │ + if old_value is not None and self.last_timestamp:
│ │ │ │ │ + return (value - old_value) / (self.next_timestamp - self.last_timestamp)
│ │ │ │ │ else:
│ │ │ │ │ - self._got_data(data, fds)
│ │ │ │ │ - finally:
│ │ │ │ │ - while fds:
│ │ │ │ │ - os.close(fds.pop())
│ │ │ │ │ -
│ │ │ │ │ - def __init__(
│ │ │ │ │ - self,
│ │ │ │ │ - handlers: Sequence[InteractionHandler],
│ │ │ │ │ - loop: 'asyncio.AbstractEventLoop | None' = None,
│ │ │ │ │ - done_callback: 'Callable[[asyncio.Future[str]], None] | None' = None,
│ │ │ │ │ - ) -> None:
│ │ │ │ │ - self._loop = loop or get_running_loop()
│ │ │ │ │ - self._completion_future = self._loop.create_future()
│ │ │ │ │ - self._tasks = set()
│ │ │ │ │ - self._handlers = {}
│ │ │ │ │ + return False
│ │ │ │ │
│ │ │ │ │ - for handler in handlers:
│ │ │ │ │ - for command in handler.commands:
│ │ │ │ │ - self._handlers[command] = handler
│ │ │ │ │ + def send_updates(self, samples: Samples, last_samples: Samples):
│ │ │ │ │ + data: List[Union[float, List[Optional[Union[float, bool]]]]] = []
│ │ │ │ │ + timestamp = time.time()
│ │ │ │ │ + self.next_timestamp = timestamp
│ │ │ │ │
│ │ │ │ │ - if done_callback is not None:
│ │ │ │ │ - self._completion_future.add_done_callback(done_callback)
│ │ │ │ │ + for metricinfo in self.metrics:
│ │ │ │ │ + value = samples[metricinfo.desc.name]
│ │ │ │ │
│ │ │ │ │ - self._theirs, self._ours = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
│ │ │ │ │ - self._buffer = bytearray()
│ │ │ │ │ + if metricinfo.desc.instanced:
│ │ │ │ │ + old_value = last_samples[metricinfo.desc.name]
│ │ │ │ │ + assert isinstance(value, dict)
│ │ │ │ │ + assert isinstance(old_value, dict)
│ │ │ │ │
│ │ │ │ │ - def fileno(self) -> int:
│ │ │ │ │ - return self._theirs.fileno()
│ │ │ │ │ + # If we have less or more keys the data changed, send a meta message.
│ │ │ │ │ + if value.keys() != old_value.keys():
│ │ │ │ │ + self.need_meta = True
│ │ │ │ │
│ │ │ │ │ - def start(self) -> None:
│ │ │ │ │ - logger.debug('start(%r)', self)
│ │ │ │ │ - if self._ours.fileno() != -1:
│ │ │ │ │ - logger.debug(' add_reader(%r)', self._ours)
│ │ │ │ │ - self._loop.add_reader(self._ours.fileno(), self._read_ready)
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug(' ...but agent is already finished.')
│ │ │ │ │ + if metricinfo.derive == 'rate':
│ │ │ │ │ + instances: List[Optional[Union[float, bool]]] = []
│ │ │ │ │ + for key, val in value.items():
│ │ │ │ │ + instances.append(self.calculate_sample_rate(val, old_value.get(key)))
│ │ │ │ │
│ │ │ │ │ - logger.debug(' close(%r)', self._theirs)
│ │ │ │ │ - self._theirs.close()
│ │ │ │ │ + data.append(instances)
│ │ │ │ │ + else:
│ │ │ │ │ + data.append(list(value.values()))
│ │ │ │ │ + else:
│ │ │ │ │ + old_value = last_samples.get(metricinfo.desc.name)
│ │ │ │ │ + assert not isinstance(value, dict)
│ │ │ │ │ + assert not isinstance(old_value, dict)
│ │ │ │ │
│ │ │ │ │ - def force_completion(self) -> None:
│ │ │ │ │ - logger.debug('force_completion(%r)', self)
│ │ │ │ │ + if metricinfo.derive == 'rate':
│ │ │ │ │ + data.append(self.calculate_sample_rate(value, old_value))
│ │ │ │ │ + else:
│ │ │ │ │ + data.append(value)
│ │ │ │ │
│ │ │ │ │ - # read any residual data on stderr, but don't process commands, and
│ │ │ │ │ - # don't block
│ │ │ │ │ - try:
│ │ │ │ │ - if self._ours.fileno() != -1:
│ │ │ │ │ - logger.debug(' draining pending stderr data (non-blocking)')
│ │ │ │ │ - with contextlib.suppress(BlockingIOError):
│ │ │ │ │ - while True:
│ │ │ │ │ - data = self._ours.recv(4096, socket.MSG_DONTWAIT)
│ │ │ │ │ - logger.debug(' got %d bytes', len(data))
│ │ │ │ │ - if not data:
│ │ │ │ │ - break
│ │ │ │ │ - self._buffer.extend(data)
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - self._result(exc)
│ │ │ │ │ - else:
│ │ │ │ │ - self._result(self._buffer.decode(errors='replace'))
│ │ │ │ │ + if self.need_meta:
│ │ │ │ │ + self.send_meta(samples, timestamp)
│ │ │ │ │
│ │ │ │ │ - async def communicate(self) -> None:
│ │ │ │ │ - logger.debug('_communicate(%r)', self)
│ │ │ │ │ - try:
│ │ │ │ │ - self.start()
│ │ │ │ │ - # We assume that we are the only ones to write to
│ │ │ │ │ - # self._completion_future. If we directly await it, though, it can
│ │ │ │ │ - # also have a asyncio.CancelledError posted to it from outside.
│ │ │ │ │ - # Shield it to prevent that from happening.
│ │ │ │ │ - stderr = await asyncio.shield(self._completion_future)
│ │ │ │ │ - logger.debug('_communicate(%r) stderr result is %r', self, stderr)
│ │ │ │ │ - finally:
│ │ │ │ │ - logger.debug('_communicate finished. Ensuring completion.')
│ │ │ │ │ - self.force_completion()
│ │ │ │ │ - if not self._end:
│ │ │ │ │ - logger.debug('_communicate never saw ferny.end. raising InteractionError.')
│ │ │ │ │ - raise InteractionError(stderr.strip())
│ │ │ │ │ + self.last_timestamp = self.next_timestamp
│ │ │ │ │ + self.send_data(json.dumps([data]).encode())
│ │ │ │ │
│ │ │ │ │ + async def run(self, options):
│ │ │ │ │ + self.metrics = []
│ │ │ │ │ + self.samplers = set()
│ │ │ │ │
│ │ │ │ │ -def write_askpass_to_tmpdir(tmpdir: str) -> str:
│ │ │ │ │ - askpass_path = os.path.join(tmpdir, 'ferny-askpass')
│ │ │ │ │ - fd = os.open(askpass_path, os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC | os.O_EXCL | os.O_NOFOLLOW, 0o777)
│ │ │ │ │ - try:
│ │ │ │ │ - os.write(fd, __loader__.get_data(interaction_client.__file__)) # type: ignore
│ │ │ │ │ - finally:
│ │ │ │ │ - os.close(fd)
│ │ │ │ │ - return askpass_path
│ │ │ │ │ + InternalMetricsChannel.ensure_samplers()
│ │ │ │ │
│ │ │ │ │ + self.parse_options(options)
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ -@contextlib.contextmanager
│ │ │ │ │ -def temporary_askpass(**kwargs: Any) -> Generator[str, None, None]:
│ │ │ │ │ - with tempfile.TemporaryDirectory(**kwargs) as directory:
│ │ │ │ │ - yield write_askpass_to_tmpdir(directory)
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ + last_samples = defaultdict(dict)
│ │ │ │ │ + while True:
│ │ │ │ │ + samples = self.sample()
│ │ │ │ │ + self.send_updates(samples, last_samples)
│ │ │ │ │ + last_samples = samples
│ │ │ │ │ + await asyncio.sleep(self.interval / 1000)
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/__init__.py': br'''''',
│ │ │ │ │ 'cockpit/_vendor/bei/bootloader.py': br'''# beiboot - Remote bootloader for Python
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -8285,14 +6829,15 @@
│ │ │ │ │ for spaces, text in yield_body(gadgets or {}, steps, imports):
│ │ │ │ │ lines.append(frame_spaces + spaces + text)
│ │ │ │ │ else:
│ │ │ │ │ lines.append(frame_spaces + frame_text)
│ │ │ │ │
│ │ │ │ │ return "".join(f"{line}\n" for line in [*imports, *lines]) + "\n"
│ │ │ │ │ ''',
│ │ │ │ │ + 'cockpit/_vendor/bei/__init__.py': br'''''',
│ │ │ │ │ 'cockpit/_vendor/bei/beiboot.py': br"""# beiboot - Remote bootloader for Python
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -8446,14 +6991,36 @@
│ │ │ │ │ os.execlp(command[0], *command)
│ │ │ │ │
│ │ │ │ │ # Otherwise, "full strength"
│ │ │ │ │
│ │ │ │ │ if __name__ == '__main__':
│ │ │ │ │ main()
│ │ │ │ │ """,
│ │ │ │ │ + 'cockpit/_vendor/bei/tmpfs.py': br'''import os
│ │ │ │ │ +import subprocess
│ │ │ │ │ +import sys
│ │ │ │ │ +import tempfile
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def main(*command: str) -> None:
│ │ │ │ │ + with tempfile.TemporaryDirectory() as tmpdir:
│ │ │ │ │ + os.chdir(tmpdir)
│ │ │ │ │ +
│ │ │ │ │ + for key, value in __loader__.get_contents().items():
│ │ │ │ │ + if key.startswith('tmpfs/'):
│ │ │ │ │ + subdir = os.path.dirname(key)
│ │ │ │ │ + os.makedirs(subdir, exist_ok=True)
│ │ │ │ │ + with open(key, 'wb') as fp:
│ │ │ │ │ + fp.write(value)
│ │ │ │ │ +
│ │ │ │ │ + os.chdir('tmpfs')
│ │ │ │ │ +
│ │ │ │ │ + result = subprocess.run(command, check=False)
│ │ │ │ │ + sys.exit(result.returncode)
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -8664,36 +7231,14 @@
│ │ │ │ │ sys.exit('refusing to write compressed output to a terminal')
│ │ │ │ │ sys.stdout.buffer.write(result)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ if __name__ == '__main__':
│ │ │ │ │ main()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/bei/tmpfs.py': br'''import os
│ │ │ │ │ -import subprocess
│ │ │ │ │ -import sys
│ │ │ │ │ -import tempfile
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def main(*command: str) -> None:
│ │ │ │ │ - with tempfile.TemporaryDirectory() as tmpdir:
│ │ │ │ │ - os.chdir(tmpdir)
│ │ │ │ │ -
│ │ │ │ │ - for key, value in __loader__.get_contents().items():
│ │ │ │ │ - if key.startswith('tmpfs/'):
│ │ │ │ │ - subdir = os.path.dirname(key)
│ │ │ │ │ - os.makedirs(subdir, exist_ok=True)
│ │ │ │ │ - with open(key, 'wb') as fp:
│ │ │ │ │ - fp.write(value)
│ │ │ │ │ -
│ │ │ │ │ - os.chdir('tmpfs')
│ │ │ │ │ -
│ │ │ │ │ - result = subprocess.run(command, check=False)
│ │ │ │ │ - sys.exit(result.returncode)
│ │ │ │ │ -''',
│ │ │ │ │ 'cockpit/_vendor/bei/spawn.py': br'''"""Helper to create a beipack to spawn a command with files in a tmpdir"""
│ │ │ │ │
│ │ │ │ │ import argparse
│ │ │ │ │ import os
│ │ │ │ │ import sys
│ │ │ │ │
│ │ │ │ │ from . import pack, tmpfs
│ │ │ │ │ @@ -8724,15 +7269,14 @@
│ │ │ │ │ script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command))
│ │ │ │ │ sys.stdout.write(script)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ if __name__ == '__main__':
│ │ │ │ │ main()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/bei/__init__.py': br'''''',
│ │ │ │ │ 'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack
│ │ │ │ │
│ │ │ │ │ import importlib.abc
│ │ │ │ │ import importlib.util
│ │ │ │ │ import io
│ │ │ │ │ import sys
│ │ │ │ │ from types import ModuleType
│ │ │ │ │ @@ -8825,14 +7369,1198 @@
│ │ │ │ │ return (importlib.resources.files(__name__) / filename).read_text()
│ │ │ │ │ else:
│ │ │ │ │ def read_data_file(filename: str) -> str:
│ │ │ │ │ loader = __loader__ # type: ignore[name-defined]
│ │ │ │ │ data = loader.get_data(__file__.replace('__init__.py', filename))
│ │ │ │ │ return data.decode('utf-8')
│ │ │ │ │ ''',
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/librarywrapper.py': br'''# systemd_ctypes
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import ctypes
│ │ │ │ │ +import inspect
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import sys
│ │ │ │ │ +import types
│ │ │ │ │ +from typing import (
│ │ │ │ │ + Any,
│ │ │ │ │ + Callable,
│ │ │ │ │ + Dict,
│ │ │ │ │ + Generic,
│ │ │ │ │ + NewType,
│ │ │ │ │ + NoReturn,
│ │ │ │ │ + Optional,
│ │ │ │ │ + Tuple,
│ │ │ │ │ + Type,
│ │ │ │ │ + TypeVar,
│ │ │ │ │ + Union,
│ │ │ │ │ +)
│ │ │ │ │ +
│ │ │ │ │ +from . import typing
│ │ │ │ │ +
│ │ │ │ │ +# First in 3.10, and conditional import gives type errors
│ │ │ │ │ +NoneType = type(None)
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +if typing.TYPE_CHECKING:
│ │ │ │ │ + CType = TypeVar("CType", bound=ctypes._CData)
│ │ │ │ │ + Callback = ctypes._FuncPointer
│ │ │ │ │ +else:
│ │ │ │ │ + CType = TypeVar("CType")
│ │ │ │ │ + Callback = ctypes.c_void_p
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +if typing.TYPE_CHECKING:
│ │ │ │ │ + class Reference(Generic[CType], ctypes._Pointer[CType]):
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + def byref(x: CType) -> Reference[CType]:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +else:
│ │ │ │ │ + class Reference(Generic[CType]):
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + byref = ctypes.byref
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +UserData = Optional[ctypes.c_void_p]
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class negative_errno(ctypes.c_int):
│ │ │ │ │ + def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> int:
│ │ │ │ │ + result = self.value
│ │ │ │ │ + if result < 0:
│ │ │ │ │ + raise OSError(-result, f"{func.__name__}: {os.strerror(-result)}")
│ │ │ │ │ + return result
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class utf8(ctypes.c_char_p):
│ │ │ │ │ + def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> str:
│ │ │ │ │ + assert self.value is not None
│ │ │ │ │ + return self.value.decode()
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def from_param(cls, value: str) -> 'utf8':
│ │ │ │ │ + return cls(value.encode())
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class utf8_or_null(ctypes.c_char_p):
│ │ │ │ │ + def errcheck(self,
│ │ │ │ │ + func: Callable[..., object],
│ │ │ │ │ + _args: Tuple[object, ...]) -> Optional[str]:
│ │ │ │ │ + return self.value.decode() if self.value is not None else None
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def from_param(cls, value: Optional[str]) -> 'utf8_or_null':
│ │ │ │ │ + return cls(value.encode() if value is not None else None)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class boolint(ctypes.c_int):
│ │ │ │ │ + def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> bool:
│ │ │ │ │ + return bool(self.value)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +WeakReference = NewType("WeakReference", int)
│ │ │ │ │ +Errno = typing.Annotated[NoReturn, "errno"]
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +type_map = {
│ │ │ │ │ + Union[None, Errno]: negative_errno, # technically returns int
│ │ │ │ │ + Union[bool, Errno]: negative_errno, # technically returns int
│ │ │ │ │ + Union[int, Errno]: negative_errno,
│ │ │ │ │ + bool: boolint,
│ │ │ │ │ + Optional[str]: utf8_or_null,
│ │ │ │ │ + str: utf8,
│ │ │ │ │ + int: ctypes.c_int,
│ │ │ │ │ + WeakReference: ctypes.c_void_p
│ │ │ │ │ +}
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def map_type(annotation: Any, global_vars: Dict[str, object]) -> Any:
│ │ │ │ │ + try:
│ │ │ │ │ + return type_map[annotation]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + pass # ... and try more cases below
│ │ │ │ │ +
│ │ │ │ │ + if isinstance(annotation, typing.ForwardRef):
│ │ │ │ │ + annotation = annotation.__forward_arg__
│ │ │ │ │ +
│ │ │ │ │ + if isinstance(annotation, str):
│ │ │ │ │ + annotation = global_vars[annotation]
│ │ │ │ │ +
│ │ │ │ │ + origin = typing.get_origin(annotation)
│ │ │ │ │ + args = typing.get_args(annotation)
│ │ │ │ │ +
│ │ │ │ │ + if origin is Reference:
│ │ │ │ │ + return ctypes.POINTER(map_type(args[0], global_vars))
│ │ │ │ │ +
│ │ │ │ │ + elif origin is Union and NoneType in args:
│ │ │ │ │ + # the C pointer types are already nullable
│ │ │ │ │ + other_arg, = set(args) - {NoneType}
│ │ │ │ │ + return map_type(other_arg, global_vars)
│ │ │ │ │ +
│ │ │ │ │ + elif origin is typing.Annotated:
│ │ │ │ │ + return args[1]
│ │ │ │ │ +
│ │ │ │ │ + else:
│ │ │ │ │ + assert origin is None, origin
│ │ │ │ │ + return annotation
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class ReferenceType(ctypes.c_void_p):
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def _install_cfuncs(cls, cdll: ctypes.CDLL) -> None:
│ │ │ │ │ + logger.debug('Installing stubs for %s:', cls)
│ │ │ │ │ + stubs = tuple(cls.__dict__.items())
│ │ │ │ │ + for name, stub in stubs:
│ │ │ │ │ + if name.startswith("__"):
│ │ │ │ │ + continue
│ │ │ │ │ + cls._wrap(cdll, stub)
│ │ │ │ │ +
│ │ │ │ │ + cls._wrap(cdll, cls._ref)
│ │ │ │ │ + cls._wrap(cdll, cls._unref)
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def _wrap(cls, cdll: ctypes.CDLL, stub: object) -> None:
│ │ │ │ │ + stub_type = type(stub)
│ │ │ │ │ + if isinstance(stub, staticmethod):
│ │ │ │ │ + # In older Python versions, staticmethod() isn't considered
│ │ │ │ │ + # callable, doesn't have a name, and can't be introspected with
│ │ │ │ │ + # inspect.signature(). Unwrap it.
│ │ │ │ │ + stub = stub.__func__
│ │ │ │ │ + assert isinstance(stub, types.FunctionType)
│ │ │ │ │ + name = stub.__name__
│ │ │ │ │ + signature = inspect.signature(stub)
│ │ │ │ │ + stub_globals = sys.modules.get(cls.__module__).__dict__
│ │ │ │ │ +
│ │ │ │ │ + func = cdll[f'{cls.__name__}_{name.lstrip("_")}']
│ │ │ │ │ + func.argtypes = tuple(
│ │ │ │ │ + map_type(parameter.annotation, stub_globals)
│ │ │ │ │ + for parameter in signature.parameters.values()
│ │ │ │ │ + )
│ │ │ │ │ + func.restype = map_type(signature.return_annotation, stub_globals)
│ │ │ │ │ + errcheck = getattr(func.restype, 'errcheck', None)
│ │ │ │ │ + if errcheck is not None:
│ │ │ │ │ + func.errcheck = errcheck
│ │ │ │ │ +
│ │ │ │ │ + logger.debug(' create wrapper %s.%s%s', cls.__name__, name, signature)
│ │ │ │ │ + logger.debug(' args %s res %s', func.argtypes, func.restype)
│ │ │ │ │ +
│ │ │ │ │ + # ctypes function pointer objects don't implement the usual function
│ │ │ │ │ + # descriptor logic, which means they won't bind as methods. For static
│ │ │ │ │ + # methods, that's good, but for instance methods, we add a wrapper as
│ │ │ │ │ + # the easiest and most performant way to get the binding behaviour.
│ │ │ │ │ + if stub_type is not staticmethod:
│ │ │ │ │ + setattr(cls, name, lambda *args: func(*args))
│ │ │ │ │ + else:
│ │ │ │ │ + setattr(cls, name, func)
│ │ │ │ │ +
│ │ │ │ │ + def _unref(self: 'ReferenceType') -> None:
│ │ │ │ │ + ...
│ │ │ │ │ +
│ │ │ │ │ + def _ref(self: 'ReferenceType') -> None:
│ │ │ │ │ + ...
│ │ │ │ │ +
│ │ │ │ │ + T = TypeVar("T", bound='ReferenceType')
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def ref(cls: Type[T], origin: WeakReference) -> T:
│ │ │ │ │ + self = cls(origin)
│ │ │ │ │ + self._ref()
│ │ │ │ │ + return self
│ │ │ │ │ +
│ │ │ │ │ + def __del__(self) -> None:
│ │ │ │ │ + if self.value is not None:
│ │ │ │ │ + self._unref()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing
│ │ │ │ │ +from typing import TYPE_CHECKING
│ │ │ │ │ +
│ │ │ │ │ +# The goal here is to continue to work on Python 3.6 while pretending to have
│ │ │ │ │ +# access to some modern typing features. The shims provided here are only
│ │ │ │ │ +# enough for what we need for systemd_ctypes to work at runtime.
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +if TYPE_CHECKING:
│ │ │ │ │ + # See https://github.com/python/mypy/issues/1153 for why we do this separately
│ │ │ │ │ + from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin
│ │ │ │ │ +
│ │ │ │ │ +else:
│ │ │ │ │ + # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated
│ │ │ │ │ + # arrived in 3.9. Unfortunately, it's difficult to implement a mocked up
│ │ │ │ │ + # version of Annotated which works with the real typing.get_args() and
│ │ │ │ │ + # .get_origin() in Python 3.8, so we use our own versions there as well.
│ │ │ │ │ + try:
│ │ │ │ │ + from typing import Annotated, get_args, get_origin
│ │ │ │ │ + except ImportError:
│ │ │ │ │ + class AnnotatedMeta(type):
│ │ │ │ │ + def __getitem__(cls, params):
│ │ │ │ │ + class AnnotatedType:
│ │ │ │ │ + __origin__ = Annotated
│ │ │ │ │ + __args__ = params
│ │ │ │ │ + return AnnotatedType
│ │ │ │ │ +
│ │ │ │ │ + class Annotated(metaclass=AnnotatedMeta):
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]:
│ │ │ │ │ + return getattr(annotation, '__args__', ())
│ │ │ │ │ +
│ │ │ │ │ + def get_origin(annotation: typing.Any) -> typing.Any:
│ │ │ │ │ + return getattr(annotation, '__origin__', None)
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + from typing import ForwardRef
│ │ │ │ │ + except ImportError:
│ │ │ │ │ + from typing import _ForwardRef as ForwardRef
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + from typing import TypeGuard
│ │ │ │ │ + except ImportError:
│ │ │ │ │ + T = typing.TypeVar('T')
│ │ │ │ │ +
│ │ │ │ │ + class TypeGuard(typing.Generic[T]):
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +__all__ = (
│ │ │ │ │ + 'Annotated',
│ │ │ │ │ + 'ForwardRef',
│ │ │ │ │ + 'TypeGuard',
│ │ │ │ │ + 'get_args',
│ │ │ │ │ + 'get_origin',
│ │ │ │ │ + 'TYPE_CHECKING',
│ │ │ │ │ +)
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import ctypes
│ │ │ │ │ +from enum import IntFlag, auto
│ │ │ │ │ +from typing import Optional
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class inotify_event(ctypes.Structure):
│ │ │ │ │ + _fields_ = (
│ │ │ │ │ + ('wd', ctypes.c_int32),
│ │ │ │ │ + ('mask', ctypes.c_uint32),
│ │ │ │ │ + ('cookie', ctypes.c_uint32),
│ │ │ │ │ + ('len', ctypes.c_uint32),
│ │ │ │ │ + )
│ │ │ │ │ +
│ │ │ │ │ + @property
│ │ │ │ │ + def name(self) -> Optional[bytes]:
│ │ │ │ │ + if self.len == 0:
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ + class event_with_name(ctypes.Structure):
│ │ │ │ │ + _fields_ = (*inotify_event._fields_, ('name', ctypes.c_char * self.len))
│ │ │ │ │ +
│ │ │ │ │ + name = ctypes.cast(ctypes.addressof(self), ctypes.POINTER(event_with_name)).contents.name
│ │ │ │ │ + assert isinstance(name, bytes)
│ │ │ │ │ + return name
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Event(IntFlag):
│ │ │ │ │ + ACCESS = auto()
│ │ │ │ │ + MODIFY = auto()
│ │ │ │ │ + ATTRIB = auto()
│ │ │ │ │ + CLOSE_WRITE = auto()
│ │ │ │ │ + CLOSE_NOWRITE = auto()
│ │ │ │ │ + OPEN = auto()
│ │ │ │ │ + MOVED_FROM = auto()
│ │ │ │ │ + MOVED_TO = auto()
│ │ │ │ │ + CREATE = auto()
│ │ │ │ │ + DELETE = auto()
│ │ │ │ │ + DELETE_SELF = auto()
│ │ │ │ │ + MOVE_SELF = auto()
│ │ │ │ │ +
│ │ │ │ │ + UNMOUNT = 1 << 13
│ │ │ │ │ + Q_OVERFLOW = auto()
│ │ │ │ │ + IGNORED = auto()
│ │ │ │ │ +
│ │ │ │ │ + ONLYDIR = 1 << 24
│ │ │ │ │ + DONT_FOLLOW = auto()
│ │ │ │ │ + EXCL_UNLINK = auto()
│ │ │ │ │ +
│ │ │ │ │ + MASK_CREATE = 1 << 28
│ │ │ │ │ + MASK_ADD = auto()
│ │ │ │ │ + ISDIR = auto()
│ │ │ │ │ + ONESHOT = auto()
│ │ │ │ │ +
│ │ │ │ │ + CLOSE = CLOSE_WRITE | CLOSE_NOWRITE
│ │ │ │ │ + MOVE = MOVED_FROM | MOVED_TO
│ │ │ │ │ + CHANGED = (MODIFY | ATTRIB | CLOSE_WRITE | MOVE |
│ │ │ │ │ + CREATE | DELETE | DELETE_SELF | MOVE_SELF)
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import errno
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import stat
│ │ │ │ │ +from typing import Any, List, Optional
│ │ │ │ │ +
│ │ │ │ │ +from .event import Event
│ │ │ │ │ +from .inotify import Event as IN
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# inotify hard facts:
│ │ │ │ │ +#
│ │ │ │ │ +# DELETE_SELF doesn't get called until all references to an inode are gone
│ │ │ │ │ +# - including open fds
│ │ │ │ │ +# - including on directories
│ │ │ │ │ +#
│ │ │ │ │ +# ATTRIB gets called when unlinking files (because the link count changes) but
│ │ │ │ │ +# not on directories. When unlinking an open directory, no events at all
│ │ │ │ │ +# happen on the directory. ATTRIB also collects child events, which means we
│ │ │ │ │ +# get a lot of unwanted noise.
│ │ │ │ │ +#
│ │ │ │ │ +# There's nothing like UNLINK_SELF, unfortunately.
│ │ │ │ │ +#
│ │ │ │ │ +# Even if it was possible to take this approach, it might not work: after
│ │ │ │ │ +# you've opened the fd, it might get deleted before you can establish the watch
│ │ │ │ │ +# on it.
│ │ │ │ │ +#
│ │ │ │ │ +# Additionally, systemd makes it impossible to register those events on
│ │ │ │ │ +# symlinks (because it removes IN_DONT_FOLLOW in order to watch via
│ │ │ │ │ +# /proc/self/fd).
│ │ │ │ │ +#
│ │ │ │ │ +# For all of these reasons, unfortunately, the best way seems to be to watch
│ │ │ │ │ +# for CREATE|DELETE|MOVE events on each intermediate directory.
│ │ │ │ │ +#
│ │ │ │ │ +# Unfortunately there is no way to filter to only the name we're interested in,
│ │ │ │ │ +# so we're gonna get a lot of unnecessary wakeups.
│ │ │ │ │ +#
│ │ │ │ │ +# Also: due to the above-mentioned race about watching after opening the fd,
│ │ │ │ │ +# let's just always watch for both create and delete events *before* trying to
│ │ │ │ │ +# open the fd. We could try to reduce the mask after the fact, but meh...
│ │ │ │ │ +#
│ │ │ │ │ +# We use a WatchInvalidator utility class to fill the role of "Tell me when an
│ │ │ │ │ +# event happened on this (directory) fd which impacted the name file". We
│ │ │ │ │ +# build a series of these when setting up a watch in order to find out if any
│ │ │ │ │ +# part of the path leading to the monitored file changed.
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Handle(int):
│ │ │ │ │ + """An integer subclass that makes it easier to work with file descriptors"""
│ │ │ │ │ +
│ │ │ │ │ + def __new__(cls, fd: int = -1) -> 'Handle':
│ │ │ │ │ + return super(Handle, cls).__new__(cls, fd)
│ │ │ │ │ +
│ │ │ │ │ + # separate __init__() to set _needs_close mostly to keep pylint quiet
│ │ │ │ │ + def __init__(self, fd: int = -1):
│ │ │ │ │ + super().__init__()
│ │ │ │ │ + self._needs_close = fd != -1
│ │ │ │ │ +
│ │ │ │ │ + def __bool__(self) -> bool:
│ │ │ │ │ + return self != -1
│ │ │ │ │ +
│ │ │ │ │ + def close(self) -> None:
│ │ │ │ │ + if self._needs_close:
│ │ │ │ │ + self._needs_close = False
│ │ │ │ │ + os.close(self)
│ │ │ │ │ +
│ │ │ │ │ + def __eq__(self, value: object) -> bool:
│ │ │ │ │ + if int.__eq__(self, value): # also handles both == -1
│ │ │ │ │ + return True
│ │ │ │ │ +
│ │ │ │ │ + if not isinstance(value, int): # other object is not an int
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + if not self or not value: # when only one == -1
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + return os.path.sameopenfile(self, value)
│ │ │ │ │ +
│ │ │ │ │ + def __del__(self) -> None:
│ │ │ │ │ + if self._needs_close:
│ │ │ │ │ + self.close()
│ │ │ │ │ +
│ │ │ │ │ + def __enter__(self) -> 'Handle':
│ │ │ │ │ + return self
│ │ │ │ │ +
│ │ │ │ │ + def __exit__(self, _type: type, _value: object, _traceback: object) -> None:
│ │ │ │ │ + self.close()
│ │ │ │ │ +
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def open(cls, *args: Any, **kwargs: Any) -> 'Handle':
│ │ │ │ │ + return cls(os.open(*args, **kwargs))
│ │ │ │ │ +
│ │ │ │ │ + def steal(self) -> 'Handle':
│ │ │ │ │ + self._needs_close = False
│ │ │ │ │ + return self.__class__(int(self))
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class WatchInvalidator:
│ │ │ │ │ + _name: bytes
│ │ │ │ │ + _source: Optional[Event.Source]
│ │ │ │ │ + _watch: Optional['PathWatch']
│ │ │ │ │ +
│ │ │ │ │ + def event(self, mask: IN, _cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ + logger.debug('invalidator event %s %s', mask, name)
│ │ │ │ │ + if self._watch is not None:
│ │ │ │ │ + # If this node itself disappeared, that's definitely an
│ │ │ │ │ + # invalidation. Otherwise, the name needs to match.
│ │ │ │ │ + if IN.IGNORED in mask or self._name == name:
│ │ │ │ │ + logger.debug('Invalidating!')
│ │ │ │ │ + self._watch.invalidate()
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, watch: 'PathWatch', event: Event, dirfd: int, name: str):
│ │ │ │ │ + self._watch = watch
│ │ │ │ │ + self._name = name.encode('utf-8')
│ │ │ │ │ +
│ │ │ │ │ + # establishing invalidation watches is best-effort and can fail for a
│ │ │ │ │ + # number of reasons, including search (+x) but not read (+r) permission
│ │ │ │ │ + # on a particular path component, or exceeding limits on watches
│ │ │ │ │ + try:
│ │ │ │ │ + mask = IN.CREATE | IN.DELETE | IN.MOVE | IN.DELETE_SELF | IN.IGNORED
│ │ │ │ │ + self._source = event.add_inotify_fd(dirfd, mask, self.event)
│ │ │ │ │ + except OSError:
│ │ │ │ │ + self._source = None
│ │ │ │ │ +
│ │ │ │ │ + def close(self) -> None:
│ │ │ │ │ + # This is a little bit tricky: systemd doesn't have a specific close
│ │ │ │ │ + # API outside of unref, so let's make it as explicit as possible.
│ │ │ │ │ + self._watch = None
│ │ │ │ │ + self._source = None
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class PathStack(List[str]):
│ │ │ │ │ + def add_path(self, pathname: str) -> None:
│ │ │ │ │ + # TO DO: consider doing something reasonable with trailing slashes
│ │ │ │ │ + # this is a stack, popped from the end: push components in reverse
│ │ │ │ │ + self.extend(item for item in reversed(pathname.split('/')) if item)
│ │ │ │ │ + if pathname.startswith('/'):
│ │ │ │ │ + self.append('/')
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, path: str):
│ │ │ │ │ + super().__init__()
│ │ │ │ │ + self.add_path(path)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Listener:
│ │ │ │ │ + def do_inotify_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_identity_changed(self, fd: Optional[Handle], errno: Optional[int]) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class PathWatch:
│ │ │ │ │ + _event: Event
│ │ │ │ │ + _listener: Listener
│ │ │ │ │ + _path: str
│ │ │ │ │ + _invalidators: List[WatchInvalidator]
│ │ │ │ │ + _errno: Optional[int]
│ │ │ │ │ + _source: Optional[Event.Source]
│ │ │ │ │ + _tag: Optional[None]
│ │ │ │ │ + _fd: Handle
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, path: str, listener: Listener, event: Optional[Event] = None):
│ │ │ │ │ + self._event = event or Event.default()
│ │ │ │ │ + self._path = path
│ │ │ │ │ + self._listener = listener
│ │ │ │ │ +
│ │ │ │ │ + self._invalidators = []
│ │ │ │ │ + self._errno = None
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._tag = None
│ │ │ │ │ + self._fd = Handle()
│ │ │ │ │ +
│ │ │ │ │ + self.invalidate()
│ │ │ │ │ +
│ │ │ │ │ + def got_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ + logger.debug('target event %s: %s %s %s', self._path, mask, cookie, name)
│ │ │ │ │ + self._listener.do_inotify_event(mask, cookie, name)
│ │ │ │ │ +
│ │ │ │ │ + def invalidate(self) -> None:
│ │ │ │ │ + for invalidator in self._invalidators:
│ │ │ │ │ + invalidator.close()
│ │ │ │ │ + self._invalidators = []
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + fd = self.walk()
│ │ │ │ │ + except OSError as error:
│ │ │ │ │ + logger.debug('walk ended in error %d', error.errno)
│ │ │ │ │ +
│ │ │ │ │ + if self._source or self._fd or self._errno != error.errno:
│ │ │ │ │ + logger.debug('Ending existing watches.')
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._fd.close()
│ │ │ │ │ + self._fd = Handle()
│ │ │ │ │ + self._errno = error.errno
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Notifying of new error state %d', self._errno)
│ │ │ │ │ + self._listener.do_identity_changed(None, self._errno)
│ │ │ │ │ +
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + with fd:
│ │ │ │ │ + logger.debug('walk successful. Got fd %d', fd)
│ │ │ │ │ + if fd == self._fd:
│ │ │ │ │ + logger.debug('fd seems to refer to same file. Doing nothing.')
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('This file is new for us. Removing old watch.')
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._fd.close()
│ │ │ │ │ + self._fd = fd.steal()
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + logger.debug('Establishing a new watch.')
│ │ │ │ │ + self._source = self._event.add_inotify_fd(self._fd, IN.CHANGED, self.got_event)
│ │ │ │ │ + logger.debug('Watching successfully. Notifying of new identity.')
│ │ │ │ │ + self._listener.do_identity_changed(self._fd, None)
│ │ │ │ │ + except OSError as error:
│ │ │ │ │ + logger.debug('Watching failed (%d). Notifying of new identity.', error.errno)
│ │ │ │ │ + self._listener.do_identity_changed(self._fd, error.errno)
│ │ │ │ │ +
│ │ │ │ │ + def walk(self) -> Handle:
│ │ │ │ │ + remaining_symlink_lookups = 40
│ │ │ │ │ + remaining_components = PathStack(self._path)
│ │ │ │ │ + dirfd = Handle()
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + logger.debug('Starting path walk')
│ │ │ │ │ +
│ │ │ │ │ + while remaining_components:
│ │ │ │ │ + logger.debug('r=%s dfd=%s', remaining_components, dirfd)
│ │ │ │ │ +
│ │ │ │ │ + name = remaining_components.pop()
│ │ │ │ │ +
│ │ │ │ │ + if dirfd and name != '/':
│ │ │ │ │ + self._invalidators.append(WatchInvalidator(self, self._event, dirfd, name))
│ │ │ │ │ +
│ │ │ │ │ + with Handle.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=dirfd) as fd:
│ │ │ │ │ + mode = os.fstat(fd).st_mode
│ │ │ │ │ +
│ │ │ │ │ + if stat.S_ISLNK(mode):
│ │ │ │ │ + if remaining_symlink_lookups == 0:
│ │ │ │ │ + raise OSError(errno.ELOOP, os.strerror(errno.ELOOP))
│ │ │ │ │ + remaining_symlink_lookups -= 1
│ │ │ │ │ + linkpath = os.readlink('', dir_fd=fd)
│ │ │ │ │ + logger.debug('%s is a symlink. adding %s to components', name, linkpath)
│ │ │ │ │ + remaining_components.add_path(linkpath)
│ │ │ │ │ +
│ │ │ │ │ + else:
│ │ │ │ │ + dirfd.close()
│ │ │ │ │ + dirfd = fd.steal()
│ │ │ │ │ +
│ │ │ │ │ + return dirfd.steal()
│ │ │ │ │ +
│ │ │ │ │ + finally:
│ │ │ │ │ + dirfd.close()
│ │ │ │ │ +
│ │ │ │ │ + def close(self) -> None:
│ │ │ │ │ + for invalidator in self._invalidators:
│ │ │ │ │ + invalidator.close()
│ │ │ │ │ + self._invalidators = []
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._fd.close()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/bustypes.py': br'''# systemd_ctypes
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# This file is responsible for creating functions to (de)serialize Python
│ │ │ │ │ +# objects into and out of BusMessage objects.
│ │ │ │ │ +#
│ │ │ │ │ +# Each Type corresponds to a (possibly complex) D-Bus type. It has a .reader
│ │ │ │ │ +# and a .writer property. The readers take a message and deserialize a single
│ │ │ │ │ +# value from it, returning the value:
│ │ │ │ │ +#
│ │ │ │ │ +# def reader(message: BusMessage) -> object:
│ │ │ │ │ +#
│ │ │ │ │ +# The writers take a message and a value, and append the value to the message.
│ │ │ │ │ +#
│ │ │ │ │ +# def writer(message: BusMessage, value: object) -> None:
│ │ │ │ │ +#
│ │ │ │ │ +# The necessary information for the specific type of object to be handled is
│ │ │ │ │ +# part of the function. No additional information needs to be provided.
│ │ │ │ │ +
│ │ │ │ │ +import binascii
│ │ │ │ │ +import ctypes
│ │ │ │ │ +import functools
│ │ │ │ │ +import inspect
│ │ │ │ │ +import json
│ │ │ │ │ +import re
│ │ │ │ │ +from enum import Enum
│ │ │ │ │ +from typing import Any, Callable, ClassVar, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union
│ │ │ │ │ +
│ │ │ │ │ +from . import libsystemd, typing
│ │ │ │ │ +from .typing import Annotated, TypeGuard
│ │ │ │ │ +
│ │ │ │ │ +_object_path_re = re.compile(r'/|(/[A-Za-z0-9_]+)+')
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def is_object_path(candidate: str) -> TypeGuard['BusType.objectpath']:
│ │ │ │ │ + return _object_path_re.fullmatch(candidate) is not None
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def is_signature(candidate: str) -> TypeGuard['BusType.signature']:
│ │ │ │ │ + offset = 0
│ │ │ │ │ +
│ │ │ │ │ + def maybe_pop(acceptable: str) -> Optional[str]:
│ │ │ │ │ + nonlocal offset
│ │ │ │ │ + char = candidate[offset]
│ │ │ │ │ + if char in acceptable:
│ │ │ │ │ + offset += 1
│ │ │ │ │ + return char
│ │ │ │ │ + else:
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ + def pop(acceptable: str) -> str:
│ │ │ │ │ + char = maybe_pop(acceptable)
│ │ │ │ │ + assert char is not None
│ │ │ │ │ + return char
│ │ │ │ │ +
│ │ │ │ │ + def find_next() -> None:
│ │ │ │ │ + first = pop('ybnqiuxtsogdva(') # valid first characters
│ │ │ │ │ + if first == 'a':
│ │ │ │ │ + if maybe_pop('{'): # dict
│ │ │ │ │ + pop('ybnqiuxtsogd') # key
│ │ │ │ │ + find_next() # value
│ │ │ │ │ + pop('}')
│ │ │ │ │ + else: # array
│ │ │ │ │ + find_next() # item
│ │ │ │ │ + elif first == '(': # structure
│ │ │ │ │ + find_next() # at least one item
│ │ │ │ │ + while not maybe_pop(')'):
│ │ │ │ │ + find_next() # and possibly more
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + while offset < len(candidate):
│ │ │ │ │ + find_next()
│ │ │ │ │ + return True
│ │ │ │ │ + except (AssertionError, IndexError):
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def yield_base_helpers() -> Iterable[Tuple[str, object]]:
│ │ │ │ │ + for method in ['enter_container', 'exit_container', 'open_container', 'close_container',
│ │ │ │ │ + 'append_basic', 'read_basic', 'append_array', 'read_array']:
│ │ │ │ │ + yield method, libsystemd.libsystemd[f'sd_bus_message_{method}']
│ │ │ │ │ +
│ │ │ │ │ + for name in ['size_t', 'char_p']:
│ │ │ │ │ + instance = getattr(ctypes, f'c_{name}')()
│ │ │ │ │ + yield f'{name}', instance
│ │ │ │ │ + yield f'{name}_ref', ctypes.byref(instance)
│ │ │ │ │ + yield f'{name}_setter', instance.__class__.value.__set__
│ │ │ │ │ +
│ │ │ │ │ + for char in 'aervy':
│ │ │ │ │ + yield char, ctypes.c_char(ord(char))
│ │ │ │ │ +
│ │ │ │ │ + # https://docs.python.org/3/c-api/unicode.html#c.PyUnicode_FromString
│ │ │ │ │ + to_bytes = ctypes.pythonapi.PyBytes_FromStringAndSize
│ │ │ │ │ + to_bytes.restype = ctypes.py_object
│ │ │ │ │ + yield 'to_bytes', to_bytes
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +_base_helpers = dict(yield_base_helpers())
│ │ │ │ │ +
│ │ │ │ │ +T = TypeVar('T')
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def call_with_kwargs(func: Callable[..., T], kwargs: Dict[str, Any]) -> T:
│ │ │ │ │ + parameters = set(inspect.signature(func).parameters)
│ │ │ │ │ + return func(**{key: value for key, value in kwargs.items() if key in parameters})
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Type:
│ │ │ │ │ + _cache: ClassVar[Dict[Tuple[type, Tuple[object, ...]], 'Type']] = {}
│ │ │ │ │ +
│ │ │ │ │ + __slots__ = 'typestring', 'bytes_typestring', 'writer', 'reader'
│ │ │ │ │ + typestring: str
│ │ │ │ │ + bytes_typestring: bytes
│ │ │ │ │ + reader: Callable[[libsystemd.sd_bus_message], object]
│ │ │ │ │ + writer: Callable[[libsystemd.sd_bus_message, object], None]
│ │ │ │ │ +
│ │ │ │ │ + def __new__(cls, *args: Any) -> 'Type':
│ │ │ │ │ + instance = Type._cache.get((cls, args))
│ │ │ │ │ + if instance is None:
│ │ │ │ │ + instance = object.__new__(cls)
│ │ │ │ │ + Type._cache[(cls, args)] = instance
│ │ │ │ │ + return instance
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, typestring: str, **kwargs: Any):
│ │ │ │ │ + self.typestring = typestring
│ │ │ │ │ + self.bytes_typestring = typestring.encode('ascii')
│ │ │ │ │ +
│ │ │ │ │ + kwargs = dict(_base_helpers, **kwargs)
│ │ │ │ │ + self.writer = call_with_kwargs(self.get_writer, kwargs)
│ │ │ │ │ + self.reader = call_with_kwargs(self.get_reader, kwargs)
│ │ │ │ │ +
│ │ │ │ │ + def __repr__(self) -> str:
│ │ │ │ │ + return f"{self.__class__.__name__}('{self.typestring}')"
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, **kwargs: object) -> Callable[[object, object], None]:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def get_reader(self, **kwargs: object) -> Callable[[object], object]:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class BasicType(Type):
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, typestring: str, ctype, get_wrapper=None, **kwargs):
│ │ │ │ │ + variable = ctype() # NB: not thread-safe
│ │ │ │ │ + super().__init__(typestring, ctype=ctype, type_constant=ctypes.c_char(ord(typestring)),
│ │ │ │ │ + getter=get_wrapper or ctype.value.__get__, setter=ctype.value.__set__,
│ │ │ │ │ + variable=variable, reference=ctypes.byref(variable), **kwargs)
│ │ │ │ │ +
│ │ │ │ │ + def get_reader(self, read_basic, type_constant, variable, reference, getter):
│ │ │ │ │ + def basic_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ + if read_basic(message, type_constant, reference) <= 0:
│ │ │ │ │ + raise StopIteration
│ │ │ │ │ + return getter(variable)
│ │ │ │ │ + return basic_reader
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class FixedType(BasicType):
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, append_basic, type_constant, variable, reference, setter, getter):
│ │ │ │ │ + def fixed_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ + setter(variable, value)
│ │ │ │ │ + if getter(variable) != value:
│ │ │ │ │ + raise TypeError(f"Cannot represent value {value} with type '{self.typestring}'")
│ │ │ │ │ + append_basic(message, type_constant, reference)
│ │ │ │ │ + return fixed_writer
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class StringLikeType(BasicType):
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_guarded_conversion(typestring: str, guard: Callable[[str], bool]) -> Callable[[object], bytes]:
│ │ │ │ │ + def convert(candidate: object) -> bytes:
│ │ │ │ │ + if not isinstance(candidate, str):
│ │ │ │ │ + raise TypeError(f"'{typestring}' encodes 'str', not '{candidate.__class__.__name__}'")
│ │ │ │ │ + if not guard(candidate):
│ │ │ │ │ + raise ValueError(f"Invalid value provided for type '{typestring}'")
│ │ │ │ │ + return str.encode(candidate)
│ │ │ │ │ + return convert
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, typestring: str, guard: Optional[Callable[[str], bool]] = None):
│ │ │ │ │ + # https://docs.python.org/3/c-api/unicode.html#c.PyUnicode_FromString
│ │ │ │ │ + to_unicode = ctypes.pythonapi.PyUnicode_FromString
│ │ │ │ │ + to_unicode.restype = ctypes.py_object
│ │ │ │ │ +
│ │ │ │ │ + if guard is not None:
│ │ │ │ │ + convert = StringLikeType.get_guarded_conversion(typestring, guard)
│ │ │ │ │ + else:
│ │ │ │ │ + convert = str.encode # type: ignore[assignment] # can throw TypeError on call
│ │ │ │ │ +
│ │ │ │ │ + super().__init__(typestring, ctypes.c_char_p, to_unicode, convert=convert)
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, append_basic, type_constant, convert):
│ │ │ │ │ + def string_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ + append_basic(message, type_constant, convert(value))
│ │ │ │ │ + return string_writer
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class BytestringType(Type):
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, append_array, y, size_t_setter, size_t):
│ │ │ │ │ + def bytes_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ + if not isinstance(value, bytes):
│ │ │ │ │ + if isinstance(value, str):
│ │ │ │ │ + try:
│ │ │ │ │ + value = binascii.a2b_base64(value.encode('ascii')) # or decode base64
│ │ │ │ │ + except binascii.Error as exc:
│ │ │ │ │ + raise ValueError("'ay' cannot encode invalid base64 string") from exc
│ │ │ │ │ + elif isinstance(value, (memoryview, bytearray)):
│ │ │ │ │ + value = bytes(value)
│ │ │ │ │ + else:
│ │ │ │ │ + raise TypeError("'ay' can only encode bytes-like or base64 string objects, "
│ │ │ │ │ + f"not '{value.__class__.__name__}'.")
│ │ │ │ │ + size_t_setter(size_t, len(value))
│ │ │ │ │ + append_array(message, y, value, size_t)
│ │ │ │ │ + return bytes_writer
│ │ │ │ │ +
│ │ │ │ │ + def get_reader(self, read_array, y, to_bytes, char_p, char_p_ref, size_t, size_t_ref):
│ │ │ │ │ + def bytes_reader(message):
│ │ │ │ │ + if read_array(message, y, char_p_ref, size_t_ref) <= 0:
│ │ │ │ │ + raise StopIteration
│ │ │ │ │ + return to_bytes(char_p, size_t)
│ │ │ │ │ + return bytes_reader
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class ContainerType(Type):
│ │ │ │ │ + _typestring_template: ClassVar[str]
│ │ │ │ │ + __slots__ = 'item_types'
│ │ │ │ │ + item_types: Sequence[Type]
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, *item_types: Type, **kwargs: Any):
│ │ │ │ │ + assert len(item_types) > 0
│ │ │ │ │ + item_typestrings = ''.join(item.typestring for item in item_types)
│ │ │ │ │ + self.item_types = item_types
│ │ │ │ │ + super().__init__(self._typestring_template.replace('_', item_typestrings),
│ │ │ │ │ + type_contents=ctypes.c_char_p(item_typestrings.encode('ascii')),
│ │ │ │ │ + **kwargs)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class ArrayType(ContainerType):
│ │ │ │ │ + _typestring_template = 'a_'
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, item_type: Type):
│ │ │ │ │ + super().__init__(item_type,
│ │ │ │ │ + item_writer=item_type.writer,
│ │ │ │ │ + item_reader=item_type.reader,
│ │ │ │ │ + list_append=list.append)
│ │ │ │ │ +
│ │ │ │ │ + def get_reader(self, enter_container, exit_container, list_append, item_reader):
│ │ │ │ │ + def array_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ + if enter_container(message, 0, None) <= 0:
│ │ │ │ │ + raise StopIteration
│ │ │ │ │ + result: List[object] = []
│ │ │ │ │ + try:
│ │ │ │ │ + while True:
│ │ │ │ │ + list_append(result, item_reader(message))
│ │ │ │ │ + except StopIteration:
│ │ │ │ │ + return result
│ │ │ │ │ + finally:
│ │ │ │ │ + exit_container(message)
│ │ │ │ │ + return array_reader
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, a, type_contents, open_container, close_container, item_writer):
│ │ │ │ │ + def array_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ + open_container(message, a, type_contents)
│ │ │ │ │ + for item in value: # type: ignore[attr-defined] # can throw TypeError
│ │ │ │ │ + item_writer(message, item)
│ │ │ │ │ + close_container(message)
│ │ │ │ │ + return array_writer
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class StructType(ContainerType):
│ │ │ │ │ + _typestring_template = '(_)'
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + def get_reader(self, enter_container, exit_container):
│ │ │ │ │ + item_readers = tuple(item_type.reader for item_type in self.item_types)
│ │ │ │ │ +
│ │ │ │ │ + def array_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ + if enter_container(message, 0, None) <= 0:
│ │ │ │ │ + raise StopIteration
│ │ │ │ │ + result = tuple(item_reader(message) for item_reader in item_readers)
│ │ │ │ │ + exit_container(message)
│ │ │ │ │ + return result
│ │ │ │ │ + return array_reader
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, r, type_contents, open_container, close_container):
│ │ │ │ │ + item_writers = tuple(item_type.writer for item_type in self.item_types)
│ │ │ │ │ +
│ │ │ │ │ + def struct_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ + n_items = len(value) # type: ignore[arg-type] # can throw TypeError
│ │ │ │ │ + if n_items != len(item_writers):
│ │ │ │ │ + raise TypeError(f"Wrong numbers of items ({n_items}) for structure type '{self.typestring}'")
│ │ │ │ │ + open_container(message, r, type_contents)
│ │ │ │ │ + for item_writer, item in zip(item_writers, value): # type: ignore[call-overload] # can throw TypeError
│ │ │ │ │ + item_writer(message, item)
│ │ │ │ │ + close_container(message)
│ │ │ │ │ + return struct_writer
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class DictionaryType(ContainerType):
│ │ │ │ │ + _typestring_template = 'a{_}'
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, key_type: Type, value_type: Type):
│ │ │ │ │ + assert isinstance(key_type, BasicType)
│ │ │ │ │ + item_type = '{' + key_type.typestring + value_type.typestring + '}'
│ │ │ │ │ + super().__init__(key_type, value_type,
│ │ │ │ │ + key_reader=key_type.reader, key_writer=key_type.writer,
│ │ │ │ │ + value_reader=value_type.reader, value_writer=value_type.writer,
│ │ │ │ │ + item_type=ctypes.c_char_p(item_type.encode('ascii')))
│ │ │ │ │ +
│ │ │ │ │ + def get_reader(self, enter_container, exit_container, key_reader, value_reader):
│ │ │ │ │ + def dict_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ + if enter_container(message, 0, None) <= 0: # array
│ │ │ │ │ + raise StopIteration
│ │ │ │ │ + result = {}
│ │ │ │ │ + while enter_container(message, 0, None) > 0: # entry
│ │ │ │ │ + key = key_reader(message)
│ │ │ │ │ + value = value_reader(message)
│ │ │ │ │ + result[key] = value
│ │ │ │ │ + exit_container(message)
│ │ │ │ │ + exit_container(message)
│ │ │ │ │ + return result
│ │ │ │ │ + return dict_reader
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, a, item_type, e, type_contents, open_container, close_container, key_writer, value_writer):
│ │ │ │ │ + def dict_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ + open_container(message, a, item_type) # array
│ │ │ │ │ + for key, val in value.items(): # type: ignore[attr-defined] # can raise AttributeError
│ │ │ │ │ + open_container(message, e, type_contents) # entry
│ │ │ │ │ + key_writer(message, key) # key
│ │ │ │ │ + value_writer(message, val) # value
│ │ │ │ │ + close_container(message) # end entry
│ │ │ │ │ + close_container(message) # end array
│ │ │ │ │ + return dict_writer
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class VariantType(Type):
│ │ │ │ │ + __slots__ = ()
│ │ │ │ │ +
│ │ │ │ │ + def get_reader(self, enter_container, exit_container):
│ │ │ │ │ + def variant_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ + if enter_container(message, 0, None) <= 0:
│ │ │ │ │ + raise StopIteration
│ │ │ │ │ + typestring = message.get_signature(False)
│ │ │ │ │ + type_, = from_signature(typestring)
│ │ │ │ │ + value = type_.reader(message)
│ │ │ │ │ + exit_container(message)
│ │ │ │ │ + return Variant(value, type_)
│ │ │ │ │ + return variant_reader
│ │ │ │ │ +
│ │ │ │ │ + def get_writer(self, open_container, close_container, v):
│ │ │ │ │ + def variant_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ + if isinstance(value, Variant):
│ │ │ │ │ + type_ = value.type
│ │ │ │ │ + contents = value.value
│ │ │ │ │ + else:
│ │ │ │ │ + try:
│ │ │ │ │ + type_, = from_signature(value['t']) # type: ignore[index] # can throw TypeError
│ │ │ │ │ + contents = value['v'] # type: ignore[index] # can throw TypeError
│ │ │ │ │ + except KeyError as exc:
│ │ │ │ │ + raise TypeError("'v' can encode Variant objects, or mappings with 't' and 'v' keys") from exc
│ │ │ │ │ +
│ │ │ │ │ + open_container(message, v, type_.bytes_typestring)
│ │ │ │ │ + type_.writer(message, contents)
│ │ │ │ │ + close_container(message)
│ │ │ │ │ + return variant_writer
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Variant:
│ │ │ │ │ + __slots__ = 'type', 'value'
│ │ │ │ │ + type: Type
│ │ │ │ │ + value: object
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, value: object, hint: object = None):
│ │ │ │ │ + if isinstance(hint, Type):
│ │ │ │ │ + self.type = hint
│ │ │ │ │ + elif isinstance(hint, str):
│ │ │ │ │ + self.type, = from_signature(hint)
│ │ │ │ │ + else:
│ │ │ │ │ + self.type = from_annotation(hint or value.__class__)
│ │ │ │ │ + self.value = value
│ │ │ │ │ +
│ │ │ │ │ + def __repr__(self) -> str:
│ │ │ │ │ + return f"systemd_ctypes.Variant({self.value}, '{self.type.typestring}')"
│ │ │ │ │ +
│ │ │ │ │ + def __eq__(self, other: object) -> bool:
│ │ │ │ │ + if isinstance(other, Variant):
│ │ │ │ │ + return self.type == other.type and self.value == other.value
│ │ │ │ │ + elif isinstance(other, dict):
│ │ │ │ │ + return (self.type,) == from_signature(other['t']) and self.value == other['v']
│ │ │ │ │ + else:
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + def __hash__(self) -> int:
│ │ │ │ │ + return hash(self.type) ^ hash(self.value)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class BusType(Enum):
│ │ │ │ │ + boolean = Annotated[bool, FixedType('b', ctypes.c_int, ctypes.c_int.__bool__)]
│ │ │ │ │ + byte = Annotated[int, FixedType('y', ctypes.c_uint8)]
│ │ │ │ │ + int16 = Annotated[int, FixedType('n', ctypes.c_int16)]
│ │ │ │ │ + uint16 = Annotated[int, FixedType('q', ctypes.c_uint16)]
│ │ │ │ │ + int32 = Annotated[int, FixedType('i', ctypes.c_int32)]
│ │ │ │ │ + uint32 = Annotated[int, FixedType('u', ctypes.c_uint32)]
│ │ │ │ │ + int64 = Annotated[int, FixedType('x', ctypes.c_int64)]
│ │ │ │ │ + uint64 = Annotated[int, FixedType('t', ctypes.c_uint64)]
│ │ │ │ │ + double = Annotated[float, FixedType('d', ctypes.c_double)]
│ │ │ │ │ + string = Annotated[str, StringLikeType('s')]
│ │ │ │ │ + objectpath = Annotated[str, StringLikeType('o', is_object_path)]
│ │ │ │ │ + signature = Annotated[str, StringLikeType('g', is_signature)]
│ │ │ │ │ + bytestring = Annotated[bytes, BytestringType('ay')]
│ │ │ │ │ + variant = Annotated[dict, VariantType('v')]
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# mypy gets confused by enums, so just use Any
│ │ │ │ │ +_base_equivalence_map: Dict[type, Any] = {
│ │ │ │ │ + bool: BusType.boolean,
│ │ │ │ │ + bytes: BusType.bytestring,
│ │ │ │ │ + int: BusType.int32,
│ │ │ │ │ + str: BusType.string,
│ │ │ │ │ + Variant: BusType.variant,
│ │ │ │ │ +}
│ │ │ │ │ +
│ │ │ │ │ +_factory_map: Dict[object, Callable[..., Type]] = {
│ │ │ │ │ + dict: DictionaryType, Dict: DictionaryType,
│ │ │ │ │ + list: ArrayType, List: ArrayType,
│ │ │ │ │ + tuple: StructType, Tuple: StructType,
│ │ │ │ │ +}
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +@functools.lru_cache()
│ │ │ │ │ +def from_annotation(annotation: Union[str, type, BusType]) -> Type:
│ │ │ │ │ + # Simple Python types
│ │ │ │ │ + if isinstance(annotation, str):
│ │ │ │ │ + types = from_signature(annotation)
│ │ │ │ │ + if len(types) != 1:
│ │ │ │ │ + raise TypeError(f"Signature '{annotation}' invalid as a type string "
│ │ │ │ │ + f"because it describes {len(types)} types, not one.")
│ │ │ │ │ + return types[0]
│ │ │ │ │ +
│ │ │ │ │ + if isinstance(annotation, type):
│ │ │ │ │ + annotation = _base_equivalence_map.get(annotation, annotation)
│ │ │ │ │ +
│ │ │ │ │ + # Our own BusType types
│ │ │ │ │ + if isinstance(annotation, BusType):
│ │ │ │ │ + bus_type = typing.get_args(annotation.value)[1]
│ │ │ │ │ + assert isinstance(bus_type, Type)
│ │ │ │ │ + return bus_type
│ │ │ │ │ +
│ │ │ │ │ + # Container types
│ │ │ │ │ + try:
│ │ │ │ │ + factory = _factory_map[typing.get_origin(annotation)]
│ │ │ │ │ + args = [from_annotation(arg) for arg in typing.get_args(annotation)]
│ │ │ │ │ + return factory(*args)
│ │ │ │ │ + except (AssertionError, AttributeError, KeyError, TypeError):
│ │ │ │ │ + raise TypeError(f"Cannot interpret {annotation} as a dbus type") from None
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +_base_typestring_map: Dict[str, Type] = {
│ │ │ │ │ + bustype.typestring: bustype for bustype in (from_annotation(entry) for entry in BusType)
│ │ │ │ │ +}
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +@functools.lru_cache()
│ │ │ │ │ +def from_signature(signature: str) -> Tuple[Type, ...]:
│ │ │ │ │ + stack = list(reversed(signature))
│ │ │ │ │ +
│ │ │ │ │ + def get_one() -> Type:
│ │ │ │ │ + first = stack.pop()
│ │ │ │ │ + if first == 'a':
│ │ │ │ │ + if stack[-1] == 'y':
│ │ │ │ │ + first += stack.pop()
│ │ │ │ │ + elif stack[-1] == '{':
│ │ │ │ │ + stack.pop()
│ │ │ │ │ + return DictionaryType(*get_several('}'))
│ │ │ │ │ + else:
│ │ │ │ │ + return ArrayType(get_one())
│ │ │ │ │ + elif first == '(':
│ │ │ │ │ + return StructType(*get_several(')'))
│ │ │ │ │ +
│ │ │ │ │ + return _base_typestring_map[first]
│ │ │ │ │ +
│ │ │ │ │ + def get_several(end: str) -> Iterable[Type]:
│ │ │ │ │ + yield get_one()
│ │ │ │ │ + while stack[-1] != end:
│ │ │ │ │ + yield get_one()
│ │ │ │ │ + stack.pop()
│ │ │ │ │ +
│ │ │ │ │ + def get_all() -> Iterable[Type]:
│ │ │ │ │ + while stack:
│ │ │ │ │ + yield get_one()
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + return tuple(get_all())
│ │ │ │ │ + except (AssertionError, IndexError, KeyError) as exc:
│ │ │ │ │ + raise TypeError(f"Invalid type signature '{signature}'") from exc
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class MessageType:
│ │ │ │ │ + item_types: Sequence[Type]
│ │ │ │ │ + typestrings: List[str]
│ │ │ │ │ + signature: str
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, item_types: Sequence[Union[str, type, BusType]]):
│ │ │ │ │ + self.item_types = [from_annotation(item_type) for item_type in item_types]
│ │ │ │ │ + self.typestrings = [item_type.typestring for item_type in self.item_types]
│ │ │ │ │ + self.signature = ''.join(self.typestrings)
│ │ │ │ │ +
│ │ │ │ │ + def write(self, message: libsystemd.sd_bus_message, *items: object) -> None:
│ │ │ │ │ + assert len(items) == len(self.item_types)
│ │ │ │ │ + for item_type, item in zip(self.item_types, items):
│ │ │ │ │ + item_type.writer(message, item)
│ │ │ │ │ +
│ │ │ │ │ + def read(self, message: libsystemd.sd_bus_message) -> Optional[Tuple[object, ...]]:
│ │ │ │ │ + if not message.has_signature(self.signature):
│ │ │ │ │ + return None
│ │ │ │ │ + return tuple(item_type.reader(message) for item_type in self.item_types)
│ │ │ │ │ +
│ │ │ │ │ + def __len__(self) -> int:
│ │ │ │ │ + return len(self.item_types)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class JSONEncoder(json.JSONEncoder):
│ │ │ │ │ + def default(self, obj: object) -> object:
│ │ │ │ │ + if isinstance(obj, Variant):
│ │ │ │ │ + return {"t": obj.type.typestring, "v": obj.value}
│ │ │ │ │ + elif isinstance(obj, bytes):
│ │ │ │ │ + return binascii.b2a_base64(obj, newline=False).decode('ascii')
│ │ │ │ │ + return super().default(obj)
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/event.py': br'''# systemd_ctypes
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -8964,15 +8692,15 @@
│ │ │ │ │ asyncio._systemd_ctypes_polyfills = True # type: ignore[attr-defined]
│ │ │ │ │
│ │ │ │ │ asyncio.run(main, debug=debug)
│ │ │ │ │
│ │ │ │ │ if polyfill:
│ │ │ │ │ del asyncio.create_task, asyncio.get_running_loop, asyncio.run
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/__init__.py': br'''# systemd_ctypes
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -8981,71 +8709,129 @@
│ │ │ │ │ # 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 .
│ │ │ │ │
│ │ │ │ │ -import ctypes
│ │ │ │ │ -from enum import IntFlag, auto
│ │ │ │ │ -from typing import Optional
│ │ │ │ │ +"""systemd_ctypes"""
│ │ │ │ │
│ │ │ │ │ +__version__ = "0"
│ │ │ │ │
│ │ │ │ │ -class inotify_event(ctypes.Structure):
│ │ │ │ │ - _fields_ = (
│ │ │ │ │ - ('wd', ctypes.c_int32),
│ │ │ │ │ - ('mask', ctypes.c_uint32),
│ │ │ │ │ - ('cookie', ctypes.c_uint32),
│ │ │ │ │ - ('len', ctypes.c_uint32),
│ │ │ │ │ - )
│ │ │ │ │ +from .bus import Bus, BusError, BusMessage
│ │ │ │ │ +from .bustypes import BusType, JSONEncoder, Variant
│ │ │ │ │ +from .event import Event, EventLoopPolicy, run_async
│ │ │ │ │ +from .pathwatch import Handle, PathWatch
│ │ │ │ │
│ │ │ │ │ - @property
│ │ │ │ │ - def name(self) -> Optional[bytes]:
│ │ │ │ │ - if self.len == 0:
│ │ │ │ │ - return None
│ │ │ │ │ +__all__ = [
│ │ │ │ │ + "Bus",
│ │ │ │ │ + "BusError",
│ │ │ │ │ + "BusMessage",
│ │ │ │ │ + "BusType",
│ │ │ │ │ + "Event",
│ │ │ │ │ + "EventLoopPolicy",
│ │ │ │ │ + "Handle",
│ │ │ │ │ + "JSONEncoder",
│ │ │ │ │ + "PathWatch",
│ │ │ │ │ + "Variant",
│ │ │ │ │ + "run_async",
│ │ │ │ │ +]
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/introspection.py': br'''# systemd_ctypes
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │
│ │ │ │ │ - class event_with_name(ctypes.Structure):
│ │ │ │ │ - _fields_ = (*inotify_event._fields_, ('name', ctypes.c_char * self.len))
│ │ │ │ │ +import xml.etree.ElementTree as ET
│ │ │ │ │
│ │ │ │ │ - name = ctypes.cast(ctypes.addressof(self), ctypes.POINTER(event_with_name)).contents.name
│ │ │ │ │ - assert isinstance(name, bytes)
│ │ │ │ │ - return name
│ │ │ │ │
│ │ │ │ │ +def parse_method(method):
│ │ │ │ │ + return {
│ │ │ │ │ + "in": [tag.attrib['type'] for tag in method.findall("arg") if tag.get('direction', 'in') == 'in'],
│ │ │ │ │ + "out": [tag.attrib['type'] for tag in method.findall("arg[@direction='out']")]
│ │ │ │ │ + }
│ │ │ │ │
│ │ │ │ │ -class Event(IntFlag):
│ │ │ │ │ - ACCESS = auto()
│ │ │ │ │ - MODIFY = auto()
│ │ │ │ │ - ATTRIB = auto()
│ │ │ │ │ - CLOSE_WRITE = auto()
│ │ │ │ │ - CLOSE_NOWRITE = auto()
│ │ │ │ │ - OPEN = auto()
│ │ │ │ │ - MOVED_FROM = auto()
│ │ │ │ │ - MOVED_TO = auto()
│ │ │ │ │ - CREATE = auto()
│ │ │ │ │ - DELETE = auto()
│ │ │ │ │ - DELETE_SELF = auto()
│ │ │ │ │ - MOVE_SELF = auto()
│ │ │ │ │
│ │ │ │ │ - UNMOUNT = 1 << 13
│ │ │ │ │ - Q_OVERFLOW = auto()
│ │ │ │ │ - IGNORED = auto()
│ │ │ │ │ +def parse_property(prop):
│ │ │ │ │ + return {
│ │ │ │ │ + "flags": 'w' if prop.attrib.get('access') == 'write' else 'r',
│ │ │ │ │ + "type": prop.attrib['type']
│ │ │ │ │ + }
│ │ │ │ │
│ │ │ │ │ - ONLYDIR = 1 << 24
│ │ │ │ │ - DONT_FOLLOW = auto()
│ │ │ │ │ - EXCL_UNLINK = auto()
│ │ │ │ │
│ │ │ │ │ - MASK_CREATE = 1 << 28
│ │ │ │ │ - MASK_ADD = auto()
│ │ │ │ │ - ISDIR = auto()
│ │ │ │ │ - ONESHOT = auto()
│ │ │ │ │ +def parse_signal(signal):
│ │ │ │ │ + return {"in": [tag.attrib['type'] for tag in signal.findall("arg")]}
│ │ │ │ │
│ │ │ │ │ - CLOSE = CLOSE_WRITE | CLOSE_NOWRITE
│ │ │ │ │ - MOVE = MOVED_FROM | MOVED_TO
│ │ │ │ │ - CHANGED = (MODIFY | ATTRIB | CLOSE_WRITE | MOVE |
│ │ │ │ │ - CREATE | DELETE | DELETE_SELF | MOVE_SELF)
│ │ │ │ │ +
│ │ │ │ │ +def parse_interface(interface):
│ │ │ │ │ + return {
│ │ │ │ │ + "methods": {tag.attrib['name']: parse_method(tag) for tag in interface.findall('method')},
│ │ │ │ │ + "properties": {tag.attrib['name']: parse_property(tag) for tag in interface.findall('property')},
│ │ │ │ │ + "signals": {tag.attrib['name']: parse_signal(tag) for tag in interface.findall('signal')}
│ │ │ │ │ + }
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def parse_xml(xml):
│ │ │ │ │ + et = ET.fromstring(xml)
│ │ │ │ │ + return {tag.attrib['name']: parse_interface(tag) for tag in et.findall('interface')}
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# Pretend like this is a little bit functional
│ │ │ │ │ +def element(tag, children=(), **kwargs):
│ │ │ │ │ + tag = ET.Element(tag, kwargs)
│ │ │ │ │ + tag.extend(children)
│ │ │ │ │ + return tag
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def method_to_xml(name, method_info):
│ │ │ │ │ + return element('method', name=name,
│ │ │ │ │ + children=[
│ │ │ │ │ + element('arg', type=arg_type, direction=direction)
│ │ │ │ │ + for direction in ['in', 'out']
│ │ │ │ │ + for arg_type in method_info[direction]
│ │ │ │ │ + ])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def property_to_xml(name, property_info):
│ │ │ │ │ + return element('property', name=name,
│ │ │ │ │ + access='write' if property_info['flags'] == 'w' else 'read',
│ │ │ │ │ + type=property_info['type'])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def signal_to_xml(name, signal_info):
│ │ │ │ │ + return element('signal', name=name,
│ │ │ │ │ + children=[
│ │ │ │ │ + element('arg', type=arg_type) for arg_type in signal_info['in']
│ │ │ │ │ + ])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def interface_to_xml(name, interface_info):
│ │ │ │ │ + return element('interface', name=name,
│ │ │ │ │ + children=[
│ │ │ │ │ + *(method_to_xml(name, info) for name, info in interface_info['methods'].items()),
│ │ │ │ │ + *(property_to_xml(name, info) for name, info in interface_info['properties'].items()),
│ │ │ │ │ + *(signal_to_xml(name, info) for name, info in interface_info['signals'].items()),
│ │ │ │ │ + ])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def to_xml(interfaces):
│ │ │ │ │ + node = element('node', children=(interface_to_xml(name, members) for name, members in interfaces.items()))
│ │ │ │ │ + return ET.tostring(node, encoding='unicode')
│ │ │ │ │ ''',
│ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/libsystemd.py': r'''# systemd_ctypes
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ @@ -9374,571 +9160,15 @@
│ │ │ │ │ sd_bus_message,
│ │ │ │ │ sd_bus_slot,
│ │ │ │ │ sd_event,
│ │ │ │ │ sd_event_source,
│ │ │ │ │ }:
│ │ │ │ │ cls._install_cfuncs(libsystemd)
│ │ │ │ │ '''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import errno
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import stat
│ │ │ │ │ -from typing import Any, List, Optional
│ │ │ │ │ -
│ │ │ │ │ -from .event import Event
│ │ │ │ │ -from .inotify import Event as IN
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# inotify hard facts:
│ │ │ │ │ -#
│ │ │ │ │ -# DELETE_SELF doesn't get called until all references to an inode are gone
│ │ │ │ │ -# - including open fds
│ │ │ │ │ -# - including on directories
│ │ │ │ │ -#
│ │ │ │ │ -# ATTRIB gets called when unlinking files (because the link count changes) but
│ │ │ │ │ -# not on directories. When unlinking an open directory, no events at all
│ │ │ │ │ -# happen on the directory. ATTRIB also collects child events, which means we
│ │ │ │ │ -# get a lot of unwanted noise.
│ │ │ │ │ -#
│ │ │ │ │ -# There's nothing like UNLINK_SELF, unfortunately.
│ │ │ │ │ -#
│ │ │ │ │ -# Even if it was possible to take this approach, it might not work: after
│ │ │ │ │ -# you've opened the fd, it might get deleted before you can establish the watch
│ │ │ │ │ -# on it.
│ │ │ │ │ -#
│ │ │ │ │ -# Additionally, systemd makes it impossible to register those events on
│ │ │ │ │ -# symlinks (because it removes IN_DONT_FOLLOW in order to watch via
│ │ │ │ │ -# /proc/self/fd).
│ │ │ │ │ -#
│ │ │ │ │ -# For all of these reasons, unfortunately, the best way seems to be to watch
│ │ │ │ │ -# for CREATE|DELETE|MOVE events on each intermediate directory.
│ │ │ │ │ -#
│ │ │ │ │ -# Unfortunately there is no way to filter to only the name we're interested in,
│ │ │ │ │ -# so we're gonna get a lot of unnecessary wakeups.
│ │ │ │ │ -#
│ │ │ │ │ -# Also: due to the above-mentioned race about watching after opening the fd,
│ │ │ │ │ -# let's just always watch for both create and delete events *before* trying to
│ │ │ │ │ -# open the fd. We could try to reduce the mask after the fact, but meh...
│ │ │ │ │ -#
│ │ │ │ │ -# We use a WatchInvalidator utility class to fill the role of "Tell me when an
│ │ │ │ │ -# event happened on this (directory) fd which impacted the name file". We
│ │ │ │ │ -# build a series of these when setting up a watch in order to find out if any
│ │ │ │ │ -# part of the path leading to the monitored file changed.
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Handle(int):
│ │ │ │ │ - """An integer subclass that makes it easier to work with file descriptors"""
│ │ │ │ │ -
│ │ │ │ │ - def __new__(cls, fd: int = -1) -> 'Handle':
│ │ │ │ │ - return super(Handle, cls).__new__(cls, fd)
│ │ │ │ │ -
│ │ │ │ │ - # separate __init__() to set _needs_close mostly to keep pylint quiet
│ │ │ │ │ - def __init__(self, fd: int = -1):
│ │ │ │ │ - super().__init__()
│ │ │ │ │ - self._needs_close = fd != -1
│ │ │ │ │ -
│ │ │ │ │ - def __bool__(self) -> bool:
│ │ │ │ │ - return self != -1
│ │ │ │ │ -
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - if self._needs_close:
│ │ │ │ │ - self._needs_close = False
│ │ │ │ │ - os.close(self)
│ │ │ │ │ -
│ │ │ │ │ - def __eq__(self, value: object) -> bool:
│ │ │ │ │ - if int.__eq__(self, value): # also handles both == -1
│ │ │ │ │ - return True
│ │ │ │ │ -
│ │ │ │ │ - if not isinstance(value, int): # other object is not an int
│ │ │ │ │ - return False
│ │ │ │ │ -
│ │ │ │ │ - if not self or not value: # when only one == -1
│ │ │ │ │ - return False
│ │ │ │ │ -
│ │ │ │ │ - return os.path.sameopenfile(self, value)
│ │ │ │ │ -
│ │ │ │ │ - def __del__(self) -> None:
│ │ │ │ │ - if self._needs_close:
│ │ │ │ │ - self.close()
│ │ │ │ │ -
│ │ │ │ │ - def __enter__(self) -> 'Handle':
│ │ │ │ │ - return self
│ │ │ │ │ -
│ │ │ │ │ - def __exit__(self, _type: type, _value: object, _traceback: object) -> None:
│ │ │ │ │ - self.close()
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def open(cls, *args: Any, **kwargs: Any) -> 'Handle':
│ │ │ │ │ - return cls(os.open(*args, **kwargs))
│ │ │ │ │ -
│ │ │ │ │ - def steal(self) -> 'Handle':
│ │ │ │ │ - self._needs_close = False
│ │ │ │ │ - return self.__class__(int(self))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class WatchInvalidator:
│ │ │ │ │ - _name: bytes
│ │ │ │ │ - _source: Optional[Event.Source]
│ │ │ │ │ - _watch: Optional['PathWatch']
│ │ │ │ │ -
│ │ │ │ │ - def event(self, mask: IN, _cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ - logger.debug('invalidator event %s %s', mask, name)
│ │ │ │ │ - if self._watch is not None:
│ │ │ │ │ - # If this node itself disappeared, that's definitely an
│ │ │ │ │ - # invalidation. Otherwise, the name needs to match.
│ │ │ │ │ - if IN.IGNORED in mask or self._name == name:
│ │ │ │ │ - logger.debug('Invalidating!')
│ │ │ │ │ - self._watch.invalidate()
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, watch: 'PathWatch', event: Event, dirfd: int, name: str):
│ │ │ │ │ - self._watch = watch
│ │ │ │ │ - self._name = name.encode('utf-8')
│ │ │ │ │ -
│ │ │ │ │ - # establishing invalidation watches is best-effort and can fail for a
│ │ │ │ │ - # number of reasons, including search (+x) but not read (+r) permission
│ │ │ │ │ - # on a particular path component, or exceeding limits on watches
│ │ │ │ │ - try:
│ │ │ │ │ - mask = IN.CREATE | IN.DELETE | IN.MOVE | IN.DELETE_SELF | IN.IGNORED
│ │ │ │ │ - self._source = event.add_inotify_fd(dirfd, mask, self.event)
│ │ │ │ │ - except OSError:
│ │ │ │ │ - self._source = None
│ │ │ │ │ -
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - # This is a little bit tricky: systemd doesn't have a specific close
│ │ │ │ │ - # API outside of unref, so let's make it as explicit as possible.
│ │ │ │ │ - self._watch = None
│ │ │ │ │ - self._source = None
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PathStack(List[str]):
│ │ │ │ │ - def add_path(self, pathname: str) -> None:
│ │ │ │ │ - # TO DO: consider doing something reasonable with trailing slashes
│ │ │ │ │ - # this is a stack, popped from the end: push components in reverse
│ │ │ │ │ - self.extend(item for item in reversed(pathname.split('/')) if item)
│ │ │ │ │ - if pathname.startswith('/'):
│ │ │ │ │ - self.append('/')
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, path: str):
│ │ │ │ │ - super().__init__()
│ │ │ │ │ - self.add_path(path)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Listener:
│ │ │ │ │ - def do_inotify_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ - def do_identity_changed(self, fd: Optional[Handle], errno: Optional[int]) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PathWatch:
│ │ │ │ │ - _event: Event
│ │ │ │ │ - _listener: Listener
│ │ │ │ │ - _path: str
│ │ │ │ │ - _invalidators: List[WatchInvalidator]
│ │ │ │ │ - _errno: Optional[int]
│ │ │ │ │ - _source: Optional[Event.Source]
│ │ │ │ │ - _tag: Optional[None]
│ │ │ │ │ - _fd: Handle
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, path: str, listener: Listener, event: Optional[Event] = None):
│ │ │ │ │ - self._event = event or Event.default()
│ │ │ │ │ - self._path = path
│ │ │ │ │ - self._listener = listener
│ │ │ │ │ -
│ │ │ │ │ - self._invalidators = []
│ │ │ │ │ - self._errno = None
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._tag = None
│ │ │ │ │ - self._fd = Handle()
│ │ │ │ │ -
│ │ │ │ │ - self.invalidate()
│ │ │ │ │ -
│ │ │ │ │ - def got_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ - logger.debug('target event %s: %s %s %s', self._path, mask, cookie, name)
│ │ │ │ │ - self._listener.do_inotify_event(mask, cookie, name)
│ │ │ │ │ -
│ │ │ │ │ - def invalidate(self) -> None:
│ │ │ │ │ - for invalidator in self._invalidators:
│ │ │ │ │ - invalidator.close()
│ │ │ │ │ - self._invalidators = []
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - fd = self.walk()
│ │ │ │ │ - except OSError as error:
│ │ │ │ │ - logger.debug('walk ended in error %d', error.errno)
│ │ │ │ │ -
│ │ │ │ │ - if self._source or self._fd or self._errno != error.errno:
│ │ │ │ │ - logger.debug('Ending existing watches.')
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._fd.close()
│ │ │ │ │ - self._fd = Handle()
│ │ │ │ │ - self._errno = error.errno
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('Notifying of new error state %d', self._errno)
│ │ │ │ │ - self._listener.do_identity_changed(None, self._errno)
│ │ │ │ │ -
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - with fd:
│ │ │ │ │ - logger.debug('walk successful. Got fd %d', fd)
│ │ │ │ │ - if fd == self._fd:
│ │ │ │ │ - logger.debug('fd seems to refer to same file. Doing nothing.')
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - logger.debug('This file is new for us. Removing old watch.')
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._fd.close()
│ │ │ │ │ - self._fd = fd.steal()
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - logger.debug('Establishing a new watch.')
│ │ │ │ │ - self._source = self._event.add_inotify_fd(self._fd, IN.CHANGED, self.got_event)
│ │ │ │ │ - logger.debug('Watching successfully. Notifying of new identity.')
│ │ │ │ │ - self._listener.do_identity_changed(self._fd, None)
│ │ │ │ │ - except OSError as error:
│ │ │ │ │ - logger.debug('Watching failed (%d). Notifying of new identity.', error.errno)
│ │ │ │ │ - self._listener.do_identity_changed(self._fd, error.errno)
│ │ │ │ │ -
│ │ │ │ │ - def walk(self) -> Handle:
│ │ │ │ │ - remaining_symlink_lookups = 40
│ │ │ │ │ - remaining_components = PathStack(self._path)
│ │ │ │ │ - dirfd = Handle()
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - logger.debug('Starting path walk')
│ │ │ │ │ -
│ │ │ │ │ - while remaining_components:
│ │ │ │ │ - logger.debug('r=%s dfd=%s', remaining_components, dirfd)
│ │ │ │ │ -
│ │ │ │ │ - name = remaining_components.pop()
│ │ │ │ │ -
│ │ │ │ │ - if dirfd and name != '/':
│ │ │ │ │ - self._invalidators.append(WatchInvalidator(self, self._event, dirfd, name))
│ │ │ │ │ -
│ │ │ │ │ - with Handle.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=dirfd) as fd:
│ │ │ │ │ - mode = os.fstat(fd).st_mode
│ │ │ │ │ -
│ │ │ │ │ - if stat.S_ISLNK(mode):
│ │ │ │ │ - if remaining_symlink_lookups == 0:
│ │ │ │ │ - raise OSError(errno.ELOOP, os.strerror(errno.ELOOP))
│ │ │ │ │ - remaining_symlink_lookups -= 1
│ │ │ │ │ - linkpath = os.readlink('', dir_fd=fd)
│ │ │ │ │ - logger.debug('%s is a symlink. adding %s to components', name, linkpath)
│ │ │ │ │ - remaining_components.add_path(linkpath)
│ │ │ │ │ -
│ │ │ │ │ - else:
│ │ │ │ │ - dirfd.close()
│ │ │ │ │ - dirfd = fd.steal()
│ │ │ │ │ -
│ │ │ │ │ - return dirfd.steal()
│ │ │ │ │ -
│ │ │ │ │ - finally:
│ │ │ │ │ - dirfd.close()
│ │ │ │ │ -
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - for invalidator in self._invalidators:
│ │ │ │ │ - invalidator.close()
│ │ │ │ │ - self._invalidators = []
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._fd.close()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/librarywrapper.py': br'''# systemd_ctypes
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ -
│ │ │ │ │ -import ctypes
│ │ │ │ │ -import inspect
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import sys
│ │ │ │ │ -import types
│ │ │ │ │ -from typing import (
│ │ │ │ │ - Any,
│ │ │ │ │ - Callable,
│ │ │ │ │ - Dict,
│ │ │ │ │ - Generic,
│ │ │ │ │ - NewType,
│ │ │ │ │ - NoReturn,
│ │ │ │ │ - Optional,
│ │ │ │ │ - Tuple,
│ │ │ │ │ - Type,
│ │ │ │ │ - TypeVar,
│ │ │ │ │ - Union,
│ │ │ │ │ -)
│ │ │ │ │ -
│ │ │ │ │ -from . import typing
│ │ │ │ │ -
│ │ │ │ │ -# First in 3.10, and conditional import gives type errors
│ │ │ │ │ -NoneType = type(None)
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -if typing.TYPE_CHECKING:
│ │ │ │ │ - CType = TypeVar("CType", bound=ctypes._CData)
│ │ │ │ │ - Callback = ctypes._FuncPointer
│ │ │ │ │ -else:
│ │ │ │ │ - CType = TypeVar("CType")
│ │ │ │ │ - Callback = ctypes.c_void_p
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if typing.TYPE_CHECKING:
│ │ │ │ │ - class Reference(Generic[CType], ctypes._Pointer[CType]):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - def byref(x: CType) -> Reference[CType]:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -else:
│ │ │ │ │ - class Reference(Generic[CType]):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - byref = ctypes.byref
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -UserData = Optional[ctypes.c_void_p]
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class negative_errno(ctypes.c_int):
│ │ │ │ │ - def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> int:
│ │ │ │ │ - result = self.value
│ │ │ │ │ - if result < 0:
│ │ │ │ │ - raise OSError(-result, f"{func.__name__}: {os.strerror(-result)}")
│ │ │ │ │ - return result
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class utf8(ctypes.c_char_p):
│ │ │ │ │ - def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> str:
│ │ │ │ │ - assert self.value is not None
│ │ │ │ │ - return self.value.decode()
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def from_param(cls, value: str) -> 'utf8':
│ │ │ │ │ - return cls(value.encode())
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class utf8_or_null(ctypes.c_char_p):
│ │ │ │ │ - def errcheck(self,
│ │ │ │ │ - func: Callable[..., object],
│ │ │ │ │ - _args: Tuple[object, ...]) -> Optional[str]:
│ │ │ │ │ - return self.value.decode() if self.value is not None else None
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def from_param(cls, value: Optional[str]) -> 'utf8_or_null':
│ │ │ │ │ - return cls(value.encode() if value is not None else None)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class boolint(ctypes.c_int):
│ │ │ │ │ - def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> bool:
│ │ │ │ │ - return bool(self.value)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -WeakReference = NewType("WeakReference", int)
│ │ │ │ │ -Errno = typing.Annotated[NoReturn, "errno"]
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -type_map = {
│ │ │ │ │ - Union[None, Errno]: negative_errno, # technically returns int
│ │ │ │ │ - Union[bool, Errno]: negative_errno, # technically returns int
│ │ │ │ │ - Union[int, Errno]: negative_errno,
│ │ │ │ │ - bool: boolint,
│ │ │ │ │ - Optional[str]: utf8_or_null,
│ │ │ │ │ - str: utf8,
│ │ │ │ │ - int: ctypes.c_int,
│ │ │ │ │ - WeakReference: ctypes.c_void_p
│ │ │ │ │ -}
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def map_type(annotation: Any, global_vars: Dict[str, object]) -> Any:
│ │ │ │ │ - try:
│ │ │ │ │ - return type_map[annotation]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - pass # ... and try more cases below
│ │ │ │ │ -
│ │ │ │ │ - if isinstance(annotation, typing.ForwardRef):
│ │ │ │ │ - annotation = annotation.__forward_arg__
│ │ │ │ │ -
│ │ │ │ │ - if isinstance(annotation, str):
│ │ │ │ │ - annotation = global_vars[annotation]
│ │ │ │ │ -
│ │ │ │ │ - origin = typing.get_origin(annotation)
│ │ │ │ │ - args = typing.get_args(annotation)
│ │ │ │ │ -
│ │ │ │ │ - if origin is Reference:
│ │ │ │ │ - return ctypes.POINTER(map_type(args[0], global_vars))
│ │ │ │ │ -
│ │ │ │ │ - elif origin is Union and NoneType in args:
│ │ │ │ │ - # the C pointer types are already nullable
│ │ │ │ │ - other_arg, = set(args) - {NoneType}
│ │ │ │ │ - return map_type(other_arg, global_vars)
│ │ │ │ │ -
│ │ │ │ │ - elif origin is typing.Annotated:
│ │ │ │ │ - return args[1]
│ │ │ │ │ -
│ │ │ │ │ - else:
│ │ │ │ │ - assert origin is None, origin
│ │ │ │ │ - return annotation
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class ReferenceType(ctypes.c_void_p):
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def _install_cfuncs(cls, cdll: ctypes.CDLL) -> None:
│ │ │ │ │ - logger.debug('Installing stubs for %s:', cls)
│ │ │ │ │ - stubs = tuple(cls.__dict__.items())
│ │ │ │ │ - for name, stub in stubs:
│ │ │ │ │ - if name.startswith("__"):
│ │ │ │ │ - continue
│ │ │ │ │ - cls._wrap(cdll, stub)
│ │ │ │ │ -
│ │ │ │ │ - cls._wrap(cdll, cls._ref)
│ │ │ │ │ - cls._wrap(cdll, cls._unref)
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def _wrap(cls, cdll: ctypes.CDLL, stub: object) -> None:
│ │ │ │ │ - stub_type = type(stub)
│ │ │ │ │ - if isinstance(stub, staticmethod):
│ │ │ │ │ - # In older Python versions, staticmethod() isn't considered
│ │ │ │ │ - # callable, doesn't have a name, and can't be introspected with
│ │ │ │ │ - # inspect.signature(). Unwrap it.
│ │ │ │ │ - stub = stub.__func__
│ │ │ │ │ - assert isinstance(stub, types.FunctionType)
│ │ │ │ │ - name = stub.__name__
│ │ │ │ │ - signature = inspect.signature(stub)
│ │ │ │ │ - stub_globals = sys.modules.get(cls.__module__).__dict__
│ │ │ │ │ -
│ │ │ │ │ - func = cdll[f'{cls.__name__}_{name.lstrip("_")}']
│ │ │ │ │ - func.argtypes = tuple(
│ │ │ │ │ - map_type(parameter.annotation, stub_globals)
│ │ │ │ │ - for parameter in signature.parameters.values()
│ │ │ │ │ - )
│ │ │ │ │ - func.restype = map_type(signature.return_annotation, stub_globals)
│ │ │ │ │ - errcheck = getattr(func.restype, 'errcheck', None)
│ │ │ │ │ - if errcheck is not None:
│ │ │ │ │ - func.errcheck = errcheck
│ │ │ │ │ -
│ │ │ │ │ - logger.debug(' create wrapper %s.%s%s', cls.__name__, name, signature)
│ │ │ │ │ - logger.debug(' args %s res %s', func.argtypes, func.restype)
│ │ │ │ │ -
│ │ │ │ │ - # ctypes function pointer objects don't implement the usual function
│ │ │ │ │ - # descriptor logic, which means they won't bind as methods. For static
│ │ │ │ │ - # methods, that's good, but for instance methods, we add a wrapper as
│ │ │ │ │ - # the easiest and most performant way to get the binding behaviour.
│ │ │ │ │ - if stub_type is not staticmethod:
│ │ │ │ │ - setattr(cls, name, lambda *args: func(*args))
│ │ │ │ │ - else:
│ │ │ │ │ - setattr(cls, name, func)
│ │ │ │ │ -
│ │ │ │ │ - def _unref(self: 'ReferenceType') -> None:
│ │ │ │ │ - ...
│ │ │ │ │ -
│ │ │ │ │ - def _ref(self: 'ReferenceType') -> None:
│ │ │ │ │ - ...
│ │ │ │ │ -
│ │ │ │ │ - T = TypeVar("T", bound='ReferenceType')
│ │ │ │ │ -
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def ref(cls: Type[T], origin: WeakReference) -> T:
│ │ │ │ │ - self = cls(origin)
│ │ │ │ │ - self._ref()
│ │ │ │ │ - return self
│ │ │ │ │ -
│ │ │ │ │ - def __del__(self) -> None:
│ │ │ │ │ - if self.value is not None:
│ │ │ │ │ - self._unref()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing
│ │ │ │ │ -from typing import TYPE_CHECKING
│ │ │ │ │ -
│ │ │ │ │ -# The goal here is to continue to work on Python 3.6 while pretending to have
│ │ │ │ │ -# access to some modern typing features. The shims provided here are only
│ │ │ │ │ -# enough for what we need for systemd_ctypes to work at runtime.
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if TYPE_CHECKING:
│ │ │ │ │ - # See https://github.com/python/mypy/issues/1153 for why we do this separately
│ │ │ │ │ - from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin
│ │ │ │ │ -
│ │ │ │ │ -else:
│ │ │ │ │ - # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated
│ │ │ │ │ - # arrived in 3.9. Unfortunately, it's difficult to implement a mocked up
│ │ │ │ │ - # version of Annotated which works with the real typing.get_args() and
│ │ │ │ │ - # .get_origin() in Python 3.8, so we use our own versions there as well.
│ │ │ │ │ - try:
│ │ │ │ │ - from typing import Annotated, get_args, get_origin
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - class AnnotatedMeta(type):
│ │ │ │ │ - def __getitem__(cls, params):
│ │ │ │ │ - class AnnotatedType:
│ │ │ │ │ - __origin__ = Annotated
│ │ │ │ │ - __args__ = params
│ │ │ │ │ - return AnnotatedType
│ │ │ │ │ -
│ │ │ │ │ - class Annotated(metaclass=AnnotatedMeta):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]:
│ │ │ │ │ - return getattr(annotation, '__args__', ())
│ │ │ │ │ -
│ │ │ │ │ - def get_origin(annotation: typing.Any) -> typing.Any:
│ │ │ │ │ - return getattr(annotation, '__origin__', None)
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - from typing import ForwardRef
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - from typing import _ForwardRef as ForwardRef
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - from typing import TypeGuard
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - T = typing.TypeVar('T')
│ │ │ │ │ -
│ │ │ │ │ - class TypeGuard(typing.Generic[T]):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -__all__ = (
│ │ │ │ │ - 'Annotated',
│ │ │ │ │ - 'ForwardRef',
│ │ │ │ │ - 'TypeGuard',
│ │ │ │ │ - 'get_args',
│ │ │ │ │ - 'get_origin',
│ │ │ │ │ - 'TYPE_CHECKING',
│ │ │ │ │ -)
│ │ │ │ │ -''',
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/py.typed': br'''''',
│ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/bus.py': br'''# systemd_ctypes
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -10793,608 +10023,721 @@
│ │ │ │ │ try:
│ │ │ │ │ method = self._find_member(interface, 'methods', name)
│ │ │ │ │ assert isinstance(method, Interface.Method)
│ │ │ │ │ return method._invoke(self, message)
│ │ │ │ │ except Object.Method.Unhandled:
│ │ │ │ │ return False
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/py.typed': br'''''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/__init__.py': br'''# systemd_ctypes
│ │ │ │ │ + 'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ #
│ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ #
│ │ │ │ │ # 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 .
│ │ │ │ │
│ │ │ │ │ -"""systemd_ctypes"""
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import logging
│ │ │ │ │ +import typing
│ │ │ │ │ +from typing import Any, Callable, Iterable, Sequence, TypeVar
│ │ │ │ │
│ │ │ │ │ -__version__ = "0"
│ │ │ │ │ +from .interaction_agent import InteractionAgent, InteractionHandler, get_running_loop
│ │ │ │ │ +from .ssh_errors import get_exception_for_ssh_stderr
│ │ │ │ │
│ │ │ │ │ -from .bus import Bus, BusError, BusMessage
│ │ │ │ │ -from .bustypes import BusType, JSONEncoder, Variant
│ │ │ │ │ -from .event import Event, EventLoopPolicy, run_async
│ │ │ │ │ -from .pathwatch import Handle, PathWatch
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ -__all__ = [
│ │ │ │ │ - "Bus",
│ │ │ │ │ - "BusError",
│ │ │ │ │ - "BusMessage",
│ │ │ │ │ - "BusType",
│ │ │ │ │ - "Event",
│ │ │ │ │ - "EventLoopPolicy",
│ │ │ │ │ - "Handle",
│ │ │ │ │ - "JSONEncoder",
│ │ │ │ │ - "PathWatch",
│ │ │ │ │ - "Variant",
│ │ │ │ │ - "run_async",
│ │ │ │ │ -]
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/bustypes.py': br'''# systemd_ctypes
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# 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 .
│ │ │ │ │ +P = TypeVar('P', bound=asyncio.Protocol)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -# This file is responsible for creating functions to (de)serialize Python
│ │ │ │ │ -# objects into and out of BusMessage objects.
│ │ │ │ │ -#
│ │ │ │ │ -# Each Type corresponds to a (possibly complex) D-Bus type. It has a .reader
│ │ │ │ │ -# and a .writer property. The readers take a message and deserialize a single
│ │ │ │ │ -# value from it, returning the value:
│ │ │ │ │ -#
│ │ │ │ │ -# def reader(message: BusMessage) -> object:
│ │ │ │ │ -#
│ │ │ │ │ -# The writers take a message and a value, and append the value to the message.
│ │ │ │ │ -#
│ │ │ │ │ -# def writer(message: BusMessage, value: object) -> None:
│ │ │ │ │ -#
│ │ │ │ │ -# The necessary information for the specific type of object to be handled is
│ │ │ │ │ -# part of the function. No additional information needs to be provided.
│ │ │ │ │ +class SubprocessError(Exception):
│ │ │ │ │ + returncode: int
│ │ │ │ │ + stderr: str
│ │ │ │ │
│ │ │ │ │ -import binascii
│ │ │ │ │ -import ctypes
│ │ │ │ │ -import functools
│ │ │ │ │ -import inspect
│ │ │ │ │ -import json
│ │ │ │ │ -import re
│ │ │ │ │ -from enum import Enum
│ │ │ │ │ -from typing import Any, Callable, ClassVar, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union
│ │ │ │ │ + def __init__(self, returncode: int, stderr: str) -> None:
│ │ │ │ │ + super().__init__(returncode, stderr)
│ │ │ │ │ + self.returncode = returncode
│ │ │ │ │ + self.stderr = stderr
│ │ │ │ │
│ │ │ │ │ -from . import libsystemd, typing
│ │ │ │ │ -from .typing import Annotated, TypeGuard
│ │ │ │ │
│ │ │ │ │ -_object_path_re = re.compile(r'/|(/[A-Za-z0-9_]+)+')
│ │ │ │ │ +class FernyTransport(asyncio.Transport, asyncio.SubprocessProtocol):
│ │ │ │ │ + _agent: InteractionAgent
│ │ │ │ │ + _exec_task: 'asyncio.Task[tuple[asyncio.SubprocessTransport, FernyTransport]]'
│ │ │ │ │ + _is_ssh: bool
│ │ │ │ │ + _protocol: asyncio.Protocol
│ │ │ │ │ + _protocol_disconnected: bool = False
│ │ │ │ │
│ │ │ │ │ + # These get initialized in connection_made() and once set, never get unset.
│ │ │ │ │ + _subprocess_transport: 'asyncio.SubprocessTransport | None' = None
│ │ │ │ │ + _stdin_transport: 'asyncio.WriteTransport | None' = None
│ │ │ │ │ + _stdout_transport: 'asyncio.ReadTransport | None' = None
│ │ │ │ │
│ │ │ │ │ -def is_object_path(candidate: str) -> TypeGuard['BusType.objectpath']:
│ │ │ │ │ - return _object_path_re.fullmatch(candidate) is not None
│ │ │ │ │ + # We record events that might build towards a connection termination here
│ │ │ │ │ + # and consider them from _consider_disconnect() in order to try to get the
│ │ │ │ │ + # best possible Exception for the protocol, rather than just taking the
│ │ │ │ │ + # first one (which is likely to be somewhat random).
│ │ │ │ │ + _exception: 'Exception | None' = None
│ │ │ │ │ + _stderr_output: 'str | None' = None
│ │ │ │ │ + _returncode: 'int | None' = None
│ │ │ │ │ + _transport_disconnected: bool = False
│ │ │ │ │ + _closed: bool = False
│ │ │ │ │
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def spawn(
│ │ │ │ │ + cls: 'type[typing.Self]',
│ │ │ │ │ + protocol_factory: Callable[[], P],
│ │ │ │ │ + args: Sequence[str],
│ │ │ │ │ + loop: 'asyncio.AbstractEventLoop | None' = None,
│ │ │ │ │ + interaction_handlers: Sequence[InteractionHandler] = (),
│ │ │ │ │ + is_ssh: bool = True,
│ │ │ │ │ + **kwargs: Any
│ │ │ │ │ + ) -> 'tuple[typing.Self, P]':
│ │ │ │ │ + """Connects a FernyTransport to a protocol, using the given command.
│ │ │ │ │
│ │ │ │ │ -def is_signature(candidate: str) -> TypeGuard['BusType.signature']:
│ │ │ │ │ - offset = 0
│ │ │ │ │ + This spawns an external command and connects the stdin and stdout of
│ │ │ │ │ + the command to the protocol returned by the factory.
│ │ │ │ │
│ │ │ │ │ - def maybe_pop(acceptable: str) -> Optional[str]:
│ │ │ │ │ - nonlocal offset
│ │ │ │ │ - char = candidate[offset]
│ │ │ │ │ - if char in acceptable:
│ │ │ │ │ - offset += 1
│ │ │ │ │ - return char
│ │ │ │ │ - else:
│ │ │ │ │ - return None
│ │ │ │ │ + An instance of ferny.InteractionAgent is created and attached to the
│ │ │ │ │ + stderr of the spawned process, using the provided handlers. It is the
│ │ │ │ │ + responsibility of the caller to ensure that:
│ │ │ │ │ + - a `ferny-askpass` client program is installed somewhere; and
│ │ │ │ │ + - any relevant command-line arguments or environment variables are
│ │ │ │ │ + passed correctly to the program to be spawned
│ │ │ │ │
│ │ │ │ │ - def pop(acceptable: str) -> str:
│ │ │ │ │ - char = maybe_pop(acceptable)
│ │ │ │ │ - assert char is not None
│ │ │ │ │ - return char
│ │ │ │ │ + This function returns immediately and never raises exceptions, assuming
│ │ │ │ │ + all preconditions are met.
│ │ │ │ │
│ │ │ │ │ - def find_next() -> None:
│ │ │ │ │ - first = pop('ybnqiuxtsogdva(') # valid first characters
│ │ │ │ │ - if first == 'a':
│ │ │ │ │ - if maybe_pop('{'): # dict
│ │ │ │ │ - pop('ybnqiuxtsogd') # key
│ │ │ │ │ - find_next() # value
│ │ │ │ │ - pop('}')
│ │ │ │ │ - else: # array
│ │ │ │ │ - find_next() # item
│ │ │ │ │ - elif first == '(': # structure
│ │ │ │ │ - find_next() # at least one item
│ │ │ │ │ - while not maybe_pop(')'):
│ │ │ │ │ - find_next() # and possibly more
│ │ │ │ │ + If spawning the process fails then connection_lost() will be
│ │ │ │ │ + called with the relevant OSError, even before connection_made() is
│ │ │ │ │ + called. This is somewhat non-standard behaviour, but is the easiest
│ │ │ │ │ + way to report these errors without making this function async.
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - while offset < len(candidate):
│ │ │ │ │ - find_next()
│ │ │ │ │ - return True
│ │ │ │ │ - except (AssertionError, IndexError):
│ │ │ │ │ - return False
│ │ │ │ │ + Once the process is successfully executed, connection_made() will be
│ │ │ │ │ + called and the transport can be used as normal. connection_lost() will
│ │ │ │ │ + be called if the process exits or another error occurs.
│ │ │ │ │
│ │ │ │ │ + The return value of this function is the transport, but it exists in a
│ │ │ │ │ + semi-initialized state. You can call .close() on it, but nothing else.
│ │ │ │ │ + Once .connection_made() is called, you can call all the other
│ │ │ │ │ + functions.
│ │ │ │ │
│ │ │ │ │ -def yield_base_helpers() -> Iterable[Tuple[str, object]]:
│ │ │ │ │ - for method in ['enter_container', 'exit_container', 'open_container', 'close_container',
│ │ │ │ │ - 'append_basic', 'read_basic', 'append_array', 'read_array']:
│ │ │ │ │ - yield method, libsystemd.libsystemd[f'sd_bus_message_{method}']
│ │ │ │ │ + After you call this function, `.connection_lost()` will be called on
│ │ │ │ │ + your Protocol, exactly once, no matter what. Until that happens, you
│ │ │ │ │ + are responsible for holding a reference to the returned transport.
│ │ │ │ │
│ │ │ │ │ - for name in ['size_t', 'char_p']:
│ │ │ │ │ - instance = getattr(ctypes, f'c_{name}')()
│ │ │ │ │ - yield f'{name}', instance
│ │ │ │ │ - yield f'{name}_ref', ctypes.byref(instance)
│ │ │ │ │ - yield f'{name}_setter', instance.__class__.value.__set__
│ │ │ │ │ + :param args: the full argv of the command to spawn
│ │ │ │ │ + :param loop: the event loop to use. If none is provided, we use the
│ │ │ │ │ + one which is (read: must be) currently running.
│ │ │ │ │ + :param interaction_handlers: the handlers passed to the
│ │ │ │ │ + InteractionAgent
│ │ │ │ │ + :param is_ssh: whether we should attempt to interpret stderr as ssh
│ │ │ │ │ + error messages
│ │ │ │ │ + :param kwargs: anything else is passed through to `subprocess_exec()`
│ │ │ │ │ + :returns: the usual `(Transport, Protocol)` pair
│ │ │ │ │ + """
│ │ │ │ │ + logger.debug('spawn(%r, %r, %r)', cls, protocol_factory, args)
│ │ │ │ │
│ │ │ │ │ - for char in 'aervy':
│ │ │ │ │ - yield char, ctypes.c_char(ord(char))
│ │ │ │ │ + protocol = protocol_factory()
│ │ │ │ │ + self = cls(protocol)
│ │ │ │ │ + self._is_ssh = is_ssh
│ │ │ │ │
│ │ │ │ │ - # https://docs.python.org/3/c-api/unicode.html#c.PyUnicode_FromString
│ │ │ │ │ - to_bytes = ctypes.pythonapi.PyBytes_FromStringAndSize
│ │ │ │ │ - to_bytes.restype = ctypes.py_object
│ │ │ │ │ - yield 'to_bytes', to_bytes
│ │ │ │ │ + if loop is None:
│ │ │ │ │ + loop = get_running_loop()
│ │ │ │ │
│ │ │ │ │ + self._agent = InteractionAgent(interaction_handlers, loop, self._interaction_completed)
│ │ │ │ │ + kwargs.setdefault('stderr', self._agent.fileno())
│ │ │ │ │
│ │ │ │ │ -_base_helpers = dict(yield_base_helpers())
│ │ │ │ │ + # As of Python 3.12 this isn't really asynchronous (since it uses the
│ │ │ │ │ + # subprocess module, which blocks while waiting for the exec() to
│ │ │ │ │ + # complete in the child), but we have to deal with the complication of
│ │ │ │ │ + # the async interface anyway. Since we, ourselves, want to export a
│ │ │ │ │ + # non-async interface, that means that we need a task here and a
│ │ │ │ │ + # bottom-half handler below.
│ │ │ │ │ + self._exec_task = loop.create_task(loop.subprocess_exec(lambda: self, *args, **kwargs))
│ │ │ │ │
│ │ │ │ │ -T = TypeVar('T')
│ │ │ │ │ + def exec_completed(task: asyncio.Task) -> None:
│ │ │ │ │ + logger.debug('exec_completed(%r, %r)', self, task)
│ │ │ │ │ + assert task is self._exec_task
│ │ │ │ │ + try:
│ │ │ │ │ + transport, me = task.result()
│ │ │ │ │ + assert me is self
│ │ │ │ │ + logger.debug(' success.')
│ │ │ │ │ + except asyncio.CancelledError:
│ │ │ │ │ + return # in that case, do nothing
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + logger.debug(' OSError %r', exc)
│ │ │ │ │ + self.close(exc)
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ + # Our own .connection_made() handler should have gotten called by
│ │ │ │ │ + # now. Make sure everything got filled in properly.
│ │ │ │ │ + assert self._subprocess_transport is transport
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + assert self._stdout_transport is not None
│ │ │ │ │
│ │ │ │ │ -def call_with_kwargs(func: Callable[..., T], kwargs: Dict[str, Any]) -> T:
│ │ │ │ │ - parameters = set(inspect.signature(func).parameters)
│ │ │ │ │ - return func(**{key: value for key, value in kwargs.items() if key in parameters})
│ │ │ │ │ + # Ask the InteractionAgent to start processing stderr.
│ │ │ │ │ + self._agent.start()
│ │ │ │ │
│ │ │ │ │ + self._exec_task.add_done_callback(exec_completed)
│ │ │ │ │
│ │ │ │ │ -class Type:
│ │ │ │ │ - _cache: ClassVar[Dict[Tuple[type, Tuple[object, ...]], 'Type']] = {}
│ │ │ │ │ + return self, protocol
│ │ │ │ │
│ │ │ │ │ - __slots__ = 'typestring', 'bytes_typestring', 'writer', 'reader'
│ │ │ │ │ - typestring: str
│ │ │ │ │ - bytes_typestring: bytes
│ │ │ │ │ - reader: Callable[[libsystemd.sd_bus_message], object]
│ │ │ │ │ - writer: Callable[[libsystemd.sd_bus_message, object], None]
│ │ │ │ │ + def __init__(self, protocol: asyncio.Protocol) -> None:
│ │ │ │ │ + self._protocol = protocol
│ │ │ │ │
│ │ │ │ │ - def __new__(cls, *args: Any) -> 'Type':
│ │ │ │ │ - instance = Type._cache.get((cls, args))
│ │ │ │ │ - if instance is None:
│ │ │ │ │ - instance = object.__new__(cls)
│ │ │ │ │ - Type._cache[(cls, args)] = instance
│ │ │ │ │ - return instance
│ │ │ │ │ + def _consider_disconnect(self) -> None:
│ │ │ │ │ + logger.debug('_consider_disconnect(%r)', self)
│ │ │ │ │ + # We cannot disconnect as long as any of these three things are happening
│ │ │ │ │ + if not self._exec_task.done():
│ │ │ │ │ + logger.debug(' exec_task still running %r', self._exec_task)
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - def __init__(self, typestring: str, **kwargs: Any):
│ │ │ │ │ - self.typestring = typestring
│ │ │ │ │ - self.bytes_typestring = typestring.encode('ascii')
│ │ │ │ │ + if self._subprocess_transport is not None and not self._transport_disconnected:
│ │ │ │ │ + logger.debug(' transport still connected %r', self._subprocess_transport)
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - kwargs = dict(_base_helpers, **kwargs)
│ │ │ │ │ - self.writer = call_with_kwargs(self.get_writer, kwargs)
│ │ │ │ │ - self.reader = call_with_kwargs(self.get_reader, kwargs)
│ │ │ │ │ + if self._stderr_output is None:
│ │ │ │ │ + logger.debug(' agent still running')
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - def __repr__(self) -> str:
│ │ │ │ │ - return f"{self.__class__.__name__}('{self.typestring}')"
│ │ │ │ │ + # All conditions for disconnection are satisfied.
│ │ │ │ │ + if self._protocol_disconnected:
│ │ │ │ │ + logger.debug(' already disconnected')
│ │ │ │ │ + return
│ │ │ │ │ + self._protocol_disconnected = True
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, **kwargs: object) -> Callable[[object, object], None]:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + # Now we just need to determine what we report to the protocol...
│ │ │ │ │ + if self._exception is not None:
│ │ │ │ │ + # If we got an exception reported, that's our reason for closing.
│ │ │ │ │ + logger.debug(' disconnect with exception %r', self._exception)
│ │ │ │ │ + self._protocol.connection_lost(self._exception)
│ │ │ │ │ + elif self._returncode == 0 or self._closed:
│ │ │ │ │ + # If we called close() or have a zero return status, that's a clean
│ │ │ │ │ + # exit, regardless of noise that might have landed in stderr.
│ │ │ │ │ + logger.debug(' clean disconnect')
│ │ │ │ │ + self._protocol.connection_lost(None)
│ │ │ │ │ + elif self._is_ssh and self._returncode == 255:
│ │ │ │ │ + # This is an error code due to an SSH failure. Try to interpret it.
│ │ │ │ │ + logger.debug(' disconnect with ssh error %r', self._stderr_output)
│ │ │ │ │ + self._protocol.connection_lost(get_exception_for_ssh_stderr(self._stderr_output))
│ │ │ │ │ + else:
│ │ │ │ │ + # Otherwise, report the stderr text and return code.
│ │ │ │ │ + logger.debug(' disconnect with exit code %r, stderr %r', self._returncode, self._stderr_output)
│ │ │ │ │ + # We surely have _returncode set here, since otherwise:
│ │ │ │ │ + # - exec_task failed with an exception (which we handle above); or
│ │ │ │ │ + # - we're still connected...
│ │ │ │ │ + assert self._returncode is not None
│ │ │ │ │ + self._protocol.connection_lost(SubprocessError(self._returncode, self._stderr_output))
│ │ │ │ │
│ │ │ │ │ - def get_reader(self, **kwargs: object) -> Callable[[object], object]:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + def _interaction_completed(self, future: 'asyncio.Future[str]') -> None:
│ │ │ │ │ + logger.debug('_interaction_completed(%r, %r)', self, future)
│ │ │ │ │ + try:
│ │ │ │ │ + self._stderr_output = future.result()
│ │ │ │ │ + logger.debug(' stderr: %r', self._stderr_output)
│ │ │ │ │ + except Exception as exc:
│ │ │ │ │ + logger.debug(' exception: %r', exc)
│ │ │ │ │ + self._stderr_output = '' # we need to set this in order to complete
│ │ │ │ │ + self.close(exc)
│ │ │ │ │
│ │ │ │ │ + self._consider_disconnect()
│ │ │ │ │
│ │ │ │ │ -class BasicType(Type):
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + # BaseProtocol implementation
│ │ │ │ │ + def connection_made(self, transport: asyncio.BaseTransport) -> None:
│ │ │ │ │ + logger.debug('connection_made(%r, %r)', self, transport)
│ │ │ │ │ + assert isinstance(transport, asyncio.SubprocessTransport)
│ │ │ │ │ + self._subprocess_transport = transport
│ │ │ │ │
│ │ │ │ │ - def __init__(self, typestring: str, ctype, get_wrapper=None, **kwargs):
│ │ │ │ │ - variable = ctype() # NB: not thread-safe
│ │ │ │ │ - super().__init__(typestring, ctype=ctype, type_constant=ctypes.c_char(ord(typestring)),
│ │ │ │ │ - getter=get_wrapper or ctype.value.__get__, setter=ctype.value.__set__,
│ │ │ │ │ - variable=variable, reference=ctypes.byref(variable), **kwargs)
│ │ │ │ │ + stdin_transport = transport.get_pipe_transport(0)
│ │ │ │ │ + assert isinstance(stdin_transport, asyncio.WriteTransport)
│ │ │ │ │ + self._stdin_transport = stdin_transport
│ │ │ │ │
│ │ │ │ │ - def get_reader(self, read_basic, type_constant, variable, reference, getter):
│ │ │ │ │ - def basic_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ - if read_basic(message, type_constant, reference) <= 0:
│ │ │ │ │ - raise StopIteration
│ │ │ │ │ - return getter(variable)
│ │ │ │ │ - return basic_reader
│ │ │ │ │ + stdout_transport = transport.get_pipe_transport(1)
│ │ │ │ │ + assert isinstance(stdout_transport, asyncio.ReadTransport)
│ │ │ │ │ + self._stdout_transport = stdout_transport
│ │ │ │ │
│ │ │ │ │ + stderr_transport = transport.get_pipe_transport(2)
│ │ │ │ │ + assert stderr_transport is None
│ │ │ │ │
│ │ │ │ │ -class FixedType(BasicType):
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + logger.debug('calling connection_made(%r, %r)', self, self._protocol)
│ │ │ │ │ + self._protocol.connection_made(self)
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, append_basic, type_constant, variable, reference, setter, getter):
│ │ │ │ │ - def fixed_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ - setter(variable, value)
│ │ │ │ │ - if getter(variable) != value:
│ │ │ │ │ - raise TypeError(f"Cannot represent value {value} with type '{self.typestring}'")
│ │ │ │ │ - append_basic(message, type_constant, reference)
│ │ │ │ │ - return fixed_writer
│ │ │ │ │ + def connection_lost(self, exc: 'Exception | None') -> None:
│ │ │ │ │ + logger.debug('connection_lost(%r, %r)', self, exc)
│ │ │ │ │ + if self._exception is None:
│ │ │ │ │ + self._exception = exc
│ │ │ │ │ + self._transport_disconnected = True
│ │ │ │ │ + self._consider_disconnect()
│ │ │ │ │
│ │ │ │ │ + # SubprocessProtocol implementation
│ │ │ │ │ + def pipe_data_received(self, fd: int, data: bytes) -> None:
│ │ │ │ │ + logger.debug('pipe_data_received(%r, %r, %r)', self, fd, len(data))
│ │ │ │ │ + assert fd == 1 # stderr is handled separately
│ │ │ │ │ + self._protocol.data_received(data)
│ │ │ │ │
│ │ │ │ │ -class StringLikeType(BasicType):
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + def pipe_connection_lost(self, fd: int, exc: 'Exception | None') -> None:
│ │ │ │ │ + logger.debug('pipe_connection_lost(%r, %r, %r)', self, fd, exc)
│ │ │ │ │ + assert fd in (0, 1) # stderr is handled separately
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_guarded_conversion(typestring: str, guard: Callable[[str], bool]) -> Callable[[object], bytes]:
│ │ │ │ │ - def convert(candidate: object) -> bytes:
│ │ │ │ │ - if not isinstance(candidate, str):
│ │ │ │ │ - raise TypeError(f"'{typestring}' encodes 'str', not '{candidate.__class__.__name__}'")
│ │ │ │ │ - if not guard(candidate):
│ │ │ │ │ - raise ValueError(f"Invalid value provided for type '{typestring}'")
│ │ │ │ │ - return str.encode(candidate)
│ │ │ │ │ - return convert
│ │ │ │ │ + # We treat this as a clean close
│ │ │ │ │ + if isinstance(exc, BrokenPipeError):
│ │ │ │ │ + exc = None
│ │ │ │ │
│ │ │ │ │ - def __init__(self, typestring: str, guard: Optional[Callable[[str], bool]] = None):
│ │ │ │ │ - # https://docs.python.org/3/c-api/unicode.html#c.PyUnicode_FromString
│ │ │ │ │ - to_unicode = ctypes.pythonapi.PyUnicode_FromString
│ │ │ │ │ - to_unicode.restype = ctypes.py_object
│ │ │ │ │ + # Record serious errors to propagate them to the protocol
│ │ │ │ │ + # If this is a clean exit on stdout, report an EOF
│ │ │ │ │ + if exc is not None:
│ │ │ │ │ + self.close(exc)
│ │ │ │ │ + elif fd == 1 and not self._closed:
│ │ │ │ │ + if not self._protocol.eof_received():
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - if guard is not None:
│ │ │ │ │ - convert = StringLikeType.get_guarded_conversion(typestring, guard)
│ │ │ │ │ - else:
│ │ │ │ │ - convert = str.encode # type: ignore[assignment] # can throw TypeError on call
│ │ │ │ │ + def process_exited(self) -> None:
│ │ │ │ │ + logger.debug('process_exited(%r)', self)
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + self._returncode = self._subprocess_transport.get_returncode()
│ │ │ │ │ + logger.debug(' ._returncode = %r', self._returncode)
│ │ │ │ │ + self._agent.force_completion()
│ │ │ │ │
│ │ │ │ │ - super().__init__(typestring, ctypes.c_char_p, to_unicode, convert=convert)
│ │ │ │ │ + def pause_writing(self) -> None:
│ │ │ │ │ + logger.debug('pause_writing(%r)', self)
│ │ │ │ │ + self._protocol.pause_writing()
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, append_basic, type_constant, convert):
│ │ │ │ │ - def string_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ - append_basic(message, type_constant, convert(value))
│ │ │ │ │ - return string_writer
│ │ │ │ │ + def resume_writing(self) -> None:
│ │ │ │ │ + logger.debug('resume_writing(%r)', self)
│ │ │ │ │ + self._protocol.resume_writing()
│ │ │ │ │
│ │ │ │ │ + # Transport implementation. Most of this is straight delegation.
│ │ │ │ │ + def close(self, exc: 'Exception | None' = None) -> None:
│ │ │ │ │ + logger.debug('close(%r, %r)', self, exc)
│ │ │ │ │ + self._closed = True
│ │ │ │ │ + if self._exception is None:
│ │ │ │ │ + logger.debug(' setting exception %r', exc)
│ │ │ │ │ + self._exception = exc
│ │ │ │ │ + if not self._exec_task.done():
│ │ │ │ │ + logger.debug(' cancelling _exec_task')
│ │ │ │ │ + self._exec_task.cancel()
│ │ │ │ │ + if self._subprocess_transport is not None:
│ │ │ │ │ + logger.debug(' closing _subprocess_transport')
│ │ │ │ │ + # https://github.com/python/cpython/issues/112800
│ │ │ │ │ + with contextlib.suppress(PermissionError):
│ │ │ │ │ + self._subprocess_transport.close()
│ │ │ │ │ + self._agent.force_completion()
│ │ │ │ │
│ │ │ │ │ -class BytestringType(Type):
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + def is_closing(self) -> bool:
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + return self._subprocess_transport.is_closing()
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, append_array, y, size_t_setter, size_t):
│ │ │ │ │ - def bytes_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ - if not isinstance(value, bytes):
│ │ │ │ │ - if isinstance(value, str):
│ │ │ │ │ - try:
│ │ │ │ │ - value = binascii.a2b_base64(value.encode('ascii')) # or decode base64
│ │ │ │ │ - except binascii.Error as exc:
│ │ │ │ │ - raise ValueError("'ay' cannot encode invalid base64 string") from exc
│ │ │ │ │ - elif isinstance(value, (memoryview, bytearray)):
│ │ │ │ │ - value = bytes(value)
│ │ │ │ │ - else:
│ │ │ │ │ - raise TypeError("'ay' can only encode bytes-like or base64 string objects, "
│ │ │ │ │ - f"not '{value.__class__.__name__}'.")
│ │ │ │ │ - size_t_setter(size_t, len(value))
│ │ │ │ │ - append_array(message, y, value, size_t)
│ │ │ │ │ - return bytes_writer
│ │ │ │ │ + def get_extra_info(self, name: str, default: object = None) -> object:
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + return self._subprocess_transport.get_extra_info(name, default)
│ │ │ │ │
│ │ │ │ │ - def get_reader(self, read_array, y, to_bytes, char_p, char_p_ref, size_t, size_t_ref):
│ │ │ │ │ - def bytes_reader(message):
│ │ │ │ │ - if read_array(message, y, char_p_ref, size_t_ref) <= 0:
│ │ │ │ │ - raise StopIteration
│ │ │ │ │ - return to_bytes(char_p, size_t)
│ │ │ │ │ - return bytes_reader
│ │ │ │ │ + def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:
│ │ │ │ │ + assert isinstance(protocol, asyncio.Protocol)
│ │ │ │ │ + self._protocol = protocol
│ │ │ │ │
│ │ │ │ │ + def get_protocol(self) -> asyncio.Protocol:
│ │ │ │ │ + return self._protocol
│ │ │ │ │
│ │ │ │ │ -class ContainerType(Type):
│ │ │ │ │ - _typestring_template: ClassVar[str]
│ │ │ │ │ - __slots__ = 'item_types'
│ │ │ │ │ - item_types: Sequence[Type]
│ │ │ │ │ + def is_reading(self) -> bool:
│ │ │ │ │ + assert self._stdout_transport is not None
│ │ │ │ │ + try:
│ │ │ │ │ + return self._stdout_transport.is_reading()
│ │ │ │ │ + except NotImplementedError:
│ │ │ │ │ + # This is (incorrectly) unimplemented before Python 3.11
│ │ │ │ │ + return not self._stdout_transport._paused # type:ignore[attr-defined]
│ │ │ │ │ + except AttributeError:
│ │ │ │ │ + # ...and in Python 3.6 it's even worse
│ │ │ │ │ + try:
│ │ │ │ │ + selector = self._stdout_transport._loop._selector # type:ignore[attr-defined]
│ │ │ │ │ + selector.get_key(self._stdout_transport._fileno) # type:ignore[attr-defined]
│ │ │ │ │ + return True
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + return False
│ │ │ │ │
│ │ │ │ │ - def __init__(self, *item_types: Type, **kwargs: Any):
│ │ │ │ │ - assert len(item_types) > 0
│ │ │ │ │ - item_typestrings = ''.join(item.typestring for item in item_types)
│ │ │ │ │ - self.item_types = item_types
│ │ │ │ │ - super().__init__(self._typestring_template.replace('_', item_typestrings),
│ │ │ │ │ - type_contents=ctypes.c_char_p(item_typestrings.encode('ascii')),
│ │ │ │ │ - **kwargs)
│ │ │ │ │ + def pause_reading(self) -> None:
│ │ │ │ │ + assert self._stdout_transport is not None
│ │ │ │ │ + self._stdout_transport.pause_reading()
│ │ │ │ │
│ │ │ │ │ + def resume_reading(self) -> None:
│ │ │ │ │ + assert self._stdout_transport is not None
│ │ │ │ │ + self._stdout_transport.resume_reading()
│ │ │ │ │
│ │ │ │ │ -class ArrayType(ContainerType):
│ │ │ │ │ - _typestring_template = 'a_'
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + def abort(self) -> None:
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + self._stdin_transport.abort()
│ │ │ │ │ + self._subprocess_transport.kill()
│ │ │ │ │
│ │ │ │ │ - def __init__(self, item_type: Type):
│ │ │ │ │ - super().__init__(item_type,
│ │ │ │ │ - item_writer=item_type.writer,
│ │ │ │ │ - item_reader=item_type.reader,
│ │ │ │ │ - list_append=list.append)
│ │ │ │ │ + def can_write_eof(self) -> bool:
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + return self._stdin_transport.can_write_eof() # will always be True
│ │ │ │ │
│ │ │ │ │ - def get_reader(self, enter_container, exit_container, list_append, item_reader):
│ │ │ │ │ - def array_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ - if enter_container(message, 0, None) <= 0:
│ │ │ │ │ - raise StopIteration
│ │ │ │ │ - result: List[object] = []
│ │ │ │ │ - try:
│ │ │ │ │ - while True:
│ │ │ │ │ - list_append(result, item_reader(message))
│ │ │ │ │ - except StopIteration:
│ │ │ │ │ - return result
│ │ │ │ │ - finally:
│ │ │ │ │ - exit_container(message)
│ │ │ │ │ - return array_reader
│ │ │ │ │ + def get_write_buffer_size(self) -> int:
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + return self._stdin_transport.get_write_buffer_size()
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, a, type_contents, open_container, close_container, item_writer):
│ │ │ │ │ - def array_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ - open_container(message, a, type_contents)
│ │ │ │ │ - for item in value: # type: ignore[attr-defined] # can throw TypeError
│ │ │ │ │ - item_writer(message, item)
│ │ │ │ │ - close_container(message)
│ │ │ │ │ - return array_writer
│ │ │ │ │ + def get_write_buffer_limits(self) -> 'tuple[int, int]':
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + return self._stdin_transport.get_write_buffer_limits()
│ │ │ │ │
│ │ │ │ │ + def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None:
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + return self._stdin_transport.set_write_buffer_limits(high, low)
│ │ │ │ │
│ │ │ │ │ -class StructType(ContainerType):
│ │ │ │ │ - _typestring_template = '(_)'
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + def write(self, data: bytes) -> None:
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + return self._stdin_transport.write(data)
│ │ │ │ │
│ │ │ │ │ - def get_reader(self, enter_container, exit_container):
│ │ │ │ │ - item_readers = tuple(item_type.reader for item_type in self.item_types)
│ │ │ │ │ + def writelines(self, list_of_data: Iterable[bytes]) -> None:
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + return self._stdin_transport.writelines(list_of_data)
│ │ │ │ │
│ │ │ │ │ - def array_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ - if enter_container(message, 0, None) <= 0:
│ │ │ │ │ - raise StopIteration
│ │ │ │ │ - result = tuple(item_reader(message) for item_reader in item_readers)
│ │ │ │ │ - exit_container(message)
│ │ │ │ │ - return result
│ │ │ │ │ - return array_reader
│ │ │ │ │ + def write_eof(self) -> None:
│ │ │ │ │ + assert self._stdin_transport is not None
│ │ │ │ │ + return self._stdin_transport.write_eof()
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, r, type_contents, open_container, close_container):
│ │ │ │ │ - item_writers = tuple(item_type.writer for item_type in self.item_types)
│ │ │ │ │ + # We don't really implement SubprocessTransport, but provide these as
│ │ │ │ │ + # "extras" to our user.
│ │ │ │ │ + def get_pid(self) -> int:
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + return self._subprocess_transport.get_pid()
│ │ │ │ │
│ │ │ │ │ - def struct_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ - n_items = len(value) # type: ignore[arg-type] # can throw TypeError
│ │ │ │ │ - if n_items != len(item_writers):
│ │ │ │ │ - raise TypeError(f"Wrong numbers of items ({n_items}) for structure type '{self.typestring}'")
│ │ │ │ │ - open_container(message, r, type_contents)
│ │ │ │ │ - for item_writer, item in zip(item_writers, value): # type: ignore[call-overload] # can throw TypeError
│ │ │ │ │ - item_writer(message, item)
│ │ │ │ │ - close_container(message)
│ │ │ │ │ - return struct_writer
│ │ │ │ │ + def get_returncode(self) -> 'int | None':
│ │ │ │ │ + return self._returncode
│ │ │ │ │
│ │ │ │ │ + def kill(self) -> None:
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + self._subprocess_transport.kill()
│ │ │ │ │
│ │ │ │ │ -class DictionaryType(ContainerType):
│ │ │ │ │ - _typestring_template = 'a{_}'
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + def send_signal(self, number: int) -> None:
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + self._subprocess_transport.send_signal(number)
│ │ │ │ │
│ │ │ │ │ - def __init__(self, key_type: Type, value_type: Type):
│ │ │ │ │ - assert isinstance(key_type, BasicType)
│ │ │ │ │ - item_type = '{' + key_type.typestring + value_type.typestring + '}'
│ │ │ │ │ - super().__init__(key_type, value_type,
│ │ │ │ │ - key_reader=key_type.reader, key_writer=key_type.writer,
│ │ │ │ │ - value_reader=value_type.reader, value_writer=value_type.writer,
│ │ │ │ │ - item_type=ctypes.c_char_p(item_type.encode('ascii')))
│ │ │ │ │ + def terminate(self) -> None:
│ │ │ │ │ + assert self._subprocess_transport is not None
│ │ │ │ │ + self._subprocess_transport.terminate()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3
│ │ │ │ │
│ │ │ │ │ - def get_reader(self, enter_container, exit_container, key_reader, value_reader):
│ │ │ │ │ - def dict_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ - if enter_container(message, 0, None) <= 0: # array
│ │ │ │ │ - raise StopIteration
│ │ │ │ │ - result = {}
│ │ │ │ │ - while enter_container(message, 0, None) > 0: # entry
│ │ │ │ │ - key = key_reader(message)
│ │ │ │ │ - value = value_reader(message)
│ │ │ │ │ - result[key] = value
│ │ │ │ │ - exit_container(message)
│ │ │ │ │ - exit_container(message)
│ │ │ │ │ - return result
│ │ │ │ │ - return dict_reader
│ │ │ │ │ +import array
│ │ │ │ │ +import io
│ │ │ │ │ +import os
│ │ │ │ │ +import socket
│ │ │ │ │ +import sys
│ │ │ │ │ +from typing import Sequence
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, a, item_type, e, type_contents, open_container, close_container, key_writer, value_writer):
│ │ │ │ │ - def dict_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ - open_container(message, a, item_type) # array
│ │ │ │ │ - for key, val in value.items(): # type: ignore[attr-defined] # can raise AttributeError
│ │ │ │ │ - open_container(message, e, type_contents) # entry
│ │ │ │ │ - key_writer(message, key) # key
│ │ │ │ │ - value_writer(message, val) # value
│ │ │ │ │ - close_container(message) # end entry
│ │ │ │ │ - close_container(message) # end array
│ │ │ │ │ - return dict_writer
│ │ │ │ │
│ │ │ │ │ +def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None:
│ │ │ │ │ + cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')]
│ │ │ │ │
│ │ │ │ │ -class VariantType(Type):
│ │ │ │ │ - __slots__ = ()
│ │ │ │ │ + with cmd_write:
│ │ │ │ │ + with cmd_read:
│ │ │ │ │ + with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock:
│ │ │ │ │ + fd_array = array.array('i', (cmd_read.fileno(), *fds))
│ │ │ │ │ + sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)])
│ │ │ │ │
│ │ │ │ │ - def get_reader(self, enter_container, exit_container):
│ │ │ │ │ - def variant_reader(message: libsystemd.sd_bus_message) -> object:
│ │ │ │ │ - if enter_container(message, 0, None) <= 0:
│ │ │ │ │ - raise StopIteration
│ │ │ │ │ - typestring = message.get_signature(False)
│ │ │ │ │ - type_, = from_signature(typestring)
│ │ │ │ │ - value = type_.reader(message)
│ │ │ │ │ - exit_container(message)
│ │ │ │ │ - return Variant(value, type_)
│ │ │ │ │ - return variant_reader
│ │ │ │ │ + cmd_write.write(repr((command, args)))
│ │ │ │ │
│ │ │ │ │ - def get_writer(self, open_container, close_container, v):
│ │ │ │ │ - def variant_writer(message: libsystemd.sd_bus_message, value: object) -> None:
│ │ │ │ │ - if isinstance(value, Variant):
│ │ │ │ │ - type_ = value.type
│ │ │ │ │ - contents = value.value
│ │ │ │ │ - else:
│ │ │ │ │ - try:
│ │ │ │ │ - type_, = from_signature(value['t']) # type: ignore[index] # can throw TypeError
│ │ │ │ │ - contents = value['v'] # type: ignore[index] # can throw TypeError
│ │ │ │ │ - except KeyError as exc:
│ │ │ │ │ - raise TypeError("'v' can encode Variant objects, or mappings with 't' and 'v' keys") from exc
│ │ │ │ │
│ │ │ │ │ - open_container(message, v, type_.bytes_typestring)
│ │ │ │ │ - type_.writer(message, contents)
│ │ │ │ │ - close_container(message)
│ │ │ │ │ - return variant_writer
│ │ │ │ │ +def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int:
│ │ │ │ │ + ours, theirs = socket.socketpair()
│ │ │ │ │
│ │ │ │ │ + with theirs:
│ │ │ │ │ + command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd))
│ │ │ │ │
│ │ │ │ │ -class Variant:
│ │ │ │ │ - __slots__ = 'type', 'value'
│ │ │ │ │ - type: Type
│ │ │ │ │ - value: object
│ │ │ │ │ + with ours:
│ │ │ │ │ + return int(ours.recv(16) or b'1')
│ │ │ │ │
│ │ │ │ │ - def __init__(self, value: object, hint: object = None):
│ │ │ │ │ - if isinstance(hint, Type):
│ │ │ │ │ - self.type = hint
│ │ │ │ │ - elif isinstance(hint, str):
│ │ │ │ │ - self.type, = from_signature(hint)
│ │ │ │ │ - else:
│ │ │ │ │ - self.type = from_annotation(hint or value.__class__)
│ │ │ │ │ - self.value = value
│ │ │ │ │
│ │ │ │ │ - def __repr__(self) -> str:
│ │ │ │ │ - return f"systemd_ctypes.Variant({self.value}, '{self.type.typestring}')"
│ │ │ │ │ +def main() -> None:
│ │ │ │ │ + if len(sys.argv) == 1:
│ │ │ │ │ + command(2, 'ferny.end', [])
│ │ │ │ │ + else:
│ │ │ │ │ + sys.exit(askpass(2, 1, sys.argv, dict(os.environ)))
│ │ │ │ │
│ │ │ │ │ - def __eq__(self, other: object) -> bool:
│ │ │ │ │ - if isinstance(other, Variant):
│ │ │ │ │ - return self.type == other.type and self.value == other.value
│ │ │ │ │ - elif isinstance(other, dict):
│ │ │ │ │ - return (self.type,) == from_signature(other['t']) and self.value == other['v']
│ │ │ │ │ - else:
│ │ │ │ │ - return False
│ │ │ │ │
│ │ │ │ │ - def __hash__(self) -> int:
│ │ │ │ │ - return hash(self.type) ^ hash(self.value)
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import (
│ │ │ │ │ + BEIBOOT_GADGETS,
│ │ │ │ │ + COMMAND_TEMPLATE,
│ │ │ │ │ + AskpassHandler,
│ │ │ │ │ + InteractionAgent,
│ │ │ │ │ + InteractionError,
│ │ │ │ │ + InteractionHandler,
│ │ │ │ │ + temporary_askpass,
│ │ │ │ │ + write_askpass_to_tmpdir,
│ │ │ │ │ +)
│ │ │ │ │ +from .session import Session
│ │ │ │ │ +from .ssh_askpass import (
│ │ │ │ │ + AskpassPrompt,
│ │ │ │ │ + SshAskpassResponder,
│ │ │ │ │ + SshFIDOPINPrompt,
│ │ │ │ │ + SshFIDOUserPresencePrompt,
│ │ │ │ │ + SshHostKeyPrompt,
│ │ │ │ │ + SshPassphrasePrompt,
│ │ │ │ │ + SshPasswordPrompt,
│ │ │ │ │ + SshPKCS11PINPrompt,
│ │ │ │ │ +)
│ │ │ │ │ +from .ssh_errors import (
│ │ │ │ │ + SshAuthenticationError,
│ │ │ │ │ + SshChangedHostKeyError,
│ │ │ │ │ + SshError,
│ │ │ │ │ + SshHostKeyError,
│ │ │ │ │ + SshUnknownHostKeyError,
│ │ │ │ │ +)
│ │ │ │ │ +from .transport import FernyTransport, SubprocessError
│ │ │ │ │
│ │ │ │ │ +__all__ = [
│ │ │ │ │ + 'AskpassHandler',
│ │ │ │ │ + 'AskpassPrompt',
│ │ │ │ │ + 'AuthenticationError',
│ │ │ │ │ + 'BEIBOOT_GADGETS',
│ │ │ │ │ + 'COMMAND_TEMPLATE',
│ │ │ │ │ + 'ChangedHostKeyError',
│ │ │ │ │ + 'FernyTransport',
│ │ │ │ │ + 'HostKeyError',
│ │ │ │ │ + 'InteractionAgent',
│ │ │ │ │ + 'InteractionError',
│ │ │ │ │ + 'InteractionHandler',
│ │ │ │ │ + 'Session',
│ │ │ │ │ + 'SshAskpassResponder',
│ │ │ │ │ + 'SshAuthenticationError',
│ │ │ │ │ + 'SshChangedHostKeyError',
│ │ │ │ │ + 'SshError',
│ │ │ │ │ + 'SshFIDOPINPrompt',
│ │ │ │ │ + 'SshFIDOUserPresencePrompt',
│ │ │ │ │ + 'SshHostKeyError',
│ │ │ │ │ + 'SshHostKeyPrompt',
│ │ │ │ │ + 'SshPKCS11PINPrompt',
│ │ │ │ │ + 'SshPassphrasePrompt',
│ │ │ │ │ + 'SshPasswordPrompt',
│ │ │ │ │ + 'SshUnknownHostKeyError',
│ │ │ │ │ + 'SubprocessError',
│ │ │ │ │ + 'temporary_askpass',
│ │ │ │ │ + 'write_askpass_to_tmpdir',
│ │ │ │ │ +]
│ │ │ │ │
│ │ │ │ │ -class BusType(Enum):
│ │ │ │ │ - boolean = Annotated[bool, FixedType('b', ctypes.c_int, ctypes.c_int.__bool__)]
│ │ │ │ │ - byte = Annotated[int, FixedType('y', ctypes.c_uint8)]
│ │ │ │ │ - int16 = Annotated[int, FixedType('n', ctypes.c_int16)]
│ │ │ │ │ - uint16 = Annotated[int, FixedType('q', ctypes.c_uint16)]
│ │ │ │ │ - int32 = Annotated[int, FixedType('i', ctypes.c_int32)]
│ │ │ │ │ - uint32 = Annotated[int, FixedType('u', ctypes.c_uint32)]
│ │ │ │ │ - int64 = Annotated[int, FixedType('x', ctypes.c_int64)]
│ │ │ │ │ - uint64 = Annotated[int, FixedType('t', ctypes.c_uint64)]
│ │ │ │ │ - double = Annotated[float, FixedType('d', ctypes.c_double)]
│ │ │ │ │ - string = Annotated[str, StringLikeType('s')]
│ │ │ │ │ - objectpath = Annotated[str, StringLikeType('o', is_object_path)]
│ │ │ │ │ - signature = Annotated[str, StringLikeType('g', is_signature)]
│ │ │ │ │ - bytestring = Annotated[bytes, BytestringType('ay')]
│ │ │ │ │ - variant = Annotated[dict, VariantType('v')]
│ │ │ │ │ +__version__ = '0'
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main
│ │ │ │ │
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging
│ │ │ │ │ +import re
│ │ │ │ │ +from typing import ClassVar, Match, Sequence
│ │ │ │ │
│ │ │ │ │ -# mypy gets confused by enums, so just use Any
│ │ │ │ │ -_base_equivalence_map: Dict[type, Any] = {
│ │ │ │ │ - bool: BusType.boolean,
│ │ │ │ │ - bytes: BusType.bytestring,
│ │ │ │ │ - int: BusType.int32,
│ │ │ │ │ - str: BusType.string,
│ │ │ │ │ - Variant: BusType.variant,
│ │ │ │ │ -}
│ │ │ │ │ +from .interaction_agent import AskpassHandler
│ │ │ │ │
│ │ │ │ │ -_factory_map: Dict[object, Callable[..., Type]] = {
│ │ │ │ │ - dict: DictionaryType, Dict: DictionaryType,
│ │ │ │ │ - list: ArrayType, List: ArrayType,
│ │ │ │ │ - tuple: StructType, Tuple: StructType,
│ │ │ │ │ -}
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -@functools.lru_cache()
│ │ │ │ │ -def from_annotation(annotation: Union[str, type, BusType]) -> Type:
│ │ │ │ │ - # Simple Python types
│ │ │ │ │ - if isinstance(annotation, str):
│ │ │ │ │ - types = from_signature(annotation)
│ │ │ │ │ - if len(types) != 1:
│ │ │ │ │ - raise TypeError(f"Signature '{annotation}' invalid as a type string "
│ │ │ │ │ - f"because it describes {len(types)} types, not one.")
│ │ │ │ │ - return types[0]
│ │ │ │ │ +class AskpassPrompt:
│ │ │ │ │ + """An askpass prompt resulting from a call to ferny-askpass.
│ │ │ │ │
│ │ │ │ │ - if isinstance(annotation, type):
│ │ │ │ │ - annotation = _base_equivalence_map.get(annotation, annotation)
│ │ │ │ │ + stderr: the contents of stderr from before ferny-askpass was called.
│ │ │ │ │ + Likely related to previous failed operations.
│ │ │ │ │ + messages: all but the last line of the prompt as handed to ferny-askpass.
│ │ │ │ │ + Usually contains context about the question.
│ │ │ │ │ + prompt: the last line handed to ferny-askpass. The prompt itself.
│ │ │ │ │ + """
│ │ │ │ │ + stderr: str
│ │ │ │ │ + messages: str
│ │ │ │ │ + prompt: str
│ │ │ │ │
│ │ │ │ │ - # Our own BusType types
│ │ │ │ │ - if isinstance(annotation, BusType):
│ │ │ │ │ - bus_type = typing.get_args(annotation.value)[1]
│ │ │ │ │ - assert isinstance(bus_type, Type)
│ │ │ │ │ - return bus_type
│ │ │ │ │ + def __init__(self, prompt: str, messages: str, stderr: str) -> None:
│ │ │ │ │ + self.stderr = stderr
│ │ │ │ │ + self.messages = messages
│ │ │ │ │ + self.prompt = prompt
│ │ │ │ │
│ │ │ │ │ - # Container types
│ │ │ │ │ - try:
│ │ │ │ │ - factory = _factory_map[typing.get_origin(annotation)]
│ │ │ │ │ - args = [from_annotation(arg) for arg in typing.get_args(annotation)]
│ │ │ │ │ - return factory(*args)
│ │ │ │ │ - except (AssertionError, AttributeError, KeyError, TypeError):
│ │ │ │ │ - raise TypeError(f"Cannot interpret {annotation} as a dbus type") from None
│ │ │ │ │ + def reply(self, response: str) -> None:
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ + def close(self) -> None:
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ -_base_typestring_map: Dict[str, Type] = {
│ │ │ │ │ - bustype.typestring: bustype for bustype in (from_annotation(entry) for entry in BusType)
│ │ │ │ │ + async def handle_via(self, responder: 'SshAskpassResponder') -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + response = await self.dispatch(responder)
│ │ │ │ │ + if response is not None:
│ │ │ │ │ + self.reply(response)
│ │ │ │ │ + finally:
│ │ │ │ │ + self.close()
│ │ │ │ │ +
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_prompt(self)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class SSHAskpassPrompt(AskpassPrompt):
│ │ │ │ │ + # The valid answers to prompts of this type. If this is None then any
│ │ │ │ │ + # answer is permitted. If it's a sequence then only answers from the
│ │ │ │ │ + # sequence are permitted. If it's an empty sequence, then no answer is
│ │ │ │ │ + # permitted (ie: the askpass callback should never return).
│ │ │ │ │ + answers: 'ClassVar[Sequence[str] | None]' = None
│ │ │ │ │ +
│ │ │ │ │ + # Patterns to capture. `_pattern` *must* match.
│ │ │ │ │ + _pattern: ClassVar[str]
│ │ │ │ │ + # `_extra_patterns` can fill in extra class attributes if they match.
│ │ │ │ │ + _extra_patterns: ClassVar[Sequence[str]] = ()
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None:
│ │ │ │ │ + super().__init__(prompt, messages, stderr)
│ │ │ │ │ + self.__dict__.update(match.groupdict())
│ │ │ │ │ +
│ │ │ │ │ + for pattern in self._extra_patterns:
│ │ │ │ │ + extra_match = re.search(with_helpers(pattern), messages, re.M)
│ │ │ │ │ + if extra_match is not None:
│ │ │ │ │ + self.__dict__.update(extra_match.groupdict())
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# Specific prompts
│ │ │ │ │ +HELPERS = {
│ │ │ │ │ + "%{algorithm}": r"(?P\b[-\w]+\b)",
│ │ │ │ │ + "%{filename}": r"(?P.+)",
│ │ │ │ │ + "%{fingerprint}": r"(?PSHA256:[0-9A-Za-z+/]{43})",
│ │ │ │ │ + "%{hostname}": r"(?P[^ @']+)",
│ │ │ │ │ + "%{pkcs11_id}": r"(?P.+)",
│ │ │ │ │ + "%{username}": r"(?P[^ @']+)",
│ │ │ │ │ }
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -@functools.lru_cache()
│ │ │ │ │ -def from_signature(signature: str) -> Tuple[Type, ...]:
│ │ │ │ │ - stack = list(reversed(signature))
│ │ │ │ │ +class SshPasswordPrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"%{username}@%{hostname}'s password: "
│ │ │ │ │ + username: 'str | None' = None
│ │ │ │ │ + hostname: 'str | None' = None
│ │ │ │ │
│ │ │ │ │ - def get_one() -> Type:
│ │ │ │ │ - first = stack.pop()
│ │ │ │ │ - if first == 'a':
│ │ │ │ │ - if stack[-1] == 'y':
│ │ │ │ │ - first += stack.pop()
│ │ │ │ │ - elif stack[-1] == '{':
│ │ │ │ │ - stack.pop()
│ │ │ │ │ - return DictionaryType(*get_several('}'))
│ │ │ │ │ - else:
│ │ │ │ │ - return ArrayType(get_one())
│ │ │ │ │ - elif first == '(':
│ │ │ │ │ - return StructType(*get_several(')'))
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_password_prompt(self)
│ │ │ │ │
│ │ │ │ │ - return _base_typestring_map[first]
│ │ │ │ │
│ │ │ │ │ - def get_several(end: str) -> Iterable[Type]:
│ │ │ │ │ - yield get_one()
│ │ │ │ │ - while stack[-1] != end:
│ │ │ │ │ - yield get_one()
│ │ │ │ │ - stack.pop()
│ │ │ │ │ +class SshPassphrasePrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Enter passphrase for key '%{filename}': "
│ │ │ │ │ + filename: str
│ │ │ │ │
│ │ │ │ │ - def get_all() -> Iterable[Type]:
│ │ │ │ │ - while stack:
│ │ │ │ │ - yield get_one()
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_passphrase_prompt(self)
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - return tuple(get_all())
│ │ │ │ │ - except (AssertionError, IndexError, KeyError) as exc:
│ │ │ │ │ - raise TypeError(f"Invalid type signature '{signature}'") from exc
│ │ │ │ │
│ │ │ │ │ +class SshFIDOPINPrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Enter PIN for %{algorithm} key %{filename}: "
│ │ │ │ │ + algorithm: str
│ │ │ │ │ + filename: str
│ │ │ │ │
│ │ │ │ │ -class MessageType:
│ │ │ │ │ - item_types: Sequence[Type]
│ │ │ │ │ - typestrings: List[str]
│ │ │ │ │ - signature: str
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_fido_pin_prompt(self)
│ │ │ │ │
│ │ │ │ │ - def __init__(self, item_types: Sequence[Union[str, type, BusType]]):
│ │ │ │ │ - self.item_types = [from_annotation(item_type) for item_type in item_types]
│ │ │ │ │ - self.typestrings = [item_type.typestring for item_type in self.item_types]
│ │ │ │ │ - self.signature = ''.join(self.typestrings)
│ │ │ │ │
│ │ │ │ │ - def write(self, message: libsystemd.sd_bus_message, *items: object) -> None:
│ │ │ │ │ - assert len(items) == len(self.item_types)
│ │ │ │ │ - for item_type, item in zip(self.item_types, items):
│ │ │ │ │ - item_type.writer(message, item)
│ │ │ │ │ +class SshFIDOUserPresencePrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Confirm user presence for key %{algorithm} %{fingerprint}"
│ │ │ │ │ + answers = ()
│ │ │ │ │ + algorithm: str
│ │ │ │ │ + fingerprint: str
│ │ │ │ │
│ │ │ │ │ - def read(self, message: libsystemd.sd_bus_message) -> Optional[Tuple[object, ...]]:
│ │ │ │ │ - if not message.has_signature(self.signature):
│ │ │ │ │ - return None
│ │ │ │ │ - return tuple(item_type.reader(message) for item_type in self.item_types)
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_fido_user_presence_prompt(self)
│ │ │ │ │
│ │ │ │ │ - def __len__(self) -> int:
│ │ │ │ │ - return len(self.item_types)
│ │ │ │ │
│ │ │ │ │ +class SshPKCS11PINPrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Enter PIN for '%{pkcs11_id}': "
│ │ │ │ │ + pkcs11_id: str
│ │ │ │ │
│ │ │ │ │ -class JSONEncoder(json.JSONEncoder):
│ │ │ │ │ - def default(self, obj: object) -> object:
│ │ │ │ │ - if isinstance(obj, Variant):
│ │ │ │ │ - return {"t": obj.type.typestring, "v": obj.value}
│ │ │ │ │ - elif isinstance(obj, bytes):
│ │ │ │ │ - return binascii.b2a_base64(obj, newline=False).decode('ascii')
│ │ │ │ │ - return super().default(obj)
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_pkcs11_pin_prompt(self)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class SshHostKeyPrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Are you sure you want to continue connecting \(yes/no(/\[fingerprint\])?\)\? "
│ │ │ │ │ + _extra_patterns = [
│ │ │ │ │ + r"%{fingerprint}[.]$",
│ │ │ │ │ + r"^%{algorithm} key fingerprint is",
│ │ │ │ │ + r"^The fingerprint for the %{algorithm} key sent by the remote host is$"
│ │ │ │ │ + ]
│ │ │ │ │ + answers = ('yes', 'no')
│ │ │ │ │ + algorithm: str
│ │ │ │ │ + fingerprint: str
│ │ │ │ │ +
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_host_key_prompt(self)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def with_helpers(pattern: str) -> str:
│ │ │ │ │ + for name, helper in HELPERS.items():
│ │ │ │ │ + pattern = pattern.replace(name, helper)
│ │ │ │ │ +
│ │ │ │ │ + assert '%{' not in pattern
│ │ │ │ │ + return pattern
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt:
│ │ │ │ │ + classes = [
│ │ │ │ │ + SshFIDOPINPrompt,
│ │ │ │ │ + SshFIDOUserPresencePrompt,
│ │ │ │ │ + SshHostKeyPrompt,
│ │ │ │ │ + SshPKCS11PINPrompt,
│ │ │ │ │ + SshPassphrasePrompt,
│ │ │ │ │ + SshPasswordPrompt,
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + # The last line is the line after the last newline character, excluding the
│ │ │ │ │ + # optional final newline character. eg: "x\ny\nLAST\n" or "x\ny\nLAST"
│ │ │ │ │ + second_last_newline = string.rfind('\n', 0, -1)
│ │ │ │ │ + if second_last_newline >= 0:
│ │ │ │ │ + last_line = string[second_last_newline + 1:]
│ │ │ │ │ + extras = string[:second_last_newline + 1]
│ │ │ │ │ + else:
│ │ │ │ │ + last_line = string
│ │ │ │ │ + extras = ''
│ │ │ │ │ +
│ │ │ │ │ + for cls in classes:
│ │ │ │ │ + pattern = with_helpers(cls._pattern)
│ │ │ │ │ + match = re.fullmatch(pattern, last_line)
│ │ │ │ │ + if match is not None:
│ │ │ │ │ + return cls(last_line, extras, stderr, match)
│ │ │ │ │ +
│ │ │ │ │ + return AskpassPrompt(last_line, extras, stderr)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class SshAskpassResponder(AskpassHandler):
│ │ │ │ │ + async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None':
│ │ │ │ │ + return await categorize_ssh_prompt(prompt, stderr).dispatch(self)
│ │ │ │ │ +
│ │ │ │ │ + async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None':
│ │ │ │ │ + # Default fallback for unrecognised message types: unimplemented
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ + async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │ +
│ │ │ │ │ + async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │ +
│ │ │ │ │ + async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │ +
│ │ │ │ │ + async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │ +
│ │ │ │ │ + async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │ +
│ │ │ │ │ + async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/introspection.py': br'''# systemd_ctypes
│ │ │ │ │ + 'cockpit/_vendor/ferny/session.py': r'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -11403,86 +10746,743 @@
│ │ │ │ │ # 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 .
│ │ │ │ │
│ │ │ │ │ -import xml.etree.ElementTree as ET
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import ctypes
│ │ │ │ │ +import functools
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import shlex
│ │ │ │ │ +import signal
│ │ │ │ │ +import subprocess
│ │ │ │ │ +import tempfile
│ │ │ │ │ +from typing import Mapping, Sequence
│ │ │ │ │
│ │ │ │ │ +from . import ssh_errors
│ │ │ │ │ +from .interaction_agent import InteractionAgent, InteractionError, InteractionHandler, write_askpass_to_tmpdir
│ │ │ │ │
│ │ │ │ │ -def parse_method(method):
│ │ │ │ │ - return {
│ │ │ │ │ - "in": [tag.attrib['type'] for tag in method.findall("arg") if tag.get('direction', 'in') == 'in'],
│ │ │ │ │ - "out": [tag.attrib['type'] for tag in method.findall("arg[@direction='out']")]
│ │ │ │ │ - }
│ │ │ │ │ +prctl = ctypes.cdll.LoadLibrary('libc.so.6').prctl
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +PR_SET_PDEATHSIG = 1
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def parse_property(prop):
│ │ │ │ │ - return {
│ │ │ │ │ - "flags": 'w' if prop.attrib.get('access') == 'write' else 'r',
│ │ │ │ │ - "type": prop.attrib['type']
│ │ │ │ │ - }
│ │ │ │ │ +@functools.lru_cache()
│ │ │ │ │ +def has_feature(feature: str, teststr: str = 'x') -> bool:
│ │ │ │ │ + try:
│ │ │ │ │ + subprocess.check_output(['ssh', f'-o{feature} {teststr}', '-G', 'nonexisting'], stderr=subprocess.DEVNULL)
│ │ │ │ │ + return True
│ │ │ │ │ + except subprocess.CalledProcessError:
│ │ │ │ │ + return False
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def parse_signal(signal):
│ │ │ │ │ - return {"in": [tag.attrib['type'] for tag in signal.findall("arg")]}
│ │ │ │ │ +class SubprocessContext:
│ │ │ │ │ + def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:
│ │ │ │ │ + """Return the args required to launch a process in the given context.
│ │ │ │ │
│ │ │ │ │ + For example, this might return a vector with
│ │ │ │ │ + ["sudo"]
│ │ │ │ │ + or
│ │ │ │ │ + ["flatpak-spawn", "--host"]
│ │ │ │ │ + prepended.
│ │ │ │ │
│ │ │ │ │ -def parse_interface(interface):
│ │ │ │ │ - return {
│ │ │ │ │ - "methods": {tag.attrib['name']: parse_method(tag) for tag in interface.findall('method')},
│ │ │ │ │ - "properties": {tag.attrib['name']: parse_property(tag) for tag in interface.findall('property')},
│ │ │ │ │ - "signals": {tag.attrib['name']: parse_signal(tag) for tag in interface.findall('signal')}
│ │ │ │ │ - }
│ │ │ │ │ + It is also possible that more substantial changes may be performed.
│ │ │ │ │
│ │ │ │ │ + This function is not permitted to modify its argument, although it may
│ │ │ │ │ + (optionally) return it unmodified, if no changes are required.
│ │ │ │ │ + """
│ │ │ │ │ + return args
│ │ │ │ │
│ │ │ │ │ -def parse_xml(xml):
│ │ │ │ │ - et = ET.fromstring(xml)
│ │ │ │ │ - return {tag.attrib['name']: parse_interface(tag) for tag in et.findall('interface')}
│ │ │ │ │ + def wrap_subprocess_env(self, env: Mapping[str, str]) -> Mapping[str, str]:
│ │ │ │ │ + """Return the envp required to launch a process in the given context.
│ │ │ │ │
│ │ │ │ │ + For example, this might set the "SUDO_ASKPASS" environment variable, if
│ │ │ │ │ + needed.
│ │ │ │ │
│ │ │ │ │ -# Pretend like this is a little bit functional
│ │ │ │ │ -def element(tag, children=(), **kwargs):
│ │ │ │ │ - tag = ET.Element(tag, kwargs)
│ │ │ │ │ - tag.extend(children)
│ │ │ │ │ - return tag
│ │ │ │ │ + As with wrap_subprocess_args(), this function is not permitted to
│ │ │ │ │ + modify its argument, although it may (optionally) return it unmodified
│ │ │ │ │ + if no changes are required.
│ │ │ │ │ + """
│ │ │ │ │ + return env
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def method_to_xml(name, method_info):
│ │ │ │ │ - return element('method', name=name,
│ │ │ │ │ - children=[
│ │ │ │ │ - element('arg', type=arg_type, direction=direction)
│ │ │ │ │ - for direction in ['in', 'out']
│ │ │ │ │ - for arg_type in method_info[direction]
│ │ │ │ │ - ])
│ │ │ │ │ +class Session(SubprocessContext, InteractionHandler):
│ │ │ │ │ + # Set after .connect() called, even if failed
│ │ │ │ │ + _controldir: 'tempfile.TemporaryDirectory | None' = None
│ │ │ │ │ + _controlsock: 'str | None' = None
│ │ │ │ │
│ │ │ │ │ + # Set if connected, else None
│ │ │ │ │ + _process: 'asyncio.subprocess.Process | None' = None
│ │ │ │ │
│ │ │ │ │ -def property_to_xml(name, property_info):
│ │ │ │ │ - return element('property', name=name,
│ │ │ │ │ - access='write' if property_info['flags'] == 'w' else 'read',
│ │ │ │ │ - type=property_info['type'])
│ │ │ │ │ + async def connect(self,
│ │ │ │ │ + destination: str,
│ │ │ │ │ + handle_host_key: bool = False,
│ │ │ │ │ + configfile: 'str | None' = None,
│ │ │ │ │ + identity_file: 'str | None' = None,
│ │ │ │ │ + login_name: 'str | None' = None,
│ │ │ │ │ + options: 'Mapping[str, str] | None' = None,
│ │ │ │ │ + pkcs11: 'str | None' = None,
│ │ │ │ │ + port: 'int | None' = None,
│ │ │ │ │ + interaction_responder: 'InteractionHandler | None' = None) -> None:
│ │ │ │ │ + rundir = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/run'), 'ferny')
│ │ │ │ │ + os.makedirs(rundir, exist_ok=True)
│ │ │ │ │ + self._controldir = tempfile.TemporaryDirectory(dir=rundir)
│ │ │ │ │ + self._controlsock = f'{self._controldir.name}/socket'
│ │ │ │ │ +
│ │ │ │ │ + # In general, we can't guarantee an accessible and executable version
│ │ │ │ │ + # of this file, but since it's small and we're making a temporary
│ │ │ │ │ + # directory anyway, let's just copy it into place and use it from
│ │ │ │ │ + # there.
│ │ │ │ │ + askpass_path = write_askpass_to_tmpdir(self._controldir.name)
│ │ │ │ │
│ │ │ │ │ + env = dict(os.environ)
│ │ │ │ │ + env['SSH_ASKPASS'] = askpass_path
│ │ │ │ │ + env['SSH_ASKPASS_REQUIRE'] = 'force'
│ │ │ │ │ + # old SSH doesn't understand SSH_ASKPASS_REQUIRE and guesses based on DISPLAY instead
│ │ │ │ │ + env['DISPLAY'] = '-'
│ │ │ │ │
│ │ │ │ │ -def signal_to_xml(name, signal_info):
│ │ │ │ │ - return element('signal', name=name,
│ │ │ │ │ - children=[
│ │ │ │ │ - element('arg', type=arg_type) for arg_type in signal_info['in']
│ │ │ │ │ - ])
│ │ │ │ │ + args = [
│ │ │ │ │ + '-M',
│ │ │ │ │ + '-N',
│ │ │ │ │ + '-S', self._controlsock,
│ │ │ │ │ + '-o', 'PermitLocalCommand=yes',
│ │ │ │ │ + '-o', f'LocalCommand={askpass_path}',
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ + if configfile is not None:
│ │ │ │ │ + args.append(f'-F{configfile}')
│ │ │ │ │
│ │ │ │ │ -def interface_to_xml(name, interface_info):
│ │ │ │ │ - return element('interface', name=name,
│ │ │ │ │ - children=[
│ │ │ │ │ - *(method_to_xml(name, info) for name, info in interface_info['methods'].items()),
│ │ │ │ │ - *(property_to_xml(name, info) for name, info in interface_info['properties'].items()),
│ │ │ │ │ - *(signal_to_xml(name, info) for name, info in interface_info['signals'].items()),
│ │ │ │ │ - ])
│ │ │ │ │ + if identity_file is not None:
│ │ │ │ │ + args.append(f'-i{identity_file}')
│ │ │ │ │ +
│ │ │ │ │ + if options is not None:
│ │ │ │ │ + for key in options: # Note: Mapping may not have .items()
│ │ │ │ │ + args.append(f'-o{key} {options[key]}')
│ │ │ │ │
│ │ │ │ │ + if pkcs11 is not None:
│ │ │ │ │ + args.append(f'-I{pkcs11}')
│ │ │ │ │
│ │ │ │ │ -def to_xml(interfaces):
│ │ │ │ │ - node = element('node', children=(interface_to_xml(name, members) for name, members in interfaces.items()))
│ │ │ │ │ - return ET.tostring(node, encoding='unicode')
│ │ │ │ │ + if port is not None:
│ │ │ │ │ + args.append(f'-p{port}')
│ │ │ │ │ +
│ │ │ │ │ + if login_name is not None:
│ │ │ │ │ + args.append(f'-l{login_name}')
│ │ │ │ │ +
│ │ │ │ │ + if handle_host_key and has_feature('KnownHostsCommand'):
│ │ │ │ │ + args.extend([
│ │ │ │ │ + '-o', f'KnownHostsCommand={askpass_path} %I %H %t %K %f',
│ │ │ │ │ + '-o', 'StrictHostKeyChecking=yes',
│ │ │ │ │ + ])
│ │ │ │ │ +
│ │ │ │ │ + agent = InteractionAgent([interaction_responder] if interaction_responder is not None else [])
│ │ │ │ │ +
│ │ │ │ │ + # SSH_ASKPASS_REQUIRE is not generally available, so use setsid
│ │ │ │ │ + process = await asyncio.create_subprocess_exec(
│ │ │ │ │ + *('/usr/bin/ssh', *args, destination), env=env,
│ │ │ │ │ + start_new_session=True, stdin=asyncio.subprocess.DEVNULL,
│ │ │ │ │ + stdout=asyncio.subprocess.DEVNULL, stderr=agent, # type: ignore
│ │ │ │ │ + preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL))
│ │ │ │ │ +
│ │ │ │ │ + # This is tricky: we need to clean up the subprocess, but only in case
│ │ │ │ │ + # if failure. Otherwise, we keep it around.
│ │ │ │ │ + try:
│ │ │ │ │ + await agent.communicate()
│ │ │ │ │ + assert os.path.exists(self._controlsock)
│ │ │ │ │ + self._process = process
│ │ │ │ │ + except InteractionError as exc:
│ │ │ │ │ + await process.wait()
│ │ │ │ │ + raise ssh_errors.get_exception_for_ssh_stderr(str(exc)) from None
│ │ │ │ │ + except BaseException:
│ │ │ │ │ + # If we get here because the InteractionHandler raised an
│ │ │ │ │ + # exception then SSH might still be running, and may even attempt
│ │ │ │ │ + # further interactions (ie: 2nd attempt for password). We already
│ │ │ │ │ + # have our exception and don't need any more info. Kill it.
│ │ │ │ │ + try:
│ │ │ │ │ + process.kill()
│ │ │ │ │ + except ProcessLookupError:
│ │ │ │ │ + pass # already exited? good.
│ │ │ │ │ + await process.wait()
│ │ │ │ │ + raise
│ │ │ │ │ +
│ │ │ │ │ + def is_connected(self) -> bool:
│ │ │ │ │ + return self._process is not None
│ │ │ │ │ +
│ │ │ │ │ + async def wait(self) -> None:
│ │ │ │ │ + assert self._process is not None
│ │ │ │ │ + await self._process.wait()
│ │ │ │ │ +
│ │ │ │ │ + def exit(self) -> None:
│ │ │ │ │ + assert self._process is not None
│ │ │ │ │ + self._process.terminate()
│ │ │ │ │ +
│ │ │ │ │ + async def disconnect(self) -> None:
│ │ │ │ │ + self.exit()
│ │ │ │ │ + await self.wait()
│ │ │ │ │ +
│ │ │ │ │ + # Launching of processes
│ │ │ │ │ + def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:
│ │ │ │ │ + assert self._controlsock is not None
│ │ │ │ │ + # 1. We specify the hostname as the empty string: it will be ignored
│ │ │ │ │ + # when ssh is trying to use the control socket, but in case the
│ │ │ │ │ + # socket has stopped working, ssh will try to fall back to directly
│ │ │ │ │ + # connecting, in which case an empty hostname will prevent that.
│ │ │ │ │ + # 2. We need to quote the arguments — ssh will paste them together
│ │ │ │ │ + # using only spaces, executing the result using the user's shell.
│ │ │ │ │ + return ('ssh', '-S', self._controlsock, '', *map(shlex.quote, args))
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ + 'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import ctypes
│ │ │ │ │ +import errno
│ │ │ │ │ +import os
│ │ │ │ │ +import re
│ │ │ │ │ +import socket
│ │ │ │ │ +from typing import ClassVar, Iterable, Match, Pattern
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class SshError(Exception):
│ │ │ │ │ + PATTERN: ClassVar[Pattern]
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, match: 'Match | None', stderr: str) -> None:
│ │ │ │ │ + super().__init__(match.group(0) if match is not None else stderr)
│ │ │ │ │ + self.stderr = stderr
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class SshAuthenticationError(SshError):
│ │ │ │ │ + PATTERN = re.compile(r'^([^:]+): Permission denied \(([^()]+)\)\.$', re.M)
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, match: Match, stderr: str) -> None:
│ │ │ │ │ + super().__init__(match, stderr)
│ │ │ │ │ + self.destination = match.group(1)
│ │ │ │ │ + self.methods = match.group(2).split(',')
│ │ │ │ │ + self.message = match.group(0)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# generic host key error for OSes without KnownHostsCommand support
│ │ │ │ │ +class SshHostKeyError(SshError):
│ │ │ │ │ + PATTERN = re.compile(r'^Host key verification failed.$', re.M)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# specific errors for OSes with KnownHostsCommand
│ │ │ │ │ +class SshUnknownHostKeyError(SshHostKeyError):
│ │ │ │ │ + PATTERN = re.compile(r'^No .* host key is known.*Host key verification failed.$', re.S | re.M)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class SshChangedHostKeyError(SshHostKeyError):
│ │ │ │ │ + PATTERN = re.compile(r'warning.*remote host identification has changed', re.I)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# Functionality for mapping getaddrinfo()-family error messages to their
│ │ │ │ │ +# equivalent Python exceptions.
│ │ │ │ │ +def make_gaierror_map() -> 'Iterable[tuple[str, int]]':
│ │ │ │ │ + libc = ctypes.CDLL(None)
│ │ │ │ │ + libc.gai_strerror.restype = ctypes.c_char_p
│ │ │ │ │ +
│ │ │ │ │ + for key in dir(socket):
│ │ │ │ │ + if key.startswith('EAI_'):
│ │ │ │ │ + errnum = getattr(socket, key)
│ │ │ │ │ + yield libc.gai_strerror(errnum).decode('utf-8'), errnum
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +gaierror_map = dict(make_gaierror_map())
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# Functionality for passing strerror() error messages to their equivalent
│ │ │ │ │ +# Python exceptions.
│ │ │ │ │ +# There doesn't seem to be an official API for turning an errno into the
│ │ │ │ │ +# correct subtype of OSError, and the list that cpython uses is hidden fairly
│ │ │ │ │ +# deeply inside of the implementation. This is basically copied from the
│ │ │ │ │ +# ADD_ERRNO() lines in _PyExc_InitState in cpython/Objects/exceptions.c
│ │ │ │ │ +oserror_subclass_map = dict((errnum, cls) for cls, errnum in [
│ │ │ │ │ + (BlockingIOError, errno.EAGAIN),
│ │ │ │ │ + (BlockingIOError, errno.EALREADY),
│ │ │ │ │ + (BlockingIOError, errno.EINPROGRESS),
│ │ │ │ │ + (BlockingIOError, errno.EWOULDBLOCK),
│ │ │ │ │ + (BrokenPipeError, errno.EPIPE),
│ │ │ │ │ + (BrokenPipeError, errno.ESHUTDOWN),
│ │ │ │ │ + (ChildProcessError, errno.ECHILD),
│ │ │ │ │ + (ConnectionAbortedError, errno.ECONNABORTED),
│ │ │ │ │ + (ConnectionRefusedError, errno.ECONNREFUSED),
│ │ │ │ │ + (ConnectionResetError, errno.ECONNRESET),
│ │ │ │ │ + (FileExistsError, errno.EEXIST),
│ │ │ │ │ + (FileNotFoundError, errno.ENOENT),
│ │ │ │ │ + (IsADirectoryError, errno.EISDIR),
│ │ │ │ │ + (NotADirectoryError, errno.ENOTDIR),
│ │ │ │ │ + (InterruptedError, errno.EINTR),
│ │ │ │ │ + (PermissionError, errno.EACCES),
│ │ │ │ │ + (PermissionError, errno.EPERM),
│ │ │ │ │ + (ProcessLookupError, errno.ESRCH),
│ │ │ │ │ + (TimeoutError, errno.ETIMEDOUT),
│ │ │ │ │ +])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def get_exception_for_ssh_stderr(stderr: str) -> Exception:
│ │ │ │ │ + stderr = stderr.replace('\r\n', '\n') # fix line separators
│ │ │ │ │ +
│ │ │ │ │ + # check for the specific error messages first, then for generic SshHostKeyError
│ │ │ │ │ + for ssh_cls in [SshAuthenticationError, SshChangedHostKeyError, SshUnknownHostKeyError, SshHostKeyError]:
│ │ │ │ │ + match = ssh_cls.PATTERN.search(stderr)
│ │ │ │ │ + if match is not None:
│ │ │ │ │ + return ssh_cls(match, stderr)
│ │ │ │ │ +
│ │ │ │ │ + before, colon, after = stderr.rpartition(':')
│ │ │ │ │ + if colon and after:
│ │ │ │ │ + potential_strerror = after.strip()
│ │ │ │ │ +
│ │ │ │ │ + # DNS lookup errors
│ │ │ │ │ + if potential_strerror in gaierror_map:
│ │ │ │ │ + errnum = gaierror_map[potential_strerror]
│ │ │ │ │ + return socket.gaierror(errnum, stderr)
│ │ │ │ │ +
│ │ │ │ │ + # Network connect errors
│ │ │ │ │ + for errnum in errno.errorcode:
│ │ │ │ │ + if os.strerror(errnum) == potential_strerror:
│ │ │ │ │ + os_cls = oserror_subclass_map.get(errnum, OSError)
│ │ │ │ │ + return os_cls(errnum, stderr)
│ │ │ │ │ +
│ │ │ │ │ + # No match? Generic.
│ │ │ │ │ + return SshError(None, stderr)
│ │ │ │ │ ''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/py.typed': br'''''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# 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 .
│ │ │ │ │ +
│ │ │ │ │ +import array
│ │ │ │ │ +import ast
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import re
│ │ │ │ │ +import socket
│ │ │ │ │ +import tempfile
│ │ │ │ │ +from typing import Any, Callable, ClassVar, Generator, Sequence
│ │ │ │ │ +
│ │ │ │ │ +from . import interaction_client
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +COMMAND_RE = re.compile(b'\0ferny\0([^\n]*)\0\0\n')
│ │ │ │ │ +COMMAND_TEMPLATE = '\0ferny\0{(command, args)!r}\0\0\n'
│ │ │ │ │ +
│ │ │ │ │ +BEIBOOT_GADGETS = {
│ │ │ │ │ + "command": fr"""
│ │ │ │ │ + import sys
│ │ │ │ │ + def command(command, *args):
│ │ │ │ │ + sys.stderr.write(f{COMMAND_TEMPLATE!r})
│ │ │ │ │ + sys.stderr.flush()
│ │ │ │ │ + """,
│ │ │ │ │ + "end": r"""
│ │ │ │ │ + def end():
│ │ │ │ │ + command('ferny.end')
│ │ │ │ │ + """,
│ │ │ │ │ +}
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class InteractionError(Exception):
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +try:
│ │ │ │ │ + recv_fds = socket.recv_fds
│ │ │ │ │ +except AttributeError:
│ │ │ │ │ + # Python < 3.9
│ │ │ │ │ +
│ │ │ │ │ + def recv_fds(
│ │ │ │ │ + sock: socket.socket, bufsize: int, maxfds: int, flags: int = 0
│ │ │ │ │ + ) -> 'tuple[bytes, list[int], int, None]':
│ │ │ │ │ + fds = array.array("i")
│ │ │ │ │ + msg, ancdata, flags, addr = sock.recvmsg(bufsize, socket.CMSG_LEN(maxfds * fds.itemsize))
│ │ │ │ │ + for cmsg_level, cmsg_type, cmsg_data in ancdata:
│ │ │ │ │ + if (cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS):
│ │ │ │ │ + fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
│ │ │ │ │ + return msg, list(fds), flags, addr
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def get_running_loop() -> asyncio.AbstractEventLoop:
│ │ │ │ │ + try:
│ │ │ │ │ + return asyncio.get_running_loop()
│ │ │ │ │ + except AttributeError:
│ │ │ │ │ + # Python 3.6
│ │ │ │ │ + return asyncio.get_event_loop()
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class InteractionHandler:
│ │ │ │ │ + commands: ClassVar[Sequence[str]]
│ │ │ │ │ +
│ │ │ │ │ + async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class AskpassHandler(InteractionHandler):
│ │ │ │ │ + commands: ClassVar[Sequence[str]] = ('ferny.askpass',)
│ │ │ │ │ +
│ │ │ │ │ + async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None':
│ │ │ │ │ + """Prompt the user for an authentication or confirmation interaction.
│ │ │ │ │ +
│ │ │ │ │ + 'messages' is data that was sent to stderr before the interaction was requested.
│ │ │ │ │ + 'prompt' is the interaction prompt.
│ │ │ │ │ +
│ │ │ │ │ + The expected response type depends on hint:
│ │ │ │ │ +
│ │ │ │ │ + - "confirm": ask for permission, returning "yes" if accepted
│ │ │ │ │ + - example: authorizing agent operation
│ │ │ │ │ +
│ │ │ │ │ + - "none": show a request without need for a response
│ │ │ │ │ + - example: please touch your authentication token
│ │ │ │ │ +
│ │ │ │ │ + - otherwise: return a password or other form of text token
│ │ │ │ │ + - examples: enter password, unlock private key
│ │ │ │ │ +
│ │ │ │ │ + In any case, the function should properly handle cancellation. For the
│ │ │ │ │ + "none" case, this will be the normal way to dismiss the dialog.
│ │ │ │ │ + """
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ + async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:
│ │ │ │ │ + """Prompt the user for a decision regarding acceptance of a host key.
│ │ │ │ │ +
│ │ │ │ │ + The "reason" will be either "HOSTNAME" or "ADDRESS" (if `CheckHostIP` is enabled).
│ │ │ │ │ +
│ │ │ │ │ + The host, algorithm, and key parameters are the values in the form that
│ │ │ │ │ + they would appear one a single line in the known hosts file. The
│ │ │ │ │ + fingerprint is the key fingerprint in the format that ssh would
│ │ │ │ │ + normally present it to the user.
│ │ │ │ │ +
│ │ │ │ │ + In case the host key should be accepted, this function needs to return
│ │ │ │ │ + True. Returning False means that ssh implements its default logic. To
│ │ │ │ │ + interrupt the connection, raise an exception.
│ │ │ │ │ + """
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + async def do_custom_command(
│ │ │ │ │ + self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str
│ │ │ │ │ + ) -> None:
│ │ │ │ │ + """Handle a custom command.
│ │ │ │ │ +
│ │ │ │ │ + The command name, its arguments, the passed fds, and the stderr leading
│ │ │ │ │ + up to the command invocation are all provided.
│ │ │ │ │ +
│ │ │ │ │ + See doc/interaction-protocol.md
│ │ │ │ │ + """
│ │ │ │ │ +
│ │ │ │ │ + async def _askpass_command(self, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
│ │ │ │ │ + logger.debug('_askpass_command(%s, %s, %s)', args, fds, stderr)
│ │ │ │ │ + try:
│ │ │ │ │ + argv, env = args
│ │ │ │ │ + assert isinstance(argv, list)
│ │ │ │ │ + assert all(isinstance(arg, str) for arg in argv)
│ │ │ │ │ + assert isinstance(env, dict)
│ │ │ │ │ + assert all(isinstance(key, str) and isinstance(val, str) for key, val in env.items())
│ │ │ │ │ + assert len(fds) == 2
│ │ │ │ │ + except (ValueError, TypeError, AssertionError) as exc:
│ │ │ │ │ + logger.error('Invalid arguments to askpass interaction: %s, %s: %s', args, fds, exc)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + with open(fds.pop(0), 'w') as status, open(fds.pop(0), 'w') as stdout:
│ │ │ │ │ + try:
│ │ │ │ │ + loop = get_running_loop()
│ │ │ │ │ + try:
│ │ │ │ │ + task = asyncio.current_task()
│ │ │ │ │ + except AttributeError:
│ │ │ │ │ + task = asyncio.Task.current_task() # type:ignore[attr-defined] # (Python 3.6)
│ │ │ │ │ + assert task is not None
│ │ │ │ │ + loop.add_reader(status, task.cancel)
│ │ │ │ │ +
│ │ │ │ │ + if len(argv) == 2:
│ │ │ │ │ + # normal askpass
│ │ │ │ │ + prompt = argv[1]
│ │ │ │ │ + hint = env.get('SSH_ASKPASS_PROMPT', '')
│ │ │ │ │ + logger.debug('do_askpass(%r, %r, %r)', stderr, prompt, hint)
│ │ │ │ │ + answer = await self.do_askpass(stderr, prompt, hint)
│ │ │ │ │ + logger.debug('do_askpass answer %r', answer)
│ │ │ │ │ + if answer is not None:
│ │ │ │ │ + print(answer, file=stdout)
│ │ │ │ │ + print(0, file=status)
│ │ │ │ │ +
│ │ │ │ │ + elif len(argv) == 6:
│ │ │ │ │ + # KnownHostsCommand
│ │ │ │ │ + argv0, reason, host, algorithm, key, fingerprint = argv
│ │ │ │ │ + if reason in ['ADDRESS', 'HOSTNAME']:
│ │ │ │ │ + logger.debug('do_hostkey(%r, %r, %r, %r, %r)', reason, host, algorithm, key, fingerprint)
│ │ │ │ │ + if await self.do_hostkey(reason, host, algorithm, key, fingerprint):
│ │ │ │ │ + print(host, algorithm, key, file=stdout)
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug('ignoring KnownHostsCommand reason %r', reason)
│ │ │ │ │ +
│ │ │ │ │ + print(0, file=status)
│ │ │ │ │ +
│ │ │ │ │ + else:
│ │ │ │ │ + logger.error('Incorrect number of command-line arguments to ferny-askpass: %s', argv)
│ │ │ │ │ + finally:
│ │ │ │ │ + loop.remove_reader(status)
│ │ │ │ │ +
│ │ │ │ │ + async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
│ │ │ │ │ + logger.debug('run_command(%s, %s, %s, %s)', command, args, fds, stderr)
│ │ │ │ │ + if command == 'ferny.askpass':
│ │ │ │ │ + await self._askpass_command(args, fds, stderr)
│ │ │ │ │ + else:
│ │ │ │ │ + await self.do_custom_command(command, args, fds, stderr)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class InteractionAgent:
│ │ │ │ │ + _handlers: 'dict[str, InteractionHandler]'
│ │ │ │ │ +
│ │ │ │ │ + _loop: asyncio.AbstractEventLoop
│ │ │ │ │ +
│ │ │ │ │ + _tasks: 'set[asyncio.Task]'
│ │ │ │ │ +
│ │ │ │ │ + _buffer: bytearray
│ │ │ │ │ + _ours: socket.socket
│ │ │ │ │ + _theirs: socket.socket
│ │ │ │ │ +
│ │ │ │ │ + _completion_future: 'asyncio.Future[str]'
│ │ │ │ │ + _pending_result: 'None | str | Exception' = None
│ │ │ │ │ + _end: bool = False
│ │ │ │ │ +
│ │ │ │ │ + def _consider_completion(self) -> None:
│ │ │ │ │ + logger.debug('_consider_completion(%r)', self)
│ │ │ │ │ +
│ │ │ │ │ + if self._pending_result is None or self._tasks:
│ │ │ │ │ + logger.debug(' but not ready yet')
│ │ │ │ │ +
│ │ │ │ │ + elif self._completion_future.done():
│ │ │ │ │ + logger.debug(' but already complete')
│ │ │ │ │ +
│ │ │ │ │ + elif isinstance(self._pending_result, str):
│ │ │ │ │ + logger.debug(' submitting stderr (%r) to completion_future', self._pending_result)
│ │ │ │ │ + self._completion_future.set_result(self._pending_result)
│ │ │ │ │ +
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug(' submitting exception (%r) to completion_future')
│ │ │ │ │ + self._completion_future.set_exception(self._pending_result)
│ │ │ │ │ +
│ │ │ │ │ + def _result(self, result: 'str | Exception') -> None:
│ │ │ │ │ + logger.debug('_result(%r, %r)', self, result)
│ │ │ │ │ +
│ │ │ │ │ + if self._pending_result is None:
│ │ │ │ │ + self._pending_result = result
│ │ │ │ │ +
│ │ │ │ │ + if self._ours.fileno() != -1:
│ │ │ │ │ + logger.debug(' remove_reader(%r)', self._ours)
│ │ │ │ │ + self._loop.remove_reader(self._ours.fileno())
│ │ │ │ │ +
│ │ │ │ │ + for task in self._tasks:
│ │ │ │ │ + logger.debug(' cancel(%r)', task)
│ │ │ │ │ + task.cancel()
│ │ │ │ │ +
│ │ │ │ │ + logger.debug(' closing sockets')
│ │ │ │ │ + self._theirs.close() # idempotent
│ │ │ │ │ + self._ours.close()
│ │ │ │ │ +
│ │ │ │ │ + self._consider_completion()
│ │ │ │ │ +
│ │ │ │ │ + def _invoke_command(self, stderr: bytes, command_blob: bytes, fds: 'list[int]') -> None:
│ │ │ │ │ + logger.debug('_invoke_command(%r, %r, %r)', stderr, command_blob, fds)
│ │ │ │ │ + try:
│ │ │ │ │ + command, args = ast.literal_eval(command_blob.decode())
│ │ │ │ │ + if not isinstance(command, str) or not isinstance(args, tuple):
│ │ │ │ │ + raise TypeError('Invalid argument types')
│ │ │ │ │ + except (UnicodeDecodeError, SyntaxError, ValueError, TypeError) as exc:
│ │ │ │ │ + logger.error('Received invalid ferny command: %s: %s', command_blob, exc)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + if command == 'ferny.end':
│ │ │ │ │ + self._end = True
│ │ │ │ │ + self._result(self._buffer.decode(errors='replace'))
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + handler = self._handlers[command]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + logger.error('Received unhandled ferny command: %s', command)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + # The task is responsible for the list of fds and removing itself
│ │ │ │ │ + # from the set.
│ │ │ │ │ + task_fds = list(fds)
│ │ │ │ │ + task = self._loop.create_task(handler.run_command(command, args, task_fds, stderr.decode()))
│ │ │ │ │ +
│ │ │ │ │ + def bottom_half(completed_task: asyncio.Task) -> None:
│ │ │ │ │ + assert completed_task is task
│ │ │ │ │ + while task_fds:
│ │ │ │ │ + os.close(task_fds.pop())
│ │ │ │ │ + self._tasks.remove(task)
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + task.result()
│ │ │ │ │ + logger.debug('%r completed cleanly', handler)
│ │ │ │ │ + except asyncio.CancelledError:
│ │ │ │ │ + # this is not an error — it just means ferny-askpass exited via signal
│ │ │ │ │ + logger.debug('%r was cancelled', handler)
│ │ │ │ │ + except Exception as exc:
│ │ │ │ │ + logger.debug('%r raised %r', handler, exc)
│ │ │ │ │ + self._result(exc)
│ │ │ │ │ +
│ │ │ │ │ + self._consider_completion()
│ │ │ │ │ +
│ │ │ │ │ + task.add_done_callback(bottom_half)
│ │ │ │ │ + self._tasks.add(task)
│ │ │ │ │ + fds[:] = []
│ │ │ │ │ +
│ │ │ │ │ + def _got_data(self, data: bytes, fds: 'list[int]') -> None:
│ │ │ │ │ + logger.debug('_got_data(%r, %r)', data, fds)
│ │ │ │ │ +
│ │ │ │ │ + if data == b'':
│ │ │ │ │ + self._result(self._buffer.decode(errors='replace'))
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + self._buffer.extend(data)
│ │ │ │ │ +
│ │ │ │ │ + # Read zero or more "remote" messages
│ │ │ │ │ + chunks = COMMAND_RE.split(self._buffer)
│ │ │ │ │ + self._buffer = bytearray(chunks.pop())
│ │ │ │ │ + while len(chunks) > 1:
│ │ │ │ │ + self._invoke_command(chunks[0], chunks[1], [])
│ │ │ │ │ + chunks = chunks[2:]
│ │ │ │ │ +
│ │ │ │ │ + # Maybe read one "local" message
│ │ │ │ │ + if fds:
│ │ │ │ │ + assert self._buffer.endswith(b'\0'), self._buffer
│ │ │ │ │ + stderr = self._buffer[:-1]
│ │ │ │ │ + self._buffer = bytearray(b'')
│ │ │ │ │ + with open(fds.pop(0), 'rb') as command_channel:
│ │ │ │ │ + command = command_channel.read()
│ │ │ │ │ + self._invoke_command(stderr, command, fds)
│ │ │ │ │ +
│ │ │ │ │ + def _read_ready(self) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + data, fds, _flags, _addr = recv_fds(self._ours, 4096, 10, flags=socket.MSG_DONTWAIT)
│ │ │ │ │ + except BlockingIOError:
│ │ │ │ │ + return
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + self._result(exc)
│ │ │ │ │ + else:
│ │ │ │ │ + self._got_data(data, fds)
│ │ │ │ │ + finally:
│ │ │ │ │ + while fds:
│ │ │ │ │ + os.close(fds.pop())
│ │ │ │ │ +
│ │ │ │ │ + def __init__(
│ │ │ │ │ + self,
│ │ │ │ │ + handlers: Sequence[InteractionHandler],
│ │ │ │ │ + loop: 'asyncio.AbstractEventLoop | None' = None,
│ │ │ │ │ + done_callback: 'Callable[[asyncio.Future[str]], None] | None' = None,
│ │ │ │ │ + ) -> None:
│ │ │ │ │ + self._loop = loop or get_running_loop()
│ │ │ │ │ + self._completion_future = self._loop.create_future()
│ │ │ │ │ + self._tasks = set()
│ │ │ │ │ + self._handlers = {}
│ │ │ │ │ +
│ │ │ │ │ + for handler in handlers:
│ │ │ │ │ + for command in handler.commands:
│ │ │ │ │ + self._handlers[command] = handler
│ │ │ │ │ +
│ │ │ │ │ + if done_callback is not None:
│ │ │ │ │ + self._completion_future.add_done_callback(done_callback)
│ │ │ │ │ +
│ │ │ │ │ + self._theirs, self._ours = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
│ │ │ │ │ + self._buffer = bytearray()
│ │ │ │ │ +
│ │ │ │ │ + def fileno(self) -> int:
│ │ │ │ │ + return self._theirs.fileno()
│ │ │ │ │ +
│ │ │ │ │ + def start(self) -> None:
│ │ │ │ │ + logger.debug('start(%r)', self)
│ │ │ │ │ + if self._ours.fileno() != -1:
│ │ │ │ │ + logger.debug(' add_reader(%r)', self._ours)
│ │ │ │ │ + self._loop.add_reader(self._ours.fileno(), self._read_ready)
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug(' ...but agent is already finished.')
│ │ │ │ │ +
│ │ │ │ │ + logger.debug(' close(%r)', self._theirs)
│ │ │ │ │ + self._theirs.close()
│ │ │ │ │ +
│ │ │ │ │ + def force_completion(self) -> None:
│ │ │ │ │ + logger.debug('force_completion(%r)', self)
│ │ │ │ │ +
│ │ │ │ │ + # read any residual data on stderr, but don't process commands, and
│ │ │ │ │ + # don't block
│ │ │ │ │ + try:
│ │ │ │ │ + if self._ours.fileno() != -1:
│ │ │ │ │ + logger.debug(' draining pending stderr data (non-blocking)')
│ │ │ │ │ + with contextlib.suppress(BlockingIOError):
│ │ │ │ │ + while True:
│ │ │ │ │ + data = self._ours.recv(4096, socket.MSG_DONTWAIT)
│ │ │ │ │ + logger.debug(' got %d bytes', len(data))
│ │ │ │ │ + if not data:
│ │ │ │ │ + break
│ │ │ │ │ + self._buffer.extend(data)
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + self._result(exc)
│ │ │ │ │ + else:
│ │ │ │ │ + self._result(self._buffer.decode(errors='replace'))
│ │ │ │ │ +
│ │ │ │ │ + async def communicate(self) -> None:
│ │ │ │ │ + logger.debug('_communicate(%r)', self)
│ │ │ │ │ + try:
│ │ │ │ │ + self.start()
│ │ │ │ │ + # We assume that we are the only ones to write to
│ │ │ │ │ + # self._completion_future. If we directly await it, though, it can
│ │ │ │ │ + # also have a asyncio.CancelledError posted to it from outside.
│ │ │ │ │ + # Shield it to prevent that from happening.
│ │ │ │ │ + stderr = await asyncio.shield(self._completion_future)
│ │ │ │ │ + logger.debug('_communicate(%r) stderr result is %r', self, stderr)
│ │ │ │ │ + finally:
│ │ │ │ │ + logger.debug('_communicate finished. Ensuring completion.')
│ │ │ │ │ + self.force_completion()
│ │ │ │ │ + if not self._end:
│ │ │ │ │ + logger.debug('_communicate never saw ferny.end. raising InteractionError.')
│ │ │ │ │ + raise InteractionError(stderr.strip())
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def write_askpass_to_tmpdir(tmpdir: str) -> str:
│ │ │ │ │ + askpass_path = os.path.join(tmpdir, 'ferny-askpass')
│ │ │ │ │ + fd = os.open(askpass_path, os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC | os.O_EXCL | os.O_NOFOLLOW, 0o777)
│ │ │ │ │ + try:
│ │ │ │ │ + os.write(fd, __loader__.get_data(interaction_client.__file__)) # type: ignore
│ │ │ │ │ + finally:
│ │ │ │ │ + os.close(fd)
│ │ │ │ │ + return askpass_path
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +@contextlib.contextmanager
│ │ │ │ │ +def temporary_askpass(**kwargs: Any) -> Generator[str, None, None]:
│ │ │ │ │ + with tempfile.TemporaryDirectory(**kwargs) as directory:
│ │ │ │ │ + yield write_askpass_to_tmpdir(directory)
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ }))
│ │ │ │ │ from cockpit.bridge import main as main
│ │ │ │ │ main(beipack=True)
│ │ │ ├── ./usr/lib/python3/dist-packages/cockpit-316.dist-info/direct_url.json
│ │ │ │ ├── Pretty-printed
│ │ │ │ │┄ Similarity: 0.90625%
│ │ │ │ │┄ Differences: {"'archive_info'": "{'hash': "
│ │ │ │ │┄ "'sha256=c65835a62db7f1d424f54241d98adb25d749fc610b537c5c288e56226a2ad3d6', "
│ │ │ │ │┄ "'hashes': {'sha256': "
│ │ │ │ │┄ "'c65835a62db7f1d424f54241d98adb25d749fc610b537c5c288e56226a2ad3d6'}}"}
│ │ │ │ │ @@ -1,9 +1,9 @@
│ │ │ │ │ {
│ │ │ │ │ "archive_info": {
│ │ │ │ │ - "hash": "sha256=431283aedc94efd58bdd7358fff964916c6d8bb302d4e28f273800960ac9d280",
│ │ │ │ │ + "hash": "sha256=c65835a62db7f1d424f54241d98adb25d749fc610b537c5c288e56226a2ad3d6",
│ │ │ │ │ "hashes": {
│ │ │ │ │ - "sha256": "431283aedc94efd58bdd7358fff964916c6d8bb302d4e28f273800960ac9d280"
│ │ │ │ │ + "sha256": "c65835a62db7f1d424f54241d98adb25d749fc610b537c5c288e56226a2ad3d6"
│ │ │ │ │ }
│ │ │ │ │ },
│ │ │ │ │ "url": "file:///build/reproducible-path/cockpit-316/tmp/wheel/cockpit-316-py3-none-any.whl"
│ │ │ │ │ }