{"diffoscope-json-version": 1, "source1": "/srv/reproducible-results/rbuild-debian/r-b-build.x9GZr1hx/b1/cockpit_317-5_i386.changes", "source2": "/srv/reproducible-results/rbuild-debian/r-b-build.x9GZr1hx/b2/cockpit_317-5_i386.changes", "unified_diff": null, "details": [{"source1": "Files", "source2": "Files", "unified_diff": "@@ -1,10 +1,10 @@\n \n 352b6c2bf1ab8bc205bbbfdaa55492c5 123108 debug optional cockpit-bridge-dbgsym_317-5_i386.deb\n- 87e95b4ecc1d947d4698231d6d2c1888 364900 admin optional cockpit-bridge_317-5_i386.deb\n+ 075ba1340c280f6d5d7cc1bb13efe9f0 365312 admin optional cockpit-bridge_317-5_i386.deb\n 097ce2e1e6651b3f08664f587bcde805 132180 doc optional cockpit-doc_317-5_all.deb\n ff791dc6c8e35ddc5a35f713f218c3ac 831552 admin optional cockpit-networkmanager_317-5_all.deb\n e8b11b470e82c2e8cf7e80ccdc3b3717 944828 admin optional cockpit-packagekit_317-5_all.deb\n b46703922196dc34f9ce96ee02109c1b 560524 admin optional cockpit-sosreport_317-5_all.deb\n de8cd1c072255b3f5dc542a12907d143 886272 admin optional cockpit-storaged_317-5_all.deb\n fb7ce4b12f0352249b3660ea2ad3b016 3316080 admin optional cockpit-system_317-5_all.deb\n 6498af41f44439eab2d14a34728e74a0 4340 debug optional cockpit-tests-dbgsym_317-5_i386.deb\n"}, {"source1": "cockpit-bridge_317-5_i386.deb", "source2": "cockpit-bridge_317-5_i386.deb", "unified_diff": null, "details": [{"source1": "file list", "source2": "file list", "unified_diff": "@@ -1,3 +1,3 @@\n -rw-r--r-- 0 0 0 4 2024-06-06 12:41:23.000000 debian-binary\n -rw-r--r-- 0 0 0 3880 2024-06-06 12:41:23.000000 control.tar.xz\n--rw-r--r-- 0 0 0 360828 2024-06-06 12:41:23.000000 data.tar.xz\n+-rw-r--r-- 0 0 0 361240 2024-06-06 12:41:23.000000 data.tar.xz\n"}, {"source1": "control.tar.xz", "source2": "control.tar.xz", "unified_diff": null, "details": [{"source1": "control.tar", "source2": "control.tar", "unified_diff": null, "details": [{"source1": "./control", "source2": "./control", "unified_diff": "@@ -1,13 +1,13 @@\n Package: cockpit-bridge\n Source: cockpit\n Version: 317-5\n Architecture: i386\n Maintainer: Utopia Maintenance Team \n-Installed-Size: 870\n+Installed-Size: 871\n Depends: libc6 (>= 2.38), libglib2.0-0t64 (>= 2.68.0), libjson-glib-1.0-0 (>= 1.5.2), libssh-4 (>= 0.10.4), libsystemd0 (>= 235), python3:any, glib-networking\n Recommends: openssh-client\n Breaks: cockpit-ws (<< 181.x)\n Replaces: cockpit-dashboard (<< 170.x), cockpit-ws (<< 181.x)\n Provides: cockpit-ssh\n Section: admin\n Priority: optional\n"}, {"source1": "./md5sums", "source2": "./md5sums", "unified_diff": null, "details": [{"source1": "./md5sums", "source2": "./md5sums", "comments": ["Files differ"], "unified_diff": null}]}]}]}, {"source1": "data.tar.xz", "source2": "data.tar.xz", "unified_diff": null, "details": [{"source1": "data.tar", "source2": "data.tar", "unified_diff": null, "details": [{"source1": "file list", "source2": "file list", "unified_diff": "@@ -60,15 +60,15 @@\n -rw-r--r-- 0 root (0) root (0) 6653 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/channels/metrics.py\n -rw-r--r-- 0 root (0) root (0) 4058 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/channels/packages.py\n -rw-r--r-- 0 root (0) root (0) 4872 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/channels/stream.py\n -rw-r--r-- 0 root (0) root (0) 1171 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/channels/trivial.py\n -rw-r--r-- 0 root (0) root (0) 3188 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/config.py\n drwxr-xr-x 0 root (0) root (0) 0 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/data/\n -rw-r--r-- 0 root (0) root (0) 574 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/data/__init__.py\n--rw-r--r-- 0 root (0) root (0) 86688 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz\n+-rw-r--r-- 0 root (0) root (0) 87104 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz\n -rw-r--r-- 0 root (0) root (0) 3212 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/data/fail.html\n -rw-r--r-- 0 root (0) root (0) 5517 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/internal_endpoints.py\n -rw-r--r-- 0 root (0) root (0) 7242 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/jsonutil.py\n -rw-r--r-- 0 root (0) root (0) 21539 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/packages.py\n -rw-r--r-- 0 root (0) root (0) 12729 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/peer.py\n -rw-r--r-- 0 root (0) root (0) 7580 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/polkit.py\n -rw-r--r-- 0 root (0) root (0) 2031 2024-06-06 12:41:23.000000 ./usr/lib/python3/dist-packages/cockpit/polyfills.py\n"}, {"source1": "./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz", "source2": "./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz", "unified_diff": null, "details": [{"source1": "cockpit-bridge.beipack", "source2": "cockpit-bridge.beipack", "comments": ["Ordering differences only"], "unified_diff": "@@ -64,105 +64,512 @@\n ) -> Optional[importlib.machinery.ModuleSpec]:\n if fullname not in self.modules:\n return None\n return importlib.util.spec_from_loader(fullname, self)\n \n import sys\n sys.meta_path.insert(0, BeipackLoader({\n- 'cockpit/config.py': br'''# This file is part of Cockpit.\n+ 'cockpit/samples.py': br'''# This file is part of Cockpit.\n #\n-# Copyright (C) 2023 Red Hat, Inc.\n+# Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import configparser\n+import errno\n import logging\n import os\n-from pathlib import Path\n+import re\n+from typing import Any, DefaultDict, Iterable, List, NamedTuple, Optional, Tuple\n \n-from cockpit._vendor.systemd_ctypes import bus\n+from cockpit._vendor.systemd_ctypes import Handle\n+\n+USER_HZ = os.sysconf(os.sysconf_names['SC_CLK_TCK'])\n+MS_PER_JIFFY = 1000 / (USER_HZ if (USER_HZ > 0) else 100)\n+HWMON_PATH = '/sys/class/hwmon'\n+\n+# we would like to do this, but mypy complains; https://github.com/python/mypy/issues/2900\n+# Samples = collections.defaultdict[str, Union[float, Dict[str, Union[float, None]]]]\n+Samples = DefaultDict[str, Any]\n \n logger = logging.getLogger(__name__)\n \n-XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'))\n-DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit'\n \n+def read_int_file(rootfd: int, statfile: str, default: Optional[int] = None, key: bytes = b'') -> Optional[int]:\n+ # Not every stat is available, such as cpu.weight\n+ try:\n+ fd = os.open(statfile, os.O_RDONLY, dir_fd=rootfd)\n+ except FileNotFoundError:\n+ return None\n \n-def lookup_config(filename: str) -> Path:\n- config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc').split(':')\n- fallback = None\n- for config_dir in config_dirs:\n- config_path = Path(config_dir, 'cockpit', filename)\n- if not fallback:\n- fallback = config_path\n- if config_path.exists():\n- logger.debug('lookup_config(%s): found %s', filename, config_path)\n- return config_path\n+ try:\n+ data = os.read(fd, 1024)\n+ except OSError as e:\n+ # cgroups can disappear between the open and read\n+ if e.errno != errno.ENODEV:\n+ logger.warning('Failed to read %s: %s', statfile, e)\n+ return None\n+ finally:\n+ os.close(fd)\n \n- # default to the first entry in XDG_CONFIG_DIRS; that's not according to the spec,\n- # but what Cockpit has done for years\n- logger.debug('lookup_config(%s): defaulting to %s', filename, fallback)\n- assert fallback # mypy; config_dirs always has at least one string\n- return fallback\n+ if key:\n+ start = data.index(key) + len(key)\n+ end = data.index(b'\\n', start)\n+ data = data[start:end]\n \n+ try:\n+ # 0 often means \"none\", so replace it with default value\n+ return int(data) or default\n+ except ValueError:\n+ # Some samples such as \"memory.max\" contains \"max\" when there is a no limit\n+ return None\n \n-class Config(bus.Object, interface='cockpit.Config'):\n- def __init__(self):\n- self.reload()\n \n- @bus.Interface.Method(out_types='s', in_types='ss')\n- def get_string(self, section, key):\n- try:\n- return self.config[section][key]\n- except KeyError as exc:\n- raise bus.BusError('cockpit.Config.KeyError', f'key {key} in section {section} does not exist') from exc\n+class SampleDescription(NamedTuple):\n+ name: str\n+ units: str\n+ semantics: str\n+ instanced: bool\n \n- @bus.Interface.Method(out_types='u', in_types='ssuuu')\n- def get_u_int(self, section, key, default, maximum, minimum):\n+\n+class Sampler:\n+ descriptions: List[SampleDescription]\n+\n+ def sample(self, samples: Samples) -> None:\n+ raise NotImplementedError\n+\n+\n+class CPUSampler(Sampler):\n+ descriptions = [\n+ SampleDescription('cpu.basic.nice', 'millisec', 'counter', instanced=False),\n+ SampleDescription('cpu.basic.user', 'millisec', 'counter', instanced=False),\n+ SampleDescription('cpu.basic.system', 'millisec', 'counter', instanced=False),\n+ SampleDescription('cpu.basic.iowait', 'millisec', 'counter', instanced=False),\n+\n+ SampleDescription('cpu.core.nice', 'millisec', 'counter', instanced=True),\n+ SampleDescription('cpu.core.user', 'millisec', 'counter', instanced=True),\n+ SampleDescription('cpu.core.system', 'millisec', 'counter', instanced=True),\n+ SampleDescription('cpu.core.iowait', 'millisec', 'counter', instanced=True),\n+ ]\n+\n+ def sample(self, samples: Samples) -> None:\n+ with open('/proc/stat') as stat:\n+ for line in stat:\n+ if not line.startswith('cpu'):\n+ continue\n+ cpu, user, nice, system, _idle, iowait = line.split()[:6]\n+ core = cpu[3:] or None\n+ if core:\n+ prefix = 'cpu.core'\n+ samples[f'{prefix}.nice'][core] = int(nice) * MS_PER_JIFFY\n+ samples[f'{prefix}.user'][core] = int(user) * MS_PER_JIFFY\n+ samples[f'{prefix}.system'][core] = int(system) * MS_PER_JIFFY\n+ samples[f'{prefix}.iowait'][core] = int(iowait) * MS_PER_JIFFY\n+ else:\n+ prefix = 'cpu.basic'\n+ samples[f'{prefix}.nice'] = int(nice) * MS_PER_JIFFY\n+ samples[f'{prefix}.user'] = int(user) * MS_PER_JIFFY\n+ samples[f'{prefix}.system'] = int(system) * MS_PER_JIFFY\n+ samples[f'{prefix}.iowait'] = int(iowait) * MS_PER_JIFFY\n+\n+\n+class MemorySampler(Sampler):\n+ descriptions = [\n+ SampleDescription('memory.free', 'bytes', 'instant', instanced=False),\n+ SampleDescription('memory.used', 'bytes', 'instant', instanced=False),\n+ SampleDescription('memory.cached', 'bytes', 'instant', instanced=False),\n+ SampleDescription('memory.swap-used', 'bytes', 'instant', instanced=False),\n+ ]\n+\n+ def sample(self, samples: Samples) -> None:\n+ with open('/proc/meminfo') as meminfo:\n+ items = {k: int(v.strip(' kB\\n')) for line in meminfo for k, v in [line.split(':', 1)]}\n+\n+ samples['memory.free'] = 1024 * items['MemFree']\n+ samples['memory.used'] = 1024 * (items['MemTotal'] - items['MemAvailable'])\n+ samples['memory.cached'] = 1024 * (items['Buffers'] + items['Cached'])\n+ samples['memory.swap-used'] = 1024 * (items['SwapTotal'] - items['SwapFree'])\n+\n+\n+class CPUTemperatureSampler(Sampler):\n+ # Cache found sensors, as they can't be hotplugged.\n+ sensors: Optional[List[str]] = None\n+\n+ descriptions = [\n+ SampleDescription('cpu.temperature', 'celsius', 'instant', instanced=True),\n+ ]\n+\n+ @staticmethod\n+ def detect_cpu_sensors(dir_fd: int) -> Iterable[str]:\n+ # Read the name file to decide what to do with this directory\n try:\n- value = self.config[section][key]\n- except KeyError:\n- return default\n+ with Handle.open('name', os.O_RDONLY, dir_fd=dir_fd) as fd:\n+ name = os.read(fd, 1024).decode().strip()\n+ except FileNotFoundError:\n+ return\n+\n+ if name == 'atk0110':\n+ # only sample 'CPU Temperature' in atk0110\n+ predicate = (lambda label: label == 'CPU Temperature')\n+ elif name == 'cpu_thermal':\n+ # labels are not used on ARM\n+ predicate = None\n+ elif name == 'coretemp':\n+ # accept all labels on Intel\n+ predicate = None\n+ elif name in ['k8temp', 'k10temp']:\n+ predicate = None\n+ else:\n+ # Not a CPU sensor\n+ return\n+\n+ # Now scan the directory for inputs\n+ for input_filename in os.listdir(dir_fd):\n+ if not input_filename.endswith('_input'):\n+ continue\n+\n+ if predicate:\n+ # We need to check the label\n+ try:\n+ label_filename = input_filename.replace('_input', '_label')\n+ with Handle.open(label_filename, os.O_RDONLY, dir_fd=dir_fd) as fd:\n+ label = os.read(fd, 1024).decode().strip()\n+ except FileNotFoundError:\n+ continue\n \n+ if not predicate(label):\n+ continue\n+\n+ yield input_filename\n+\n+ @staticmethod\n+ def scan_sensors() -> Iterable[str]:\n try:\n- int_val = int(value)\n- except ValueError:\n- logger.warning('cockpit.conf: [%s] %s is not an integer', section, key)\n- return default\n+ top_fd = Handle.open(HWMON_PATH, os.O_RDONLY | os.O_DIRECTORY)\n+ except FileNotFoundError:\n+ return\n \n- return min(max(int_val, minimum), maximum)\n+ with top_fd:\n+ for hwmon_name in os.listdir(top_fd):\n+ with Handle.open(hwmon_name, os.O_RDONLY | os.O_DIRECTORY, dir_fd=top_fd) as subdir_fd:\n+ for sensor in CPUTemperatureSampler.detect_cpu_sensors(subdir_fd):\n+ yield f'{HWMON_PATH}/{hwmon_name}/{sensor}'\n \n- @bus.Interface.Method()\n- def reload(self):\n- self.config = configparser.ConfigParser(interpolation=None)\n- cockpit_conf = lookup_config('cockpit.conf')\n- logger.debug(\"cockpit.Config: loading %s\", cockpit_conf)\n- # this may not exist, but it's ok to not have a config file and thus leave self.config empty\n- self.config.read(cockpit_conf)\n+ def sample(self, samples: Samples) -> None:\n+ if self.sensors is None:\n+ self.sensors = list(CPUTemperatureSampler.scan_sensors())\n \n+ for sensor_path in self.sensors:\n+ with open(sensor_path) as sensor:\n+ temperature = int(sensor.read().strip())\n+ if temperature == 0:\n+ return\n \n-class Environment(bus.Object, interface='cockpit.Environment'):\n- variables = bus.Interface.Property('a{ss}')\n+ samples['cpu.temperature'][sensor_path] = temperature / 1000\n \n- @variables.getter\n- def get_variables(self):\n- return os.environ.copy()\n+\n+class DiskSampler(Sampler):\n+ descriptions = [\n+ SampleDescription('disk.all.read', 'bytes', 'counter', instanced=False),\n+ SampleDescription('disk.all.written', 'bytes', 'counter', instanced=False),\n+ SampleDescription('disk.dev.read', 'bytes', 'counter', instanced=True),\n+ SampleDescription('disk.dev.written', 'bytes', 'counter', instanced=True),\n+ ]\n+\n+ def sample(self, samples: Samples) -> None:\n+ with open('/proc/diskstats') as diskstats:\n+ all_read_bytes = 0\n+ all_written_bytes = 0\n+\n+ for line in diskstats:\n+ # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats\n+ fields = line.strip().split()\n+ dev_major = fields[0]\n+ dev_name = fields[2]\n+ num_sectors_read = fields[5]\n+ num_sectors_written = fields[9]\n+\n+ # ignore mdraid\n+ if dev_major == '9':\n+ continue\n+\n+ # ignore device-mapper\n+ if dev_name.startswith('dm-'):\n+ continue\n+\n+ # Skip partitions\n+ if dev_name[:2] in ['sd', 'hd', 'vd'] and dev_name[-1].isdigit():\n+ continue\n+\n+ # Ignore nvme partitions\n+ if dev_name.startswith('nvme') and 'p' in dev_name:\n+ continue\n+\n+ read_bytes = int(num_sectors_read) * 512\n+ written_bytes = int(num_sectors_written) * 512\n+\n+ all_read_bytes += read_bytes\n+ all_written_bytes += written_bytes\n+\n+ samples['disk.dev.read'][dev_name] = read_bytes\n+ samples['disk.dev.written'][dev_name] = written_bytes\n+\n+ samples['disk.all.read'] = all_read_bytes\n+ samples['disk.all.written'] = all_written_bytes\n+\n+\n+class CGroupSampler(Sampler):\n+ descriptions = [\n+ SampleDescription('cgroup.memory.usage', 'bytes', 'instant', instanced=True),\n+ SampleDescription('cgroup.memory.limit', 'bytes', 'instant', instanced=True),\n+ SampleDescription('cgroup.memory.sw-usage', 'bytes', 'instant', instanced=True),\n+ SampleDescription('cgroup.memory.sw-limit', 'bytes', 'instant', instanced=True),\n+ SampleDescription('cgroup.cpu.usage', 'millisec', 'counter', instanced=True),\n+ SampleDescription('cgroup.cpu.shares', 'count', 'instant', instanced=True),\n+ ]\n+\n+ cgroups_v2: Optional[bool] = None\n+\n+ def sample(self, samples: Samples) -> None:\n+ if self.cgroups_v2 is None:\n+ self.cgroups_v2 = os.path.exists('/sys/fs/cgroup/cgroup.controllers')\n+\n+ if self.cgroups_v2:\n+ cgroups_v2_path = '/sys/fs/cgroup/'\n+ for path, _, _, rootfd in os.fwalk(cgroups_v2_path):\n+ cgroup = path.replace(cgroups_v2_path, '')\n+\n+ if not cgroup:\n+ continue\n+\n+ samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.current', 0)\n+ samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.max')\n+ samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.swap.current', 0)\n+ samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.swap.max')\n+ samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.weight')\n+ usage_usec = read_int_file(rootfd, 'cpu.stat', 0, key=b'usage_usec')\n+ if usage_usec:\n+ samples['cgroup.cpu.usage'][cgroup] = usage_usec / 1000\n+ else:\n+ memory_path = '/sys/fs/cgroup/memory/'\n+ for path, _, _, rootfd in os.fwalk(memory_path):\n+ cgroup = path.replace(memory_path, '')\n+\n+ if not cgroup:\n+ continue\n+\n+ samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.usage_in_bytes', 0)\n+ samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.limit_in_bytes')\n+ samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.memsw.usage_in_bytes', 0)\n+ samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.memsw.limit_in_bytes')\n+\n+ cpu_path = '/sys/fs/cgroup/cpu/'\n+ for path, _, _, rootfd in os.fwalk(cpu_path):\n+ cgroup = path.replace(cpu_path, '')\n+\n+ if not cgroup:\n+ continue\n+\n+ samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.shares')\n+ usage_nsec = read_int_file(rootfd, 'cpuacct.usage')\n+ if usage_nsec:\n+ samples['cgroup.cpu.usage'][cgroup] = usage_nsec / 1000000\n+\n+\n+class CGroupDiskIO(Sampler):\n+ IO_RE = re.compile(rb'\\bread_bytes: (?P\\d+).*\\nwrite_bytes: (?P\\d+)', flags=re.S)\n+ descriptions = [\n+ SampleDescription('disk.cgroup.read', 'bytes', 'counter', instanced=True),\n+ SampleDescription('disk.cgroup.written', 'bytes', 'counter', instanced=True),\n+ ]\n+\n+ @staticmethod\n+ def get_cgroup_name(fd: int) -> str:\n+ with Handle.open('cgroup', os.O_RDONLY, dir_fd=fd) as cgroup_fd:\n+ cgroup_name = os.read(cgroup_fd, 2048).decode().strip()\n+\n+ # Skip leading ::0/\n+ return cgroup_name[4:]\n+\n+ @staticmethod\n+ def get_proc_io(fd: int) -> Tuple[int, int]:\n+ with Handle.open('io', os.O_RDONLY, dir_fd=fd) as io_fd:\n+ data = os.read(io_fd, 4096)\n+\n+ match = re.search(CGroupDiskIO.IO_RE, data)\n+ if match:\n+ proc_read = int(match.group('read'))\n+ proc_write = int(match.group('write'))\n+\n+ return proc_read, proc_write\n+\n+ return 0, 0\n+\n+ def sample(self, samples: Samples):\n+ with Handle.open('/proc', os.O_RDONLY | os.O_DIRECTORY) as proc_fd:\n+ reads = samples['disk.cgroup.read']\n+ writes = samples['disk.cgroup.written']\n+\n+ for path in os.listdir(proc_fd):\n+ # non-pid entries in proc are guaranteed to start with a character a-z\n+ if path[0] < '0' or path[0] > '9':\n+ continue\n+\n+ try:\n+ with Handle.open(path, os.O_PATH, dir_fd=proc_fd) as pid_fd:\n+ cgroup_name = self.get_cgroup_name(pid_fd)\n+ proc_read, proc_write = self.get_proc_io(pid_fd)\n+ except (FileNotFoundError, PermissionError, ProcessLookupError):\n+ continue\n+\n+ reads[cgroup_name] = reads.get(cgroup_name, 0) + proc_read\n+ writes[cgroup_name] = writes.get(cgroup_name, 0) + proc_write\n+\n+\n+class NetworkSampler(Sampler):\n+ descriptions = [\n+ SampleDescription('network.interface.tx', 'bytes', 'counter', instanced=True),\n+ SampleDescription('network.interface.rx', 'bytes', 'counter', instanced=True),\n+ ]\n+\n+ def sample(self, samples: Samples) -> None:\n+ with open(\"/proc/net/dev\") as network_samples:\n+ for line in network_samples:\n+ fields = line.split()\n+\n+ # Skip header line\n+ if fields[0][-1] != ':':\n+ continue\n+\n+ iface = fields[0][:-1]\n+ samples['network.interface.rx'][iface] = int(fields[1])\n+ samples['network.interface.tx'][iface] = int(fields[9])\n+\n+\n+class MountSampler(Sampler):\n+ descriptions = [\n+ SampleDescription('mount.total', 'bytes', 'instant', instanced=True),\n+ SampleDescription('mount.used', 'bytes', 'instant', instanced=True),\n+ ]\n+\n+ def sample(self, samples: Samples) -> None:\n+ with open('/proc/mounts') as mounts:\n+ for line in mounts:\n+ # Only look at real devices\n+ if line[0] != '/':\n+ continue\n+\n+ path = line.split()[1]\n+ try:\n+ res = os.statvfs(path)\n+ except OSError:\n+ continue\n+ frsize = res.f_frsize\n+ total = frsize * res.f_blocks\n+ samples['mount.total'][path] = total\n+ samples['mount.used'][path] = total - frsize * res.f_bfree\n+\n+\n+class BlockSampler(Sampler):\n+ descriptions = [\n+ SampleDescription('block.device.read', 'bytes', 'counter', instanced=True),\n+ SampleDescription('block.device.written', 'bytes', 'counter', instanced=True),\n+ ]\n+\n+ def sample(self, samples: Samples) -> None:\n+ with open('/proc/diskstats') as diskstats:\n+ for line in diskstats:\n+ # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats\n+ [_, _, dev_name, _, _, sectors_read, _, _, _, sectors_written, *_] = line.strip().split()\n+\n+ samples['block.device.read'][dev_name] = int(sectors_read) * 512\n+ samples['block.device.written'][dev_name] = int(sectors_written) * 512\n+\n+\n+SAMPLERS = [\n+ BlockSampler,\n+ CGroupSampler,\n+ CGroupDiskIO,\n+ CPUSampler,\n+ CPUTemperatureSampler,\n+ DiskSampler,\n+ MemorySampler,\n+ MountSampler,\n+ NetworkSampler,\n+]\n ''',\n- 'cockpit/packages.py': br'''# This file is part of Cockpit.\n+ 'cockpit/polyfills.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2023 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n+\n+import contextlib\n+import socket\n+\n+\n+def install():\n+ \"\"\"Add shims for older Python versions\"\"\"\n+\n+ # introduced in 3.9\n+ if not hasattr(socket, 'recv_fds'):\n+ import array\n+\n+ import _socket\n+\n+ def recv_fds(sock, bufsize, maxfds, flags=0):\n+ fds = array.array(\"i\")\n+ msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize))\n+ for cmsg_level, cmsg_type, cmsg_data in ancdata:\n+ if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS):\n+ fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])\n+ return msg, list(fds), flags, addr\n+\n+ socket.recv_fds = recv_fds\n+\n+ # introduced in 3.7\n+ if not hasattr(contextlib, 'AsyncExitStack'):\n+ class AsyncExitStack:\n+ async def __aenter__(self):\n+ self.cms = []\n+ return self\n+\n+ async def enter_async_context(self, cm):\n+ result = await cm.__aenter__()\n+ self.cms.append(cm)\n+ return result\n+\n+ async def __aexit__(self, exc_type, exc_value, traceback):\n+ for cm in self.cms:\n+ cm.__aexit__(exc_type, exc_value, traceback)\n+\n+ contextlib.AsyncExitStack = AsyncExitStack\n+''',\n+ 'cockpit/bridge.py': r'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -171,578 +578,583 @@\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import collections\n+import argparse\n+import asyncio\n import contextlib\n-import functools\n-import gzip\n-import io\n-import itertools\n import json\n import logging\n-import mimetypes\n import os\n-import re\n-import shutil\n-from pathlib import Path\n-from typing import (\n- BinaryIO,\n- Callable,\n- ClassVar,\n- Dict,\n- Iterable,\n- List,\n- NamedTuple,\n- Optional,\n- Pattern,\n- Sequence,\n- Tuple,\n- TypeVar,\n-)\n+import pwd\n+import shlex\n+import socket\n+import stat\n+import subprocess\n+from typing import Iterable, List, Optional, Sequence, Tuple, Type\n \n-from cockpit._vendor.systemd_ctypes import bus\n+from cockpit._vendor.ferny import interaction_client\n+from cockpit._vendor.systemd_ctypes import bus, run_async\n \n-from . import config\n+from . import polyfills\n from ._version import __version__\n-from .jsonutil import (\n- JsonError,\n- JsonObject,\n- JsonValue,\n- get_bool,\n- get_dict,\n- get_int,\n- get_objv,\n- get_str,\n- get_strv,\n- json_merge_patch,\n- typechecked,\n-)\n+from .channel import ChannelRoutingRule\n+from .channels import CHANNEL_TYPES\n+from .config import Config, Environment\n+from .internal_endpoints import EXPORTS\n+from .jsonutil import JsonError, JsonObject, get_dict\n+from .packages import BridgeConfig, Packages, PackagesListener\n+from .peer import PeersRoutingRule\n+from .remote import HostRoutingRule\n+from .router import Router\n+from .superuser import SuperuserRoutingRule\n+from .transports import StdioTransport\n \n logger = logging.getLogger(__name__)\n \n \n-# In practice, this is going to get called over and over again with exactly the\n-# same list. Let's try to cache the result.\n-@functools.lru_cache()\n-def parse_accept_language(accept_language: str) -> Sequence[str]:\n- \"\"\"Parse the Accept-Language header, if it exists.\n+class InternalBus:\n+ exportees: List[bus.Slot]\n \n- Returns an ordered list of languages, with fallbacks inserted, and\n- truncated to the position where 'en' would have otherwise appeared, if\n- applicable.\n+ def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]):\n+ client_socket, server_socket = socket.socketpair()\n+ self.client = bus.Bus.new(fd=client_socket.detach())\n+ self.server = bus.Bus.new(fd=server_socket.detach(), server=True)\n+ self.exportees = [self.server.add_object(path, cls()) for path, cls in exports]\n \n- https://tools.ietf.org/html/rfc7231#section-5.3.5\n- https://datatracker.ietf.org/doc/html/rfc4647#section-3.4\n- \"\"\"\n+ def export(self, path: str, obj: bus.BaseObject) -> None:\n+ self.exportees.append(self.server.add_object(path, obj))\n \n- logger.debug('parse_accept_language(%r)', accept_language)\n- locales_with_q = []\n- for entry in accept_language.split(','):\n- entry = entry.strip().lower()\n- logger.debug(' entry %r', entry)\n- locale, _, qstr = entry.partition(';q=')\n+\n+class Bridge(Router, PackagesListener):\n+ internal_bus: InternalBus\n+ packages: Optional[Packages]\n+ bridge_configs: Sequence[BridgeConfig]\n+ args: argparse.Namespace\n+\n+ def __init__(self, args: argparse.Namespace):\n+ self.internal_bus = InternalBus(EXPORTS)\n+ self.bridge_configs = []\n+ self.args = args\n+\n+ self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged)\n+ self.internal_bus.export('/superuser', self.superuser_rule)\n+\n+ self.internal_bus.export('/config', Config())\n+ self.internal_bus.export('/environment', Environment())\n+\n+ self.peers_rule = PeersRoutingRule(self)\n+\n+ if args.beipack:\n+ # Some special stuff for beipack\n+ self.superuser_rule.set_configs((\n+ BridgeConfig({\n+ \"privileged\": True,\n+ \"spawn\": [\"sudo\", \"-k\", \"-A\", \"python3\", \"-ic\", \"# cockpit-bridge\", \"--privileged\"],\n+ \"environ\": [\"SUDO_ASKPASS=ferny-askpass\"],\n+ }),\n+ ))\n+ self.packages = None\n+ elif args.privileged:\n+ self.packages = None\n+ else:\n+ self.packages = Packages(self)\n+ self.internal_bus.export('/packages', self.packages)\n+ self.packages_loaded()\n+\n+ super().__init__([\n+ HostRoutingRule(self),\n+ self.superuser_rule,\n+ ChannelRoutingRule(self, CHANNEL_TYPES),\n+ self.peers_rule,\n+ ])\n+\n+ @staticmethod\n+ def get_os_release():\n try:\n- q = float(qstr or 1.0)\n- except ValueError:\n- continue # ignore malformed entry\n+ file = open('/etc/os-release', encoding='utf-8')\n+ except FileNotFoundError:\n+ try:\n+ file = open('/usr/lib/os-release', encoding='utf-8')\n+ except FileNotFoundError:\n+ logger.warning(\"Neither /etc/os-release nor /usr/lib/os-release exists\")\n+ return {}\n \n- while locale:\n- logger.debug(' adding %r q=%r', locale, q)\n- locales_with_q.append((locale, q))\n- # strip off '-detail' suffixes until there's nothing left\n- locale, _, _region = locale.rpartition('-')\n+ os_release = {}\n+ for line in file.readlines():\n+ line = line.strip()\n+ if not line or line.startswith('#'):\n+ continue\n+ try:\n+ k, v = line.split('=')\n+ (v_parsed, ) = shlex.split(v) # expect exactly one token\n+ except ValueError:\n+ logger.warning('Ignoring invalid line in os-release: %r', line)\n+ continue\n+ os_release[k] = v_parsed\n+ return os_release\n \n- # Sort the list by highest q value. Otherwise, this is a stable sort.\n- locales_with_q.sort(key=lambda pair: pair[1], reverse=True)\n- logger.debug(' sorted list is %r', locales_with_q)\n+ def do_init(self, message: JsonObject) -> None:\n+ # we're only interested in the case where this is a dict, but\n+ # 'superuser' may well be `False` and that's not an error\n+ with contextlib.suppress(JsonError):\n+ superuser = get_dict(message, 'superuser')\n+ self.superuser_rule.init(superuser)\n \n- # If we have 'en' anywhere in our list, ignore it and all items after it.\n- # This will result in us getting an untranslated (ie: English) version if\n- # none of the more-preferred languages are found, which is what we want.\n- # We also take the chance to drop duplicate items. Note: both of these\n- # things need to happen after sorting.\n- results = []\n- for locale, _q in locales_with_q:\n- if locale == 'en':\n- break\n- if locale not in results:\n- results.append(locale)\n+ def do_send_init(self) -> None:\n+ init_args = {\n+ 'capabilities': {'explicit-superuser': True},\n+ 'command': 'init',\n+ 'os-release': self.get_os_release(),\n+ 'version': 1,\n+ }\n \n- logger.debug(' results list is %r', results)\n- return tuple(results)\n+ if self.packages is not None:\n+ init_args['packages'] = dict.fromkeys(self.packages.packages)\n \n+ self.write_control(init_args)\n \n-def sortify_version(version: str) -> str:\n- \"\"\"Convert a version string to a form that can be compared\"\"\"\n- # 0-pad each numeric component. Only supports numeric versions like 1.2.3.\n- return '.'.join(part.zfill(8) for part in version.split('.'))\n+ # PackagesListener interface\n+ def packages_loaded(self) -> None:\n+ assert self.packages\n+ bridge_configs = self.packages.get_bridge_configs()\n+ if self.bridge_configs != bridge_configs:\n+ self.superuser_rule.set_configs(bridge_configs)\n+ self.peers_rule.set_configs(bridge_configs)\n+ self.bridge_configs = bridge_configs\n \n \n-@functools.lru_cache()\n-def get_libexecdir() -> str:\n- \"\"\"Detect libexecdir on current machine\n+async def run(args) -> None:\n+ logger.debug(\"Hi. How are you today?\")\n \n- This only works for systems which have cockpit-ws installed.\n- \"\"\"\n- for candidate in ['/usr/local/libexec', '/usr/libexec', '/usr/local/lib/cockpit', '/usr/lib/cockpit']:\n- if os.path.exists(os.path.join(candidate, 'cockpit-askpass')):\n- return candidate\n- else:\n- logger.warning('Could not detect libexecdir')\n- # give readable error messages\n- return '/nonexistent/libexec'\n+ # Unit tests require this\n+ me = pwd.getpwuid(os.getuid())\n+ os.environ['HOME'] = me.pw_dir\n+ os.environ['SHELL'] = me.pw_shell\n+ os.environ['USER'] = me.pw_name\n \n+ logger.debug('Starting the router.')\n+ router = Bridge(args)\n+ StdioTransport(asyncio.get_running_loop(), router)\n \n-# HACK: Type narrowing over Union types is not supported in the general case,\n-# but this works for the case we care about: knowing that when we pass in an\n-# JsonObject, we'll get an JsonObject back.\n-J = TypeVar('J', JsonObject, JsonValue)\n+ logger.debug('Startup done. Looping until connection closes.')\n \n+ try:\n+ await router.communicate()\n+ except (BrokenPipeError, ConnectionResetError):\n+ # not unexpected if the peer doesn't hang up cleanly\n+ pass\n \n-def patch_libexecdir(obj: J) -> J:\n- if isinstance(obj, str):\n- if '${libexecdir}/cockpit-askpass' in obj:\n- # extra-special case: we handle this internally\n- abs_askpass = shutil.which('cockpit-askpass')\n- if abs_askpass is not None:\n- return obj.replace('${libexecdir}/cockpit-askpass', abs_askpass)\n- return obj.replace('${libexecdir}', get_libexecdir())\n- elif isinstance(obj, dict):\n- return {key: patch_libexecdir(value) for key, value in obj.items()}\n- elif isinstance(obj, list):\n- return [patch_libexecdir(item) for item in obj]\n- else:\n- return obj\n \n+def try_to_receive_stderr():\n+ try:\n+ ours, theirs = socket.socketpair()\n+ with ours:\n+ with theirs:\n+ interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()])\n+ _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1)\n+ except OSError:\n+ return\n \n-# A document is a binary stream with a Content-Type, optional Content-Encoding,\n-# and optional Content-Security-Policy\n-class Document(NamedTuple):\n- data: BinaryIO\n- content_type: str\n- content_encoding: Optional[str] = None\n- content_security_policy: Optional[str] = None\n+ try:\n+ stderr_fd, = fds\n+ # We're about to abruptly drop our end of the stderr socketpair that we\n+ # share with the ferny agent. ferny would normally treat that as an\n+ # unexpected error. Instruct it to do a clean exit, instead.\n+ interaction_client.command(2, 'ferny.end')\n+ os.dup2(stderr_fd, 2)\n+ finally:\n+ for fd in fds:\n+ os.close(fd)\n \n \n-class PackagesListener:\n- def packages_loaded(self) -> None:\n- \"\"\"Called when the packages have been reloaded\"\"\"\n+def setup_journald() -> bool:\n+ # If stderr is a socket, prefer systemd-journal logging. This covers the\n+ # case we're already connected to the journal but also the case where we're\n+ # talking to the ferny agent, while leaving logging to file or terminal\n+ # unaffected.\n+ if not stat.S_ISSOCK(os.fstat(2).st_mode):\n+ # not a socket? Don't redirect.\n+ return False\n \n+ try:\n+ import systemd.journal # type: ignore[import]\n+ except ImportError:\n+ # No python3-systemd? Don't redirect.\n+ return False\n \n-class BridgeConfig(dict, JsonObject):\n- def __init__(self, value: JsonObject):\n- super().__init__(value)\n+ logging.root.addHandler(systemd.journal.JournalHandler())\n+ return True\n \n- self.label = get_str(self, 'label', None)\n \n- self.privileged = get_bool(self, 'privileged', default=False)\n- self.match = get_dict(self, 'match', {})\n- if not self.privileged and not self.match:\n- raise JsonError(value, 'must have match rules or be privileged')\n+def setup_logging(*, debug: bool) -> None:\n+ \"\"\"Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set\"\"\"\n \n- self.environ = get_strv(self, 'environ', ())\n- self.spawn = get_strv(self, 'spawn')\n- if not self.spawn:\n- raise JsonError(value, 'spawn vector must be non-empty')\n+ modules = os.getenv('COCKPIT_DEBUG', '')\n \n- self.name = self.label or self.spawn[0]\n+ # Either setup logging via journal or via formatted messages to stderr\n+ if not setup_journald():\n+ logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s')\n \n+ if debug or modules == 'all':\n+ logging.getLogger().setLevel(level=logging.DEBUG)\n+ elif modules:\n+ for module in modules.split(','):\n+ module = module.strip()\n+ if not module:\n+ continue\n \n-class Condition:\n- def __init__(self, value: JsonObject):\n- try:\n- (self.name, self.value), = value.items()\n- except ValueError as exc:\n- raise JsonError(value, 'must contain exactly one key/value pair') from exc\n+ logging.getLogger(module).setLevel(logging.DEBUG)\n \n \n-class Manifest(dict, JsonObject):\n- # Skip version check when running out of the git checkout (__version__ is None)\n- COCKPIT_VERSION = __version__ and sortify_version(__version__)\n+def start_ssh_agent() -> None:\n+ # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust,\n+ # but it gets cleared on setgid ssh-agent, which some distros still do\n+ try:\n+ proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'],\n+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)\n+ assert proc.stdout is not None\n \n- def __init__(self, path: Path, value: JsonObject):\n- super().__init__(value)\n- self.path = path\n- self.name = get_str(self, 'name', self.path.name)\n- self.bridges = get_objv(self, 'bridges', BridgeConfig)\n- self.priority = get_int(self, 'priority', 1)\n- self.csp = get_str(self, 'content-security-policy', '')\n- self.conditions = get_objv(self, 'conditions', Condition)\n+ # Wait for the agent to write at least one line and look for the\n+ # listener socket. If we fail to find it, kill the agent \u2014 something\n+ # went wrong.\n+ for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True):\n+ if token.startswith('SSH_AUTH_SOCK='):\n+ os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1)\n+ break\n+ else:\n+ proc.terminate()\n+ proc.wait()\n \n- # Skip version check when running out of the git checkout (COCKPIT_VERSION is None)\n- if self.COCKPIT_VERSION is not None:\n- requires = get_dict(self, 'requires', {})\n- for name, version in requires.items():\n- if name != 'cockpit':\n- raise JsonError(name, 'non-cockpit requirement listed')\n- if sortify_version(typechecked(version, str)) > self.COCKPIT_VERSION:\n- raise JsonError(version, f'required cockpit version ({version}) not met')\n+ except FileNotFoundError:\n+ logger.debug(\"Couldn't start ssh-agent (FileNotFoundError)\")\n \n+ except OSError as exc:\n+ logger.warning(\"Could not start ssh-agent: %s\", exc)\n \n-class Package:\n- # For po{,.manifest}.js files, the interesting part is the locale name\n- PO_JS_RE: ClassVar[Pattern] = re.compile(r'(po|po\\.manifest)\\.([^.]+)\\.js(\\.gz)?')\n \n- # immutable after __init__\n- manifest: Manifest\n- name: str\n- path: Path\n- priority: int\n+def main(*, beipack: bool = False) -> None:\n+ polyfills.install()\n \n- # computed later\n- translations: Optional[Dict[str, Dict[str, str]]] = None\n- files: Optional[Dict[str, str]] = None\n+ parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')\n+ parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge')\n+ parser.add_argument('--packages', action='store_true', help='Show Cockpit package information')\n+ parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information')\n+ parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)')\n+ parser.add_argument('--version', action='store_true', help='Show Cockpit version information')\n+ args = parser.parse_args()\n \n- def __init__(self, manifest: Manifest):\n- self.manifest = manifest\n- self.name = manifest.name\n- self.path = manifest.path\n- self.priority = manifest.priority\n+ # This is determined by who calls us\n+ args.beipack = beipack\n \n- def ensure_scanned(self) -> None:\n- \"\"\"Ensure that the package has been scanned.\n+ # If we were run with --privileged then our stderr is currently being\n+ # consumed by the main bridge looking for startup-related error messages.\n+ # Let's switch back to the original stderr stream, which has a side-effect\n+ # of indicating that our startup is more or less complete. Any errors\n+ # after this point will land in the journal.\n+ if args.privileged:\n+ try_to_receive_stderr()\n \n- This allows us to defer scanning the files of the package until we know\n- that we'll actually use it.\n- \"\"\"\n+ setup_logging(debug=args.debug)\n \n- if self.files is not None:\n- return\n+ # Special modes\n+ if args.packages:\n+ Packages().show()\n+ return\n+ elif args.version:\n+ print(f'Version: {__version__}\\nProtocol: 1')\n+ return\n+ elif args.bridges:\n+ print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2))\n+ return\n \n- self.files = {}\n- self.translations = {'po.js': {}, 'po.manifest.js': {}}\n+ # The privileged bridge doesn't need ssh-agent, but the main one does\n+ if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged:\n+ start_ssh_agent()\n \n- for file in self.path.rglob('*'):\n- name = str(file.relative_to(self.path))\n- if name in ['.', '..', 'manifest.json']:\n- continue\n+ # asyncio.run() shim for Python 3.6 support\n+ run_async(run(args), debug=args.debug)\n \n- po_match = Package.PO_JS_RE.fullmatch(name)\n- if po_match:\n- basename = po_match.group(1)\n- locale = po_match.group(2)\n- # Accept-Language is case-insensitive and uses '-' to separate variants\n- lower_locale = locale.lower().replace('_', '-')\n \n- logger.debug('Adding translation %r %r -> %r', basename, lower_locale, name)\n- self.translations[f'{basename}.js'][lower_locale] = name\n- else:\n- # strip out trailing '.gz' components\n- basename = re.sub('.gz$', '', name)\n- logger.debug('Adding content %r -> %r', basename, name)\n- self.files[basename] = name\n+if __name__ == '__main__':\n+ main()\n+'''.encode('utf-8'),\n+ 'cockpit/beipack.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2023 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n- # If we see a filename like `x.min.js` we want to also offer it\n- # at `x.js`, but only if `x.js(.gz)` itself is not present.\n- # Note: this works for both the case where we found the `x.js`\n- # first (it's already in the map) and also if we find it second\n- # (it will be replaced in the map by the line just above).\n- # See https://github.com/cockpit-project/cockpit/pull/19716\n- self.files.setdefault(basename.replace('.min.', '.'), name)\n+import logging\n+import lzma\n+from typing import List, Sequence, Tuple\n \n- # support old cockpit-po-plugin which didn't write po.manifest.??.js\n- if not self.translations['po.manifest.js']:\n- self.translations['po.manifest.js'] = self.translations['po.js']\n+from cockpit._vendor import ferny\n+from cockpit._vendor.bei import beipack\n \n- def get_content_security_policy(self) -> str:\n- policy = {\n- \"default-src\": \"'self'\",\n- \"connect-src\": \"'self'\",\n- \"form-action\": \"'self'\",\n- \"base-uri\": \"'self'\",\n- \"object-src\": \"'none'\",\n- \"font-src\": \"'self' data:\",\n- \"img-src\": \"'self' data:\",\n- }\n+from .data import read_cockpit_data_file\n+from .peer import Peer, PeerError\n \n- for item in self.manifest.csp.split(';'):\n- item = item.strip()\n- if item:\n- key, _, value = item.strip().partition(' ')\n- policy[key] = value\n+logger = logging.getLogger(__name__)\n \n- return ' '.join(f'{k} {v};' for k, v in policy.items()) + ' block-all-mixed-content'\n \n- def load_file(self, filename: str) -> Document:\n- content_type, content_encoding = mimetypes.guess_type(filename)\n- content_security_policy = None\n+def get_bridge_beipack_xz() -> Tuple[str, bytes]:\n+ try:\n+ bridge_beipack_xz = read_cockpit_data_file('cockpit-bridge.beipack.xz')\n+ logger.debug('Got pre-built cockpit-bridge.beipack.xz')\n+ except FileNotFoundError:\n+ logger.debug('Pre-built cockpit-bridge.beipack.xz; building our own.')\n+ # beipack ourselves\n+ cockpit_contents = beipack.collect_module('cockpit', recursive=True)\n+ bridge_beipack = beipack.pack(cockpit_contents, entrypoint='cockpit.bridge:main', args='beipack=True')\n+ bridge_beipack_xz = lzma.compress(bridge_beipack.encode())\n+ logger.debug(' ... done!')\n \n- if content_type is None:\n- content_type = 'text/plain'\n- elif content_type.startswith('text/html'):\n- content_security_policy = self.get_content_security_policy()\n+ return 'cockpit/data/cockpit-bridge.beipack.xz', bridge_beipack_xz\n \n- path = self.path / filename\n- logger.debug(' loading data from %s', path)\n \n- return Document(path.open('rb'), content_type, content_encoding, content_security_policy)\n+class BridgeBeibootHelper(ferny.InteractionHandler):\n+ # ferny.InteractionHandler ClassVar\n+ commands = ['beiboot.provide', 'beiboot.exc']\n \n- def load_translation(self, path: str, locales: Sequence[str]) -> Document:\n- self.ensure_scanned()\n- assert self.translations is not None\n+ peer: Peer\n+ payload: bytes\n+ steps: Sequence[Tuple[str, Sequence[object]]]\n \n- # First match wins\n- for locale in locales:\n- with contextlib.suppress(KeyError):\n- return self.load_file(self.translations[path][locale])\n+ def __init__(self, peer: Peer, args: Sequence[str] = ()) -> None:\n+ filename, payload = get_bridge_beipack_xz()\n \n- # We prefer to return an empty document than 404 in order to avoid\n- # errors in the console when a translation can't be found\n- return Document(io.BytesIO(), 'text/javascript')\n+ self.peer = peer\n+ self.payload = payload\n+ self.steps = (('boot_xz', (filename, len(payload), tuple(args))),)\n \n- def load_path(self, path: str, headers: JsonObject) -> Document:\n- self.ensure_scanned()\n- assert self.files is not None\n- assert self.translations is not None\n+ async def run_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None:\n+ logger.debug('Got ferny request %s %s %s %s', command, args, fds, stderr)\n+ if command == 'beiboot.provide':\n+ try:\n+ size, = args\n+ assert size == len(self.payload)\n+ except (AssertionError, ValueError) as exc:\n+ raise PeerError('internal-error', message=f'ferny interaction error {exc!s}') from exc\n \n- if path in self.translations:\n- locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))\n- return self.load_translation(path, locales)\n+ assert self.peer.transport is not None\n+ logger.debug('Writing %d bytes of payload', len(self.payload))\n+ self.peer.transport.write(self.payload)\n+ elif command == 'beiboot.exc':\n+ raise PeerError('internal-error', message=f'Remote exception: {args[0]}')\n else:\n- return self.load_file(self.files[path])\n+ raise PeerError('internal-error', message=f'Unexpected ferny interaction command {command}')\n+''',\n+ 'cockpit/jsonutil.py': r'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2023 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n+from enum import Enum\n+from typing import Callable, Container, Dict, List, Mapping, Optional, Sequence, Type, TypeVar, Union\n \n-class PackagesLoader:\n- CONDITIONS: ClassVar[Dict[str, Callable[[str], bool]]] = {\n- 'path-exists': os.path.exists,\n- 'path-not-exists': lambda p: not os.path.exists(p),\n- }\n+JsonLiteral = Union[str, float, bool, None]\n \n- @classmethod\n- def get_xdg_data_dirs(cls) -> Iterable[str]:\n- try:\n- yield os.environ['XDG_DATA_HOME']\n- except KeyError:\n- yield os.path.expanduser('~/.local/share')\n+# immutable\n+JsonValue = Union['JsonObject', Sequence['JsonValue'], JsonLiteral]\n+JsonObject = Mapping[str, JsonValue]\n \n- try:\n- yield from os.environ['XDG_DATA_DIRS'].split(':')\n- except KeyError:\n- yield from ('/usr/local/share', '/usr/share')\n+# mutable\n+JsonDocument = Union['JsonDict', 'JsonList', JsonLiteral]\n+JsonDict = Dict[str, JsonDocument]\n+JsonList = List[JsonDocument]\n \n- @classmethod\n- def patch_manifest(cls, manifest: JsonObject, parent: Path) -> JsonObject:\n- override_files = [\n- parent / 'override.json',\n- config.lookup_config(f'{parent.name}.override.json'),\n- config.DOT_CONFIG_COCKPIT / f'{parent.name}.override.json',\n- ]\n \n- for override_file in override_files:\n- try:\n- override: JsonValue = json.loads(override_file.read_bytes())\n- except FileNotFoundError:\n- continue\n- except json.JSONDecodeError as exc:\n- # User input error: report a warning\n- logger.warning('%s: %s', override_file, exc)\n+DT = TypeVar('DT')\n+T = TypeVar('T')\n \n- if not isinstance(override, dict):\n- logger.warning('%s: override file is not a dictionary', override_file)\n- continue\n \n- manifest = json_merge_patch(manifest, override)\n+class JsonError(Exception):\n+ value: object\n \n- return patch_libexecdir(manifest)\n+ def __init__(self, value: object, msg: str):\n+ super().__init__(msg)\n+ self.value = value\n \n- @classmethod\n- def load_manifests(cls) -> Iterable[Manifest]:\n- for datadir in cls.get_xdg_data_dirs():\n- logger.debug(\"Scanning for manifest files under %s\", datadir)\n- for file in Path(datadir).glob('cockpit/*/manifest.json'):\n- logger.debug(\"Considering file %s\", file)\n- try:\n- manifest = json.loads(file.read_text())\n- except json.JSONDecodeError as exc:\n- logger.error(\"%s: %s\", file, exc)\n- continue\n- if not isinstance(manifest, dict):\n- logger.error(\"%s: json document isn't an object\", file)\n- continue\n \n- parent = file.parent\n- manifest = cls.patch_manifest(manifest, parent)\n- try:\n- yield Manifest(parent, manifest)\n- except JsonError as exc:\n- logger.warning('%s %s', file, exc)\n+def typechecked(value: JsonValue, expected_type: Type[T]) -> T:\n+ \"\"\"Ensure a JSON value has the expected type, returning it if so.\"\"\"\n+ if not isinstance(value, expected_type):\n+ raise JsonError(value, f'must have type {expected_type.__name__}')\n+ return value\n \n- def check_condition(self, condition: str, value: object) -> bool:\n- check_fn = self.CONDITIONS[condition]\n \n- # All known predicates currently only work on strings\n- if not isinstance(value, str):\n- return False\n+# We can't use None as a sentinel because it's often the actual default value\n+# EllipsisType is difficult because it's not available before 3.10.\n+# See https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions\n+class _Empty(Enum):\n+ TOKEN = 0\n \n- return check_fn(value)\n \n- def check_conditions(self, manifest: Manifest) -> bool:\n- for condition in manifest.conditions:\n- try:\n- okay = self.check_condition(condition.name, condition.value)\n- except KeyError:\n- # do *not* ignore manifests with unknown predicates, for forward compatibility\n- logger.warning(' %s: ignoring unknown predicate in manifest: %s', manifest.path, condition.name)\n- continue\n+_empty = _Empty.TOKEN\n \n- if not okay:\n- logger.debug(' hiding package %s as its %s condition is not met', manifest.path, condition)\n- return False\n \n- return True\n+def _get(obj: JsonObject, cast: Callable[[JsonValue], T], key: str, default: Union[DT, _Empty]) -> Union[T, DT]:\n+ try:\n+ return cast(obj[key])\n+ except KeyError:\n+ if default is not _empty:\n+ return default\n+ raise JsonError(obj, f\"attribute '{key}' required\") from None\n+ except JsonError as exc:\n+ target = f\"attribute '{key}'\" + (' elements:' if exc.value is not obj[key] else ':')\n+ raise JsonError(obj, f\"{target} {exc!s}\") from exc\n \n- def load_packages(self) -> Iterable[Tuple[str, Package]]:\n- logger.debug('Scanning for available package manifests:')\n- # Sort all available packages into buckets by to their claimed name\n- names: Dict[str, List[Manifest]] = collections.defaultdict(list)\n- for manifest in self.load_manifests():\n- logger.debug(' %s/manifest.json', manifest.path)\n- names[manifest.name].append(manifest)\n- logger.debug('done.')\n \n- logger.debug('Selecting packages to serve:')\n- for name, candidates in names.items():\n- # For each package name, iterate the candidates in descending\n- # priority order and select the first one which passes all checks\n- for candidate in sorted(candidates, key=lambda manifest: manifest.priority, reverse=True):\n- try:\n- if self.check_conditions(candidate):\n- logger.debug(' creating package %s -> %s', name, candidate.path)\n- yield name, Package(candidate)\n- break\n- except JsonError:\n- logger.warning(' %s: ignoring package with invalid manifest file', candidate.path)\n+def get_bool(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, bool]:\n+ return _get(obj, lambda v: typechecked(v, bool), key, default)\n \n- logger.debug(' ignoring %s: unmet conditions', candidate.path)\n- logger.debug('done.')\n \n+def get_int(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, int]:\n+ return _get(obj, lambda v: typechecked(v, int), key, default)\n \n-class Packages(bus.Object, interface='cockpit.Packages'):\n- loader: PackagesLoader\n- listener: Optional[PackagesListener]\n- packages: Dict[str, Package]\n- saw_first_reload_hint: bool\n \n- def __init__(self, listener: Optional[PackagesListener] = None, loader: Optional[PackagesLoader] = None):\n- self.listener = listener\n- self.loader = loader or PackagesLoader()\n- self.load()\n+def get_str(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, str]:\n+ return _get(obj, lambda v: typechecked(v, str), key, default)\n \n- # Reloading the Shell in the browser should reload the\n- # packages. This is implemented by having the Shell call\n- # reload_hint whenever it starts. The first call of this\n- # method in each session is ignored so that packages are not\n- # loaded twice right after logging in.\n- #\n- self.saw_first_reload_hint = False\n \n- def load(self) -> None:\n- self.packages = dict(self.loader.load_packages())\n- self.manifests = json.dumps({name: dict(package.manifest) for name, package in self.packages.items()})\n- logger.debug('Packages loaded: %s', list(self.packages))\n+def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Optional[str]:\n+ return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default)\n \n- def show(self):\n- for name in sorted(self.packages):\n- package = self.packages[name]\n- menuitems = []\n- for entry in itertools.chain(\n- package.manifest.get('menu', {}).values(),\n- package.manifest.get('tools', {}).values()):\n- with contextlib.suppress(KeyError):\n- menuitems.append(entry['label'])\n- print(f'{name:20} {\", \".join(menuitems):40} {package.path}')\n \n- def get_bridge_configs(self) -> Sequence[BridgeConfig]:\n- def yield_configs():\n- for package in sorted(self.packages.values(), key=lambda package: -package.priority):\n- yield from package.manifest.bridges\n- return tuple(yield_configs())\n+def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]:\n+ return _get(obj, lambda v: typechecked(v, dict), key, default)\n \n- # D-Bus Interface\n- manifests = bus.Interface.Property('s', value=\"{}\")\n \n- @bus.Interface.Method()\n- def reload(self):\n- self.load()\n- if self.listener is not None:\n- self.listener.packages_loaded()\n+def get_object(\n+ obj: JsonObject,\n+ key: str,\n+ constructor: Callable[[JsonObject], T],\n+ default: Union[DT, _Empty] = _empty\n+) -> Union[DT, T]:\n+ return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default)\n \n- @bus.Interface.Method()\n- def reload_hint(self):\n- if self.saw_first_reload_hint:\n- self.reload()\n- self.saw_first_reload_hint = True\n \n- def load_manifests_js(self, headers: JsonObject) -> Document:\n- logger.debug('Serving /manifests.js')\n+def get_strv(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, Sequence[str]]:\n+ def as_strv(value: JsonValue) -> Sequence[str]:\n+ return tuple(typechecked(item, str) for item in typechecked(value, list))\n+ return _get(obj, as_strv, key, default)\n \n- chunks: List[bytes] = []\n \n- # Send the translations required for the manifest files, from each package\n- locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))\n- for name, package in self.packages.items():\n- if name in ['static', 'base1']:\n- continue\n+def get_enum(\n+ obj: JsonObject, key: str, choices: Container[str], default: Union[DT, _Empty] = _empty\n+) -> Union[DT, str]:\n+ def as_choice(value: JsonValue) -> str:\n+ # mypy can't do `__eq__()`-based type narrowing...\n+ # https://github.com/python/mypy/issues/17101\n+ if isinstance(value, str) and value in choices:\n+ return value\n+ raise JsonError(value, f'invalid value \"{value}\" not in {choices}')\n+ return _get(obj, as_choice, key, default)\n \n- # find_translation will always find at least 'en'\n- translation = package.load_translation('po.manifest.js', locales)\n- with translation.data:\n- if translation.content_encoding == 'gzip':\n- data = gzip.decompress(translation.data.read())\n- else:\n- data = translation.data.read()\n \n- chunks.append(data)\n+def get_objv(obj: JsonObject, key: str, constructor: Callable[[JsonObject], T]) -> Union[DT, Sequence[T]]:\n+ def as_objv(value: JsonValue) -> Sequence[T]:\n+ return tuple(constructor(typechecked(item, dict)) for item in typechecked(value, list))\n+ return _get(obj, as_objv, key, ())\n \n- chunks.append(b\"\"\"\n- (function (root, data) {\n- if (typeof define === 'function' && define.amd) {\n- define(data);\n- }\n \n- if (typeof cockpit === 'object') {\n- cockpit.manifests = data;\n- } else {\n- root.manifests = data;\n- }\n- }(this, \"\"\" + self.manifests.encode() + b\"\"\"))\"\"\")\n+def create_object(message: 'JsonObject | None', kwargs: JsonObject) -> JsonObject:\n+ \"\"\"Constructs a JSON object based on message and kwargs.\n \n- return Document(io.BytesIO(b'\\n'.join(chunks)), 'text/javascript')\n+ If only message is given, it is returned, unmodified. If message is None,\n+ it is equivalent to an empty dictionary. A copy is always made.\n \n- def load_manifests_json(self) -> Document:\n- logger.debug('Serving /manifests.json')\n- return Document(io.BytesIO(self.manifests.encode()), 'application/json')\n+ If kwargs are present, then any underscore ('_') present in a key name is\n+ rewritten to a dash ('-'). This is intended to bridge between the required\n+ Python syntax when providing kwargs and idiomatic JSON (which uses '-' for\n+ attributes). These values override values in message.\n \n- PATH_RE = re.compile(\n- r'/' # leading '/'\n- r'(?:([^/]+)/)?' # optional leading path component\n- r'((?:[^/]+/)*[^/]+)' # remaining path components\n- )\n+ The idea is that `message` should be used for passing data along, and\n+ kwargs used for data originating at a given call site, possibly including\n+ modifications to an original message.\n+ \"\"\"\n+ result = dict(message or {})\n \n- def load_path(self, path: str, headers: JsonObject) -> Document:\n- logger.debug('packages: serving %s', path)\n+ for key, value in kwargs.items():\n+ # rewrite '_' (necessary in Python syntax kwargs list) to '-' (idiomatic JSON)\n+ json_key = key.replace('_', '-')\n+ result[json_key] = value\n \n- match = self.PATH_RE.fullmatch(path)\n- if match is None:\n- raise ValueError(f'Invalid HTTP path {path}')\n- packagename, filename = match.groups()\n+ return result\n \n- if packagename is not None:\n- return self.packages[packagename].load_path(filename, headers)\n- elif filename == 'manifests.js':\n- return self.load_manifests_js(headers)\n- elif filename == 'manifests.json':\n- return self.load_manifests_json()\n+\n+def json_merge_patch(current: JsonObject, patch: JsonObject) -> JsonObject:\n+ \"\"\"Perform a JSON merge patch (RFC 7396) using 'current' and 'patch'.\n+ Neither of the original dictionaries is modified \u2014 the result is returned.\n+ \"\"\"\n+ # Always take a copy ('result') \u2014 we never modify the input ('current')\n+ result = dict(current)\n+ for key, patch_value in patch.items():\n+ if isinstance(patch_value, Mapping):\n+ current_value = current.get(key, None)\n+ if not isinstance(current_value, Mapping):\n+ current_value = {}\n+ result[key] = json_merge_patch(current_value, patch_value)\n+ elif patch_value is not None:\n+ result[key] = patch_value\n else:\n- raise KeyError\n-''',\n+ result.pop(key, None)\n+\n+ return result\n+\n+\n+def json_merge_and_filter_patch(current: JsonDict, patch: JsonDict) -> None:\n+ \"\"\"Perform a JSON merge patch (RFC 7396) modifying 'current' with 'patch'.\n+ Also modifies 'patch' to remove redundant operations.\n+ \"\"\"\n+ for key, patch_value in tuple(patch.items()):\n+ current_value = current.get(key, None)\n+\n+ if isinstance(patch_value, dict):\n+ if not isinstance(current_value, dict):\n+ current[key] = current_value = {}\n+ json_merge_and_filter_patch(current_value, patch_value)\n+ else:\n+ json_merge_and_filter_patch(current_value, patch_value)\n+ if not patch_value:\n+ del patch[key]\n+ elif current_value == patch_value:\n+ del patch[key]\n+ elif patch_value is not None:\n+ current[key] = patch_value\n+ else:\n+ del current[key]\n+'''.encode('utf-8'),\n 'cockpit/superuser.py': br'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n@@ -981,15 +1393,15 @@\n def answer(self, reply: str) -> None:\n if self.pending_prompt is not None:\n logger.debug('responding to pending prompt')\n self.pending_prompt.set_result(reply)\n else:\n logger.debug('got Answer, but no prompt pending')\n ''',\n- 'cockpit/bridge.py': r'''# This file is part of Cockpit.\n+ 'cockpit/beiboot.py': br'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -1000,311 +1412,612 @@\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n import argparse\n import asyncio\n-import contextlib\n-import json\n+import base64\n+import importlib.resources\n import logging\n import os\n-import pwd\n import shlex\n-import socket\n-import stat\n-import subprocess\n-from typing import Iterable, List, Optional, Sequence, Tuple, Type\n+from pathlib import Path\n+from typing import Dict, Iterable, Optional, Sequence\n \n-from cockpit._vendor.ferny import interaction_client\n-from cockpit._vendor.systemd_ctypes import bus, run_async\n+from cockpit import polyfills\n+from cockpit._vendor import ferny\n+from cockpit._vendor.bei import bootloader\n+from cockpit.beipack import BridgeBeibootHelper\n+from cockpit.bridge import setup_logging\n+from cockpit.channel import ChannelRoutingRule\n+from cockpit.channels import PackagesChannel\n+from cockpit.jsonutil import JsonObject\n+from cockpit.packages import Packages, PackagesLoader, patch_libexecdir\n+from cockpit.peer import Peer\n+from cockpit.protocol import CockpitProblem\n+from cockpit.router import Router, RoutingRule\n+from cockpit.transports import StdioTransport\n \n-from . import polyfills\n-from ._version import __version__\n-from .channel import ChannelRoutingRule\n-from .channels import CHANNEL_TYPES\n-from .config import Config, Environment\n-from .internal_endpoints import EXPORTS\n-from .jsonutil import JsonError, JsonObject, get_dict\n-from .packages import BridgeConfig, Packages, PackagesListener\n-from .peer import PeersRoutingRule\n-from .remote import HostRoutingRule\n-from .router import Router\n-from .superuser import SuperuserRoutingRule\n-from .transports import StdioTransport\n+logger = logging.getLogger('cockpit.beiboot')\n \n-logger = logging.getLogger(__name__)\n \n+def ensure_ferny_askpass() -> Path:\n+ \"\"\"Create askpass executable\n \n-class InternalBus:\n- exportees: List[bus.Slot]\n+ We need this for the flatpak: ssh and thus the askpass program run on the host (via flatpak-spawn),\n+ not the flatpak. Thus we cannot use the shipped cockpit-askpass program.\n+ \"\"\"\n+ src_path = importlib.resources.files(ferny.__name__) / 'interaction_client.py'\n+ src_data = src_path.read_bytes()\n \n- def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]):\n- client_socket, server_socket = socket.socketpair()\n- self.client = bus.Bus.new(fd=client_socket.detach())\n- self.server = bus.Bus.new(fd=server_socket.detach(), server=True)\n- self.exportees = [self.server.add_object(path, cls()) for path, cls in exports]\n+ # Create the file in $XDG_CACHE_HOME, one of the few locations that a flatpak can write to\n+ xdg_cache_home = os.environ.get('XDG_CACHE_HOME')\n+ if xdg_cache_home is None:\n+ xdg_cache_home = os.path.expanduser('~/.cache')\n+ os.makedirs(xdg_cache_home, exist_ok=True)\n+ dest_path = Path(xdg_cache_home, 'cockpit-client-askpass')\n \n- def export(self, path: str, obj: bus.BaseObject) -> None:\n- self.exportees.append(self.server.add_object(path, obj))\n+ logger.debug(\"Checking if %s exists...\", dest_path)\n \n+ # Check first to see if we already wrote the current version\n+ try:\n+ if dest_path.read_bytes() != src_data:\n+ logger.debug(\" ... it exists but is not the same version...\")\n+ raise ValueError\n+ if not dest_path.stat().st_mode & 0o100:\n+ logger.debug(\" ... it has the correct contents, but is not executable...\")\n+ raise ValueError\n+ except (FileNotFoundError, ValueError):\n+ logger.debug(\" ... writing contents.\")\n+ dest_path.write_bytes(src_data)\n+ dest_path.chmod(0o700)\n \n-class Bridge(Router, PackagesListener):\n- internal_bus: InternalBus\n- packages: Optional[Packages]\n- bridge_configs: Sequence[BridgeConfig]\n- args: argparse.Namespace\n+ return dest_path\n \n- def __init__(self, args: argparse.Namespace):\n- self.internal_bus = InternalBus(EXPORTS)\n- self.bridge_configs = []\n- self.args = args\n \n- self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged)\n- self.internal_bus.export('/superuser', self.superuser_rule)\n+def get_interesting_files() -> Iterable[str]:\n+ for manifest in PackagesLoader.load_manifests():\n+ for condition in manifest.conditions:\n+ if condition.name in ('path-exists', 'path-not-exists') and isinstance(condition.value, str):\n+ yield condition.value\n \n- self.internal_bus.export('/config', Config())\n- self.internal_bus.export('/environment', Environment())\n \n- self.peers_rule = PeersRoutingRule(self)\n+class ProxyPackagesLoader(PackagesLoader):\n+ file_status: Dict[str, bool]\n \n- if args.beipack:\n- # Some special stuff for beipack\n- self.superuser_rule.set_configs((\n- BridgeConfig({\n- \"privileged\": True,\n- \"spawn\": [\"sudo\", \"-k\", \"-A\", \"python3\", \"-ic\", \"# cockpit-bridge\", \"--privileged\"],\n- \"environ\": [\"SUDO_ASKPASS=ferny-askpass\"],\n- }),\n- ))\n- self.packages = None\n- elif args.privileged:\n- self.packages = None\n+ def check_condition(self, condition: str, value: object) -> bool:\n+ assert isinstance(value, str)\n+ assert value in self.file_status\n+\n+ if condition == 'path-exists':\n+ return self.file_status[value]\n+ elif condition == 'path-not-exists':\n+ return not self.file_status[value]\n else:\n- self.packages = Packages(self)\n- self.internal_bus.export('/packages', self.packages)\n- self.packages_loaded()\n+ raise KeyError\n \n- super().__init__([\n- HostRoutingRule(self),\n- self.superuser_rule,\n- ChannelRoutingRule(self, CHANNEL_TYPES),\n- self.peers_rule,\n- ])\n+ def __init__(self, file_status: Dict[str, bool]):\n+ self.file_status = file_status\n \n- @staticmethod\n- def get_os_release():\n- try:\n- file = open('/etc/os-release', encoding='utf-8')\n- except FileNotFoundError:\n- try:\n- file = open('/usr/lib/os-release', encoding='utf-8')\n- except FileNotFoundError:\n- logger.warning(\"Neither /etc/os-release nor /usr/lib/os-release exists\")\n- return {}\n \n- os_release = {}\n- for line in file.readlines():\n- line = line.strip()\n- if not line or line.startswith('#'):\n- continue\n- try:\n- k, v = line.split('=')\n- (v_parsed, ) = shlex.split(v) # expect exactly one token\n- except ValueError:\n- logger.warning('Ignoring invalid line in os-release: %r', line)\n- continue\n- os_release[k] = v_parsed\n- return os_release\n+BEIBOOT_GADGETS = {\n+ \"report_exists\": r\"\"\"\n+ import os\n+ def report_exists(files):\n+ command('cockpit.report-exists', {name: os.path.exists(name) for name in files})\n+ \"\"\",\n+ **ferny.BEIBOOT_GADGETS\n+}\n \n- def do_init(self, message: JsonObject) -> None:\n- # we're only interested in the case where this is a dict, but\n- # 'superuser' may well be `False` and that's not an error\n- with contextlib.suppress(JsonError):\n- superuser = get_dict(message, 'superuser')\n- self.superuser_rule.init(superuser)\n \n- def do_send_init(self) -> None:\n- init_args = {\n- 'capabilities': {'explicit-superuser': True},\n- 'command': 'init',\n- 'os-release': self.get_os_release(),\n- 'version': 1,\n- }\n+class DefaultRoutingRule(RoutingRule):\n+ peer: 'Peer | None'\n \n- if self.packages is not None:\n- init_args['packages'] = dict.fromkeys(self.packages.packages)\n+ def __init__(self, router: Router):\n+ super().__init__(router)\n \n- self.write_control(init_args)\n+ def apply_rule(self, options: JsonObject) -> 'Peer | None':\n+ return self.peer\n \n- # PackagesListener interface\n- def packages_loaded(self) -> None:\n- assert self.packages\n- bridge_configs = self.packages.get_bridge_configs()\n- if self.bridge_configs != bridge_configs:\n- self.superuser_rule.set_configs(bridge_configs)\n- self.peers_rule.set_configs(bridge_configs)\n- self.bridge_configs = bridge_configs\n+ def shutdown(self) -> None:\n+ if self.peer is not None:\n+ self.peer.close()\n+\n+\n+class AuthorizeResponder(ferny.AskpassHandler):\n+ commands = ('ferny.askpass', 'cockpit.report-exists')\n+ router: Router\n+\n+ def __init__(self, router: Router):\n+ self.router = router\n+\n+ async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:\n+ if hint == 'none':\n+ # We have three problems here:\n+ #\n+ # - we have no way to present a message on the login\n+ # screen without presenting a prompt and a button\n+ # - the login screen will not try to repost the login\n+ # request because it doesn't understand that we are not\n+ # waiting on input, which means that it won't notice\n+ # that we've logged in successfully\n+ # - cockpit-ws has an issue where if we retry the request\n+ # again after login succeeded then it won't forward the\n+ # init message to the client, stalling the login. This\n+ # is a race and can't be fixed without -ws changes.\n+ #\n+ # Let's avoid all of that by just showing nothing.\n+ return None\n+\n+ challenge = 'X-Conversation - ' + base64.b64encode(prompt.encode()).decode()\n+ response = await self.router.request_authorization(challenge,\n+ messages=messages,\n+ prompt=prompt,\n+ hint=hint,\n+ echo=False)\n+\n+ b64 = response.removeprefix('X-Conversation -').strip()\n+ response = base64.b64decode(b64.encode()).decode()\n+ logger.debug('Returning a %d chars response', len(response))\n+ return response\n+\n+ async def do_custom_command(self, command: str, args: tuple, fds: list[int], stderr: str) -> None:\n+ logger.debug('Got ferny command %s %s %s', command, args, stderr)\n+\n+ if command == 'cockpit.report-exists':\n+ file_status, = args\n+ # FIXME: evil duck typing here -- this is a half-way Bridge\n+ self.router.packages = Packages(loader=ProxyPackagesLoader(file_status)) # type: ignore[attr-defined]\n+ self.router.routing_rules.insert(0, ChannelRoutingRule(self.router, [PackagesChannel]))\n+\n+\n+class SshPeer(Peer):\n+ always: bool\n+\n+ def __init__(self, router: Router, destination: str, args: argparse.Namespace):\n+ self.destination = destination\n+ self.always = args.always\n+ super().__init__(router)\n+\n+ async def do_connect_transport(self) -> None:\n+ beiboot_helper = BridgeBeibootHelper(self)\n+\n+ agent = ferny.InteractionAgent([AuthorizeResponder(self.router), beiboot_helper])\n+\n+ # We want to run a python interpreter somewhere...\n+ cmd: Sequence[str] = ('python3', '-ic', '# cockpit-bridge')\n+ env: Sequence[str] = ()\n+\n+ in_flatpak = os.path.exists('/.flatpak-info')\n+\n+ # Remote host? Wrap command with SSH\n+ if self.destination != 'localhost':\n+ if in_flatpak:\n+ # we run ssh and thus the helper on the host, always use the xdg-cache helper\n+ ssh_askpass = ensure_ferny_askpass()\n+ else:\n+ # outside of the flatpak we expect cockpit-ws and thus an installed helper\n+ askpass = patch_libexecdir('${libexecdir}/cockpit-askpass')\n+ assert isinstance(askpass, str)\n+ ssh_askpass = Path(askpass)\n+ if not ssh_askpass.exists():\n+ logger.error(\"Could not find cockpit-askpass helper at %r\", askpass)\n+\n+ env = (\n+ f'SSH_ASKPASS={ssh_askpass!s}',\n+ 'DISPLAY=x',\n+ 'SSH_ASKPASS_REQUIRE=force',\n+ )\n+ host, _, port = self.destination.rpartition(':')\n+ # catch cases like `host:123` but not cases like `[2001:abcd::1]\n+ if port.isdigit():\n+ host_args = ['-p', port, host]\n+ else:\n+ host_args = [self.destination]\n+\n+ cmd = ('ssh', *host_args, shlex.join(cmd))\n+\n+ # Running in flatpak? Wrap command with flatpak-spawn --host\n+ if in_flatpak:\n+ cmd = ('flatpak-spawn', '--host',\n+ *(f'--env={kv}' for kv in env),\n+ *cmd)\n+ env = ()\n+\n+ logger.debug(\"Launching command: cmd=%s env=%s\", cmd, env)\n+ transport = await self.spawn(cmd, env, stderr=agent, start_new_session=True)\n+\n+ if not self.always:\n+ exec_cockpit_bridge_steps = [('try_exec', (['cockpit-bridge'],))]\n+ else:\n+ exec_cockpit_bridge_steps = []\n+\n+ # Send the first-stage bootloader\n+ stage1 = bootloader.make_bootloader([\n+ *exec_cockpit_bridge_steps,\n+ ('report_exists', [list(get_interesting_files())]),\n+ *beiboot_helper.steps,\n+ ], gadgets=BEIBOOT_GADGETS)\n+ transport.write(stage1.encode())\n+\n+ # Wait for \"init\" or error, handling auth and beiboot requests\n+ await agent.communicate()\n+\n+ def transport_control_received(self, command: str, message: JsonObject) -> None:\n+ if command == 'authorize':\n+ # We've disabled this for explicit-superuser bridges, but older\n+ # bridges don't support that and will ask us anyway.\n+ return\n+\n+ super().transport_control_received(command, message)\n+\n+\n+class SshBridge(Router):\n+ packages: Optional[Packages] = None\n+ ssh_peer: SshPeer\n+\n+ def __init__(self, args: argparse.Namespace):\n+ # By default, we route everything to the other host. We add an extra\n+ # routing rule for the packages webserver only if we're running the\n+ # beipack.\n+ rule = DefaultRoutingRule(self)\n+ super().__init__([rule])\n+\n+ # This needs to be created after Router.__init__ is called.\n+ self.ssh_peer = SshPeer(self, args.destination, args)\n+ rule.peer = self.ssh_peer\n+\n+ def do_send_init(self):\n+ pass # wait for the peer to do it first\n+\n+ def do_init(self, message):\n+ # https://github.com/cockpit-project/cockpit/issues/18927\n+ #\n+ # We tell cockpit-ws that we have the explicit-superuser capability and\n+ # handle it ourselves (just below) by sending `superuser-init-done` and\n+ # passing {'superuser': False} on to the actual bridge (Python or C).\n+ if isinstance(message.get('superuser'), dict):\n+ self.write_control(command='superuser-init-done')\n+ message['superuser'] = False\n+ self.ssh_peer.write_control(message)\n \n \n async def run(args) -> None:\n logger.debug(\"Hi. How are you today?\")\n \n- # Unit tests require this\n- me = pwd.getpwuid(os.getuid())\n- os.environ['HOME'] = me.pw_dir\n- os.environ['SHELL'] = me.pw_shell\n- os.environ['USER'] = me.pw_name\n+ bridge = SshBridge(args)\n+ StdioTransport(asyncio.get_running_loop(), bridge)\n \n- logger.debug('Starting the router.')\n- router = Bridge(args)\n- StdioTransport(asyncio.get_running_loop(), router)\n+ try:\n+ message = dict(await bridge.ssh_peer.start())\n \n- logger.debug('Startup done. Looping until connection closes.')\n+ # See comment in do_init() above: we tell cockpit-ws that we support\n+ # this and then handle it ourselves when we get the init message.\n+ capabilities = message.setdefault('capabilities', {})\n+ if not isinstance(capabilities, dict):\n+ bridge.write_control(command='init', problem='protocol-error', message='capabilities must be a dict')\n+ return\n+ assert isinstance(capabilities, dict) # convince mypy\n+ capabilities['explicit-superuser'] = True\n+\n+ # only patch the packages line if we are in beiboot mode\n+ if bridge.packages:\n+ message['packages'] = dict.fromkeys(bridge.packages.packages)\n+\n+ bridge.write_control(message)\n+ bridge.ssh_peer.thaw_endpoint()\n+ except ferny.InteractionError as exc:\n+ error = ferny.ssh_errors.get_exception_for_ssh_stderr(str(exc))\n+ logger.debug(\"ferny.InteractionError: %s, interpreted as: %r\", exc, error)\n+ if isinstance(error, ferny.SshAuthenticationError):\n+ problem = 'authentication-failed'\n+ elif isinstance(error, ferny.SshHostKeyError):\n+ problem = 'unknown-hostkey'\n+ elif isinstance(error, OSError):\n+ # usually DNS/socket errors\n+ problem = 'unknown-host'\n+ else:\n+ problem = 'internal-error'\n+ bridge.write_control(command='init', problem=problem, message=str(error))\n+ return\n+ except CockpitProblem as exc:\n+ logger.debug(\"CockpitProblem: %s\", exc)\n+ bridge.write_control(exc.attrs, command='init')\n+ return\n \n+ logger.debug('Startup done. Looping until connection closes.')\n try:\n- await router.communicate()\n- except (BrokenPipeError, ConnectionResetError):\n- # not unexpected if the peer doesn't hang up cleanly\n+ await bridge.communicate()\n+ except BrokenPipeError:\n+ # expected if the peer doesn't hang up cleanly\n pass\n \n \n-def try_to_receive_stderr():\n- try:\n- ours, theirs = socket.socketpair()\n- with ours:\n- with theirs:\n- interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()])\n- _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1)\n- except OSError:\n- return\n+def main() -> None:\n+ polyfills.install()\n \n- try:\n- stderr_fd, = fds\n- # We're about to abruptly drop our end of the stderr socketpair that we\n- # share with the ferny agent. ferny would normally treat that as an\n- # unexpected error. Instruct it to do a clean exit, instead.\n- interaction_client.command(2, 'ferny.end')\n- os.dup2(stderr_fd, 2)\n- finally:\n- for fd in fds:\n- os.close(fd)\n+ parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')\n+ parser.add_argument('--always', action='store_true', help=\"Never try to run cockpit-bridge from the system\")\n+ parser.add_argument('--debug', action='store_true')\n+ parser.add_argument('destination', help=\"Name of the remote host to connect to, or 'localhost'\")\n+ args = parser.parse_args()\n \n+ setup_logging(debug=args.debug)\n \n-def setup_journald() -> bool:\n- # If stderr is a socket, prefer systemd-journal logging. This covers the\n- # case we're already connected to the journal but also the case where we're\n- # talking to the ferny agent, while leaving logging to file or terminal\n- # unaffected.\n- if not stat.S_ISSOCK(os.fstat(2).st_mode):\n- # not a socket? Don't redirect.\n- return False\n+ asyncio.run(run(args), debug=args.debug)\n \n- try:\n- import systemd.journal # type: ignore[import]\n- except ImportError:\n- # No python3-systemd? Don't redirect.\n- return False\n \n- logging.root.addHandler(systemd.journal.JournalHandler())\n- return True\n+if __name__ == '__main__':\n+ main()\n+''',\n+ 'cockpit/__init__.py': br'''from ._version import __version__\n \n+__all__ = (\n+ '__version__',\n+)\n+''',\n+ 'cockpit/protocol.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2022 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n-def setup_logging(*, debug: bool) -> None:\n- \"\"\"Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set\"\"\"\n+import asyncio\n+import json\n+import logging\n+import traceback\n+import uuid\n \n- modules = os.getenv('COCKPIT_DEBUG', '')\n+from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_int, get_str, get_str_or_none, typechecked\n \n- # Either setup logging via journal or via formatted messages to stderr\n- if not setup_journald():\n- logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s')\n+logger = logging.getLogger(__name__)\n \n- if debug or modules == 'all':\n- logging.getLogger().setLevel(level=logging.DEBUG)\n- elif modules:\n- for module in modules.split(','):\n- module = module.strip()\n- if not module:\n- continue\n \n- logging.getLogger(module).setLevel(logging.DEBUG)\n+class CockpitProblem(Exception):\n+ \"\"\"A type of exception that carries a problem code and a message.\n \n+ Depending on the scope, this is used to handle shutting down:\n \n-def start_ssh_agent() -> None:\n- # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust,\n- # but it gets cleared on setgid ssh-agent, which some distros still do\n- try:\n- proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'],\n- stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)\n- assert proc.stdout is not None\n+ - an individual channel (sends problem code in the close message)\n+ - peer connections (sends problem code in close message for each open channel)\n+ - the main stdio interaction with the bridge\n \n- # Wait for the agent to write at least one line and look for the\n- # listener socket. If we fail to find it, kill the agent \u2014 something\n- # went wrong.\n- for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True):\n- if token.startswith('SSH_AUTH_SOCK='):\n- os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1)\n- break\n+ It is usually thrown in response to some violation of expected protocol\n+ when parsing messages, connecting to a peer, or opening a channel.\n+ \"\"\"\n+ attrs: JsonObject\n+\n+ def __init__(self, problem: str, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n+ kwargs['problem'] = problem\n+ self.attrs = create_object(_msg, kwargs)\n+ super().__init__(get_str(self.attrs, 'message', problem))\n+\n+ def get_attrs(self) -> JsonObject:\n+ if self.attrs['problem'] == 'internal-error' and self.__cause__ is not None:\n+ return dict(self.attrs, cause=traceback.format_exception(\n+ self.__cause__.__class__, self.__cause__, self.__cause__.__traceback__\n+ ))\n else:\n- proc.terminate()\n- proc.wait()\n+ return self.attrs\n \n- except FileNotFoundError:\n- logger.debug(\"Couldn't start ssh-agent (FileNotFoundError)\")\n \n- except OSError as exc:\n- logger.warning(\"Could not start ssh-agent: %s\", exc)\n+class CockpitProtocolError(CockpitProblem):\n+ def __init__(self, message: str, problem: str = 'protocol-error'):\n+ super().__init__(problem, message=message)\n \n \n-def main(*, beipack: bool = False) -> None:\n- polyfills.install()\n+class CockpitProtocol(asyncio.Protocol):\n+ \"\"\"A naive implementation of the Cockpit frame protocol\n \n- parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')\n- parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge')\n- parser.add_argument('--packages', action='store_true', help='Show Cockpit package information')\n- parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information')\n- parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)')\n- parser.add_argument('--version', action='store_true', help='Show Cockpit version information')\n- args = parser.parse_args()\n+ We need to use this because Python's SelectorEventLoop doesn't supported\n+ buffered protocols.\n+ \"\"\"\n+ transport: 'asyncio.Transport | None' = None\n+ buffer = b''\n+ _closed: bool = False\n+ _communication_done: 'asyncio.Future[None] | None' = None\n \n- # This is determined by who calls us\n- args.beipack = beipack\n+ def do_ready(self) -> None:\n+ pass\n \n- # If we were run with --privileged then our stderr is currently being\n- # consumed by the main bridge looking for startup-related error messages.\n- # Let's switch back to the original stderr stream, which has a side-effect\n- # of indicating that our startup is more or less complete. Any errors\n- # after this point will land in the journal.\n- if args.privileged:\n- try_to_receive_stderr()\n+ def do_closed(self, exc: 'Exception | None') -> None:\n+ pass\n \n- setup_logging(debug=args.debug)\n+ def transport_control_received(self, command: str, message: JsonObject) -> None:\n+ raise NotImplementedError\n \n- # Special modes\n- if args.packages:\n- Packages().show()\n- return\n- elif args.version:\n- print(f'Version: {__version__}\\nProtocol: 1')\n- return\n- elif args.bridges:\n- print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2))\n- return\n+ def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:\n+ raise NotImplementedError\n \n- # The privileged bridge doesn't need ssh-agent, but the main one does\n- if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged:\n- start_ssh_agent()\n+ def channel_data_received(self, channel: str, data: bytes) -> None:\n+ raise NotImplementedError\n \n- # asyncio.run() shim for Python 3.6 support\n- run_async(run(args), debug=args.debug)\n+ def frame_received(self, frame: bytes) -> None:\n+ header, _, data = frame.partition(b'\\n')\n+\n+ if header != b'':\n+ channel = header.decode('ascii')\n+ logger.debug('data received: %d bytes of data for channel %s', len(data), channel)\n+ self.channel_data_received(channel, data)\n \n+ else:\n+ self.control_received(data)\n \n-if __name__ == '__main__':\n- main()\n-'''.encode('utf-8'),\n+ def control_received(self, data: bytes) -> None:\n+ try:\n+ message = typechecked(json.loads(data), dict)\n+ command = get_str(message, 'command')\n+ channel = get_str(message, 'channel', None)\n+\n+ if channel is not None:\n+ logger.debug('channel control received %s', message)\n+ self.channel_control_received(channel, command, message)\n+ else:\n+ logger.debug('transport control received %s', message)\n+ self.transport_control_received(command, message)\n+\n+ except (json.JSONDecodeError, JsonError) as exc:\n+ raise CockpitProtocolError(f'control message: {exc!s}') from exc\n+\n+ def consume_one_frame(self, data: bytes) -> int:\n+ \"\"\"Consumes a single frame from view.\n+\n+ Returns positive if a number of bytes were consumed, or negative if no\n+ work can be done because of a given number of bytes missing.\n+ \"\"\"\n+\n+ try:\n+ newline = data.index(b'\\n')\n+ except ValueError as exc:\n+ if len(data) < 10:\n+ # Let's try reading more\n+ return len(data) - 10\n+ raise CockpitProtocolError(\"size line is too long\") from exc\n+\n+ try:\n+ length = int(data[:newline])\n+ except ValueError as exc:\n+ raise CockpitProtocolError(\"frame size is not an integer\") from exc\n+\n+ start = newline + 1\n+ end = start + length\n+\n+ if end > len(data):\n+ # We need to read more\n+ return len(data) - end\n+\n+ # We can consume a full frame\n+ self.frame_received(data[start:end])\n+ return end\n+\n+ def connection_made(self, transport: asyncio.BaseTransport) -> None:\n+ logger.debug('connection_made(%s)', transport)\n+ assert isinstance(transport, asyncio.Transport)\n+ self.transport = transport\n+ self.do_ready()\n+\n+ if self._closed:\n+ logger.debug(' but the protocol already was closed, so closing transport')\n+ transport.close()\n+\n+ def connection_lost(self, exc: 'Exception | None') -> None:\n+ logger.debug('connection_lost')\n+ assert self.transport is not None\n+ self.transport = None\n+ self.close(exc)\n+\n+ def close(self, exc: 'Exception | None' = None) -> None:\n+ if self._closed:\n+ return\n+ self._closed = True\n+\n+ if self.transport:\n+ self.transport.close()\n+\n+ self.do_closed(exc)\n+\n+ def write_channel_data(self, channel: str, payload: bytes) -> None:\n+ \"\"\"Send a given payload (bytes) on channel (string)\"\"\"\n+ # Channel is certainly ascii (as enforced by .encode() below)\n+ frame_length = len(channel + '\\n') + len(payload)\n+ header = f'{frame_length}\\n{channel}\\n'.encode('ascii')\n+ if self.transport is not None:\n+ logger.debug('writing to transport %s', self.transport)\n+ self.transport.write(header + payload)\n+ else:\n+ logger.debug('cannot write to closed transport')\n+\n+ def write_control(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n+ \"\"\"Write a control message. See jsonutil.create_object() for details.\"\"\"\n+ logger.debug('sending control message %r %r', _msg, kwargs)\n+ pretty = json.dumps(create_object(_msg, kwargs), indent=2) + '\\n'\n+ self.write_channel_data('', pretty.encode())\n+\n+ def data_received(self, data: bytes) -> None:\n+ try:\n+ self.buffer += data\n+ while self.buffer:\n+ result = self.consume_one_frame(self.buffer)\n+ if result <= 0:\n+ return\n+ self.buffer = self.buffer[result:]\n+ except CockpitProtocolError as exc:\n+ self.close(exc)\n+\n+ def eof_received(self) -> bool:\n+ return False\n+\n+\n+# Helpful functionality for \"server\"-side protocol implementations\n+class CockpitProtocolServer(CockpitProtocol):\n+ init_host: 'str | None' = None\n+ authorizations: 'dict[str, asyncio.Future[str]] | None' = None\n+\n+ def do_send_init(self) -> None:\n+ raise NotImplementedError\n+\n+ def do_init(self, message: JsonObject) -> None:\n+ pass\n+\n+ def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n+ raise NotImplementedError\n+\n+ def transport_control_received(self, command: str, message: JsonObject) -> None:\n+ if command == 'init':\n+ if get_int(message, 'version') != 1:\n+ raise CockpitProtocolError('incorrect version number')\n+ self.init_host = get_str(message, 'host')\n+ self.do_init(message)\n+ elif command == 'kill':\n+ self.do_kill(get_str_or_none(message, 'host', None), get_str_or_none(message, 'group', None), message)\n+ elif command == 'authorize':\n+ self.do_authorize(message)\n+ else:\n+ raise CockpitProtocolError(f'unexpected control message {command} received')\n+\n+ def do_ready(self) -> None:\n+ self.do_send_init()\n+\n+ # authorize request/response API\n+ async def request_authorization(\n+ self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue\n+ ) -> str:\n+ if self.authorizations is None:\n+ self.authorizations = {}\n+ cookie = str(uuid.uuid4())\n+ future = asyncio.get_running_loop().create_future()\n+ try:\n+ self.authorizations[cookie] = future\n+ self.write_control(None, command='authorize', challenge=challenge, cookie=cookie, **kwargs)\n+ return await asyncio.wait_for(future, timeout)\n+ finally:\n+ self.authorizations.pop(cookie)\n+\n+ def do_authorize(self, message: JsonObject) -> None:\n+ cookie = get_str(message, 'cookie')\n+ response = get_str(message, 'response')\n+\n+ if self.authorizations is None or cookie not in self.authorizations:\n+ logger.warning('no matching authorize request')\n+ return\n+\n+ self.authorizations[cookie].set_result(response)\n+''',\n 'cockpit/transports.py': br'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n@@ -1851,837 +2564,14 @@\n self._loop.remove_reader(self._fd)\n os.close(self._fd)\n self._fd = -1\n \n def __del__(self) -> None:\n self.close()\n ''',\n- 'cockpit/beipack.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2023 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import logging\n-import lzma\n-from typing import List, Sequence, Tuple\n-\n-from cockpit._vendor import ferny\n-from cockpit._vendor.bei import beipack\n-\n-from .data import read_cockpit_data_file\n-from .peer import Peer, PeerError\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-def get_bridge_beipack_xz() -> Tuple[str, bytes]:\n- try:\n- bridge_beipack_xz = read_cockpit_data_file('cockpit-bridge.beipack.xz')\n- logger.debug('Got pre-built cockpit-bridge.beipack.xz')\n- except FileNotFoundError:\n- logger.debug('Pre-built cockpit-bridge.beipack.xz; building our own.')\n- # beipack ourselves\n- cockpit_contents = beipack.collect_module('cockpit', recursive=True)\n- bridge_beipack = beipack.pack(cockpit_contents, entrypoint='cockpit.bridge:main', args='beipack=True')\n- bridge_beipack_xz = lzma.compress(bridge_beipack.encode())\n- logger.debug(' ... done!')\n-\n- return 'cockpit/data/cockpit-bridge.beipack.xz', bridge_beipack_xz\n-\n-\n-class BridgeBeibootHelper(ferny.InteractionHandler):\n- # ferny.InteractionHandler ClassVar\n- commands = ['beiboot.provide', 'beiboot.exc']\n-\n- peer: Peer\n- payload: bytes\n- steps: Sequence[Tuple[str, Sequence[object]]]\n-\n- def __init__(self, peer: Peer, args: Sequence[str] = ()) -> None:\n- filename, payload = get_bridge_beipack_xz()\n-\n- self.peer = peer\n- self.payload = payload\n- self.steps = (('boot_xz', (filename, len(payload), tuple(args))),)\n-\n- async def run_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None:\n- logger.debug('Got ferny request %s %s %s %s', command, args, fds, stderr)\n- if command == 'beiboot.provide':\n- try:\n- size, = args\n- assert size == len(self.payload)\n- except (AssertionError, ValueError) as exc:\n- raise PeerError('internal-error', message=f'ferny interaction error {exc!s}') from exc\n-\n- assert self.peer.transport is not None\n- logger.debug('Writing %d bytes of payload', len(self.payload))\n- self.peer.transport.write(self.payload)\n- elif command == 'beiboot.exc':\n- raise PeerError('internal-error', message=f'Remote exception: {args[0]}')\n- else:\n- raise PeerError('internal-error', message=f'Unexpected ferny interaction command {command}')\n-''',\n- 'cockpit/peer.py': r'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2022 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import asyncio\n-import logging\n-import os\n-from typing import Callable, List, Optional, Sequence\n-\n-from .jsonutil import JsonObject, JsonValue\n-from .packages import BridgeConfig\n-from .protocol import CockpitProblem, CockpitProtocol, CockpitProtocolError\n-from .router import Endpoint, Router, RoutingRule\n-from .transports import SubprocessProtocol, SubprocessTransport\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-class PeerError(CockpitProblem):\n- pass\n-\n-\n-class PeerExited(Exception):\n- def __init__(self, exit_code: int):\n- self.exit_code = exit_code\n-\n-\n-class Peer(CockpitProtocol, SubprocessProtocol, Endpoint):\n- done_callbacks: List[Callable[[], None]]\n- init_future: Optional[asyncio.Future]\n-\n- def __init__(self, router: Router):\n- super().__init__(router)\n-\n- # All Peers start out frozen \u2014 we only unfreeze after we see the first 'init' message\n- self.freeze_endpoint()\n-\n- self.init_future = asyncio.get_running_loop().create_future()\n- self.done_callbacks = []\n-\n- # Initialization\n- async def do_connect_transport(self) -> None:\n- raise NotImplementedError\n-\n- async def spawn(self, argv: Sequence[str], env: Sequence[str], **kwargs) -> asyncio.Transport:\n- # Not actually async...\n- loop = asyncio.get_running_loop()\n- user_env = dict(e.split('=', 1) for e in env)\n- return SubprocessTransport(loop, self, argv, env=dict(os.environ, **user_env), **kwargs)\n-\n- async def start(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> JsonObject:\n- \"\"\"Request that the Peer is started and connected to the router.\n-\n- Creates the transport, connects it to the protocol, and participates in\n- exchanging of init messages. If anything goes wrong, the connection\n- will be closed and an exception will be raised.\n-\n- The Peer starts out in a frozen state (ie: attempts to send messages to\n- it will initially be queued). If init_host is not None then an init\n- message is sent with the given 'host' field, plus any extra kwargs, and\n- the queue is thawed. Otherwise, the caller is responsible for sending\n- the init message and thawing the peer.\n-\n- In any case, the return value is the init message from the peer.\n- \"\"\"\n- assert self.init_future is not None\n-\n- def _connect_task_done(task: asyncio.Task) -> None:\n- assert task is connect_task\n- try:\n- task.result()\n- except asyncio.CancelledError: # we did that (below)\n- pass # we want to ignore it\n- except Exception as exc:\n- self.close(exc)\n-\n- connect_task = asyncio.create_task(self.do_connect_transport())\n- connect_task.add_done_callback(_connect_task_done)\n-\n- try:\n- # Wait for something to happen:\n- # - exception from our connection function\n- # - receiving \"init\" from the other side\n- # - receiving EOF from the other side\n- # - .close() was called\n- # - other transport exception\n- init_message = await self.init_future\n-\n- except (PeerExited, BrokenPipeError):\n- # These are fairly generic errors. PeerExited means that we observed the process exiting.\n- # BrokenPipeError means that we got EPIPE when attempting to write() to it. In both cases,\n- # the process is gone, but it's not clear why. If the connection process is still running,\n- # perhaps we'd get a better error message from it.\n- await connect_task\n- # Otherwise, re-raise\n- raise\n-\n- finally:\n- self.init_future = None\n-\n- # In any case (failure or success) make sure this is done.\n- if not connect_task.done():\n- connect_task.cancel()\n-\n- if init_host is not None:\n- logger.debug(' sending init message back, host %s', init_host)\n- # Send \"init\" back\n- self.write_control(None, command='init', version=1, host=init_host, **kwargs)\n-\n- # Thaw the queued messages\n- self.thaw_endpoint()\n-\n- return init_message\n-\n- # Background initialization\n- def start_in_background(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> None:\n- def _start_task_done(task: asyncio.Task) -> None:\n- assert task is start_task\n-\n- try:\n- task.result()\n- except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError):\n- pass # Those are expected. Others will throw.\n-\n- start_task = asyncio.create_task(self.start(init_host, **kwargs))\n- start_task.add_done_callback(_start_task_done)\n-\n- # Shutdown\n- def add_done_callback(self, callback: Callable[[], None]) -> None:\n- self.done_callbacks.append(callback)\n-\n- # Handling of interesting events\n- def do_superuser_init_done(self) -> None:\n- pass\n-\n- def do_authorize(self, message: JsonObject) -> None:\n- pass\n-\n- def transport_control_received(self, command: str, message: JsonObject) -> None:\n- if command == 'init' and self.init_future is not None:\n- logger.debug('Got init message with active init_future. Setting result.')\n- self.init_future.set_result(message)\n- elif command == 'authorize':\n- self.do_authorize(message)\n- elif command == 'superuser-init-done':\n- self.do_superuser_init_done()\n- else:\n- raise CockpitProtocolError(f'Received unexpected control message {command}')\n-\n- def eof_received(self) -> bool:\n- # We always expect to be the ones to close the connection, so if we get\n- # an EOF, then we consider it to be an error. This allows us to\n- # distinguish close caused by unexpected EOF (but no errno from a\n- # syscall failure) vs. close caused by calling .close() on our side.\n- # The process is still running at this point, so keep it and handle\n- # the error in process_exited().\n- logger.debug('Peer %s received unexpected EOF', self.__class__.__name__)\n- return True\n-\n- def do_closed(self, exc: Optional[Exception]) -> None:\n- logger.debug('Peer %s connection lost %s %s', self.__class__.__name__, type(exc), exc)\n-\n- if exc is None:\n- self.shutdown_endpoint(problem='terminated')\n- elif isinstance(exc, PeerExited):\n- # a common case is that the called peer does not exist\n- if exc.exit_code == 127:\n- self.shutdown_endpoint(problem='no-cockpit')\n- else:\n- self.shutdown_endpoint(problem='terminated', message=f'Peer exited with status {exc.exit_code}')\n- elif isinstance(exc, CockpitProblem):\n- self.shutdown_endpoint(exc.attrs)\n- else:\n- self.shutdown_endpoint(problem='internal-error',\n- message=f\"[{exc.__class__.__name__}] {exc!s}\")\n-\n- # If .start() is running, we need to make sure it stops running,\n- # raising the correct exception.\n- if self.init_future is not None and not self.init_future.done():\n- if exc is not None:\n- self.init_future.set_exception(exc)\n- else:\n- self.init_future.cancel()\n-\n- for callback in self.done_callbacks:\n- callback()\n-\n- def process_exited(self) -> None:\n- assert isinstance(self.transport, SubprocessTransport)\n- logger.debug('Peer %s exited, status %d', self.__class__.__name__, self.transport.get_returncode())\n- returncode = self.transport.get_returncode()\n- assert isinstance(returncode, int)\n- self.close(PeerExited(returncode))\n-\n- # Forwarding data: from the peer to the router\n- def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:\n- if self.init_future is not None:\n- raise CockpitProtocolError('Received unexpected channel control message before init')\n- self.send_channel_control(channel, command, message)\n-\n- def channel_data_received(self, channel: str, data: bytes) -> None:\n- if self.init_future is not None:\n- raise CockpitProtocolError('Received unexpected channel data before init')\n- self.send_channel_data(channel, data)\n-\n- # Forwarding data: from the router to the peer\n- def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:\n- assert self.init_future is None\n- self.write_control(message)\n-\n- def do_channel_data(self, channel: str, data: bytes) -> None:\n- assert self.init_future is None\n- self.write_channel_data(channel, data)\n-\n- def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n- assert self.init_future is None\n- self.write_control(message)\n-\n- def do_close(self) -> None:\n- self.close()\n-\n-\n-class ConfiguredPeer(Peer):\n- config: BridgeConfig\n- args: Sequence[str]\n- env: Sequence[str]\n-\n- def __init__(self, router: Router, config: BridgeConfig):\n- self.config = config\n- self.args = config.spawn\n- self.env = config.environ\n- super().__init__(router)\n-\n- async def do_connect_transport(self) -> None:\n- await self.spawn(self.args, self.env)\n-\n-\n-class PeerRoutingRule(RoutingRule):\n- config: BridgeConfig\n- match: JsonObject\n- peer: Optional[Peer]\n-\n- def __init__(self, router: Router, config: BridgeConfig):\n- super().__init__(router)\n- self.config = config\n- self.match = config.match\n- self.peer = None\n-\n- def apply_rule(self, options: JsonObject) -> Optional[Peer]:\n- # Check that we match\n-\n- for key, value in self.match.items():\n- if key not in options:\n- logger.debug(' rejecting because key %s is missing', key)\n- return None\n- if value is not None and options[key] != value:\n- logger.debug(' rejecting because key %s has wrong value %s (vs %s)', key, options[key], value)\n- return None\n-\n- # Start the peer if it's not running already\n- if self.peer is None:\n- self.peer = ConfiguredPeer(self.router, self.config)\n- self.peer.add_done_callback(self.peer_closed)\n- assert self.router.init_host\n- self.peer.start_in_background(init_host=self.router.init_host)\n-\n- return self.peer\n-\n- def peer_closed(self):\n- self.peer = None\n-\n- def shutdown(self):\n- if self.peer is not None:\n- self.peer.close()\n-\n-\n-class PeersRoutingRule(RoutingRule):\n- rules: List[PeerRoutingRule] = []\n-\n- def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:\n- logger.debug(' considering %d rules', len(self.rules))\n- for rule in self.rules:\n- logger.debug(' considering %s', rule.config.name)\n- endpoint = rule.apply_rule(options)\n- if endpoint is not None:\n- logger.debug(' selected')\n- return endpoint\n- logger.debug(' no peer rules matched')\n- return None\n-\n- def set_configs(self, bridge_configs: Sequence[BridgeConfig]) -> None:\n- old_rules = self.rules\n- self.rules = []\n-\n- for config in bridge_configs:\n- # Those are handled elsewhere...\n- if config.privileged or 'host' in config.match:\n- continue\n-\n- # Try to reuse an existing rule, if one exists...\n- for rule in list(old_rules):\n- if rule.config == config:\n- old_rules.remove(rule)\n- break\n- else:\n- # ... otherwise, create a new one.\n- rule = PeerRoutingRule(self.router, config)\n-\n- self.rules.append(rule)\n-\n- # close down the old rules that didn't get reclaimed\n- for rule in old_rules:\n- rule.shutdown()\n-\n- def shutdown(self):\n- for rule in self.rules:\n- rule.shutdown()\n-'''.encode('utf-8'),\n- 'cockpit/internal_endpoints.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2022 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import asyncio\n-import glob\n-import grp\n-import json\n-import logging\n-import os\n-import pwd\n-from pathlib import Path\n-from typing import Dict, Optional\n-\n-from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch\n-\n-from . import config\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-class cockpit_LoginMessages(bus.Object):\n- messages: Optional[str] = None\n-\n- def __init__(self):\n- fdstr = os.environ.pop('COCKPIT_LOGIN_MESSAGES_MEMFD', None)\n- if fdstr is None:\n- logger.debug(\"COCKPIT_LOGIN_MESSAGES_MEMFD wasn't set. No login messages today.\")\n- return\n-\n- logger.debug(\"Trying to read login messages from fd %s\", fdstr)\n- try:\n- with open(int(fdstr), 'r') as login_messages:\n- login_messages.seek(0)\n- self.messages = login_messages.read()\n- except (ValueError, OSError, UnicodeDecodeError) as exc:\n- # ValueError - the envvar wasn't an int\n- # OSError - the fd wasn't open, or other read failure\n- # UnicodeDecodeError - didn't contain utf-8\n- # For all of these, we simply failed to get the message.\n- logger.debug(\"Reading login messages failed: %s\", exc)\n- else:\n- logger.debug(\"Successfully read login messages: %s\", self.messages)\n-\n- @bus.Interface.Method(out_types=['s'])\n- def get(self):\n- return self.messages or '{}'\n-\n- @bus.Interface.Method(out_types=[])\n- def dismiss(self):\n- self.messages = None\n-\n-\n-class cockpit_Machines(bus.Object):\n- path: Path\n- watch: pathwatch.PathWatch\n- pending_notify: Optional[asyncio.Handle]\n-\n- # D-Bus implementation\n- machines = bus.Interface.Property('a{sa{sv}}')\n-\n- @machines.getter\n- def get_machines(self) -> Dict[str, Dict[str, Variant]]:\n- results: Dict[str, Dict[str, Variant]] = {}\n-\n- for filename in glob.glob(f'{self.path}/*.json'):\n- with open(filename) as fp:\n- try:\n- contents = json.load(fp)\n- except json.JSONDecodeError:\n- logger.warning('Invalid JSON in file %s. Ignoring.', filename)\n- continue\n- # merge\n- for hostname, attrs in contents.items():\n- results[hostname] = {key: Variant(value) for key, value in attrs.items()}\n-\n- return results\n-\n- @bus.Interface.Method(in_types=['s', 's', 'a{sv}'])\n- def update(self, filename: str, hostname: str, attrs: Dict[str, Variant]) -> None:\n- try:\n- with self.path.joinpath(filename).open() as fp:\n- contents = json.load(fp)\n- except json.JSONDecodeError as exc:\n- # Refuse to replace corrupted file\n- raise bus.BusError('cockpit.Machines.Error', f'File {filename} is in invalid format: {exc}.') from exc\n- except FileNotFoundError:\n- # But an empty file is an expected case\n- contents = {}\n-\n- contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()})\n-\n- self.path.mkdir(parents=True, exist_ok=True)\n- with open(self.path.joinpath(filename), 'w') as fp:\n- json.dump(contents, fp, indent=2)\n-\n- def notify(self):\n- def _notify_now():\n- self.properties_changed('cockpit.Machines', {}, ['Machines'])\n- self.pending_notify = None\n-\n- # avoid a flurry of update notifications\n- if self.pending_notify is None:\n- self.pending_notify = asyncio.get_running_loop().call_later(1.0, _notify_now)\n-\n- # inotify events\n- def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None:\n- self.notify()\n-\n- def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None:\n- self.notify()\n-\n- def __init__(self):\n- self.path = config.lookup_config('machines.d')\n-\n- # ignore the first callback\n- self.pending_notify = ...\n- self.watch = pathwatch.PathWatch(str(self.path), self)\n- self.pending_notify = None\n-\n-\n-class cockpit_User(bus.Object):\n- name = bus.Interface.Property('s', value='')\n- full = bus.Interface.Property('s', value='')\n- id = bus.Interface.Property('i', value=0)\n- home = bus.Interface.Property('s', value='')\n- shell = bus.Interface.Property('s', value='')\n- groups = bus.Interface.Property('as', value=[])\n-\n- def __init__(self):\n- user = pwd.getpwuid(os.getuid())\n- self.name = user.pw_name\n- self.full = user.pw_gecos\n- self.id = user.pw_uid\n- self.home = user.pw_dir\n- self.shell = user.pw_shell\n- self.groups = [gr.gr_name for gr in grp.getgrall() if user.pw_name in gr.gr_mem]\n-\n-\n-EXPORTS = [\n- ('/LoginMessages', cockpit_LoginMessages),\n- ('/machines', cockpit_Machines),\n- ('/user', cockpit_User),\n-]\n-''',\n- 'cockpit/polyfills.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2023 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import contextlib\n-import socket\n-\n-\n-def install():\n- \"\"\"Add shims for older Python versions\"\"\"\n-\n- # introduced in 3.9\n- if not hasattr(socket, 'recv_fds'):\n- import array\n-\n- import _socket\n-\n- def recv_fds(sock, bufsize, maxfds, flags=0):\n- fds = array.array(\"i\")\n- msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize))\n- for cmsg_level, cmsg_type, cmsg_data in ancdata:\n- if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS):\n- fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])\n- return msg, list(fds), flags, addr\n-\n- socket.recv_fds = recv_fds\n-\n- # introduced in 3.7\n- if not hasattr(contextlib, 'AsyncExitStack'):\n- class AsyncExitStack:\n- async def __aenter__(self):\n- self.cms = []\n- return self\n-\n- async def enter_async_context(self, cm):\n- result = await cm.__aenter__()\n- self.cms.append(cm)\n- return result\n-\n- async def __aexit__(self, exc_type, exc_value, traceback):\n- for cm in self.cms:\n- cm.__aexit__(exc_type, exc_value, traceback)\n-\n- contextlib.AsyncExitStack = AsyncExitStack\n-''',\n- 'cockpit/jsonutil.py': r'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2023 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-from enum import Enum\n-from typing import Callable, Container, Dict, List, Mapping, Optional, Sequence, Type, TypeVar, Union\n-\n-JsonLiteral = Union[str, float, bool, None]\n-\n-# immutable\n-JsonValue = Union['JsonObject', Sequence['JsonValue'], JsonLiteral]\n-JsonObject = Mapping[str, JsonValue]\n-\n-# mutable\n-JsonDocument = Union['JsonDict', 'JsonList', JsonLiteral]\n-JsonDict = Dict[str, JsonDocument]\n-JsonList = List[JsonDocument]\n-\n-\n-DT = TypeVar('DT')\n-T = TypeVar('T')\n-\n-\n-class JsonError(Exception):\n- value: object\n-\n- def __init__(self, value: object, msg: str):\n- super().__init__(msg)\n- self.value = value\n-\n-\n-def typechecked(value: JsonValue, expected_type: Type[T]) -> T:\n- \"\"\"Ensure a JSON value has the expected type, returning it if so.\"\"\"\n- if not isinstance(value, expected_type):\n- raise JsonError(value, f'must have type {expected_type.__name__}')\n- return value\n-\n-\n-# We can't use None as a sentinel because it's often the actual default value\n-# EllipsisType is difficult because it's not available before 3.10.\n-# See https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions\n-class _Empty(Enum):\n- TOKEN = 0\n-\n-\n-_empty = _Empty.TOKEN\n-\n-\n-def _get(obj: JsonObject, cast: Callable[[JsonValue], T], key: str, default: Union[DT, _Empty]) -> Union[T, DT]:\n- try:\n- return cast(obj[key])\n- except KeyError:\n- if default is not _empty:\n- return default\n- raise JsonError(obj, f\"attribute '{key}' required\") from None\n- except JsonError as exc:\n- target = f\"attribute '{key}'\" + (' elements:' if exc.value is not obj[key] else ':')\n- raise JsonError(obj, f\"{target} {exc!s}\") from exc\n-\n-\n-def get_bool(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, bool]:\n- return _get(obj, lambda v: typechecked(v, bool), key, default)\n-\n-\n-def get_int(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, int]:\n- return _get(obj, lambda v: typechecked(v, int), key, default)\n-\n-\n-def get_str(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, str]:\n- return _get(obj, lambda v: typechecked(v, str), key, default)\n-\n-\n-def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Optional[str]:\n- return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default)\n-\n-\n-def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]:\n- return _get(obj, lambda v: typechecked(v, dict), key, default)\n-\n-\n-def get_object(\n- obj: JsonObject,\n- key: str,\n- constructor: Callable[[JsonObject], T],\n- default: Union[DT, _Empty] = _empty\n-) -> Union[DT, T]:\n- return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default)\n-\n-\n-def get_strv(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, Sequence[str]]:\n- def as_strv(value: JsonValue) -> Sequence[str]:\n- return tuple(typechecked(item, str) for item in typechecked(value, list))\n- return _get(obj, as_strv, key, default)\n-\n-\n-def get_enum(\n- obj: JsonObject, key: str, choices: Container[str], default: Union[DT, _Empty] = _empty\n-) -> Union[DT, str]:\n- def as_choice(value: JsonValue) -> str:\n- # mypy can't do `__eq__()`-based type narrowing...\n- # https://github.com/python/mypy/issues/17101\n- if isinstance(value, str) and value in choices:\n- return value\n- raise JsonError(value, f'invalid value \"{value}\" not in {choices}')\n- return _get(obj, as_choice, key, default)\n-\n-\n-def get_objv(obj: JsonObject, key: str, constructor: Callable[[JsonObject], T]) -> Union[DT, Sequence[T]]:\n- def as_objv(value: JsonValue) -> Sequence[T]:\n- return tuple(constructor(typechecked(item, dict)) for item in typechecked(value, list))\n- return _get(obj, as_objv, key, ())\n-\n-\n-def create_object(message: 'JsonObject | None', kwargs: JsonObject) -> JsonObject:\n- \"\"\"Constructs a JSON object based on message and kwargs.\n-\n- If only message is given, it is returned, unmodified. If message is None,\n- it is equivalent to an empty dictionary. A copy is always made.\n-\n- If kwargs are present, then any underscore ('_') present in a key name is\n- rewritten to a dash ('-'). This is intended to bridge between the required\n- Python syntax when providing kwargs and idiomatic JSON (which uses '-' for\n- attributes). These values override values in message.\n-\n- The idea is that `message` should be used for passing data along, and\n- kwargs used for data originating at a given call site, possibly including\n- modifications to an original message.\n- \"\"\"\n- result = dict(message or {})\n-\n- for key, value in kwargs.items():\n- # rewrite '_' (necessary in Python syntax kwargs list) to '-' (idiomatic JSON)\n- json_key = key.replace('_', '-')\n- result[json_key] = value\n-\n- return result\n-\n-\n-def json_merge_patch(current: JsonObject, patch: JsonObject) -> JsonObject:\n- \"\"\"Perform a JSON merge patch (RFC 7396) using 'current' and 'patch'.\n- Neither of the original dictionaries is modified \u2014 the result is returned.\n- \"\"\"\n- # Always take a copy ('result') \u2014 we never modify the input ('current')\n- result = dict(current)\n- for key, patch_value in patch.items():\n- if isinstance(patch_value, Mapping):\n- current_value = current.get(key, None)\n- if not isinstance(current_value, Mapping):\n- current_value = {}\n- result[key] = json_merge_patch(current_value, patch_value)\n- elif patch_value is not None:\n- result[key] = patch_value\n- else:\n- result.pop(key, None)\n-\n- return result\n-\n-\n-def json_merge_and_filter_patch(current: JsonDict, patch: JsonDict) -> None:\n- \"\"\"Perform a JSON merge patch (RFC 7396) modifying 'current' with 'patch'.\n- Also modifies 'patch' to remove redundant operations.\n- \"\"\"\n- for key, patch_value in tuple(patch.items()):\n- current_value = current.get(key, None)\n-\n- if isinstance(patch_value, dict):\n- if not isinstance(current_value, dict):\n- current[key] = current_value = {}\n- json_merge_and_filter_patch(current_value, patch_value)\n- else:\n- json_merge_and_filter_patch(current_value, patch_value)\n- if not patch_value:\n- del patch[key]\n- elif current_value == patch_value:\n- del patch[key]\n- elif patch_value is not None:\n- current[key] = patch_value\n- else:\n- del current[key]\n-'''.encode('utf-8'),\n- 'cockpit/__init__.py': br'''from ._version import __version__\n-\n-__all__ = (\n- '__version__',\n-)\n-''',\n 'cockpit/polkit.py': r'''# This file is part of Cockpit.\n #\n # Copyright (C) 2023 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n@@ -2846,15 +2736,15 @@\n 'org.freedesktop.PolicyKit1.Authority',\n 'UnregisterAuthenticationAgent',\n '(sa{sv})s',\n self.subject, AGENT_DBUS_PATH)\n self.agent_slot.cancel()\n logger.debug('Unregistered agent for %r', self.subject)\n '''.encode('utf-8'),\n- 'cockpit/beiboot.py': br'''# This file is part of Cockpit.\n+ 'cockpit/remote.py': r'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -2863,350 +2753,231 @@\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import argparse\n-import asyncio\n-import base64\n-import importlib.resources\n+import getpass\n import logging\n-import os\n-import shlex\n-from pathlib import Path\n-from typing import Dict, Iterable, Optional, Sequence\n+import re\n+import socket\n+from typing import Dict, List, Optional, Tuple\n \n-from cockpit import polyfills\n from cockpit._vendor import ferny\n-from cockpit._vendor.bei import bootloader\n-from cockpit.beipack import BridgeBeibootHelper\n-from cockpit.bridge import setup_logging\n-from cockpit.channel import ChannelRoutingRule\n-from cockpit.channels import PackagesChannel\n-from cockpit.jsonutil import JsonObject\n-from cockpit.packages import Packages, PackagesLoader, patch_libexecdir\n-from cockpit.peer import Peer\n-from cockpit.protocol import CockpitProblem\n-from cockpit.router import Router, RoutingRule\n-from cockpit.transports import StdioTransport\n-\n-logger = logging.getLogger('cockpit.beiboot')\n-\n-\n-def ensure_ferny_askpass() -> Path:\n- \"\"\"Create askpass executable\n-\n- We need this for the flatpak: ssh and thus the askpass program run on the host (via flatpak-spawn),\n- not the flatpak. Thus we cannot use the shipped cockpit-askpass program.\n- \"\"\"\n- src_path = importlib.resources.files(ferny.__name__) / 'interaction_client.py'\n- src_data = src_path.read_bytes()\n-\n- # Create the file in $XDG_CACHE_HOME, one of the few locations that a flatpak can write to\n- xdg_cache_home = os.environ.get('XDG_CACHE_HOME')\n- if xdg_cache_home is None:\n- xdg_cache_home = os.path.expanduser('~/.cache')\n- os.makedirs(xdg_cache_home, exist_ok=True)\n- dest_path = Path(xdg_cache_home, 'cockpit-client-askpass')\n-\n- logger.debug(\"Checking if %s exists...\", dest_path)\n-\n- # Check first to see if we already wrote the current version\n- try:\n- if dest_path.read_bytes() != src_data:\n- logger.debug(\" ... it exists but is not the same version...\")\n- raise ValueError\n- if not dest_path.stat().st_mode & 0o100:\n- logger.debug(\" ... it has the correct contents, but is not executable...\")\n- raise ValueError\n- except (FileNotFoundError, ValueError):\n- logger.debug(\" ... writing contents.\")\n- dest_path.write_bytes(src_data)\n- dest_path.chmod(0o700)\n-\n- return dest_path\n-\n-\n-def get_interesting_files() -> Iterable[str]:\n- for manifest in PackagesLoader.load_manifests():\n- for condition in manifest.conditions:\n- if condition.name in ('path-exists', 'path-not-exists') and isinstance(condition.value, str):\n- yield condition.value\n-\n-\n-class ProxyPackagesLoader(PackagesLoader):\n- file_status: Dict[str, bool]\n-\n- def check_condition(self, condition: str, value: object) -> bool:\n- assert isinstance(value, str)\n- assert value in self.file_status\n-\n- if condition == 'path-exists':\n- return self.file_status[value]\n- elif condition == 'path-not-exists':\n- return not self.file_status[value]\n- else:\n- raise KeyError\n-\n- def __init__(self, file_status: Dict[str, bool]):\n- self.file_status = file_status\n-\n \n-BEIBOOT_GADGETS = {\n- \"report_exists\": r\"\"\"\n- import os\n- def report_exists(files):\n- command('cockpit.report-exists', {name: os.path.exists(name) for name in files})\n- \"\"\",\n- **ferny.BEIBOOT_GADGETS\n-}\n+from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none\n+from .peer import Peer, PeerError\n+from .router import Router, RoutingRule\n \n+logger = logging.getLogger(__name__)\n \n-class DefaultRoutingRule(RoutingRule):\n- peer: 'Peer | None'\n \n- def __init__(self, router: Router):\n- super().__init__(router)\n+class PasswordResponder(ferny.AskpassHandler):\n+ PASSPHRASE_RE = re.compile(r\"Enter passphrase for key '(.*)': \")\n \n- def apply_rule(self, options: JsonObject) -> 'Peer | None':\n- return self.peer\n+ password: Optional[str]\n \n- def shutdown(self) -> None:\n- if self.peer is not None:\n- self.peer.close()\n+ hostkeys_seen: List[Tuple[str, str, str, str, str]]\n+ error_message: Optional[str]\n+ password_attempts: int\n \n+ def __init__(self, password: Optional[str]):\n+ self.password = password\n \n-class AuthorizeResponder(ferny.AskpassHandler):\n- commands = ('ferny.askpass', 'cockpit.report-exists')\n- router: Router\n+ self.hostkeys_seen = []\n+ self.error_message = None\n+ self.password_attempts = 0\n \n- def __init__(self, router: Router):\n- self.router = router\n+ async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:\n+ self.hostkeys_seen.append((reason, host, algorithm, key, fingerprint))\n+ return False\n \n async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:\n- if hint == 'none':\n- # We have three problems here:\n- #\n- # - we have no way to present a message on the login\n- # screen without presenting a prompt and a button\n- # - the login screen will not try to repost the login\n- # request because it doesn't understand that we are not\n- # waiting on input, which means that it won't notice\n- # that we've logged in successfully\n- # - cockpit-ws has an issue where if we retry the request\n- # again after login succeeded then it won't forward the\n- # init message to the client, stalling the login. This\n- # is a race and can't be fixed without -ws changes.\n- #\n- # Let's avoid all of that by just showing nothing.\n- return None\n-\n- challenge = 'X-Conversation - ' + base64.b64encode(prompt.encode()).decode()\n- response = await self.router.request_authorization(challenge,\n- messages=messages,\n- prompt=prompt,\n- hint=hint,\n- echo=False)\n-\n- b64 = response.removeprefix('X-Conversation -').strip()\n- response = base64.b64decode(b64.encode()).decode()\n- logger.debug('Returning a %d chars response', len(response))\n- return response\n+ logger.debug('Got askpass(%s): %s', hint, prompt)\n \n- async def do_custom_command(self, command: str, args: tuple, fds: list[int], stderr: str) -> None:\n- logger.debug('Got ferny command %s %s %s', command, args, stderr)\n+ match = PasswordResponder.PASSPHRASE_RE.fullmatch(prompt)\n+ if match is not None:\n+ # We never unlock private keys \u2014 we rather need to throw a\n+ # specially-formatted error message which will cause the frontend\n+ # to load the named key into the agent for us and try again.\n+ path = match.group(1)\n+ logger.debug(\"This is a passphrase request for %s, but we don't do those. Abort.\", path)\n+ self.error_message = f'locked identity: {path}'\n+ return None\n \n- if command == 'cockpit.report-exists':\n- file_status, = args\n- # FIXME: evil duck typing here -- this is a half-way Bridge\n- self.router.packages = Packages(loader=ProxyPackagesLoader(file_status)) # type: ignore[attr-defined]\n- self.router.routing_rules.insert(0, ChannelRoutingRule(self.router, [PackagesChannel]))\n+ assert self.password is not None\n+ assert self.password_attempts == 0\n+ self.password_attempts += 1\n+ return self.password\n \n \n class SshPeer(Peer):\n- always: bool\n-\n- def __init__(self, router: Router, destination: str, args: argparse.Namespace):\n- self.destination = destination\n- self.always = args.always\n- super().__init__(router)\n+ session: Optional[ferny.Session] = None\n+ host: str\n+ user: Optional[str]\n+ password: Optional[str]\n+ private: bool\n \n async def do_connect_transport(self) -> None:\n- beiboot_helper = BridgeBeibootHelper(self)\n+ assert self.session is not None\n+ logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private)\n \n- agent = ferny.InteractionAgent([AuthorizeResponder(self.router), beiboot_helper])\n+ basename, colon, portstr = self.host.rpartition(':')\n+ if colon and portstr.isdigit():\n+ host = basename\n+ port = int(portstr)\n+ else:\n+ host = self.host\n+ port = None\n \n- # We want to run a python interpreter somewhere...\n- cmd: Sequence[str] = ('python3', '-ic', '# cockpit-bridge')\n- env: Sequence[str] = ()\n+ responder = PasswordResponder(self.password)\n+ options = {\"StrictHostKeyChecking\": 'yes'}\n \n- in_flatpak = os.path.exists('/.flatpak-info')\n+ if self.password is not None:\n+ options.update(NumberOfPasswordPrompts='1')\n+ else:\n+ options.update(PasswordAuthentication=\"no\", KbdInteractiveAuthentication=\"no\")\n \n- # Remote host? Wrap command with SSH\n- if self.destination != 'localhost':\n- if in_flatpak:\n- # we run ssh and thus the helper on the host, always use the xdg-cache helper\n- ssh_askpass = ensure_ferny_askpass()\n- else:\n- # outside of the flatpak we expect cockpit-ws and thus an installed helper\n- askpass = patch_libexecdir('${libexecdir}/cockpit-askpass')\n- assert isinstance(askpass, str)\n- ssh_askpass = Path(askpass)\n- if not ssh_askpass.exists():\n- logger.error(\"Could not find cockpit-askpass helper at %r\", askpass)\n+ try:\n+ await self.session.connect(host, login_name=self.user, port=port,\n+ handle_host_key=self.private, options=options,\n+ interaction_responder=responder)\n+ except (OSError, socket.gaierror) as exc:\n+ logger.debug('connecting to host %s failed: %s', host, exc)\n+ raise PeerError('no-host', error='no-host', message=str(exc)) from exc\n \n- env = (\n- f'SSH_ASKPASS={ssh_askpass!s}',\n- 'DISPLAY=x',\n- 'SSH_ASKPASS_REQUIRE=force',\n- )\n- host, _, port = self.destination.rpartition(':')\n- # catch cases like `host:123` but not cases like `[2001:abcd::1]\n- if port.isdigit():\n- host_args = ['-p', port, host]\n+ except ferny.SshHostKeyError as exc:\n+ if responder.hostkeys_seen:\n+ # If we saw a hostkey then we can issue a detailed error message\n+ # containing the key that would need to be accepted. That will\n+ # cause the front-end to present a dialog.\n+ _reason, host, algorithm, key, fingerprint = responder.hostkeys_seen[0]\n+ error_args = {'host-key': f'{host} {algorithm} {key}', 'host-fingerprint': fingerprint}\n else:\n- host_args = [self.destination]\n+ error_args = {}\n \n- cmd = ('ssh', *host_args, shlex.join(cmd))\n+ if isinstance(exc, ferny.SshChangedHostKeyError):\n+ error = 'invalid-hostkey'\n+ elif self.private:\n+ error = 'unknown-hostkey'\n+ else:\n+ # non-private session case. throw a generic error.\n+ error = 'unknown-host'\n \n- # Running in flatpak? Wrap command with flatpak-spawn --host\n- if in_flatpak:\n- cmd = ('flatpak-spawn', '--host',\n- *(f'--env={kv}' for kv in env),\n- *cmd)\n- env = ()\n+ logger.debug('SshPeer got a %s %s; private %s, seen hostkeys %r; raising %s with extra args %r',\n+ type(exc), exc, self.private, responder.hostkeys_seen, error, error_args)\n+ raise PeerError(error, error_args, error=error, auth_method_results={}) from exc\n \n- logger.debug(\"Launching command: cmd=%s env=%s\", cmd, env)\n- transport = await self.spawn(cmd, env, stderr=agent, start_new_session=True)\n+ except ferny.SshAuthenticationError as exc:\n+ logger.debug('authentication to host %s failed: %s', host, exc)\n \n- if not self.always:\n- exec_cockpit_bridge_steps = [('try_exec', (['cockpit-bridge'],))]\n- else:\n- exec_cockpit_bridge_steps = []\n+ results = dict.fromkeys(exc.methods, \"not-provided\")\n+ if 'password' in results and self.password is not None:\n+ if responder.password_attempts == 0:\n+ results['password'] = 'not-tried'\n+ else:\n+ results['password'] = 'denied'\n \n- # Send the first-stage bootloader\n- stage1 = bootloader.make_bootloader([\n- *exec_cockpit_bridge_steps,\n- ('report_exists', [list(get_interesting_files())]),\n- *beiboot_helper.steps,\n- ], gadgets=BEIBOOT_GADGETS)\n- transport.write(stage1.encode())\n+ raise PeerError('authentication-failed',\n+ error=responder.error_message or 'authentication-failed',\n+ auth_method_results=results) from exc\n \n- # Wait for \"init\" or error, handling auth and beiboot requests\n- await agent.communicate()\n+ except ferny.SshError as exc:\n+ logger.debug('unknown failure connecting to host %s: %s', host, exc)\n+ raise PeerError('internal-error', message=str(exc)) from exc\n \n- def transport_control_received(self, command: str, message: JsonObject) -> None:\n- if command == 'authorize':\n- # We've disabled this for explicit-superuser bridges, but older\n- # bridges don't support that and will ask us anyway.\n- return\n+ args = self.session.wrap_subprocess_args(['cockpit-bridge'])\n+ await self.spawn(args, [])\n \n- super().transport_control_received(command, message)\n+ def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n+ if host == self.host:\n+ self.close()\n+ elif host is None:\n+ super().do_kill(host, group, message)\n \n+ def do_authorize(self, message: JsonObject) -> None:\n+ if get_str(message, 'challenge').startswith('plain1:'):\n+ cookie = get_str(message, 'cookie')\n+ self.write_control(command='authorize', cookie=cookie, response=self.password or '')\n+ self.password = None # once is enough...\n \n-class SshBridge(Router):\n- packages: Optional[Packages] = None\n- ssh_peer: SshPeer\n+ def do_superuser_init_done(self) -> None:\n+ self.password = None\n \n- def __init__(self, args: argparse.Namespace):\n- # By default, we route everything to the other host. We add an extra\n- # routing rule for the packages webserver only if we're running the\n- # beipack.\n- rule = DefaultRoutingRule(self)\n- super().__init__([rule])\n+ def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None:\n+ super().__init__(router)\n+ self.host = host\n+ self.user = user\n+ self.password = get_str(options, 'password', None)\n+ self.private = private\n \n- # This needs to be created after Router.__init__ is called.\n- self.ssh_peer = SshPeer(self, args.destination, args)\n- rule.peer = self.ssh_peer\n+ self.session = ferny.Session()\n \n- def do_send_init(self):\n- pass # wait for the peer to do it first\n+ superuser: JsonValue\n+ init_superuser = get_str_or_none(options, 'init-superuser', None)\n+ if init_superuser in (None, 'none'):\n+ superuser = False\n+ else:\n+ superuser = {'id': init_superuser}\n \n- def do_init(self, message):\n- # https://github.com/cockpit-project/cockpit/issues/18927\n- #\n- # We tell cockpit-ws that we have the explicit-superuser capability and\n- # handle it ourselves (just below) by sending `superuser-init-done` and\n- # passing {'superuser': False} on to the actual bridge (Python or C).\n- if isinstance(message.get('superuser'), dict):\n- self.write_control(command='superuser-init-done')\n- message['superuser'] = False\n- self.ssh_peer.write_control(message)\n+ self.start_in_background(init_host=host, superuser=superuser)\n \n \n-async def run(args) -> None:\n- logger.debug(\"Hi. How are you today?\")\n+class HostRoutingRule(RoutingRule):\n+ remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer]\n \n- bridge = SshBridge(args)\n- StdioTransport(asyncio.get_running_loop(), bridge)\n+ def __init__(self, router):\n+ super().__init__(router)\n+ self.remotes = {}\n \n- try:\n- message = dict(await bridge.ssh_peer.start())\n+ def apply_rule(self, options: JsonObject) -> Optional[Peer]:\n+ assert self.router is not None\n+ assert self.router.init_host is not None\n \n- # See comment in do_init() above: we tell cockpit-ws that we support\n- # this and then handle it ourselves when we get the init message.\n- capabilities = message.setdefault('capabilities', {})\n- if not isinstance(capabilities, dict):\n- bridge.write_control(command='init', problem='protocol-error', message='capabilities must be a dict')\n- return\n- assert isinstance(capabilities, dict) # convince mypy\n- capabilities['explicit-superuser'] = True\n+ host = get_str(options, 'host', self.router.init_host)\n+ if host == self.router.init_host:\n+ return None\n \n- # only patch the packages line if we are in beiboot mode\n- if bridge.packages:\n- message['packages'] = dict.fromkeys(bridge.packages.packages)\n+ user = get_str(options, 'user', None)\n+ # HACK: the front-end relies on this for tracking connections without an explicit user name;\n+ # the user will then be determined by SSH (`User` in the config or the current user)\n+ # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c\n+ if user == getpass.getuser():\n+ user = None\n+ if not user:\n+ user_from_host, _, _ = host.rpartition('@')\n+ user = user_from_host or None # avoid ''\n \n- bridge.write_control(message)\n- bridge.ssh_peer.thaw_endpoint()\n- except ferny.InteractionError as exc:\n- error = ferny.ssh_errors.get_exception_for_ssh_stderr(str(exc))\n- logger.debug(\"ferny.InteractionError: %s, interpreted as: %r\", exc, error)\n- if isinstance(error, ferny.SshAuthenticationError):\n- problem = 'authentication-failed'\n- elif isinstance(error, ferny.SshHostKeyError):\n- problem = 'unknown-hostkey'\n- elif isinstance(error, OSError):\n- # usually DNS/socket errors\n- problem = 'unknown-host'\n+ if get_str(options, 'session', None) == 'private':\n+ nonce = get_str(options, 'channel')\n else:\n- problem = 'internal-error'\n- bridge.write_control(command='init', problem=problem, message=str(error))\n- return\n- except CockpitProblem as exc:\n- logger.debug(\"CockpitProblem: %s\", exc)\n- bridge.write_control(exc.attrs, command='init')\n- return\n-\n- logger.debug('Startup done. Looping until connection closes.')\n- try:\n- await bridge.communicate()\n- except BrokenPipeError:\n- # expected if the peer doesn't hang up cleanly\n- pass\n-\n+ nonce = None\n \n-def main() -> None:\n- polyfills.install()\n+ assert isinstance(host, str)\n+ assert user is None or isinstance(user, str)\n+ assert nonce is None or isinstance(nonce, str)\n \n- parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')\n- parser.add_argument('--always', action='store_true', help=\"Never try to run cockpit-bridge from the system\")\n- parser.add_argument('--debug', action='store_true')\n- parser.add_argument('destination', help=\"Name of the remote host to connect to, or 'localhost'\")\n- args = parser.parse_args()\n+ key = host, user, nonce\n \n- setup_logging(debug=args.debug)\n+ logger.debug('Request for channel %s is remote.', options)\n+ logger.debug('key=%s', key)\n \n- asyncio.run(run(args), debug=args.debug)\n+ if key not in self.remotes:\n+ logger.debug('%s is not among the existing remotes %s. Opening a new connection.', key, self.remotes)\n+ peer = SshPeer(self.router, host, user, options, private=nonce is not None)\n+ peer.add_done_callback(lambda: self.remotes.__delitem__(key))\n+ self.remotes[key] = peer\n \n+ return self.remotes[key]\n \n-if __name__ == '__main__':\n- main()\n-''',\n+ def shutdown(self):\n+ for peer in set(self.remotes.values()):\n+ peer.close()\n+'''.encode('utf-8'),\n 'cockpit/channel.py': r'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n@@ -3758,15 +3529,15 @@\n try:\n while self.send_data(next(self.__generator)):\n pass\n except StopIteration as stop:\n self.done()\n self.close(stop.value)\n '''.encode('utf-8'),\n- 'cockpit/remote.py': r'''# This file is part of Cockpit.\n+ 'cockpit/internal_endpoints.py': br'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -3775,234 +3546,246 @@\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import getpass\n+import asyncio\n+import glob\n+import grp\n+import json\n import logging\n-import re\n-import socket\n-from typing import Dict, List, Optional, Tuple\n+import os\n+import pwd\n+from pathlib import Path\n+from typing import Dict, Optional\n \n-from cockpit._vendor import ferny\n+from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch\n \n-from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none\n-from .peer import Peer, PeerError\n-from .router import Router, RoutingRule\n+from . import config\n \n logger = logging.getLogger(__name__)\n \n \n-class PasswordResponder(ferny.AskpassHandler):\n- PASSPHRASE_RE = re.compile(r\"Enter passphrase for key '(.*)': \")\n-\n- password: Optional[str]\n-\n- hostkeys_seen: List[Tuple[str, str, str, str, str]]\n- error_message: Optional[str]\n- password_attempts: int\n+class cockpit_LoginMessages(bus.Object):\n+ messages: Optional[str] = None\n \n- def __init__(self, password: Optional[str]):\n- self.password = password\n+ def __init__(self):\n+ fdstr = os.environ.pop('COCKPIT_LOGIN_MESSAGES_MEMFD', None)\n+ if fdstr is None:\n+ logger.debug(\"COCKPIT_LOGIN_MESSAGES_MEMFD wasn't set. No login messages today.\")\n+ return\n \n- self.hostkeys_seen = []\n- self.error_message = None\n- self.password_attempts = 0\n+ logger.debug(\"Trying to read login messages from fd %s\", fdstr)\n+ try:\n+ with open(int(fdstr), 'r') as login_messages:\n+ login_messages.seek(0)\n+ self.messages = login_messages.read()\n+ except (ValueError, OSError, UnicodeDecodeError) as exc:\n+ # ValueError - the envvar wasn't an int\n+ # OSError - the fd wasn't open, or other read failure\n+ # UnicodeDecodeError - didn't contain utf-8\n+ # For all of these, we simply failed to get the message.\n+ logger.debug(\"Reading login messages failed: %s\", exc)\n+ else:\n+ logger.debug(\"Successfully read login messages: %s\", self.messages)\n \n- async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:\n- self.hostkeys_seen.append((reason, host, algorithm, key, fingerprint))\n- return False\n+ @bus.Interface.Method(out_types=['s'])\n+ def get(self):\n+ return self.messages or '{}'\n \n- async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:\n- logger.debug('Got askpass(%s): %s', hint, prompt)\n+ @bus.Interface.Method(out_types=[])\n+ def dismiss(self):\n+ self.messages = None\n \n- match = PasswordResponder.PASSPHRASE_RE.fullmatch(prompt)\n- if match is not None:\n- # We never unlock private keys \u2014 we rather need to throw a\n- # specially-formatted error message which will cause the frontend\n- # to load the named key into the agent for us and try again.\n- path = match.group(1)\n- logger.debug(\"This is a passphrase request for %s, but we don't do those. Abort.\", path)\n- self.error_message = f'locked identity: {path}'\n- return None\n \n- assert self.password is not None\n- assert self.password_attempts == 0\n- self.password_attempts += 1\n- return self.password\n+class cockpit_Machines(bus.Object):\n+ path: Path\n+ watch: pathwatch.PathWatch\n+ pending_notify: Optional[asyncio.Handle]\n \n+ # D-Bus implementation\n+ machines = bus.Interface.Property('a{sa{sv}}')\n \n-class SshPeer(Peer):\n- session: Optional[ferny.Session] = None\n- host: str\n- user: Optional[str]\n- password: Optional[str]\n- private: bool\n+ @machines.getter\n+ def get_machines(self) -> Dict[str, Dict[str, Variant]]:\n+ results: Dict[str, Dict[str, Variant]] = {}\n \n- async def do_connect_transport(self) -> None:\n- assert self.session is not None\n- logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private)\n+ for filename in glob.glob(f'{self.path}/*.json'):\n+ with open(filename) as fp:\n+ try:\n+ contents = json.load(fp)\n+ except json.JSONDecodeError:\n+ logger.warning('Invalid JSON in file %s. Ignoring.', filename)\n+ continue\n+ # merge\n+ for hostname, attrs in contents.items():\n+ results[hostname] = {key: Variant(value) for key, value in attrs.items()}\n \n- basename, colon, portstr = self.host.rpartition(':')\n- if colon and portstr.isdigit():\n- host = basename\n- port = int(portstr)\n- else:\n- host = self.host\n- port = None\n+ return results\n \n- responder = PasswordResponder(self.password)\n- options = {\"StrictHostKeyChecking\": 'yes'}\n+ @bus.Interface.Method(in_types=['s', 's', 'a{sv}'])\n+ def update(self, filename: str, hostname: str, attrs: Dict[str, Variant]) -> None:\n+ try:\n+ with self.path.joinpath(filename).open() as fp:\n+ contents = json.load(fp)\n+ except json.JSONDecodeError as exc:\n+ # Refuse to replace corrupted file\n+ raise bus.BusError('cockpit.Machines.Error', f'File {filename} is in invalid format: {exc}.') from exc\n+ except FileNotFoundError:\n+ # But an empty file is an expected case\n+ contents = {}\n \n- if self.password is not None:\n- options.update(NumberOfPasswordPrompts='1')\n- else:\n- options.update(PasswordAuthentication=\"no\", KbdInteractiveAuthentication=\"no\")\n+ contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()})\n \n- try:\n- await self.session.connect(host, login_name=self.user, port=port,\n- handle_host_key=self.private, options=options,\n- interaction_responder=responder)\n- except (OSError, socket.gaierror) as exc:\n- logger.debug('connecting to host %s failed: %s', host, exc)\n- raise PeerError('no-host', error='no-host', message=str(exc)) from exc\n+ self.path.mkdir(parents=True, exist_ok=True)\n+ with open(self.path.joinpath(filename), 'w') as fp:\n+ json.dump(contents, fp, indent=2)\n \n- except ferny.SshHostKeyError as exc:\n- if responder.hostkeys_seen:\n- # If we saw a hostkey then we can issue a detailed error message\n- # containing the key that would need to be accepted. That will\n- # cause the front-end to present a dialog.\n- _reason, host, algorithm, key, fingerprint = responder.hostkeys_seen[0]\n- error_args = {'host-key': f'{host} {algorithm} {key}', 'host-fingerprint': fingerprint}\n- else:\n- error_args = {}\n+ def notify(self):\n+ def _notify_now():\n+ self.properties_changed('cockpit.Machines', {}, ['Machines'])\n+ self.pending_notify = None\n \n- if isinstance(exc, ferny.SshChangedHostKeyError):\n- error = 'invalid-hostkey'\n- elif self.private:\n- error = 'unknown-hostkey'\n- else:\n- # non-private session case. throw a generic error.\n- error = 'unknown-host'\n+ # avoid a flurry of update notifications\n+ if self.pending_notify is None:\n+ self.pending_notify = asyncio.get_running_loop().call_later(1.0, _notify_now)\n \n- logger.debug('SshPeer got a %s %s; private %s, seen hostkeys %r; raising %s with extra args %r',\n- type(exc), exc, self.private, responder.hostkeys_seen, error, error_args)\n- raise PeerError(error, error_args, error=error, auth_method_results={}) from exc\n+ # inotify events\n+ def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None:\n+ self.notify()\n \n- except ferny.SshAuthenticationError as exc:\n- logger.debug('authentication to host %s failed: %s', host, exc)\n+ def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None:\n+ self.notify()\n \n- results = dict.fromkeys(exc.methods, \"not-provided\")\n- if 'password' in results and self.password is not None:\n- if responder.password_attempts == 0:\n- results['password'] = 'not-tried'\n- else:\n- results['password'] = 'denied'\n+ def __init__(self):\n+ self.path = config.lookup_config('machines.d')\n \n- raise PeerError('authentication-failed',\n- error=responder.error_message or 'authentication-failed',\n- auth_method_results=results) from exc\n+ # ignore the first callback\n+ self.pending_notify = ...\n+ self.watch = pathwatch.PathWatch(str(self.path), self)\n+ self.pending_notify = None\n \n- except ferny.SshError as exc:\n- logger.debug('unknown failure connecting to host %s: %s', host, exc)\n- raise PeerError('internal-error', message=str(exc)) from exc\n \n- args = self.session.wrap_subprocess_args(['cockpit-bridge'])\n- await self.spawn(args, [])\n+class cockpit_User(bus.Object):\n+ name = bus.Interface.Property('s', value='')\n+ full = bus.Interface.Property('s', value='')\n+ id = bus.Interface.Property('i', value=0)\n+ home = bus.Interface.Property('s', value='')\n+ shell = bus.Interface.Property('s', value='')\n+ groups = bus.Interface.Property('as', value=[])\n \n- def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n- if host == self.host:\n- self.close()\n- elif host is None:\n- super().do_kill(host, group, message)\n+ def __init__(self):\n+ user = pwd.getpwuid(os.getuid())\n+ self.name = user.pw_name\n+ self.full = user.pw_gecos\n+ self.id = user.pw_uid\n+ self.home = user.pw_dir\n+ self.shell = user.pw_shell\n+ self.groups = [gr.gr_name for gr in grp.getgrall() if user.pw_name in gr.gr_mem]\n \n- def do_authorize(self, message: JsonObject) -> None:\n- if get_str(message, 'challenge').startswith('plain1:'):\n- cookie = get_str(message, 'cookie')\n- self.write_control(command='authorize', cookie=cookie, response=self.password or '')\n- self.password = None # once is enough...\n \n- def do_superuser_init_done(self) -> None:\n- self.password = None\n+EXPORTS = [\n+ ('/LoginMessages', cockpit_LoginMessages),\n+ ('/machines', cockpit_Machines),\n+ ('/user', cockpit_User),\n+]\n+''',\n+ 'cockpit/config.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2023 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n- def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None:\n- super().__init__(router)\n- self.host = host\n- self.user = user\n- self.password = get_str(options, 'password', None)\n- self.private = private\n+import configparser\n+import logging\n+import os\n+from pathlib import Path\n \n- self.session = ferny.Session()\n+from cockpit._vendor.systemd_ctypes import bus\n \n- superuser: JsonValue\n- init_superuser = get_str_or_none(options, 'init-superuser', None)\n- if init_superuser in (None, 'none'):\n- superuser = False\n- else:\n- superuser = {'id': init_superuser}\n+logger = logging.getLogger(__name__)\n \n- self.start_in_background(init_host=host, superuser=superuser)\n+XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'))\n+DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit'\n \n \n-class HostRoutingRule(RoutingRule):\n- remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer]\n+def lookup_config(filename: str) -> Path:\n+ config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc').split(':')\n+ fallback = None\n+ for config_dir in config_dirs:\n+ config_path = Path(config_dir, 'cockpit', filename)\n+ if not fallback:\n+ fallback = config_path\n+ if config_path.exists():\n+ logger.debug('lookup_config(%s): found %s', filename, config_path)\n+ return config_path\n \n- def __init__(self, router):\n- super().__init__(router)\n- self.remotes = {}\n+ # default to the first entry in XDG_CONFIG_DIRS; that's not according to the spec,\n+ # but what Cockpit has done for years\n+ logger.debug('lookup_config(%s): defaulting to %s', filename, fallback)\n+ assert fallback # mypy; config_dirs always has at least one string\n+ return fallback\n \n- def apply_rule(self, options: JsonObject) -> Optional[Peer]:\n- assert self.router is not None\n- assert self.router.init_host is not None\n \n- host = get_str(options, 'host', self.router.init_host)\n- if host == self.router.init_host:\n- return None\n+class Config(bus.Object, interface='cockpit.Config'):\n+ def __init__(self):\n+ self.reload()\n \n- user = get_str(options, 'user', None)\n- # HACK: the front-end relies on this for tracking connections without an explicit user name;\n- # the user will then be determined by SSH (`User` in the config or the current user)\n- # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c\n- if user == getpass.getuser():\n- user = None\n- if not user:\n- user_from_host, _, _ = host.rpartition('@')\n- user = user_from_host or None # avoid ''\n+ @bus.Interface.Method(out_types='s', in_types='ss')\n+ def get_string(self, section, key):\n+ try:\n+ return self.config[section][key]\n+ except KeyError as exc:\n+ raise bus.BusError('cockpit.Config.KeyError', f'key {key} in section {section} does not exist') from exc\n \n- if get_str(options, 'session', None) == 'private':\n- nonce = get_str(options, 'channel')\n- else:\n- nonce = None\n+ @bus.Interface.Method(out_types='u', in_types='ssuuu')\n+ def get_u_int(self, section, key, default, maximum, minimum):\n+ try:\n+ value = self.config[section][key]\n+ except KeyError:\n+ return default\n \n- assert isinstance(host, str)\n- assert user is None or isinstance(user, str)\n- assert nonce is None or isinstance(nonce, str)\n+ try:\n+ int_val = int(value)\n+ except ValueError:\n+ logger.warning('cockpit.conf: [%s] %s is not an integer', section, key)\n+ return default\n \n- key = host, user, nonce\n+ return min(max(int_val, minimum), maximum)\n \n- logger.debug('Request for channel %s is remote.', options)\n- logger.debug('key=%s', key)\n+ @bus.Interface.Method()\n+ def reload(self):\n+ self.config = configparser.ConfigParser(interpolation=None)\n+ cockpit_conf = lookup_config('cockpit.conf')\n+ logger.debug(\"cockpit.Config: loading %s\", cockpit_conf)\n+ # this may not exist, but it's ok to not have a config file and thus leave self.config empty\n+ self.config.read(cockpit_conf)\n \n- if key not in self.remotes:\n- logger.debug('%s is not among the existing remotes %s. Opening a new connection.', key, self.remotes)\n- peer = SshPeer(self.router, host, user, options, private=nonce is not None)\n- peer.add_done_callback(lambda: self.remotes.__delitem__(key))\n- self.remotes[key] = peer\n \n- return self.remotes[key]\n+class Environment(bus.Object, interface='cockpit.Environment'):\n+ variables = bus.Interface.Property('a{ss}')\n \n- def shutdown(self):\n- for peer in set(self.remotes.values()):\n- peer.close()\n-'''.encode('utf-8'),\n- 'cockpit/_version.py': br'''__version__ = '317'\n+ @variables.getter\n+ def get_variables(self):\n+ return os.environ.copy()\n ''',\n- 'cockpit/router.py': br'''# This file is part of Cockpit.\n+ 'cockpit/peer.py': r'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -4012,200 +3795,533 @@\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n import asyncio\n-import collections\n import logging\n-from typing import Dict, List, Optional\n+import os\n+from typing import Callable, List, Optional, Sequence\n \n from .jsonutil import JsonObject, JsonValue\n-from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer\n+from .packages import BridgeConfig\n+from .protocol import CockpitProblem, CockpitProtocol, CockpitProtocolError\n+from .router import Endpoint, Router, RoutingRule\n+from .transports import SubprocessProtocol, SubprocessTransport\n \n logger = logging.getLogger(__name__)\n \n \n-class ExecutionQueue:\n- \"\"\"Temporarily delay calls to a given set of class methods.\n-\n- Functions by replacing the named function at the instance __dict__\n- level, effectively providing an override for exactly one instance\n- of `method`'s object.\n- Queues the invocations. Run them later with .run(), which also reverses\n- the redirection by deleting the named methods from the instance.\n- \"\"\"\n- def __init__(self, methods):\n- self.queue = collections.deque()\n- self.methods = methods\n+class PeerError(CockpitProblem):\n+ pass\n \n- for method in self.methods:\n- self._wrap(method)\n \n- def _wrap(self, method):\n- # NB: this function is stored in the instance dict and therefore\n- # doesn't function as a descriptor, isn't a method, doesn't get bound,\n- # and therefore doesn't receive a self parameter\n- setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args)))\n+class PeerExited(Exception):\n+ def __init__(self, exit_code: int):\n+ self.exit_code = exit_code\n \n- def run(self):\n- logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue))\n- for method, args in self.queue:\n- method(*args)\n \n- for method in self.methods:\n- delattr(method.__self__, method.__func__.__name__)\n+class Peer(CockpitProtocol, SubprocessProtocol, Endpoint):\n+ done_callbacks: List[Callable[[], None]]\n+ init_future: Optional[asyncio.Future]\n \n+ def __init__(self, router: Router):\n+ super().__init__(router)\n \n-class Endpoint:\n- router: 'Router'\n- __endpoint_frozen_queue: Optional[ExecutionQueue] = None\n+ # All Peers start out frozen \u2014 we only unfreeze after we see the first 'init' message\n+ self.freeze_endpoint()\n \n- def __init__(self, router: 'Router'):\n- router.add_endpoint(self)\n- self.router = router\n+ self.init_future = asyncio.get_running_loop().create_future()\n+ self.done_callbacks = []\n \n- def freeze_endpoint(self):\n- assert self.__endpoint_frozen_queue is None\n- logger.debug('Freezing endpoint %s', self)\n- self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill})\n+ # Initialization\n+ async def do_connect_transport(self) -> None:\n+ raise NotImplementedError\n \n- def thaw_endpoint(self):\n- assert self.__endpoint_frozen_queue is not None\n- logger.debug('Thawing endpoint %s', self)\n- self.__endpoint_frozen_queue.run()\n- self.__endpoint_frozen_queue = None\n+ async def spawn(self, argv: Sequence[str], env: Sequence[str], **kwargs) -> asyncio.Transport:\n+ # Not actually async...\n+ loop = asyncio.get_running_loop()\n+ user_env = dict(e.split('=', 1) for e in env)\n+ return SubprocessTransport(loop, self, argv, env=dict(os.environ, **user_env), **kwargs)\n \n- # interface for receiving messages\n- def do_close(self):\n- raise NotImplementedError\n+ async def start(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> JsonObject:\n+ \"\"\"Request that the Peer is started and connected to the router.\n \n- def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:\n- raise NotImplementedError\n+ Creates the transport, connects it to the protocol, and participates in\n+ exchanging of init messages. If anything goes wrong, the connection\n+ will be closed and an exception will be raised.\n \n- def do_channel_data(self, channel: str, data: bytes) -> None:\n- raise NotImplementedError\n+ The Peer starts out in a frozen state (ie: attempts to send messages to\n+ it will initially be queued). If init_host is not None then an init\n+ message is sent with the given 'host' field, plus any extra kwargs, and\n+ the queue is thawed. Otherwise, the caller is responsible for sending\n+ the init message and thawing the peer.\n \n- def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n- raise NotImplementedError\n+ In any case, the return value is the init message from the peer.\n+ \"\"\"\n+ assert self.init_future is not None\n \n- # interface for sending messages\n- def send_channel_data(self, channel: str, data: bytes) -> None:\n- self.router.write_channel_data(channel, data)\n+ def _connect_task_done(task: asyncio.Task) -> None:\n+ assert task is connect_task\n+ try:\n+ task.result()\n+ except asyncio.CancelledError: # we did that (below)\n+ pass # we want to ignore it\n+ except Exception as exc:\n+ self.close(exc)\n \n- def send_channel_control(\n- self, channel: str, command: str, _msg: 'JsonObject | None', **kwargs: JsonValue\n- ) -> None:\n- self.router.write_control(_msg, channel=channel, command=command, **kwargs)\n- if command == 'close':\n- self.router.endpoints[self].remove(channel)\n- self.router.drop_channel(channel)\n+ connect_task = asyncio.create_task(self.do_connect_transport())\n+ connect_task.add_done_callback(_connect_task_done)\n \n- def shutdown_endpoint(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n- self.router.shutdown_endpoint(self, _msg, **kwargs)\n+ try:\n+ # Wait for something to happen:\n+ # - exception from our connection function\n+ # - receiving \"init\" from the other side\n+ # - receiving EOF from the other side\n+ # - .close() was called\n+ # - other transport exception\n+ init_message = await self.init_future\n \n+ except (PeerExited, BrokenPipeError):\n+ # These are fairly generic errors. PeerExited means that we observed the process exiting.\n+ # BrokenPipeError means that we got EPIPE when attempting to write() to it. In both cases,\n+ # the process is gone, but it's not clear why. If the connection process is still running,\n+ # perhaps we'd get a better error message from it.\n+ await connect_task\n+ # Otherwise, re-raise\n+ raise\n \n-class RoutingError(CockpitProblem):\n- pass\n+ finally:\n+ self.init_future = None\n \n+ # In any case (failure or success) make sure this is done.\n+ if not connect_task.done():\n+ connect_task.cancel()\n \n-class RoutingRule:\n- router: 'Router'\n+ if init_host is not None:\n+ logger.debug(' sending init message back, host %s', init_host)\n+ # Send \"init\" back\n+ self.write_control(None, command='init', version=1, host=init_host, **kwargs)\n \n- def __init__(self, router: 'Router'):\n- self.router = router\n+ # Thaw the queued messages\n+ self.thaw_endpoint()\n \n- def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:\n- \"\"\"Check if a routing rule applies to a given 'open' message.\n+ return init_message\n \n- This should inspect the options dictionary and do one of the following three things:\n+ # Background initialization\n+ def start_in_background(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> None:\n+ def _start_task_done(task: asyncio.Task) -> None:\n+ assert task is start_task\n \n- - return an Endpoint to handle this channel\n- - raise a RoutingError to indicate that the open should be rejected\n- - return None to let the next rule run\n- \"\"\"\n- raise NotImplementedError\n+ try:\n+ task.result()\n+ except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError):\n+ pass # Those are expected. Others will throw.\n \n- def shutdown(self):\n- raise NotImplementedError\n+ start_task = asyncio.create_task(self.start(init_host, **kwargs))\n+ start_task.add_done_callback(_start_task_done)\n \n+ # Shutdown\n+ def add_done_callback(self, callback: Callable[[], None]) -> None:\n+ self.done_callbacks.append(callback)\n \n-class Router(CockpitProtocolServer):\n- routing_rules: List[RoutingRule]\n- open_channels: Dict[str, Endpoint]\n- endpoints: 'dict[Endpoint, set[str]]'\n- no_endpoints: asyncio.Event # set if endpoints dict is empty\n- _eof: bool = False\n+ # Handling of interesting events\n+ def do_superuser_init_done(self) -> None:\n+ pass\n \n- def __init__(self, routing_rules: List[RoutingRule]):\n- for rule in routing_rules:\n- rule.router = self\n- self.routing_rules = routing_rules\n- self.open_channels = {}\n- self.endpoints = {}\n- self.no_endpoints = asyncio.Event()\n- self.no_endpoints.set() # at first there are no endpoints\n+ def do_authorize(self, message: JsonObject) -> None:\n+ pass\n \n- def check_rules(self, options: JsonObject) -> Endpoint:\n- for rule in self.routing_rules:\n- logger.debug(' applying rule %s', rule)\n- endpoint = rule.apply_rule(options)\n- if endpoint is not None:\n- logger.debug(' resulting endpoint is %s', endpoint)\n- return endpoint\n+ def transport_control_received(self, command: str, message: JsonObject) -> None:\n+ if command == 'init' and self.init_future is not None:\n+ logger.debug('Got init message with active init_future. Setting result.')\n+ self.init_future.set_result(message)\n+ elif command == 'authorize':\n+ self.do_authorize(message)\n+ elif command == 'superuser-init-done':\n+ self.do_superuser_init_done()\n else:\n- logger.debug(' No rules matched')\n- raise RoutingError('not-supported')\n+ raise CockpitProtocolError(f'Received unexpected control message {command}')\n \n- def drop_channel(self, channel: str) -> None:\n- try:\n- self.open_channels.pop(channel)\n- logger.debug('router dropped channel %s', channel)\n- except KeyError:\n- logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels)\n+ def eof_received(self) -> bool:\n+ # We always expect to be the ones to close the connection, so if we get\n+ # an EOF, then we consider it to be an error. This allows us to\n+ # distinguish close caused by unexpected EOF (but no errno from a\n+ # syscall failure) vs. close caused by calling .close() on our side.\n+ # The process is still running at this point, so keep it and handle\n+ # the error in process_exited().\n+ logger.debug('Peer %s received unexpected EOF', self.__class__.__name__)\n+ return True\n \n- def add_endpoint(self, endpoint: Endpoint) -> None:\n- self.endpoints[endpoint] = set()\n- self.no_endpoints.clear()\n+ def do_closed(self, exc: Optional[Exception]) -> None:\n+ logger.debug('Peer %s connection lost %s %s', self.__class__.__name__, type(exc), exc)\n \n- def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n- channels = self.endpoints.pop(endpoint)\n- logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels)\n- for channel in channels:\n- self.write_control(_msg, command='close', channel=channel, **kwargs)\n- self.drop_channel(channel)\n+ if exc is None:\n+ self.shutdown_endpoint(problem='terminated')\n+ elif isinstance(exc, PeerExited):\n+ # a common case is that the called peer does not exist\n+ if exc.exit_code == 127:\n+ self.shutdown_endpoint(problem='no-cockpit')\n+ else:\n+ self.shutdown_endpoint(problem='terminated', message=f'Peer exited with status {exc.exit_code}')\n+ elif isinstance(exc, CockpitProblem):\n+ self.shutdown_endpoint(exc.attrs)\n+ else:\n+ self.shutdown_endpoint(problem='internal-error',\n+ message=f\"[{exc.__class__.__name__}] {exc!s}\")\n \n- if not self.endpoints:\n- self.no_endpoints.set()\n+ # If .start() is running, we need to make sure it stops running,\n+ # raising the correct exception.\n+ if self.init_future is not None and not self.init_future.done():\n+ if exc is not None:\n+ self.init_future.set_exception(exc)\n+ else:\n+ self.init_future.cancel()\n \n- # were we waiting to exit?\n- if self._eof:\n- logger.debug(' endpoints remaining: %r', self.endpoints)\n- if not self.endpoints and self.transport:\n- logger.debug(' close transport')\n- self.transport.close()\n+ for callback in self.done_callbacks:\n+ callback()\n \n- def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n- endpoints = set(self.endpoints)\n- logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints))\n- for endpoint in endpoints:\n- endpoint.do_kill(host, group, message)\n+ def process_exited(self) -> None:\n+ assert isinstance(self.transport, SubprocessTransport)\n+ logger.debug('Peer %s exited, status %d', self.__class__.__name__, self.transport.get_returncode())\n+ returncode = self.transport.get_returncode()\n+ assert isinstance(returncode, int)\n+ self.close(PeerExited(returncode))\n \n+ # Forwarding data: from the peer to the router\n def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:\n- # If this is an open message then we need to apply the routing rules to\n- # figure out the correct endpoint to connect. If it's not an open\n- # message, then we expect the endpoint to already exist.\n- if command == 'open':\n- if channel in self.open_channels:\n- raise CockpitProtocolError('channel is already open')\n+ if self.init_future is not None:\n+ raise CockpitProtocolError('Received unexpected channel control message before init')\n+ self.send_channel_control(channel, command, message)\n \n- try:\n- logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload'))\n- endpoint = self.check_rules(message)\n+ def channel_data_received(self, channel: str, data: bytes) -> None:\n+ if self.init_future is not None:\n+ raise CockpitProtocolError('Received unexpected channel data before init')\n+ self.send_channel_data(channel, data)\n+\n+ # Forwarding data: from the router to the peer\n+ def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:\n+ assert self.init_future is None\n+ self.write_control(message)\n+\n+ def do_channel_data(self, channel: str, data: bytes) -> None:\n+ assert self.init_future is None\n+ self.write_channel_data(channel, data)\n+\n+ def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n+ assert self.init_future is None\n+ self.write_control(message)\n+\n+ def do_close(self) -> None:\n+ self.close()\n+\n+\n+class ConfiguredPeer(Peer):\n+ config: BridgeConfig\n+ args: Sequence[str]\n+ env: Sequence[str]\n+\n+ def __init__(self, router: Router, config: BridgeConfig):\n+ self.config = config\n+ self.args = config.spawn\n+ self.env = config.environ\n+ super().__init__(router)\n+\n+ async def do_connect_transport(self) -> None:\n+ await self.spawn(self.args, self.env)\n+\n+\n+class PeerRoutingRule(RoutingRule):\n+ config: BridgeConfig\n+ match: JsonObject\n+ peer: Optional[Peer]\n+\n+ def __init__(self, router: Router, config: BridgeConfig):\n+ super().__init__(router)\n+ self.config = config\n+ self.match = config.match\n+ self.peer = None\n+\n+ def apply_rule(self, options: JsonObject) -> Optional[Peer]:\n+ # Check that we match\n+\n+ for key, value in self.match.items():\n+ if key not in options:\n+ logger.debug(' rejecting because key %s is missing', key)\n+ return None\n+ if value is not None and options[key] != value:\n+ logger.debug(' rejecting because key %s has wrong value %s (vs %s)', key, options[key], value)\n+ return None\n+\n+ # Start the peer if it's not running already\n+ if self.peer is None:\n+ self.peer = ConfiguredPeer(self.router, self.config)\n+ self.peer.add_done_callback(self.peer_closed)\n+ assert self.router.init_host\n+ self.peer.start_in_background(init_host=self.router.init_host)\n+\n+ return self.peer\n+\n+ def peer_closed(self):\n+ self.peer = None\n+\n+ def shutdown(self):\n+ if self.peer is not None:\n+ self.peer.close()\n+\n+\n+class PeersRoutingRule(RoutingRule):\n+ rules: List[PeerRoutingRule] = []\n+\n+ def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:\n+ logger.debug(' considering %d rules', len(self.rules))\n+ for rule in self.rules:\n+ logger.debug(' considering %s', rule.config.name)\n+ endpoint = rule.apply_rule(options)\n+ if endpoint is not None:\n+ logger.debug(' selected')\n+ return endpoint\n+ logger.debug(' no peer rules matched')\n+ return None\n+\n+ def set_configs(self, bridge_configs: Sequence[BridgeConfig]) -> None:\n+ old_rules = self.rules\n+ self.rules = []\n+\n+ for config in bridge_configs:\n+ # Those are handled elsewhere...\n+ if config.privileged or 'host' in config.match:\n+ continue\n+\n+ # Try to reuse an existing rule, if one exists...\n+ for rule in list(old_rules):\n+ if rule.config == config:\n+ old_rules.remove(rule)\n+ break\n+ else:\n+ # ... otherwise, create a new one.\n+ rule = PeerRoutingRule(self.router, config)\n+\n+ self.rules.append(rule)\n+\n+ # close down the old rules that didn't get reclaimed\n+ for rule in old_rules:\n+ rule.shutdown()\n+\n+ def shutdown(self):\n+ for rule in self.rules:\n+ rule.shutdown()\n+'''.encode('utf-8'),\n+ 'cockpit/_version.py': br'''__version__ = '317'\n+''',\n+ 'cockpit/router.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2022 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n+\n+import asyncio\n+import collections\n+import logging\n+from typing import Dict, List, Optional\n+\n+from .jsonutil import JsonObject, JsonValue\n+from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+class ExecutionQueue:\n+ \"\"\"Temporarily delay calls to a given set of class methods.\n+\n+ Functions by replacing the named function at the instance __dict__\n+ level, effectively providing an override for exactly one instance\n+ of `method`'s object.\n+ Queues the invocations. Run them later with .run(), which also reverses\n+ the redirection by deleting the named methods from the instance.\n+ \"\"\"\n+ def __init__(self, methods):\n+ self.queue = collections.deque()\n+ self.methods = methods\n+\n+ for method in self.methods:\n+ self._wrap(method)\n+\n+ def _wrap(self, method):\n+ # NB: this function is stored in the instance dict and therefore\n+ # doesn't function as a descriptor, isn't a method, doesn't get bound,\n+ # and therefore doesn't receive a self parameter\n+ setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args)))\n+\n+ def run(self):\n+ logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue))\n+ for method, args in self.queue:\n+ method(*args)\n+\n+ for method in self.methods:\n+ delattr(method.__self__, method.__func__.__name__)\n+\n+\n+class Endpoint:\n+ router: 'Router'\n+ __endpoint_frozen_queue: Optional[ExecutionQueue] = None\n+\n+ def __init__(self, router: 'Router'):\n+ router.add_endpoint(self)\n+ self.router = router\n+\n+ def freeze_endpoint(self):\n+ assert self.__endpoint_frozen_queue is None\n+ logger.debug('Freezing endpoint %s', self)\n+ self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill})\n+\n+ def thaw_endpoint(self):\n+ assert self.__endpoint_frozen_queue is not None\n+ logger.debug('Thawing endpoint %s', self)\n+ self.__endpoint_frozen_queue.run()\n+ self.__endpoint_frozen_queue = None\n+\n+ # interface for receiving messages\n+ def do_close(self):\n+ raise NotImplementedError\n+\n+ def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:\n+ raise NotImplementedError\n+\n+ def do_channel_data(self, channel: str, data: bytes) -> None:\n+ raise NotImplementedError\n+\n+ def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n+ raise NotImplementedError\n+\n+ # interface for sending messages\n+ def send_channel_data(self, channel: str, data: bytes) -> None:\n+ self.router.write_channel_data(channel, data)\n+\n+ def send_channel_control(\n+ self, channel: str, command: str, _msg: 'JsonObject | None', **kwargs: JsonValue\n+ ) -> None:\n+ self.router.write_control(_msg, channel=channel, command=command, **kwargs)\n+ if command == 'close':\n+ self.router.endpoints[self].remove(channel)\n+ self.router.drop_channel(channel)\n+\n+ def shutdown_endpoint(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n+ self.router.shutdown_endpoint(self, _msg, **kwargs)\n+\n+\n+class RoutingError(CockpitProblem):\n+ pass\n+\n+\n+class RoutingRule:\n+ router: 'Router'\n+\n+ def __init__(self, router: 'Router'):\n+ self.router = router\n+\n+ def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:\n+ \"\"\"Check if a routing rule applies to a given 'open' message.\n+\n+ This should inspect the options dictionary and do one of the following three things:\n+\n+ - return an Endpoint to handle this channel\n+ - raise a RoutingError to indicate that the open should be rejected\n+ - return None to let the next rule run\n+ \"\"\"\n+ raise NotImplementedError\n+\n+ def shutdown(self):\n+ raise NotImplementedError\n+\n+\n+class Router(CockpitProtocolServer):\n+ routing_rules: List[RoutingRule]\n+ open_channels: Dict[str, Endpoint]\n+ endpoints: 'dict[Endpoint, set[str]]'\n+ no_endpoints: asyncio.Event # set if endpoints dict is empty\n+ _eof: bool = False\n+\n+ def __init__(self, routing_rules: List[RoutingRule]):\n+ for rule in routing_rules:\n+ rule.router = self\n+ self.routing_rules = routing_rules\n+ self.open_channels = {}\n+ self.endpoints = {}\n+ self.no_endpoints = asyncio.Event()\n+ self.no_endpoints.set() # at first there are no endpoints\n+\n+ def check_rules(self, options: JsonObject) -> Endpoint:\n+ for rule in self.routing_rules:\n+ logger.debug(' applying rule %s', rule)\n+ endpoint = rule.apply_rule(options)\n+ if endpoint is not None:\n+ logger.debug(' resulting endpoint is %s', endpoint)\n+ return endpoint\n+ else:\n+ logger.debug(' No rules matched')\n+ raise RoutingError('not-supported')\n+\n+ def drop_channel(self, channel: str) -> None:\n+ try:\n+ self.open_channels.pop(channel)\n+ logger.debug('router dropped channel %s', channel)\n+ except KeyError:\n+ logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels)\n+\n+ def add_endpoint(self, endpoint: Endpoint) -> None:\n+ self.endpoints[endpoint] = set()\n+ self.no_endpoints.clear()\n+\n+ def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n+ channels = self.endpoints.pop(endpoint)\n+ logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels)\n+ for channel in channels:\n+ self.write_control(_msg, command='close', channel=channel, **kwargs)\n+ self.drop_channel(channel)\n+\n+ if not self.endpoints:\n+ self.no_endpoints.set()\n+\n+ # were we waiting to exit?\n+ if self._eof:\n+ logger.debug(' endpoints remaining: %r', self.endpoints)\n+ if not self.endpoints and self.transport:\n+ logger.debug(' close transport')\n+ self.transport.close()\n+\n+ def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n+ endpoints = set(self.endpoints)\n+ logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints))\n+ for endpoint in endpoints:\n+ endpoint.do_kill(host, group, message)\n+\n+ def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:\n+ # If this is an open message then we need to apply the routing rules to\n+ # figure out the correct endpoint to connect. If it's not an open\n+ # message, then we expect the endpoint to already exist.\n+ if command == 'open':\n+ if channel in self.open_channels:\n+ raise CockpitProtocolError('channel is already open')\n+\n+ try:\n+ logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload'))\n+ endpoint = self.check_rules(message)\n except RoutingError as exc:\n self.write_control(exc.get_attrs(), command='close', channel=channel)\n return\n \n self.open_channels[channel] = endpoint\n self.endpoints[endpoint].add(channel)\n else:\n@@ -4261,15 +4377,15 @@\n finally:\n self._communication_done = None\n \n # In an orderly exit, this is already done, but in case it wasn't\n # orderly, we need to make sure the endpoints shut down anyway...\n await self.no_endpoints.wait()\n ''',\n- 'cockpit/samples.py': br'''# This file is part of Cockpit.\n+ 'cockpit/packages.py': br'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -4278,437 +4394,579 @@\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import errno\n+import collections\n+import contextlib\n+import functools\n+import gzip\n+import io\n+import itertools\n+import json\n import logging\n+import mimetypes\n import os\n import re\n-from typing import Any, DefaultDict, Iterable, List, NamedTuple, Optional, Tuple\n-\n-from cockpit._vendor.systemd_ctypes import Handle\n+import shutil\n+from pathlib import Path\n+from typing import (\n+ BinaryIO,\n+ Callable,\n+ ClassVar,\n+ Dict,\n+ Iterable,\n+ List,\n+ NamedTuple,\n+ Optional,\n+ Pattern,\n+ Sequence,\n+ Tuple,\n+ TypeVar,\n+)\n \n-USER_HZ = os.sysconf(os.sysconf_names['SC_CLK_TCK'])\n-MS_PER_JIFFY = 1000 / (USER_HZ if (USER_HZ > 0) else 100)\n-HWMON_PATH = '/sys/class/hwmon'\n+from cockpit._vendor.systemd_ctypes import bus\n \n-# we would like to do this, but mypy complains; https://github.com/python/mypy/issues/2900\n-# Samples = collections.defaultdict[str, Union[float, Dict[str, Union[float, None]]]]\n-Samples = DefaultDict[str, Any]\n+from . import config\n+from ._version import __version__\n+from .jsonutil import (\n+ JsonError,\n+ JsonObject,\n+ JsonValue,\n+ get_bool,\n+ get_dict,\n+ get_int,\n+ get_objv,\n+ get_str,\n+ get_strv,\n+ json_merge_patch,\n+ typechecked,\n+)\n \n logger = logging.getLogger(__name__)\n \n \n-def read_int_file(rootfd: int, statfile: str, default: Optional[int] = None, key: bytes = b'') -> Optional[int]:\n- # Not every stat is available, such as cpu.weight\n- try:\n- fd = os.open(statfile, os.O_RDONLY, dir_fd=rootfd)\n- except FileNotFoundError:\n- return None\n+# In practice, this is going to get called over and over again with exactly the\n+# same list. Let's try to cache the result.\n+@functools.lru_cache()\n+def parse_accept_language(accept_language: str) -> Sequence[str]:\n+ \"\"\"Parse the Accept-Language header, if it exists.\n \n- try:\n- data = os.read(fd, 1024)\n- except OSError as e:\n- # cgroups can disappear between the open and read\n- if e.errno != errno.ENODEV:\n- logger.warning('Failed to read %s: %s', statfile, e)\n- return None\n- finally:\n- os.close(fd)\n+ Returns an ordered list of languages, with fallbacks inserted, and\n+ truncated to the position where 'en' would have otherwise appeared, if\n+ applicable.\n \n- if key:\n- start = data.index(key) + len(key)\n- end = data.index(b'\\n', start)\n- data = data[start:end]\n+ https://tools.ietf.org/html/rfc7231#section-5.3.5\n+ https://datatracker.ietf.org/doc/html/rfc4647#section-3.4\n+ \"\"\"\n \n- try:\n- # 0 often means \"none\", so replace it with default value\n- return int(data) or default\n- except ValueError:\n- # Some samples such as \"memory.max\" contains \"max\" when there is a no limit\n- return None\n+ logger.debug('parse_accept_language(%r)', accept_language)\n+ locales_with_q = []\n+ for entry in accept_language.split(','):\n+ entry = entry.strip().lower()\n+ logger.debug(' entry %r', entry)\n+ locale, _, qstr = entry.partition(';q=')\n+ try:\n+ q = float(qstr or 1.0)\n+ except ValueError:\n+ continue # ignore malformed entry\n \n+ while locale:\n+ logger.debug(' adding %r q=%r', locale, q)\n+ locales_with_q.append((locale, q))\n+ # strip off '-detail' suffixes until there's nothing left\n+ locale, _, _region = locale.rpartition('-')\n \n-class SampleDescription(NamedTuple):\n- name: str\n- units: str\n- semantics: str\n- instanced: bool\n+ # Sort the list by highest q value. Otherwise, this is a stable sort.\n+ locales_with_q.sort(key=lambda pair: pair[1], reverse=True)\n+ logger.debug(' sorted list is %r', locales_with_q)\n \n+ # If we have 'en' anywhere in our list, ignore it and all items after it.\n+ # This will result in us getting an untranslated (ie: English) version if\n+ # none of the more-preferred languages are found, which is what we want.\n+ # We also take the chance to drop duplicate items. Note: both of these\n+ # things need to happen after sorting.\n+ results = []\n+ for locale, _q in locales_with_q:\n+ if locale == 'en':\n+ break\n+ if locale not in results:\n+ results.append(locale)\n \n-class Sampler:\n- descriptions: List[SampleDescription]\n+ logger.debug(' results list is %r', results)\n+ return tuple(results)\n \n- def sample(self, samples: Samples) -> None:\n- raise NotImplementedError\n \n+def sortify_version(version: str) -> str:\n+ \"\"\"Convert a version string to a form that can be compared\"\"\"\n+ # 0-pad each numeric component. Only supports numeric versions like 1.2.3.\n+ return '.'.join(part.zfill(8) for part in version.split('.'))\n \n-class CPUSampler(Sampler):\n- descriptions = [\n- SampleDescription('cpu.basic.nice', 'millisec', 'counter', instanced=False),\n- SampleDescription('cpu.basic.user', 'millisec', 'counter', instanced=False),\n- SampleDescription('cpu.basic.system', 'millisec', 'counter', instanced=False),\n- SampleDescription('cpu.basic.iowait', 'millisec', 'counter', instanced=False),\n \n- SampleDescription('cpu.core.nice', 'millisec', 'counter', instanced=True),\n- SampleDescription('cpu.core.user', 'millisec', 'counter', instanced=True),\n- SampleDescription('cpu.core.system', 'millisec', 'counter', instanced=True),\n- SampleDescription('cpu.core.iowait', 'millisec', 'counter', instanced=True),\n- ]\n+@functools.lru_cache()\n+def get_libexecdir() -> str:\n+ \"\"\"Detect libexecdir on current machine\n \n- def sample(self, samples: Samples) -> None:\n- with open('/proc/stat') as stat:\n- for line in stat:\n- if not line.startswith('cpu'):\n- continue\n- cpu, user, nice, system, _idle, iowait = line.split()[:6]\n- core = cpu[3:] or None\n- if core:\n- prefix = 'cpu.core'\n- samples[f'{prefix}.nice'][core] = int(nice) * MS_PER_JIFFY\n- samples[f'{prefix}.user'][core] = int(user) * MS_PER_JIFFY\n- samples[f'{prefix}.system'][core] = int(system) * MS_PER_JIFFY\n- samples[f'{prefix}.iowait'][core] = int(iowait) * MS_PER_JIFFY\n- else:\n- prefix = 'cpu.basic'\n- samples[f'{prefix}.nice'] = int(nice) * MS_PER_JIFFY\n- samples[f'{prefix}.user'] = int(user) * MS_PER_JIFFY\n- samples[f'{prefix}.system'] = int(system) * MS_PER_JIFFY\n- samples[f'{prefix}.iowait'] = int(iowait) * MS_PER_JIFFY\n+ This only works for systems which have cockpit-ws installed.\n+ \"\"\"\n+ for candidate in ['/usr/local/libexec', '/usr/libexec', '/usr/local/lib/cockpit', '/usr/lib/cockpit']:\n+ if os.path.exists(os.path.join(candidate, 'cockpit-askpass')):\n+ return candidate\n+ else:\n+ logger.warning('Could not detect libexecdir')\n+ # give readable error messages\n+ return '/nonexistent/libexec'\n \n \n-class MemorySampler(Sampler):\n- descriptions = [\n- SampleDescription('memory.free', 'bytes', 'instant', instanced=False),\n- SampleDescription('memory.used', 'bytes', 'instant', instanced=False),\n- SampleDescription('memory.cached', 'bytes', 'instant', instanced=False),\n- SampleDescription('memory.swap-used', 'bytes', 'instant', instanced=False),\n- ]\n+# HACK: Type narrowing over Union types is not supported in the general case,\n+# but this works for the case we care about: knowing that when we pass in an\n+# JsonObject, we'll get an JsonObject back.\n+J = TypeVar('J', JsonObject, JsonValue)\n \n- def sample(self, samples: Samples) -> None:\n- with open('/proc/meminfo') as meminfo:\n- items = {k: int(v.strip(' kB\\n')) for line in meminfo for k, v in [line.split(':', 1)]}\n \n- samples['memory.free'] = 1024 * items['MemFree']\n- samples['memory.used'] = 1024 * (items['MemTotal'] - items['MemAvailable'])\n- samples['memory.cached'] = 1024 * (items['Buffers'] + items['Cached'])\n- samples['memory.swap-used'] = 1024 * (items['SwapTotal'] - items['SwapFree'])\n+def patch_libexecdir(obj: J) -> J:\n+ if isinstance(obj, str):\n+ if '${libexecdir}/cockpit-askpass' in obj:\n+ # extra-special case: we handle this internally\n+ abs_askpass = shutil.which('cockpit-askpass')\n+ if abs_askpass is not None:\n+ return obj.replace('${libexecdir}/cockpit-askpass', abs_askpass)\n+ return obj.replace('${libexecdir}', get_libexecdir())\n+ elif isinstance(obj, dict):\n+ return {key: patch_libexecdir(value) for key, value in obj.items()}\n+ elif isinstance(obj, list):\n+ return [patch_libexecdir(item) for item in obj]\n+ else:\n+ return obj\n \n \n-class CPUTemperatureSampler(Sampler):\n- # Cache found sensors, as they can't be hotplugged.\n- sensors: Optional[List[str]] = None\n+# A document is a binary stream with a Content-Type, optional Content-Encoding,\n+# and optional Content-Security-Policy\n+class Document(NamedTuple):\n+ data: BinaryIO\n+ content_type: str\n+ content_encoding: Optional[str] = None\n+ content_security_policy: Optional[str] = None\n \n- descriptions = [\n- SampleDescription('cpu.temperature', 'celsius', 'instant', instanced=True),\n- ]\n \n- @staticmethod\n- def detect_cpu_sensors(dir_fd: int) -> Iterable[str]:\n- # Read the name file to decide what to do with this directory\n+class PackagesListener:\n+ def packages_loaded(self) -> None:\n+ \"\"\"Called when the packages have been reloaded\"\"\"\n+\n+\n+class BridgeConfig(dict, JsonObject):\n+ def __init__(self, value: JsonObject):\n+ super().__init__(value)\n+\n+ self.label = get_str(self, 'label', None)\n+\n+ self.privileged = get_bool(self, 'privileged', default=False)\n+ self.match = get_dict(self, 'match', {})\n+ if not self.privileged and not self.match:\n+ raise JsonError(value, 'must have match rules or be privileged')\n+\n+ self.environ = get_strv(self, 'environ', ())\n+ self.spawn = get_strv(self, 'spawn')\n+ if not self.spawn:\n+ raise JsonError(value, 'spawn vector must be non-empty')\n+\n+ self.name = self.label or self.spawn[0]\n+\n+\n+class Condition:\n+ def __init__(self, value: JsonObject):\n try:\n- with Handle.open('name', os.O_RDONLY, dir_fd=dir_fd) as fd:\n- name = os.read(fd, 1024).decode().strip()\n- except FileNotFoundError:\n- return\n+ (self.name, self.value), = value.items()\n+ except ValueError as exc:\n+ raise JsonError(value, 'must contain exactly one key/value pair') from exc\n \n- if name == 'atk0110':\n- # only sample 'CPU Temperature' in atk0110\n- predicate = (lambda label: label == 'CPU Temperature')\n- elif name == 'cpu_thermal':\n- # labels are not used on ARM\n- predicate = None\n- elif name == 'coretemp':\n- # accept all labels on Intel\n- predicate = None\n- elif name in ['k8temp', 'k10temp']:\n- predicate = None\n- else:\n- # Not a CPU sensor\n+\n+class Manifest(dict, JsonObject):\n+ # Skip version check when running out of the git checkout (__version__ is None)\n+ COCKPIT_VERSION = __version__ and sortify_version(__version__)\n+\n+ def __init__(self, path: Path, value: JsonObject):\n+ super().__init__(value)\n+ self.path = path\n+ self.name = get_str(self, 'name', self.path.name)\n+ self.bridges = get_objv(self, 'bridges', BridgeConfig)\n+ self.priority = get_int(self, 'priority', 1)\n+ self.csp = get_str(self, 'content-security-policy', '')\n+ self.conditions = get_objv(self, 'conditions', Condition)\n+\n+ # Skip version check when running out of the git checkout (COCKPIT_VERSION is None)\n+ if self.COCKPIT_VERSION is not None:\n+ requires = get_dict(self, 'requires', {})\n+ for name, version in requires.items():\n+ if name != 'cockpit':\n+ raise JsonError(name, 'non-cockpit requirement listed')\n+ if sortify_version(typechecked(version, str)) > self.COCKPIT_VERSION:\n+ raise JsonError(version, f'required cockpit version ({version}) not met')\n+\n+\n+class Package:\n+ # For po{,.manifest}.js files, the interesting part is the locale name\n+ PO_JS_RE: ClassVar[Pattern] = re.compile(r'(po|po\\.manifest)\\.([^.]+)\\.js(\\.gz)?')\n+\n+ # immutable after __init__\n+ manifest: Manifest\n+ name: str\n+ path: Path\n+ priority: int\n+\n+ # computed later\n+ translations: Optional[Dict[str, Dict[str, str]]] = None\n+ files: Optional[Dict[str, str]] = None\n+\n+ def __init__(self, manifest: Manifest):\n+ self.manifest = manifest\n+ self.name = manifest.name\n+ self.path = manifest.path\n+ self.priority = manifest.priority\n+\n+ def ensure_scanned(self) -> None:\n+ \"\"\"Ensure that the package has been scanned.\n+\n+ This allows us to defer scanning the files of the package until we know\n+ that we'll actually use it.\n+ \"\"\"\n+\n+ if self.files is not None:\n return\n \n- # Now scan the directory for inputs\n- for input_filename in os.listdir(dir_fd):\n- if not input_filename.endswith('_input'):\n+ self.files = {}\n+ self.translations = {'po.js': {}, 'po.manifest.js': {}}\n+\n+ for file in self.path.rglob('*'):\n+ name = str(file.relative_to(self.path))\n+ if name in ['.', '..', 'manifest.json']:\n continue\n \n- if predicate:\n- # We need to check the label\n- try:\n- label_filename = input_filename.replace('_input', '_label')\n- with Handle.open(label_filename, os.O_RDONLY, dir_fd=dir_fd) as fd:\n- label = os.read(fd, 1024).decode().strip()\n- except FileNotFoundError:\n- continue\n+ po_match = Package.PO_JS_RE.fullmatch(name)\n+ if po_match:\n+ basename = po_match.group(1)\n+ locale = po_match.group(2)\n+ # Accept-Language is case-insensitive and uses '-' to separate variants\n+ lower_locale = locale.lower().replace('_', '-')\n \n- if not predicate(label):\n- continue\n+ logger.debug('Adding translation %r %r -> %r', basename, lower_locale, name)\n+ self.translations[f'{basename}.js'][lower_locale] = name\n+ else:\n+ # strip out trailing '.gz' components\n+ basename = re.sub('.gz$', '', name)\n+ logger.debug('Adding content %r -> %r', basename, name)\n+ self.files[basename] = name\n \n- yield input_filename\n+ # If we see a filename like `x.min.js` we want to also offer it\n+ # at `x.js`, but only if `x.js(.gz)` itself is not present.\n+ # Note: this works for both the case where we found the `x.js`\n+ # first (it's already in the map) and also if we find it second\n+ # (it will be replaced in the map by the line just above).\n+ # See https://github.com/cockpit-project/cockpit/pull/19716\n+ self.files.setdefault(basename.replace('.min.', '.'), name)\n \n- @staticmethod\n- def scan_sensors() -> Iterable[str]:\n- try:\n- top_fd = Handle.open(HWMON_PATH, os.O_RDONLY | os.O_DIRECTORY)\n- except FileNotFoundError:\n- return\n+ # support old cockpit-po-plugin which didn't write po.manifest.??.js\n+ if not self.translations['po.manifest.js']:\n+ self.translations['po.manifest.js'] = self.translations['po.js']\n \n- with top_fd:\n- for hwmon_name in os.listdir(top_fd):\n- with Handle.open(hwmon_name, os.O_RDONLY | os.O_DIRECTORY, dir_fd=top_fd) as subdir_fd:\n- for sensor in CPUTemperatureSampler.detect_cpu_sensors(subdir_fd):\n- yield f'{HWMON_PATH}/{hwmon_name}/{sensor}'\n+ def get_content_security_policy(self) -> str:\n+ policy = {\n+ \"default-src\": \"'self'\",\n+ \"connect-src\": \"'self'\",\n+ \"form-action\": \"'self'\",\n+ \"base-uri\": \"'self'\",\n+ \"object-src\": \"'none'\",\n+ \"font-src\": \"'self' data:\",\n+ \"img-src\": \"'self' data:\",\n+ }\n \n- def sample(self, samples: Samples) -> None:\n- if self.sensors is None:\n- self.sensors = list(CPUTemperatureSampler.scan_sensors())\n+ for item in self.manifest.csp.split(';'):\n+ item = item.strip()\n+ if item:\n+ key, _, value = item.strip().partition(' ')\n+ policy[key] = value\n \n- for sensor_path in self.sensors:\n- with open(sensor_path) as sensor:\n- temperature = int(sensor.read().strip())\n- if temperature == 0:\n- return\n+ return ' '.join(f'{k} {v};' for k, v in policy.items()) + ' block-all-mixed-content'\n \n- samples['cpu.temperature'][sensor_path] = temperature / 1000\n+ def load_file(self, filename: str) -> Document:\n+ content_type, content_encoding = mimetypes.guess_type(filename)\n+ content_security_policy = None\n \n+ if content_type is None:\n+ content_type = 'text/plain'\n+ elif content_type.startswith('text/html'):\n+ content_security_policy = self.get_content_security_policy()\n \n-class DiskSampler(Sampler):\n- descriptions = [\n- SampleDescription('disk.all.read', 'bytes', 'counter', instanced=False),\n- SampleDescription('disk.all.written', 'bytes', 'counter', instanced=False),\n- SampleDescription('disk.dev.read', 'bytes', 'counter', instanced=True),\n- SampleDescription('disk.dev.written', 'bytes', 'counter', instanced=True),\n- ]\n+ path = self.path / filename\n+ logger.debug(' loading data from %s', path)\n \n- def sample(self, samples: Samples) -> None:\n- with open('/proc/diskstats') as diskstats:\n- all_read_bytes = 0\n- all_written_bytes = 0\n+ return Document(path.open('rb'), content_type, content_encoding, content_security_policy)\n \n- for line in diskstats:\n- # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats\n- fields = line.strip().split()\n- dev_major = fields[0]\n- dev_name = fields[2]\n- num_sectors_read = fields[5]\n- num_sectors_written = fields[9]\n+ def load_translation(self, path: str, locales: Sequence[str]) -> Document:\n+ self.ensure_scanned()\n+ assert self.translations is not None\n \n- # ignore mdraid\n- if dev_major == '9':\n- continue\n+ # First match wins\n+ for locale in locales:\n+ with contextlib.suppress(KeyError):\n+ return self.load_file(self.translations[path][locale])\n \n- # ignore device-mapper\n- if dev_name.startswith('dm-'):\n- continue\n+ # We prefer to return an empty document than 404 in order to avoid\n+ # errors in the console when a translation can't be found\n+ return Document(io.BytesIO(), 'text/javascript')\n \n- # Skip partitions\n- if dev_name[:2] in ['sd', 'hd', 'vd'] and dev_name[-1].isdigit():\n- continue\n+ def load_path(self, path: str, headers: JsonObject) -> Document:\n+ self.ensure_scanned()\n+ assert self.files is not None\n+ assert self.translations is not None\n \n- # Ignore nvme partitions\n- if dev_name.startswith('nvme') and 'p' in dev_name:\n- continue\n+ if path in self.translations:\n+ locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))\n+ return self.load_translation(path, locales)\n+ else:\n+ return self.load_file(self.files[path])\n \n- read_bytes = int(num_sectors_read) * 512\n- written_bytes = int(num_sectors_written) * 512\n \n- all_read_bytes += read_bytes\n- all_written_bytes += written_bytes\n+class PackagesLoader:\n+ CONDITIONS: ClassVar[Dict[str, Callable[[str], bool]]] = {\n+ 'path-exists': os.path.exists,\n+ 'path-not-exists': lambda p: not os.path.exists(p),\n+ }\n \n- samples['disk.dev.read'][dev_name] = read_bytes\n- samples['disk.dev.written'][dev_name] = written_bytes\n+ @classmethod\n+ def get_xdg_data_dirs(cls) -> Iterable[str]:\n+ try:\n+ yield os.environ['XDG_DATA_HOME']\n+ except KeyError:\n+ yield os.path.expanduser('~/.local/share')\n \n- samples['disk.all.read'] = all_read_bytes\n- samples['disk.all.written'] = all_written_bytes\n+ try:\n+ yield from os.environ['XDG_DATA_DIRS'].split(':')\n+ except KeyError:\n+ yield from ('/usr/local/share', '/usr/share')\n \n+ @classmethod\n+ def patch_manifest(cls, manifest: JsonObject, parent: Path) -> JsonObject:\n+ override_files = [\n+ parent / 'override.json',\n+ config.lookup_config(f'{parent.name}.override.json'),\n+ config.DOT_CONFIG_COCKPIT / f'{parent.name}.override.json',\n+ ]\n \n-class CGroupSampler(Sampler):\n- descriptions = [\n- SampleDescription('cgroup.memory.usage', 'bytes', 'instant', instanced=True),\n- SampleDescription('cgroup.memory.limit', 'bytes', 'instant', instanced=True),\n- SampleDescription('cgroup.memory.sw-usage', 'bytes', 'instant', instanced=True),\n- SampleDescription('cgroup.memory.sw-limit', 'bytes', 'instant', instanced=True),\n- SampleDescription('cgroup.cpu.usage', 'millisec', 'counter', instanced=True),\n- SampleDescription('cgroup.cpu.shares', 'count', 'instant', instanced=True),\n- ]\n+ for override_file in override_files:\n+ try:\n+ override: JsonValue = json.loads(override_file.read_bytes())\n+ except FileNotFoundError:\n+ continue\n+ except json.JSONDecodeError as exc:\n+ # User input error: report a warning\n+ logger.warning('%s: %s', override_file, exc)\n \n- cgroups_v2: Optional[bool] = None\n+ if not isinstance(override, dict):\n+ logger.warning('%s: override file is not a dictionary', override_file)\n+ continue\n \n- def sample(self, samples: Samples) -> None:\n- if self.cgroups_v2 is None:\n- self.cgroups_v2 = os.path.exists('/sys/fs/cgroup/cgroup.controllers')\n+ manifest = json_merge_patch(manifest, override)\n \n- if self.cgroups_v2:\n- cgroups_v2_path = '/sys/fs/cgroup/'\n- for path, _, _, rootfd in os.fwalk(cgroups_v2_path):\n- cgroup = path.replace(cgroups_v2_path, '')\n+ return patch_libexecdir(manifest)\n \n- if not cgroup:\n+ @classmethod\n+ def load_manifests(cls) -> Iterable[Manifest]:\n+ for datadir in cls.get_xdg_data_dirs():\n+ logger.debug(\"Scanning for manifest files under %s\", datadir)\n+ for file in Path(datadir).glob('cockpit/*/manifest.json'):\n+ logger.debug(\"Considering file %s\", file)\n+ try:\n+ manifest = json.loads(file.read_text())\n+ except json.JSONDecodeError as exc:\n+ logger.error(\"%s: %s\", file, exc)\n+ continue\n+ if not isinstance(manifest, dict):\n+ logger.error(\"%s: json document isn't an object\", file)\n continue\n \n- samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.current', 0)\n- samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.max')\n- samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.swap.current', 0)\n- samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.swap.max')\n- samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.weight')\n- usage_usec = read_int_file(rootfd, 'cpu.stat', 0, key=b'usage_usec')\n- if usage_usec:\n- samples['cgroup.cpu.usage'][cgroup] = usage_usec / 1000\n- else:\n- memory_path = '/sys/fs/cgroup/memory/'\n- for path, _, _, rootfd in os.fwalk(memory_path):\n- cgroup = path.replace(memory_path, '')\n+ parent = file.parent\n+ manifest = cls.patch_manifest(manifest, parent)\n+ try:\n+ yield Manifest(parent, manifest)\n+ except JsonError as exc:\n+ logger.warning('%s %s', file, exc)\n \n- if not cgroup:\n- continue\n+ def check_condition(self, condition: str, value: object) -> bool:\n+ check_fn = self.CONDITIONS[condition]\n \n- samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.usage_in_bytes', 0)\n- samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.limit_in_bytes')\n- samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.memsw.usage_in_bytes', 0)\n- samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.memsw.limit_in_bytes')\n+ # All known predicates currently only work on strings\n+ if not isinstance(value, str):\n+ return False\n \n- cpu_path = '/sys/fs/cgroup/cpu/'\n- for path, _, _, rootfd in os.fwalk(cpu_path):\n- cgroup = path.replace(cpu_path, '')\n+ return check_fn(value)\n \n- if not cgroup:\n- continue\n+ def check_conditions(self, manifest: Manifest) -> bool:\n+ for condition in manifest.conditions:\n+ try:\n+ okay = self.check_condition(condition.name, condition.value)\n+ except KeyError:\n+ # do *not* ignore manifests with unknown predicates, for forward compatibility\n+ logger.warning(' %s: ignoring unknown predicate in manifest: %s', manifest.path, condition.name)\n+ continue\n \n- samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.shares')\n- usage_nsec = read_int_file(rootfd, 'cpuacct.usage')\n- if usage_nsec:\n- samples['cgroup.cpu.usage'][cgroup] = usage_nsec / 1000000\n+ if not okay:\n+ logger.debug(' hiding package %s as its %s condition is not met', manifest.path, condition)\n+ return False\n \n+ return True\n \n-class CGroupDiskIO(Sampler):\n- IO_RE = re.compile(rb'\\bread_bytes: (?P\\d+).*\\nwrite_bytes: (?P\\d+)', flags=re.S)\n- descriptions = [\n- SampleDescription('disk.cgroup.read', 'bytes', 'counter', instanced=True),\n- SampleDescription('disk.cgroup.written', 'bytes', 'counter', instanced=True),\n- ]\n+ def load_packages(self) -> Iterable[Tuple[str, Package]]:\n+ logger.debug('Scanning for available package manifests:')\n+ # Sort all available packages into buckets by to their claimed name\n+ names: Dict[str, List[Manifest]] = collections.defaultdict(list)\n+ for manifest in self.load_manifests():\n+ logger.debug(' %s/manifest.json', manifest.path)\n+ names[manifest.name].append(manifest)\n+ logger.debug('done.')\n \n- @staticmethod\n- def get_cgroup_name(fd: int) -> str:\n- with Handle.open('cgroup', os.O_RDONLY, dir_fd=fd) as cgroup_fd:\n- cgroup_name = os.read(cgroup_fd, 2048).decode().strip()\n+ logger.debug('Selecting packages to serve:')\n+ for name, candidates in names.items():\n+ # For each package name, iterate the candidates in descending\n+ # priority order and select the first one which passes all checks\n+ for candidate in sorted(candidates, key=lambda manifest: manifest.priority, reverse=True):\n+ try:\n+ if self.check_conditions(candidate):\n+ logger.debug(' creating package %s -> %s', name, candidate.path)\n+ yield name, Package(candidate)\n+ break\n+ except JsonError:\n+ logger.warning(' %s: ignoring package with invalid manifest file', candidate.path)\n \n- # Skip leading ::0/\n- return cgroup_name[4:]\n+ logger.debug(' ignoring %s: unmet conditions', candidate.path)\n+ logger.debug('done.')\n \n- @staticmethod\n- def get_proc_io(fd: int) -> Tuple[int, int]:\n- with Handle.open('io', os.O_RDONLY, dir_fd=fd) as io_fd:\n- data = os.read(io_fd, 4096)\n \n- match = re.search(CGroupDiskIO.IO_RE, data)\n- if match:\n- proc_read = int(match.group('read'))\n- proc_write = int(match.group('write'))\n+class Packages(bus.Object, interface='cockpit.Packages'):\n+ loader: PackagesLoader\n+ listener: Optional[PackagesListener]\n+ packages: Dict[str, Package]\n+ saw_first_reload_hint: bool\n \n- return proc_read, proc_write\n+ def __init__(self, listener: Optional[PackagesListener] = None, loader: Optional[PackagesLoader] = None):\n+ self.listener = listener\n+ self.loader = loader or PackagesLoader()\n+ self.load()\n \n- return 0, 0\n+ # Reloading the Shell in the browser should reload the\n+ # packages. This is implemented by having the Shell call\n+ # reload_hint whenever it starts. The first call of this\n+ # method in each session is ignored so that packages are not\n+ # loaded twice right after logging in.\n+ #\n+ self.saw_first_reload_hint = False\n \n- def sample(self, samples: Samples):\n- with Handle.open('/proc', os.O_RDONLY | os.O_DIRECTORY) as proc_fd:\n- reads = samples['disk.cgroup.read']\n- writes = samples['disk.cgroup.written']\n+ def load(self) -> None:\n+ self.packages = dict(self.loader.load_packages())\n+ self.manifests = json.dumps({name: dict(package.manifest) for name, package in self.packages.items()})\n+ logger.debug('Packages loaded: %s', list(self.packages))\n \n- for path in os.listdir(proc_fd):\n- # non-pid entries in proc are guaranteed to start with a character a-z\n- if path[0] < '0' or path[0] > '9':\n- continue\n+ def show(self):\n+ for name in sorted(self.packages):\n+ package = self.packages[name]\n+ menuitems = []\n+ for entry in itertools.chain(\n+ package.manifest.get('menu', {}).values(),\n+ package.manifest.get('tools', {}).values()):\n+ with contextlib.suppress(KeyError):\n+ menuitems.append(entry['label'])\n+ print(f'{name:20} {\", \".join(menuitems):40} {package.path}')\n \n- try:\n- with Handle.open(path, os.O_PATH, dir_fd=proc_fd) as pid_fd:\n- cgroup_name = self.get_cgroup_name(pid_fd)\n- proc_read, proc_write = self.get_proc_io(pid_fd)\n- except (FileNotFoundError, PermissionError, ProcessLookupError):\n- continue\n+ def get_bridge_configs(self) -> Sequence[BridgeConfig]:\n+ def yield_configs():\n+ for package in sorted(self.packages.values(), key=lambda package: -package.priority):\n+ yield from package.manifest.bridges\n+ return tuple(yield_configs())\n \n- reads[cgroup_name] = reads.get(cgroup_name, 0) + proc_read\n- writes[cgroup_name] = writes.get(cgroup_name, 0) + proc_write\n+ # D-Bus Interface\n+ manifests = bus.Interface.Property('s', value=\"{}\")\n \n+ @bus.Interface.Method()\n+ def reload(self):\n+ self.load()\n+ if self.listener is not None:\n+ self.listener.packages_loaded()\n \n-class NetworkSampler(Sampler):\n- descriptions = [\n- SampleDescription('network.interface.tx', 'bytes', 'counter', instanced=True),\n- SampleDescription('network.interface.rx', 'bytes', 'counter', instanced=True),\n- ]\n+ @bus.Interface.Method()\n+ def reload_hint(self):\n+ if self.saw_first_reload_hint:\n+ self.reload()\n+ self.saw_first_reload_hint = True\n \n- def sample(self, samples: Samples) -> None:\n- with open(\"/proc/net/dev\") as network_samples:\n- for line in network_samples:\n- fields = line.split()\n+ def load_manifests_js(self, headers: JsonObject) -> Document:\n+ logger.debug('Serving /manifests.js')\n \n- # Skip header line\n- if fields[0][-1] != ':':\n- continue\n+ chunks: List[bytes] = []\n \n- iface = fields[0][:-1]\n- samples['network.interface.rx'][iface] = int(fields[1])\n- samples['network.interface.tx'][iface] = int(fields[9])\n+ # Send the translations required for the manifest files, from each package\n+ locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))\n+ for name, package in self.packages.items():\n+ if name in ['static', 'base1']:\n+ continue\n \n+ # find_translation will always find at least 'en'\n+ translation = package.load_translation('po.manifest.js', locales)\n+ with translation.data:\n+ if translation.content_encoding == 'gzip':\n+ data = gzip.decompress(translation.data.read())\n+ else:\n+ data = translation.data.read()\n \n-class MountSampler(Sampler):\n- descriptions = [\n- SampleDescription('mount.total', 'bytes', 'instant', instanced=True),\n- SampleDescription('mount.used', 'bytes', 'instant', instanced=True),\n- ]\n+ chunks.append(data)\n \n- def sample(self, samples: Samples) -> None:\n- with open('/proc/mounts') as mounts:\n- for line in mounts:\n- # Only look at real devices\n- if line[0] != '/':\n- continue\n+ chunks.append(b\"\"\"\n+ (function (root, data) {\n+ if (typeof define === 'function' && define.amd) {\n+ define(data);\n+ }\n \n- path = line.split()[1]\n- try:\n- res = os.statvfs(path)\n- except OSError:\n- continue\n- frsize = res.f_frsize\n- total = frsize * res.f_blocks\n- samples['mount.total'][path] = total\n- samples['mount.used'][path] = total - frsize * res.f_bfree\n+ if (typeof cockpit === 'object') {\n+ cockpit.manifests = data;\n+ } else {\n+ root.manifests = data;\n+ }\n+ }(this, \"\"\" + self.manifests.encode() + b\"\"\"))\"\"\")\n \n+ return Document(io.BytesIO(b'\\n'.join(chunks)), 'text/javascript')\n \n-class BlockSampler(Sampler):\n- descriptions = [\n- SampleDescription('block.device.read', 'bytes', 'counter', instanced=True),\n- SampleDescription('block.device.written', 'bytes', 'counter', instanced=True),\n- ]\n+ def load_manifests_json(self) -> Document:\n+ logger.debug('Serving /manifests.json')\n+ return Document(io.BytesIO(self.manifests.encode()), 'application/json')\n \n- def sample(self, samples: Samples) -> None:\n- with open('/proc/diskstats') as diskstats:\n- for line in diskstats:\n- # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats\n- [_, _, dev_name, _, _, sectors_read, _, _, _, sectors_written, *_] = line.strip().split()\n+ PATH_RE = re.compile(\n+ r'/' # leading '/'\n+ r'(?:([^/]+)/)?' # optional leading path component\n+ r'((?:[^/]+/)*[^/]+)' # remaining path components\n+ )\n \n- samples['block.device.read'][dev_name] = int(sectors_read) * 512\n- samples['block.device.written'][dev_name] = int(sectors_written) * 512\n+ def load_path(self, path: str, headers: JsonObject) -> Document:\n+ logger.debug('packages: serving %s', path)\n \n+ match = self.PATH_RE.fullmatch(path)\n+ if match is None:\n+ raise ValueError(f'Invalid HTTP path {path}')\n+ packagename, filename = match.groups()\n \n-SAMPLERS = [\n- BlockSampler,\n- CGroupSampler,\n- CGroupDiskIO,\n- CPUSampler,\n- CPUTemperatureSampler,\n- DiskSampler,\n- MemorySampler,\n- MountSampler,\n- NetworkSampler,\n-]\n+ if packagename is not None:\n+ return self.packages[packagename].load_path(filename, headers)\n+ elif filename == 'manifests.js':\n+ return self.load_manifests_js(headers)\n+ elif filename == 'manifests.json':\n+ return self.load_manifests_json()\n+ else:\n+ raise KeyError\n ''',\n- 'cockpit/protocol.py': br'''# This file is part of Cockpit.\n+ 'cockpit/channels/http.py': br'''# This file is part of Cockpit.\n #\n # Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -4717,1838 +4975,1843 @@\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import asyncio\n-import json\n+import http.client\n import logging\n-import traceback\n-import uuid\n+import socket\n+import ssl\n \n-from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_int, get_str, get_str_or_none, typechecked\n+from ..channel import AsyncChannel, ChannelError\n+from ..jsonutil import JsonObject, get_dict, get_enum, get_int, get_object, get_str, typechecked\n \n logger = logging.getLogger(__name__)\n \n \n-class CockpitProblem(Exception):\n- \"\"\"A type of exception that carries a problem code and a message.\n-\n- Depending on the scope, this is used to handle shutting down:\n-\n- - an individual channel (sends problem code in the close message)\n- - peer connections (sends problem code in close message for each open channel)\n- - the main stdio interaction with the bridge\n-\n- It is usually thrown in response to some violation of expected protocol\n- when parsing messages, connecting to a peer, or opening a channel.\n- \"\"\"\n- attrs: JsonObject\n-\n- def __init__(self, problem: str, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n- kwargs['problem'] = problem\n- self.attrs = create_object(_msg, kwargs)\n- super().__init__(get_str(self.attrs, 'message', problem))\n-\n- def get_attrs(self) -> JsonObject:\n- if self.attrs['problem'] == 'internal-error' and self.__cause__ is not None:\n- return dict(self.attrs, cause=traceback.format_exception(\n- self.__cause__.__class__, self.__cause__, self.__cause__.__traceback__\n- ))\n- else:\n- return self.attrs\n-\n-\n-class CockpitProtocolError(CockpitProblem):\n- def __init__(self, message: str, problem: str = 'protocol-error'):\n- super().__init__(problem, message=message)\n-\n-\n-class CockpitProtocol(asyncio.Protocol):\n- \"\"\"A naive implementation of the Cockpit frame protocol\n-\n- We need to use this because Python's SelectorEventLoop doesn't supported\n- buffered protocols.\n- \"\"\"\n- transport: 'asyncio.Transport | None' = None\n- buffer = b''\n- _closed: bool = False\n- _communication_done: 'asyncio.Future[None] | None' = None\n-\n- def do_ready(self) -> None:\n- pass\n-\n- def do_closed(self, exc: 'Exception | None') -> None:\n- pass\n-\n- def transport_control_received(self, command: str, message: JsonObject) -> None:\n- raise NotImplementedError\n-\n- def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:\n- raise NotImplementedError\n+class HttpChannel(AsyncChannel):\n+ payload = 'http-stream2'\n \n- def channel_data_received(self, channel: str, data: bytes) -> None:\n- raise NotImplementedError\n+ @staticmethod\n+ def get_headers(response: http.client.HTTPResponse, *, binary: bool) -> JsonObject:\n+ # Never send these headers\n+ remove = {'Connection', 'Transfer-Encoding'}\n \n- def frame_received(self, frame: bytes) -> None:\n- header, _, data = frame.partition(b'\\n')\n+ if not binary:\n+ # Only send these headers for raw binary streams\n+ remove.update({'Content-Length', 'Range'})\n \n- if header != b'':\n- channel = header.decode('ascii')\n- logger.debug('data received: %d bytes of data for channel %s', len(data), channel)\n- self.channel_data_received(channel, data)\n+ return {key: value for key, value in response.getheaders() if key not in remove}\n \n- else:\n- self.control_received(data)\n+ @staticmethod\n+ def create_client(options: JsonObject) -> http.client.HTTPConnection:\n+ opt_address = get_str(options, 'address', 'localhost')\n+ opt_tls = get_dict(options, 'tls', None)\n+ opt_unix = get_str(options, 'unix', None)\n+ opt_port = get_int(options, 'port', None)\n \n- def control_received(self, data: bytes) -> None:\n- try:\n- message = typechecked(json.loads(data), dict)\n- command = get_str(message, 'command')\n- channel = get_str(message, 'channel', None)\n+ if opt_tls is not None and opt_unix is not None:\n+ raise ChannelError('protocol-error', message='TLS on Unix socket is not supported')\n+ if opt_port is None and opt_unix is None:\n+ raise ChannelError('protocol-error', message='no \"port\" or \"unix\" option for channel')\n+ if opt_port is not None and opt_unix is not None:\n+ raise ChannelError('protocol-error', message='cannot specify both \"port\" and \"unix\" options')\n \n- if channel is not None:\n- logger.debug('channel control received %s', message)\n- self.channel_control_received(channel, command, message)\n+ if opt_tls is not None:\n+ authority = get_dict(opt_tls, 'authority', None)\n+ if authority is not None:\n+ data = get_str(authority, 'data', None)\n+ if data is not None:\n+ context = ssl.create_default_context(cadata=data)\n+ else:\n+ context = ssl.create_default_context(cafile=get_str(authority, 'file'))\n else:\n- logger.debug('transport control received %s', message)\n- self.transport_control_received(command, message)\n-\n- except (json.JSONDecodeError, JsonError) as exc:\n- raise CockpitProtocolError(f'control message: {exc!s}') from exc\n-\n- def consume_one_frame(self, data: bytes) -> int:\n- \"\"\"Consumes a single frame from view.\n-\n- Returns positive if a number of bytes were consumed, or negative if no\n- work can be done because of a given number of bytes missing.\n- \"\"\"\n-\n- try:\n- newline = data.index(b'\\n')\n- except ValueError as exc:\n- if len(data) < 10:\n- # Let's try reading more\n- return len(data) - 10\n- raise CockpitProtocolError(\"size line is too long\") from exc\n-\n- try:\n- length = int(data[:newline])\n- except ValueError as exc:\n- raise CockpitProtocolError(\"frame size is not an integer\") from exc\n-\n- start = newline + 1\n- end = start + length\n-\n- if end > len(data):\n- # We need to read more\n- return len(data) - end\n-\n- # We can consume a full frame\n- self.frame_received(data[start:end])\n- return end\n-\n- def connection_made(self, transport: asyncio.BaseTransport) -> None:\n- logger.debug('connection_made(%s)', transport)\n- assert isinstance(transport, asyncio.Transport)\n- self.transport = transport\n- self.do_ready()\n-\n- if self._closed:\n- logger.debug(' but the protocol already was closed, so closing transport')\n- transport.close()\n-\n- def connection_lost(self, exc: 'Exception | None') -> None:\n- logger.debug('connection_lost')\n- assert self.transport is not None\n- self.transport = None\n- self.close(exc)\n+ context = ssl.create_default_context()\n \n- def close(self, exc: 'Exception | None' = None) -> None:\n- if self._closed:\n- return\n- self._closed = True\n+ if 'validate' in opt_tls and not opt_tls['validate']:\n+ context.check_hostname = False\n+ context.verify_mode = ssl.VerifyMode.CERT_NONE\n \n- if self.transport:\n- self.transport.close()\n+ # See https://github.com/python/typeshed/issues/11057\n+ return http.client.HTTPSConnection(opt_address, port=opt_port, context=context) # type: ignore[arg-type]\n \n- self.do_closed(exc)\n+ else:\n+ return http.client.HTTPConnection(opt_address, port=opt_port)\n \n- def write_channel_data(self, channel: str, payload: bytes) -> None:\n- \"\"\"Send a given payload (bytes) on channel (string)\"\"\"\n- # Channel is certainly ascii (as enforced by .encode() below)\n- frame_length = len(channel + '\\n') + len(payload)\n- header = f'{frame_length}\\n{channel}\\n'.encode('ascii')\n- if self.transport is not None:\n- logger.debug('writing to transport %s', self.transport)\n- self.transport.write(header + payload)\n+ @staticmethod\n+ def connect(connection: http.client.HTTPConnection, opt_unix: 'str | None') -> None:\n+ # Blocks. Runs in a thread.\n+ if opt_unix:\n+ # create the connection's socket so that it won't call .connect() internally (which only supports TCP)\n+ connection.sock = socket.socket(socket.AF_UNIX)\n+ connection.sock.connect(opt_unix)\n else:\n- logger.debug('cannot write to closed transport')\n+ # explicitly call connect(), so that we can do proper error handling\n+ connection.connect()\n \n- def write_control(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:\n- \"\"\"Write a control message. See jsonutil.create_object() for details.\"\"\"\n- logger.debug('sending control message %r %r', _msg, kwargs)\n- pretty = json.dumps(create_object(_msg, kwargs), indent=2) + '\\n'\n- self.write_channel_data('', pretty.encode())\n+ @staticmethod\n+ def request(\n+ connection: http.client.HTTPConnection, method: str, path: str, headers: 'dict[str, str]', body: bytes\n+ ) -> http.client.HTTPResponse:\n+ # Blocks. Runs in a thread.\n+ connection.request(method, path, headers=headers or {}, body=body)\n+ return connection.getresponse()\n \n- def data_received(self, data: bytes) -> None:\n- try:\n- self.buffer += data\n- while self.buffer:\n- result = self.consume_one_frame(self.buffer)\n- if result <= 0:\n- return\n- self.buffer = self.buffer[result:]\n- except CockpitProtocolError as exc:\n- self.close(exc)\n+ async def run(self, options: JsonObject) -> None:\n+ logger.debug('open %s', options)\n \n- def eof_received(self) -> bool:\n- return False\n+ binary = get_enum(options, 'binary', ['raw'], None) is not None\n+ method = get_str(options, 'method')\n+ path = get_str(options, 'path')\n+ headers = get_object(options, 'headers', lambda d: {k: typechecked(v, str) for k, v in d.items()}, None)\n \n+ if 'connection' in options:\n+ raise ChannelError('protocol-error', message='connection sharing is not implemented on this bridge')\n \n-# Helpful functionality for \"server\"-side protocol implementations\n-class CockpitProtocolServer(CockpitProtocol):\n- init_host: 'str | None' = None\n- authorizations: 'dict[str, asyncio.Future[str]] | None' = None\n+ connection = self.create_client(options)\n \n- def do_send_init(self) -> None:\n- raise NotImplementedError\n+ self.ready()\n \n- def do_init(self, message: JsonObject) -> None:\n- pass\n+ body = b''\n+ while True:\n+ data = await self.read()\n+ if data is None:\n+ break\n+ body += data\n \n- def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:\n- raise NotImplementedError\n+ # Connect in a thread and handle errors\n+ try:\n+ await self.in_thread(self.connect, connection, get_str(options, 'unix', None))\n+ except ssl.SSLCertVerificationError as exc:\n+ raise ChannelError('unknown-hostkey', message=str(exc)) from exc\n+ except (OSError, IOError) as exc:\n+ raise ChannelError('not-found', message=str(exc)) from exc\n \n- def transport_control_received(self, command: str, message: JsonObject) -> None:\n- if command == 'init':\n- if get_int(message, 'version') != 1:\n- raise CockpitProtocolError('incorrect version number')\n- self.init_host = get_str(message, 'host')\n- self.do_init(message)\n- elif command == 'kill':\n- self.do_kill(get_str_or_none(message, 'host', None), get_str_or_none(message, 'group', None), message)\n- elif command == 'authorize':\n- self.do_authorize(message)\n- else:\n- raise CockpitProtocolError(f'unexpected control message {command} received')\n+ # Submit request in a thread and handle errors\n+ try:\n+ response = await self.in_thread(self.request, connection, method, path, headers or {}, body)\n+ except (http.client.HTTPException, OSError) as exc:\n+ raise ChannelError('terminated', message=str(exc)) from exc\n \n- def do_ready(self) -> None:\n- self.do_send_init()\n+ self.send_control(command='response',\n+ status=response.status,\n+ reason=response.reason,\n+ headers=self.get_headers(response, binary=binary))\n \n- # authorize request/response API\n- async def request_authorization(\n- self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue\n- ) -> str:\n- if self.authorizations is None:\n- self.authorizations = {}\n- cookie = str(uuid.uuid4())\n- future = asyncio.get_running_loop().create_future()\n+ # Receive the body and finish up\n try:\n- self.authorizations[cookie] = future\n- self.write_control(None, command='authorize', challenge=challenge, cookie=cookie, **kwargs)\n- return await asyncio.wait_for(future, timeout)\n- finally:\n- self.authorizations.pop(cookie)\n+ while True:\n+ block = await self.in_thread(response.read1, self.BLOCK_SIZE)\n+ if not block:\n+ break\n+ await self.write(block)\n \n- def do_authorize(self, message: JsonObject) -> None:\n- cookie = get_str(message, 'cookie')\n- response = get_str(message, 'response')\n+ logger.debug('reading response done')\n+ # this returns immediately and does not read anything more, but updates the http.client's\n+ # internal state machine to \"response done\"\n+ block = response.read()\n+ assert block == b''\n \n- if self.authorizations is None or cookie not in self.authorizations:\n- logger.warning('no matching authorize request')\n- return\n+ await self.in_thread(connection.close)\n+ except (http.client.HTTPException, OSError) as exc:\n+ raise ChannelError('terminated', message=str(exc)) from exc\n \n- self.authorizations[cookie].set_result(response)\n+ self.done()\n ''',\n- 'cockpit/_vendor/__init__.py': br'''''',\n- 'cockpit/_vendor/bei/tmpfs.py': br'''import os\n-import subprocess\n-import sys\n-import tempfile\n-\n+ 'cockpit/channels/__init__.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2022 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n-def main(*command: str) -> None:\n- with tempfile.TemporaryDirectory() as tmpdir:\n- os.chdir(tmpdir)\n+from .dbus import DBusChannel\n+from .filesystem import FsInfoChannel, FsListChannel, FsReadChannel, FsReplaceChannel, FsWatchChannel\n+from .http import HttpChannel\n+from .metrics import InternalMetricsChannel\n+from .packages import PackagesChannel\n+from .stream import SocketStreamChannel, SubprocessStreamChannel\n+from .trivial import EchoChannel, NullChannel\n \n- for key, value in __loader__.get_contents().items():\n- if key.startswith('tmpfs/'):\n- subdir = os.path.dirname(key)\n- os.makedirs(subdir, exist_ok=True)\n- with open(key, 'wb') as fp:\n- fp.write(value)\n+CHANNEL_TYPES = [\n+ DBusChannel,\n+ EchoChannel,\n+ FsInfoChannel,\n+ FsListChannel,\n+ FsReadChannel,\n+ FsReplaceChannel,\n+ FsWatchChannel,\n+ HttpChannel,\n+ InternalMetricsChannel,\n+ NullChannel,\n+ PackagesChannel,\n+ SubprocessStreamChannel,\n+ SocketStreamChannel,\n+]\n+''',\n+ 'cockpit/channels/trivial.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2022 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n- os.chdir('tmpfs')\n+import logging\n \n- result = subprocess.run(command, check=False)\n- sys.exit(result.returncode)\n-''',\n- 'cockpit/_vendor/bei/spawn.py': br'''\"\"\"Helper to create a beipack to spawn a command with files in a tmpdir\"\"\"\n+from ..channel import Channel\n \n-import argparse\n-import os\n-import sys\n+logger = logging.getLogger(__name__)\n \n-from . import pack, tmpfs\n \n+class EchoChannel(Channel):\n+ payload = 'echo'\n \n-def main() -> None:\n- parser = argparse.ArgumentParser()\n- parser.add_argument('--file', '-f', action='append')\n- parser.add_argument('command', nargs='+', help='The command to execute')\n- args = parser.parse_args()\n+ def do_open(self, options):\n+ self.ready()\n \n- contents = {\n- '_beitmpfs.py': tmpfs.__spec__.loader.get_data(tmpfs.__spec__.origin)\n- }\n+ def do_data(self, data):\n+ self.send_data(data)\n \n- if args.file is not None:\n- files = args.file\n- else:\n- file = args.command[-1]\n- files = [file]\n- args.command[-1] = './' + os.path.basename(file)\n+ def do_done(self):\n+ self.done()\n+ self.close()\n \n- for filename in files:\n- with open(filename, 'rb') as file:\n- basename = os.path.basename(filename)\n- contents[f'tmpfs/{basename}'] = file.read()\n \n- script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command))\n- sys.stdout.write(script)\n+class NullChannel(Channel):\n+ payload = 'null'\n \n+ def do_open(self, options):\n+ self.ready()\n \n-if __name__ == '__main__':\n- main()\n+ def do_close(self):\n+ self.close()\n ''',\n- 'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python\n+ 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit.\n #\n-# Copyright (C) 2022 Allison Karlitskaya \n+# Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n+# along with this program. If not, see .\n \n-import argparse\n-import binascii\n-import lzma\n-import os\n+import asyncio\n+import json\n+import logging\n import sys\n-import tempfile\n-import zipfile\n-from typing import Dict, Iterable, List, Optional, Set, Tuple\n-\n-from .data import read_data_file\n-\n-\n-def escape_string(data: str) -> str:\n- # Avoid mentioning ' ' ' literally, to make our own packing a bit prettier\n- triplequote = \"'\" * 3\n- if triplequote not in data:\n- return \"r\" + triplequote + data + triplequote\n- if '\"\"\"' not in data:\n- return 'r\"\"\"' + data + '\"\"\"'\n- return repr(data)\n-\n-\n-def ascii_bytes_repr(data: bytes) -> str:\n- return 'b' + escape_string(data.decode('ascii'))\n-\n-\n-def utf8_bytes_repr(data: bytes) -> str:\n- return escape_string(data.decode('utf-8')) + \".encode('utf-8')\"\n-\n-\n-def base64_bytes_repr(data: bytes, imports: Set[str]) -> str:\n- # base85 is smaller, but base64 is in C, and ~20x faster.\n- # when compressing with `xz -e` the size difference is marginal.\n- imports.add('from binascii import a2b_base64')\n- encoded = binascii.b2a_base64(data).decode('ascii').strip()\n- return f'a2b_base64(\"{encoded}\")'\n-\n-\n-def bytes_repr(data: bytes, imports: Set[str]) -> str:\n- # Strategy:\n- # if the file is ascii, encode it directly as bytes\n- # otherwise, if it's UTF-8, use a unicode string and encode\n- # otherwise, base64\n-\n- try:\n- return ascii_bytes_repr(data)\n- except UnicodeDecodeError:\n- # it's not ascii\n- pass\n-\n- # utf-8\n- try:\n- return utf8_bytes_repr(data)\n- except UnicodeDecodeError:\n- # it's not utf-8\n- pass\n-\n- return base64_bytes_repr(data, imports)\n-\n-\n-def dict_repr(contents: Dict[str, bytes], imports: Set[str]) -> str:\n- return ('{\\n' +\n- ''.join(f' {repr(k)}: {bytes_repr(v, imports)},\\n'\n- for k, v in contents.items()) +\n- '}')\n+import time\n+from collections import defaultdict\n+from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union\n \n+from ..channel import AsyncChannel, ChannelError\n+from ..jsonutil import JsonList\n+from ..samples import SAMPLERS, SampleDescription, Sampler, Samples\n \n-def pack(contents: Dict[str, bytes],\n- entrypoint: Optional[str] = None,\n- args: str = '') -> str:\n- \"\"\"Creates a beipack with the given `contents`.\n+logger = logging.getLogger(__name__)\n \n- If `entrypoint` is given, it should be an entry point which is run as the\n- \"main\" function. It is given in the `package.module:func format` such that\n- the following code is emitted:\n \n- from package.module import func as main\n- main()\n+class MetricInfo(NamedTuple):\n+ derive: Optional[str]\n+ desc: SampleDescription\n \n- Additionally, if `args` is given, it is written verbatim between the parens\n- of the call to main (ie: it should already be in Python syntax).\n- \"\"\"\n \n- loader = read_data_file('beipack_loader.py')\n- lines = [line for line in loader.splitlines() if line]\n- lines.append('')\n+class InternalMetricsChannel(AsyncChannel):\n+ payload = 'metrics1'\n+ restrictions = [('source', 'internal')]\n \n- imports = {'import sys'}\n- contents_txt = dict_repr(contents, imports)\n- lines.extend(imports)\n- lines.append(f'sys.meta_path.insert(0, BeipackLoader({contents_txt}))')\n+ metrics: List[MetricInfo]\n+ samplers: Set\n+ samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None\n \n- if entrypoint:\n- package, main = entrypoint.split(':')\n- lines.append(f'from {package} import {main} as main')\n- lines.append(f'main({args})')\n+ interval: int = 1000\n+ need_meta: bool = True\n+ last_timestamp: float = 0\n+ next_timestamp: float = 0\n \n- return ''.join(f'{line}\\n' for line in lines)\n+ @classmethod\n+ def ensure_samplers(cls):\n+ if cls.samplers_cache is None:\n+ cls.samplers_cache = {desc.name: (sampler, desc) for sampler in SAMPLERS for desc in sampler.descriptions}\n \n+ def parse_options(self, options):\n+ logger.debug('metrics internal open: %s, channel: %s', options, self.channel)\n \n-def collect_contents(filenames: List[str],\n- relative_to: Optional[str] = None) -> Dict[str, bytes]:\n- contents: Dict[str, bytes] = {}\n+ interval = options.get('interval', self.interval)\n+ if not isinstance(interval, int) or interval <= 0 or interval > sys.maxsize:\n+ raise ChannelError('protocol-error', message=f'invalid \"interval\" value: {interval}')\n \n- for filename in filenames:\n- with open(filename, 'rb') as file:\n- contents[os.path.relpath(filename, start=relative_to)] = file.read()\n+ self.interval = interval\n \n- return contents\n+ metrics = options.get('metrics')\n+ if not isinstance(metrics, list) or len(metrics) == 0:\n+ logger.error('invalid \"metrics\" value: %s', metrics)\n+ raise ChannelError('protocol-error', message='invalid \"metrics\" option was specified (not an array)')\n \n+ sampler_classes = set()\n+ for metric in metrics:\n+ # validate it's an object\n+ name = metric.get('name')\n+ units = metric.get('units')\n+ derive = metric.get('derive')\n \n-def collect_module(name: str, *, recursive: bool) -> Dict[str, bytes]:\n- import importlib.resources\n- from importlib.resources.abc import Traversable\n+ try:\n+ sampler, desc = self.samplers_cache[name]\n+ except KeyError as exc:\n+ logger.error('unsupported metric: %s', name)\n+ raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc\n \n- def walk(path: str, entry: Traversable) -> Iterable[Tuple[str, bytes]]:\n- for item in entry.iterdir():\n- itemname = f'{path}/{item.name}'\n- if item.is_file():\n- yield itemname, item.read_bytes()\n- elif recursive and item.name != '__pycache__':\n- yield from walk(itemname, item)\n+ if units and units != desc.units:\n+ raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}')\n \n- return dict(walk(name.replace('.', '/'), importlib.resources.files(name)))\n+ sampler_classes.add(sampler)\n+ self.metrics.append(MetricInfo(derive=derive, desc=desc))\n \n+ self.samplers = {cls() for cls in sampler_classes}\n \n-def collect_zip(filename: str) -> Dict[str, bytes]:\n- contents = {}\n+ def send_meta(self, samples: Samples, timestamp: float):\n+ metrics: JsonList = []\n+ for metricinfo in self.metrics:\n+ if metricinfo.desc.instanced:\n+ metrics.append({\n+ 'name': metricinfo.desc.name,\n+ 'units': metricinfo.desc.units,\n+ 'instances': list(samples[metricinfo.desc.name].keys()),\n+ 'semantics': metricinfo.desc.semantics\n+ })\n+ else:\n+ metrics.append({\n+ 'name': metricinfo.desc.name,\n+ 'derive': metricinfo.derive, # type: ignore[dict-item]\n+ 'units': metricinfo.desc.units,\n+ 'semantics': metricinfo.desc.semantics\n+ })\n \n- with zipfile.ZipFile(filename) as file:\n- for entry in file.filelist:\n- if '.dist-info/' in entry.filename:\n- continue\n- contents[entry.filename] = file.read(entry)\n+ self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics)\n+ self.need_meta = False\n \n- return contents\n+ def sample(self):\n+ samples = defaultdict(dict)\n+ for sampler in self.samplers:\n+ sampler.sample(samples)\n+ return samples\n \n+ def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Union[float, bool]:\n+ if old_value is not None and self.last_timestamp:\n+ return (value - old_value) / (self.next_timestamp - self.last_timestamp)\n+ else:\n+ return False\n \n-def collect_pep517(path: str) -> Dict[str, bytes]:\n- with tempfile.TemporaryDirectory() as tmpdir:\n- import build\n- builder = build.ProjectBuilder(path)\n- wheel = builder.build('wheel', tmpdir)\n- return collect_zip(wheel)\n+ def send_updates(self, samples: Samples, last_samples: Samples):\n+ data: List[Union[float, List[Optional[Union[float, bool]]]]] = []\n+ timestamp = time.time()\n+ self.next_timestamp = timestamp\n \n+ for metricinfo in self.metrics:\n+ value = samples[metricinfo.desc.name]\n \n-def main() -> None:\n- parser = argparse.ArgumentParser()\n- parser.add_argument('--python', '-p',\n- help=\"add a #!python3 interpreter line using the given path\")\n- parser.add_argument('--xz', '-J', action='store_true',\n- help=\"compress the output with `xz`\")\n- parser.add_argument('--topdir',\n- help=\"toplevel directory (paths are stored relative to this)\")\n- parser.add_argument('--output', '-o',\n- help=\"write output to a file (default: stdout)\")\n- parser.add_argument('--main', '-m', metavar='MODULE:FUNC',\n- help=\"use FUNC from MODULE as the main function\")\n- parser.add_argument('--main-args', metavar='ARGS',\n- help=\"arguments to main() in Python syntax\", default='')\n- parser.add_argument('--module', action='append', default=[],\n- help=\"collect installed modules (recursively)\")\n- parser.add_argument('--zip', '-z', action='append', default=[],\n- help=\"include files from a zipfile (or wheel)\")\n- parser.add_argument('--build', metavar='DIR', action='append', default=[],\n- help=\"PEP-517 from a given source directory\")\n- parser.add_argument('files', nargs='*',\n- help=\"files to include in the beipack\")\n- args = parser.parse_args()\n+ if metricinfo.desc.instanced:\n+ old_value = last_samples[metricinfo.desc.name]\n+ assert isinstance(value, dict)\n+ assert isinstance(old_value, dict)\n \n- contents = collect_contents(args.files, relative_to=args.topdir)\n+ # If we have less or more keys the data changed, send a meta message.\n+ if value.keys() != old_value.keys():\n+ self.need_meta = True\n \n- for file in args.zip:\n- contents.update(collect_zip(file))\n+ if metricinfo.derive == 'rate':\n+ instances: List[Optional[Union[float, bool]]] = []\n+ for key, val in value.items():\n+ instances.append(self.calculate_sample_rate(val, old_value.get(key)))\n \n- for name in args.module:\n- contents.update(collect_module(name, recursive=True))\n+ data.append(instances)\n+ else:\n+ data.append(list(value.values()))\n+ else:\n+ old_value = last_samples.get(metricinfo.desc.name)\n+ assert not isinstance(value, dict)\n+ assert not isinstance(old_value, dict)\n \n- for path in args.build:\n- contents.update(collect_pep517(path))\n+ if metricinfo.derive == 'rate':\n+ data.append(self.calculate_sample_rate(value, old_value))\n+ else:\n+ data.append(value)\n \n- result = pack(contents, args.main, args.main_args).encode('utf-8')\n+ if self.need_meta:\n+ self.send_meta(samples, timestamp)\n \n- if args.python:\n- result = b'#!' + args.python.encode('ascii') + b'\\n' + result\n+ self.last_timestamp = self.next_timestamp\n+ self.send_data(json.dumps([data]).encode())\n \n- if args.xz:\n- result = lzma.compress(result, preset=lzma.PRESET_EXTREME)\n+ async def run(self, options):\n+ self.metrics = []\n+ self.samplers = set()\n \n- if args.output:\n- with open(args.output, 'wb') as file:\n- file.write(result)\n- else:\n- if args.xz and os.isatty(1):\n- sys.exit('refusing to write compressed output to a terminal')\n- sys.stdout.buffer.write(result)\n+ InternalMetricsChannel.ensure_samplers()\n \n+ self.parse_options(options)\n+ self.ready()\n \n-if __name__ == '__main__':\n- main()\n+ last_samples = defaultdict(dict)\n+ while True:\n+ samples = self.sample()\n+ self.send_updates(samples, last_samples)\n+ last_samples = samples\n+ await asyncio.sleep(self.interval / 1000)\n ''',\n- 'cockpit/_vendor/bei/bootloader.py': br'''# beiboot - Remote bootloader for Python\n+ 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit.\n #\n-# Copyright (C) 2023 Allison Karlitskaya \n+# Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import textwrap\n-from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple\n-\n-GADGETS = {\n- \"_frame\": r\"\"\"\n- import sys\n- import traceback\n- try:\n- ...\n- except SystemExit:\n- raise\n- except BaseException:\n- command('beiboot.exc', traceback.format_exc())\n- sys.exit(37)\n- \"\"\",\n- \"try_exec\": r\"\"\"\n- import contextlib\n- import os\n- def try_exec(argv):\n- with contextlib.suppress(OSError):\n- os.execvp(argv[0], argv)\n- \"\"\",\n- \"boot_xz\": r\"\"\"\n- import lzma\n- import sys\n- def boot_xz(filename, size, args=[], send_end=False):\n- command('beiboot.provide', size)\n- src_xz = sys.stdin.buffer.read(size)\n- src = lzma.decompress(src_xz)\n- sys.argv = [filename, *args]\n- if send_end:\n- end()\n- exec(src, {\n- '__name__': '__main__',\n- '__self_source__': src_xz,\n- '__file__': filename})\n- sys.exit()\n- \"\"\",\n-}\n-\n-\n-def split_code(code: str, imports: Set[str]) -> Iterable[Tuple[str, str]]:\n- for line in textwrap.dedent(code).splitlines():\n- text = line.lstrip(\" \")\n- if text.startswith(\"import \"):\n- imports.add(text)\n- elif text:\n- spaces = len(line) - len(text)\n- assert (spaces % 4) == 0\n- yield \"\\t\" * (spaces // 4), text\n-\n-\n-def yield_body(user_gadgets: Dict[str, str],\n- steps: Sequence[Tuple[str, Sequence[object]]],\n- imports: Set[str]) -> Iterable[Tuple[str, str]]:\n- # Allow the caller to override our gadgets, but keep the original\n- # variable for use in the next step.\n- gadgets = dict(GADGETS, **user_gadgets)\n-\n- # First emit the gadgets. Emit all gadgets provided by the caller,\n- # plus any referred to by the caller's list of steps.\n- provided_gadgets = set(user_gadgets)\n- step_gadgets = {name for name, _args in steps}\n- for name in provided_gadgets | step_gadgets:\n- yield from split_code(gadgets[name], imports)\n-\n- # Yield functions mentioned in steps from the caller\n- for name, args in steps:\n- yield '', name + repr(tuple(args))\n-\n-\n-def make_bootloader(steps: Sequence[Tuple[str, Sequence[object]]],\n- gadgets: Optional[Dict[str, str]] = None) -> str:\n- imports: Set[str] = set()\n- lines: List[str] = []\n-\n- for frame_spaces, frame_text in split_code(GADGETS[\"_frame\"], imports):\n- if frame_text == \"...\":\n- for spaces, text in yield_body(gadgets or {}, steps, imports):\n- lines.append(frame_spaces + spaces + text)\n- else:\n- lines.append(frame_spaces + frame_text)\n+# along with this program. If not, see .\n \n- return \"\".join(f\"{line}\\n\" for line in [*imports, *lines]) + \"\\n\"\n-''',\n- 'cockpit/_vendor/bei/__init__.py': br'''''',\n- 'cockpit/_vendor/bei/beiboot.py': br\"\"\"# beiboot - Remote bootloader for Python\n+# Missing stuff compared to the C bridge that we should probably add:\n #\n-# Copyright (C) 2022 Allison Karlitskaya \n+# - removing matches\n+# - removing watches\n+# - emitting of signals\n+# - publishing of objects\n+# - failing more gracefully in some cases (during open, etc)\n #\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n+# Stuff we might or might not do:\n #\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n+# - using non-default service names\n #\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n+# Stuff we should probably not do:\n+#\n+# - emulation of ObjectManager via recursive introspection\n+# - automatic detection of ObjectManager below the given path_namespace\n+# - recursive scraping of properties for new object paths\n+# (for path_namespace watches that don't hit an ObjectManager)\n \n-import argparse\n import asyncio\n-import os\n-import shlex\n-import subprocess\n-import sys\n-import threading\n-from typing import IO, List, Sequence, Tuple\n-\n-from .bootloader import make_bootloader\n-\n-\n-def get_python_command(local: bool = False,\n- tty: bool = False,\n- sh: bool = False) -> Sequence[str]:\n- interpreter = sys.executable if local else 'python3'\n- command: Sequence[str]\n-\n- if tty:\n- command = (interpreter, '-iq')\n- else:\n- command = (\n- interpreter, '-ic',\n- # https://github.com/python/cpython/issues/93139\n- '''\" - beiboot - \"; import sys; sys.ps1 = ''; sys.ps2 = '';'''\n- )\n+import errno\n+import json\n+import logging\n+import traceback\n+import xml.etree.ElementTree as ET\n \n- if sh:\n- command = (' '.join(shlex.quote(arg) for arg in command),)\n+from cockpit._vendor import systemd_ctypes\n+from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection\n \n- return command\n+from ..channel import Channel, ChannelError\n \n+logger = logging.getLogger(__name__)\n \n-def get_ssh_command(*args: str, tty: bool = False) -> Sequence[str]:\n- return ('ssh',\n- *(['-t'] if tty else ()),\n- *args,\n- *get_python_command(tty=tty, sh=True))\n+# The dbusjson3 payload\n+#\n+# This channel payload type translates JSON encoded messages on a\n+# Cockpit channel to D-Bus messages, in a mostly straightforward way.\n+# See doc/protocol.md for a description of the basics.\n+#\n+# However, dbusjson3 offers some advanced features as well that are\n+# meant to support the \"magic\" DBusProxy objects implemented by\n+# cockpit.js. Those proxy objects \"magically\" expose all the methods\n+# and properties of a D-Bus interface without requiring any explicit\n+# binding code to be generated for a JavaScript client. A dbusjson3\n+# channel does this by doing automatic introspection and property\n+# retrieval without much direction from the JavaScript client.\n+#\n+# The details of what exactly is done is not specified very strictly,\n+# and the Python bridge will likely differ from the C bridge\n+# significantly. This will be informed by what existing code actually\n+# needs, and we might end up with a more concrete description of what\n+# a client can actually expect.\n+#\n+# Here is an example of a more complex scenario:\n+#\n+# - The client adds a \"watch\" for a path namespace. There is a\n+# ObjectManager at the given path and the bridge emits \"meta\" and\n+# \"notify\" messages to describe all interfaces and objects reported\n+# by that ObjectManager.\n+#\n+# - The client makes a method call that causes a new object with a new\n+# interface to appear at the ObjectManager. The bridge will send a\n+# \"meta\" and \"notify\" message to describe this new object.\n+#\n+# - Since the InterfacesAdded signal was emitted before the method\n+# reply, the bridge must send the \"meta\" and \"notify\" messages\n+# before the method reply message.\n+#\n+# - However, in order to construct the \"meta\" message, the bridge must\n+# perform a Introspect call, and consequently must delay sending the\n+# method reply until that call has finished.\n+#\n+# The Python bridge implements this delaying of messages with\n+# coroutines and a fair mutex. Every message coming from D-Bus will\n+# wait on the mutex for its turn to send its message on the Cockpit\n+# channel, and will keep that mutex locked until it is done with\n+# sending. Since the mutex is fair, everyone will nicely wait in line\n+# without messages getting re-ordered.\n+#\n+# The scenario above will play out like this:\n+#\n+# - While adding the initial \"watch\", the lock is held until the\n+# \"meta\" and \"notify\" messages have been sent.\n+#\n+# - Later, when the InterfacesAdded signal comes in that has been\n+# triggered by the method call, the mutex will be locked while the\n+# necessary introspection is going on.\n+#\n+# - The method reply will likely come while the mutex is locked, and\n+# the task for sending that reply on the Cockpit channel will enter\n+# the wait queue of the mutex.\n+#\n+# - Once the introspection is done and the new \"meta\" and \"notify\"\n+# messages have been sent, the mutex is unlocked, the method reply\n+# task acquires it, and sends its message.\n \n \n-def get_container_command(*args: str, tty: bool = False) -> Sequence[str]:\n- return ('podman', 'exec', '--interactive',\n- *(['--tty'] if tty else ()),\n- *args,\n- *get_python_command(tty=tty))\n+class InterfaceCache:\n+ def __init__(self):\n+ self.cache = {}\n+ self.old = set() # Interfaces already returned by get_interface_if_new\n \n+ def inject(self, interfaces):\n+ self.cache.update(interfaces)\n \n-def get_command(*args: str, tty: bool = False, sh: bool = False) -> Sequence[str]:\n- return (*args, *get_python_command(local=True, tty=tty, sh=sh))\n+ async def introspect_path(self, bus, destination, object_path):\n+ xml, = await bus.call_method_async(destination, object_path,\n+ 'org.freedesktop.DBus.Introspectable',\n+ 'Introspect')\n \n+ et = ET.fromstring(xml)\n \n-def splice_in_thread(src: int, dst: IO[bytes]) -> None:\n- def _thread() -> None:\n- # os.splice() only in Python 3.10\n- with dst:\n- block_size = 1 << 20\n- while True:\n- data = os.read(src, block_size)\n- if not data:\n- break\n- dst.write(data)\n- dst.flush()\n+ interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')}\n \n- threading.Thread(target=_thread, daemon=True).start()\n+ # Add all interfaces we found: we might use them later\n+ self.inject(interfaces)\n \n+ return interfaces\n \n-def send_and_splice(command: Sequence[str], script: bytes) -> None:\n- with subprocess.Popen(command, stdin=subprocess.PIPE) as proc:\n- assert proc.stdin is not None\n- proc.stdin.write(script)\n+ async def get_interface(self, interface_name, bus=None, destination=None, object_path=None):\n+ try:\n+ return self.cache[interface_name]\n+ except KeyError:\n+ pass\n \n- splice_in_thread(0, proc.stdin)\n- sys.exit(proc.wait())\n+ if bus and object_path:\n+ try:\n+ await self.introspect_path(bus, destination, object_path)\n+ except BusError:\n+ pass\n \n+ return self.cache.get(interface_name)\n \n-def send_xz_and_splice(command: Sequence[str], script: bytes) -> None:\n- import ferny\n+ async def get_interface_if_new(self, interface_name, bus, destination, object_path):\n+ if interface_name in self.old:\n+ return None\n+ self.old.add(interface_name)\n+ return await self.get_interface(interface_name, bus, destination, object_path)\n \n- class Responder(ferny.InteractionResponder):\n- async def do_custom_command(self,\n- command: str,\n- args: Tuple,\n- fds: List[int],\n- stderr: str) -> None:\n- assert proc.stdin is not None\n- if command == 'beiboot.provide':\n- proc.stdin.write(script)\n- proc.stdin.flush()\n+ async def get_signature(self, interface_name, method, bus=None, destination=None, object_path=None):\n+ interface = await self.get_interface(interface_name, bus, destination, object_path)\n+ if interface is None:\n+ raise KeyError(f'Interface {interface_name} is not found')\n \n- agent = ferny.InteractionAgent(Responder())\n- with subprocess.Popen(command, stdin=subprocess.PIPE, stderr=agent) as proc:\n- assert proc.stdin is not None\n- proc.stdin.write(make_bootloader([\n- ('boot_xz', ('script.py.xz', len(script), [], True)),\n- ], gadgets=ferny.BEIBOOT_GADGETS).encode())\n- proc.stdin.flush()\n+ return ''.join(interface['methods'][method]['in'])\n \n- asyncio.run(agent.communicate())\n- splice_in_thread(0, proc.stdin)\n- sys.exit(proc.wait())\n \n+def notify_update(notify, path, interface_name, props):\n+ notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()}\n \n-def main() -> None:\n- parser = argparse.ArgumentParser()\n- parser.add_argument('--sh', action='store_true',\n- help='Pass Python interpreter command as shell-script')\n- parser.add_argument('--xz', help=\"the xz to run remotely\")\n- parser.add_argument('--script',\n- help=\"the script to run remotely (must be repl-friendly)\")\n- parser.add_argument('command', nargs='*')\n \n- args = parser.parse_args()\n- tty = not args.script and os.isatty(0)\n+class DBusChannel(Channel):\n+ json_encoder = systemd_ctypes.JSONEncoder(indent=2)\n+ payload = 'dbus-json3'\n \n- if args.command == []:\n- command = get_python_command(tty=tty)\n- elif args.command[0] == 'ssh':\n- command = get_ssh_command(*args.command[1:], tty=tty)\n- elif args.command[0] == 'container':\n- command = get_container_command(*args.command[1:], tty=tty)\n- else:\n- command = get_command(*args.command, tty=tty, sh=args.sh)\n+ matches = None\n+ name = None\n+ bus = None\n+ owner = None\n \n- if args.script:\n- with open(args.script, 'rb') as file:\n- script = file.read()\n+ async def setup_name_owner_tracking(self):\n+ def send_owner(owner):\n+ # We must be careful not to send duplicate owner\n+ # notifications. cockpit.js relies on that.\n+ if self.owner != owner:\n+ self.owner = owner\n+ self.send_json(owner=owner)\n \n- send_and_splice(command, script)\n+ def handler(message):\n+ _name, _old, new = message.get_body()\n+ send_owner(owner=new if new != \"\" else None)\n+ self.add_signal_handler(handler,\n+ sender='org.freedesktop.DBus',\n+ path='/org/freedesktop/DBus',\n+ interface='org.freedesktop.DBus',\n+ member='NameOwnerChanged',\n+ arg0=self.name)\n+ try:\n+ unique_name, = await self.bus.call_method_async(\"org.freedesktop.DBus\",\n+ \"/org/freedesktop/DBus\",\n+ \"org.freedesktop.DBus\",\n+ \"GetNameOwner\", \"s\", self.name)\n+ except BusError as error:\n+ if error.name == \"org.freedesktop.DBus.Error.NameHasNoOwner\":\n+ # Try to start it. If it starts successfully, we will\n+ # get a NameOwnerChanged signal (which will set\n+ # self.owner) before StartServiceByName returns.\n+ try:\n+ await self.bus.call_method_async(\"org.freedesktop.DBus\",\n+ \"/org/freedesktop/DBus\",\n+ \"org.freedesktop.DBus\",\n+ \"StartServiceByName\", \"su\", self.name, 0)\n+ except BusError as start_error:\n+ logger.debug(\"Failed to start service '%s': %s\", self.name, start_error.message)\n+ self.send_json(owner=None)\n+ else:\n+ logger.debug(\"Failed to get owner of service '%s': %s\", self.name, error.message)\n+ else:\n+ send_owner(unique_name)\n \n- elif args.xz:\n- with open(args.xz, 'rb') as file:\n- script = file.read()\n+ def do_open(self, options):\n+ self.cache = InterfaceCache()\n+ self.name = options.get('name')\n+ self.matches = []\n \n- send_xz_and_splice(command, script)\n+ bus = options.get('bus')\n+ address = options.get('address')\n \n- else:\n- # If we're streaming from stdin then this is a lot easier...\n- os.execlp(command[0], *command)\n+ try:\n+ if address is not None:\n+ if bus is not None and bus != 'none':\n+ raise ChannelError('protocol-error', message='only one of \"bus\" and \"address\" can be specified')\n+ logger.debug('get bus with address %s for %s', address, self.name)\n+ self.bus = Bus.new(address=address, bus_client=self.name is not None)\n+ elif bus == 'internal':\n+ logger.debug('get internal bus for %s', self.name)\n+ self.bus = self.router.internal_bus.client\n+ else:\n+ if bus == 'session':\n+ logger.debug('get session bus for %s', self.name)\n+ self.bus = Bus.default_user()\n+ elif bus == 'system' or bus is None:\n+ logger.debug('get system bus for %s', self.name)\n+ self.bus = Bus.default_system()\n+ else:\n+ raise ChannelError('protocol-error', message=f'invalid bus \"{bus}\"')\n+ except OSError as exc:\n+ raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc\n \n- # Otherwise, \"full strength\"\n+ try:\n+ self.bus.attach_event(None, 0)\n+ except OSError as err:\n+ if err.errno != errno.EBUSY:\n+ raise\n \n-if __name__ == '__main__':\n- main()\n-\"\"\",\n- 'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack\n+ # This needs to be a fair mutex so that outgoing messages don't\n+ # get re-ordered. asyncio.Lock is fair.\n+ self.watch_processing_lock = asyncio.Lock()\n \n-import importlib.abc\n-import importlib.util\n-import io\n-import sys\n-from types import ModuleType\n-from typing import BinaryIO, Dict, Iterator, Optional, Sequence\n+ if self.name is not None:\n+ async def get_ready():\n+ async with self.watch_processing_lock:\n+ await self.setup_name_owner_tracking()\n+ if self.owner:\n+ self.ready(unique_name=self.owner)\n+ else:\n+ self.close({'problem': 'not-found'})\n+ self.create_task(get_ready())\n+ else:\n+ self.ready()\n \n+ def add_signal_handler(self, handler, **kwargs):\n+ r = dict(**kwargs)\n+ r['type'] = 'signal'\n+ if 'sender' not in r and self.name is not None:\n+ r['sender'] = self.name\n+ # HACK - https://github.com/bus1/dbus-broker/issues/309\n+ # path_namespace='/' in a rule does not work.\n+ if r.get('path_namespace') == \"/\":\n+ del r['path_namespace']\n \n-class BeipackLoader(importlib.abc.SourceLoader, importlib.abc.MetaPathFinder):\n- if sys.version_info >= (3, 11):\n- from importlib.resources.abc import ResourceReader as AbstractResourceReader\n- else:\n- AbstractResourceReader = object\n+ def filter_owner(message):\n+ if self.owner is not None and self.owner == message.get_sender():\n+ handler(message)\n \n- class ResourceReader(AbstractResourceReader):\n- def __init__(self, contents: Dict[str, bytes], filename: str) -> None:\n- self._contents = contents\n- self._dir = f'{filename}/'\n+ if self.name is not None and 'sender' in r and r['sender'] == self.name:\n+ func = filter_owner\n+ else:\n+ func = handler\n+ r_string = ','.join(f\"{key}='{value}'\" for key, value in r.items())\n+ if not self.is_closing():\n+ # this gets an EINTR very often especially on RHEL 8\n+ while True:\n+ try:\n+ match = self.bus.add_match(r_string, func)\n+ break\n+ except InterruptedError:\n+ pass\n \n- def is_resource(self, resource: str) -> bool:\n- return f'{self._dir}{resource}' in self._contents\n+ self.matches.append(match)\n \n- def open_resource(self, resource: str) -> BinaryIO:\n- return io.BytesIO(self._contents[f'{self._dir}{resource}'])\n+ def add_async_signal_handler(self, handler, **kwargs):\n+ def sync_handler(message):\n+ self.create_task(handler(message))\n+ self.add_signal_handler(sync_handler, **kwargs)\n \n- def resource_path(self, resource: str) -> str:\n- raise FileNotFoundError\n+ async def do_call(self, message):\n+ path, iface, method, args = message['call']\n+ cookie = message.get('id')\n+ flags = message.get('flags')\n \n- def contents(self) -> Iterator[str]:\n- dir_length = len(self._dir)\n- result = set()\n+ timeout = message.get('timeout')\n+ if timeout is not None:\n+ # sd_bus timeout is \u03bcs, cockpit API timeout is ms\n+ timeout *= 1000\n+ else:\n+ # sd_bus has no \"indefinite\" timeout, so use MAX_UINT64\n+ timeout = 2 ** 64 - 1\n \n- for filename in self._contents:\n- if filename.startswith(self._dir):\n- try:\n- next_slash = filename.index('/', dir_length)\n- except ValueError:\n- next_slash = None\n- result.add(filename[dir_length:next_slash])\n+ # We have to figure out the signature of the call. Either we got told it:\n+ signature = message.get('type')\n \n- return iter(result)\n+ # ... or there aren't any arguments\n+ if signature is None and len(args) == 0:\n+ signature = ''\n \n- contents: Dict[str, bytes]\n- modules: Dict[str, str]\n+ # ... or we need to introspect\n+ if signature is None:\n+ try:\n+ logger.debug('Doing introspection request for %s %s', iface, method)\n+ signature = await self.cache.get_signature(iface, method, self.bus, self.name, path)\n+ except BusError as error:\n+ self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie)\n+ return\n+ except KeyError:\n+ self.send_json(\n+ error=[\n+ \"org.freedesktop.DBus.Error.UnknownMethod\",\n+ [f\"Introspection data for method {iface} {method} not available\"]],\n+ id=cookie)\n+ return\n+ except Exception as exc:\n+ self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie)\n+ return\n \n- def __init__(self, contents: Dict[str, bytes]) -> None:\n try:\n- contents[__file__] = __self_source__ # type: ignore[name-defined]\n- except NameError:\n- pass\n-\n- self.contents = contents\n- self.modules = {\n- self.get_fullname(filename): filename\n- for filename in contents\n- if filename.endswith(\".py\")\n- }\n-\n- def get_fullname(self, filename: str) -> str:\n- assert filename.endswith(\".py\")\n- filename = filename[:-3]\n- if filename.endswith(\"/__init__\"):\n- filename = filename[:-9]\n- return filename.replace(\"/\", \".\")\n+ method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args)\n+ reply = await self.bus.call_async(method_call, timeout=timeout)\n+ # If the method call has kicked off any signals related to\n+ # watch processing, wait for that to be done.\n+ async with self.watch_processing_lock:\n+ # TODO: stop hard-coding the endian flag here.\n+ self.send_json(\n+ reply=[reply.get_body()], id=cookie,\n+ flags=\"<\" if flags is not None else None,\n+ type=reply.get_signature(True)) # noqa: FBT003\n+ except BusError as error:\n+ # actually, should send the fields from the message body\n+ self.send_json(error=[error.name, [error.message]], id=cookie)\n+ except Exception:\n+ logger.exception(\"do_call(%s): generic exception\", message)\n+ self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie)\n \n- def get_resource_reader(self, fullname: str) -> ResourceReader:\n- return BeipackLoader.ResourceReader(self.contents, fullname.replace('.', '/'))\n+ async def do_add_match(self, message):\n+ add_match = message['add-match']\n+ logger.debug('adding match %s', add_match)\n \n- def get_data(self, path: str) -> bytes:\n- return self.contents[path]\n+ async def match_hit(message):\n+ logger.debug('got match')\n+ async with self.watch_processing_lock:\n+ self.send_json(signal=[\n+ message.get_path(),\n+ message.get_interface(),\n+ message.get_member(),\n+ list(message.get_body())\n+ ])\n \n- def get_filename(self, fullname: str) -> str:\n- return self.modules[fullname]\n+ self.add_async_signal_handler(match_hit, **add_match)\n \n- def find_spec(\n- self,\n- fullname: str,\n- path: Optional[Sequence[str]],\n- target: Optional[ModuleType] = None\n- ) -> Optional[importlib.machinery.ModuleSpec]:\n- if fullname not in self.modules:\n- return None\n- return importlib.util.spec_from_loader(fullname, self)\n-''',\n- 'cockpit/_vendor/bei/data/__init__.py': br'''import sys\n+ async def setup_objectmanager_watch(self, path, interface_name, meta, notify):\n+ # Watch the objects managed by the ObjectManager at \"path\".\n+ # Properties are not watched, that is done by setup_path_watch\n+ # below via recursive_props == True.\n \n-if sys.version_info >= (3, 9):\n- import importlib.abc\n- import importlib.resources\n+ async def handler(message):\n+ member = message.get_member()\n+ if member == \"InterfacesAdded\":\n+ (path, interface_props) = message.get_body()\n+ logger.debug('interfaces added %s %s', path, interface_props)\n+ meta = {}\n+ notify = {}\n+ async with self.watch_processing_lock:\n+ for name, props in interface_props.items():\n+ if interface_name is None or name == interface_name:\n+ mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path)\n+ if mm:\n+ meta.update({name: mm})\n+ notify_update(notify, path, name, props)\n+ self.send_json(meta=meta)\n+ self.send_json(notify=notify)\n+ elif member == \"InterfacesRemoved\":\n+ (path, interfaces) = message.get_body()\n+ logger.debug('interfaces removed %s %s', path, interfaces)\n+ async with self.watch_processing_lock:\n+ notify = {path: dict.fromkeys(interfaces)}\n+ self.send_json(notify=notify)\n \n- def read_data_file(filename: str) -> str:\n- return (importlib.resources.files(__name__) / filename).read_text()\n-else:\n- def read_data_file(filename: str) -> str:\n- loader = __loader__ # type: ignore[name-defined]\n- data = loader.get_data(__file__.replace('__init__.py', filename))\n- return data.decode('utf-8')\n-''',\n- 'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes\n-#\n-# Copyright (C) 2022 Allison Karlitskaya \n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n+ self.add_async_signal_handler(handler,\n+ path=path,\n+ interface=\"org.freedesktop.DBus.ObjectManager\")\n+ objects, = await self.bus.call_method_async(self.name, path,\n+ 'org.freedesktop.DBus.ObjectManager',\n+ 'GetManagedObjects')\n+ for p, ifaces in objects.items():\n+ for iface, props in ifaces.items():\n+ if interface_name is None or iface == interface_name:\n+ mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p)\n+ if mm:\n+ meta.update({iface: mm})\n+ notify_update(notify, p, iface, props)\n \n-import ctypes\n-from enum import IntFlag, auto\n-from typing import Optional\n+ async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify):\n+ # Watch a single object at \"path\", but maybe also watch for\n+ # property changes for all objects below \"path\".\n \n+ async def handler(message):\n+ async with self.watch_processing_lock:\n+ path = message.get_path()\n+ name, props, invalids = message.get_body()\n+ logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids)\n+ for inv in invalids:\n+ try:\n+ reply, = await self.bus.call_method_async(self.name, path,\n+ 'org.freedesktop.DBus.Properties', 'Get',\n+ 'ss', name, inv)\n+ except BusError as exc:\n+ logger.debug('failed to fetch property %s.%s on %s %s: %s',\n+ name, inv, self.name, path, str(exc))\n+ continue\n+ props[inv] = reply\n+ notify = {}\n+ notify_update(notify, path, name, props)\n+ self.send_json(notify=notify)\n \n-class inotify_event(ctypes.Structure):\n- _fields_ = (\n- ('wd', ctypes.c_int32),\n- ('mask', ctypes.c_uint32),\n- ('cookie', ctypes.c_uint32),\n- ('len', ctypes.c_uint32),\n- )\n+ this_meta = await self.cache.introspect_path(self.bus, self.name, path)\n+ if interface_name is not None:\n+ interface = this_meta.get(interface_name)\n+ this_meta = {interface_name: interface}\n+ meta.update(this_meta)\n+ if recursive_props:\n+ self.add_async_signal_handler(handler,\n+ interface=\"org.freedesktop.DBus.Properties\",\n+ path_namespace=path)\n+ else:\n+ self.add_async_signal_handler(handler,\n+ interface=\"org.freedesktop.DBus.Properties\",\n+ path=path)\n \n- @property\n- def name(self) -> Optional[bytes]:\n- if self.len == 0:\n- return None\n+ for name in meta:\n+ if name.startswith(\"org.freedesktop.DBus.\"):\n+ continue\n+ try:\n+ props, = await self.bus.call_method_async(self.name, path,\n+ 'org.freedesktop.DBus.Properties',\n+ 'GetAll', 's', name)\n+ notify_update(notify, path, name, props)\n+ except BusError:\n+ pass\n \n- class event_with_name(ctypes.Structure):\n- _fields_ = (*inotify_event._fields_, ('name', ctypes.c_char * self.len))\n+ async def do_watch(self, message):\n+ watch = message['watch']\n+ path = watch.get('path')\n+ path_namespace = watch.get('path_namespace')\n+ interface_name = watch.get('interface')\n+ cookie = message.get('id')\n \n- name = ctypes.cast(ctypes.addressof(self), ctypes.POINTER(event_with_name)).contents.name\n- assert isinstance(name, bytes)\n- return name\n+ path = path or path_namespace\n+ recursive = path == path_namespace\n \n+ if path is None or cookie is None:\n+ logger.debug('ignored incomplete watch request %s', message)\n+ self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie)\n+ self.send_json(reply=[], id=cookie)\n+ return\n \n-class Event(IntFlag):\n- ACCESS = auto()\n- MODIFY = auto()\n- ATTRIB = auto()\n- CLOSE_WRITE = auto()\n- CLOSE_NOWRITE = auto()\n- OPEN = auto()\n- MOVED_FROM = auto()\n- MOVED_TO = auto()\n- CREATE = auto()\n- DELETE = auto()\n- DELETE_SELF = auto()\n- MOVE_SELF = auto()\n+ try:\n+ async with self.watch_processing_lock:\n+ meta = {}\n+ notify = {}\n+ await self.setup_path_watch(path, interface_name, recursive, meta, notify)\n+ if recursive:\n+ await self.setup_objectmanager_watch(path, interface_name, meta, notify)\n+ self.send_json(meta=meta)\n+ self.send_json(notify=notify)\n+ self.send_json(reply=[], id=message['id'])\n+ except BusError as error:\n+ logger.debug(\"do_watch(%s) caught D-Bus error: %s\", message, error.message)\n+ self.send_json(error=[error.name, [error.message]], id=cookie)\n \n- UNMOUNT = 1 << 13\n- Q_OVERFLOW = auto()\n- IGNORED = auto()\n+ async def do_meta(self, message):\n+ self.cache.inject(message['meta'])\n \n- ONLYDIR = 1 << 24\n- DONT_FOLLOW = auto()\n- EXCL_UNLINK = auto()\n+ def do_data(self, data):\n+ message = json.loads(data)\n+ logger.debug('receive dbus request %s %s', self.name, message)\n \n- MASK_CREATE = 1 << 28\n- MASK_ADD = auto()\n- ISDIR = auto()\n- ONESHOT = auto()\n+ if 'call' in message:\n+ self.create_task(self.do_call(message))\n+ elif 'add-match' in message:\n+ self.create_task(self.do_add_match(message))\n+ elif 'watch' in message:\n+ self.create_task(self.do_watch(message))\n+ elif 'meta' in message:\n+ self.create_task(self.do_meta(message))\n+ else:\n+ logger.debug('ignored dbus request %s', message)\n+ return\n \n- CLOSE = CLOSE_WRITE | CLOSE_NOWRITE\n- MOVE = MOVED_FROM | MOVED_TO\n- CHANGED = (MODIFY | ATTRIB | CLOSE_WRITE | MOVE |\n- CREATE | DELETE | DELETE_SELF | MOVE_SELF)\n-''',\n- 'cockpit/_vendor/systemd_ctypes/bus.py': br'''# systemd_ctypes\n+ def do_close(self):\n+ for slot in self.matches:\n+ slot.cancel()\n+ self.matches = []\n+ self.close()\n+'''.encode('utf-8'),\n+ 'cockpit/channels/filesystem.py': r'''# This file is part of Cockpit.\n #\n-# Copyright (C) 2022 Allison Karlitskaya \n+# Copyright (C) 2022 Red Hat, Inc.\n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n+# along with this program. If not, see .\n \n import asyncio\n+import contextlib\n import enum\n+import errno\n+import fnmatch\n+import functools\n+import grp\n import logging\n-import typing\n-from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union\n+import os\n+import pwd\n+import re\n+import stat\n+import tempfile\n+from pathlib import Path\n+from typing import Callable, Iterable, Iterator\n \n-from . import bustypes, introspection, libsystemd\n-from .librarywrapper import WeakReference, byref\n+from cockpit._vendor.systemd_ctypes import Handle, PathWatch\n+from cockpit._vendor.systemd_ctypes.inotify import Event as InotifyEvent\n+from cockpit._vendor.systemd_ctypes.pathwatch import Listener as PathWatchListener\n \n-logger = logging.getLogger(__name__)\n+from ..channel import AsyncChannel, Channel, ChannelError, GeneratorChannel\n+from ..jsonutil import (\n+ JsonDict,\n+ JsonDocument,\n+ JsonError,\n+ JsonObject,\n+ get_bool,\n+ get_enum,\n+ get_int,\n+ get_str,\n+ get_strv,\n+ json_merge_and_filter_patch,\n+)\n \n+logger = logging.getLogger(__name__)\n \n-class BusError(Exception):\n- \"\"\"An exception corresponding to a D-Bus error message\n \n- This exception is raised by the method call methods. You can also raise it\n- from your own method handlers. It can also be passed directly to functions\n- such as Message.reply_method_error().\n+@functools.lru_cache()\n+def my_umask() -> int:\n+ match = re.search(r'^Umask:\\s*0([0-7]*)$', Path('/proc/self/status').read_text(), re.M)\n+ return (match and int(match.group(1), 8)) or 0o077\n \n- :name: the 'code' of the error, like org.freedesktop.DBus.Error.UnknownMethod\n- :message: a human-readable description of the error\n- \"\"\"\n- def __init__(self, name: str, message: str):\n- super().__init__(f'{name}: {message}')\n- self.name = name\n- self.message = message\n \n+def tag_from_stat(buf):\n+ return f'1:{buf.st_ino}-{buf.st_mtime}-{buf.st_mode:o}-{buf.st_uid}-{buf.st_gid}'\n \n-class BusMessage(libsystemd.sd_bus_message):\n- \"\"\"A message, received from or to be sent over D-Bus\n \n- This is the low-level interface to receiving and sending individual\n- messages over D-Bus. You won't normally need to use it.\n+def tag_from_path(path):\n+ try:\n+ return tag_from_stat(os.stat(path))\n+ except FileNotFoundError:\n+ return '-'\n+ except OSError:\n+ return None\n \n- A message is associated with a particular bus. You can create messages for\n- a bus with Bus.message_new_method_call() or Bus.message_new_signal(). You\n- can create replies to method calls with Message.new_method_return() or\n- Message.new_method_error(). You can append arguments with Message.append()\n- and send the message with Message.send().\n- \"\"\"\n- def get_bus(self) -> 'Bus':\n- \"\"\"Get the bus that a message is associated with.\n \n- This is the bus that a message came from or will be sent on. Every\n- message has an associated bus, and it cannot be changed.\n+def tag_from_fd(fd):\n+ try:\n+ return tag_from_stat(os.fstat(fd))\n+ except OSError:\n+ return None\n \n- :returns: the Bus\n- \"\"\"\n- return Bus.ref(self._get_bus())\n \n- def get_error(self) -> Optional[BusError]:\n- \"\"\"Get the BusError from a message.\n+class FsListChannel(Channel):\n+ payload = 'fslist1'\n \n- :returns: a BusError for an error message, or None for a non-error message\n- \"\"\"\n- error = self._get_error()\n- if error:\n- return BusError(*error.contents.get())\n+ def send_entry(self, event, entry):\n+ if entry.is_symlink():\n+ mode = 'link'\n+ elif entry.is_file():\n+ mode = 'file'\n+ elif entry.is_dir():\n+ mode = 'directory'\n else:\n- return None\n-\n- def new_method_return(self, signature: str = '', *args: Any) -> 'BusMessage':\n- \"\"\"Create a new (successful) return message as a reply to this message.\n-\n- This only makes sense when performed on a method call message.\n-\n- :signature: The signature of the result, as a string.\n- :args: The values to send, conforming to the signature string.\n-\n- :returns: the reply message\n- \"\"\"\n- reply = BusMessage()\n- self._new_method_return(byref(reply))\n- reply.append(signature, *args)\n- return reply\n-\n- def new_method_error(self, error: Union[BusError, OSError]) -> 'BusMessage':\n- \"\"\"Create a new error message as a reply to this message.\n-\n- This only makes sense when performed on a method call message.\n-\n- :error: BusError or OSError of the error to send\n+ mode = 'special'\n \n- :returns: the reply message\n- \"\"\"\n- reply = BusMessage()\n- if isinstance(error, BusError):\n- self._new_method_errorf(byref(reply), error.name, \"%s\", error.message)\n- else:\n- assert isinstance(error, OSError)\n- self._new_method_errnof(byref(reply), error.errno, \"%s\", str(error))\n- return reply\n+ self.send_json(event=event, path=entry.name, type=mode)\n \n- def append_arg(self, typestring: str, arg: Any) -> None:\n- \"\"\"Append a single argument to the message.\n+ def do_open(self, options):\n+ path = options.get('path')\n+ watch = options.get('watch', True)\n \n- :typestring: a single typestring, such as 's', or 'a{sv}'\n- :arg: the argument to append, matching the typestring\n- \"\"\"\n- type_, = bustypes.from_signature(typestring)\n- type_.writer(self, arg)\n+ if watch:\n+ raise ChannelError('not-supported', message='watching is not implemented, use fswatch1')\n \n- def append(self, signature: str, *args: Any) -> None:\n- \"\"\"Append zero or more arguments to the message.\n+ try:\n+ scan_dir = os.scandir(path)\n+ except FileNotFoundError as error:\n+ raise ChannelError('not-found', message=str(error)) from error\n+ except PermissionError as error:\n+ raise ChannelError('access-denied', message=str(error)) from error\n+ except OSError as error:\n+ raise ChannelError('internal-error', message=str(error)) from error\n \n- :signature: concatenated typestrings, such 'a{sv}' (one arg), or 'ss' (two args)\n- :args: one argument for each type string in the signature\n- \"\"\"\n- types = bustypes.from_signature(signature)\n- assert len(types) == len(args), f'call args {args} have different length than signature {signature}'\n- for type_, arg in zip(types, args):\n- type_.writer(self, arg)\n+ self.ready()\n+ for entry in scan_dir:\n+ self.send_entry(\"present\", entry)\n \n- def get_body(self) -> Tuple[object, ...]:\n- \"\"\"Gets the body of a message.\n+ if not watch:\n+ self.done()\n+ self.close()\n \n- Possible return values are (), ('single',), or ('x', 'y'). If you\n- check the signature of the message using Message.has_signature() then\n- you can use tuple unpacking.\n \n- single, = message.get_body()\n+class FsReadChannel(GeneratorChannel):\n+ payload = 'fsread1'\n \n- x, y = other_message.get_body()\n+ def do_yield_data(self, options: JsonObject) -> Iterator[bytes]:\n+ path = get_str(options, 'path')\n+ binary = get_enum(options, 'binary', ['raw'], None) is not None\n+ max_read_size = get_int(options, 'max_read_size', None)\n \n- :returns: an n-tuple containing one value per argument in the message\n- \"\"\"\n- self.rewind(True)\n- types = bustypes.from_signature(self.get_signature(True))\n- return tuple(type_.reader(self) for type_ in types)\n+ logger.debug('Opening file \"%s\" for reading', path)\n \n- def send(self) -> bool: # Literal[True]\n- \"\"\"Sends a message on the bus that it was created for.\n+ try:\n+ with open(path, 'rb') as filep:\n+ buf = os.stat(filep.fileno())\n+ if max_read_size is not None and buf.st_size > max_read_size:\n+ raise ChannelError('too-large')\n \n- :returns: True\n- \"\"\"\n- self.get_bus().send(self, None)\n- return True\n+ if binary and stat.S_ISREG(buf.st_mode):\n+ self.ready(size_hint=buf.st_size)\n+ else:\n+ self.ready()\n \n- def reply_method_error(self, error: Union[BusError, OSError]) -> bool: # Literal[True]\n- \"\"\"Sends an error as a reply to a method call message.\n+ while True:\n+ data = filep.read1(Channel.BLOCK_SIZE)\n+ if data == b'':\n+ break\n+ logger.debug(' ...sending %d bytes', len(data))\n+ if not binary:\n+ data = data.replace(b'\\0', b'').decode('utf-8', errors='ignore').encode('utf-8')\n+ yield data\n \n- :error: A BusError or OSError\n+ return {'tag': tag_from_stat(buf)}\n \n- :returns: True\n- \"\"\"\n- return self.new_method_error(error).send()\n+ except FileNotFoundError:\n+ return {'tag': '-'}\n+ except PermissionError as exc:\n+ raise ChannelError('access-denied') from exc\n+ except OSError as exc:\n+ raise ChannelError('internal-error', message=str(exc)) from exc\n \n- def reply_method_return(self, signature: str = '', *args: Any) -> bool: # Literal[True]\n- \"\"\"Sends a return value as a reply to a method call message.\n \n- :signature: The signature of the result, as a string.\n- :args: The values to send, conforming to the signature string.\n+class FsReplaceChannel(AsyncChannel):\n+ payload = 'fsreplace1'\n \n- :returns: True\n- \"\"\"\n- return self.new_method_return(signature, *args).send()\n+ def delete(self, path: str, tag: 'str | None') -> str:\n+ if tag is not None and tag != tag_from_path(path):\n+ raise ChannelError('change-conflict')\n+ with contextlib.suppress(FileNotFoundError): # delete is idempotent\n+ os.unlink(path)\n+ return '-'\n \n- def _coroutine_task_complete(self, out_type: bustypes.MessageType, task: asyncio.Task) -> None:\n+ async def set_contents(self, path: str, tag: 'str | None', data: 'bytes | None', size: 'int | None') -> str:\n+ dirname, basename = os.path.split(path)\n+ tmpname: str | None\n+ fd, tmpname = tempfile.mkstemp(dir=dirname, prefix=f'.{basename}-')\n try:\n- self.reply_method_function_return_value(out_type, task.result())\n- except (BusError, OSError) as exc:\n- self.reply_method_error(exc)\n-\n- def reply_method_function_return_value(self,\n- out_type: bustypes.MessageType,\n- return_value: Any) -> bool: # Literal[True]:\n- \"\"\"Sends the result of a function call as a reply to a method call message.\n-\n- This call does a bit of magic: it adapts from the usual Python return\n- value conventions (where the return value is ``None``, a single value,\n- or a tuple) to the normal D-Bus return value conventions (where the\n- result is always a tuple).\n-\n- Additionally, if the value is found to be a coroutine, a task is\n- created to run the coroutine to completion and return the result\n- (including exception handling).\n-\n- :out_types: The types of the return values, as an iterable.\n- :return_value: The return value of a Python function call.\n+ if size is not None:\n+ logger.debug('fallocate(%s.tmp, %d)', path, size)\n+ if size: # posix_fallocate() of 0 bytes is EINVAL\n+ await self.in_thread(os.posix_fallocate, fd, 0, size)\n+ self.ready() # ...only after that worked\n \n- :returns: True\n- \"\"\"\n- if asyncio.coroutines.iscoroutine(return_value):\n- task = asyncio.create_task(return_value)\n- task.add_done_callback(lambda task: self._coroutine_task_complete(out_type, task))\n- return True\n+ written = 0\n+ while data is not None:\n+ await self.in_thread(os.write, fd, data)\n+ written += len(data)\n+ data = await self.read()\n \n- reply = self.new_method_return()\n- # In the general case, a function returns an n-tuple, but...\n- if len(out_type) == 0:\n- # Functions with no return value return None.\n- assert return_value is None\n- elif len(out_type) == 1:\n- # Functions with a single return value return that value.\n- out_type.write(reply, return_value)\n- else:\n- # (general case) n return values are handled as an n-tuple.\n- assert len(out_type) == len(return_value)\n- out_type.write(reply, *return_value)\n- return reply.send()\n+ if size is not None and written < size:\n+ logger.debug('ftruncate(%s.tmp, %d)', path, written)\n+ await self.in_thread(os.ftruncate, fd, written)\n \n+ await self.in_thread(os.fdatasync, fd)\n \n-class Slot(libsystemd.sd_bus_slot):\n- def __init__(self, callback: Callable[[BusMessage], bool]):\n- def handler(message: WeakReference, _data: object, _err: object) -> int:\n- return 1 if callback(BusMessage.ref(message)) else 0\n- self.trampoline = libsystemd.sd_bus_message_handler_t(handler)\n+ if tag is None:\n+ # no preconditions about what currently exists or not\n+ # calculate the file mode from the umask\n+ os.fchmod(fd, 0o666 & ~my_umask())\n+ os.rename(tmpname, path)\n+ tmpname = None\n \n+ elif tag == '-':\n+ # the file must not exist. file mode from umask.\n+ os.fchmod(fd, 0o666 & ~my_umask())\n+ os.link(tmpname, path) # will fail if file exists\n \n-if typing.TYPE_CHECKING:\n- FutureMessage = asyncio.Future[BusMessage]\n-else:\n- # Python 3.6 can't subscript asyncio.Future\n- FutureMessage = asyncio.Future\n+ else:\n+ # the file must exist with the given tag\n+ buf = os.stat(path)\n+ if tag != tag_from_stat(buf):\n+ raise ChannelError('change-conflict')\n+ # chown/chmod from the existing file permissions\n+ os.fchmod(fd, stat.S_IMODE(buf.st_mode))\n+ os.fchown(fd, buf.st_uid, buf.st_gid)\n+ os.rename(tmpname, path)\n+ tmpname = None\n \n+ finally:\n+ os.close(fd)\n+ if tmpname is not None:\n+ os.unlink(tmpname)\n \n-class PendingCall(Slot):\n- future: FutureMessage\n+ return tag_from_path(path)\n \n- def __init__(self) -> None:\n- future = asyncio.get_running_loop().create_future()\n+ async def run(self, options: JsonObject) -> JsonObject:\n+ path = get_str(options, 'path')\n+ size = get_int(options, 'size', None)\n+ tag = get_str(options, 'tag', None)\n \n- def done(message: BusMessage) -> bool:\n- error = message.get_error()\n- if future.cancelled():\n- return True\n- if error is not None:\n- future.set_exception(error)\n+ try:\n+ # In the `size` case, .set_contents() sends the ready only after\n+ # it knows that the allocate was successful. In the case without\n+ # `size`, we need to send the ready() up front in order to\n+ # receive the first frame and decide if we're creating or deleting.\n+ if size is not None:\n+ tag = await self.set_contents(path, tag, b'', size)\n else:\n- future.set_result(message)\n- return True\n-\n- super().__init__(done)\n- self.future = future\n+ self.ready()\n+ data = await self.read()\n+ # if we get EOF right away, that's a request to delete\n+ if data is None:\n+ tag = self.delete(path, tag)\n+ else:\n+ tag = await self.set_contents(path, tag, data, None)\n \n+ self.done()\n+ return {'tag': tag}\n \n-class Bus(libsystemd.sd_bus):\n- _default_system_instance = None\n- _default_user_instance = None\n+ except FileNotFoundError as exc:\n+ raise ChannelError('not-found') from exc\n+ except FileExistsError as exc:\n+ # that's from link() noticing that the target file already exists\n+ raise ChannelError('change-conflict') from exc\n+ except PermissionError as exc:\n+ raise ChannelError('access-denied') from exc\n+ except IsADirectoryError as exc:\n+ # not ideal, but the closest code we have\n+ raise ChannelError('access-denied', message=str(exc)) from exc\n+ except OSError as exc:\n+ raise ChannelError('internal-error', message=str(exc)) from exc\n \n- class NameFlags(enum.IntFlag):\n- DEFAULT = 0\n- REPLACE_EXISTING = 1 << 0\n- ALLOW_REPLACEMENT = 1 << 1\n- QUEUE = 1 << 2\n \n- @staticmethod\n- def new(\n- fd: Optional[int] = None,\n- address: Optional[str] = None,\n- bus_client: bool = False,\n- server: bool = False,\n- start: bool = True,\n- attach_event: bool = True\n- ) -> 'Bus':\n- bus = Bus()\n- Bus._new(byref(bus))\n- if address is not None:\n- bus.set_address(address)\n- if fd is not None:\n- bus.set_fd(fd, fd)\n- if bus_client:\n- bus.set_bus_client(True)\n- if server:\n- bus.set_server(True, libsystemd.sd_id128())\n- if address is not None or fd is not None:\n- if start:\n- bus.start()\n- if attach_event:\n- bus.attach_event(None, 0)\n- return bus\n+class FsWatchChannel(Channel):\n+ payload = 'fswatch1'\n+ _tag = None\n+ _path = None\n+ _watch = None\n \n- @staticmethod\n- def default_system(attach_event: bool = True) -> 'Bus':\n- if Bus._default_system_instance is None:\n- Bus._default_system_instance = Bus()\n- Bus._default_system(byref(Bus._default_system_instance))\n- if attach_event:\n- Bus._default_system_instance.attach_event(None, 0)\n- return Bus._default_system_instance\n+ # The C bridge doesn't send the initial event, and the JS calls read()\n+ # instead to figure out the initial state of the file. If we send the\n+ # initial state then we cause the event to get delivered twice.\n+ # Ideally we'll sort that out at some point, but for now, suppress it.\n+ _active = False\n \n @staticmethod\n- def default_user(attach_event: bool = True) -> 'Bus':\n- if Bus._default_user_instance is None:\n- Bus._default_user_instance = Bus()\n- Bus._default_user(byref(Bus._default_user_instance))\n- if attach_event:\n- Bus._default_user_instance.attach_event(None, 0)\n- return Bus._default_user_instance\n-\n- def message_new_method_call(\n- self,\n- destination: Optional[str],\n- path: str,\n- interface: str,\n- member: str,\n- types: str = '',\n- *args: object\n- ) -> BusMessage:\n- message = BusMessage()\n- self._message_new_method_call(byref(message), destination, path, interface, member)\n- message.append(types, *args)\n- return message\n-\n- def message_new_signal(\n- self, path: str, interface: str, member: str, types: str = '', *args: object\n- ) -> BusMessage:\n- message = BusMessage()\n- self._message_new_signal(byref(message), path, interface, member)\n- message.append(types, *args)\n- return message\n+ def mask_to_event_and_type(mask):\n+ if (InotifyEvent.CREATE or InotifyEvent.MOVED_TO) in mask:\n+ return 'created', 'directory' if InotifyEvent.ISDIR in mask else 'file'\n+ elif InotifyEvent.MOVED_FROM in mask or InotifyEvent.DELETE in mask or InotifyEvent.DELETE_SELF in mask:\n+ return 'deleted', None\n+ elif InotifyEvent.ATTRIB in mask:\n+ return 'attribute-changed', None\n+ elif InotifyEvent.CLOSE_WRITE in mask:\n+ return 'done-hint', None\n+ else:\n+ return 'changed', None\n \n- def call(self, message: BusMessage, timeout: Optional[int] = None) -> BusMessage:\n- reply = BusMessage()\n- error = libsystemd.sd_bus_error()\n- try:\n- self._call(message, timeout or 0, byref(error), byref(reply))\n- return reply\n- except OSError as exc:\n- raise BusError(*error.get()) from exc\n+ def do_inotify_event(self, mask, _cookie, name):\n+ logger.debug(\"do_inotify_event(%s): mask %X name %s\", self._path, mask, name)\n+ event, type_ = self.mask_to_event_and_type(mask)\n+ if name:\n+ # file inside watched directory changed\n+ path = os.path.join(self._path, name.decode())\n+ tag = tag_from_path(path)\n+ self.send_json(event=event, path=path, tag=tag, type=type_)\n+ else:\n+ # the watched path itself changed; filter out duplicate events\n+ tag = tag_from_path(self._path)\n+ if tag == self._tag:\n+ return\n+ self._tag = tag\n+ self.send_json(event=event, path=self._path, tag=self._tag, type=type_)\n \n- def call_method(\n- self,\n- destination: str,\n- path: str,\n- interface: str,\n- member: str,\n- types: str = '',\n- *args: object,\n- timeout: Optional[int] = None\n- ) -> Tuple[object, ...]:\n- logger.debug('Doing sync method call %s %s %s %s %s %s',\n- destination, path, interface, member, types, args)\n- message = self.message_new_method_call(destination, path, interface, member, types, *args)\n- message = self.call(message, timeout)\n- return message.get_body()\n+ def do_identity_changed(self, fd, err):\n+ logger.debug(\"do_identity_changed(%s): fd %s, err %s\", self._path, str(fd), err)\n+ self._tag = tag_from_fd(fd) if fd else '-'\n+ if self._active:\n+ self.send_json(event='created' if fd else 'deleted', path=self._path, tag=self._tag)\n \n- async def call_async(\n- self,\n- message: BusMessage,\n- timeout: Optional[int] = None\n- ) -> BusMessage:\n- pending = PendingCall()\n- self._call_async(byref(pending), message, pending.trampoline, pending.userdata, timeout or 0)\n- return await pending.future\n+ def do_open(self, options):\n+ self._path = options['path']\n+ self._tag = None\n \n- async def call_method_async(\n- self,\n- destination: Optional[str],\n- path: str,\n- interface: str,\n- member: str,\n- types: str = '',\n- *args: object,\n- timeout: Optional[int] = None\n- ) -> Tuple[object, ...]:\n- logger.debug('Doing async method call %s %s %s %s %s %s',\n- destination, path, interface, member, types, args)\n- message = self.message_new_method_call(destination, path, interface, member, types, *args)\n- message = await self.call_async(message, timeout)\n- return message.get_body()\n+ self._active = False\n+ self._watch = PathWatch(self._path, self)\n+ self._active = True\n \n- def add_match(self, rule: str, handler: Callable[[BusMessage], bool]) -> Slot:\n- slot = Slot(handler)\n- self._add_match(byref(slot), rule, slot.trampoline, slot.userdata)\n- return slot\n+ self.ready()\n \n- def add_object(self, path: str, obj: 'BaseObject') -> Slot:\n- slot = Slot(obj.message_received)\n- self._add_object(byref(slot), path, slot.trampoline, slot.userdata)\n- obj.registered_on_bus(self, path)\n- return slot\n+ def do_close(self):\n+ self._watch.close()\n+ self._watch = None\n+ self.close()\n \n \n-class BaseObject:\n- \"\"\"Base object type for exporting objects on the bus\n+class Follow(enum.Enum):\n+ NO = False\n+ YES = True\n \n- This is the lowest-level class that can be passed to Bus.add_object().\n \n- If you want to directly subclass this, you'll need to implement\n- `message_received()`.\n+class FsInfoChannel(Channel, PathWatchListener):\n+ payload = 'fsinfo'\n \n- Subclassing from `bus.Object` is probably a better choice.\n- \"\"\"\n- _dbus_bus: Optional[Bus] = None\n- _dbus_path: Optional[str] = None\n+ # Options (all get set in `do_open()`)\n+ path: str\n+ attrs: 'set[str]'\n+ fnmatch: str\n+ targets: bool\n+ follow: bool\n+ watch: bool\n \n- def registered_on_bus(self, bus: Bus, path: str) -> None:\n- \"\"\"Report that an instance was exported on a given bus and path.\n+ # State\n+ current_value: JsonDict\n+ effective_fnmatch: str = ''\n+ fd: 'Handle | None' = None\n+ pending: 'set[str] | None' = None\n+ path_watch: 'PathWatch | None' = None\n+ getattrs: 'Callable[[int, str, Follow], JsonDocument]'\n \n- This is used so that the instance knows where to send signals.\n- Bus.add_object() calls this: you probably shouldn't call this on your\n- own.\n- \"\"\"\n- self._dbus_bus = bus\n- self._dbus_path = path\n+ @staticmethod\n+ def make_getattrs(attrs: Iterable[str]) -> 'Callable[[int, str, Follow], JsonDocument | None]':\n+ # Cached for the duration of the closure we're creating\n+ @functools.lru_cache()\n+ def get_user(uid: int) -> 'str | int':\n+ try:\n+ return pwd.getpwuid(uid).pw_name\n+ except KeyError:\n+ return uid\n \n- self.registered()\n+ @functools.lru_cache()\n+ def get_group(gid: int) -> 'str | int':\n+ try:\n+ return grp.getgrgid(gid).gr_name\n+ except KeyError:\n+ return gid\n \n- def registered(self) -> None:\n- \"\"\"Called after an object has been registered on the bus\n+ stat_types = {stat.S_IFREG: 'reg', stat.S_IFDIR: 'dir', stat.S_IFLNK: 'lnk', stat.S_IFCHR: 'chr',\n+ stat.S_IFBLK: 'blk', stat.S_IFIFO: 'fifo', stat.S_IFSOCK: 'sock'}\n+ available_stat_getters = {\n+ 'type': lambda buf: stat_types.get(stat.S_IFMT(buf.st_mode)),\n+ 'tag': tag_from_stat,\n+ 'mode': lambda buf: stat.S_IMODE(buf.st_mode),\n+ 'size': lambda buf: buf.st_size,\n+ 'uid': lambda buf: buf.st_uid,\n+ 'gid': lambda buf: buf.st_gid,\n+ 'mtime': lambda buf: buf.st_mtime,\n+ 'user': lambda buf: get_user(buf.st_uid),\n+ 'group': lambda buf: get_group(buf.st_gid),\n+ }\n+ stat_getters = tuple((key, available_stat_getters.get(key, lambda _: None)) for key in attrs)\n \n- This is the correct method to implement to do some initial work that\n- needs to be done after registration. The default implementation does\n- nothing.\n- \"\"\"\n- pass\n+ def get_attrs(fd: int, name: str, follow: Follow) -> 'JsonDict | None':\n+ try:\n+ buf = os.stat(name, follow_symlinks=follow.value, dir_fd=fd) if name else os.fstat(fd)\n+ except FileNotFoundError:\n+ return None\n+ except OSError:\n+ return {name: None for name, func in stat_getters}\n \n- def emit_signal(\n- self, interface: str, name: str, signature: str, *args: Any\n- ) -> bool:\n- \"\"\"Emit a D-Bus signal on this object\n+ result = {key: func(buf) for key, func in stat_getters}\n \n- The object must have been exported on the bus with Bus.add_object().\n+ if 'target' in result and stat.S_IFMT(buf.st_mode) == stat.S_IFLNK:\n+ with contextlib.suppress(OSError):\n+ result['target'] = os.readlink(name, dir_fd=fd)\n \n- :interface: the interface of the signal\n- :name: the 'member' name of the signal to emit\n- :signature: the type signature, as a string\n- :args: the arguments, according to the signature\n- :returns: True\n- \"\"\"\n- assert self._dbus_bus is not None\n- assert self._dbus_path is not None\n- return self._dbus_bus.message_new_signal(self._dbus_path, interface, name, signature, *args).send()\n+ return result\n \n- def message_received(self, message: BusMessage) -> bool:\n- \"\"\"Called when a message is received for this object\n+ return get_attrs\n \n- This is the lowest level interface to the BaseObject. You need to\n- handle method calls, properties, and introspection.\n+ def send_update(self, updates: JsonDict, *, reset: bool = False) -> None:\n+ if reset:\n+ if set(self.current_value) & set(updates):\n+ # if we have an overlap, we need to do a proper reset\n+ self.send_json(dict.fromkeys(self.current_value), partial=True)\n+ self.current_value = {'partial': True}\n+ updates.update(partial=None)\n+ else:\n+ # otherwise there's no overlap: we can just remove the old keys\n+ updates.update(dict.fromkeys(self.current_value))\n \n- You are expected to handle the message and return True. Normally this\n- means that you send a reply. If you don't want to handle the message,\n- return False and other handlers will have a chance to run. If no\n- handler handles the message, systemd will generate a suitable error\n- message and send that, instead.\n+ json_merge_and_filter_patch(self.current_value, updates)\n+ if updates:\n+ self.send_json(updates)\n \n- :message: the message that was received\n- :returns: True if the message was handled\n- \"\"\"\n- raise NotImplementedError\n+ def process_update(self, updates: 'set[str]', *, reset: bool = False) -> None:\n+ assert self.fd is not None\n \n+ entries: JsonDict = {name: self.getattrs(self.fd, name, Follow.NO) for name in updates}\n \n-class Interface:\n- \"\"\"The high-level base class for defining D-Bus interfaces\n+ info = entries.pop('', {})\n+ assert isinstance(info, dict) # fstat() will never fail with FileNotFoundError\n \n- This class provides high-level APIs for defining methods, properties, and\n- signals, as well as implementing introspection.\n+ if self.effective_fnmatch:\n+ info['entries'] = entries\n \n- On its own, this class doesn't provide a mechanism for exporting anything\n- on the bus. The Object class does that, and you'll generally want to\n- subclass from it, as it contains several built-in standard interfaces\n- (introspection, properties, etc.).\n+ if self.targets:\n+ info['targets'] = targets = {}\n+ for name in {e.get('target') for e in entries.values() if isinstance(e, dict)}:\n+ if isinstance(name, str) and ('/' in name or not self.interesting(name)):\n+ # if this target is a string that we wouldn't otherwise\n+ # report, then report it via our \"targets\" attribute.\n+ targets[name] = self.getattrs(self.fd, name, Follow.YES)\n \n- The name of your class will be interpreted as a D-Bus interface name.\n- Underscores are converted to dots. No case conversion is performed. If\n- the interface name can't be represented using this scheme, or if you'd like\n- to name your class differently, you can provide an interface= kwarg to the\n- class definition.\n+ self.send_update({'info': info}, reset=reset)\n \n- class com_example_Interface(bus.Object):\n- pass\n+ def process_pending_updates(self) -> None:\n+ assert self.pending is not None\n+ if self.pending:\n+ self.process_update(self.pending)\n+ self.pending = None\n \n- class MyInterface(bus.Object, interface='org.cockpit_project.Interface'):\n- pass\n+ def interesting(self, name: str) -> bool:\n+ if name == '':\n+ return True\n+ else:\n+ # only report updates on entry filenames if we match them\n+ return fnmatch.fnmatch(name, self.effective_fnmatch)\n \n- The methods, properties, and signals which are visible from D-Bus are\n- defined using helper classes with the corresponding names (Method,\n- Property, Signal). You should use normal Python snake_case conventions for\n- the member names: they will automatically be converted to CamelCase by\n- splitting on underscore and converting the first letter of each resulting\n- word to uppercase. For example, `method_name` becomes `MethodName`.\n+ def schedule_update(self, name: str) -> None:\n+ if not self.interesting(name):\n+ return\n \n- Each Method, Property, or Signal constructor takes an optional name= kwargs\n- to override the automatic name conversion convention above.\n+ if self.pending is None:\n+ asyncio.get_running_loop().call_later(0.1, self.process_pending_updates)\n+ self.pending = set()\n \n- An example class might look like:\n+ self.pending.add(name)\n \n- class com_example_MyObject(bus.Object):\n- created = bus.Interface.Signal('s', 'i')\n- renames = bus.Interface.Property('u', value=0)\n- name = bus.Interface.Property('s', 'undefined')\n+ def report_error(self, err: int) -> None:\n+ if err == errno.ENOENT:\n+ problem = 'not-found'\n+ elif err in (errno.EPERM, errno.EACCES):\n+ problem = 'access-denied'\n+ elif err == errno.ENOTDIR:\n+ problem = 'not-directory'\n+ else:\n+ problem = 'internal-error'\n \n- @bus.Interface.Method(out_types=(), in_types='s')\n- def rename(self, name):\n- self.renames += 1\n- self.name = name\n+ self.send_update({'error': {\n+ 'problem': problem, 'message': os.strerror(err), 'errno': errno.errorcode[err]\n+ }}, reset=True)\n \n- def registered(self):\n- self.created('Hello', 42)\n+ def flag_onlydir_error(self, fd: Handle) -> bool:\n+ # If our requested path ended with '/' then make sure we got a\n+ # directory, or else it's an error. open() will have already flagged\n+ # that for us, but systemd_ctypes doesn't do that (yet).\n+ if not self.watch or not self.path.endswith('/'):\n+ return False\n \n- See the documentation for the Method, Property, and Signal classes for\n- more information and examples.\n- \"\"\"\n+ buf = os.fstat(fd) # this should never fail\n+ if stat.S_IFMT(buf.st_mode) != stat.S_IFDIR:\n+ self.report_error(errno.ENOTDIR)\n+ return True\n \n- # Class variables\n- _dbus_interfaces: Dict[str, Dict[str, Dict[str, Any]]]\n- _dbus_members: Optional[Tuple[str, Dict[str, Dict[str, Any]]]]\n+ return False\n \n- # Instance variables: stored in Python form\n- _dbus_property_values: Optional[Dict[str, Any]] = None\n+ def report_initial_state(self, fd: Handle) -> None:\n+ if self.flag_onlydir_error(fd):\n+ return\n \n- @classmethod\n- def __init_subclass__(cls, interface: Optional[str] = None) -> None:\n- if interface is None:\n- assert '__' not in cls.__name__, 'Class name cannot contain sequential underscores'\n- interface = cls.__name__.replace('_', '.')\n+ self.fd = fd\n \n- # This is the information for this subclass directly\n- members: Dict[str, Dict[str, Interface._Member]] = {'methods': {}, 'properties': {}, 'signals': {}}\n- for name, member in cls.__dict__.items():\n- if isinstance(member, Interface._Member):\n- member.setup(interface, name, members)\n+ entries = {''}\n+ if self.fnmatch:\n+ try:\n+ entries.update(os.listdir(f'/proc/self/fd/{self.fd}'))\n+ self.effective_fnmatch = self.fnmatch\n+ except OSError:\n+ # If we failed to get an initial list, then report nothing from now on\n+ self.effective_fnmatch = ''\n \n- # We only store the information if something was actually defined\n- if sum(len(category) for category in members.values()) > 0:\n- cls._dbus_members = (interface, members)\n+ self.process_update({e for e in entries if self.interesting(e)}, reset=True)\n \n- # This is the information for this subclass, with all its ancestors\n- cls._dbus_interfaces = dict(ancestor.__dict__['_dbus_members']\n- for ancestor in cls.mro()\n- if '_dbus_members' in ancestor.__dict__)\n+ def do_inotify_event(self, mask: InotifyEvent, cookie: int, rawname: 'bytes | None') -> None:\n+ logger.debug('do_inotify_event(%r, %r, %r)', mask, cookie, rawname)\n+ name = (rawname or b'').decode(errors='surrogateescape')\n \n- @classmethod\n- def _find_interface(cls, interface: str) -> Dict[str, Dict[str, '_Member']]:\n- try:\n- return cls._dbus_interfaces[interface]\n- except KeyError as exc:\n- raise Object.Method.Unhandled from exc\n+ self.schedule_update(name)\n \n- @classmethod\n- def _find_category(cls, interface: str, category: str) -> Dict[str, '_Member']:\n- return cls._find_interface(interface)[category]\n+ if name and mask | (InotifyEvent.CREATE | InotifyEvent.DELETE |\n+ InotifyEvent.MOVED_TO | InotifyEvent.MOVED_FROM):\n+ # These events change the mtime of the directory\n+ self.schedule_update('')\n \n- @classmethod\n- def _find_member(cls, interface: str, category: str, member: str) -> '_Member':\n- members = cls._find_category(interface, category)\n- try:\n- return members[member]\n- except KeyError as exc:\n- raise Object.Method.Unhandled from exc\n+ def do_identity_changed(self, fd: 'Handle | None', err: 'int | None') -> None:\n+ logger.debug('do_identity_changed(%r, %r)', fd, err)\n+ # If there were previously pending changes, they are now irrelevant.\n+ if self.pending is not None:\n+ # Note: don't set to None, since the handler is still pending\n+ self.pending.clear()\n \n- class _Member:\n- _category: str # filled in from subclasses\n+ if err is None:\n+ assert fd is not None\n+ self.report_initial_state(fd)\n+ else:\n+ self.report_error(err)\n \n- _python_name: Optional[str] = None\n- _name: Optional[str] = None\n- _interface: Optional[str] = None\n- _description: Optional[Dict[str, Any]]\n+ def do_close(self) -> None:\n+ # non-watch channels close immediately \u2014 if we get this, we're watching\n+ assert self.path_watch is not None\n+ self.path_watch.close()\n+ self.close()\n \n- def __init__(self, name: Optional[str] = None) -> None:\n- self._python_name = None\n- self._interface = None\n- self._name = name\n+ def do_open(self, options: JsonObject) -> None:\n+ self.path = get_str(options, 'path')\n+ if not os.path.isabs(self.path):\n+ raise JsonError(options, '\"path\" must be an absolute path')\n \n- def setup(self, interface: str, name: str, members: Dict[str, Dict[str, 'Interface._Member']]) -> None:\n- self._python_name = name # for error messages\n- if self._name is None:\n- self._name = ''.join(word.title() for word in name.split('_'))\n- self._interface = interface\n- self._description = self._describe()\n- members[self._category][self._name] = self\n+ attrs = set(get_strv(options, 'attrs'))\n+ self.getattrs = self.make_getattrs(attrs - {'targets', 'entries'})\n+ self.fnmatch = get_str(options, 'fnmatch', '*' if 'entries' in attrs else '')\n+ self.targets = 'targets' in attrs\n+ self.follow = get_bool(options, 'follow', default=True)\n+ self.watch = get_bool(options, 'watch', default=False)\n+ if self.watch and not self.follow:\n+ raise JsonError(options, '\"watch: true\" and \"follow: false\" are (currently) incompatible')\n+ if self.targets and not self.follow:\n+ raise JsonError(options, '`targets: \"stat\"` and `follow: false` are (currently) incompatible')\n \n- def _describe(self) -> Dict[str, Any]:\n- raise NotImplementedError\n+ self.current_value = {}\n+ self.ready()\n \n- def __getitem__(self, key: str) -> Any:\n- # Acts as an adaptor for dict accesses from introspection.to_xml()\n- assert self._description is not None\n- return self._description[key]\n+ if not self.watch:\n+ try:\n+ fd = Handle.open(self.path, os.O_PATH if self.follow else os.O_PATH | os.O_NOFOLLOW)\n+ except OSError as exc:\n+ self.report_error(exc.errno)\n+ else:\n+ self.report_initial_state(fd)\n+ fd.close()\n \n- class Property(_Member):\n- \"\"\"Defines a D-Bus property on an interface\n+ self.done()\n+ self.close()\n \n- There are two main ways to define properties: with and without getters.\n- If you define a property without a getter, then you must provide a\n- value (via the value= kwarg). In this case, the property value is\n- maintained internally and can be accessed from Python in the usual way.\n- Change signals are sent automatically.\n+ else:\n+ # PathWatch will call do_identity_changed(), which does the same as\n+ # above: calls either report_initial_state() or report_error(),\n+ # depending on if it was provided with an fd or an error code.\n+ self.path_watch = PathWatch(self.path, self)\n+'''.encode('utf-8'),\n+ 'cockpit/channels/stream.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2022 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n- class MyObject(bus.Object):\n- counter = bus.Interface.Property('i', value=0)\n+import asyncio\n+import logging\n+import os\n+import subprocess\n+from typing import Dict\n \n- a = MyObject()\n- a.counter = 5\n- a.counter += 1\n- print(a.counter)\n+from ..channel import ChannelError, ProtocolChannel\n+from ..jsonutil import JsonDict, JsonObject, get_bool, get_enum, get_int, get_object, get_str, get_strv\n+from ..transports import SubprocessProtocol, SubprocessTransport, WindowSize\n \n- The other way to define properties is with a getter function. In this\n- case, you can read from the property in the normal way, but not write\n- to it. You are responsible for emitting change signals for yourself.\n- You must not provide the value= kwarg.\n+logger = logging.getLogger(__name__)\n \n- class MyObject(bus.Object):\n- _counter = 0\n \n- counter = bus.Interface.Property('i')\n- @counter.getter\n- def get_counter(self):\n- return self._counter\n+class SocketStreamChannel(ProtocolChannel):\n+ payload = 'stream'\n \n- @counter.setter\n- def set_counter(self, value):\n- self._counter = value\n- self.property_changed('Counter')\n+ async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport:\n+ if 'unix' in options and 'port' in options:\n+ raise ChannelError('protocol-error', message='cannot specify both \"port\" and \"unix\" options')\n \n- In either case, you can provide a setter function. This function has\n- no impact on Python code, but makes the property writable from the view\n- of D-Bus. Your setter will be called when a Properties.Set() call is\n- made, and no other action will be performed. A trivial implementation\n- might look like:\n+ try:\n+ # Unix\n+ if 'unix' in options:\n+ path = get_str(options, 'unix')\n+ label = f'Unix socket {path}'\n+ transport, _ = await loop.create_unix_connection(lambda: self, path)\n \n- class MyObject(bus.Object):\n- counter = bus.Interface.Property('i', value=0)\n- @counter.setter\n- def set_counter(self, value):\n- # we got a request to set the counter from D-Bus\n- self.counter = value\n+ # TCP\n+ elif 'port' in options:\n+ port = get_int(options, 'port')\n+ host = get_str(options, 'address', 'localhost')\n+ label = f'TCP socket {host}:{port}'\n \n- In all cases, the first (and only mandatory) argument to the\n- constructor is the D-Bus type of the property.\n+ transport, _ = await loop.create_connection(lambda: self, host, port)\n+ else:\n+ raise ChannelError('protocol-error',\n+ message='no \"port\" or \"unix\" or other address option for channel')\n \n- Your getter and setter functions can be provided by kwarg to the\n- constructor. You can also give a name= kwarg to override the default\n- name conversion scheme.\n- \"\"\"\n- _category = 'properties'\n+ logger.debug('SocketStreamChannel: connected to %s', label)\n+ except OSError as error:\n+ logger.info('SocketStreamChannel: connecting to %s failed: %s', label, error)\n+ if isinstance(error, ConnectionRefusedError):\n+ problem = 'not-found'\n+ else:\n+ problem = 'terminated'\n+ raise ChannelError(problem, message=str(error)) from error\n+ self.close_on_eof()\n+ assert isinstance(transport, asyncio.Transport)\n+ return transport\n \n- _getter: Optional[Callable[[Any], Any]]\n- _setter: Optional[Callable[[Any, Any], None]]\n- _type: bustypes.Type\n- _value: Any\n \n- def __init__(self, type_string: str,\n- value: Any = None,\n- name: Optional[str] = None,\n- getter: Optional[Callable[[Any], Any]] = None,\n- setter: Optional[Callable[[Any, Any], None]] = None):\n- assert value is None or getter is None, 'A property cannot have both a value and a getter'\n+class SubprocessStreamChannel(ProtocolChannel, SubprocessProtocol):\n+ payload = 'stream'\n+ restrictions = (('spawn', None),)\n \n- super().__init__(name=name)\n- self._getter = getter\n- self._setter = setter\n- self._type, = bustypes.from_signature(type_string)\n- self._value = value\n+ def process_exited(self) -> None:\n+ self.close_on_eof()\n \n- def _describe(self) -> Dict[str, Any]:\n- return {'type': self._type.typestring, 'flags': 'r' if self._setter is None else 'w'}\n+ def _get_close_args(self) -> JsonObject:\n+ assert isinstance(self._transport, SubprocessTransport)\n+ args: JsonDict = {'exit-status': self._transport.get_returncode()}\n+ stderr = self._transport.get_stderr()\n+ if stderr is not None:\n+ args['message'] = stderr\n+ return args\n \n- def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Any:\n- assert self._name is not None\n- if obj is None:\n- return self\n- if self._getter is not None:\n- return self._getter.__get__(obj, cls)()\n- elif self._value is not None:\n- if obj._dbus_property_values is not None:\n- return obj._dbus_property_values.get(self._name, self._value)\n- else:\n- return self._value\n- else:\n- raise AttributeError(f\"'{obj.__class__.__name__}' property '{self._python_name}' \"\n- f\"was not properly initialised: use either the 'value=' kwarg or \"\n- f\"the @'{self._python_name}.getter' decorator\")\n+ def do_options(self, options):\n+ window = get_object(options, 'window', WindowSize, None)\n+ if window is not None:\n+ self._transport.set_window_size(window)\n \n- def __set__(self, obj: 'Object', value: Any) -> None:\n- assert self._name is not None\n- if self._getter is not None:\n- raise AttributeError(f\"Cannot directly assign '{obj.__class__.__name__}' \"\n- \"property '{self._python_name}' because it has a getter\")\n- if obj._dbus_property_values is None:\n- obj._dbus_property_values = {}\n- obj._dbus_property_values[self._name] = value\n- if obj._dbus_bus is not None:\n- obj.properties_changed(self._interface, {self._name: bustypes.Variant(value, self._type)}, [])\n+ async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> SubprocessTransport:\n+ args = get_strv(options, 'spawn')\n+ err = get_enum(options, 'err', ['out', 'ignore', 'message'], 'message')\n+ cwd = get_str(options, 'directory', '.')\n+ pty = get_bool(options, 'pty', default=False)\n+ window = get_object(options, 'window', WindowSize, None)\n+ environ = get_strv(options, 'environ', [])\n \n- def to_dbus(self, obj: 'Object') -> bustypes.Variant:\n- return bustypes.Variant(self.__get__(obj), self._type)\n+ if err == 'out':\n+ stderr = subprocess.STDOUT\n+ elif err == 'ignore':\n+ stderr = subprocess.DEVNULL\n+ else:\n+ stderr = subprocess.PIPE\n \n- def from_dbus(self, obj: 'Object', value: bustypes.Variant) -> None:\n- if self._setter is None or self._type != value.type:\n- raise Object.Method.Unhandled\n- self._setter.__get__(obj)(value.value)\n+ env: Dict[str, str] = dict(os.environ)\n+ try:\n+ env.update(dict(e.split('=', 1) for e in environ))\n+ except ValueError:\n+ raise ChannelError('protocol-error', message='invalid \"environ\" option for stream channel') from None\n \n- def getter(self, getter: Callable[[Any], Any]) -> Callable[[Any], Any]:\n- if self._value is not None:\n- raise ValueError('A property cannot have both a value and a getter')\n- if self._getter is not None:\n- raise ValueError('This property already has a getter')\n- self._getter = getter\n- return getter\n+ try:\n+ transport = SubprocessTransport(loop, self, args, pty=pty, window=window, env=env, cwd=cwd, stderr=stderr)\n+ logger.debug('Spawned process args=%s pid=%i', args, transport.get_pid())\n+ return transport\n+ except FileNotFoundError as error:\n+ raise ChannelError('not-found') from error\n+ except PermissionError as error:\n+ raise ChannelError('access-denied') from error\n+ except OSError as error:\n+ logger.info(\"Failed to spawn %s: %s\", args, str(error))\n+ raise ChannelError('internal-error') from error\n+''',\n+ 'cockpit/channels/packages.py': br'''# This file is part of Cockpit.\n+#\n+# Copyright (C) 2022 Red Hat, Inc.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n- def setter(self, setter: Callable[[Any, Any], None]) -> Callable[[Any, Any], None]:\n- self._setter = setter\n- return setter\n+import logging\n+from typing import Optional\n \n- class Signal(_Member):\n- \"\"\"Defines a D-Bus signal on an interface\n+from ..channel import AsyncChannel\n+from ..data import read_cockpit_data_file\n+from ..jsonutil import JsonObject, get_dict, get_str\n+from ..packages import Packages\n \n- This is a callable which will result in the signal being emitted.\n+logger = logging.getLogger(__name__)\n \n- The constructor takes the types of the arguments, each one as a\n- separate parameter. For example:\n \n- properties_changed = Interface.Signal('s', 'a{sv}', 'as')\n+class PackagesChannel(AsyncChannel):\n+ payload = 'http-stream1'\n+ restrictions = [(\"internal\", \"packages\")]\n \n- You can give a name= kwarg to override the default name conversion\n- scheme.\n- \"\"\"\n- _category = 'signals'\n- _type: bustypes.MessageType\n+ # used to carry data forward from open to done\n+ options: Optional[JsonObject] = None\n \n- def __init__(self, *out_types: str, name: Optional[str] = None) -> None:\n- super().__init__(name=name)\n- self._type = bustypes.MessageType(out_types)\n+ def http_error(self, status: int, message: str) -> None:\n+ template = read_cockpit_data_file('fail.html')\n+ self.send_json(status=status, reason='ERROR', headers={'Content-Type': 'text/html; charset=utf-8'})\n+ self.send_data(template.replace(b'@@message@@', message.encode('utf-8')))\n+ self.done()\n+ self.close()\n \n- def _describe(self) -> Dict[str, Any]:\n- return {'in': self._type.typestrings}\n+ async def run(self, options: JsonObject) -> None:\n+ packages: Packages = self.router.packages # type: ignore[attr-defined] # yes, this is evil\n \n- def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Callable[..., None]:\n- def emitter(obj: Object, *args: Any) -> None:\n- assert self._interface is not None\n- assert self._name is not None\n- assert obj._dbus_bus is not None\n- assert obj._dbus_path is not None\n- message = obj._dbus_bus.message_new_signal(obj._dbus_path, self._interface, self._name)\n- self._type.write(message, *args)\n- message.send()\n- return emitter.__get__(obj, cls)\n+ try:\n+ if get_str(options, 'method') != 'GET':\n+ raise ValueError(f'Unsupported HTTP method {options[\"method\"]}')\n \n- class Method(_Member):\n- \"\"\"Defines a D-Bus method on an interface\n+ self.ready()\n+ if await self.read() is not None:\n+ raise ValueError('Received unexpected data')\n \n- This is a function decorator which marks a given method for export.\n+ path = get_str(options, 'path')\n+ headers = get_dict(options, 'headers')\n+ document = packages.load_path(path, headers)\n \n- The constructor takes two arguments: the type of the output arguments,\n- and the type of the input arguments. Both should be given as a\n- sequence.\n+ # Note: we can't cache documents right now. See\n+ # https://github.com/cockpit-project/cockpit/issues/19071\n+ # for future plans.\n+ out_headers = {\n+ 'Cache-Control': 'no-cache, no-store',\n+ 'Content-Type': document.content_type,\n+ }\n \n- @Interface.Method(['a{sv}'], ['s'])\n- def get_all(self, interface):\n- ...\n+ if document.content_encoding is not None:\n+ out_headers['Content-Encoding'] = document.content_encoding\n \n- You can give a name= kwarg to override the default name conversion\n- scheme.\n- \"\"\"\n- _category = 'methods'\n+ if document.content_security_policy is not None:\n+ policy = document.content_security_policy\n \n- class Unhandled(Exception):\n- \"\"\"Raised by a method to indicate that the message triggering that\n- method call remains unhandled.\"\"\"\n- pass\n+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src\n+ #\n+ # Note: connect-src 'self' does not resolve to websocket\n+ # schemes in all browsers, more info in this issue.\n+ #\n+ # https://github.com/w3c/webappsec-csp/issues/7\n+ if \"connect-src 'self';\" in policy:\n+ protocol = headers.get('X-Forwarded-Proto')\n+ host = headers.get('X-Forwarded-Host')\n+ if not isinstance(protocol, str) or not isinstance(host, str):\n+ raise ValueError('Invalid host or protocol header')\n \n- def __init__(self, out_types: Sequence[str] = (), in_types: Sequence[str] = (), name: Optional[str] = None):\n- super().__init__(name=name)\n- self._out_type = bustypes.MessageType(out_types)\n- self._in_type = bustypes.MessageType(in_types)\n- self._func = None\n+ websocket_scheme = \"wss\" if protocol == \"https\" else \"ws\"\n+ websocket_origin = f\"{websocket_scheme}://{host}\"\n+ policy = policy.replace(\"connect-src 'self';\", f\"connect-src {websocket_origin} 'self';\")\n \n- def __get__(self, obj, cls=None):\n- return self._func.__get__(obj, cls)\n+ out_headers['Content-Security-Policy'] = policy\n \n- def __call__(self, *args, **kwargs):\n- # decorator\n- self._func, = args\n- return self\n+ except ValueError as exc:\n+ self.http_error(400, str(exc))\n \n- def _describe(self) -> Dict[str, Any]:\n- return {'in': [item.typestring for item in self._in_type.item_types],\n- 'out': [item.typestring for item in self._out_type.item_types]}\n+ except KeyError:\n+ self.http_error(404, 'Not found')\n \n- def _invoke(self, obj, message):\n- args = self._in_type.read(message)\n- if args is None:\n- return False\n- try:\n- result = self._func.__get__(obj)(*args)\n- except (BusError, OSError) as error:\n- return message.reply_method_error(error)\n+ except OSError as exc:\n+ self.http_error(500, f'Internal error: {exc!s}')\n \n- return message.reply_method_function_return_value(self._out_type, result)\n+ else:\n+ self.send_json(status=200, reason='OK', headers=out_headers)\n+ await self.sendfile(document.data)\n+''',\n+ 'cockpit/_vendor/__init__.py': br'''''',\n+ 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3\n \n+import array\n+import io\n+import os\n+import socket\n+import sys\n+from typing import Sequence\n \n-class org_freedesktop_DBus_Peer(Interface):\n- @Interface.Method()\n- @staticmethod\n- def ping() -> None:\n- pass\n \n- @Interface.Method('s')\n- @staticmethod\n- def get_machine_id() -> str:\n- with open('/etc/machine-id', encoding='ascii') as file:\n- return file.read().strip()\n+def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None:\n+ cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')]\n \n+ with cmd_write:\n+ with cmd_read:\n+ with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock:\n+ fd_array = array.array('i', (cmd_read.fileno(), *fds))\n+ sock.sendmsg([b'\\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)])\n \n-class org_freedesktop_DBus_Introspectable(Interface):\n- @Interface.Method('s')\n- @classmethod\n- def introspect(cls) -> str:\n- return introspection.to_xml(cls._dbus_interfaces)\n+ cmd_write.write(repr((command, args)))\n \n \n-class org_freedesktop_DBus_Properties(Interface):\n- properties_changed = Interface.Signal('s', 'a{sv}', 'as')\n+def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int:\n+ ours, theirs = socket.socketpair()\n \n- @Interface.Method('v', 'ss')\n- def get(self, interface, name):\n- return self._find_member(interface, 'properties', name).to_dbus(self)\n+ with theirs:\n+ command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd))\n \n- @Interface.Method(['a{sv}'], 's')\n- def get_all(self, interface):\n- properties = self._find_category(interface, 'properties')\n- return {name: prop.to_dbus(self) for name, prop in properties.items()}\n+ with ours:\n+ return int(ours.recv(16) or b'1')\n \n- @Interface.Method('', 'ssv')\n- def set(self, interface, name, value):\n- self._find_member(interface, 'properties', name).from_dbus(self, value)\n \n+def main() -> None:\n+ if len(sys.argv) == 1:\n+ command(2, 'ferny.end', [])\n+ else:\n+ sys.exit(askpass(2, 1, sys.argv, dict(os.environ)))\n \n-class Object(org_freedesktop_DBus_Introspectable,\n- org_freedesktop_DBus_Peer,\n- org_freedesktop_DBus_Properties,\n- BaseObject,\n- Interface):\n- \"\"\"High-level base class for exporting objects on D-Bus\n \n- This is usually where you should start.\n+if __name__ == '__main__':\n+ main()\n+''',\n+ 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import (\n+ BEIBOOT_GADGETS,\n+ COMMAND_TEMPLATE,\n+ AskpassHandler,\n+ InteractionAgent,\n+ InteractionError,\n+ InteractionHandler,\n+ temporary_askpass,\n+ write_askpass_to_tmpdir,\n+)\n+from .session import Session\n+from .ssh_askpass import (\n+ AskpassPrompt,\n+ SshAskpassResponder,\n+ SshFIDOPINPrompt,\n+ SshFIDOUserPresencePrompt,\n+ SshHostKeyPrompt,\n+ SshPassphrasePrompt,\n+ SshPasswordPrompt,\n+ SshPKCS11PINPrompt,\n+)\n+from .ssh_errors import (\n+ SshAuthenticationError,\n+ SshChangedHostKeyError,\n+ SshError,\n+ SshHostKeyError,\n+ SshUnknownHostKeyError,\n+)\n+from .transport import FernyTransport, SubprocessError\n \n- This provides a base for exporting objects on the bus, implements the\n- standard D-Bus interfaces, and allows you to add your own interfaces to the\n- mix. See the documentation for Interface to find out how to define and\n- implement your D-Bus interface.\n- \"\"\"\n- def message_received(self, message: BusMessage) -> bool:\n- interface = message.get_interface()\n- name = message.get_member()\n+__all__ = [\n+ 'AskpassHandler',\n+ 'AskpassPrompt',\n+ 'AuthenticationError',\n+ 'BEIBOOT_GADGETS',\n+ 'COMMAND_TEMPLATE',\n+ 'ChangedHostKeyError',\n+ 'FernyTransport',\n+ 'HostKeyError',\n+ 'InteractionAgent',\n+ 'InteractionError',\n+ 'InteractionHandler',\n+ 'Session',\n+ 'SshAskpassResponder',\n+ 'SshAuthenticationError',\n+ 'SshChangedHostKeyError',\n+ 'SshError',\n+ 'SshFIDOPINPrompt',\n+ 'SshFIDOUserPresencePrompt',\n+ 'SshHostKeyError',\n+ 'SshHostKeyPrompt',\n+ 'SshPKCS11PINPrompt',\n+ 'SshPassphrasePrompt',\n+ 'SshPasswordPrompt',\n+ 'SshUnknownHostKeyError',\n+ 'SubprocessError',\n+ 'temporary_askpass',\n+ 'write_askpass_to_tmpdir',\n+]\n \n- try:\n- method = self._find_member(interface, 'methods', name)\n- assert isinstance(method, Interface.Method)\n- return method._invoke(self, message)\n- except Object.Method.Unhandled:\n- return False\n+__version__ = '0'\n ''',\n- 'cockpit/_vendor/systemd_ctypes/event.py': br'''# systemd_ctypes\n+ 'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1)\n #\n-# Copyright (C) 2022 Allison Karlitskaya \n+# Copyright (C) 2023 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n@@ -6556,136 +6819,399 @@\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n import asyncio\n-import selectors\n-import sys\n-from typing import Callable, ClassVar, Coroutine, List, Optional, Tuple\n+import contextlib\n+import logging\n+import typing\n+from typing import Any, Callable, Iterable, Sequence, TypeVar\n \n-from . import inotify, libsystemd\n-from .librarywrapper import Reference, UserData, byref\n+from .interaction_agent import InteractionAgent, InteractionHandler, get_running_loop\n+from .ssh_errors import get_exception_for_ssh_stderr\n \n+logger = logging.getLogger(__name__)\n \n-class Event(libsystemd.sd_event):\n- class Source(libsystemd.sd_event_source):\n- def cancel(self) -> None:\n- self._unref()\n- self.value = None\n+P = TypeVar('P', bound=asyncio.Protocol)\n \n- _default_instance: ClassVar[Optional['Event']] = None\n \n- @staticmethod\n- def default() -> 'Event':\n- if Event._default_instance is None:\n- Event._default_instance = Event()\n- Event._default(byref(Event._default_instance))\n- return Event._default_instance\n+class SubprocessError(Exception):\n+ returncode: int\n+ stderr: str\n \n- InotifyHandler = Callable[[inotify.Event, int, Optional[bytes]], None]\n+ def __init__(self, returncode: int, stderr: str) -> None:\n+ super().__init__(returncode, stderr)\n+ self.returncode = returncode\n+ self.stderr = stderr\n \n- class InotifySource(Source):\n- def __init__(self, handler: 'Event.InotifyHandler') -> None:\n- def callback(source: libsystemd.sd_event_source,\n- _event: Reference[inotify.inotify_event],\n- userdata: UserData) -> int:\n- event = _event.contents\n- handler(inotify.Event(event.mask), event.cookie, event.name)\n- return 0\n- self.trampoline = libsystemd.sd_event_inotify_handler_t(callback)\n \n- def add_inotify(self, path: str, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:\n- source = Event.InotifySource(handler)\n- self._add_inotify(byref(source), path, mask, source.trampoline, source.userdata)\n- return source\n+class FernyTransport(asyncio.Transport, asyncio.SubprocessProtocol):\n+ _agent: InteractionAgent\n+ _exec_task: 'asyncio.Task[tuple[asyncio.SubprocessTransport, FernyTransport]]'\n+ _is_ssh: bool\n+ _protocol: asyncio.Protocol\n+ _protocol_disconnected: bool = False\n \n- def add_inotify_fd(self, fd: int, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:\n- # HACK: sd_event_add_inotify_fd() got added in 250, which is too new. Fake it.\n- return self.add_inotify(f'/proc/self/fd/{fd}', mask, handler)\n+ # These get initialized in connection_made() and once set, never get unset.\n+ _subprocess_transport: 'asyncio.SubprocessTransport | None' = None\n+ _stdin_transport: 'asyncio.WriteTransport | None' = None\n+ _stdout_transport: 'asyncio.ReadTransport | None' = None\n \n+ # We record events that might build towards a connection termination here\n+ # and consider them from _consider_disconnect() in order to try to get the\n+ # best possible Exception for the protocol, rather than just taking the\n+ # first one (which is likely to be somewhat random).\n+ _exception: 'Exception | None' = None\n+ _stderr_output: 'str | None' = None\n+ _returncode: 'int | None' = None\n+ _transport_disconnected: bool = False\n+ _closed: bool = False\n \n-# This is all a bit more awkward than it should have to be: systemd's event\n-# loop chaining model is designed for glib's prepare/check/dispatch paradigm;\n-# failing to call prepare() can lead to deadlocks, for example.\n-#\n-# Hack a selector subclass which calls prepare() before sleeping and this for us.\n-class Selector(selectors.DefaultSelector):\n- def __init__(self, event: Optional[Event] = None) -> None:\n- super().__init__()\n- self.sd_event = event or Event.default()\n- self.key = self.register(self.sd_event.get_fd(), selectors.EVENT_READ)\n+ @classmethod\n+ def spawn(\n+ cls: 'type[typing.Self]',\n+ protocol_factory: Callable[[], P],\n+ args: Sequence[str],\n+ loop: 'asyncio.AbstractEventLoop | None' = None,\n+ interaction_handlers: Sequence[InteractionHandler] = (),\n+ is_ssh: bool = True,\n+ **kwargs: Any\n+ ) -> 'tuple[typing.Self, P]':\n+ \"\"\"Connects a FernyTransport to a protocol, using the given command.\n \n- def select(\n- self, timeout: Optional[float] = None\n- ) -> List[Tuple[selectors.SelectorKey, int]]:\n- # It's common to drop the last reference to a Source or Slot object on\n- # a dispatch of that same source/slot from the main loop. If we happen\n- # to garbage collect before returning, the trampoline could be\n- # destroyed before we're done using it. Provide a mechanism to defer\n- # the destruction of trampolines for as long as we might be\n- # dispatching. This gets cleared again at the bottom, before return.\n- libsystemd.Trampoline.deferred = []\n+ This spawns an external command and connects the stdin and stdout of\n+ the command to the protocol returned by the factory.\n \n- while self.sd_event.prepare():\n- self.sd_event.dispatch()\n- ready = super().select(timeout)\n- # workaround https://github.com/systemd/systemd/issues/23826\n- # keep calling wait() until there's nothing left\n- while self.sd_event.wait(0):\n- self.sd_event.dispatch()\n- while self.sd_event.prepare():\n- self.sd_event.dispatch()\n+ An instance of ferny.InteractionAgent is created and attached to the\n+ stderr of the spawned process, using the provided handlers. It is the\n+ responsibility of the caller to ensure that:\n+ - a `ferny-askpass` client program is installed somewhere; and\n+ - any relevant command-line arguments or environment variables are\n+ passed correctly to the program to be spawned\n \n- # We can be sure we're not dispatching callbacks anymore\n- libsystemd.Trampoline.deferred = None\n+ This function returns immediately and never raises exceptions, assuming\n+ all preconditions are met.\n \n- # This could return zero events with infinite timeout, but nobody seems to mind.\n- return [(key, events) for (key, events) in ready if key != self.key]\n+ If spawning the process fails then connection_lost() will be\n+ called with the relevant OSError, even before connection_made() is\n+ called. This is somewhat non-standard behaviour, but is the easiest\n+ way to report these errors without making this function async.\n \n+ Once the process is successfully executed, connection_made() will be\n+ called and the transport can be used as normal. connection_lost() will\n+ be called if the process exits or another error occurs.\n \n-class EventLoopPolicy(asyncio.DefaultEventLoopPolicy):\n- def new_event_loop(self) -> asyncio.AbstractEventLoop:\n- return asyncio.SelectorEventLoop(Selector())\n+ The return value of this function is the transport, but it exists in a\n+ semi-initialized state. You can call .close() on it, but nothing else.\n+ Once .connection_made() is called, you can call all the other\n+ functions.\n \n+ After you call this function, `.connection_lost()` will be called on\n+ your Protocol, exactly once, no matter what. Until that happens, you\n+ are responsible for holding a reference to the returned transport.\n \n-def run_async(main: Coroutine[None, None, None], debug: Optional[bool] = None) -> None:\n- asyncio.set_event_loop_policy(EventLoopPolicy())\n+ :param args: the full argv of the command to spawn\n+ :param loop: the event loop to use. If none is provided, we use the\n+ one which is (read: must be) currently running.\n+ :param interaction_handlers: the handlers passed to the\n+ InteractionAgent\n+ :param is_ssh: whether we should attempt to interpret stderr as ssh\n+ error messages\n+ :param kwargs: anything else is passed through to `subprocess_exec()`\n+ :returns: the usual `(Transport, Protocol)` pair\n+ \"\"\"\n+ logger.debug('spawn(%r, %r, %r)', cls, protocol_factory, args)\n \n- polyfill = sys.version_info < (3, 7, 0) and not hasattr(asyncio, 'run')\n- if polyfill:\n- # Polyfills for Python 3.6:\n- loop = asyncio.get_event_loop()\n+ protocol = protocol_factory()\n+ self = cls(protocol)\n+ self._is_ssh = is_ssh\n \n- assert not hasattr(asyncio, 'get_running_loop')\n- asyncio.get_running_loop = lambda: loop\n+ if loop is None:\n+ loop = get_running_loop()\n \n- assert not hasattr(asyncio, 'create_task')\n- asyncio.create_task = loop.create_task\n+ self._agent = InteractionAgent(interaction_handlers, loop, self._interaction_completed)\n+ kwargs.setdefault('stderr', self._agent.fileno())\n \n- assert not hasattr(asyncio, 'run')\n+ # As of Python 3.12 this isn't really asynchronous (since it uses the\n+ # subprocess module, which blocks while waiting for the exec() to\n+ # complete in the child), but we have to deal with the complication of\n+ # the async interface anyway. Since we, ourselves, want to export a\n+ # non-async interface, that means that we need a task here and a\n+ # bottom-half handler below.\n+ self._exec_task = loop.create_task(loop.subprocess_exec(lambda: self, *args, **kwargs))\n \n- def run(\n- main: Coroutine[None, None, None], debug: Optional[bool] = None\n- ) -> None:\n- if debug is not None:\n- loop.set_debug(debug)\n- loop.run_until_complete(main)\n+ def exec_completed(task: asyncio.Task) -> None:\n+ logger.debug('exec_completed(%r, %r)', self, task)\n+ assert task is self._exec_task\n+ try:\n+ transport, me = task.result()\n+ assert me is self\n+ logger.debug(' success.')\n+ except asyncio.CancelledError:\n+ return # in that case, do nothing\n+ except OSError as exc:\n+ logger.debug(' OSError %r', exc)\n+ self.close(exc)\n+ return\n \n- asyncio.run = run # type: ignore[assignment]\n+ # Our own .connection_made() handler should have gotten called by\n+ # now. Make sure everything got filled in properly.\n+ assert self._subprocess_transport is transport\n+ assert self._stdin_transport is not None\n+ assert self._stdout_transport is not None\n \n- asyncio._systemd_ctypes_polyfills = True # type: ignore[attr-defined]\n+ # Ask the InteractionAgent to start processing stderr.\n+ self._agent.start()\n \n- asyncio.run(main, debug=debug)\n+ self._exec_task.add_done_callback(exec_completed)\n \n- if polyfill:\n- del asyncio.create_task, asyncio.get_running_loop, asyncio.run\n+ return self, protocol\n+\n+ def __init__(self, protocol: asyncio.Protocol) -> None:\n+ self._protocol = protocol\n+\n+ def _consider_disconnect(self) -> None:\n+ logger.debug('_consider_disconnect(%r)', self)\n+ # We cannot disconnect as long as any of these three things are happening\n+ if not self._exec_task.done():\n+ logger.debug(' exec_task still running %r', self._exec_task)\n+ return\n+\n+ if self._subprocess_transport is not None and not self._transport_disconnected:\n+ logger.debug(' transport still connected %r', self._subprocess_transport)\n+ return\n+\n+ if self._stderr_output is None:\n+ logger.debug(' agent still running')\n+ return\n+\n+ # All conditions for disconnection are satisfied.\n+ if self._protocol_disconnected:\n+ logger.debug(' already disconnected')\n+ return\n+ self._protocol_disconnected = True\n+\n+ # Now we just need to determine what we report to the protocol...\n+ if self._exception is not None:\n+ # If we got an exception reported, that's our reason for closing.\n+ logger.debug(' disconnect with exception %r', self._exception)\n+ self._protocol.connection_lost(self._exception)\n+ elif self._returncode == 0 or self._closed:\n+ # If we called close() or have a zero return status, that's a clean\n+ # exit, regardless of noise that might have landed in stderr.\n+ logger.debug(' clean disconnect')\n+ self._protocol.connection_lost(None)\n+ elif self._is_ssh and self._returncode == 255:\n+ # This is an error code due to an SSH failure. Try to interpret it.\n+ logger.debug(' disconnect with ssh error %r', self._stderr_output)\n+ self._protocol.connection_lost(get_exception_for_ssh_stderr(self._stderr_output))\n+ else:\n+ # Otherwise, report the stderr text and return code.\n+ logger.debug(' disconnect with exit code %r, stderr %r', self._returncode, self._stderr_output)\n+ # We surely have _returncode set here, since otherwise:\n+ # - exec_task failed with an exception (which we handle above); or\n+ # - we're still connected...\n+ assert self._returncode is not None\n+ self._protocol.connection_lost(SubprocessError(self._returncode, self._stderr_output))\n+\n+ def _interaction_completed(self, future: 'asyncio.Future[str]') -> None:\n+ logger.debug('_interaction_completed(%r, %r)', self, future)\n+ try:\n+ self._stderr_output = future.result()\n+ logger.debug(' stderr: %r', self._stderr_output)\n+ except Exception as exc:\n+ logger.debug(' exception: %r', exc)\n+ self._stderr_output = '' # we need to set this in order to complete\n+ self.close(exc)\n+\n+ self._consider_disconnect()\n+\n+ # BaseProtocol implementation\n+ def connection_made(self, transport: asyncio.BaseTransport) -> None:\n+ logger.debug('connection_made(%r, %r)', self, transport)\n+ assert isinstance(transport, asyncio.SubprocessTransport)\n+ self._subprocess_transport = transport\n+\n+ stdin_transport = transport.get_pipe_transport(0)\n+ assert isinstance(stdin_transport, asyncio.WriteTransport)\n+ self._stdin_transport = stdin_transport\n+\n+ stdout_transport = transport.get_pipe_transport(1)\n+ assert isinstance(stdout_transport, asyncio.ReadTransport)\n+ self._stdout_transport = stdout_transport\n+\n+ stderr_transport = transport.get_pipe_transport(2)\n+ assert stderr_transport is None\n+\n+ logger.debug('calling connection_made(%r, %r)', self, self._protocol)\n+ self._protocol.connection_made(self)\n+\n+ def connection_lost(self, exc: 'Exception | None') -> None:\n+ logger.debug('connection_lost(%r, %r)', self, exc)\n+ if self._exception is None:\n+ self._exception = exc\n+ self._transport_disconnected = True\n+ self._consider_disconnect()\n+\n+ # SubprocessProtocol implementation\n+ def pipe_data_received(self, fd: int, data: bytes) -> None:\n+ logger.debug('pipe_data_received(%r, %r, %r)', self, fd, len(data))\n+ assert fd == 1 # stderr is handled separately\n+ self._protocol.data_received(data)\n+\n+ def pipe_connection_lost(self, fd: int, exc: 'Exception | None') -> None:\n+ logger.debug('pipe_connection_lost(%r, %r, %r)', self, fd, exc)\n+ assert fd in (0, 1) # stderr is handled separately\n+\n+ # We treat this as a clean close\n+ if isinstance(exc, BrokenPipeError):\n+ exc = None\n+\n+ # Record serious errors to propagate them to the protocol\n+ # If this is a clean exit on stdout, report an EOF\n+ if exc is not None:\n+ self.close(exc)\n+ elif fd == 1 and not self._closed:\n+ if not self._protocol.eof_received():\n+ self.close()\n+\n+ def process_exited(self) -> None:\n+ logger.debug('process_exited(%r)', self)\n+ assert self._subprocess_transport is not None\n+ self._returncode = self._subprocess_transport.get_returncode()\n+ logger.debug(' ._returncode = %r', self._returncode)\n+ self._agent.force_completion()\n+\n+ def pause_writing(self) -> None:\n+ logger.debug('pause_writing(%r)', self)\n+ self._protocol.pause_writing()\n+\n+ def resume_writing(self) -> None:\n+ logger.debug('resume_writing(%r)', self)\n+ self._protocol.resume_writing()\n+\n+ # Transport implementation. Most of this is straight delegation.\n+ def close(self, exc: 'Exception | None' = None) -> None:\n+ logger.debug('close(%r, %r)', self, exc)\n+ self._closed = True\n+ if self._exception is None:\n+ logger.debug(' setting exception %r', exc)\n+ self._exception = exc\n+ if not self._exec_task.done():\n+ logger.debug(' cancelling _exec_task')\n+ self._exec_task.cancel()\n+ if self._subprocess_transport is not None:\n+ logger.debug(' closing _subprocess_transport')\n+ # https://github.com/python/cpython/issues/112800\n+ with contextlib.suppress(PermissionError):\n+ self._subprocess_transport.close()\n+ self._agent.force_completion()\n+\n+ def is_closing(self) -> bool:\n+ assert self._subprocess_transport is not None\n+ return self._subprocess_transport.is_closing()\n+\n+ def get_extra_info(self, name: str, default: object = None) -> object:\n+ assert self._subprocess_transport is not None\n+ return self._subprocess_transport.get_extra_info(name, default)\n+\n+ def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:\n+ assert isinstance(protocol, asyncio.Protocol)\n+ self._protocol = protocol\n+\n+ def get_protocol(self) -> asyncio.Protocol:\n+ return self._protocol\n+\n+ def is_reading(self) -> bool:\n+ assert self._stdout_transport is not None\n+ try:\n+ return self._stdout_transport.is_reading()\n+ except NotImplementedError:\n+ # This is (incorrectly) unimplemented before Python 3.11\n+ return not self._stdout_transport._paused # type:ignore[attr-defined]\n+ except AttributeError:\n+ # ...and in Python 3.6 it's even worse\n+ try:\n+ selector = self._stdout_transport._loop._selector # type:ignore[attr-defined]\n+ selector.get_key(self._stdout_transport._fileno) # type:ignore[attr-defined]\n+ return True\n+ except KeyError:\n+ return False\n+\n+ def pause_reading(self) -> None:\n+ assert self._stdout_transport is not None\n+ self._stdout_transport.pause_reading()\n+\n+ def resume_reading(self) -> None:\n+ assert self._stdout_transport is not None\n+ self._stdout_transport.resume_reading()\n+\n+ def abort(self) -> None:\n+ assert self._stdin_transport is not None\n+ assert self._subprocess_transport is not None\n+ self._stdin_transport.abort()\n+ self._subprocess_transport.kill()\n+\n+ def can_write_eof(self) -> bool:\n+ assert self._stdin_transport is not None\n+ return self._stdin_transport.can_write_eof() # will always be True\n+\n+ def get_write_buffer_size(self) -> int:\n+ assert self._stdin_transport is not None\n+ return self._stdin_transport.get_write_buffer_size()\n+\n+ def get_write_buffer_limits(self) -> 'tuple[int, int]':\n+ assert self._stdin_transport is not None\n+ return self._stdin_transport.get_write_buffer_limits()\n+\n+ def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None:\n+ assert self._stdin_transport is not None\n+ return self._stdin_transport.set_write_buffer_limits(high, low)\n+\n+ def write(self, data: bytes) -> None:\n+ assert self._stdin_transport is not None\n+ return self._stdin_transport.write(data)\n+\n+ def writelines(self, list_of_data: Iterable[bytes]) -> None:\n+ assert self._stdin_transport is not None\n+ return self._stdin_transport.writelines(list_of_data)\n+\n+ def write_eof(self) -> None:\n+ assert self._stdin_transport is not None\n+ return self._stdin_transport.write_eof()\n+\n+ # We don't really implement SubprocessTransport, but provide these as\n+ # \"extras\" to our user.\n+ def get_pid(self) -> int:\n+ assert self._subprocess_transport is not None\n+ return self._subprocess_transport.get_pid()\n+\n+ def get_returncode(self) -> 'int | None':\n+ return self._returncode\n+\n+ def kill(self) -> None:\n+ assert self._subprocess_transport is not None\n+ self._subprocess_transport.kill()\n+\n+ def send_signal(self, number: int) -> None:\n+ assert self._subprocess_transport is not None\n+ self._subprocess_transport.send_signal(number)\n+\n+ def terminate(self) -> None:\n+ assert self._subprocess_transport is not None\n+ self._subprocess_transport.terminate()\n ''',\n- 'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes\n+ 'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main\n+\n+if __name__ == '__main__':\n+ main()\n+''',\n+ 'cockpit/_vendor/ferny/session.py': r'''# ferny - asyncio SSH client library, using ssh(1)\n #\n # Copyright (C) 2022 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -6694,280 +7220,947 @@\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import errno\n+import asyncio\n+import ctypes\n+import functools\n import logging\n import os\n-import stat\n-from typing import Any, List, Optional\n+import shlex\n+import signal\n+import subprocess\n+import tempfile\n+from typing import Mapping, Sequence\n \n-from .event import Event\n-from .inotify import Event as IN\n+from . import ssh_errors\n+from .interaction_agent import InteractionAgent, InteractionError, InteractionHandler, write_askpass_to_tmpdir\n \n+prctl = ctypes.cdll.LoadLibrary('libc.so.6').prctl\n logger = logging.getLogger(__name__)\n+PR_SET_PDEATHSIG = 1\n \n \n-# inotify hard facts:\n-#\n-# DELETE_SELF doesn't get called until all references to an inode are gone\n-# - including open fds\n-# - including on directories\n+@functools.lru_cache()\n+def has_feature(feature: str, teststr: str = 'x') -> bool:\n+ try:\n+ subprocess.check_output(['ssh', f'-o{feature} {teststr}', '-G', 'nonexisting'], stderr=subprocess.DEVNULL)\n+ return True\n+ except subprocess.CalledProcessError:\n+ return False\n+\n+\n+class SubprocessContext:\n+ def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:\n+ \"\"\"Return the args required to launch a process in the given context.\n+\n+ For example, this might return a vector with\n+ [\"sudo\"]\n+ or\n+ [\"flatpak-spawn\", \"--host\"]\n+ prepended.\n+\n+ It is also possible that more substantial changes may be performed.\n+\n+ This function is not permitted to modify its argument, although it may\n+ (optionally) return it unmodified, if no changes are required.\n+ \"\"\"\n+ return args\n+\n+ def wrap_subprocess_env(self, env: Mapping[str, str]) -> Mapping[str, str]:\n+ \"\"\"Return the envp required to launch a process in the given context.\n+\n+ For example, this might set the \"SUDO_ASKPASS\" environment variable, if\n+ needed.\n+\n+ As with wrap_subprocess_args(), this function is not permitted to\n+ modify its argument, although it may (optionally) return it unmodified\n+ if no changes are required.\n+ \"\"\"\n+ return env\n+\n+\n+class Session(SubprocessContext, InteractionHandler):\n+ # Set after .connect() called, even if failed\n+ _controldir: 'tempfile.TemporaryDirectory | None' = None\n+ _controlsock: 'str | None' = None\n+\n+ # Set if connected, else None\n+ _process: 'asyncio.subprocess.Process | None' = None\n+\n+ async def connect(self,\n+ destination: str,\n+ handle_host_key: bool = False,\n+ configfile: 'str | None' = None,\n+ identity_file: 'str | None' = None,\n+ login_name: 'str | None' = None,\n+ options: 'Mapping[str, str] | None' = None,\n+ pkcs11: 'str | None' = None,\n+ port: 'int | None' = None,\n+ interaction_responder: 'InteractionHandler | None' = None) -> None:\n+ rundir = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/run'), 'ferny')\n+ os.makedirs(rundir, exist_ok=True)\n+ self._controldir = tempfile.TemporaryDirectory(dir=rundir)\n+ self._controlsock = f'{self._controldir.name}/socket'\n+\n+ # In general, we can't guarantee an accessible and executable version\n+ # of this file, but since it's small and we're making a temporary\n+ # directory anyway, let's just copy it into place and use it from\n+ # there.\n+ askpass_path = write_askpass_to_tmpdir(self._controldir.name)\n+\n+ env = dict(os.environ)\n+ env['SSH_ASKPASS'] = askpass_path\n+ env['SSH_ASKPASS_REQUIRE'] = 'force'\n+ # old SSH doesn't understand SSH_ASKPASS_REQUIRE and guesses based on DISPLAY instead\n+ env['DISPLAY'] = '-'\n+\n+ args = [\n+ '-M',\n+ '-N',\n+ '-S', self._controlsock,\n+ '-o', 'PermitLocalCommand=yes',\n+ '-o', f'LocalCommand={askpass_path}',\n+ ]\n+\n+ if configfile is not None:\n+ args.append(f'-F{configfile}')\n+\n+ if identity_file is not None:\n+ args.append(f'-i{identity_file}')\n+\n+ if options is not None:\n+ for key in options: # Note: Mapping may not have .items()\n+ args.append(f'-o{key} {options[key]}')\n+\n+ if pkcs11 is not None:\n+ args.append(f'-I{pkcs11}')\n+\n+ if port is not None:\n+ args.append(f'-p{port}')\n+\n+ if login_name is not None:\n+ args.append(f'-l{login_name}')\n+\n+ if handle_host_key and has_feature('KnownHostsCommand'):\n+ args.extend([\n+ '-o', f'KnownHostsCommand={askpass_path} %I %H %t %K %f',\n+ '-o', 'StrictHostKeyChecking=yes',\n+ ])\n+\n+ agent = InteractionAgent([interaction_responder] if interaction_responder is not None else [])\n+\n+ # SSH_ASKPASS_REQUIRE is not generally available, so use setsid\n+ process = await asyncio.create_subprocess_exec(\n+ *('/usr/bin/ssh', *args, destination), env=env,\n+ start_new_session=True, stdin=asyncio.subprocess.DEVNULL,\n+ stdout=asyncio.subprocess.DEVNULL, stderr=agent, # type: ignore\n+ preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL))\n+\n+ # This is tricky: we need to clean up the subprocess, but only in case\n+ # if failure. Otherwise, we keep it around.\n+ try:\n+ await agent.communicate()\n+ assert os.path.exists(self._controlsock)\n+ self._process = process\n+ except InteractionError as exc:\n+ await process.wait()\n+ raise ssh_errors.get_exception_for_ssh_stderr(str(exc)) from None\n+ except BaseException:\n+ # If we get here because the InteractionHandler raised an\n+ # exception then SSH might still be running, and may even attempt\n+ # further interactions (ie: 2nd attempt for password). We already\n+ # have our exception and don't need any more info. Kill it.\n+ try:\n+ process.kill()\n+ except ProcessLookupError:\n+ pass # already exited? good.\n+ await process.wait()\n+ raise\n+\n+ def is_connected(self) -> bool:\n+ return self._process is not None\n+\n+ async def wait(self) -> None:\n+ assert self._process is not None\n+ await self._process.wait()\n+\n+ def exit(self) -> None:\n+ assert self._process is not None\n+ self._process.terminate()\n+\n+ async def disconnect(self) -> None:\n+ self.exit()\n+ await self.wait()\n+\n+ # Launching of processes\n+ def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:\n+ assert self._controlsock is not None\n+ # 1. We specify the hostname as the empty string: it will be ignored\n+ # when ssh is trying to use the control socket, but in case the\n+ # socket has stopped working, ssh will try to fall back to directly\n+ # connecting, in which case an empty hostname will prevent that.\n+ # 2. We need to quote the arguments \u2014 ssh will paste them together\n+ # using only spaces, executing the result using the user's shell.\n+ return ('ssh', '-S', self._controlsock, '', *map(shlex.quote, args))\n+'''.encode('utf-8'),\n+ 'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1)\n #\n-# ATTRIB gets called when unlinking files (because the link count changes) but\n-# not on directories. When unlinking an open directory, no events at all\n-# happen on the directory. ATTRIB also collects child events, which means we\n-# get a lot of unwanted noise.\n+# Copyright (C) 2023 Allison Karlitskaya \n #\n-# There's nothing like UNLINK_SELF, unfortunately.\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n #\n-# Even if it was possible to take this approach, it might not work: after\n-# you've opened the fd, it might get deleted before you can establish the watch\n-# on it.\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n #\n-# Additionally, systemd makes it impossible to register those events on\n-# symlinks (because it removes IN_DONT_FOLLOW in order to watch via\n-# /proc/self/fd).\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n+\n+import ctypes\n+import errno\n+import os\n+import re\n+import socket\n+from typing import ClassVar, Iterable, Match, Pattern\n+\n+\n+class SshError(Exception):\n+ PATTERN: ClassVar[Pattern]\n+\n+ def __init__(self, match: 'Match | None', stderr: str) -> None:\n+ super().__init__(match.group(0) if match is not None else stderr)\n+ self.stderr = stderr\n+\n+\n+class SshAuthenticationError(SshError):\n+ PATTERN = re.compile(r'^([^:]+): Permission denied \\(([^()]+)\\)\\.$', re.M)\n+\n+ def __init__(self, match: Match, stderr: str) -> None:\n+ super().__init__(match, stderr)\n+ self.destination = match.group(1)\n+ self.methods = match.group(2).split(',')\n+ self.message = match.group(0)\n+\n+\n+# generic host key error for OSes without KnownHostsCommand support\n+class SshHostKeyError(SshError):\n+ PATTERN = re.compile(r'^Host key verification failed.$', re.M)\n+\n+\n+# specific errors for OSes with KnownHostsCommand\n+class SshUnknownHostKeyError(SshHostKeyError):\n+ PATTERN = re.compile(r'^No .* host key is known.*Host key verification failed.$', re.S | re.M)\n+\n+\n+class SshChangedHostKeyError(SshHostKeyError):\n+ PATTERN = re.compile(r'warning.*remote host identification has changed', re.I)\n+\n+\n+# Functionality for mapping getaddrinfo()-family error messages to their\n+# equivalent Python exceptions.\n+def make_gaierror_map() -> 'Iterable[tuple[str, int]]':\n+ libc = ctypes.CDLL(None)\n+ libc.gai_strerror.restype = ctypes.c_char_p\n+\n+ for key in dir(socket):\n+ if key.startswith('EAI_'):\n+ errnum = getattr(socket, key)\n+ yield libc.gai_strerror(errnum).decode('utf-8'), errnum\n+\n+\n+gaierror_map = dict(make_gaierror_map())\n+\n+\n+# Functionality for passing strerror() error messages to their equivalent\n+# Python exceptions.\n+# There doesn't seem to be an official API for turning an errno into the\n+# correct subtype of OSError, and the list that cpython uses is hidden fairly\n+# deeply inside of the implementation. This is basically copied from the\n+# ADD_ERRNO() lines in _PyExc_InitState in cpython/Objects/exceptions.c\n+oserror_subclass_map = dict((errnum, cls) for cls, errnum in [\n+ (BlockingIOError, errno.EAGAIN),\n+ (BlockingIOError, errno.EALREADY),\n+ (BlockingIOError, errno.EINPROGRESS),\n+ (BlockingIOError, errno.EWOULDBLOCK),\n+ (BrokenPipeError, errno.EPIPE),\n+ (BrokenPipeError, errno.ESHUTDOWN),\n+ (ChildProcessError, errno.ECHILD),\n+ (ConnectionAbortedError, errno.ECONNABORTED),\n+ (ConnectionRefusedError, errno.ECONNREFUSED),\n+ (ConnectionResetError, errno.ECONNRESET),\n+ (FileExistsError, errno.EEXIST),\n+ (FileNotFoundError, errno.ENOENT),\n+ (IsADirectoryError, errno.EISDIR),\n+ (NotADirectoryError, errno.ENOTDIR),\n+ (InterruptedError, errno.EINTR),\n+ (PermissionError, errno.EACCES),\n+ (PermissionError, errno.EPERM),\n+ (ProcessLookupError, errno.ESRCH),\n+ (TimeoutError, errno.ETIMEDOUT),\n+])\n+\n+\n+def get_exception_for_ssh_stderr(stderr: str) -> Exception:\n+ stderr = stderr.replace('\\r\\n', '\\n') # fix line separators\n+\n+ # check for the specific error messages first, then for generic SshHostKeyError\n+ for ssh_cls in [SshAuthenticationError, SshChangedHostKeyError, SshUnknownHostKeyError, SshHostKeyError]:\n+ match = ssh_cls.PATTERN.search(stderr)\n+ if match is not None:\n+ return ssh_cls(match, stderr)\n+\n+ before, colon, after = stderr.rpartition(':')\n+ if colon and after:\n+ potential_strerror = after.strip()\n+\n+ # DNS lookup errors\n+ if potential_strerror in gaierror_map:\n+ errnum = gaierror_map[potential_strerror]\n+ return socket.gaierror(errnum, stderr)\n+\n+ # Network connect errors\n+ for errnum in errno.errorcode:\n+ if os.strerror(errnum) == potential_strerror:\n+ os_cls = oserror_subclass_map.get(errnum, OSError)\n+ return os_cls(errnum, stderr)\n+\n+ # No match? Generic.\n+ return SshError(None, stderr)\n+''',\n+ 'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1)\n #\n-# For all of these reasons, unfortunately, the best way seems to be to watch\n-# for CREATE|DELETE|MOVE events on each intermediate directory.\n+# Copyright (C) 2023 Allison Karlitskaya \n #\n-# Unfortunately there is no way to filter to only the name we're interested in,\n-# so we're gonna get a lot of unnecessary wakeups.\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n #\n-# Also: due to the above-mentioned race about watching after opening the fd,\n-# let's just always watch for both create and delete events *before* trying to\n-# open the fd. We could try to reduce the mask after the fact, but meh...\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n #\n-# We use a WatchInvalidator utility class to fill the role of \"Tell me when an\n-# event happened on this (directory) fd which impacted the name file\". We\n-# build a series of these when setting up a watch in order to find out if any\n-# part of the path leading to the monitored file changed.\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n \n+import array\n+import ast\n+import asyncio\n+import contextlib\n+import logging\n+import os\n+import re\n+import socket\n+import tempfile\n+from typing import Any, Callable, ClassVar, Generator, Sequence\n \n-class Handle(int):\n- \"\"\"An integer subclass that makes it easier to work with file descriptors\"\"\"\n+from . import interaction_client\n \n- def __new__(cls, fd: int = -1) -> 'Handle':\n- return super(Handle, cls).__new__(cls, fd)\n+logger = logging.getLogger(__name__)\n \n- # separate __init__() to set _needs_close mostly to keep pylint quiet\n- def __init__(self, fd: int = -1):\n- super().__init__()\n- self._needs_close = fd != -1\n \n- def __bool__(self) -> bool:\n- return self != -1\n+COMMAND_RE = re.compile(b'\\0ferny\\0([^\\n]*)\\0\\0\\n')\n+COMMAND_TEMPLATE = '\\0ferny\\0{(command, args)!r}\\0\\0\\n'\n \n- def close(self) -> None:\n- if self._needs_close:\n- self._needs_close = False\n- os.close(self)\n+BEIBOOT_GADGETS = {\n+ \"command\": fr\"\"\"\n+ import sys\n+ def command(command, *args):\n+ sys.stderr.write(f{COMMAND_TEMPLATE!r})\n+ sys.stderr.flush()\n+ \"\"\",\n+ \"end\": r\"\"\"\n+ def end():\n+ command('ferny.end')\n+ \"\"\",\n+}\n \n- def __eq__(self, value: object) -> bool:\n- if int.__eq__(self, value): # also handles both == -1\n- return True\n \n- if not isinstance(value, int): # other object is not an int\n- return False\n+class InteractionError(Exception):\n+ pass\n \n- if not self or not value: # when only one == -1\n- return False\n \n- return os.path.sameopenfile(self, value)\n+try:\n+ recv_fds = socket.recv_fds\n+except AttributeError:\n+ # Python < 3.9\n \n- def __del__(self) -> None:\n- if self._needs_close:\n- self.close()\n+ def recv_fds(\n+ sock: socket.socket, bufsize: int, maxfds: int, flags: int = 0\n+ ) -> 'tuple[bytes, list[int], int, None]':\n+ fds = array.array(\"i\")\n+ msg, ancdata, flags, addr = sock.recvmsg(bufsize, socket.CMSG_LEN(maxfds * fds.itemsize))\n+ for cmsg_level, cmsg_type, cmsg_data in ancdata:\n+ if (cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS):\n+ fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])\n+ return msg, list(fds), flags, addr\n \n- def __enter__(self) -> 'Handle':\n- return self\n \n- def __exit__(self, _type: type, _value: object, _traceback: object) -> None:\n- self.close()\n+def get_running_loop() -> asyncio.AbstractEventLoop:\n+ try:\n+ return asyncio.get_running_loop()\n+ except AttributeError:\n+ # Python 3.6\n+ return asyncio.get_event_loop()\n \n- @classmethod\n- def open(cls, *args: Any, **kwargs: Any) -> 'Handle':\n- return cls(os.open(*args, **kwargs))\n \n- def steal(self) -> 'Handle':\n- self._needs_close = False\n- return self.__class__(int(self))\n+class InteractionHandler:\n+ commands: ClassVar[Sequence[str]]\n \n+ async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:\n+ raise NotImplementedError\n \n-class WatchInvalidator:\n- _name: bytes\n- _source: Optional[Event.Source]\n- _watch: Optional['PathWatch']\n \n- def event(self, mask: IN, _cookie: int, name: Optional[bytes]) -> None:\n- logger.debug('invalidator event %s %s', mask, name)\n- if self._watch is not None:\n- # If this node itself disappeared, that's definitely an\n- # invalidation. Otherwise, the name needs to match.\n- if IN.IGNORED in mask or self._name == name:\n- logger.debug('Invalidating!')\n- self._watch.invalidate()\n+class AskpassHandler(InteractionHandler):\n+ commands: ClassVar[Sequence[str]] = ('ferny.askpass',)\n \n- def __init__(self, watch: 'PathWatch', event: Event, dirfd: int, name: str):\n- self._watch = watch\n- self._name = name.encode('utf-8')\n+ async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None':\n+ \"\"\"Prompt the user for an authentication or confirmation interaction.\n \n- # establishing invalidation watches is best-effort and can fail for a\n- # number of reasons, including search (+x) but not read (+r) permission\n- # on a particular path component, or exceeding limits on watches\n+ 'messages' is data that was sent to stderr before the interaction was requested.\n+ 'prompt' is the interaction prompt.\n+\n+ The expected response type depends on hint:\n+\n+ - \"confirm\": ask for permission, returning \"yes\" if accepted\n+ - example: authorizing agent operation\n+\n+ - \"none\": show a request without need for a response\n+ - example: please touch your authentication token\n+\n+ - otherwise: return a password or other form of text token\n+ - examples: enter password, unlock private key\n+\n+ In any case, the function should properly handle cancellation. For the\n+ \"none\" case, this will be the normal way to dismiss the dialog.\n+ \"\"\"\n+ return None\n+\n+ async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:\n+ \"\"\"Prompt the user for a decision regarding acceptance of a host key.\n+\n+ The \"reason\" will be either \"HOSTNAME\" or \"ADDRESS\" (if `CheckHostIP` is enabled).\n+\n+ The host, algorithm, and key parameters are the values in the form that\n+ they would appear one a single line in the known hosts file. The\n+ fingerprint is the key fingerprint in the format that ssh would\n+ normally present it to the user.\n+\n+ In case the host key should be accepted, this function needs to return\n+ True. Returning False means that ssh implements its default logic. To\n+ interrupt the connection, raise an exception.\n+ \"\"\"\n+ return False\n+\n+ async def do_custom_command(\n+ self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str\n+ ) -> None:\n+ \"\"\"Handle a custom command.\n+\n+ The command name, its arguments, the passed fds, and the stderr leading\n+ up to the command invocation are all provided.\n+\n+ See doc/interaction-protocol.md\n+ \"\"\"\n+\n+ async def _askpass_command(self, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:\n+ logger.debug('_askpass_command(%s, %s, %s)', args, fds, stderr)\n try:\n- mask = IN.CREATE | IN.DELETE | IN.MOVE | IN.DELETE_SELF | IN.IGNORED\n- self._source = event.add_inotify_fd(dirfd, mask, self.event)\n- except OSError:\n- self._source = None\n+ argv, env = args\n+ assert isinstance(argv, list)\n+ assert all(isinstance(arg, str) for arg in argv)\n+ assert isinstance(env, dict)\n+ assert all(isinstance(key, str) and isinstance(val, str) for key, val in env.items())\n+ assert len(fds) == 2\n+ except (ValueError, TypeError, AssertionError) as exc:\n+ logger.error('Invalid arguments to askpass interaction: %s, %s: %s', args, fds, exc)\n+ return\n \n- def close(self) -> None:\n- # This is a little bit tricky: systemd doesn't have a specific close\n- # API outside of unref, so let's make it as explicit as possible.\n- self._watch = None\n- self._source = None\n+ with open(fds.pop(0), 'w') as status, open(fds.pop(0), 'w') as stdout:\n+ try:\n+ loop = get_running_loop()\n+ try:\n+ task = asyncio.current_task()\n+ except AttributeError:\n+ task = asyncio.Task.current_task() # type:ignore[attr-defined] # (Python 3.6)\n+ assert task is not None\n+ loop.add_reader(status, task.cancel)\n \n+ if len(argv) == 2:\n+ # normal askpass\n+ prompt = argv[1]\n+ hint = env.get('SSH_ASKPASS_PROMPT', '')\n+ logger.debug('do_askpass(%r, %r, %r)', stderr, prompt, hint)\n+ answer = await self.do_askpass(stderr, prompt, hint)\n+ logger.debug('do_askpass answer %r', answer)\n+ if answer is not None:\n+ print(answer, file=stdout)\n+ print(0, file=status)\n \n-class PathStack(List[str]):\n- def add_path(self, pathname: str) -> None:\n- # TO DO: consider doing something reasonable with trailing slashes\n- # this is a stack, popped from the end: push components in reverse\n- self.extend(item for item in reversed(pathname.split('/')) if item)\n- if pathname.startswith('/'):\n- self.append('/')\n+ elif len(argv) == 6:\n+ # KnownHostsCommand\n+ argv0, reason, host, algorithm, key, fingerprint = argv\n+ if reason in ['ADDRESS', 'HOSTNAME']:\n+ logger.debug('do_hostkey(%r, %r, %r, %r, %r)', reason, host, algorithm, key, fingerprint)\n+ if await self.do_hostkey(reason, host, algorithm, key, fingerprint):\n+ print(host, algorithm, key, file=stdout)\n+ else:\n+ logger.debug('ignoring KnownHostsCommand reason %r', reason)\n \n- def __init__(self, path: str):\n- super().__init__()\n- self.add_path(path)\n+ print(0, file=status)\n \n+ else:\n+ logger.error('Incorrect number of command-line arguments to ferny-askpass: %s', argv)\n+ finally:\n+ loop.remove_reader(status)\n \n-class Listener:\n- def do_inotify_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:\n- raise NotImplementedError\n+ async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:\n+ logger.debug('run_command(%s, %s, %s, %s)', command, args, fds, stderr)\n+ if command == 'ferny.askpass':\n+ await self._askpass_command(args, fds, stderr)\n+ else:\n+ await self.do_custom_command(command, args, fds, stderr)\n \n- def do_identity_changed(self, fd: Optional[Handle], errno: Optional[int]) -> None:\n- raise NotImplementedError\n \n+class InteractionAgent:\n+ _handlers: 'dict[str, InteractionHandler]'\n \n-class PathWatch:\n- _event: Event\n- _listener: Listener\n- _path: str\n- _invalidators: List[WatchInvalidator]\n- _errno: Optional[int]\n- _source: Optional[Event.Source]\n- _tag: Optional[None]\n- _fd: Handle\n+ _loop: asyncio.AbstractEventLoop\n \n- def __init__(self, path: str, listener: Listener, event: Optional[Event] = None):\n- self._event = event or Event.default()\n- self._path = path\n- self._listener = listener\n+ _tasks: 'set[asyncio.Task]'\n \n- self._invalidators = []\n- self._errno = None\n- self._source = None\n- self._tag = None\n- self._fd = Handle()\n+ _buffer: bytearray\n+ _ours: socket.socket\n+ _theirs: socket.socket\n \n- self.invalidate()\n+ _completion_future: 'asyncio.Future[str]'\n+ _pending_result: 'None | str | Exception' = None\n+ _end: bool = False\n \n- def got_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:\n- logger.debug('target event %s: %s %s %s', self._path, mask, cookie, name)\n- self._listener.do_inotify_event(mask, cookie, name)\n+ def _consider_completion(self) -> None:\n+ logger.debug('_consider_completion(%r)', self)\n \n- def invalidate(self) -> None:\n- for invalidator in self._invalidators:\n- invalidator.close()\n- self._invalidators = []\n+ if self._pending_result is None or self._tasks:\n+ logger.debug(' but not ready yet')\n \n- try:\n- fd = self.walk()\n- except OSError as error:\n- logger.debug('walk ended in error %d', error.errno)\n+ elif self._completion_future.done():\n+ logger.debug(' but already complete')\n \n- if self._source or self._fd or self._errno != error.errno:\n- logger.debug('Ending existing watches.')\n- self._source = None\n- self._fd.close()\n- self._fd = Handle()\n- self._errno = error.errno\n+ elif isinstance(self._pending_result, str):\n+ logger.debug(' submitting stderr (%r) to completion_future', self._pending_result)\n+ self._completion_future.set_result(self._pending_result)\n \n- logger.debug('Notifying of new error state %d', self._errno)\n- self._listener.do_identity_changed(None, self._errno)\n+ else:\n+ logger.debug(' submitting exception (%r) to completion_future')\n+ self._completion_future.set_exception(self._pending_result)\n+\n+ def _result(self, result: 'str | Exception') -> None:\n+ logger.debug('_result(%r, %r)', self, result)\n+\n+ if self._pending_result is None:\n+ self._pending_result = result\n+\n+ if self._ours.fileno() != -1:\n+ logger.debug(' remove_reader(%r)', self._ours)\n+ self._loop.remove_reader(self._ours.fileno())\n+\n+ for task in self._tasks:\n+ logger.debug(' cancel(%r)', task)\n+ task.cancel()\n+\n+ logger.debug(' closing sockets')\n+ self._theirs.close() # idempotent\n+ self._ours.close()\n+\n+ self._consider_completion()\n+\n+ def _invoke_command(self, stderr: bytes, command_blob: bytes, fds: 'list[int]') -> None:\n+ logger.debug('_invoke_command(%r, %r, %r)', stderr, command_blob, fds)\n+ try:\n+ command, args = ast.literal_eval(command_blob.decode())\n+ if not isinstance(command, str) or not isinstance(args, tuple):\n+ raise TypeError('Invalid argument types')\n+ except (UnicodeDecodeError, SyntaxError, ValueError, TypeError) as exc:\n+ logger.error('Received invalid ferny command: %s: %s', command_blob, exc)\n+ return\n \n+ if command == 'ferny.end':\n+ self._end = True\n+ self._result(self._buffer.decode(errors='replace'))\n return\n \n- with fd:\n- logger.debug('walk successful. Got fd %d', fd)\n- if fd == self._fd:\n- logger.debug('fd seems to refer to same file. Doing nothing.')\n- return\n+ try:\n+ handler = self._handlers[command]\n+ except KeyError:\n+ logger.error('Received unhandled ferny command: %s', command)\n+ return\n \n- logger.debug('This file is new for us. Removing old watch.')\n- self._source = None\n- self._fd.close()\n- self._fd = fd.steal()\n+ # The task is responsible for the list of fds and removing itself\n+ # from the set.\n+ task_fds = list(fds)\n+ task = self._loop.create_task(handler.run_command(command, args, task_fds, stderr.decode()))\n+\n+ def bottom_half(completed_task: asyncio.Task) -> None:\n+ assert completed_task is task\n+ while task_fds:\n+ os.close(task_fds.pop())\n+ self._tasks.remove(task)\n \n try:\n- logger.debug('Establishing a new watch.')\n- self._source = self._event.add_inotify_fd(self._fd, IN.CHANGED, self.got_event)\n- logger.debug('Watching successfully. Notifying of new identity.')\n- self._listener.do_identity_changed(self._fd, None)\n- except OSError as error:\n- logger.debug('Watching failed (%d). Notifying of new identity.', error.errno)\n- self._listener.do_identity_changed(self._fd, error.errno)\n+ task.result()\n+ logger.debug('%r completed cleanly', handler)\n+ except asyncio.CancelledError:\n+ # this is not an error \u2014 it just means ferny-askpass exited via signal\n+ logger.debug('%r was cancelled', handler)\n+ except Exception as exc:\n+ logger.debug('%r raised %r', handler, exc)\n+ self._result(exc)\n \n- def walk(self) -> Handle:\n- remaining_symlink_lookups = 40\n- remaining_components = PathStack(self._path)\n- dirfd = Handle()\n+ self._consider_completion()\n+\n+ task.add_done_callback(bottom_half)\n+ self._tasks.add(task)\n+ fds[:] = []\n+\n+ def _got_data(self, data: bytes, fds: 'list[int]') -> None:\n+ logger.debug('_got_data(%r, %r)', data, fds)\n+\n+ if data == b'':\n+ self._result(self._buffer.decode(errors='replace'))\n+ return\n+\n+ self._buffer.extend(data)\n+\n+ # Read zero or more \"remote\" messages\n+ chunks = COMMAND_RE.split(self._buffer)\n+ self._buffer = bytearray(chunks.pop())\n+ while len(chunks) > 1:\n+ self._invoke_command(chunks[0], chunks[1], [])\n+ chunks = chunks[2:]\n+\n+ # Maybe read one \"local\" message\n+ if fds:\n+ assert self._buffer.endswith(b'\\0'), self._buffer\n+ stderr = self._buffer[:-1]\n+ self._buffer = bytearray(b'')\n+ with open(fds.pop(0), 'rb') as command_channel:\n+ command = command_channel.read()\n+ self._invoke_command(stderr, command, fds)\n \n+ def _read_ready(self) -> None:\n try:\n- logger.debug('Starting path walk')\n+ data, fds, _flags, _addr = recv_fds(self._ours, 4096, 10, flags=socket.MSG_DONTWAIT)\n+ except BlockingIOError:\n+ return\n+ except OSError as exc:\n+ self._result(exc)\n+ else:\n+ self._got_data(data, fds)\n+ finally:\n+ while fds:\n+ os.close(fds.pop())\n \n- while remaining_components:\n- logger.debug('r=%s dfd=%s', remaining_components, dirfd)\n+ def __init__(\n+ self,\n+ handlers: Sequence[InteractionHandler],\n+ loop: 'asyncio.AbstractEventLoop | None' = None,\n+ done_callback: 'Callable[[asyncio.Future[str]], None] | None' = None,\n+ ) -> None:\n+ self._loop = loop or get_running_loop()\n+ self._completion_future = self._loop.create_future()\n+ self._tasks = set()\n+ self._handlers = {}\n \n- name = remaining_components.pop()\n+ for handler in handlers:\n+ for command in handler.commands:\n+ self._handlers[command] = handler\n \n- if dirfd and name != '/':\n- self._invalidators.append(WatchInvalidator(self, self._event, dirfd, name))\n+ if done_callback is not None:\n+ self._completion_future.add_done_callback(done_callback)\n \n- with Handle.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=dirfd) as fd:\n- mode = os.fstat(fd).st_mode\n+ self._theirs, self._ours = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)\n+ self._buffer = bytearray()\n \n- if stat.S_ISLNK(mode):\n- if remaining_symlink_lookups == 0:\n- raise OSError(errno.ELOOP, os.strerror(errno.ELOOP))\n- remaining_symlink_lookups -= 1\n- linkpath = os.readlink('', dir_fd=fd)\n- logger.debug('%s is a symlink. adding %s to components', name, linkpath)\n- remaining_components.add_path(linkpath)\n+ def fileno(self) -> int:\n+ return self._theirs.fileno()\n \n- else:\n- dirfd.close()\n- dirfd = fd.steal()\n+ def start(self) -> None:\n+ logger.debug('start(%r)', self)\n+ if self._ours.fileno() != -1:\n+ logger.debug(' add_reader(%r)', self._ours)\n+ self._loop.add_reader(self._ours.fileno(), self._read_ready)\n+ else:\n+ logger.debug(' ...but agent is already finished.')\n \n- return dirfd.steal()\n+ logger.debug(' close(%r)', self._theirs)\n+ self._theirs.close()\n+\n+ def force_completion(self) -> None:\n+ logger.debug('force_completion(%r)', self)\n+\n+ # read any residual data on stderr, but don't process commands, and\n+ # don't block\n+ try:\n+ if self._ours.fileno() != -1:\n+ logger.debug(' draining pending stderr data (non-blocking)')\n+ with contextlib.suppress(BlockingIOError):\n+ while True:\n+ data = self._ours.recv(4096, socket.MSG_DONTWAIT)\n+ logger.debug(' got %d bytes', len(data))\n+ if not data:\n+ break\n+ self._buffer.extend(data)\n+ except OSError as exc:\n+ self._result(exc)\n+ else:\n+ self._result(self._buffer.decode(errors='replace'))\n \n+ async def communicate(self) -> None:\n+ logger.debug('_communicate(%r)', self)\n+ try:\n+ self.start()\n+ # We assume that we are the only ones to write to\n+ # self._completion_future. If we directly await it, though, it can\n+ # also have a asyncio.CancelledError posted to it from outside.\n+ # Shield it to prevent that from happening.\n+ stderr = await asyncio.shield(self._completion_future)\n+ logger.debug('_communicate(%r) stderr result is %r', self, stderr)\n finally:\n- dirfd.close()\n+ logger.debug('_communicate finished. Ensuring completion.')\n+ self.force_completion()\n+ if not self._end:\n+ logger.debug('_communicate never saw ferny.end. raising InteractionError.')\n+ raise InteractionError(stderr.strip())\n+\n+\n+def write_askpass_to_tmpdir(tmpdir: str) -> str:\n+ askpass_path = os.path.join(tmpdir, 'ferny-askpass')\n+ fd = os.open(askpass_path, os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC | os.O_EXCL | os.O_NOFOLLOW, 0o777)\n+ try:\n+ os.write(fd, __loader__.get_data(interaction_client.__file__)) # type: ignore\n+ finally:\n+ os.close(fd)\n+ return askpass_path\n+\n+\n+@contextlib.contextmanager\n+def temporary_askpass(**kwargs: Any) -> Generator[str, None, None]:\n+ with tempfile.TemporaryDirectory(**kwargs) as directory:\n+ yield write_askpass_to_tmpdir(directory)\n+'''.encode('utf-8'),\n+ 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging\n+import re\n+from typing import ClassVar, Match, Sequence\n+\n+from .interaction_agent import AskpassHandler\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+class AskpassPrompt:\n+ \"\"\"An askpass prompt resulting from a call to ferny-askpass.\n+\n+ stderr: the contents of stderr from before ferny-askpass was called.\n+ Likely related to previous failed operations.\n+ messages: all but the last line of the prompt as handed to ferny-askpass.\n+ Usually contains context about the question.\n+ prompt: the last line handed to ferny-askpass. The prompt itself.\n+ \"\"\"\n+ stderr: str\n+ messages: str\n+ prompt: str\n+\n+ def __init__(self, prompt: str, messages: str, stderr: str) -> None:\n+ self.stderr = stderr\n+ self.messages = messages\n+ self.prompt = prompt\n+\n+ def reply(self, response: str) -> None:\n+ pass\n \n def close(self) -> None:\n- for invalidator in self._invalidators:\n- invalidator.close()\n- self._invalidators = []\n- self._source = None\n- self._fd.close()\n+ pass\n+\n+ async def handle_via(self, responder: 'SshAskpassResponder') -> None:\n+ try:\n+ response = await self.dispatch(responder)\n+ if response is not None:\n+ self.reply(response)\n+ finally:\n+ self.close()\n+\n+ async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n+ return await responder.do_prompt(self)\n+\n+\n+class SSHAskpassPrompt(AskpassPrompt):\n+ # The valid answers to prompts of this type. If this is None then any\n+ # answer is permitted. If it's a sequence then only answers from the\n+ # sequence are permitted. If it's an empty sequence, then no answer is\n+ # permitted (ie: the askpass callback should never return).\n+ answers: 'ClassVar[Sequence[str] | None]' = None\n+\n+ # Patterns to capture. `_pattern` *must* match.\n+ _pattern: ClassVar[str]\n+ # `_extra_patterns` can fill in extra class attributes if they match.\n+ _extra_patterns: ClassVar[Sequence[str]] = ()\n+\n+ def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None:\n+ super().__init__(prompt, messages, stderr)\n+ self.__dict__.update(match.groupdict())\n+\n+ for pattern in self._extra_patterns:\n+ extra_match = re.search(with_helpers(pattern), messages, re.M)\n+ if extra_match is not None:\n+ self.__dict__.update(extra_match.groupdict())\n+\n+\n+# Specific prompts\n+HELPERS = {\n+ \"%{algorithm}\": r\"(?P\\b[-\\w]+\\b)\",\n+ \"%{filename}\": r\"(?P.+)\",\n+ \"%{fingerprint}\": r\"(?PSHA256:[0-9A-Za-z+/]{43})\",\n+ \"%{hostname}\": r\"(?P[^ @']+)\",\n+ \"%{pkcs11_id}\": r\"(?P.+)\",\n+ \"%{username}\": r\"(?P[^ @']+)\",\n+}\n+\n+\n+class SshPasswordPrompt(SSHAskpassPrompt):\n+ _pattern = r\"%{username}@%{hostname}'s password: \"\n+ username: 'str | None' = None\n+ hostname: 'str | None' = None\n+\n+ async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n+ return await responder.do_password_prompt(self)\n+\n+\n+class SshPassphrasePrompt(SSHAskpassPrompt):\n+ _pattern = r\"Enter passphrase for key '%{filename}': \"\n+ filename: str\n+\n+ async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n+ return await responder.do_passphrase_prompt(self)\n+\n+\n+class SshFIDOPINPrompt(SSHAskpassPrompt):\n+ _pattern = r\"Enter PIN for %{algorithm} key %{filename}: \"\n+ algorithm: str\n+ filename: str\n+\n+ async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n+ return await responder.do_fido_pin_prompt(self)\n+\n+\n+class SshFIDOUserPresencePrompt(SSHAskpassPrompt):\n+ _pattern = r\"Confirm user presence for key %{algorithm} %{fingerprint}\"\n+ answers = ()\n+ algorithm: str\n+ fingerprint: str\n+\n+ async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n+ return await responder.do_fido_user_presence_prompt(self)\n+\n+\n+class SshPKCS11PINPrompt(SSHAskpassPrompt):\n+ _pattern = r\"Enter PIN for '%{pkcs11_id}': \"\n+ pkcs11_id: str\n+\n+ async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n+ return await responder.do_pkcs11_pin_prompt(self)\n+\n+\n+class SshHostKeyPrompt(SSHAskpassPrompt):\n+ _pattern = r\"Are you sure you want to continue connecting \\(yes/no(/\\[fingerprint\\])?\\)\\? \"\n+ _extra_patterns = [\n+ r\"%{fingerprint}[.]$\",\n+ r\"^%{algorithm} key fingerprint is\",\n+ r\"^The fingerprint for the %{algorithm} key sent by the remote host is$\"\n+ ]\n+ answers = ('yes', 'no')\n+ algorithm: str\n+ fingerprint: str\n+\n+ async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n+ return await responder.do_host_key_prompt(self)\n+\n+\n+def with_helpers(pattern: str) -> str:\n+ for name, helper in HELPERS.items():\n+ pattern = pattern.replace(name, helper)\n+\n+ assert '%{' not in pattern\n+ return pattern\n+\n+\n+def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt:\n+ classes = [\n+ SshFIDOPINPrompt,\n+ SshFIDOUserPresencePrompt,\n+ SshHostKeyPrompt,\n+ SshPKCS11PINPrompt,\n+ SshPassphrasePrompt,\n+ SshPasswordPrompt,\n+ ]\n+\n+ # The last line is the line after the last newline character, excluding the\n+ # optional final newline character. eg: \"x\\ny\\nLAST\\n\" or \"x\\ny\\nLAST\"\n+ second_last_newline = string.rfind('\\n', 0, -1)\n+ if second_last_newline >= 0:\n+ last_line = string[second_last_newline + 1:]\n+ extras = string[:second_last_newline + 1]\n+ else:\n+ last_line = string\n+ extras = ''\n+\n+ for cls in classes:\n+ pattern = with_helpers(cls._pattern)\n+ match = re.fullmatch(pattern, last_line)\n+ if match is not None:\n+ return cls(last_line, extras, stderr, match)\n+\n+ return AskpassPrompt(last_line, extras, stderr)\n+\n+\n+class SshAskpassResponder(AskpassHandler):\n+ async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None':\n+ return await categorize_ssh_prompt(prompt, stderr).dispatch(self)\n+\n+ async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None':\n+ # Default fallback for unrecognised message types: unimplemented\n+ return None\n+\n+ async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None':\n+ return await self.do_prompt(prompt)\n+\n+ async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None':\n+ return await self.do_prompt(prompt)\n+\n+ async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None':\n+ return await self.do_prompt(prompt)\n+\n+ async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None':\n+ return await self.do_prompt(prompt)\n+\n+ async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None':\n+ return await self.do_prompt(prompt)\n+\n+ async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None':\n+ return await self.do_prompt(prompt)\n ''',\n+ 'cockpit/_vendor/ferny/py.typed': br'''''',\n 'cockpit/_vendor/systemd_ctypes/introspection.py': br'''# systemd_ctypes\n #\n # Copyright (C) 2022 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n@@ -7268,15 +8461,193 @@\n self._ref()\n return self\n \n def __del__(self) -> None:\n if self.value is not None:\n self._unref()\n ''',\n- 'cockpit/_vendor/systemd_ctypes/py.typed': br'''''',\n+ 'cockpit/_vendor/systemd_ctypes/__init__.py': br'''# systemd_ctypes\n+#\n+# Copyright (C) 2022 Allison Karlitskaya \n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n+\n+\"\"\"systemd_ctypes\"\"\"\n+\n+__version__ = \"0\"\n+\n+from .bus import Bus, BusError, BusMessage\n+from .bustypes import BusType, JSONEncoder, Variant\n+from .event import Event, EventLoopPolicy, run_async\n+from .pathwatch import Handle, PathWatch\n+\n+__all__ = [\n+ \"Bus\",\n+ \"BusError\",\n+ \"BusMessage\",\n+ \"BusType\",\n+ \"Event\",\n+ \"EventLoopPolicy\",\n+ \"Handle\",\n+ \"JSONEncoder\",\n+ \"PathWatch\",\n+ \"Variant\",\n+ \"run_async\",\n+]\n+''',\n+ 'cockpit/_vendor/systemd_ctypes/event.py': br'''# systemd_ctypes\n+#\n+# Copyright (C) 2022 Allison Karlitskaya \n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n+\n+import asyncio\n+import selectors\n+import sys\n+from typing import Callable, ClassVar, Coroutine, List, Optional, Tuple\n+\n+from . import inotify, libsystemd\n+from .librarywrapper import Reference, UserData, byref\n+\n+\n+class Event(libsystemd.sd_event):\n+ class Source(libsystemd.sd_event_source):\n+ def cancel(self) -> None:\n+ self._unref()\n+ self.value = None\n+\n+ _default_instance: ClassVar[Optional['Event']] = None\n+\n+ @staticmethod\n+ def default() -> 'Event':\n+ if Event._default_instance is None:\n+ Event._default_instance = Event()\n+ Event._default(byref(Event._default_instance))\n+ return Event._default_instance\n+\n+ InotifyHandler = Callable[[inotify.Event, int, Optional[bytes]], None]\n+\n+ class InotifySource(Source):\n+ def __init__(self, handler: 'Event.InotifyHandler') -> None:\n+ def callback(source: libsystemd.sd_event_source,\n+ _event: Reference[inotify.inotify_event],\n+ userdata: UserData) -> int:\n+ event = _event.contents\n+ handler(inotify.Event(event.mask), event.cookie, event.name)\n+ return 0\n+ self.trampoline = libsystemd.sd_event_inotify_handler_t(callback)\n+\n+ def add_inotify(self, path: str, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:\n+ source = Event.InotifySource(handler)\n+ self._add_inotify(byref(source), path, mask, source.trampoline, source.userdata)\n+ return source\n+\n+ def add_inotify_fd(self, fd: int, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:\n+ # HACK: sd_event_add_inotify_fd() got added in 250, which is too new. Fake it.\n+ return self.add_inotify(f'/proc/self/fd/{fd}', mask, handler)\n+\n+\n+# This is all a bit more awkward than it should have to be: systemd's event\n+# loop chaining model is designed for glib's prepare/check/dispatch paradigm;\n+# failing to call prepare() can lead to deadlocks, for example.\n+#\n+# Hack a selector subclass which calls prepare() before sleeping and this for us.\n+class Selector(selectors.DefaultSelector):\n+ def __init__(self, event: Optional[Event] = None) -> None:\n+ super().__init__()\n+ self.sd_event = event or Event.default()\n+ self.key = self.register(self.sd_event.get_fd(), selectors.EVENT_READ)\n+\n+ def select(\n+ self, timeout: Optional[float] = None\n+ ) -> List[Tuple[selectors.SelectorKey, int]]:\n+ # It's common to drop the last reference to a Source or Slot object on\n+ # a dispatch of that same source/slot from the main loop. If we happen\n+ # to garbage collect before returning, the trampoline could be\n+ # destroyed before we're done using it. Provide a mechanism to defer\n+ # the destruction of trampolines for as long as we might be\n+ # dispatching. This gets cleared again at the bottom, before return.\n+ libsystemd.Trampoline.deferred = []\n+\n+ while self.sd_event.prepare():\n+ self.sd_event.dispatch()\n+ ready = super().select(timeout)\n+ # workaround https://github.com/systemd/systemd/issues/23826\n+ # keep calling wait() until there's nothing left\n+ while self.sd_event.wait(0):\n+ self.sd_event.dispatch()\n+ while self.sd_event.prepare():\n+ self.sd_event.dispatch()\n+\n+ # We can be sure we're not dispatching callbacks anymore\n+ libsystemd.Trampoline.deferred = None\n+\n+ # This could return zero events with infinite timeout, but nobody seems to mind.\n+ return [(key, events) for (key, events) in ready if key != self.key]\n+\n+\n+class EventLoopPolicy(asyncio.DefaultEventLoopPolicy):\n+ def new_event_loop(self) -> asyncio.AbstractEventLoop:\n+ return asyncio.SelectorEventLoop(Selector())\n+\n+\n+def run_async(main: Coroutine[None, None, None], debug: Optional[bool] = None) -> None:\n+ asyncio.set_event_loop_policy(EventLoopPolicy())\n+\n+ polyfill = sys.version_info < (3, 7, 0) and not hasattr(asyncio, 'run')\n+ if polyfill:\n+ # Polyfills for Python 3.6:\n+ loop = asyncio.get_event_loop()\n+\n+ assert not hasattr(asyncio, 'get_running_loop')\n+ asyncio.get_running_loop = lambda: loop\n+\n+ assert not hasattr(asyncio, 'create_task')\n+ asyncio.create_task = loop.create_task\n+\n+ assert not hasattr(asyncio, 'run')\n+\n+ def run(\n+ main: Coroutine[None, None, None], debug: Optional[bool] = None\n+ ) -> None:\n+ if debug is not None:\n+ loop.set_debug(debug)\n+ loop.run_until_complete(main)\n+\n+ asyncio.run = run # type: ignore[assignment]\n+\n+ asyncio._systemd_ctypes_polyfills = True # type: ignore[attr-defined]\n+\n+ asyncio.run(main, debug=debug)\n+\n+ if polyfill:\n+ del asyncio.create_task, asyncio.get_running_loop, asyncio.run\n+''',\n 'cockpit/_vendor/systemd_ctypes/bustypes.py': br'''# systemd_ctypes\n #\n # Copyright (C) 2023 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n@@ -7821,15 +9192,15 @@\n def default(self, obj: object) -> object:\n if isinstance(obj, Variant):\n return {\"t\": obj.type.typestring, \"v\": obj.value}\n elif isinstance(obj, bytes):\n return binascii.b2a_base64(obj, newline=False).decode('ascii')\n return super().default(obj)\n ''',\n- 'cockpit/_vendor/systemd_ctypes/__init__.py': br'''# systemd_ctypes\n+ 'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes\n #\n # Copyright (C) 2022 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n@@ -7838,36 +9209,413 @@\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-\"\"\"systemd_ctypes\"\"\"\n+import errno\n+import logging\n+import os\n+import stat\n+from typing import Any, List, Optional\n \n-__version__ = \"0\"\n+from .event import Event\n+from .inotify import Event as IN\n \n-from .bus import Bus, BusError, BusMessage\n-from .bustypes import BusType, JSONEncoder, Variant\n-from .event import Event, EventLoopPolicy, run_async\n-from .pathwatch import Handle, PathWatch\n+logger = logging.getLogger(__name__)\n \n-__all__ = [\n- \"Bus\",\n- \"BusError\",\n- \"BusMessage\",\n- \"BusType\",\n- \"Event\",\n- \"EventLoopPolicy\",\n- \"Handle\",\n- \"JSONEncoder\",\n- \"PathWatch\",\n- \"Variant\",\n- \"run_async\",\n-]\n+\n+# inotify hard facts:\n+#\n+# DELETE_SELF doesn't get called until all references to an inode are gone\n+# - including open fds\n+# - including on directories\n+#\n+# ATTRIB gets called when unlinking files (because the link count changes) but\n+# not on directories. When unlinking an open directory, no events at all\n+# happen on the directory. ATTRIB also collects child events, which means we\n+# get a lot of unwanted noise.\n+#\n+# There's nothing like UNLINK_SELF, unfortunately.\n+#\n+# Even if it was possible to take this approach, it might not work: after\n+# you've opened the fd, it might get deleted before you can establish the watch\n+# on it.\n+#\n+# Additionally, systemd makes it impossible to register those events on\n+# symlinks (because it removes IN_DONT_FOLLOW in order to watch via\n+# /proc/self/fd).\n+#\n+# For all of these reasons, unfortunately, the best way seems to be to watch\n+# for CREATE|DELETE|MOVE events on each intermediate directory.\n+#\n+# Unfortunately there is no way to filter to only the name we're interested in,\n+# so we're gonna get a lot of unnecessary wakeups.\n+#\n+# Also: due to the above-mentioned race about watching after opening the fd,\n+# let's just always watch for both create and delete events *before* trying to\n+# open the fd. We could try to reduce the mask after the fact, but meh...\n+#\n+# We use a WatchInvalidator utility class to fill the role of \"Tell me when an\n+# event happened on this (directory) fd which impacted the name file\". We\n+# build a series of these when setting up a watch in order to find out if any\n+# part of the path leading to the monitored file changed.\n+\n+\n+class Handle(int):\n+ \"\"\"An integer subclass that makes it easier to work with file descriptors\"\"\"\n+\n+ def __new__(cls, fd: int = -1) -> 'Handle':\n+ return super(Handle, cls).__new__(cls, fd)\n+\n+ # separate __init__() to set _needs_close mostly to keep pylint quiet\n+ def __init__(self, fd: int = -1):\n+ super().__init__()\n+ self._needs_close = fd != -1\n+\n+ def __bool__(self) -> bool:\n+ return self != -1\n+\n+ def close(self) -> None:\n+ if self._needs_close:\n+ self._needs_close = False\n+ os.close(self)\n+\n+ def __eq__(self, value: object) -> bool:\n+ if int.__eq__(self, value): # also handles both == -1\n+ return True\n+\n+ if not isinstance(value, int): # other object is not an int\n+ return False\n+\n+ if not self or not value: # when only one == -1\n+ return False\n+\n+ return os.path.sameopenfile(self, value)\n+\n+ def __del__(self) -> None:\n+ if self._needs_close:\n+ self.close()\n+\n+ def __enter__(self) -> 'Handle':\n+ return self\n+\n+ def __exit__(self, _type: type, _value: object, _traceback: object) -> None:\n+ self.close()\n+\n+ @classmethod\n+ def open(cls, *args: Any, **kwargs: Any) -> 'Handle':\n+ return cls(os.open(*args, **kwargs))\n+\n+ def steal(self) -> 'Handle':\n+ self._needs_close = False\n+ return self.__class__(int(self))\n+\n+\n+class WatchInvalidator:\n+ _name: bytes\n+ _source: Optional[Event.Source]\n+ _watch: Optional['PathWatch']\n+\n+ def event(self, mask: IN, _cookie: int, name: Optional[bytes]) -> None:\n+ logger.debug('invalidator event %s %s', mask, name)\n+ if self._watch is not None:\n+ # If this node itself disappeared, that's definitely an\n+ # invalidation. Otherwise, the name needs to match.\n+ if IN.IGNORED in mask or self._name == name:\n+ logger.debug('Invalidating!')\n+ self._watch.invalidate()\n+\n+ def __init__(self, watch: 'PathWatch', event: Event, dirfd: int, name: str):\n+ self._watch = watch\n+ self._name = name.encode('utf-8')\n+\n+ # establishing invalidation watches is best-effort and can fail for a\n+ # number of reasons, including search (+x) but not read (+r) permission\n+ # on a particular path component, or exceeding limits on watches\n+ try:\n+ mask = IN.CREATE | IN.DELETE | IN.MOVE | IN.DELETE_SELF | IN.IGNORED\n+ self._source = event.add_inotify_fd(dirfd, mask, self.event)\n+ except OSError:\n+ self._source = None\n+\n+ def close(self) -> None:\n+ # This is a little bit tricky: systemd doesn't have a specific close\n+ # API outside of unref, so let's make it as explicit as possible.\n+ self._watch = None\n+ self._source = None\n+\n+\n+class PathStack(List[str]):\n+ def add_path(self, pathname: str) -> None:\n+ # TO DO: consider doing something reasonable with trailing slashes\n+ # this is a stack, popped from the end: push components in reverse\n+ self.extend(item for item in reversed(pathname.split('/')) if item)\n+ if pathname.startswith('/'):\n+ self.append('/')\n+\n+ def __init__(self, path: str):\n+ super().__init__()\n+ self.add_path(path)\n+\n+\n+class Listener:\n+ def do_inotify_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:\n+ raise NotImplementedError\n+\n+ def do_identity_changed(self, fd: Optional[Handle], errno: Optional[int]) -> None:\n+ raise NotImplementedError\n+\n+\n+class PathWatch:\n+ _event: Event\n+ _listener: Listener\n+ _path: str\n+ _invalidators: List[WatchInvalidator]\n+ _errno: Optional[int]\n+ _source: Optional[Event.Source]\n+ _tag: Optional[None]\n+ _fd: Handle\n+\n+ def __init__(self, path: str, listener: Listener, event: Optional[Event] = None):\n+ self._event = event or Event.default()\n+ self._path = path\n+ self._listener = listener\n+\n+ self._invalidators = []\n+ self._errno = None\n+ self._source = None\n+ self._tag = None\n+ self._fd = Handle()\n+\n+ self.invalidate()\n+\n+ def got_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:\n+ logger.debug('target event %s: %s %s %s', self._path, mask, cookie, name)\n+ self._listener.do_inotify_event(mask, cookie, name)\n+\n+ def invalidate(self) -> None:\n+ for invalidator in self._invalidators:\n+ invalidator.close()\n+ self._invalidators = []\n+\n+ try:\n+ fd = self.walk()\n+ except OSError as error:\n+ logger.debug('walk ended in error %d', error.errno)\n+\n+ if self._source or self._fd or self._errno != error.errno:\n+ logger.debug('Ending existing watches.')\n+ self._source = None\n+ self._fd.close()\n+ self._fd = Handle()\n+ self._errno = error.errno\n+\n+ logger.debug('Notifying of new error state %d', self._errno)\n+ self._listener.do_identity_changed(None, self._errno)\n+\n+ return\n+\n+ with fd:\n+ logger.debug('walk successful. Got fd %d', fd)\n+ if fd == self._fd:\n+ logger.debug('fd seems to refer to same file. Doing nothing.')\n+ return\n+\n+ logger.debug('This file is new for us. Removing old watch.')\n+ self._source = None\n+ self._fd.close()\n+ self._fd = fd.steal()\n+\n+ try:\n+ logger.debug('Establishing a new watch.')\n+ self._source = self._event.add_inotify_fd(self._fd, IN.CHANGED, self.got_event)\n+ logger.debug('Watching successfully. Notifying of new identity.')\n+ self._listener.do_identity_changed(self._fd, None)\n+ except OSError as error:\n+ logger.debug('Watching failed (%d). Notifying of new identity.', error.errno)\n+ self._listener.do_identity_changed(self._fd, error.errno)\n+\n+ def walk(self) -> Handle:\n+ remaining_symlink_lookups = 40\n+ remaining_components = PathStack(self._path)\n+ dirfd = Handle()\n+\n+ try:\n+ logger.debug('Starting path walk')\n+\n+ while remaining_components:\n+ logger.debug('r=%s dfd=%s', remaining_components, dirfd)\n+\n+ name = remaining_components.pop()\n+\n+ if dirfd and name != '/':\n+ self._invalidators.append(WatchInvalidator(self, self._event, dirfd, name))\n+\n+ with Handle.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=dirfd) as fd:\n+ mode = os.fstat(fd).st_mode\n+\n+ if stat.S_ISLNK(mode):\n+ if remaining_symlink_lookups == 0:\n+ raise OSError(errno.ELOOP, os.strerror(errno.ELOOP))\n+ remaining_symlink_lookups -= 1\n+ linkpath = os.readlink('', dir_fd=fd)\n+ logger.debug('%s is a symlink. adding %s to components', name, linkpath)\n+ remaining_components.add_path(linkpath)\n+\n+ else:\n+ dirfd.close()\n+ dirfd = fd.steal()\n+\n+ return dirfd.steal()\n+\n+ finally:\n+ dirfd.close()\n+\n+ def close(self) -> None:\n+ for invalidator in self._invalidators:\n+ invalidator.close()\n+ self._invalidators = []\n+ self._source = None\n+ self._fd.close()\n+''',\n+ 'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing\n+from typing import TYPE_CHECKING\n+\n+# The goal here is to continue to work on Python 3.6 while pretending to have\n+# access to some modern typing features. The shims provided here are only\n+# enough for what we need for systemd_ctypes to work at runtime.\n+\n+\n+if TYPE_CHECKING:\n+ # See https://github.com/python/mypy/issues/1153 for why we do this separately\n+ from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin\n+\n+else:\n+ # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated\n+ # arrived in 3.9. Unfortunately, it's difficult to implement a mocked up\n+ # version of Annotated which works with the real typing.get_args() and\n+ # .get_origin() in Python 3.8, so we use our own versions there as well.\n+ try:\n+ from typing import Annotated, get_args, get_origin\n+ except ImportError:\n+ class AnnotatedMeta(type):\n+ def __getitem__(cls, params):\n+ class AnnotatedType:\n+ __origin__ = Annotated\n+ __args__ = params\n+ return AnnotatedType\n+\n+ class Annotated(metaclass=AnnotatedMeta):\n+ pass\n+\n+ def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]:\n+ return getattr(annotation, '__args__', ())\n+\n+ def get_origin(annotation: typing.Any) -> typing.Any:\n+ return getattr(annotation, '__origin__', None)\n+\n+ try:\n+ from typing import ForwardRef\n+ except ImportError:\n+ from typing import _ForwardRef as ForwardRef\n+\n+ try:\n+ from typing import TypeGuard\n+ except ImportError:\n+ T = typing.TypeVar('T')\n+\n+ class TypeGuard(typing.Generic[T]):\n+ pass\n+\n+\n+__all__ = (\n+ 'Annotated',\n+ 'ForwardRef',\n+ 'TypeGuard',\n+ 'get_args',\n+ 'get_origin',\n+ 'TYPE_CHECKING',\n+)\n+''',\n+ 'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes\n+#\n+# Copyright (C) 2022 Allison Karlitskaya \n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program. If not, see .\n+\n+import ctypes\n+from enum import IntFlag, auto\n+from typing import Optional\n+\n+\n+class inotify_event(ctypes.Structure):\n+ _fields_ = (\n+ ('wd', ctypes.c_int32),\n+ ('mask', ctypes.c_uint32),\n+ ('cookie', ctypes.c_uint32),\n+ ('len', ctypes.c_uint32),\n+ )\n+\n+ @property\n+ def name(self) -> Optional[bytes]:\n+ if self.len == 0:\n+ return None\n+\n+ class event_with_name(ctypes.Structure):\n+ _fields_ = (*inotify_event._fields_, ('name', ctypes.c_char * self.len))\n+\n+ name = ctypes.cast(ctypes.addressof(self), ctypes.POINTER(event_with_name)).contents.name\n+ assert isinstance(name, bytes)\n+ return name\n+\n+\n+class Event(IntFlag):\n+ ACCESS = auto()\n+ MODIFY = auto()\n+ ATTRIB = auto()\n+ CLOSE_WRITE = auto()\n+ CLOSE_NOWRITE = auto()\n+ OPEN = auto()\n+ MOVED_FROM = auto()\n+ MOVED_TO = auto()\n+ CREATE = auto()\n+ DELETE = auto()\n+ DELETE_SELF = auto()\n+ MOVE_SELF = auto()\n+\n+ UNMOUNT = 1 << 13\n+ Q_OVERFLOW = auto()\n+ IGNORED = auto()\n+\n+ ONLYDIR = 1 << 24\n+ DONT_FOLLOW = auto()\n+ EXCL_UNLINK = auto()\n+\n+ MASK_CREATE = 1 << 28\n+ MASK_ADD = auto()\n+ ISDIR = auto()\n+ ONESHOT = auto()\n+\n+ CLOSE = CLOSE_WRITE | CLOSE_NOWRITE\n+ MOVE = MOVED_FROM | MOVED_TO\n+ CHANGED = (MODIFY | ATTRIB | CLOSE_WRITE | MOVE |\n+ CREATE | DELETE | DELETE_SELF | MOVE_SELF)\n ''',\n 'cockpit/_vendor/systemd_ctypes/libsystemd.py': r'''# systemd_ctypes\n #\n # Copyright (C) 2022 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n@@ -8196,3327 +9944,1579 @@\n sd_bus_message,\n sd_bus_slot,\n sd_event,\n sd_event_source,\n }:\n cls._install_cfuncs(libsystemd)\n '''.encode('utf-8'),\n- 'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing\n-from typing import TYPE_CHECKING\n-\n-# The goal here is to continue to work on Python 3.6 while pretending to have\n-# access to some modern typing features. The shims provided here are only\n-# enough for what we need for systemd_ctypes to work at runtime.\n-\n-\n-if TYPE_CHECKING:\n- # See https://github.com/python/mypy/issues/1153 for why we do this separately\n- from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin\n-\n-else:\n- # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated\n- # arrived in 3.9. Unfortunately, it's difficult to implement a mocked up\n- # version of Annotated which works with the real typing.get_args() and\n- # .get_origin() in Python 3.8, so we use our own versions there as well.\n- try:\n- from typing import Annotated, get_args, get_origin\n- except ImportError:\n- class AnnotatedMeta(type):\n- def __getitem__(cls, params):\n- class AnnotatedType:\n- __origin__ = Annotated\n- __args__ = params\n- return AnnotatedType\n-\n- class Annotated(metaclass=AnnotatedMeta):\n- pass\n-\n- def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]:\n- return getattr(annotation, '__args__', ())\n-\n- def get_origin(annotation: typing.Any) -> typing.Any:\n- return getattr(annotation, '__origin__', None)\n-\n- try:\n- from typing import ForwardRef\n- except ImportError:\n- from typing import _ForwardRef as ForwardRef\n-\n- try:\n- from typing import TypeGuard\n- except ImportError:\n- T = typing.TypeVar('T')\n-\n- class TypeGuard(typing.Generic[T]):\n- pass\n-\n-\n-__all__ = (\n- 'Annotated',\n- 'ForwardRef',\n- 'TypeGuard',\n- 'get_args',\n- 'get_origin',\n- 'TYPE_CHECKING',\n-)\n-''',\n- 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3\n-\n-import array\n-import io\n-import os\n-import socket\n-import sys\n-from typing import Sequence\n-\n-\n-def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None:\n- cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')]\n-\n- with cmd_write:\n- with cmd_read:\n- with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock:\n- fd_array = array.array('i', (cmd_read.fileno(), *fds))\n- sock.sendmsg([b'\\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)])\n-\n- cmd_write.write(repr((command, args)))\n-\n-\n-def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int:\n- ours, theirs = socket.socketpair()\n-\n- with theirs:\n- command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd))\n-\n- with ours:\n- return int(ours.recv(16) or b'1')\n-\n-\n-def main() -> None:\n- if len(sys.argv) == 1:\n- command(2, 'ferny.end', [])\n- else:\n- sys.exit(askpass(2, 1, sys.argv, dict(os.environ)))\n-\n-\n-if __name__ == '__main__':\n- main()\n-''',\n- 'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1)\n+ 'cockpit/_vendor/systemd_ctypes/bus.py': br'''# systemd_ctypes\n #\n-# Copyright (C) 2023 Allison Karlitskaya \n+# Copyright (C) 2022 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n # along with this program. If not, see .\n \n-import array\n-import ast\n import asyncio\n-import contextlib\n+import enum\n import logging\n-import os\n-import re\n-import socket\n-import tempfile\n-from typing import Any, Callable, ClassVar, Generator, Sequence\n+import typing\n+from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union\n \n-from . import interaction_client\n+from . import bustypes, introspection, libsystemd\n+from .librarywrapper import WeakReference, byref\n \n logger = logging.getLogger(__name__)\n \n \n-COMMAND_RE = re.compile(b'\\0ferny\\0([^\\n]*)\\0\\0\\n')\n-COMMAND_TEMPLATE = '\\0ferny\\0{(command, args)!r}\\0\\0\\n'\n-\n-BEIBOOT_GADGETS = {\n- \"command\": fr\"\"\"\n- import sys\n- def command(command, *args):\n- sys.stderr.write(f{COMMAND_TEMPLATE!r})\n- sys.stderr.flush()\n- \"\"\",\n- \"end\": r\"\"\"\n- def end():\n- command('ferny.end')\n- \"\"\",\n-}\n-\n-\n-class InteractionError(Exception):\n- pass\n-\n-\n-try:\n- recv_fds = socket.recv_fds\n-except AttributeError:\n- # Python < 3.9\n-\n- def recv_fds(\n- sock: socket.socket, bufsize: int, maxfds: int, flags: int = 0\n- ) -> 'tuple[bytes, list[int], int, None]':\n- fds = array.array(\"i\")\n- msg, ancdata, flags, addr = sock.recvmsg(bufsize, socket.CMSG_LEN(maxfds * fds.itemsize))\n- for cmsg_level, cmsg_type, cmsg_data in ancdata:\n- if (cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS):\n- fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])\n- return msg, list(fds), flags, addr\n-\n-\n-def get_running_loop() -> asyncio.AbstractEventLoop:\n- try:\n- return asyncio.get_running_loop()\n- except AttributeError:\n- # Python 3.6\n- return asyncio.get_event_loop()\n-\n-\n-class InteractionHandler:\n- commands: ClassVar[Sequence[str]]\n-\n- async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:\n- raise NotImplementedError\n-\n-\n-class AskpassHandler(InteractionHandler):\n- commands: ClassVar[Sequence[str]] = ('ferny.askpass',)\n-\n- async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None':\n- \"\"\"Prompt the user for an authentication or confirmation interaction.\n-\n- 'messages' is data that was sent to stderr before the interaction was requested.\n- 'prompt' is the interaction prompt.\n-\n- The expected response type depends on hint:\n+class BusError(Exception):\n+ \"\"\"An exception corresponding to a D-Bus error message\n \n- - \"confirm\": ask for permission, returning \"yes\" if accepted\n- - example: authorizing agent operation\n+ This exception is raised by the method call methods. You can also raise it\n+ from your own method handlers. It can also be passed directly to functions\n+ such as Message.reply_method_error().\n \n- - \"none\": show a request without need for a response\n- - example: please touch your authentication token\n+ :name: the 'code' of the error, like org.freedesktop.DBus.Error.UnknownMethod\n+ :message: a human-readable description of the error\n+ \"\"\"\n+ def __init__(self, name: str, message: str):\n+ super().__init__(f'{name}: {message}')\n+ self.name = name\n+ self.message = message\n \n- - otherwise: return a password or other form of text token\n- - examples: enter password, unlock private key\n \n- In any case, the function should properly handle cancellation. For the\n- \"none\" case, this will be the normal way to dismiss the dialog.\n- \"\"\"\n- return None\n+class BusMessage(libsystemd.sd_bus_message):\n+ \"\"\"A message, received from or to be sent over D-Bus\n \n- async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:\n- \"\"\"Prompt the user for a decision regarding acceptance of a host key.\n+ This is the low-level interface to receiving and sending individual\n+ messages over D-Bus. You won't normally need to use it.\n \n- The \"reason\" will be either \"HOSTNAME\" or \"ADDRESS\" (if `CheckHostIP` is enabled).\n+ A message is associated with a particular bus. You can create messages for\n+ a bus with Bus.message_new_method_call() or Bus.message_new_signal(). You\n+ can create replies to method calls with Message.new_method_return() or\n+ Message.new_method_error(). You can append arguments with Message.append()\n+ and send the message with Message.send().\n+ \"\"\"\n+ def get_bus(self) -> 'Bus':\n+ \"\"\"Get the bus that a message is associated with.\n \n- The host, algorithm, and key parameters are the values in the form that\n- they would appear one a single line in the known hosts file. The\n- fingerprint is the key fingerprint in the format that ssh would\n- normally present it to the user.\n+ This is the bus that a message came from or will be sent on. Every\n+ message has an associated bus, and it cannot be changed.\n \n- In case the host key should be accepted, this function needs to return\n- True. Returning False means that ssh implements its default logic. To\n- interrupt the connection, raise an exception.\n+ :returns: the Bus\n \"\"\"\n- return False\n-\n- async def do_custom_command(\n- self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str\n- ) -> None:\n- \"\"\"Handle a custom command.\n+ return Bus.ref(self._get_bus())\n \n- The command name, its arguments, the passed fds, and the stderr leading\n- up to the command invocation are all provided.\n+ def get_error(self) -> Optional[BusError]:\n+ \"\"\"Get the BusError from a message.\n \n- See doc/interaction-protocol.md\n+ :returns: a BusError for an error message, or None for a non-error message\n \"\"\"\n-\n- async def _askpass_command(self, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:\n- logger.debug('_askpass_command(%s, %s, %s)', args, fds, stderr)\n- try:\n- argv, env = args\n- assert isinstance(argv, list)\n- assert all(isinstance(arg, str) for arg in argv)\n- assert isinstance(env, dict)\n- assert all(isinstance(key, str) and isinstance(val, str) for key, val in env.items())\n- assert len(fds) == 2\n- except (ValueError, TypeError, AssertionError) as exc:\n- logger.error('Invalid arguments to askpass interaction: %s, %s: %s', args, fds, exc)\n- return\n-\n- with open(fds.pop(0), 'w') as status, open(fds.pop(0), 'w') as stdout:\n- try:\n- loop = get_running_loop()\n- try:\n- task = asyncio.current_task()\n- except AttributeError:\n- task = asyncio.Task.current_task() # type:ignore[attr-defined] # (Python 3.6)\n- assert task is not None\n- loop.add_reader(status, task.cancel)\n-\n- if len(argv) == 2:\n- # normal askpass\n- prompt = argv[1]\n- hint = env.get('SSH_ASKPASS_PROMPT', '')\n- logger.debug('do_askpass(%r, %r, %r)', stderr, prompt, hint)\n- answer = await self.do_askpass(stderr, prompt, hint)\n- logger.debug('do_askpass answer %r', answer)\n- if answer is not None:\n- print(answer, file=stdout)\n- print(0, file=status)\n-\n- elif len(argv) == 6:\n- # KnownHostsCommand\n- argv0, reason, host, algorithm, key, fingerprint = argv\n- if reason in ['ADDRESS', 'HOSTNAME']:\n- logger.debug('do_hostkey(%r, %r, %r, %r, %r)', reason, host, algorithm, key, fingerprint)\n- if await self.do_hostkey(reason, host, algorithm, key, fingerprint):\n- print(host, algorithm, key, file=stdout)\n- else:\n- logger.debug('ignoring KnownHostsCommand reason %r', reason)\n-\n- print(0, file=status)\n-\n- else:\n- logger.error('Incorrect number of command-line arguments to ferny-askpass: %s', argv)\n- finally:\n- loop.remove_reader(status)\n-\n- async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:\n- logger.debug('run_command(%s, %s, %s, %s)', command, args, fds, stderr)\n- if command == 'ferny.askpass':\n- await self._askpass_command(args, fds, stderr)\n- else:\n- await self.do_custom_command(command, args, fds, stderr)\n-\n-\n-class InteractionAgent:\n- _handlers: 'dict[str, InteractionHandler]'\n-\n- _loop: asyncio.AbstractEventLoop\n-\n- _tasks: 'set[asyncio.Task]'\n-\n- _buffer: bytearray\n- _ours: socket.socket\n- _theirs: socket.socket\n-\n- _completion_future: 'asyncio.Future[str]'\n- _pending_result: 'None | str | Exception' = None\n- _end: bool = False\n-\n- def _consider_completion(self) -> None:\n- logger.debug('_consider_completion(%r)', self)\n-\n- if self._pending_result is None or self._tasks:\n- logger.debug(' but not ready yet')\n-\n- elif self._completion_future.done():\n- logger.debug(' but already complete')\n-\n- elif isinstance(self._pending_result, str):\n- logger.debug(' submitting stderr (%r) to completion_future', self._pending_result)\n- self._completion_future.set_result(self._pending_result)\n-\n+ error = self._get_error()\n+ if error:\n+ return BusError(*error.contents.get())\n else:\n- logger.debug(' submitting exception (%r) to completion_future')\n- self._completion_future.set_exception(self._pending_result)\n-\n- def _result(self, result: 'str | Exception') -> None:\n- logger.debug('_result(%r, %r)', self, result)\n-\n- if self._pending_result is None:\n- self._pending_result = result\n-\n- if self._ours.fileno() != -1:\n- logger.debug(' remove_reader(%r)', self._ours)\n- self._loop.remove_reader(self._ours.fileno())\n-\n- for task in self._tasks:\n- logger.debug(' cancel(%r)', task)\n- task.cancel()\n-\n- logger.debug(' closing sockets')\n- self._theirs.close() # idempotent\n- self._ours.close()\n-\n- self._consider_completion()\n-\n- def _invoke_command(self, stderr: bytes, command_blob: bytes, fds: 'list[int]') -> None:\n- logger.debug('_invoke_command(%r, %r, %r)', stderr, command_blob, fds)\n- try:\n- command, args = ast.literal_eval(command_blob.decode())\n- if not isinstance(command, str) or not isinstance(args, tuple):\n- raise TypeError('Invalid argument types')\n- except (UnicodeDecodeError, SyntaxError, ValueError, TypeError) as exc:\n- logger.error('Received invalid ferny command: %s: %s', command_blob, exc)\n- return\n-\n- if command == 'ferny.end':\n- self._end = True\n- self._result(self._buffer.decode(errors='replace'))\n- return\n-\n- try:\n- handler = self._handlers[command]\n- except KeyError:\n- logger.error('Received unhandled ferny command: %s', command)\n- return\n-\n- # The task is responsible for the list of fds and removing itself\n- # from the set.\n- task_fds = list(fds)\n- task = self._loop.create_task(handler.run_command(command, args, task_fds, stderr.decode()))\n-\n- def bottom_half(completed_task: asyncio.Task) -> None:\n- assert completed_task is task\n- while task_fds:\n- os.close(task_fds.pop())\n- self._tasks.remove(task)\n-\n- try:\n- task.result()\n- logger.debug('%r completed cleanly', handler)\n- except asyncio.CancelledError:\n- # this is not an error \u2014 it just means ferny-askpass exited via signal\n- logger.debug('%r was cancelled', handler)\n- except Exception as exc:\n- logger.debug('%r raised %r', handler, exc)\n- self._result(exc)\n-\n- self._consider_completion()\n-\n- task.add_done_callback(bottom_half)\n- self._tasks.add(task)\n- fds[:] = []\n-\n- def _got_data(self, data: bytes, fds: 'list[int]') -> None:\n- logger.debug('_got_data(%r, %r)', data, fds)\n-\n- if data == b'':\n- self._result(self._buffer.decode(errors='replace'))\n- return\n-\n- self._buffer.extend(data)\n-\n- # Read zero or more \"remote\" messages\n- chunks = COMMAND_RE.split(self._buffer)\n- self._buffer = bytearray(chunks.pop())\n- while len(chunks) > 1:\n- self._invoke_command(chunks[0], chunks[1], [])\n- chunks = chunks[2:]\n+ return None\n \n- # Maybe read one \"local\" message\n- if fds:\n- assert self._buffer.endswith(b'\\0'), self._buffer\n- stderr = self._buffer[:-1]\n- self._buffer = bytearray(b'')\n- with open(fds.pop(0), 'rb') as command_channel:\n- command = command_channel.read()\n- self._invoke_command(stderr, command, fds)\n+ def new_method_return(self, signature: str = '', *args: Any) -> 'BusMessage':\n+ \"\"\"Create a new (successful) return message as a reply to this message.\n \n- def _read_ready(self) -> None:\n- try:\n- data, fds, _flags, _addr = recv_fds(self._ours, 4096, 10, flags=socket.MSG_DONTWAIT)\n- except BlockingIOError:\n- return\n- except OSError as exc:\n- self._result(exc)\n- else:\n- self._got_data(data, fds)\n- finally:\n- while fds:\n- os.close(fds.pop())\n+ This only makes sense when performed on a method call message.\n \n- def __init__(\n- self,\n- handlers: Sequence[InteractionHandler],\n- loop: 'asyncio.AbstractEventLoop | None' = None,\n- done_callback: 'Callable[[asyncio.Future[str]], None] | None' = None,\n- ) -> None:\n- self._loop = loop or get_running_loop()\n- self._completion_future = self._loop.create_future()\n- self._tasks = set()\n- self._handlers = {}\n+ :signature: The signature of the result, as a string.\n+ :args: The values to send, conforming to the signature string.\n \n- for handler in handlers:\n- for command in handler.commands:\n- self._handlers[command] = handler\n+ :returns: the reply message\n+ \"\"\"\n+ reply = BusMessage()\n+ self._new_method_return(byref(reply))\n+ reply.append(signature, *args)\n+ return reply\n \n- if done_callback is not None:\n- self._completion_future.add_done_callback(done_callback)\n+ def new_method_error(self, error: Union[BusError, OSError]) -> 'BusMessage':\n+ \"\"\"Create a new error message as a reply to this message.\n \n- self._theirs, self._ours = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)\n- self._buffer = bytearray()\n+ This only makes sense when performed on a method call message.\n \n- def fileno(self) -> int:\n- return self._theirs.fileno()\n+ :error: BusError or OSError of the error to send\n \n- def start(self) -> None:\n- logger.debug('start(%r)', self)\n- if self._ours.fileno() != -1:\n- logger.debug(' add_reader(%r)', self._ours)\n- self._loop.add_reader(self._ours.fileno(), self._read_ready)\n+ :returns: the reply message\n+ \"\"\"\n+ reply = BusMessage()\n+ if isinstance(error, BusError):\n+ self._new_method_errorf(byref(reply), error.name, \"%s\", error.message)\n else:\n- logger.debug(' ...but agent is already finished.')\n-\n- logger.debug(' close(%r)', self._theirs)\n- self._theirs.close()\n-\n- def force_completion(self) -> None:\n- logger.debug('force_completion(%r)', self)\n+ assert isinstance(error, OSError)\n+ self._new_method_errnof(byref(reply), error.errno, \"%s\", str(error))\n+ return reply\n \n- # read any residual data on stderr, but don't process commands, and\n- # don't block\n- try:\n- if self._ours.fileno() != -1:\n- logger.debug(' draining pending stderr data (non-blocking)')\n- with contextlib.suppress(BlockingIOError):\n- while True:\n- data = self._ours.recv(4096, socket.MSG_DONTWAIT)\n- logger.debug(' got %d bytes', len(data))\n- if not data:\n- break\n- self._buffer.extend(data)\n- except OSError as exc:\n- self._result(exc)\n- else:\n- self._result(self._buffer.decode(errors='replace'))\n+ def append_arg(self, typestring: str, arg: Any) -> None:\n+ \"\"\"Append a single argument to the message.\n \n- async def communicate(self) -> None:\n- logger.debug('_communicate(%r)', self)\n- try:\n- self.start()\n- # We assume that we are the only ones to write to\n- # self._completion_future. If we directly await it, though, it can\n- # also have a asyncio.CancelledError posted to it from outside.\n- # Shield it to prevent that from happening.\n- stderr = await asyncio.shield(self._completion_future)\n- logger.debug('_communicate(%r) stderr result is %r', self, stderr)\n- finally:\n- logger.debug('_communicate finished. Ensuring completion.')\n- self.force_completion()\n- if not self._end:\n- logger.debug('_communicate never saw ferny.end. raising InteractionError.')\n- raise InteractionError(stderr.strip())\n+ :typestring: a single typestring, such as 's', or 'a{sv}'\n+ :arg: the argument to append, matching the typestring\n+ \"\"\"\n+ type_, = bustypes.from_signature(typestring)\n+ type_.writer(self, arg)\n \n+ def append(self, signature: str, *args: Any) -> None:\n+ \"\"\"Append zero or more arguments to the message.\n \n-def write_askpass_to_tmpdir(tmpdir: str) -> str:\n- askpass_path = os.path.join(tmpdir, 'ferny-askpass')\n- fd = os.open(askpass_path, os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC | os.O_EXCL | os.O_NOFOLLOW, 0o777)\n- try:\n- os.write(fd, __loader__.get_data(interaction_client.__file__)) # type: ignore\n- finally:\n- os.close(fd)\n- return askpass_path\n+ :signature: concatenated typestrings, such 'a{sv}' (one arg), or 'ss' (two args)\n+ :args: one argument for each type string in the signature\n+ \"\"\"\n+ types = bustypes.from_signature(signature)\n+ assert len(types) == len(args), f'call args {args} have different length than signature {signature}'\n+ for type_, arg in zip(types, args):\n+ type_.writer(self, arg)\n \n+ def get_body(self) -> Tuple[object, ...]:\n+ \"\"\"Gets the body of a message.\n \n-@contextlib.contextmanager\n-def temporary_askpass(**kwargs: Any) -> Generator[str, None, None]:\n- with tempfile.TemporaryDirectory(**kwargs) as directory:\n- yield write_askpass_to_tmpdir(directory)\n-'''.encode('utf-8'),\n- 'cockpit/_vendor/ferny/session.py': r'''# ferny - asyncio SSH client library, using ssh(1)\n-#\n-# Copyright (C) 2022 Allison Karlitskaya \n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n+ Possible return values are (), ('single',), or ('x', 'y'). If you\n+ check the signature of the message using Message.has_signature() then\n+ you can use tuple unpacking.\n \n-import asyncio\n-import ctypes\n-import functools\n-import logging\n-import os\n-import shlex\n-import signal\n-import subprocess\n-import tempfile\n-from typing import Mapping, Sequence\n+ single, = message.get_body()\n \n-from . import ssh_errors\n-from .interaction_agent import InteractionAgent, InteractionError, InteractionHandler, write_askpass_to_tmpdir\n+ x, y = other_message.get_body()\n \n-prctl = ctypes.cdll.LoadLibrary('libc.so.6').prctl\n-logger = logging.getLogger(__name__)\n-PR_SET_PDEATHSIG = 1\n+ :returns: an n-tuple containing one value per argument in the message\n+ \"\"\"\n+ self.rewind(True)\n+ types = bustypes.from_signature(self.get_signature(True))\n+ return tuple(type_.reader(self) for type_ in types)\n \n+ def send(self) -> bool: # Literal[True]\n+ \"\"\"Sends a message on the bus that it was created for.\n \n-@functools.lru_cache()\n-def has_feature(feature: str, teststr: str = 'x') -> bool:\n- try:\n- subprocess.check_output(['ssh', f'-o{feature} {teststr}', '-G', 'nonexisting'], stderr=subprocess.DEVNULL)\n+ :returns: True\n+ \"\"\"\n+ self.get_bus().send(self, None)\n return True\n- except subprocess.CalledProcessError:\n- return False\n \n+ def reply_method_error(self, error: Union[BusError, OSError]) -> bool: # Literal[True]\n+ \"\"\"Sends an error as a reply to a method call message.\n \n-class SubprocessContext:\n- def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:\n- \"\"\"Return the args required to launch a process in the given context.\n-\n- For example, this might return a vector with\n- [\"sudo\"]\n- or\n- [\"flatpak-spawn\", \"--host\"]\n- prepended.\n-\n- It is also possible that more substantial changes may be performed.\n+ :error: A BusError or OSError\n \n- This function is not permitted to modify its argument, although it may\n- (optionally) return it unmodified, if no changes are required.\n+ :returns: True\n \"\"\"\n- return args\n+ return self.new_method_error(error).send()\n \n- def wrap_subprocess_env(self, env: Mapping[str, str]) -> Mapping[str, str]:\n- \"\"\"Return the envp required to launch a process in the given context.\n+ def reply_method_return(self, signature: str = '', *args: Any) -> bool: # Literal[True]\n+ \"\"\"Sends a return value as a reply to a method call message.\n \n- For example, this might set the \"SUDO_ASKPASS\" environment variable, if\n- needed.\n+ :signature: The signature of the result, as a string.\n+ :args: The values to send, conforming to the signature string.\n \n- As with wrap_subprocess_args(), this function is not permitted to\n- modify its argument, although it may (optionally) return it unmodified\n- if no changes are required.\n+ :returns: True\n \"\"\"\n- return env\n-\n-\n-class Session(SubprocessContext, InteractionHandler):\n- # Set after .connect() called, even if failed\n- _controldir: 'tempfile.TemporaryDirectory | None' = None\n- _controlsock: 'str | None' = None\n-\n- # Set if connected, else None\n- _process: 'asyncio.subprocess.Process | None' = None\n-\n- async def connect(self,\n- destination: str,\n- handle_host_key: bool = False,\n- configfile: 'str | None' = None,\n- identity_file: 'str | None' = None,\n- login_name: 'str | None' = None,\n- options: 'Mapping[str, str] | None' = None,\n- pkcs11: 'str | None' = None,\n- port: 'int | None' = None,\n- interaction_responder: 'InteractionHandler | None' = None) -> None:\n- rundir = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/run'), 'ferny')\n- os.makedirs(rundir, exist_ok=True)\n- self._controldir = tempfile.TemporaryDirectory(dir=rundir)\n- self._controlsock = f'{self._controldir.name}/socket'\n-\n- # In general, we can't guarantee an accessible and executable version\n- # of this file, but since it's small and we're making a temporary\n- # directory anyway, let's just copy it into place and use it from\n- # there.\n- askpass_path = write_askpass_to_tmpdir(self._controldir.name)\n-\n- env = dict(os.environ)\n- env['SSH_ASKPASS'] = askpass_path\n- env['SSH_ASKPASS_REQUIRE'] = 'force'\n- # old SSH doesn't understand SSH_ASKPASS_REQUIRE and guesses based on DISPLAY instead\n- env['DISPLAY'] = '-'\n-\n- args = [\n- '-M',\n- '-N',\n- '-S', self._controlsock,\n- '-o', 'PermitLocalCommand=yes',\n- '-o', f'LocalCommand={askpass_path}',\n- ]\n-\n- if configfile is not None:\n- args.append(f'-F{configfile}')\n-\n- if identity_file is not None:\n- args.append(f'-i{identity_file}')\n-\n- if options is not None:\n- for key in options: # Note: Mapping may not have .items()\n- args.append(f'-o{key} {options[key]}')\n-\n- if pkcs11 is not None:\n- args.append(f'-I{pkcs11}')\n-\n- if port is not None:\n- args.append(f'-p{port}')\n-\n- if login_name is not None:\n- args.append(f'-l{login_name}')\n-\n- if handle_host_key and has_feature('KnownHostsCommand'):\n- args.extend([\n- '-o', f'KnownHostsCommand={askpass_path} %I %H %t %K %f',\n- '-o', 'StrictHostKeyChecking=yes',\n- ])\n-\n- agent = InteractionAgent([interaction_responder] if interaction_responder is not None else [])\n-\n- # SSH_ASKPASS_REQUIRE is not generally available, so use setsid\n- process = await asyncio.create_subprocess_exec(\n- *('/usr/bin/ssh', *args, destination), env=env,\n- start_new_session=True, stdin=asyncio.subprocess.DEVNULL,\n- stdout=asyncio.subprocess.DEVNULL, stderr=agent, # type: ignore\n- preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL))\n+ return self.new_method_return(signature, *args).send()\n \n- # This is tricky: we need to clean up the subprocess, but only in case\n- # if failure. Otherwise, we keep it around.\n+ def _coroutine_task_complete(self, out_type: bustypes.MessageType, task: asyncio.Task) -> None:\n try:\n- await agent.communicate()\n- assert os.path.exists(self._controlsock)\n- self._process = process\n- except InteractionError as exc:\n- await process.wait()\n- raise ssh_errors.get_exception_for_ssh_stderr(str(exc)) from None\n- except BaseException:\n- # If we get here because the InteractionHandler raised an\n- # exception then SSH might still be running, and may even attempt\n- # further interactions (ie: 2nd attempt for password). We already\n- # have our exception and don't need any more info. Kill it.\n- try:\n- process.kill()\n- except ProcessLookupError:\n- pass # already exited? good.\n- await process.wait()\n- raise\n-\n- def is_connected(self) -> bool:\n- return self._process is not None\n-\n- async def wait(self) -> None:\n- assert self._process is not None\n- await self._process.wait()\n-\n- def exit(self) -> None:\n- assert self._process is not None\n- self._process.terminate()\n-\n- async def disconnect(self) -> None:\n- self.exit()\n- await self.wait()\n-\n- # Launching of processes\n- def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:\n- assert self._controlsock is not None\n- # 1. We specify the hostname as the empty string: it will be ignored\n- # when ssh is trying to use the control socket, but in case the\n- # socket has stopped working, ssh will try to fall back to directly\n- # connecting, in which case an empty hostname will prevent that.\n- # 2. We need to quote the arguments \u2014 ssh will paste them together\n- # using only spaces, executing the result using the user's shell.\n- return ('ssh', '-S', self._controlsock, '', *map(shlex.quote, args))\n-'''.encode('utf-8'),\n- 'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1)\n-#\n-# Copyright (C) 2023 Allison Karlitskaya \n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import asyncio\n-import contextlib\n-import logging\n-import typing\n-from typing import Any, Callable, Iterable, Sequence, TypeVar\n-\n-from .interaction_agent import InteractionAgent, InteractionHandler, get_running_loop\n-from .ssh_errors import get_exception_for_ssh_stderr\n-\n-logger = logging.getLogger(__name__)\n-\n-P = TypeVar('P', bound=asyncio.Protocol)\n-\n-\n-class SubprocessError(Exception):\n- returncode: int\n- stderr: str\n-\n- def __init__(self, returncode: int, stderr: str) -> None:\n- super().__init__(returncode, stderr)\n- self.returncode = returncode\n- self.stderr = stderr\n-\n-\n-class FernyTransport(asyncio.Transport, asyncio.SubprocessProtocol):\n- _agent: InteractionAgent\n- _exec_task: 'asyncio.Task[tuple[asyncio.SubprocessTransport, FernyTransport]]'\n- _is_ssh: bool\n- _protocol: asyncio.Protocol\n- _protocol_disconnected: bool = False\n-\n- # These get initialized in connection_made() and once set, never get unset.\n- _subprocess_transport: 'asyncio.SubprocessTransport | None' = None\n- _stdin_transport: 'asyncio.WriteTransport | None' = None\n- _stdout_transport: 'asyncio.ReadTransport | None' = None\n-\n- # We record events that might build towards a connection termination here\n- # and consider them from _consider_disconnect() in order to try to get the\n- # best possible Exception for the protocol, rather than just taking the\n- # first one (which is likely to be somewhat random).\n- _exception: 'Exception | None' = None\n- _stderr_output: 'str | None' = None\n- _returncode: 'int | None' = None\n- _transport_disconnected: bool = False\n- _closed: bool = False\n-\n- @classmethod\n- def spawn(\n- cls: 'type[typing.Self]',\n- protocol_factory: Callable[[], P],\n- args: Sequence[str],\n- loop: 'asyncio.AbstractEventLoop | None' = None,\n- interaction_handlers: Sequence[InteractionHandler] = (),\n- is_ssh: bool = True,\n- **kwargs: Any\n- ) -> 'tuple[typing.Self, P]':\n- \"\"\"Connects a FernyTransport to a protocol, using the given command.\n-\n- This spawns an external command and connects the stdin and stdout of\n- the command to the protocol returned by the factory.\n-\n- An instance of ferny.InteractionAgent is created and attached to the\n- stderr of the spawned process, using the provided handlers. It is the\n- responsibility of the caller to ensure that:\n- - a `ferny-askpass` client program is installed somewhere; and\n- - any relevant command-line arguments or environment variables are\n- passed correctly to the program to be spawned\n-\n- This function returns immediately and never raises exceptions, assuming\n- all preconditions are met.\n+ self.reply_method_function_return_value(out_type, task.result())\n+ except (BusError, OSError) as exc:\n+ self.reply_method_error(exc)\n \n- If spawning the process fails then connection_lost() will be\n- called with the relevant OSError, even before connection_made() is\n- called. This is somewhat non-standard behaviour, but is the easiest\n- way to report these errors without making this function async.\n+ def reply_method_function_return_value(self,\n+ out_type: bustypes.MessageType,\n+ return_value: Any) -> bool: # Literal[True]:\n+ \"\"\"Sends the result of a function call as a reply to a method call message.\n \n- Once the process is successfully executed, connection_made() will be\n- called and the transport can be used as normal. connection_lost() will\n- be called if the process exits or another error occurs.\n+ This call does a bit of magic: it adapts from the usual Python return\n+ value conventions (where the return value is ``None``, a single value,\n+ or a tuple) to the normal D-Bus return value conventions (where the\n+ result is always a tuple).\n \n- The return value of this function is the transport, but it exists in a\n- semi-initialized state. You can call .close() on it, but nothing else.\n- Once .connection_made() is called, you can call all the other\n- functions.\n+ Additionally, if the value is found to be a coroutine, a task is\n+ created to run the coroutine to completion and return the result\n+ (including exception handling).\n \n- After you call this function, `.connection_lost()` will be called on\n- your Protocol, exactly once, no matter what. Until that happens, you\n- are responsible for holding a reference to the returned transport.\n+ :out_types: The types of the return values, as an iterable.\n+ :return_value: The return value of a Python function call.\n \n- :param args: the full argv of the command to spawn\n- :param loop: the event loop to use. If none is provided, we use the\n- one which is (read: must be) currently running.\n- :param interaction_handlers: the handlers passed to the\n- InteractionAgent\n- :param is_ssh: whether we should attempt to interpret stderr as ssh\n- error messages\n- :param kwargs: anything else is passed through to `subprocess_exec()`\n- :returns: the usual `(Transport, Protocol)` pair\n+ :returns: True\n \"\"\"\n- logger.debug('spawn(%r, %r, %r)', cls, protocol_factory, args)\n-\n- protocol = protocol_factory()\n- self = cls(protocol)\n- self._is_ssh = is_ssh\n-\n- if loop is None:\n- loop = get_running_loop()\n-\n- self._agent = InteractionAgent(interaction_handlers, loop, self._interaction_completed)\n- kwargs.setdefault('stderr', self._agent.fileno())\n-\n- # As of Python 3.12 this isn't really asynchronous (since it uses the\n- # subprocess module, which blocks while waiting for the exec() to\n- # complete in the child), but we have to deal with the complication of\n- # the async interface anyway. Since we, ourselves, want to export a\n- # non-async interface, that means that we need a task here and a\n- # bottom-half handler below.\n- self._exec_task = loop.create_task(loop.subprocess_exec(lambda: self, *args, **kwargs))\n-\n- def exec_completed(task: asyncio.Task) -> None:\n- logger.debug('exec_completed(%r, %r)', self, task)\n- assert task is self._exec_task\n- try:\n- transport, me = task.result()\n- assert me is self\n- logger.debug(' success.')\n- except asyncio.CancelledError:\n- return # in that case, do nothing\n- except OSError as exc:\n- logger.debug(' OSError %r', exc)\n- self.close(exc)\n- return\n-\n- # Our own .connection_made() handler should have gotten called by\n- # now. Make sure everything got filled in properly.\n- assert self._subprocess_transport is transport\n- assert self._stdin_transport is not None\n- assert self._stdout_transport is not None\n-\n- # Ask the InteractionAgent to start processing stderr.\n- self._agent.start()\n-\n- self._exec_task.add_done_callback(exec_completed)\n-\n- return self, protocol\n-\n- def __init__(self, protocol: asyncio.Protocol) -> None:\n- self._protocol = protocol\n-\n- def _consider_disconnect(self) -> None:\n- logger.debug('_consider_disconnect(%r)', self)\n- # We cannot disconnect as long as any of these three things are happening\n- if not self._exec_task.done():\n- logger.debug(' exec_task still running %r', self._exec_task)\n- return\n-\n- if self._subprocess_transport is not None and not self._transport_disconnected:\n- logger.debug(' transport still connected %r', self._subprocess_transport)\n- return\n-\n- if self._stderr_output is None:\n- logger.debug(' agent still running')\n- return\n-\n- # All conditions for disconnection are satisfied.\n- if self._protocol_disconnected:\n- logger.debug(' already disconnected')\n- return\n- self._protocol_disconnected = True\n+ if asyncio.coroutines.iscoroutine(return_value):\n+ task = asyncio.create_task(return_value)\n+ task.add_done_callback(lambda task: self._coroutine_task_complete(out_type, task))\n+ return True\n \n- # Now we just need to determine what we report to the protocol...\n- if self._exception is not None:\n- # If we got an exception reported, that's our reason for closing.\n- logger.debug(' disconnect with exception %r', self._exception)\n- self._protocol.connection_lost(self._exception)\n- elif self._returncode == 0 or self._closed:\n- # If we called close() or have a zero return status, that's a clean\n- # exit, regardless of noise that might have landed in stderr.\n- logger.debug(' clean disconnect')\n- self._protocol.connection_lost(None)\n- elif self._is_ssh and self._returncode == 255:\n- # This is an error code due to an SSH failure. Try to interpret it.\n- logger.debug(' disconnect with ssh error %r', self._stderr_output)\n- self._protocol.connection_lost(get_exception_for_ssh_stderr(self._stderr_output))\n+ reply = self.new_method_return()\n+ # In the general case, a function returns an n-tuple, but...\n+ if len(out_type) == 0:\n+ # Functions with no return value return None.\n+ assert return_value is None\n+ elif len(out_type) == 1:\n+ # Functions with a single return value return that value.\n+ out_type.write(reply, return_value)\n else:\n- # Otherwise, report the stderr text and return code.\n- logger.debug(' disconnect with exit code %r, stderr %r', self._returncode, self._stderr_output)\n- # We surely have _returncode set here, since otherwise:\n- # - exec_task failed with an exception (which we handle above); or\n- # - we're still connected...\n- assert self._returncode is not None\n- self._protocol.connection_lost(SubprocessError(self._returncode, self._stderr_output))\n-\n- def _interaction_completed(self, future: 'asyncio.Future[str]') -> None:\n- logger.debug('_interaction_completed(%r, %r)', self, future)\n- try:\n- self._stderr_output = future.result()\n- logger.debug(' stderr: %r', self._stderr_output)\n- except Exception as exc:\n- logger.debug(' exception: %r', exc)\n- self._stderr_output = '' # we need to set this in order to complete\n- self.close(exc)\n-\n- self._consider_disconnect()\n-\n- # BaseProtocol implementation\n- def connection_made(self, transport: asyncio.BaseTransport) -> None:\n- logger.debug('connection_made(%r, %r)', self, transport)\n- assert isinstance(transport, asyncio.SubprocessTransport)\n- self._subprocess_transport = transport\n-\n- stdin_transport = transport.get_pipe_transport(0)\n- assert isinstance(stdin_transport, asyncio.WriteTransport)\n- self._stdin_transport = stdin_transport\n-\n- stdout_transport = transport.get_pipe_transport(1)\n- assert isinstance(stdout_transport, asyncio.ReadTransport)\n- self._stdout_transport = stdout_transport\n-\n- stderr_transport = transport.get_pipe_transport(2)\n- assert stderr_transport is None\n-\n- logger.debug('calling connection_made(%r, %r)', self, self._protocol)\n- self._protocol.connection_made(self)\n-\n- def connection_lost(self, exc: 'Exception | None') -> None:\n- logger.debug('connection_lost(%r, %r)', self, exc)\n- if self._exception is None:\n- self._exception = exc\n- self._transport_disconnected = True\n- self._consider_disconnect()\n-\n- # SubprocessProtocol implementation\n- def pipe_data_received(self, fd: int, data: bytes) -> None:\n- logger.debug('pipe_data_received(%r, %r, %r)', self, fd, len(data))\n- assert fd == 1 # stderr is handled separately\n- self._protocol.data_received(data)\n-\n- def pipe_connection_lost(self, fd: int, exc: 'Exception | None') -> None:\n- logger.debug('pipe_connection_lost(%r, %r, %r)', self, fd, exc)\n- assert fd in (0, 1) # stderr is handled separately\n-\n- # We treat this as a clean close\n- if isinstance(exc, BrokenPipeError):\n- exc = None\n-\n- # Record serious errors to propagate them to the protocol\n- # If this is a clean exit on stdout, report an EOF\n- if exc is not None:\n- self.close(exc)\n- elif fd == 1 and not self._closed:\n- if not self._protocol.eof_received():\n- self.close()\n-\n- def process_exited(self) -> None:\n- logger.debug('process_exited(%r)', self)\n- assert self._subprocess_transport is not None\n- self._returncode = self._subprocess_transport.get_returncode()\n- logger.debug(' ._returncode = %r', self._returncode)\n- self._agent.force_completion()\n+ # (general case) n return values are handled as an n-tuple.\n+ assert len(out_type) == len(return_value)\n+ out_type.write(reply, *return_value)\n+ return reply.send()\n \n- def pause_writing(self) -> None:\n- logger.debug('pause_writing(%r)', self)\n- self._protocol.pause_writing()\n \n- def resume_writing(self) -> None:\n- logger.debug('resume_writing(%r)', self)\n- self._protocol.resume_writing()\n+class Slot(libsystemd.sd_bus_slot):\n+ def __init__(self, callback: Callable[[BusMessage], bool]):\n+ def handler(message: WeakReference, _data: object, _err: object) -> int:\n+ return 1 if callback(BusMessage.ref(message)) else 0\n+ self.trampoline = libsystemd.sd_bus_message_handler_t(handler)\n \n- # Transport implementation. Most of this is straight delegation.\n- def close(self, exc: 'Exception | None' = None) -> None:\n- logger.debug('close(%r, %r)', self, exc)\n- self._closed = True\n- if self._exception is None:\n- logger.debug(' setting exception %r', exc)\n- self._exception = exc\n- if not self._exec_task.done():\n- logger.debug(' cancelling _exec_task')\n- self._exec_task.cancel()\n- if self._subprocess_transport is not None:\n- logger.debug(' closing _subprocess_transport')\n- # https://github.com/python/cpython/issues/112800\n- with contextlib.suppress(PermissionError):\n- self._subprocess_transport.close()\n- self._agent.force_completion()\n \n- def is_closing(self) -> bool:\n- assert self._subprocess_transport is not None\n- return self._subprocess_transport.is_closing()\n+if typing.TYPE_CHECKING:\n+ FutureMessage = asyncio.Future[BusMessage]\n+else:\n+ # Python 3.6 can't subscript asyncio.Future\n+ FutureMessage = asyncio.Future\n \n- def get_extra_info(self, name: str, default: object = None) -> object:\n- assert self._subprocess_transport is not None\n- return self._subprocess_transport.get_extra_info(name, default)\n \n- def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:\n- assert isinstance(protocol, asyncio.Protocol)\n- self._protocol = protocol\n+class PendingCall(Slot):\n+ future: FutureMessage\n \n- def get_protocol(self) -> asyncio.Protocol:\n- return self._protocol\n+ def __init__(self) -> None:\n+ future = asyncio.get_running_loop().create_future()\n \n- def is_reading(self) -> bool:\n- assert self._stdout_transport is not None\n- try:\n- return self._stdout_transport.is_reading()\n- except NotImplementedError:\n- # This is (incorrectly) unimplemented before Python 3.11\n- return not self._stdout_transport._paused # type:ignore[attr-defined]\n- except AttributeError:\n- # ...and in Python 3.6 it's even worse\n- try:\n- selector = self._stdout_transport._loop._selector # type:ignore[attr-defined]\n- selector.get_key(self._stdout_transport._fileno) # type:ignore[attr-defined]\n+ def done(message: BusMessage) -> bool:\n+ error = message.get_error()\n+ if future.cancelled():\n return True\n- except KeyError:\n- return False\n-\n- def pause_reading(self) -> None:\n- assert self._stdout_transport is not None\n- self._stdout_transport.pause_reading()\n+ if error is not None:\n+ future.set_exception(error)\n+ else:\n+ future.set_result(message)\n+ return True\n \n- def resume_reading(self) -> None:\n- assert self._stdout_transport is not None\n- self._stdout_transport.resume_reading()\n+ super().__init__(done)\n+ self.future = future\n \n- def abort(self) -> None:\n- assert self._stdin_transport is not None\n- assert self._subprocess_transport is not None\n- self._stdin_transport.abort()\n- self._subprocess_transport.kill()\n \n- def can_write_eof(self) -> bool:\n- assert self._stdin_transport is not None\n- return self._stdin_transport.can_write_eof() # will always be True\n+class Bus(libsystemd.sd_bus):\n+ _default_system_instance = None\n+ _default_user_instance = None\n \n- def get_write_buffer_size(self) -> int:\n- assert self._stdin_transport is not None\n- return self._stdin_transport.get_write_buffer_size()\n+ class NameFlags(enum.IntFlag):\n+ DEFAULT = 0\n+ REPLACE_EXISTING = 1 << 0\n+ ALLOW_REPLACEMENT = 1 << 1\n+ QUEUE = 1 << 2\n \n- def get_write_buffer_limits(self) -> 'tuple[int, int]':\n- assert self._stdin_transport is not None\n- return self._stdin_transport.get_write_buffer_limits()\n+ @staticmethod\n+ def new(\n+ fd: Optional[int] = None,\n+ address: Optional[str] = None,\n+ bus_client: bool = False,\n+ server: bool = False,\n+ start: bool = True,\n+ attach_event: bool = True\n+ ) -> 'Bus':\n+ bus = Bus()\n+ Bus._new(byref(bus))\n+ if address is not None:\n+ bus.set_address(address)\n+ if fd is not None:\n+ bus.set_fd(fd, fd)\n+ if bus_client:\n+ bus.set_bus_client(True)\n+ if server:\n+ bus.set_server(True, libsystemd.sd_id128())\n+ if address is not None or fd is not None:\n+ if start:\n+ bus.start()\n+ if attach_event:\n+ bus.attach_event(None, 0)\n+ return bus\n \n- def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None:\n- assert self._stdin_transport is not None\n- return self._stdin_transport.set_write_buffer_limits(high, low)\n+ @staticmethod\n+ def default_system(attach_event: bool = True) -> 'Bus':\n+ if Bus._default_system_instance is None:\n+ Bus._default_system_instance = Bus()\n+ Bus._default_system(byref(Bus._default_system_instance))\n+ if attach_event:\n+ Bus._default_system_instance.attach_event(None, 0)\n+ return Bus._default_system_instance\n \n- def write(self, data: bytes) -> None:\n- assert self._stdin_transport is not None\n- return self._stdin_transport.write(data)\n+ @staticmethod\n+ def default_user(attach_event: bool = True) -> 'Bus':\n+ if Bus._default_user_instance is None:\n+ Bus._default_user_instance = Bus()\n+ Bus._default_user(byref(Bus._default_user_instance))\n+ if attach_event:\n+ Bus._default_user_instance.attach_event(None, 0)\n+ return Bus._default_user_instance\n \n- def writelines(self, list_of_data: Iterable[bytes]) -> None:\n- assert self._stdin_transport is not None\n- return self._stdin_transport.writelines(list_of_data)\n+ def message_new_method_call(\n+ self,\n+ destination: Optional[str],\n+ path: str,\n+ interface: str,\n+ member: str,\n+ types: str = '',\n+ *args: object\n+ ) -> BusMessage:\n+ message = BusMessage()\n+ self._message_new_method_call(byref(message), destination, path, interface, member)\n+ message.append(types, *args)\n+ return message\n \n- def write_eof(self) -> None:\n- assert self._stdin_transport is not None\n- return self._stdin_transport.write_eof()\n+ def message_new_signal(\n+ self, path: str, interface: str, member: str, types: str = '', *args: object\n+ ) -> BusMessage:\n+ message = BusMessage()\n+ self._message_new_signal(byref(message), path, interface, member)\n+ message.append(types, *args)\n+ return message\n \n- # We don't really implement SubprocessTransport, but provide these as\n- # \"extras\" to our user.\n- def get_pid(self) -> int:\n- assert self._subprocess_transport is not None\n- return self._subprocess_transport.get_pid()\n+ def call(self, message: BusMessage, timeout: Optional[int] = None) -> BusMessage:\n+ reply = BusMessage()\n+ error = libsystemd.sd_bus_error()\n+ try:\n+ self._call(message, timeout or 0, byref(error), byref(reply))\n+ return reply\n+ except OSError as exc:\n+ raise BusError(*error.get()) from exc\n \n- def get_returncode(self) -> 'int | None':\n- return self._returncode\n+ def call_method(\n+ self,\n+ destination: str,\n+ path: str,\n+ interface: str,\n+ member: str,\n+ types: str = '',\n+ *args: object,\n+ timeout: Optional[int] = None\n+ ) -> Tuple[object, ...]:\n+ logger.debug('Doing sync method call %s %s %s %s %s %s',\n+ destination, path, interface, member, types, args)\n+ message = self.message_new_method_call(destination, path, interface, member, types, *args)\n+ message = self.call(message, timeout)\n+ return message.get_body()\n \n- def kill(self) -> None:\n- assert self._subprocess_transport is not None\n- self._subprocess_transport.kill()\n+ async def call_async(\n+ self,\n+ message: BusMessage,\n+ timeout: Optional[int] = None\n+ ) -> BusMessage:\n+ pending = PendingCall()\n+ self._call_async(byref(pending), message, pending.trampoline, pending.userdata, timeout or 0)\n+ return await pending.future\n \n- def send_signal(self, number: int) -> None:\n- assert self._subprocess_transport is not None\n- self._subprocess_transport.send_signal(number)\n+ async def call_method_async(\n+ self,\n+ destination: Optional[str],\n+ path: str,\n+ interface: str,\n+ member: str,\n+ types: str = '',\n+ *args: object,\n+ timeout: Optional[int] = None\n+ ) -> Tuple[object, ...]:\n+ logger.debug('Doing async method call %s %s %s %s %s %s',\n+ destination, path, interface, member, types, args)\n+ message = self.message_new_method_call(destination, path, interface, member, types, *args)\n+ message = await self.call_async(message, timeout)\n+ return message.get_body()\n \n- def terminate(self) -> None:\n- assert self._subprocess_transport is not None\n- self._subprocess_transport.terminate()\n-''',\n- 'cockpit/_vendor/ferny/py.typed': br'''''',\n- 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import (\n- BEIBOOT_GADGETS,\n- COMMAND_TEMPLATE,\n- AskpassHandler,\n- InteractionAgent,\n- InteractionError,\n- InteractionHandler,\n- temporary_askpass,\n- write_askpass_to_tmpdir,\n-)\n-from .session import Session\n-from .ssh_askpass import (\n- AskpassPrompt,\n- SshAskpassResponder,\n- SshFIDOPINPrompt,\n- SshFIDOUserPresencePrompt,\n- SshHostKeyPrompt,\n- SshPassphrasePrompt,\n- SshPasswordPrompt,\n- SshPKCS11PINPrompt,\n-)\n-from .ssh_errors import (\n- SshAuthenticationError,\n- SshChangedHostKeyError,\n- SshError,\n- SshHostKeyError,\n- SshUnknownHostKeyError,\n-)\n-from .transport import FernyTransport, SubprocessError\n+ def add_match(self, rule: str, handler: Callable[[BusMessage], bool]) -> Slot:\n+ slot = Slot(handler)\n+ self._add_match(byref(slot), rule, slot.trampoline, slot.userdata)\n+ return slot\n \n-__all__ = [\n- 'AskpassHandler',\n- 'AskpassPrompt',\n- 'AuthenticationError',\n- 'BEIBOOT_GADGETS',\n- 'COMMAND_TEMPLATE',\n- 'ChangedHostKeyError',\n- 'FernyTransport',\n- 'HostKeyError',\n- 'InteractionAgent',\n- 'InteractionError',\n- 'InteractionHandler',\n- 'Session',\n- 'SshAskpassResponder',\n- 'SshAuthenticationError',\n- 'SshChangedHostKeyError',\n- 'SshError',\n- 'SshFIDOPINPrompt',\n- 'SshFIDOUserPresencePrompt',\n- 'SshHostKeyError',\n- 'SshHostKeyPrompt',\n- 'SshPKCS11PINPrompt',\n- 'SshPassphrasePrompt',\n- 'SshPasswordPrompt',\n- 'SshUnknownHostKeyError',\n- 'SubprocessError',\n- 'temporary_askpass',\n- 'write_askpass_to_tmpdir',\n-]\n+ def add_object(self, path: str, obj: 'BaseObject') -> Slot:\n+ slot = Slot(obj.message_received)\n+ self._add_object(byref(slot), path, slot.trampoline, slot.userdata)\n+ obj.registered_on_bus(self, path)\n+ return slot\n \n-__version__ = '0'\n-''',\n- 'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1)\n-#\n-# Copyright (C) 2023 Allison Karlitskaya \n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n \n-import ctypes\n-import errno\n-import os\n-import re\n-import socket\n-from typing import ClassVar, Iterable, Match, Pattern\n+class BaseObject:\n+ \"\"\"Base object type for exporting objects on the bus\n \n+ This is the lowest-level class that can be passed to Bus.add_object().\n \n-class SshError(Exception):\n- PATTERN: ClassVar[Pattern]\n+ If you want to directly subclass this, you'll need to implement\n+ `message_received()`.\n \n- def __init__(self, match: 'Match | None', stderr: str) -> None:\n- super().__init__(match.group(0) if match is not None else stderr)\n- self.stderr = stderr\n+ Subclassing from `bus.Object` is probably a better choice.\n+ \"\"\"\n+ _dbus_bus: Optional[Bus] = None\n+ _dbus_path: Optional[str] = None\n \n+ def registered_on_bus(self, bus: Bus, path: str) -> None:\n+ \"\"\"Report that an instance was exported on a given bus and path.\n \n-class SshAuthenticationError(SshError):\n- PATTERN = re.compile(r'^([^:]+): Permission denied \\(([^()]+)\\)\\.$', re.M)\n+ This is used so that the instance knows where to send signals.\n+ Bus.add_object() calls this: you probably shouldn't call this on your\n+ own.\n+ \"\"\"\n+ self._dbus_bus = bus\n+ self._dbus_path = path\n \n- def __init__(self, match: Match, stderr: str) -> None:\n- super().__init__(match, stderr)\n- self.destination = match.group(1)\n- self.methods = match.group(2).split(',')\n- self.message = match.group(0)\n+ self.registered()\n \n+ def registered(self) -> None:\n+ \"\"\"Called after an object has been registered on the bus\n \n-# generic host key error for OSes without KnownHostsCommand support\n-class SshHostKeyError(SshError):\n- PATTERN = re.compile(r'^Host key verification failed.$', re.M)\n+ This is the correct method to implement to do some initial work that\n+ needs to be done after registration. The default implementation does\n+ nothing.\n+ \"\"\"\n+ pass\n \n+ def emit_signal(\n+ self, interface: str, name: str, signature: str, *args: Any\n+ ) -> bool:\n+ \"\"\"Emit a D-Bus signal on this object\n \n-# specific errors for OSes with KnownHostsCommand\n-class SshUnknownHostKeyError(SshHostKeyError):\n- PATTERN = re.compile(r'^No .* host key is known.*Host key verification failed.$', re.S | re.M)\n+ The object must have been exported on the bus with Bus.add_object().\n \n+ :interface: the interface of the signal\n+ :name: the 'member' name of the signal to emit\n+ :signature: the type signature, as a string\n+ :args: the arguments, according to the signature\n+ :returns: True\n+ \"\"\"\n+ assert self._dbus_bus is not None\n+ assert self._dbus_path is not None\n+ return self._dbus_bus.message_new_signal(self._dbus_path, interface, name, signature, *args).send()\n \n-class SshChangedHostKeyError(SshHostKeyError):\n- PATTERN = re.compile(r'warning.*remote host identification has changed', re.I)\n+ def message_received(self, message: BusMessage) -> bool:\n+ \"\"\"Called when a message is received for this object\n \n+ This is the lowest level interface to the BaseObject. You need to\n+ handle method calls, properties, and introspection.\n \n-# Functionality for mapping getaddrinfo()-family error messages to their\n-# equivalent Python exceptions.\n-def make_gaierror_map() -> 'Iterable[tuple[str, int]]':\n- libc = ctypes.CDLL(None)\n- libc.gai_strerror.restype = ctypes.c_char_p\n+ You are expected to handle the message and return True. Normally this\n+ means that you send a reply. If you don't want to handle the message,\n+ return False and other handlers will have a chance to run. If no\n+ handler handles the message, systemd will generate a suitable error\n+ message and send that, instead.\n \n- for key in dir(socket):\n- if key.startswith('EAI_'):\n- errnum = getattr(socket, key)\n- yield libc.gai_strerror(errnum).decode('utf-8'), errnum\n+ :message: the message that was received\n+ :returns: True if the message was handled\n+ \"\"\"\n+ raise NotImplementedError\n \n \n-gaierror_map = dict(make_gaierror_map())\n+class Interface:\n+ \"\"\"The high-level base class for defining D-Bus interfaces\n \n+ This class provides high-level APIs for defining methods, properties, and\n+ signals, as well as implementing introspection.\n \n-# Functionality for passing strerror() error messages to their equivalent\n-# Python exceptions.\n-# There doesn't seem to be an official API for turning an errno into the\n-# correct subtype of OSError, and the list that cpython uses is hidden fairly\n-# deeply inside of the implementation. This is basically copied from the\n-# ADD_ERRNO() lines in _PyExc_InitState in cpython/Objects/exceptions.c\n-oserror_subclass_map = dict((errnum, cls) for cls, errnum in [\n- (BlockingIOError, errno.EAGAIN),\n- (BlockingIOError, errno.EALREADY),\n- (BlockingIOError, errno.EINPROGRESS),\n- (BlockingIOError, errno.EWOULDBLOCK),\n- (BrokenPipeError, errno.EPIPE),\n- (BrokenPipeError, errno.ESHUTDOWN),\n- (ChildProcessError, errno.ECHILD),\n- (ConnectionAbortedError, errno.ECONNABORTED),\n- (ConnectionRefusedError, errno.ECONNREFUSED),\n- (ConnectionResetError, errno.ECONNRESET),\n- (FileExistsError, errno.EEXIST),\n- (FileNotFoundError, errno.ENOENT),\n- (IsADirectoryError, errno.EISDIR),\n- (NotADirectoryError, errno.ENOTDIR),\n- (InterruptedError, errno.EINTR),\n- (PermissionError, errno.EACCES),\n- (PermissionError, errno.EPERM),\n- (ProcessLookupError, errno.ESRCH),\n- (TimeoutError, errno.ETIMEDOUT),\n-])\n+ On its own, this class doesn't provide a mechanism for exporting anything\n+ on the bus. The Object class does that, and you'll generally want to\n+ subclass from it, as it contains several built-in standard interfaces\n+ (introspection, properties, etc.).\n \n+ The name of your class will be interpreted as a D-Bus interface name.\n+ Underscores are converted to dots. No case conversion is performed. If\n+ the interface name can't be represented using this scheme, or if you'd like\n+ to name your class differently, you can provide an interface= kwarg to the\n+ class definition.\n \n-def get_exception_for_ssh_stderr(stderr: str) -> Exception:\n- stderr = stderr.replace('\\r\\n', '\\n') # fix line separators\n+ class com_example_Interface(bus.Object):\n+ pass\n \n- # check for the specific error messages first, then for generic SshHostKeyError\n- for ssh_cls in [SshAuthenticationError, SshChangedHostKeyError, SshUnknownHostKeyError, SshHostKeyError]:\n- match = ssh_cls.PATTERN.search(stderr)\n- if match is not None:\n- return ssh_cls(match, stderr)\n+ class MyInterface(bus.Object, interface='org.cockpit_project.Interface'):\n+ pass\n \n- before, colon, after = stderr.rpartition(':')\n- if colon and after:\n- potential_strerror = after.strip()\n+ The methods, properties, and signals which are visible from D-Bus are\n+ defined using helper classes with the corresponding names (Method,\n+ Property, Signal). You should use normal Python snake_case conventions for\n+ the member names: they will automatically be converted to CamelCase by\n+ splitting on underscore and converting the first letter of each resulting\n+ word to uppercase. For example, `method_name` becomes `MethodName`.\n \n- # DNS lookup errors\n- if potential_strerror in gaierror_map:\n- errnum = gaierror_map[potential_strerror]\n- return socket.gaierror(errnum, stderr)\n+ Each Method, Property, or Signal constructor takes an optional name= kwargs\n+ to override the automatic name conversion convention above.\n \n- # Network connect errors\n- for errnum in errno.errorcode:\n- if os.strerror(errnum) == potential_strerror:\n- os_cls = oserror_subclass_map.get(errnum, OSError)\n- return os_cls(errnum, stderr)\n+ An example class might look like:\n \n- # No match? Generic.\n- return SshError(None, stderr)\n-''',\n- 'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main\n+ class com_example_MyObject(bus.Object):\n+ created = bus.Interface.Signal('s', 'i')\n+ renames = bus.Interface.Property('u', value=0)\n+ name = bus.Interface.Property('s', 'undefined')\n \n-if __name__ == '__main__':\n- main()\n-''',\n- 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging\n-import re\n-from typing import ClassVar, Match, Sequence\n+ @bus.Interface.Method(out_types=(), in_types='s')\n+ def rename(self, name):\n+ self.renames += 1\n+ self.name = name\n \n-from .interaction_agent import AskpassHandler\n+ def registered(self):\n+ self.created('Hello', 42)\n \n-logger = logging.getLogger(__name__)\n+ See the documentation for the Method, Property, and Signal classes for\n+ more information and examples.\n+ \"\"\"\n \n+ # Class variables\n+ _dbus_interfaces: Dict[str, Dict[str, Dict[str, Any]]]\n+ _dbus_members: Optional[Tuple[str, Dict[str, Dict[str, Any]]]]\n \n-class AskpassPrompt:\n- \"\"\"An askpass prompt resulting from a call to ferny-askpass.\n+ # Instance variables: stored in Python form\n+ _dbus_property_values: Optional[Dict[str, Any]] = None\n \n- stderr: the contents of stderr from before ferny-askpass was called.\n- Likely related to previous failed operations.\n- messages: all but the last line of the prompt as handed to ferny-askpass.\n- Usually contains context about the question.\n- prompt: the last line handed to ferny-askpass. The prompt itself.\n- \"\"\"\n- stderr: str\n- messages: str\n- prompt: str\n+ @classmethod\n+ def __init_subclass__(cls, interface: Optional[str] = None) -> None:\n+ if interface is None:\n+ assert '__' not in cls.__name__, 'Class name cannot contain sequential underscores'\n+ interface = cls.__name__.replace('_', '.')\n \n- def __init__(self, prompt: str, messages: str, stderr: str) -> None:\n- self.stderr = stderr\n- self.messages = messages\n- self.prompt = prompt\n+ # This is the information for this subclass directly\n+ members: Dict[str, Dict[str, Interface._Member]] = {'methods': {}, 'properties': {}, 'signals': {}}\n+ for name, member in cls.__dict__.items():\n+ if isinstance(member, Interface._Member):\n+ member.setup(interface, name, members)\n \n- def reply(self, response: str) -> None:\n- pass\n+ # We only store the information if something was actually defined\n+ if sum(len(category) for category in members.values()) > 0:\n+ cls._dbus_members = (interface, members)\n \n- def close(self) -> None:\n- pass\n+ # This is the information for this subclass, with all its ancestors\n+ cls._dbus_interfaces = dict(ancestor.__dict__['_dbus_members']\n+ for ancestor in cls.mro()\n+ if '_dbus_members' in ancestor.__dict__)\n \n- async def handle_via(self, responder: 'SshAskpassResponder') -> None:\n+ @classmethod\n+ def _find_interface(cls, interface: str) -> Dict[str, Dict[str, '_Member']]:\n try:\n- response = await self.dispatch(responder)\n- if response is not None:\n- self.reply(response)\n- finally:\n- self.close()\n-\n- async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n- return await responder.do_prompt(self)\n-\n+ return cls._dbus_interfaces[interface]\n+ except KeyError as exc:\n+ raise Object.Method.Unhandled from exc\n \n-class SSHAskpassPrompt(AskpassPrompt):\n- # The valid answers to prompts of this type. If this is None then any\n- # answer is permitted. If it's a sequence then only answers from the\n- # sequence are permitted. If it's an empty sequence, then no answer is\n- # permitted (ie: the askpass callback should never return).\n- answers: 'ClassVar[Sequence[str] | None]' = None\n+ @classmethod\n+ def _find_category(cls, interface: str, category: str) -> Dict[str, '_Member']:\n+ return cls._find_interface(interface)[category]\n \n- # Patterns to capture. `_pattern` *must* match.\n- _pattern: ClassVar[str]\n- # `_extra_patterns` can fill in extra class attributes if they match.\n- _extra_patterns: ClassVar[Sequence[str]] = ()\n+ @classmethod\n+ def _find_member(cls, interface: str, category: str, member: str) -> '_Member':\n+ members = cls._find_category(interface, category)\n+ try:\n+ return members[member]\n+ except KeyError as exc:\n+ raise Object.Method.Unhandled from exc\n \n- def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None:\n- super().__init__(prompt, messages, stderr)\n- self.__dict__.update(match.groupdict())\n+ class _Member:\n+ _category: str # filled in from subclasses\n \n- for pattern in self._extra_patterns:\n- extra_match = re.search(with_helpers(pattern), messages, re.M)\n- if extra_match is not None:\n- self.__dict__.update(extra_match.groupdict())\n+ _python_name: Optional[str] = None\n+ _name: Optional[str] = None\n+ _interface: Optional[str] = None\n+ _description: Optional[Dict[str, Any]]\n \n+ def __init__(self, name: Optional[str] = None) -> None:\n+ self._python_name = None\n+ self._interface = None\n+ self._name = name\n \n-# Specific prompts\n-HELPERS = {\n- \"%{algorithm}\": r\"(?P\\b[-\\w]+\\b)\",\n- \"%{filename}\": r\"(?P.+)\",\n- \"%{fingerprint}\": r\"(?PSHA256:[0-9A-Za-z+/]{43})\",\n- \"%{hostname}\": r\"(?P[^ @']+)\",\n- \"%{pkcs11_id}\": r\"(?P.+)\",\n- \"%{username}\": r\"(?P[^ @']+)\",\n-}\n+ def setup(self, interface: str, name: str, members: Dict[str, Dict[str, 'Interface._Member']]) -> None:\n+ self._python_name = name # for error messages\n+ if self._name is None:\n+ self._name = ''.join(word.title() for word in name.split('_'))\n+ self._interface = interface\n+ self._description = self._describe()\n+ members[self._category][self._name] = self\n \n+ def _describe(self) -> Dict[str, Any]:\n+ raise NotImplementedError\n \n-class SshPasswordPrompt(SSHAskpassPrompt):\n- _pattern = r\"%{username}@%{hostname}'s password: \"\n- username: 'str | None' = None\n- hostname: 'str | None' = None\n+ def __getitem__(self, key: str) -> Any:\n+ # Acts as an adaptor for dict accesses from introspection.to_xml()\n+ assert self._description is not None\n+ return self._description[key]\n \n- async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n- return await responder.do_password_prompt(self)\n+ class Property(_Member):\n+ \"\"\"Defines a D-Bus property on an interface\n \n+ There are two main ways to define properties: with and without getters.\n+ If you define a property without a getter, then you must provide a\n+ value (via the value= kwarg). In this case, the property value is\n+ maintained internally and can be accessed from Python in the usual way.\n+ Change signals are sent automatically.\n \n-class SshPassphrasePrompt(SSHAskpassPrompt):\n- _pattern = r\"Enter passphrase for key '%{filename}': \"\n- filename: str\n+ class MyObject(bus.Object):\n+ counter = bus.Interface.Property('i', value=0)\n \n- async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n- return await responder.do_passphrase_prompt(self)\n+ a = MyObject()\n+ a.counter = 5\n+ a.counter += 1\n+ print(a.counter)\n \n+ The other way to define properties is with a getter function. In this\n+ case, you can read from the property in the normal way, but not write\n+ to it. You are responsible for emitting change signals for yourself.\n+ You must not provide the value= kwarg.\n \n-class SshFIDOPINPrompt(SSHAskpassPrompt):\n- _pattern = r\"Enter PIN for %{algorithm} key %{filename}: \"\n- algorithm: str\n- filename: str\n+ class MyObject(bus.Object):\n+ _counter = 0\n \n- async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n- return await responder.do_fido_pin_prompt(self)\n+ counter = bus.Interface.Property('i')\n+ @counter.getter\n+ def get_counter(self):\n+ return self._counter\n \n+ @counter.setter\n+ def set_counter(self, value):\n+ self._counter = value\n+ self.property_changed('Counter')\n \n-class SshFIDOUserPresencePrompt(SSHAskpassPrompt):\n- _pattern = r\"Confirm user presence for key %{algorithm} %{fingerprint}\"\n- answers = ()\n- algorithm: str\n- fingerprint: str\n+ In either case, you can provide a setter function. This function has\n+ no impact on Python code, but makes the property writable from the view\n+ of D-Bus. Your setter will be called when a Properties.Set() call is\n+ made, and no other action will be performed. A trivial implementation\n+ might look like:\n \n- async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n- return await responder.do_fido_user_presence_prompt(self)\n+ class MyObject(bus.Object):\n+ counter = bus.Interface.Property('i', value=0)\n+ @counter.setter\n+ def set_counter(self, value):\n+ # we got a request to set the counter from D-Bus\n+ self.counter = value\n \n+ In all cases, the first (and only mandatory) argument to the\n+ constructor is the D-Bus type of the property.\n \n-class SshPKCS11PINPrompt(SSHAskpassPrompt):\n- _pattern = r\"Enter PIN for '%{pkcs11_id}': \"\n- pkcs11_id: str\n+ Your getter and setter functions can be provided by kwarg to the\n+ constructor. You can also give a name= kwarg to override the default\n+ name conversion scheme.\n+ \"\"\"\n+ _category = 'properties'\n \n- async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n- return await responder.do_pkcs11_pin_prompt(self)\n+ _getter: Optional[Callable[[Any], Any]]\n+ _setter: Optional[Callable[[Any, Any], None]]\n+ _type: bustypes.Type\n+ _value: Any\n \n+ def __init__(self, type_string: str,\n+ value: Any = None,\n+ name: Optional[str] = None,\n+ getter: Optional[Callable[[Any], Any]] = None,\n+ setter: Optional[Callable[[Any, Any], None]] = None):\n+ assert value is None or getter is None, 'A property cannot have both a value and a getter'\n \n-class SshHostKeyPrompt(SSHAskpassPrompt):\n- _pattern = r\"Are you sure you want to continue connecting \\(yes/no(/\\[fingerprint\\])?\\)\\? \"\n- _extra_patterns = [\n- r\"%{fingerprint}[.]$\",\n- r\"^%{algorithm} key fingerprint is\",\n- r\"^The fingerprint for the %{algorithm} key sent by the remote host is$\"\n- ]\n- answers = ('yes', 'no')\n- algorithm: str\n- fingerprint: str\n+ super().__init__(name=name)\n+ self._getter = getter\n+ self._setter = setter\n+ self._type, = bustypes.from_signature(type_string)\n+ self._value = value\n \n- async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':\n- return await responder.do_host_key_prompt(self)\n+ def _describe(self) -> Dict[str, Any]:\n+ return {'type': self._type.typestring, 'flags': 'r' if self._setter is None else 'w'}\n \n+ def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Any:\n+ assert self._name is not None\n+ if obj is None:\n+ return self\n+ if self._getter is not None:\n+ return self._getter.__get__(obj, cls)()\n+ elif self._value is not None:\n+ if obj._dbus_property_values is not None:\n+ return obj._dbus_property_values.get(self._name, self._value)\n+ else:\n+ return self._value\n+ else:\n+ raise AttributeError(f\"'{obj.__class__.__name__}' property '{self._python_name}' \"\n+ f\"was not properly initialised: use either the 'value=' kwarg or \"\n+ f\"the @'{self._python_name}.getter' decorator\")\n \n-def with_helpers(pattern: str) -> str:\n- for name, helper in HELPERS.items():\n- pattern = pattern.replace(name, helper)\n+ def __set__(self, obj: 'Object', value: Any) -> None:\n+ assert self._name is not None\n+ if self._getter is not None:\n+ raise AttributeError(f\"Cannot directly assign '{obj.__class__.__name__}' \"\n+ \"property '{self._python_name}' because it has a getter\")\n+ if obj._dbus_property_values is None:\n+ obj._dbus_property_values = {}\n+ obj._dbus_property_values[self._name] = value\n+ if obj._dbus_bus is not None:\n+ obj.properties_changed(self._interface, {self._name: bustypes.Variant(value, self._type)}, [])\n \n- assert '%{' not in pattern\n- return pattern\n+ def to_dbus(self, obj: 'Object') -> bustypes.Variant:\n+ return bustypes.Variant(self.__get__(obj), self._type)\n \n+ def from_dbus(self, obj: 'Object', value: bustypes.Variant) -> None:\n+ if self._setter is None or self._type != value.type:\n+ raise Object.Method.Unhandled\n+ self._setter.__get__(obj)(value.value)\n \n-def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt:\n- classes = [\n- SshFIDOPINPrompt,\n- SshFIDOUserPresencePrompt,\n- SshHostKeyPrompt,\n- SshPKCS11PINPrompt,\n- SshPassphrasePrompt,\n- SshPasswordPrompt,\n- ]\n+ def getter(self, getter: Callable[[Any], Any]) -> Callable[[Any], Any]:\n+ if self._value is not None:\n+ raise ValueError('A property cannot have both a value and a getter')\n+ if self._getter is not None:\n+ raise ValueError('This property already has a getter')\n+ self._getter = getter\n+ return getter\n \n- # The last line is the line after the last newline character, excluding the\n- # optional final newline character. eg: \"x\\ny\\nLAST\\n\" or \"x\\ny\\nLAST\"\n- second_last_newline = string.rfind('\\n', 0, -1)\n- if second_last_newline >= 0:\n- last_line = string[second_last_newline + 1:]\n- extras = string[:second_last_newline + 1]\n- else:\n- last_line = string\n- extras = ''\n+ def setter(self, setter: Callable[[Any, Any], None]) -> Callable[[Any, Any], None]:\n+ self._setter = setter\n+ return setter\n \n- for cls in classes:\n- pattern = with_helpers(cls._pattern)\n- match = re.fullmatch(pattern, last_line)\n- if match is not None:\n- return cls(last_line, extras, stderr, match)\n+ class Signal(_Member):\n+ \"\"\"Defines a D-Bus signal on an interface\n \n- return AskpassPrompt(last_line, extras, stderr)\n+ This is a callable which will result in the signal being emitted.\n \n+ The constructor takes the types of the arguments, each one as a\n+ separate parameter. For example:\n \n-class SshAskpassResponder(AskpassHandler):\n- async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None':\n- return await categorize_ssh_prompt(prompt, stderr).dispatch(self)\n+ properties_changed = Interface.Signal('s', 'a{sv}', 'as')\n \n- async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None':\n- # Default fallback for unrecognised message types: unimplemented\n- return None\n+ You can give a name= kwarg to override the default name conversion\n+ scheme.\n+ \"\"\"\n+ _category = 'signals'\n+ _type: bustypes.MessageType\n \n- async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None':\n- return await self.do_prompt(prompt)\n+ def __init__(self, *out_types: str, name: Optional[str] = None) -> None:\n+ super().__init__(name=name)\n+ self._type = bustypes.MessageType(out_types)\n \n- async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None':\n- return await self.do_prompt(prompt)\n+ def _describe(self) -> Dict[str, Any]:\n+ return {'in': self._type.typestrings}\n \n- async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None':\n- return await self.do_prompt(prompt)\n+ def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Callable[..., None]:\n+ def emitter(obj: Object, *args: Any) -> None:\n+ assert self._interface is not None\n+ assert self._name is not None\n+ assert obj._dbus_bus is not None\n+ assert obj._dbus_path is not None\n+ message = obj._dbus_bus.message_new_signal(obj._dbus_path, self._interface, self._name)\n+ self._type.write(message, *args)\n+ message.send()\n+ return emitter.__get__(obj, cls)\n \n- async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None':\n- return await self.do_prompt(prompt)\n+ class Method(_Member):\n+ \"\"\"Defines a D-Bus method on an interface\n \n- async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None':\n- return await self.do_prompt(prompt)\n+ This is a function decorator which marks a given method for export.\n \n- async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None':\n- return await self.do_prompt(prompt)\n-''',\n- 'cockpit/data/fail.html': br'''\n-\n-\n- @@message@@\n- \n- \n- \n-\n-\n-
\n- \n-

@@message@@

\n-
\n-\n-\n-''',\n- 'cockpit/data/__init__.py': br'''import sys\n+ The constructor takes two arguments: the type of the output arguments,\n+ and the type of the input arguments. Both should be given as a\n+ sequence.\n \n-if sys.version_info >= (3, 11):\n- import importlib.resources\n+ @Interface.Method(['a{sv}'], ['s'])\n+ def get_all(self, interface):\n+ ...\n \n- def read_cockpit_data_file(filename: str) -> bytes:\n- return (importlib.resources.files('cockpit.data') / filename).read_bytes()\n+ You can give a name= kwarg to override the default name conversion\n+ scheme.\n+ \"\"\"\n+ _category = 'methods'\n \n-else:\n- import importlib.abc\n+ class Unhandled(Exception):\n+ \"\"\"Raised by a method to indicate that the message triggering that\n+ method call remains unhandled.\"\"\"\n+ pass\n \n- def read_cockpit_data_file(filename: str) -> bytes:\n- # https://github.com/python/mypy/issues/4182\n- loader = __loader__ # type: ignore[name-defined]\n- assert isinstance(loader, importlib.abc.ResourceLoader)\n+ def __init__(self, out_types: Sequence[str] = (), in_types: Sequence[str] = (), name: Optional[str] = None):\n+ super().__init__(name=name)\n+ self._out_type = bustypes.MessageType(out_types)\n+ self._in_type = bustypes.MessageType(in_types)\n+ self._func = None\n \n- path = __file__.replace('__init__.py', filename)\n- return loader.get_data(path)\n-''',\n- 'cockpit/channels/http.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2022 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n+ def __get__(self, obj, cls=None):\n+ return self._func.__get__(obj, cls)\n \n-import http.client\n-import logging\n-import socket\n-import ssl\n+ def __call__(self, *args, **kwargs):\n+ # decorator\n+ self._func, = args\n+ return self\n \n-from ..channel import AsyncChannel, ChannelError\n-from ..jsonutil import JsonObject, get_dict, get_enum, get_int, get_object, get_str, typechecked\n+ def _describe(self) -> Dict[str, Any]:\n+ return {'in': [item.typestring for item in self._in_type.item_types],\n+ 'out': [item.typestring for item in self._out_type.item_types]}\n \n-logger = logging.getLogger(__name__)\n+ def _invoke(self, obj, message):\n+ args = self._in_type.read(message)\n+ if args is None:\n+ return False\n+ try:\n+ result = self._func.__get__(obj)(*args)\n+ except (BusError, OSError) as error:\n+ return message.reply_method_error(error)\n \n+ return message.reply_method_function_return_value(self._out_type, result)\n \n-class HttpChannel(AsyncChannel):\n- payload = 'http-stream2'\n \n+class org_freedesktop_DBus_Peer(Interface):\n+ @Interface.Method()\n @staticmethod\n- def get_headers(response: http.client.HTTPResponse, *, binary: bool) -> JsonObject:\n- # Never send these headers\n- remove = {'Connection', 'Transfer-Encoding'}\n-\n- if not binary:\n- # Only send these headers for raw binary streams\n- remove.update({'Content-Length', 'Range'})\n-\n- return {key: value for key, value in response.getheaders() if key not in remove}\n+ def ping() -> None:\n+ pass\n \n+ @Interface.Method('s')\n @staticmethod\n- def create_client(options: JsonObject) -> http.client.HTTPConnection:\n- opt_address = get_str(options, 'address', 'localhost')\n- opt_tls = get_dict(options, 'tls', None)\n- opt_unix = get_str(options, 'unix', None)\n- opt_port = get_int(options, 'port', None)\n-\n- if opt_tls is not None and opt_unix is not None:\n- raise ChannelError('protocol-error', message='TLS on Unix socket is not supported')\n- if opt_port is None and opt_unix is None:\n- raise ChannelError('protocol-error', message='no \"port\" or \"unix\" option for channel')\n- if opt_port is not None and opt_unix is not None:\n- raise ChannelError('protocol-error', message='cannot specify both \"port\" and \"unix\" options')\n-\n- if opt_tls is not None:\n- authority = get_dict(opt_tls, 'authority', None)\n- if authority is not None:\n- data = get_str(authority, 'data', None)\n- if data is not None:\n- context = ssl.create_default_context(cadata=data)\n- else:\n- context = ssl.create_default_context(cafile=get_str(authority, 'file'))\n- else:\n- context = ssl.create_default_context()\n-\n- if 'validate' in opt_tls and not opt_tls['validate']:\n- context.check_hostname = False\n- context.verify_mode = ssl.VerifyMode.CERT_NONE\n-\n- # See https://github.com/python/typeshed/issues/11057\n- return http.client.HTTPSConnection(opt_address, port=opt_port, context=context) # type: ignore[arg-type]\n-\n- else:\n- return http.client.HTTPConnection(opt_address, port=opt_port)\n+ def get_machine_id() -> str:\n+ with open('/etc/machine-id', encoding='ascii') as file:\n+ return file.read().strip()\n \n- @staticmethod\n- def connect(connection: http.client.HTTPConnection, opt_unix: 'str | None') -> None:\n- # Blocks. Runs in a thread.\n- if opt_unix:\n- # create the connection's socket so that it won't call .connect() internally (which only supports TCP)\n- connection.sock = socket.socket(socket.AF_UNIX)\n- connection.sock.connect(opt_unix)\n- else:\n- # explicitly call connect(), so that we can do proper error handling\n- connection.connect()\n \n- @staticmethod\n- def request(\n- connection: http.client.HTTPConnection, method: str, path: str, headers: 'dict[str, str]', body: bytes\n- ) -> http.client.HTTPResponse:\n- # Blocks. Runs in a thread.\n- connection.request(method, path, headers=headers or {}, body=body)\n- return connection.getresponse()\n+class org_freedesktop_DBus_Introspectable(Interface):\n+ @Interface.Method('s')\n+ @classmethod\n+ def introspect(cls) -> str:\n+ return introspection.to_xml(cls._dbus_interfaces)\n \n- async def run(self, options: JsonObject) -> None:\n- logger.debug('open %s', options)\n \n- binary = get_enum(options, 'binary', ['raw'], None) is not None\n- method = get_str(options, 'method')\n- path = get_str(options, 'path')\n- headers = get_object(options, 'headers', lambda d: {k: typechecked(v, str) for k, v in d.items()}, None)\n+class org_freedesktop_DBus_Properties(Interface):\n+ properties_changed = Interface.Signal('s', 'a{sv}', 'as')\n \n- if 'connection' in options:\n- raise ChannelError('protocol-error', message='connection sharing is not implemented on this bridge')\n+ @Interface.Method('v', 'ss')\n+ def get(self, interface, name):\n+ return self._find_member(interface, 'properties', name).to_dbus(self)\n \n- connection = self.create_client(options)\n+ @Interface.Method(['a{sv}'], 's')\n+ def get_all(self, interface):\n+ properties = self._find_category(interface, 'properties')\n+ return {name: prop.to_dbus(self) for name, prop in properties.items()}\n \n- self.ready()\n+ @Interface.Method('', 'ssv')\n+ def set(self, interface, name, value):\n+ self._find_member(interface, 'properties', name).from_dbus(self, value)\n \n- body = b''\n- while True:\n- data = await self.read()\n- if data is None:\n- break\n- body += data\n \n- # Connect in a thread and handle errors\n- try:\n- await self.in_thread(self.connect, connection, get_str(options, 'unix', None))\n- except ssl.SSLCertVerificationError as exc:\n- raise ChannelError('unknown-hostkey', message=str(exc)) from exc\n- except (OSError, IOError) as exc:\n- raise ChannelError('not-found', message=str(exc)) from exc\n+class Object(org_freedesktop_DBus_Introspectable,\n+ org_freedesktop_DBus_Peer,\n+ org_freedesktop_DBus_Properties,\n+ BaseObject,\n+ Interface):\n+ \"\"\"High-level base class for exporting objects on D-Bus\n \n- # Submit request in a thread and handle errors\n- try:\n- response = await self.in_thread(self.request, connection, method, path, headers or {}, body)\n- except (http.client.HTTPException, OSError) as exc:\n- raise ChannelError('terminated', message=str(exc)) from exc\n+ This is usually where you should start.\n \n- self.send_control(command='response',\n- status=response.status,\n- reason=response.reason,\n- headers=self.get_headers(response, binary=binary))\n+ This provides a base for exporting objects on the bus, implements the\n+ standard D-Bus interfaces, and allows you to add your own interfaces to the\n+ mix. See the documentation for Interface to find out how to define and\n+ implement your D-Bus interface.\n+ \"\"\"\n+ def message_received(self, message: BusMessage) -> bool:\n+ interface = message.get_interface()\n+ name = message.get_member()\n \n- # Receive the body and finish up\n try:\n- while True:\n- block = await self.in_thread(response.read1, self.BLOCK_SIZE)\n- if not block:\n- break\n- await self.write(block)\n-\n- logger.debug('reading response done')\n- # this returns immediately and does not read anything more, but updates the http.client's\n- # internal state machine to \"response done\"\n- block = response.read()\n- assert block == b''\n-\n- await self.in_thread(connection.close)\n- except (http.client.HTTPException, OSError) as exc:\n- raise ChannelError('terminated', message=str(exc)) from exc\n-\n- self.done()\n+ method = self._find_member(interface, 'methods', name)\n+ assert isinstance(method, Interface.Method)\n+ return method._invoke(self, message)\n+ except Object.Method.Unhandled:\n+ return False\n ''',\n- 'cockpit/channels/packages.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2022 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import logging\n-from typing import Optional\n-\n-from ..channel import AsyncChannel\n-from ..data import read_cockpit_data_file\n-from ..jsonutil import JsonObject, get_dict, get_str\n-from ..packages import Packages\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-class PackagesChannel(AsyncChannel):\n- payload = 'http-stream1'\n- restrictions = [(\"internal\", \"packages\")]\n-\n- # used to carry data forward from open to done\n- options: Optional[JsonObject] = None\n-\n- def http_error(self, status: int, message: str) -> None:\n- template = read_cockpit_data_file('fail.html')\n- self.send_json(status=status, reason='ERROR', headers={'Content-Type': 'text/html; charset=utf-8'})\n- self.send_data(template.replace(b'@@message@@', message.encode('utf-8')))\n- self.done()\n- self.close()\n-\n- async def run(self, options: JsonObject) -> None:\n- packages: Packages = self.router.packages # type: ignore[attr-defined] # yes, this is evil\n-\n- try:\n- if get_str(options, 'method') != 'GET':\n- raise ValueError(f'Unsupported HTTP method {options[\"method\"]}')\n-\n- self.ready()\n- if await self.read() is not None:\n- raise ValueError('Received unexpected data')\n-\n- path = get_str(options, 'path')\n- headers = get_dict(options, 'headers')\n- document = packages.load_path(path, headers)\n-\n- # Note: we can't cache documents right now. See\n- # https://github.com/cockpit-project/cockpit/issues/19071\n- # for future plans.\n- out_headers = {\n- 'Cache-Control': 'no-cache, no-store',\n- 'Content-Type': document.content_type,\n- }\n-\n- if document.content_encoding is not None:\n- out_headers['Content-Encoding'] = document.content_encoding\n-\n- if document.content_security_policy is not None:\n- policy = document.content_security_policy\n-\n- # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src\n- #\n- # Note: connect-src 'self' does not resolve to websocket\n- # schemes in all browsers, more info in this issue.\n- #\n- # https://github.com/w3c/webappsec-csp/issues/7\n- if \"connect-src 'self';\" in policy:\n- protocol = headers.get('X-Forwarded-Proto')\n- host = headers.get('X-Forwarded-Host')\n- if not isinstance(protocol, str) or not isinstance(host, str):\n- raise ValueError('Invalid host or protocol header')\n-\n- websocket_scheme = \"wss\" if protocol == \"https\" else \"ws\"\n- websocket_origin = f\"{websocket_scheme}://{host}\"\n- policy = policy.replace(\"connect-src 'self';\", f\"connect-src {websocket_origin} 'self';\")\n+ 'cockpit/_vendor/systemd_ctypes/py.typed': br'''''',\n+ 'cockpit/_vendor/bei/tmpfs.py': br'''import os\n+import subprocess\n+import sys\n+import tempfile\n \n- out_headers['Content-Security-Policy'] = policy\n \n- except ValueError as exc:\n- self.http_error(400, str(exc))\n+def main(*command: str) -> None:\n+ with tempfile.TemporaryDirectory() as tmpdir:\n+ os.chdir(tmpdir)\n \n- except KeyError:\n- self.http_error(404, 'Not found')\n+ for key, value in __loader__.get_contents().items():\n+ if key.startswith('tmpfs/'):\n+ subdir = os.path.dirname(key)\n+ os.makedirs(subdir, exist_ok=True)\n+ with open(key, 'wb') as fp:\n+ fp.write(value)\n \n- except OSError as exc:\n- self.http_error(500, f'Internal error: {exc!s}')\n+ os.chdir('tmpfs')\n \n- else:\n- self.send_json(status=200, reason='OK', headers=out_headers)\n- await self.sendfile(document.data)\n+ result = subprocess.run(command, check=False)\n+ sys.exit(result.returncode)\n ''',\n- 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit.\n+ 'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python\n #\n-# Copyright (C) 2022 Red Hat, Inc.\n+# Copyright (C) 2022 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-# Missing stuff compared to the C bridge that we should probably add:\n-#\n-# - removing matches\n-# - removing watches\n-# - emitting of signals\n-# - publishing of objects\n-# - failing more gracefully in some cases (during open, etc)\n-#\n-# Stuff we might or might not do:\n-#\n-# - using non-default service names\n-#\n-# Stuff we should probably not do:\n-#\n-# - emulation of ObjectManager via recursive introspection\n-# - automatic detection of ObjectManager below the given path_namespace\n-# - recursive scraping of properties for new object paths\n-# (for path_namespace watches that don't hit an ObjectManager)\n-\n-import asyncio\n-import errno\n-import json\n-import logging\n-import traceback\n-import xml.etree.ElementTree as ET\n-\n-from cockpit._vendor import systemd_ctypes\n-from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection\n-\n-from ..channel import Channel, ChannelError\n-\n-logger = logging.getLogger(__name__)\n-\n-# The dbusjson3 payload\n-#\n-# This channel payload type translates JSON encoded messages on a\n-# Cockpit channel to D-Bus messages, in a mostly straightforward way.\n-# See doc/protocol.md for a description of the basics.\n-#\n-# However, dbusjson3 offers some advanced features as well that are\n-# meant to support the \"magic\" DBusProxy objects implemented by\n-# cockpit.js. Those proxy objects \"magically\" expose all the methods\n-# and properties of a D-Bus interface without requiring any explicit\n-# binding code to be generated for a JavaScript client. A dbusjson3\n-# channel does this by doing automatic introspection and property\n-# retrieval without much direction from the JavaScript client.\n-#\n-# The details of what exactly is done is not specified very strictly,\n-# and the Python bridge will likely differ from the C bridge\n-# significantly. This will be informed by what existing code actually\n-# needs, and we might end up with a more concrete description of what\n-# a client can actually expect.\n-#\n-# Here is an example of a more complex scenario:\n-#\n-# - The client adds a \"watch\" for a path namespace. There is a\n-# ObjectManager at the given path and the bridge emits \"meta\" and\n-# \"notify\" messages to describe all interfaces and objects reported\n-# by that ObjectManager.\n-#\n-# - The client makes a method call that causes a new object with a new\n-# interface to appear at the ObjectManager. The bridge will send a\n-# \"meta\" and \"notify\" message to describe this new object.\n-#\n-# - Since the InterfacesAdded signal was emitted before the method\n-# reply, the bridge must send the \"meta\" and \"notify\" messages\n-# before the method reply message.\n-#\n-# - However, in order to construct the \"meta\" message, the bridge must\n-# perform a Introspect call, and consequently must delay sending the\n-# method reply until that call has finished.\n-#\n-# The Python bridge implements this delaying of messages with\n-# coroutines and a fair mutex. Every message coming from D-Bus will\n-# wait on the mutex for its turn to send its message on the Cockpit\n-# channel, and will keep that mutex locked until it is done with\n-# sending. Since the mutex is fair, everyone will nicely wait in line\n-# without messages getting re-ordered.\n-#\n-# The scenario above will play out like this:\n-#\n-# - While adding the initial \"watch\", the lock is held until the\n-# \"meta\" and \"notify\" messages have been sent.\n-#\n-# - Later, when the InterfacesAdded signal comes in that has been\n-# triggered by the method call, the mutex will be locked while the\n-# necessary introspection is going on.\n-#\n-# - The method reply will likely come while the mutex is locked, and\n-# the task for sending that reply on the Cockpit channel will enter\n-# the wait queue of the mutex.\n-#\n-# - Once the introspection is done and the new \"meta\" and \"notify\"\n-# messages have been sent, the mutex is unlocked, the method reply\n-# task acquires it, and sends its message.\n-\n-\n-class InterfaceCache:\n- def __init__(self):\n- self.cache = {}\n- self.old = set() # Interfaces already returned by get_interface_if_new\n-\n- def inject(self, interfaces):\n- self.cache.update(interfaces)\n-\n- async def introspect_path(self, bus, destination, object_path):\n- xml, = await bus.call_method_async(destination, object_path,\n- 'org.freedesktop.DBus.Introspectable',\n- 'Introspect')\n-\n- et = ET.fromstring(xml)\n-\n- interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')}\n-\n- # Add all interfaces we found: we might use them later\n- self.inject(interfaces)\n-\n- return interfaces\n-\n- async def get_interface(self, interface_name, bus=None, destination=None, object_path=None):\n- try:\n- return self.cache[interface_name]\n- except KeyError:\n- pass\n-\n- if bus and object_path:\n- try:\n- await self.introspect_path(bus, destination, object_path)\n- except BusError:\n- pass\n-\n- return self.cache.get(interface_name)\n+# along with this program. If not, see .\n \n- async def get_interface_if_new(self, interface_name, bus, destination, object_path):\n- if interface_name in self.old:\n- return None\n- self.old.add(interface_name)\n- return await self.get_interface(interface_name, bus, destination, object_path)\n+import argparse\n+import binascii\n+import lzma\n+import os\n+import sys\n+import tempfile\n+import zipfile\n+from typing import Dict, Iterable, List, Optional, Set, Tuple\n \n- async def get_signature(self, interface_name, method, bus=None, destination=None, object_path=None):\n- interface = await self.get_interface(interface_name, bus, destination, object_path)\n- if interface is None:\n- raise KeyError(f'Interface {interface_name} is not found')\n+from .data import read_data_file\n \n- return ''.join(interface['methods'][method]['in'])\n \n+def escape_string(data: str) -> str:\n+ # Avoid mentioning ' ' ' literally, to make our own packing a bit prettier\n+ triplequote = \"'\" * 3\n+ if triplequote not in data:\n+ return \"r\" + triplequote + data + triplequote\n+ if '\"\"\"' not in data:\n+ return 'r\"\"\"' + data + '\"\"\"'\n+ return repr(data)\n \n-def notify_update(notify, path, interface_name, props):\n- notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()}\n \n+def ascii_bytes_repr(data: bytes) -> str:\n+ return 'b' + escape_string(data.decode('ascii'))\n \n-class DBusChannel(Channel):\n- json_encoder = systemd_ctypes.JSONEncoder(indent=2)\n- payload = 'dbus-json3'\n \n- matches = None\n- name = None\n- bus = None\n- owner = None\n+def utf8_bytes_repr(data: bytes) -> str:\n+ return escape_string(data.decode('utf-8')) + \".encode('utf-8')\"\n \n- async def setup_name_owner_tracking(self):\n- def send_owner(owner):\n- # We must be careful not to send duplicate owner\n- # notifications. cockpit.js relies on that.\n- if self.owner != owner:\n- self.owner = owner\n- self.send_json(owner=owner)\n \n- def handler(message):\n- _name, _old, new = message.get_body()\n- send_owner(owner=new if new != \"\" else None)\n- self.add_signal_handler(handler,\n- sender='org.freedesktop.DBus',\n- path='/org/freedesktop/DBus',\n- interface='org.freedesktop.DBus',\n- member='NameOwnerChanged',\n- arg0=self.name)\n- try:\n- unique_name, = await self.bus.call_method_async(\"org.freedesktop.DBus\",\n- \"/org/freedesktop/DBus\",\n- \"org.freedesktop.DBus\",\n- \"GetNameOwner\", \"s\", self.name)\n- except BusError as error:\n- if error.name == \"org.freedesktop.DBus.Error.NameHasNoOwner\":\n- # Try to start it. If it starts successfully, we will\n- # get a NameOwnerChanged signal (which will set\n- # self.owner) before StartServiceByName returns.\n- try:\n- await self.bus.call_method_async(\"org.freedesktop.DBus\",\n- \"/org/freedesktop/DBus\",\n- \"org.freedesktop.DBus\",\n- \"StartServiceByName\", \"su\", self.name, 0)\n- except BusError as start_error:\n- logger.debug(\"Failed to start service '%s': %s\", self.name, start_error.message)\n- self.send_json(owner=None)\n- else:\n- logger.debug(\"Failed to get owner of service '%s': %s\", self.name, error.message)\n- else:\n- send_owner(unique_name)\n+def base64_bytes_repr(data: bytes, imports: Set[str]) -> str:\n+ # base85 is smaller, but base64 is in C, and ~20x faster.\n+ # when compressing with `xz -e` the size difference is marginal.\n+ imports.add('from binascii import a2b_base64')\n+ encoded = binascii.b2a_base64(data).decode('ascii').strip()\n+ return f'a2b_base64(\"{encoded}\")'\n \n- def do_open(self, options):\n- self.cache = InterfaceCache()\n- self.name = options.get('name')\n- self.matches = []\n \n- bus = options.get('bus')\n- address = options.get('address')\n+def bytes_repr(data: bytes, imports: Set[str]) -> str:\n+ # Strategy:\n+ # if the file is ascii, encode it directly as bytes\n+ # otherwise, if it's UTF-8, use a unicode string and encode\n+ # otherwise, base64\n \n- try:\n- if address is not None:\n- if bus is not None and bus != 'none':\n- raise ChannelError('protocol-error', message='only one of \"bus\" and \"address\" can be specified')\n- logger.debug('get bus with address %s for %s', address, self.name)\n- self.bus = Bus.new(address=address, bus_client=self.name is not None)\n- elif bus == 'internal':\n- logger.debug('get internal bus for %s', self.name)\n- self.bus = self.router.internal_bus.client\n- else:\n- if bus == 'session':\n- logger.debug('get session bus for %s', self.name)\n- self.bus = Bus.default_user()\n- elif bus == 'system' or bus is None:\n- logger.debug('get system bus for %s', self.name)\n- self.bus = Bus.default_system()\n- else:\n- raise ChannelError('protocol-error', message=f'invalid bus \"{bus}\"')\n- except OSError as exc:\n- raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc\n+ try:\n+ return ascii_bytes_repr(data)\n+ except UnicodeDecodeError:\n+ # it's not ascii\n+ pass\n \n- try:\n- self.bus.attach_event(None, 0)\n- except OSError as err:\n- if err.errno != errno.EBUSY:\n- raise\n+ # utf-8\n+ try:\n+ return utf8_bytes_repr(data)\n+ except UnicodeDecodeError:\n+ # it's not utf-8\n+ pass\n \n- # This needs to be a fair mutex so that outgoing messages don't\n- # get re-ordered. asyncio.Lock is fair.\n- self.watch_processing_lock = asyncio.Lock()\n+ return base64_bytes_repr(data, imports)\n \n- if self.name is not None:\n- async def get_ready():\n- async with self.watch_processing_lock:\n- await self.setup_name_owner_tracking()\n- if self.owner:\n- self.ready(unique_name=self.owner)\n- else:\n- self.close({'problem': 'not-found'})\n- self.create_task(get_ready())\n- else:\n- self.ready()\n \n- def add_signal_handler(self, handler, **kwargs):\n- r = dict(**kwargs)\n- r['type'] = 'signal'\n- if 'sender' not in r and self.name is not None:\n- r['sender'] = self.name\n- # HACK - https://github.com/bus1/dbus-broker/issues/309\n- # path_namespace='/' in a rule does not work.\n- if r.get('path_namespace') == \"/\":\n- del r['path_namespace']\n+def dict_repr(contents: Dict[str, bytes], imports: Set[str]) -> str:\n+ return ('{\\n' +\n+ ''.join(f' {repr(k)}: {bytes_repr(v, imports)},\\n'\n+ for k, v in contents.items()) +\n+ '}')\n \n- def filter_owner(message):\n- if self.owner is not None and self.owner == message.get_sender():\n- handler(message)\n \n- if self.name is not None and 'sender' in r and r['sender'] == self.name:\n- func = filter_owner\n- else:\n- func = handler\n- r_string = ','.join(f\"{key}='{value}'\" for key, value in r.items())\n- if not self.is_closing():\n- # this gets an EINTR very often especially on RHEL 8\n- while True:\n- try:\n- match = self.bus.add_match(r_string, func)\n- break\n- except InterruptedError:\n- pass\n+def pack(contents: Dict[str, bytes],\n+ entrypoint: Optional[str] = None,\n+ args: str = '') -> str:\n+ \"\"\"Creates a beipack with the given `contents`.\n \n- self.matches.append(match)\n+ If `entrypoint` is given, it should be an entry point which is run as the\n+ \"main\" function. It is given in the `package.module:func format` such that\n+ the following code is emitted:\n \n- def add_async_signal_handler(self, handler, **kwargs):\n- def sync_handler(message):\n- self.create_task(handler(message))\n- self.add_signal_handler(sync_handler, **kwargs)\n+ from package.module import func as main\n+ main()\n \n- async def do_call(self, message):\n- path, iface, method, args = message['call']\n- cookie = message.get('id')\n- flags = message.get('flags')\n+ Additionally, if `args` is given, it is written verbatim between the parens\n+ of the call to main (ie: it should already be in Python syntax).\n+ \"\"\"\n \n- timeout = message.get('timeout')\n- if timeout is not None:\n- # sd_bus timeout is \u03bcs, cockpit API timeout is ms\n- timeout *= 1000\n- else:\n- # sd_bus has no \"indefinite\" timeout, so use MAX_UINT64\n- timeout = 2 ** 64 - 1\n+ loader = read_data_file('beipack_loader.py')\n+ lines = [line for line in loader.splitlines() if line]\n+ lines.append('')\n \n- # We have to figure out the signature of the call. Either we got told it:\n- signature = message.get('type')\n+ imports = {'import sys'}\n+ contents_txt = dict_repr(contents, imports)\n+ lines.extend(imports)\n+ lines.append(f'sys.meta_path.insert(0, BeipackLoader({contents_txt}))')\n \n- # ... or there aren't any arguments\n- if signature is None and len(args) == 0:\n- signature = ''\n+ if entrypoint:\n+ package, main = entrypoint.split(':')\n+ lines.append(f'from {package} import {main} as main')\n+ lines.append(f'main({args})')\n \n- # ... or we need to introspect\n- if signature is None:\n- try:\n- logger.debug('Doing introspection request for %s %s', iface, method)\n- signature = await self.cache.get_signature(iface, method, self.bus, self.name, path)\n- except BusError as error:\n- self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie)\n- return\n- except KeyError:\n- self.send_json(\n- error=[\n- \"org.freedesktop.DBus.Error.UnknownMethod\",\n- [f\"Introspection data for method {iface} {method} not available\"]],\n- id=cookie)\n- return\n- except Exception as exc:\n- self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie)\n- return\n+ return ''.join(f'{line}\\n' for line in lines)\n \n- try:\n- method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args)\n- reply = await self.bus.call_async(method_call, timeout=timeout)\n- # If the method call has kicked off any signals related to\n- # watch processing, wait for that to be done.\n- async with self.watch_processing_lock:\n- # TODO: stop hard-coding the endian flag here.\n- self.send_json(\n- reply=[reply.get_body()], id=cookie,\n- flags=\"<\" if flags is not None else None,\n- type=reply.get_signature(True)) # noqa: FBT003\n- except BusError as error:\n- # actually, should send the fields from the message body\n- self.send_json(error=[error.name, [error.message]], id=cookie)\n- except Exception:\n- logger.exception(\"do_call(%s): generic exception\", message)\n- self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie)\n \n- async def do_add_match(self, message):\n- add_match = message['add-match']\n- logger.debug('adding match %s', add_match)\n+def collect_contents(filenames: List[str],\n+ relative_to: Optional[str] = None) -> Dict[str, bytes]:\n+ contents: Dict[str, bytes] = {}\n \n- async def match_hit(message):\n- logger.debug('got match')\n- async with self.watch_processing_lock:\n- self.send_json(signal=[\n- message.get_path(),\n- message.get_interface(),\n- message.get_member(),\n- list(message.get_body())\n- ])\n+ for filename in filenames:\n+ with open(filename, 'rb') as file:\n+ contents[os.path.relpath(filename, start=relative_to)] = file.read()\n \n- self.add_async_signal_handler(match_hit, **add_match)\n+ return contents\n \n- async def setup_objectmanager_watch(self, path, interface_name, meta, notify):\n- # Watch the objects managed by the ObjectManager at \"path\".\n- # Properties are not watched, that is done by setup_path_watch\n- # below via recursive_props == True.\n \n- async def handler(message):\n- member = message.get_member()\n- if member == \"InterfacesAdded\":\n- (path, interface_props) = message.get_body()\n- logger.debug('interfaces added %s %s', path, interface_props)\n- meta = {}\n- notify = {}\n- async with self.watch_processing_lock:\n- for name, props in interface_props.items():\n- if interface_name is None or name == interface_name:\n- mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path)\n- if mm:\n- meta.update({name: mm})\n- notify_update(notify, path, name, props)\n- self.send_json(meta=meta)\n- self.send_json(notify=notify)\n- elif member == \"InterfacesRemoved\":\n- (path, interfaces) = message.get_body()\n- logger.debug('interfaces removed %s %s', path, interfaces)\n- async with self.watch_processing_lock:\n- notify = {path: dict.fromkeys(interfaces)}\n- self.send_json(notify=notify)\n+def collect_module(name: str, *, recursive: bool) -> Dict[str, bytes]:\n+ import importlib.resources\n+ from importlib.resources.abc import Traversable\n \n- self.add_async_signal_handler(handler,\n- path=path,\n- interface=\"org.freedesktop.DBus.ObjectManager\")\n- objects, = await self.bus.call_method_async(self.name, path,\n- 'org.freedesktop.DBus.ObjectManager',\n- 'GetManagedObjects')\n- for p, ifaces in objects.items():\n- for iface, props in ifaces.items():\n- if interface_name is None or iface == interface_name:\n- mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p)\n- if mm:\n- meta.update({iface: mm})\n- notify_update(notify, p, iface, props)\n+ def walk(path: str, entry: Traversable) -> Iterable[Tuple[str, bytes]]:\n+ for item in entry.iterdir():\n+ itemname = f'{path}/{item.name}'\n+ if item.is_file():\n+ yield itemname, item.read_bytes()\n+ elif recursive and item.name != '__pycache__':\n+ yield from walk(itemname, item)\n \n- async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify):\n- # Watch a single object at \"path\", but maybe also watch for\n- # property changes for all objects below \"path\".\n+ return dict(walk(name.replace('.', '/'), importlib.resources.files(name)))\n \n- async def handler(message):\n- async with self.watch_processing_lock:\n- path = message.get_path()\n- name, props, invalids = message.get_body()\n- logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids)\n- for inv in invalids:\n- try:\n- reply, = await self.bus.call_method_async(self.name, path,\n- 'org.freedesktop.DBus.Properties', 'Get',\n- 'ss', name, inv)\n- except BusError as exc:\n- logger.debug('failed to fetch property %s.%s on %s %s: %s',\n- name, inv, self.name, path, str(exc))\n- continue\n- props[inv] = reply\n- notify = {}\n- notify_update(notify, path, name, props)\n- self.send_json(notify=notify)\n \n- this_meta = await self.cache.introspect_path(self.bus, self.name, path)\n- if interface_name is not None:\n- interface = this_meta.get(interface_name)\n- this_meta = {interface_name: interface}\n- meta.update(this_meta)\n- if recursive_props:\n- self.add_async_signal_handler(handler,\n- interface=\"org.freedesktop.DBus.Properties\",\n- path_namespace=path)\n- else:\n- self.add_async_signal_handler(handler,\n- interface=\"org.freedesktop.DBus.Properties\",\n- path=path)\n+def collect_zip(filename: str) -> Dict[str, bytes]:\n+ contents = {}\n \n- for name in meta:\n- if name.startswith(\"org.freedesktop.DBus.\"):\n+ with zipfile.ZipFile(filename) as file:\n+ for entry in file.filelist:\n+ if '.dist-info/' in entry.filename:\n continue\n- try:\n- props, = await self.bus.call_method_async(self.name, path,\n- 'org.freedesktop.DBus.Properties',\n- 'GetAll', 's', name)\n- notify_update(notify, path, name, props)\n- except BusError:\n- pass\n-\n- async def do_watch(self, message):\n- watch = message['watch']\n- path = watch.get('path')\n- path_namespace = watch.get('path_namespace')\n- interface_name = watch.get('interface')\n- cookie = message.get('id')\n-\n- path = path or path_namespace\n- recursive = path == path_namespace\n-\n- if path is None or cookie is None:\n- logger.debug('ignored incomplete watch request %s', message)\n- self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie)\n- self.send_json(reply=[], id=cookie)\n- return\n-\n- try:\n- async with self.watch_processing_lock:\n- meta = {}\n- notify = {}\n- await self.setup_path_watch(path, interface_name, recursive, meta, notify)\n- if recursive:\n- await self.setup_objectmanager_watch(path, interface_name, meta, notify)\n- self.send_json(meta=meta)\n- self.send_json(notify=notify)\n- self.send_json(reply=[], id=message['id'])\n- except BusError as error:\n- logger.debug(\"do_watch(%s) caught D-Bus error: %s\", message, error.message)\n- self.send_json(error=[error.name, [error.message]], id=cookie)\n+ contents[entry.filename] = file.read(entry)\n \n- async def do_meta(self, message):\n- self.cache.inject(message['meta'])\n+ return contents\n \n- def do_data(self, data):\n- message = json.loads(data)\n- logger.debug('receive dbus request %s %s', self.name, message)\n \n- if 'call' in message:\n- self.create_task(self.do_call(message))\n- elif 'add-match' in message:\n- self.create_task(self.do_add_match(message))\n- elif 'watch' in message:\n- self.create_task(self.do_watch(message))\n- elif 'meta' in message:\n- self.create_task(self.do_meta(message))\n- else:\n- logger.debug('ignored dbus request %s', message)\n- return\n+def collect_pep517(path: str) -> Dict[str, bytes]:\n+ with tempfile.TemporaryDirectory() as tmpdir:\n+ import build\n+ builder = build.ProjectBuilder(path)\n+ wheel = builder.build('wheel', tmpdir)\n+ return collect_zip(wheel)\n \n- def do_close(self):\n- for slot in self.matches:\n- slot.cancel()\n- self.matches = []\n- self.close()\n-'''.encode('utf-8'),\n- 'cockpit/channels/trivial.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2022 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n \n-import logging\n+def main() -> None:\n+ parser = argparse.ArgumentParser()\n+ parser.add_argument('--python', '-p',\n+ help=\"add a #!python3 interpreter line using the given path\")\n+ parser.add_argument('--xz', '-J', action='store_true',\n+ help=\"compress the output with `xz`\")\n+ parser.add_argument('--topdir',\n+ help=\"toplevel directory (paths are stored relative to this)\")\n+ parser.add_argument('--output', '-o',\n+ help=\"write output to a file (default: stdout)\")\n+ parser.add_argument('--main', '-m', metavar='MODULE:FUNC',\n+ help=\"use FUNC from MODULE as the main function\")\n+ parser.add_argument('--main-args', metavar='ARGS',\n+ help=\"arguments to main() in Python syntax\", default='')\n+ parser.add_argument('--module', action='append', default=[],\n+ help=\"collect installed modules (recursively)\")\n+ parser.add_argument('--zip', '-z', action='append', default=[],\n+ help=\"include files from a zipfile (or wheel)\")\n+ parser.add_argument('--build', metavar='DIR', action='append', default=[],\n+ help=\"PEP-517 from a given source directory\")\n+ parser.add_argument('files', nargs='*',\n+ help=\"files to include in the beipack\")\n+ args = parser.parse_args()\n \n-from ..channel import Channel\n+ contents = collect_contents(args.files, relative_to=args.topdir)\n \n-logger = logging.getLogger(__name__)\n+ for file in args.zip:\n+ contents.update(collect_zip(file))\n \n+ for name in args.module:\n+ contents.update(collect_module(name, recursive=True))\n \n-class EchoChannel(Channel):\n- payload = 'echo'\n+ for path in args.build:\n+ contents.update(collect_pep517(path))\n \n- def do_open(self, options):\n- self.ready()\n+ result = pack(contents, args.main, args.main_args).encode('utf-8')\n \n- def do_data(self, data):\n- self.send_data(data)\n+ if args.python:\n+ result = b'#!' + args.python.encode('ascii') + b'\\n' + result\n \n- def do_done(self):\n- self.done()\n- self.close()\n+ if args.xz:\n+ result = lzma.compress(result, preset=lzma.PRESET_EXTREME)\n \n+ if args.output:\n+ with open(args.output, 'wb') as file:\n+ file.write(result)\n+ else:\n+ if args.xz and os.isatty(1):\n+ sys.exit('refusing to write compressed output to a terminal')\n+ sys.stdout.buffer.write(result)\n \n-class NullChannel(Channel):\n- payload = 'null'\n \n- def do_open(self, options):\n- self.ready()\n-\n- def do_close(self):\n- self.close()\n+if __name__ == '__main__':\n+ main()\n ''',\n- 'cockpit/channels/stream.py': br'''# This file is part of Cockpit.\n+ 'cockpit/_vendor/bei/beiboot.py': br\"\"\"# beiboot - Remote bootloader for Python\n #\n-# Copyright (C) 2022 Red Hat, Inc.\n+# Copyright (C) 2022 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n+# along with this program. If not, see .\n \n+import argparse\n import asyncio\n-import logging\n import os\n+import shlex\n import subprocess\n-from typing import Dict\n-\n-from ..channel import ChannelError, ProtocolChannel\n-from ..jsonutil import JsonDict, JsonObject, get_bool, get_enum, get_int, get_object, get_str, get_strv\n-from ..transports import SubprocessProtocol, SubprocessTransport, WindowSize\n-\n-logger = logging.getLogger(__name__)\n-\n-\n-class SocketStreamChannel(ProtocolChannel):\n- payload = 'stream'\n-\n- async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport:\n- if 'unix' in options and 'port' in options:\n- raise ChannelError('protocol-error', message='cannot specify both \"port\" and \"unix\" options')\n-\n- try:\n- # Unix\n- if 'unix' in options:\n- path = get_str(options, 'unix')\n- label = f'Unix socket {path}'\n- transport, _ = await loop.create_unix_connection(lambda: self, path)\n-\n- # TCP\n- elif 'port' in options:\n- port = get_int(options, 'port')\n- host = get_str(options, 'address', 'localhost')\n- label = f'TCP socket {host}:{port}'\n-\n- transport, _ = await loop.create_connection(lambda: self, host, port)\n- else:\n- raise ChannelError('protocol-error',\n- message='no \"port\" or \"unix\" or other address option for channel')\n-\n- logger.debug('SocketStreamChannel: connected to %s', label)\n- except OSError as error:\n- logger.info('SocketStreamChannel: connecting to %s failed: %s', label, error)\n- if isinstance(error, ConnectionRefusedError):\n- problem = 'not-found'\n- else:\n- problem = 'terminated'\n- raise ChannelError(problem, message=str(error)) from error\n- self.close_on_eof()\n- assert isinstance(transport, asyncio.Transport)\n- return transport\n-\n-\n-class SubprocessStreamChannel(ProtocolChannel, SubprocessProtocol):\n- payload = 'stream'\n- restrictions = (('spawn', None),)\n-\n- def process_exited(self) -> None:\n- self.close_on_eof()\n-\n- def _get_close_args(self) -> JsonObject:\n- assert isinstance(self._transport, SubprocessTransport)\n- args: JsonDict = {'exit-status': self._transport.get_returncode()}\n- stderr = self._transport.get_stderr()\n- if stderr is not None:\n- args['message'] = stderr\n- return args\n-\n- def do_options(self, options):\n- window = get_object(options, 'window', WindowSize, None)\n- if window is not None:\n- self._transport.set_window_size(window)\n-\n- async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> SubprocessTransport:\n- args = get_strv(options, 'spawn')\n- err = get_enum(options, 'err', ['out', 'ignore', 'message'], 'message')\n- cwd = get_str(options, 'directory', '.')\n- pty = get_bool(options, 'pty', default=False)\n- window = get_object(options, 'window', WindowSize, None)\n- environ = get_strv(options, 'environ', [])\n-\n- if err == 'out':\n- stderr = subprocess.STDOUT\n- elif err == 'ignore':\n- stderr = subprocess.DEVNULL\n- else:\n- stderr = subprocess.PIPE\n-\n- env: Dict[str, str] = dict(os.environ)\n- try:\n- env.update(dict(e.split('=', 1) for e in environ))\n- except ValueError:\n- raise ChannelError('protocol-error', message='invalid \"environ\" option for stream channel') from None\n-\n- try:\n- transport = SubprocessTransport(loop, self, args, pty=pty, window=window, env=env, cwd=cwd, stderr=stderr)\n- logger.debug('Spawned process args=%s pid=%i', args, transport.get_pid())\n- return transport\n- except FileNotFoundError as error:\n- raise ChannelError('not-found') from error\n- except PermissionError as error:\n- raise ChannelError('access-denied') from error\n- except OSError as error:\n- logger.info(\"Failed to spawn %s: %s\", args, str(error))\n- raise ChannelError('internal-error') from error\n-''',\n- 'cockpit/channels/__init__.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2022 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-from .dbus import DBusChannel\n-from .filesystem import FsInfoChannel, FsListChannel, FsReadChannel, FsReplaceChannel, FsWatchChannel\n-from .http import HttpChannel\n-from .metrics import InternalMetricsChannel\n-from .packages import PackagesChannel\n-from .stream import SocketStreamChannel, SubprocessStreamChannel\n-from .trivial import EchoChannel, NullChannel\n-\n-CHANNEL_TYPES = [\n- DBusChannel,\n- EchoChannel,\n- FsInfoChannel,\n- FsListChannel,\n- FsReadChannel,\n- FsReplaceChannel,\n- FsWatchChannel,\n- HttpChannel,\n- InternalMetricsChannel,\n- NullChannel,\n- PackagesChannel,\n- SubprocessStreamChannel,\n- SocketStreamChannel,\n-]\n-''',\n- 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit.\n-#\n-# Copyright (C) 2022 Red Hat, Inc.\n-#\n-# This program is free software: you can redistribute it and/or modify\n-# it under the terms of the GNU General Public License as published by\n-# the Free Software Foundation, either version 3 of the License, or\n-# (at your option) any later version.\n-#\n-# This program is distributed in the hope that it will be useful,\n-# but WITHOUT ANY WARRANTY; without even the implied warranty of\n-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n-# GNU General Public License for more details.\n-#\n-# You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import asyncio\n-import json\n-import logging\n import sys\n-import time\n-from collections import defaultdict\n-from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union\n-\n-from ..channel import AsyncChannel, ChannelError\n-from ..jsonutil import JsonList\n-from ..samples import SAMPLERS, SampleDescription, Sampler, Samples\n+import threading\n+from typing import IO, List, Sequence, Tuple\n \n-logger = logging.getLogger(__name__)\n+from .bootloader import make_bootloader\n \n \n-class MetricInfo(NamedTuple):\n- derive: Optional[str]\n- desc: SampleDescription\n+def get_python_command(local: bool = False,\n+ tty: bool = False,\n+ sh: bool = False) -> Sequence[str]:\n+ interpreter = sys.executable if local else 'python3'\n+ command: Sequence[str]\n \n+ if tty:\n+ command = (interpreter, '-iq')\n+ else:\n+ command = (\n+ interpreter, '-ic',\n+ # https://github.com/python/cpython/issues/93139\n+ '''\" - beiboot - \"; import sys; sys.ps1 = ''; sys.ps2 = '';'''\n+ )\n \n-class InternalMetricsChannel(AsyncChannel):\n- payload = 'metrics1'\n- restrictions = [('source', 'internal')]\n+ if sh:\n+ command = (' '.join(shlex.quote(arg) for arg in command),)\n \n- metrics: List[MetricInfo]\n- samplers: Set\n- samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None\n+ return command\n \n- interval: int = 1000\n- need_meta: bool = True\n- last_timestamp: float = 0\n- next_timestamp: float = 0\n \n- @classmethod\n- def ensure_samplers(cls):\n- if cls.samplers_cache is None:\n- cls.samplers_cache = {desc.name: (sampler, desc) for sampler in SAMPLERS for desc in sampler.descriptions}\n+def get_ssh_command(*args: str, tty: bool = False) -> Sequence[str]:\n+ return ('ssh',\n+ *(['-t'] if tty else ()),\n+ *args,\n+ *get_python_command(tty=tty, sh=True))\n \n- def parse_options(self, options):\n- logger.debug('metrics internal open: %s, channel: %s', options, self.channel)\n \n- interval = options.get('interval', self.interval)\n- if not isinstance(interval, int) or interval <= 0 or interval > sys.maxsize:\n- raise ChannelError('protocol-error', message=f'invalid \"interval\" value: {interval}')\n+def get_container_command(*args: str, tty: bool = False) -> Sequence[str]:\n+ return ('podman', 'exec', '--interactive',\n+ *(['--tty'] if tty else ()),\n+ *args,\n+ *get_python_command(tty=tty))\n \n- self.interval = interval\n \n- metrics = options.get('metrics')\n- if not isinstance(metrics, list) or len(metrics) == 0:\n- logger.error('invalid \"metrics\" value: %s', metrics)\n- raise ChannelError('protocol-error', message='invalid \"metrics\" option was specified (not an array)')\n+def get_command(*args: str, tty: bool = False, sh: bool = False) -> Sequence[str]:\n+ return (*args, *get_python_command(local=True, tty=tty, sh=sh))\n \n- sampler_classes = set()\n- for metric in metrics:\n- # validate it's an object\n- name = metric.get('name')\n- units = metric.get('units')\n- derive = metric.get('derive')\n \n- try:\n- sampler, desc = self.samplers_cache[name]\n- except KeyError as exc:\n- logger.error('unsupported metric: %s', name)\n- raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc\n+def splice_in_thread(src: int, dst: IO[bytes]) -> None:\n+ def _thread() -> None:\n+ # os.splice() only in Python 3.10\n+ with dst:\n+ block_size = 1 << 20\n+ while True:\n+ data = os.read(src, block_size)\n+ if not data:\n+ break\n+ dst.write(data)\n+ dst.flush()\n \n- if units and units != desc.units:\n- raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}')\n+ threading.Thread(target=_thread, daemon=True).start()\n \n- sampler_classes.add(sampler)\n- self.metrics.append(MetricInfo(derive=derive, desc=desc))\n \n- self.samplers = {cls() for cls in sampler_classes}\n+def send_and_splice(command: Sequence[str], script: bytes) -> None:\n+ with subprocess.Popen(command, stdin=subprocess.PIPE) as proc:\n+ assert proc.stdin is not None\n+ proc.stdin.write(script)\n \n- def send_meta(self, samples: Samples, timestamp: float):\n- metrics: JsonList = []\n- for metricinfo in self.metrics:\n- if metricinfo.desc.instanced:\n- metrics.append({\n- 'name': metricinfo.desc.name,\n- 'units': metricinfo.desc.units,\n- 'instances': list(samples[metricinfo.desc.name].keys()),\n- 'semantics': metricinfo.desc.semantics\n- })\n- else:\n- metrics.append({\n- 'name': metricinfo.desc.name,\n- 'derive': metricinfo.derive, # type: ignore[dict-item]\n- 'units': metricinfo.desc.units,\n- 'semantics': metricinfo.desc.semantics\n- })\n+ splice_in_thread(0, proc.stdin)\n+ sys.exit(proc.wait())\n \n- self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics)\n- self.need_meta = False\n \n- def sample(self):\n- samples = defaultdict(dict)\n- for sampler in self.samplers:\n- sampler.sample(samples)\n- return samples\n+def send_xz_and_splice(command: Sequence[str], script: bytes) -> None:\n+ import ferny\n \n- def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Union[float, bool]:\n- if old_value is not None and self.last_timestamp:\n- return (value - old_value) / (self.next_timestamp - self.last_timestamp)\n- else:\n- return False\n+ class Responder(ferny.InteractionResponder):\n+ async def do_custom_command(self,\n+ command: str,\n+ args: Tuple,\n+ fds: List[int],\n+ stderr: str) -> None:\n+ assert proc.stdin is not None\n+ if command == 'beiboot.provide':\n+ proc.stdin.write(script)\n+ proc.stdin.flush()\n \n- def send_updates(self, samples: Samples, last_samples: Samples):\n- data: List[Union[float, List[Optional[Union[float, bool]]]]] = []\n- timestamp = time.time()\n- self.next_timestamp = timestamp\n+ agent = ferny.InteractionAgent(Responder())\n+ with subprocess.Popen(command, stdin=subprocess.PIPE, stderr=agent) as proc:\n+ assert proc.stdin is not None\n+ proc.stdin.write(make_bootloader([\n+ ('boot_xz', ('script.py.xz', len(script), [], True)),\n+ ], gadgets=ferny.BEIBOOT_GADGETS).encode())\n+ proc.stdin.flush()\n \n- for metricinfo in self.metrics:\n- value = samples[metricinfo.desc.name]\n+ asyncio.run(agent.communicate())\n+ splice_in_thread(0, proc.stdin)\n+ sys.exit(proc.wait())\n \n- if metricinfo.desc.instanced:\n- old_value = last_samples[metricinfo.desc.name]\n- assert isinstance(value, dict)\n- assert isinstance(old_value, dict)\n \n- # If we have less or more keys the data changed, send a meta message.\n- if value.keys() != old_value.keys():\n- self.need_meta = True\n+def main() -> None:\n+ parser = argparse.ArgumentParser()\n+ parser.add_argument('--sh', action='store_true',\n+ help='Pass Python interpreter command as shell-script')\n+ parser.add_argument('--xz', help=\"the xz to run remotely\")\n+ parser.add_argument('--script',\n+ help=\"the script to run remotely (must be repl-friendly)\")\n+ parser.add_argument('command', nargs='*')\n \n- if metricinfo.derive == 'rate':\n- instances: List[Optional[Union[float, bool]]] = []\n- for key, val in value.items():\n- instances.append(self.calculate_sample_rate(val, old_value.get(key)))\n+ args = parser.parse_args()\n+ tty = not args.script and os.isatty(0)\n \n- data.append(instances)\n- else:\n- data.append(list(value.values()))\n- else:\n- old_value = last_samples.get(metricinfo.desc.name)\n- assert not isinstance(value, dict)\n- assert not isinstance(old_value, dict)\n+ if args.command == []:\n+ command = get_python_command(tty=tty)\n+ elif args.command[0] == 'ssh':\n+ command = get_ssh_command(*args.command[1:], tty=tty)\n+ elif args.command[0] == 'container':\n+ command = get_container_command(*args.command[1:], tty=tty)\n+ else:\n+ command = get_command(*args.command, tty=tty, sh=args.sh)\n \n- if metricinfo.derive == 'rate':\n- data.append(self.calculate_sample_rate(value, old_value))\n- else:\n- data.append(value)\n+ if args.script:\n+ with open(args.script, 'rb') as file:\n+ script = file.read()\n \n- if self.need_meta:\n- self.send_meta(samples, timestamp)\n+ send_and_splice(command, script)\n \n- self.last_timestamp = self.next_timestamp\n- self.send_data(json.dumps([data]).encode())\n+ elif args.xz:\n+ with open(args.xz, 'rb') as file:\n+ script = file.read()\n \n- async def run(self, options):\n- self.metrics = []\n- self.samplers = set()\n+ send_xz_and_splice(command, script)\n \n- InternalMetricsChannel.ensure_samplers()\n+ else:\n+ # If we're streaming from stdin then this is a lot easier...\n+ os.execlp(command[0], *command)\n \n- self.parse_options(options)\n- self.ready()\n+ # Otherwise, \"full strength\"\n \n- last_samples = defaultdict(dict)\n- while True:\n- samples = self.sample()\n- self.send_updates(samples, last_samples)\n- last_samples = samples\n- await asyncio.sleep(self.interval / 1000)\n-''',\n- 'cockpit/channels/filesystem.py': r'''# This file is part of Cockpit.\n+if __name__ == '__main__':\n+ main()\n+\"\"\",\n+ 'cockpit/_vendor/bei/__init__.py': br'''''',\n+ 'cockpit/_vendor/bei/bootloader.py': br'''# beiboot - Remote bootloader for Python\n #\n-# Copyright (C) 2022 Red Hat, Inc.\n+# Copyright (C) 2023 Allison Karlitskaya \n #\n # This program is free software: you can redistribute it and/or modify\n # it under the terms of the GNU General Public License as published by\n # the Free Software Foundation, either version 3 of the License, or\n # (at your option) any later version.\n #\n # This program is distributed in the hope that it will be useful,\n # but WITHOUT ANY WARRANTY; without even the implied warranty of\n # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n # GNU General Public License for more details.\n #\n # You should have received a copy of the GNU General Public License\n-# along with this program. If not, see .\n-\n-import asyncio\n-import contextlib\n-import enum\n-import errno\n-import fnmatch\n-import functools\n-import grp\n-import logging\n-import os\n-import pwd\n-import re\n-import stat\n-import tempfile\n-from pathlib import Path\n-from typing import Callable, Iterable, Iterator\n-\n-from cockpit._vendor.systemd_ctypes import Handle, PathWatch\n-from cockpit._vendor.systemd_ctypes.inotify import Event as InotifyEvent\n-from cockpit._vendor.systemd_ctypes.pathwatch import Listener as PathWatchListener\n-\n-from ..channel import AsyncChannel, Channel, ChannelError, GeneratorChannel\n-from ..jsonutil import (\n- JsonDict,\n- JsonDocument,\n- JsonError,\n- JsonObject,\n- get_bool,\n- get_enum,\n- get_int,\n- get_str,\n- get_strv,\n- json_merge_and_filter_patch,\n-)\n-\n-logger = logging.getLogger(__name__)\n+# along with this program. If not, see .\n \n+import textwrap\n+from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple\n \n-@functools.lru_cache()\n-def my_umask() -> int:\n- match = re.search(r'^Umask:\\s*0([0-7]*)$', Path('/proc/self/status').read_text(), re.M)\n- return (match and int(match.group(1), 8)) or 0o077\n+GADGETS = {\n+ \"_frame\": r\"\"\"\n+ import sys\n+ import traceback\n+ try:\n+ ...\n+ except SystemExit:\n+ raise\n+ except BaseException:\n+ command('beiboot.exc', traceback.format_exc())\n+ sys.exit(37)\n+ \"\"\",\n+ \"try_exec\": r\"\"\"\n+ import contextlib\n+ import os\n+ def try_exec(argv):\n+ with contextlib.suppress(OSError):\n+ os.execvp(argv[0], argv)\n+ \"\"\",\n+ \"boot_xz\": r\"\"\"\n+ import lzma\n+ import sys\n+ def boot_xz(filename, size, args=[], send_end=False):\n+ command('beiboot.provide', size)\n+ src_xz = sys.stdin.buffer.read(size)\n+ src = lzma.decompress(src_xz)\n+ sys.argv = [filename, *args]\n+ if send_end:\n+ end()\n+ exec(src, {\n+ '__name__': '__main__',\n+ '__self_source__': src_xz,\n+ '__file__': filename})\n+ sys.exit()\n+ \"\"\",\n+}\n \n \n-def tag_from_stat(buf):\n- return f'1:{buf.st_ino}-{buf.st_mtime}-{buf.st_mode:o}-{buf.st_uid}-{buf.st_gid}'\n+def split_code(code: str, imports: Set[str]) -> Iterable[Tuple[str, str]]:\n+ for line in textwrap.dedent(code).splitlines():\n+ text = line.lstrip(\" \")\n+ if text.startswith(\"import \"):\n+ imports.add(text)\n+ elif text:\n+ spaces = len(line) - len(text)\n+ assert (spaces % 4) == 0\n+ yield \"\\t\" * (spaces // 4), text\n \n \n-def tag_from_path(path):\n- try:\n- return tag_from_stat(os.stat(path))\n- except FileNotFoundError:\n- return '-'\n- except OSError:\n- return None\n+def yield_body(user_gadgets: Dict[str, str],\n+ steps: Sequence[Tuple[str, Sequence[object]]],\n+ imports: Set[str]) -> Iterable[Tuple[str, str]]:\n+ # Allow the caller to override our gadgets, but keep the original\n+ # variable for use in the next step.\n+ gadgets = dict(GADGETS, **user_gadgets)\n \n+ # First emit the gadgets. Emit all gadgets provided by the caller,\n+ # plus any referred to by the caller's list of steps.\n+ provided_gadgets = set(user_gadgets)\n+ step_gadgets = {name for name, _args in steps}\n+ for name in provided_gadgets | step_gadgets:\n+ yield from split_code(gadgets[name], imports)\n \n-def tag_from_fd(fd):\n- try:\n- return tag_from_stat(os.fstat(fd))\n- except OSError:\n- return None\n+ # Yield functions mentioned in steps from the caller\n+ for name, args in steps:\n+ yield '', name + repr(tuple(args))\n \n \n-class FsListChannel(Channel):\n- payload = 'fslist1'\n+def make_bootloader(steps: Sequence[Tuple[str, Sequence[object]]],\n+ gadgets: Optional[Dict[str, str]] = None) -> str:\n+ imports: Set[str] = set()\n+ lines: List[str] = []\n \n- def send_entry(self, event, entry):\n- if entry.is_symlink():\n- mode = 'link'\n- elif entry.is_file():\n- mode = 'file'\n- elif entry.is_dir():\n- mode = 'directory'\n+ for frame_spaces, frame_text in split_code(GADGETS[\"_frame\"], imports):\n+ if frame_text == \"...\":\n+ for spaces, text in yield_body(gadgets or {}, steps, imports):\n+ lines.append(frame_spaces + spaces + text)\n else:\n- mode = 'special'\n-\n- self.send_json(event=event, path=entry.name, type=mode)\n-\n- def do_open(self, options):\n- path = options.get('path')\n- watch = options.get('watch', True)\n-\n- if watch:\n- raise ChannelError('not-supported', message='watching is not implemented, use fswatch1')\n-\n- try:\n- scan_dir = os.scandir(path)\n- except FileNotFoundError as error:\n- raise ChannelError('not-found', message=str(error)) from error\n- except PermissionError as error:\n- raise ChannelError('access-denied', message=str(error)) from error\n- except OSError as error:\n- raise ChannelError('internal-error', message=str(error)) from error\n+ lines.append(frame_spaces + frame_text)\n \n- self.ready()\n- for entry in scan_dir:\n- self.send_entry(\"present\", entry)\n+ return \"\".join(f\"{line}\\n\" for line in [*imports, *lines]) + \"\\n\"\n+''',\n+ 'cockpit/_vendor/bei/spawn.py': br'''\"\"\"Helper to create a beipack to spawn a command with files in a tmpdir\"\"\"\n \n- if not watch:\n- self.done()\n- self.close()\n+import argparse\n+import os\n+import sys\n \n+from . import pack, tmpfs\n \n-class FsReadChannel(GeneratorChannel):\n- payload = 'fsread1'\n \n- def do_yield_data(self, options: JsonObject) -> Iterator[bytes]:\n- path = get_str(options, 'path')\n- binary = get_enum(options, 'binary', ['raw'], None) is not None\n- max_read_size = get_int(options, 'max_read_size', None)\n+def main() -> None:\n+ parser = argparse.ArgumentParser()\n+ parser.add_argument('--file', '-f', action='append')\n+ parser.add_argument('command', nargs='+', help='The command to execute')\n+ args = parser.parse_args()\n \n- logger.debug('Opening file \"%s\" for reading', path)\n+ contents = {\n+ '_beitmpfs.py': tmpfs.__spec__.loader.get_data(tmpfs.__spec__.origin)\n+ }\n \n- try:\n- with open(path, 'rb') as filep:\n- buf = os.stat(filep.fileno())\n- if max_read_size is not None and buf.st_size > max_read_size:\n- raise ChannelError('too-large')\n+ if args.file is not None:\n+ files = args.file\n+ else:\n+ file = args.command[-1]\n+ files = [file]\n+ args.command[-1] = './' + os.path.basename(file)\n \n- if binary and stat.S_ISREG(buf.st_mode):\n- self.ready(size_hint=buf.st_size)\n- else:\n- self.ready()\n+ for filename in files:\n+ with open(filename, 'rb') as file:\n+ basename = os.path.basename(filename)\n+ contents[f'tmpfs/{basename}'] = file.read()\n \n- while True:\n- data = filep.read1(Channel.BLOCK_SIZE)\n- if data == b'':\n- break\n- logger.debug(' ...sending %d bytes', len(data))\n- if not binary:\n- data = data.replace(b'\\0', b'').decode('utf-8', errors='ignore').encode('utf-8')\n- yield data\n+ script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command))\n+ sys.stdout.write(script)\n \n- return {'tag': tag_from_stat(buf)}\n \n- except FileNotFoundError:\n- return {'tag': '-'}\n- except PermissionError as exc:\n- raise ChannelError('access-denied') from exc\n- except OSError as exc:\n- raise ChannelError('internal-error', message=str(exc)) from exc\n+if __name__ == '__main__':\n+ main()\n+''',\n+ 'cockpit/_vendor/bei/data/__init__.py': br'''import sys\n \n+if sys.version_info >= (3, 9):\n+ import importlib.abc\n+ import importlib.resources\n \n-class FsReplaceChannel(AsyncChannel):\n- payload = 'fsreplace1'\n+ def read_data_file(filename: str) -> str:\n+ return (importlib.resources.files(__name__) / filename).read_text()\n+else:\n+ def read_data_file(filename: str) -> str:\n+ loader = __loader__ # type: ignore[name-defined]\n+ data = loader.get_data(__file__.replace('__init__.py', filename))\n+ return data.decode('utf-8')\n+''',\n+ 'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack\n \n- def delete(self, path: str, tag: 'str | None') -> str:\n- if tag is not None and tag != tag_from_path(path):\n- raise ChannelError('change-conflict')\n- with contextlib.suppress(FileNotFoundError): # delete is idempotent\n- os.unlink(path)\n- return '-'\n+import importlib.abc\n+import importlib.util\n+import io\n+import sys\n+from types import ModuleType\n+from typing import BinaryIO, Dict, Iterator, Optional, Sequence\n \n- async def set_contents(self, path: str, tag: 'str | None', data: 'bytes | None', size: 'int | None') -> str:\n- dirname, basename = os.path.split(path)\n- tmpname: str | None\n- fd, tmpname = tempfile.mkstemp(dir=dirname, prefix=f'.{basename}-')\n- try:\n- if size is not None:\n- logger.debug('fallocate(%s.tmp, %d)', path, size)\n- if size: # posix_fallocate() of 0 bytes is EINVAL\n- await self.in_thread(os.posix_fallocate, fd, 0, size)\n- self.ready() # ...only after that worked\n \n- written = 0\n- while data is not None:\n- await self.in_thread(os.write, fd, data)\n- written += len(data)\n- data = await self.read()\n+class BeipackLoader(importlib.abc.SourceLoader, importlib.abc.MetaPathFinder):\n+ if sys.version_info >= (3, 11):\n+ from importlib.resources.abc import ResourceReader as AbstractResourceReader\n+ else:\n+ AbstractResourceReader = object\n \n- if size is not None and written < size:\n- logger.debug('ftruncate(%s.tmp, %d)', path, written)\n- await self.in_thread(os.ftruncate, fd, written)\n+ class ResourceReader(AbstractResourceReader):\n+ def __init__(self, contents: Dict[str, bytes], filename: str) -> None:\n+ self._contents = contents\n+ self._dir = f'{filename}/'\n \n- await self.in_thread(os.fdatasync, fd)\n+ def is_resource(self, resource: str) -> bool:\n+ return f'{self._dir}{resource}' in self._contents\n \n- if tag is None:\n- # no preconditions about what currently exists or not\n- # calculate the file mode from the umask\n- os.fchmod(fd, 0o666 & ~my_umask())\n- os.rename(tmpname, path)\n- tmpname = None\n+ def open_resource(self, resource: str) -> BinaryIO:\n+ return io.BytesIO(self._contents[f'{self._dir}{resource}'])\n \n- elif tag == '-':\n- # the file must not exist. file mode from umask.\n- os.fchmod(fd, 0o666 & ~my_umask())\n- os.link(tmpname, path) # will fail if file exists\n+ def resource_path(self, resource: str) -> str:\n+ raise FileNotFoundError\n \n- else:\n- # the file must exist with the given tag\n- buf = os.stat(path)\n- if tag != tag_from_stat(buf):\n- raise ChannelError('change-conflict')\n- # chown/chmod from the existing file permissions\n- os.fchmod(fd, stat.S_IMODE(buf.st_mode))\n- os.fchown(fd, buf.st_uid, buf.st_gid)\n- os.rename(tmpname, path)\n- tmpname = None\n+ def contents(self) -> Iterator[str]:\n+ dir_length = len(self._dir)\n+ result = set()\n \n- finally:\n- os.close(fd)\n- if tmpname is not None:\n- os.unlink(tmpname)\n+ for filename in self._contents:\n+ if filename.startswith(self._dir):\n+ try:\n+ next_slash = filename.index('/', dir_length)\n+ except ValueError:\n+ next_slash = None\n+ result.add(filename[dir_length:next_slash])\n \n- return tag_from_path(path)\n+ return iter(result)\n \n- async def run(self, options: JsonObject) -> JsonObject:\n- path = get_str(options, 'path')\n- size = get_int(options, 'size', None)\n- tag = get_str(options, 'tag', None)\n+ contents: Dict[str, bytes]\n+ modules: Dict[str, str]\n \n+ def __init__(self, contents: Dict[str, bytes]) -> None:\n try:\n- # In the `size` case, .set_contents() sends the ready only after\n- # it knows that the allocate was successful. In the case without\n- # `size`, we need to send the ready() up front in order to\n- # receive the first frame and decide if we're creating or deleting.\n- if size is not None:\n- tag = await self.set_contents(path, tag, b'', size)\n- else:\n- self.ready()\n- data = await self.read()\n- # if we get EOF right away, that's a request to delete\n- if data is None:\n- tag = self.delete(path, tag)\n- else:\n- tag = await self.set_contents(path, tag, data, None)\n-\n- self.done()\n- return {'tag': tag}\n-\n- except FileNotFoundError as exc:\n- raise ChannelError('not-found') from exc\n- except FileExistsError as exc:\n- # that's from link() noticing that the target file already exists\n- raise ChannelError('change-conflict') from exc\n- except PermissionError as exc:\n- raise ChannelError('access-denied') from exc\n- except IsADirectoryError as exc:\n- # not ideal, but the closest code we have\n- raise ChannelError('access-denied', message=str(exc)) from exc\n- except OSError as exc:\n- raise ChannelError('internal-error', message=str(exc)) from exc\n-\n-\n-class FsWatchChannel(Channel):\n- payload = 'fswatch1'\n- _tag = None\n- _path = None\n- _watch = None\n-\n- # The C bridge doesn't send the initial event, and the JS calls read()\n- # instead to figure out the initial state of the file. If we send the\n- # initial state then we cause the event to get delivered twice.\n- # Ideally we'll sort that out at some point, but for now, suppress it.\n- _active = False\n-\n- @staticmethod\n- def mask_to_event_and_type(mask):\n- if (InotifyEvent.CREATE or InotifyEvent.MOVED_TO) in mask:\n- return 'created', 'directory' if InotifyEvent.ISDIR in mask else 'file'\n- elif InotifyEvent.MOVED_FROM in mask or InotifyEvent.DELETE in mask or InotifyEvent.DELETE_SELF in mask:\n- return 'deleted', None\n- elif InotifyEvent.ATTRIB in mask:\n- return 'attribute-changed', None\n- elif InotifyEvent.CLOSE_WRITE in mask:\n- return 'done-hint', None\n- else:\n- return 'changed', None\n-\n- def do_inotify_event(self, mask, _cookie, name):\n- logger.debug(\"do_inotify_event(%s): mask %X name %s\", self._path, mask, name)\n- event, type_ = self.mask_to_event_and_type(mask)\n- if name:\n- # file inside watched directory changed\n- path = os.path.join(self._path, name.decode())\n- tag = tag_from_path(path)\n- self.send_json(event=event, path=path, tag=tag, type=type_)\n- else:\n- # the watched path itself changed; filter out duplicate events\n- tag = tag_from_path(self._path)\n- if tag == self._tag:\n- return\n- self._tag = tag\n- self.send_json(event=event, path=self._path, tag=self._tag, type=type_)\n-\n- def do_identity_changed(self, fd, err):\n- logger.debug(\"do_identity_changed(%s): fd %s, err %s\", self._path, str(fd), err)\n- self._tag = tag_from_fd(fd) if fd else '-'\n- if self._active:\n- self.send_json(event='created' if fd else 'deleted', path=self._path, tag=self._tag)\n-\n- def do_open(self, options):\n- self._path = options['path']\n- self._tag = None\n-\n- self._active = False\n- self._watch = PathWatch(self._path, self)\n- self._active = True\n-\n- self.ready()\n-\n- def do_close(self):\n- self._watch.close()\n- self._watch = None\n- self.close()\n-\n-\n-class Follow(enum.Enum):\n- NO = False\n- YES = True\n-\n-\n-class FsInfoChannel(Channel, PathWatchListener):\n- payload = 'fsinfo'\n-\n- # Options (all get set in `do_open()`)\n- path: str\n- attrs: 'set[str]'\n- fnmatch: str\n- targets: bool\n- follow: bool\n- watch: bool\n-\n- # State\n- current_value: JsonDict\n- effective_fnmatch: str = ''\n- fd: 'Handle | None' = None\n- pending: 'set[str] | None' = None\n- path_watch: 'PathWatch | None' = None\n- getattrs: 'Callable[[int, str, Follow], JsonDocument]'\n-\n- @staticmethod\n- def make_getattrs(attrs: Iterable[str]) -> 'Callable[[int, str, Follow], JsonDocument | None]':\n- # Cached for the duration of the closure we're creating\n- @functools.lru_cache()\n- def get_user(uid: int) -> 'str | int':\n- try:\n- return pwd.getpwuid(uid).pw_name\n- except KeyError:\n- return uid\n-\n- @functools.lru_cache()\n- def get_group(gid: int) -> 'str | int':\n- try:\n- return grp.getgrgid(gid).gr_name\n- except KeyError:\n- return gid\n+ contents[__file__] = __self_source__ # type: ignore[name-defined]\n+ except NameError:\n+ pass\n \n- stat_types = {stat.S_IFREG: 'reg', stat.S_IFDIR: 'dir', stat.S_IFLNK: 'lnk', stat.S_IFCHR: 'chr',\n- stat.S_IFBLK: 'blk', stat.S_IFIFO: 'fifo', stat.S_IFSOCK: 'sock'}\n- available_stat_getters = {\n- 'type': lambda buf: stat_types.get(stat.S_IFMT(buf.st_mode)),\n- 'tag': tag_from_stat,\n- 'mode': lambda buf: stat.S_IMODE(buf.st_mode),\n- 'size': lambda buf: buf.st_size,\n- 'uid': lambda buf: buf.st_uid,\n- 'gid': lambda buf: buf.st_gid,\n- 'mtime': lambda buf: buf.st_mtime,\n- 'user': lambda buf: get_user(buf.st_uid),\n- 'group': lambda buf: get_group(buf.st_gid),\n+ self.contents = contents\n+ self.modules = {\n+ self.get_fullname(filename): filename\n+ for filename in contents\n+ if filename.endswith(\".py\")\n }\n- stat_getters = tuple((key, available_stat_getters.get(key, lambda _: None)) for key in attrs)\n-\n- def get_attrs(fd: int, name: str, follow: Follow) -> 'JsonDict | None':\n- try:\n- buf = os.stat(name, follow_symlinks=follow.value, dir_fd=fd) if name else os.fstat(fd)\n- except FileNotFoundError:\n- return None\n- except OSError:\n- return {name: None for name, func in stat_getters}\n-\n- result = {key: func(buf) for key, func in stat_getters}\n-\n- if 'target' in result and stat.S_IFMT(buf.st_mode) == stat.S_IFLNK:\n- with contextlib.suppress(OSError):\n- result['target'] = os.readlink(name, dir_fd=fd)\n-\n- return result\n-\n- return get_attrs\n-\n- def send_update(self, updates: JsonDict, *, reset: bool = False) -> None:\n- if reset:\n- if set(self.current_value) & set(updates):\n- # if we have an overlap, we need to do a proper reset\n- self.send_json(dict.fromkeys(self.current_value), partial=True)\n- self.current_value = {'partial': True}\n- updates.update(partial=None)\n- else:\n- # otherwise there's no overlap: we can just remove the old keys\n- updates.update(dict.fromkeys(self.current_value))\n-\n- json_merge_and_filter_patch(self.current_value, updates)\n- if updates:\n- self.send_json(updates)\n-\n- def process_update(self, updates: 'set[str]', *, reset: bool = False) -> None:\n- assert self.fd is not None\n-\n- entries: JsonDict = {name: self.getattrs(self.fd, name, Follow.NO) for name in updates}\n-\n- info = entries.pop('', {})\n- assert isinstance(info, dict) # fstat() will never fail with FileNotFoundError\n-\n- if self.effective_fnmatch:\n- info['entries'] = entries\n-\n- if self.targets:\n- info['targets'] = targets = {}\n- for name in {e.get('target') for e in entries.values() if isinstance(e, dict)}:\n- if isinstance(name, str) and ('/' in name or not self.interesting(name)):\n- # if this target is a string that we wouldn't otherwise\n- # report, then report it via our \"targets\" attribute.\n- targets[name] = self.getattrs(self.fd, name, Follow.YES)\n-\n- self.send_update({'info': info}, reset=reset)\n-\n- def process_pending_updates(self) -> None:\n- assert self.pending is not None\n- if self.pending:\n- self.process_update(self.pending)\n- self.pending = None\n-\n- def interesting(self, name: str) -> bool:\n- if name == '':\n- return True\n- else:\n- # only report updates on entry filenames if we match them\n- return fnmatch.fnmatch(name, self.effective_fnmatch)\n-\n- def schedule_update(self, name: str) -> None:\n- if not self.interesting(name):\n- return\n-\n- if self.pending is None:\n- asyncio.get_running_loop().call_later(0.1, self.process_pending_updates)\n- self.pending = set()\n-\n- self.pending.add(name)\n-\n- def report_error(self, err: int) -> None:\n- if err == errno.ENOENT:\n- problem = 'not-found'\n- elif err in (errno.EPERM, errno.EACCES):\n- problem = 'access-denied'\n- elif err == errno.ENOTDIR:\n- problem = 'not-directory'\n- else:\n- problem = 'internal-error'\n-\n- self.send_update({'error': {\n- 'problem': problem, 'message': os.strerror(err), 'errno': errno.errorcode[err]\n- }}, reset=True)\n-\n- def flag_onlydir_error(self, fd: Handle) -> bool:\n- # If our requested path ended with '/' then make sure we got a\n- # directory, or else it's an error. open() will have already flagged\n- # that for us, but systemd_ctypes doesn't do that (yet).\n- if not self.watch or not self.path.endswith('/'):\n- return False\n-\n- buf = os.fstat(fd) # this should never fail\n- if stat.S_IFMT(buf.st_mode) != stat.S_IFDIR:\n- self.report_error(errno.ENOTDIR)\n- return True\n-\n- return False\n-\n- def report_initial_state(self, fd: Handle) -> None:\n- if self.flag_onlydir_error(fd):\n- return\n-\n- self.fd = fd\n-\n- entries = {''}\n- if self.fnmatch:\n- try:\n- entries.update(os.listdir(f'/proc/self/fd/{self.fd}'))\n- self.effective_fnmatch = self.fnmatch\n- except OSError:\n- # If we failed to get an initial list, then report nothing from now on\n- self.effective_fnmatch = ''\n-\n- self.process_update({e for e in entries if self.interesting(e)}, reset=True)\n-\n- def do_inotify_event(self, mask: InotifyEvent, cookie: int, rawname: 'bytes | None') -> None:\n- logger.debug('do_inotify_event(%r, %r, %r)', mask, cookie, rawname)\n- name = (rawname or b'').decode(errors='surrogateescape')\n \n- self.schedule_update(name)\n-\n- if name and mask | (InotifyEvent.CREATE | InotifyEvent.DELETE |\n- InotifyEvent.MOVED_TO | InotifyEvent.MOVED_FROM):\n- # These events change the mtime of the directory\n- self.schedule_update('')\n+ def get_fullname(self, filename: str) -> str:\n+ assert filename.endswith(\".py\")\n+ filename = filename[:-3]\n+ if filename.endswith(\"/__init__\"):\n+ filename = filename[:-9]\n+ return filename.replace(\"/\", \".\")\n \n- def do_identity_changed(self, fd: 'Handle | None', err: 'int | None') -> None:\n- logger.debug('do_identity_changed(%r, %r)', fd, err)\n- # If there were previously pending changes, they are now irrelevant.\n- if self.pending is not None:\n- # Note: don't set to None, since the handler is still pending\n- self.pending.clear()\n+ def get_resource_reader(self, fullname: str) -> ResourceReader:\n+ return BeipackLoader.ResourceReader(self.contents, fullname.replace('.', '/'))\n \n- if err is None:\n- assert fd is not None\n- self.report_initial_state(fd)\n- else:\n- self.report_error(err)\n+ def get_data(self, path: str) -> bytes:\n+ return self.contents[path]\n \n- def do_close(self) -> None:\n- # non-watch channels close immediately \u2014 if we get this, we're watching\n- assert self.path_watch is not None\n- self.path_watch.close()\n- self.close()\n+ def get_filename(self, fullname: str) -> str:\n+ return self.modules[fullname]\n \n- def do_open(self, options: JsonObject) -> None:\n- self.path = get_str(options, 'path')\n- if not os.path.isabs(self.path):\n- raise JsonError(options, '\"path\" must be an absolute path')\n+ def find_spec(\n+ self,\n+ fullname: str,\n+ path: Optional[Sequence[str]],\n+ target: Optional[ModuleType] = None\n+ ) -> Optional[importlib.machinery.ModuleSpec]:\n+ if fullname not in self.modules:\n+ return None\n+ return importlib.util.spec_from_loader(fullname, self)\n+''',\n+ 'cockpit/data/fail.html': br'''\n+\n+\n+ @@message@@\n+ \n+ \n+ \n+\n+\n+
\n+ \n+

@@message@@

\n+
\n+\n+\n+''',\n+ 'cockpit/data/__init__.py': br'''import sys\n \n- attrs = set(get_strv(options, 'attrs'))\n- self.getattrs = self.make_getattrs(attrs - {'targets', 'entries'})\n- self.fnmatch = get_str(options, 'fnmatch', '*' if 'entries' in attrs else '')\n- self.targets = 'targets' in attrs\n- self.follow = get_bool(options, 'follow', default=True)\n- self.watch = get_bool(options, 'watch', default=False)\n- if self.watch and not self.follow:\n- raise JsonError(options, '\"watch: true\" and \"follow: false\" are (currently) incompatible')\n- if self.targets and not self.follow:\n- raise JsonError(options, '`targets: \"stat\"` and `follow: false` are (currently) incompatible')\n+if sys.version_info >= (3, 11):\n+ import importlib.resources\n \n- self.current_value = {}\n- self.ready()\n+ def read_cockpit_data_file(filename: str) -> bytes:\n+ return (importlib.resources.files('cockpit.data') / filename).read_bytes()\n \n- if not self.watch:\n- try:\n- fd = Handle.open(self.path, os.O_PATH if self.follow else os.O_PATH | os.O_NOFOLLOW)\n- except OSError as exc:\n- self.report_error(exc.errno)\n- else:\n- self.report_initial_state(fd)\n- fd.close()\n+else:\n+ import importlib.abc\n \n- self.done()\n- self.close()\n+ def read_cockpit_data_file(filename: str) -> bytes:\n+ # https://github.com/python/mypy/issues/4182\n+ loader = __loader__ # type: ignore[name-defined]\n+ assert isinstance(loader, importlib.abc.ResourceLoader)\n \n- else:\n- # PathWatch will call do_identity_changed(), which does the same as\n- # above: calls either report_initial_state() or report_error(),\n- # depending on if it was provided with an fd or an error code.\n- self.path_watch = PathWatch(self.path, self)\n-'''.encode('utf-8'),\n+ path = __file__.replace('__init__.py', filename)\n+ return loader.get_data(path)\n+''',\n }))\n from cockpit.bridge import main as main\n main(beipack=True)\n"}]}, {"source1": "./usr/lib/python3/dist-packages/cockpit-317.dist-info/direct_url.json", "source2": "./usr/lib/python3/dist-packages/cockpit-317.dist-info/direct_url.json", "unified_diff": null, "details": [{"source1": "Pretty-printed", "source2": "Pretty-printed", "comments": ["Similarity: 0.90625%", "Differences: {\"'archive_info'\": \"{'hash': \"", " \"'sha256=dec088452ec6f25fcf224728543a98272d3d6296c11d172cddcbef693df5d1a8', \"", " \"'hashes': {'sha256': \"", " \"'dec088452ec6f25fcf224728543a98272d3d6296c11d172cddcbef693df5d1a8'}}\"}"], "unified_diff": "@@ -1,9 +1,9 @@\n {\n \"archive_info\": {\n- \"hash\": \"sha256=706224e4acf3739a18e599033e60b05e24f2267635349e04d8cf7144c0a58444\",\n+ \"hash\": \"sha256=dec088452ec6f25fcf224728543a98272d3d6296c11d172cddcbef693df5d1a8\",\n \"hashes\": {\n- \"sha256\": \"706224e4acf3739a18e599033e60b05e24f2267635349e04d8cf7144c0a58444\"\n+ \"sha256\": \"dec088452ec6f25fcf224728543a98272d3d6296c11d172cddcbef693df5d1a8\"\n }\n },\n \"url\": \"file:///build/reproducible-path/cockpit-317/tmp/wheel/cockpit-317-py3-none-any.whl\"\n }\n"}]}]}]}]}]}