--- /srv/reproducible-results/rbuild-debian/r-b-build.YMJ09NhJ/b1/cockpit_311-1_i386.changes +++ /srv/reproducible-results/rbuild-debian/r-b-build.YMJ09NhJ/b2/cockpit_311-1_i386.changes ├── Files │ @@ -1,10 +1,10 @@ │ │ b5b06f08164e81593d5217a5d174292d 122484 debug optional cockpit-bridge-dbgsym_311-1_i386.deb │ - 3c716e1680bb286e83ec5a3eae87c983 362084 admin optional cockpit-bridge_311-1_i386.deb │ + b1df311acd7ae35edc9d402aabae66ad 361120 admin optional cockpit-bridge_311-1_i386.deb │ d6420f2f7547b4e22925ba79ff18803d 131364 doc optional cockpit-doc_311-1_all.deb │ 88b04dc126f5b100988e2bdf9812be7b 833092 admin optional cockpit-networkmanager_311-1_all.deb │ 270d4784b46c2aff32acab6a65b331f8 939100 admin optional cockpit-packagekit_311-1_all.deb │ 4feda8da1f498f882e610b575489ca41 197740 debug optional cockpit-pcp-dbgsym_311-1_i386.deb │ bb6a702d8bd1247451384f7ba0a36b44 86348 admin optional cockpit-pcp_311-1_i386.deb │ e3b2783c793e9b15a89ff5b04c527610 557948 admin optional cockpit-sosreport_311-1_all.deb │ cdf3488303d663c6fd12c06a5f2b8016 872112 admin optional cockpit-storaged_311-1_all.deb ├── cockpit-bridge_311-1_i386.deb │ ├── file list │ │ @@ -1,3 +1,3 @@ │ │ -rw-r--r-- 0 0 0 4 2024-02-15 08:39:16.000000 debian-binary │ │ --rw-r--r-- 0 0 0 3880 2024-02-15 08:39:16.000000 control.tar.xz │ │ --rw-r--r-- 0 0 0 358012 2024-02-15 08:39:16.000000 data.tar.xz │ │ +-rw-r--r-- 0 0 0 3884 2024-02-15 08:39:16.000000 control.tar.xz │ │ +-rw-r--r-- 0 0 0 357044 2024-02-15 08:39:16.000000 data.tar.xz │ ├── control.tar.xz │ │ ├── control.tar │ │ │ ├── ./control │ │ │ │ @@ -1,13 +1,13 @@ │ │ │ │ Package: cockpit-bridge │ │ │ │ Source: cockpit │ │ │ │ Version: 311-1 │ │ │ │ Architecture: i386 │ │ │ │ Maintainer: Utopia Maintenance Team │ │ │ │ -Installed-Size: 863 │ │ │ │ +Installed-Size: 862 │ │ │ │ Depends: libc6 (>= 2.34), libglib2.0-0 (>= 2.68.0), libjson-glib-1.0-0 (>= 1.5.2), libssh-4 (>= 0.10.4), libsystemd0 (>= 235), python3:any, glib-networking │ │ │ │ Recommends: openssh-client │ │ │ │ Breaks: cockpit-ws (<< 181.x) │ │ │ │ Replaces: cockpit-dashboard (<< 170.x), cockpit-ws (<< 181.x) │ │ │ │ Provides: cockpit-ssh │ │ │ │ Section: admin │ │ │ │ Priority: optional │ │ │ ├── ./md5sums │ │ │ │ ├── ./md5sums │ │ │ │ │┄ Files differ │ ├── data.tar.xz │ │ ├── data.tar │ │ │ ├── file list │ │ │ │ @@ -60,15 +60,15 @@ │ │ │ │ -rw-r--r-- 0 root (0) root (0) 6861 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/channels/metrics.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 4053 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/channels/packages.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 4827 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/channels/stream.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 1171 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/channels/trivial.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 3188 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/config.py │ │ │ │ drwxr-xr-x 0 root (0) root (0) 0 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/data/ │ │ │ │ -rw-r--r-- 0 root (0) root (0) 574 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/data/__init__.py │ │ │ │ --rw-r--r-- 0 root (0) root (0) 86628 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz │ │ │ │ +-rw-r--r-- 0 root (0) root (0) 85652 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz │ │ │ │ -rw-r--r-- 0 root (0) root (0) 3212 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/data/fail.html │ │ │ │ -rw-r--r-- 0 root (0) root (0) 5517 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/internal_endpoints.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 6742 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/jsonutil.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 21539 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/packages.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 12729 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/peer.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 7580 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/polkit.py │ │ │ │ -rw-r--r-- 0 root (0) root (0) 2031 2024-02-15 08:39:16.000000 ./usr/lib/python3/dist-packages/cockpit/polyfills.py │ │ │ ├── ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz │ │ │ │ ├── cockpit-bridge.beipack │ │ │ │ │┄ Ordering differences only │ │ │ │ │ @@ -64,15 +64,15 @@ │ │ │ │ │ ) -> Optional[importlib.machinery.ModuleSpec]: │ │ │ │ │ if fullname not in self.modules: │ │ │ │ │ return None │ │ │ │ │ return importlib.util.spec_from_loader(fullname, self) │ │ │ │ │ │ │ │ │ │ import sys │ │ │ │ │ sys.meta_path.insert(0, BeipackLoader({ │ │ │ │ │ - 'cockpit/superuser.py': br'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/peer.py': r'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ @@ -81,302 +81,596 @@ │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import array │ │ │ │ │ import asyncio │ │ │ │ │ -import contextlib │ │ │ │ │ -import getpass │ │ │ │ │ import logging │ │ │ │ │ import os │ │ │ │ │ -import socket │ │ │ │ │ -from tempfile import TemporaryDirectory │ │ │ │ │ -from typing import List, Optional, Sequence, Tuple │ │ │ │ │ - │ │ │ │ │ -from cockpit._vendor import ferny │ │ │ │ │ -from cockpit._vendor.bei.bootloader import make_bootloader │ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Variant, bus │ │ │ │ │ +from typing import Callable, List, Optional, Sequence │ │ │ │ │ │ │ │ │ │ -from .beipack import BridgeBeibootHelper │ │ │ │ │ -from .jsonutil import JsonObject, get_str │ │ │ │ │ +from .jsonutil import JsonObject, JsonValue │ │ │ │ │ from .packages import BridgeConfig │ │ │ │ │ -from .peer import ConfiguredPeer, Peer, PeerError │ │ │ │ │ -from .polkit import PolkitAgent │ │ │ │ │ -from .router import Router, RoutingError, RoutingRule │ │ │ │ │ +from .protocol import CockpitProblem, CockpitProtocol, CockpitProtocolError │ │ │ │ │ +from .router import Endpoint, Router, RoutingRule │ │ │ │ │ +from .transports import SubprocessProtocol, SubprocessTransport │ │ │ │ │ │ │ │ │ │ logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class SuperuserPeer(ConfiguredPeer): │ │ │ │ │ - responder: ferny.AskpassHandler │ │ │ │ │ +class PeerError(CockpitProblem): │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - def __init__(self, router: Router, config: BridgeConfig, responder: ferny.AskpassHandler): │ │ │ │ │ - super().__init__(router, config) │ │ │ │ │ - self.responder = responder │ │ │ │ │ │ │ │ │ │ - async def do_connect_transport(self) -> None: │ │ │ │ │ - async with contextlib.AsyncExitStack() as context: │ │ │ │ │ - if 'pkexec' in self.args: │ │ │ │ │ - logger.debug('connecting polkit superuser peer transport %r', self.args) │ │ │ │ │ - await context.enter_async_context(PolkitAgent(self.responder)) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug('connecting non-polkit superuser peer transport %r', self.args) │ │ │ │ │ +class PeerExited(Exception): │ │ │ │ │ + def __init__(self, exit_code: int): │ │ │ │ │ + self.exit_code = exit_code │ │ │ │ │ │ │ │ │ │ - responders: 'list[ferny.InteractionHandler]' = [self.responder] │ │ │ │ │ │ │ │ │ │ - if '# cockpit-bridge' in self.args: │ │ │ │ │ - logger.debug('going to beiboot superuser bridge %r', self.args) │ │ │ │ │ - helper = BridgeBeibootHelper(self, ['--privileged']) │ │ │ │ │ - responders.append(helper) │ │ │ │ │ - stage1 = make_bootloader(helper.steps, gadgets=ferny.BEIBOOT_GADGETS).encode() │ │ │ │ │ - else: │ │ │ │ │ - stage1 = None │ │ │ │ │ +class Peer(CockpitProtocol, SubprocessProtocol, Endpoint): │ │ │ │ │ + done_callbacks: List[Callable[[], None]] │ │ │ │ │ + init_future: Optional[asyncio.Future] │ │ │ │ │ │ │ │ │ │ - agent = ferny.InteractionAgent(responders) │ │ │ │ │ + def __init__(self, router: Router): │ │ │ │ │ + super().__init__(router) │ │ │ │ │ │ │ │ │ │ - if 'SUDO_ASKPASS=ferny-askpass' in self.env: │ │ │ │ │ - tmpdir = context.enter_context(TemporaryDirectory()) │ │ │ │ │ - ferny_askpass = ferny.write_askpass_to_tmpdir(tmpdir) │ │ │ │ │ - env: Sequence[str] = [f'SUDO_ASKPASS={ferny_askpass}'] │ │ │ │ │ - else: │ │ │ │ │ - env = self.env │ │ │ │ │ + # All Peers start out frozen — we only unfreeze after we see the first 'init' message │ │ │ │ │ + self.freeze_endpoint() │ │ │ │ │ │ │ │ │ │ - transport = await self.spawn(self.args, env, stderr=agent, start_new_session=True) │ │ │ │ │ + self.init_future = asyncio.get_running_loop().create_future() │ │ │ │ │ + self.done_callbacks = [] │ │ │ │ │ │ │ │ │ │ - if stage1 is not None: │ │ │ │ │ - transport.write(stage1) │ │ │ │ │ + # Initialization │ │ │ │ │ + async def do_connect_transport(self) -> None: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + async def spawn(self, argv: Sequence[str], env: Sequence[str], **kwargs) -> asyncio.Transport: │ │ │ │ │ + # Not actually async... │ │ │ │ │ + loop = asyncio.get_running_loop() │ │ │ │ │ + user_env = dict(e.split('=', 1) for e in env) │ │ │ │ │ + return SubprocessTransport(loop, self, argv, env=dict(os.environ, **user_env), **kwargs) │ │ │ │ │ + │ │ │ │ │ + async def start(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> JsonObject: │ │ │ │ │ + """Request that the Peer is started and connected to the router. │ │ │ │ │ + │ │ │ │ │ + Creates the transport, connects it to the protocol, and participates in │ │ │ │ │ + exchanging of init messages. If anything goes wrong, the connection │ │ │ │ │ + will be closed and an exception will be raised. │ │ │ │ │ + │ │ │ │ │ + The Peer starts out in a frozen state (ie: attempts to send messages to │ │ │ │ │ + it will initially be queued). If init_host is not None then an init │ │ │ │ │ + message is sent with the given 'host' field, plus any extra kwargs, and │ │ │ │ │ + the queue is thawed. Otherwise, the caller is responsible for sending │ │ │ │ │ + the init message and thawing the peer. │ │ │ │ │ │ │ │ │ │ + In any case, the return value is the init message from the peer. │ │ │ │ │ + """ │ │ │ │ │ + assert self.init_future is not None │ │ │ │ │ + │ │ │ │ │ + def _connect_task_done(task: asyncio.Task) -> None: │ │ │ │ │ + assert task is connect_task │ │ │ │ │ try: │ │ │ │ │ - await agent.communicate() │ │ │ │ │ - except ferny.InteractionError as exc: │ │ │ │ │ - raise PeerError('authentication-failed', message=str(exc)) from exc │ │ │ │ │ + task.result() │ │ │ │ │ + except asyncio.CancelledError: # we did that (below) │ │ │ │ │ + pass # we want to ignore it │ │ │ │ │ + except Exception as exc: │ │ │ │ │ + self.close(exc) │ │ │ │ │ │ │ │ │ │ + connect_task = asyncio.create_task(self.do_connect_transport()) │ │ │ │ │ + connect_task.add_done_callback(_connect_task_done) │ │ │ │ │ │ │ │ │ │ -class CockpitResponder(ferny.AskpassHandler): │ │ │ │ │ - commands = ('ferny.askpass', 'cockpit.send-stderr') │ │ │ │ │ + try: │ │ │ │ │ + # Wait for something to happen: │ │ │ │ │ + # - exception from our connection function │ │ │ │ │ + # - receiving "init" from the other side │ │ │ │ │ + # - receiving EOF from the other side │ │ │ │ │ + # - .close() was called │ │ │ │ │ + # - other transport exception │ │ │ │ │ + init_message = await self.init_future │ │ │ │ │ │ │ │ │ │ - async def do_custom_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None: │ │ │ │ │ - if command == 'cockpit.send-stderr': │ │ │ │ │ - with socket.socket(fileno=fds[0]) as sock: │ │ │ │ │ - fds.pop(0) │ │ │ │ │ - # socket.send_fds(sock, [b'\0'], [2]) # New in Python 3.9 │ │ │ │ │ - sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", [2]))]) │ │ │ │ │ + except (PeerExited, BrokenPipeError): │ │ │ │ │ + # These are fairly generic errors. PeerExited means that we observed the process exiting. │ │ │ │ │ + # BrokenPipeError means that we got EPIPE when attempting to write() to it. In both cases, │ │ │ │ │ + # the process is gone, but it's not clear why. If the connection process is still running, │ │ │ │ │ + # perhaps we'd get a better error message from it. │ │ │ │ │ + await connect_task │ │ │ │ │ + # Otherwise, re-raise │ │ │ │ │ + raise │ │ │ │ │ │ │ │ │ │ + finally: │ │ │ │ │ + self.init_future = None │ │ │ │ │ │ │ │ │ │ -class AuthorizeResponder(CockpitResponder): │ │ │ │ │ - def __init__(self, router: Router): │ │ │ │ │ - self.router = router │ │ │ │ │ + # In any case (failure or success) make sure this is done. │ │ │ │ │ + if not connect_task.done(): │ │ │ │ │ + connect_task.cancel() │ │ │ │ │ │ │ │ │ │ - async def do_askpass(self, messages: str, prompt: str, hint: str) -> str: │ │ │ │ │ - hexuser = ''.join(f'{c:02x}' for c in getpass.getuser().encode('ascii')) │ │ │ │ │ - return await self.router.request_authorization(f'plain1:{hexuser}') │ │ │ │ │ + if init_host is not None: │ │ │ │ │ + logger.debug(' sending init message back, host %s', init_host) │ │ │ │ │ + # Send "init" back │ │ │ │ │ + self.write_control(None, command='init', version=1, host=init_host, **kwargs) │ │ │ │ │ │ │ │ │ │ + # Thaw the queued messages │ │ │ │ │ + self.thaw_endpoint() │ │ │ │ │ │ │ │ │ │ -class SuperuserRoutingRule(RoutingRule, CockpitResponder, bus.Object, interface='cockpit.Superuser'): │ │ │ │ │ - superuser_configs: Sequence[BridgeConfig] = () │ │ │ │ │ - pending_prompt: Optional[asyncio.Future] │ │ │ │ │ - peer: Optional[SuperuserPeer] │ │ │ │ │ + return init_message │ │ │ │ │ │ │ │ │ │ - # D-Bus signals │ │ │ │ │ - prompt = bus.Interface.Signal('s', 's', 's', 'b', 's') # message, prompt, default, echo, error │ │ │ │ │ + # Background initialization │ │ │ │ │ + def start_in_background(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> None: │ │ │ │ │ + def _start_task_done(task: asyncio.Task) -> None: │ │ │ │ │ + assert task is start_task │ │ │ │ │ │ │ │ │ │ - # D-Bus properties │ │ │ │ │ - bridges = bus.Interface.Property('as', value=[]) │ │ │ │ │ - current = bus.Interface.Property('s', value='none') │ │ │ │ │ - methods = bus.Interface.Property('a{sv}', value={}) │ │ │ │ │ + try: │ │ │ │ │ + task.result() │ │ │ │ │ + except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError): │ │ │ │ │ + pass # Those are expected. Others will throw. │ │ │ │ │ │ │ │ │ │ - # RoutingRule │ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Peer]: │ │ │ │ │ - superuser = options.get('superuser') │ │ │ │ │ + start_task = asyncio.create_task(self.start(init_host, **kwargs)) │ │ │ │ │ + start_task.add_done_callback(_start_task_done) │ │ │ │ │ │ │ │ │ │ - if not superuser or self.current == 'root': │ │ │ │ │ - # superuser not requested, or already superuser? Next rule. │ │ │ │ │ - return None │ │ │ │ │ - elif self.peer or superuser == 'try': │ │ │ │ │ - # superuser requested and active? Return it. │ │ │ │ │ - # 'try' requested? Either return the peer, or None. │ │ │ │ │ - return self.peer │ │ │ │ │ + # Shutdown │ │ │ │ │ + def add_done_callback(self, callback: Callable[[], None]) -> None: │ │ │ │ │ + self.done_callbacks.append(callback) │ │ │ │ │ + │ │ │ │ │ + # Handling of interesting events │ │ │ │ │ + def do_superuser_init_done(self) -> None: │ │ │ │ │ + pass │ │ │ │ │ + │ │ │ │ │ + def do_authorize(self, message: JsonObject) -> None: │ │ │ │ │ + pass │ │ │ │ │ + │ │ │ │ │ + def transport_control_received(self, command: str, message: JsonObject) -> None: │ │ │ │ │ + if command == 'init' and self.init_future is not None: │ │ │ │ │ + logger.debug('Got init message with active init_future. Setting result.') │ │ │ │ │ + self.init_future.set_result(message) │ │ │ │ │ + elif command == 'authorize': │ │ │ │ │ + self.do_authorize(message) │ │ │ │ │ + elif command == 'superuser-init-done': │ │ │ │ │ + self.do_superuser_init_done() │ │ │ │ │ else: │ │ │ │ │ - # superuser requested, but not active? That's an error. │ │ │ │ │ - raise RoutingError('access-denied') │ │ │ │ │ + raise CockpitProtocolError(f'Received unexpected control message {command}') │ │ │ │ │ │ │ │ │ │ - # ferny.AskpassHandler │ │ │ │ │ - async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]: │ │ │ │ │ - assert self.pending_prompt is None │ │ │ │ │ - echo = hint == "confirm" │ │ │ │ │ - self.pending_prompt = asyncio.get_running_loop().create_future() │ │ │ │ │ - try: │ │ │ │ │ - logger.debug('prompting for %s', prompt) │ │ │ │ │ - # with sudo, all stderr messages are treated as warning/errors by the UI │ │ │ │ │ - # (such as the lecture or "wrong password"), so pass them in the "error" field │ │ │ │ │ - self.prompt('', prompt, '', echo, messages) │ │ │ │ │ - return await self.pending_prompt │ │ │ │ │ - finally: │ │ │ │ │ - self.pending_prompt = None │ │ │ │ │ + def eof_received(self) -> bool: │ │ │ │ │ + # We always expect to be the ones to close the connection, so if we get │ │ │ │ │ + # an EOF, then we consider it to be an error. This allows us to │ │ │ │ │ + # distinguish close caused by unexpected EOF (but no errno from a │ │ │ │ │ + # syscall failure) vs. close caused by calling .close() on our side. │ │ │ │ │ + # The process is still running at this point, so keep it and handle │ │ │ │ │ + # the error in process_exited(). │ │ │ │ │ + logger.debug('Peer %s received unexpected EOF', self.__class__.__name__) │ │ │ │ │ + return True │ │ │ │ │ │ │ │ │ │ - def __init__(self, router: Router, *, privileged: bool = False): │ │ │ │ │ - super().__init__(router) │ │ │ │ │ + def do_closed(self, exc: Optional[Exception]) -> None: │ │ │ │ │ + logger.debug('Peer %s connection lost %s %s', self.__class__.__name__, type(exc), exc) │ │ │ │ │ │ │ │ │ │ - self.pending_prompt = None │ │ │ │ │ - self.peer = None │ │ │ │ │ - self.startup = None │ │ │ │ │ + if exc is None: │ │ │ │ │ + self.shutdown_endpoint(problem='terminated') │ │ │ │ │ + elif isinstance(exc, PeerExited): │ │ │ │ │ + # a common case is that the called peer does not exist │ │ │ │ │ + if exc.exit_code == 127: │ │ │ │ │ + self.shutdown_endpoint(problem='no-cockpit') │ │ │ │ │ + else: │ │ │ │ │ + self.shutdown_endpoint(problem='terminated', message=f'Peer exited with status {exc.exit_code}') │ │ │ │ │ + elif isinstance(exc, CockpitProblem): │ │ │ │ │ + self.shutdown_endpoint(exc.attrs) │ │ │ │ │ + else: │ │ │ │ │ + self.shutdown_endpoint(problem='internal-error', │ │ │ │ │ + message=f"[{exc.__class__.__name__}] {exc!s}") │ │ │ │ │ │ │ │ │ │ - if privileged or os.getuid() == 0: │ │ │ │ │ - self.current = 'root' │ │ │ │ │ + # If .start() is running, we need to make sure it stops running, │ │ │ │ │ + # raising the correct exception. │ │ │ │ │ + if self.init_future is not None and not self.init_future.done(): │ │ │ │ │ + if exc is not None: │ │ │ │ │ + self.init_future.set_exception(exc) │ │ │ │ │ + else: │ │ │ │ │ + self.init_future.cancel() │ │ │ │ │ │ │ │ │ │ - def peer_done(self): │ │ │ │ │ - self.current = 'none' │ │ │ │ │ - self.peer = None │ │ │ │ │ + for callback in self.done_callbacks: │ │ │ │ │ + callback() │ │ │ │ │ │ │ │ │ │ - async def go(self, name: str, responder: ferny.AskpassHandler) -> None: │ │ │ │ │ - if self.current != 'none': │ │ │ │ │ - raise bus.BusError('cockpit.Superuser.Error', 'Superuser bridge already running') │ │ │ │ │ + def process_exited(self) -> None: │ │ │ │ │ + assert isinstance(self.transport, SubprocessTransport) │ │ │ │ │ + logger.debug('Peer %s exited, status %d', self.__class__.__name__, self.transport.get_returncode()) │ │ │ │ │ + returncode = self.transport.get_returncode() │ │ │ │ │ + assert isinstance(returncode, int) │ │ │ │ │ + self.close(PeerExited(returncode)) │ │ │ │ │ │ │ │ │ │ - assert self.peer is None │ │ │ │ │ - assert self.startup is None │ │ │ │ │ + # Forwarding data: from the peer to the router │ │ │ │ │ + def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ + if self.init_future is not None: │ │ │ │ │ + raise CockpitProtocolError('Received unexpected channel control message before init') │ │ │ │ │ + self.send_channel_control(channel, command, message) │ │ │ │ │ │ │ │ │ │ - for config in self.superuser_configs: │ │ │ │ │ - if name in (config.name, 'any'): │ │ │ │ │ - break │ │ │ │ │ - else: │ │ │ │ │ - raise bus.BusError('cockpit.Superuser.Error', f'Unknown superuser bridge type "{name}"') │ │ │ │ │ + def channel_data_received(self, channel: str, data: bytes) -> None: │ │ │ │ │ + if self.init_future is not None: │ │ │ │ │ + raise CockpitProtocolError('Received unexpected channel data before init') │ │ │ │ │ + self.send_channel_data(channel, data) │ │ │ │ │ │ │ │ │ │ - self.current = 'init' │ │ │ │ │ - self.peer = SuperuserPeer(self.router, config, responder) │ │ │ │ │ - self.peer.add_done_callback(self.peer_done) │ │ │ │ │ + # Forwarding data: from the router to the peer │ │ │ │ │ + def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ + assert self.init_future is None │ │ │ │ │ + self.write_control(message) │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - await self.peer.start(init_host=self.router.init_host) │ │ │ │ │ - except asyncio.CancelledError: │ │ │ │ │ - raise bus.BusError('cockpit.Superuser.Error.Cancelled', 'Operation aborted') from None │ │ │ │ │ - except (OSError, PeerError) as exc: │ │ │ │ │ - raise bus.BusError('cockpit.Superuser.Error', str(exc)) from exc │ │ │ │ │ + def do_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ + assert self.init_future is None │ │ │ │ │ + self.write_channel_data(channel, data) │ │ │ │ │ │ │ │ │ │ - self.current = self.peer.config.name │ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ + assert self.init_future is None │ │ │ │ │ + self.write_control(message) │ │ │ │ │ │ │ │ │ │ - def set_configs(self, configs: Sequence[BridgeConfig]): │ │ │ │ │ - logger.debug("set_configs() with %d items", len(configs)) │ │ │ │ │ - configs = [config for config in configs if config.privileged] │ │ │ │ │ - self.superuser_configs = tuple(configs) │ │ │ │ │ - self.bridges = [config.name for config in self.superuser_configs] │ │ │ │ │ - self.methods = {c.label: Variant({'label': Variant(c.label)}, 'a{sv}') for c in configs if c.label} │ │ │ │ │ + def do_close(self) -> None: │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - logger.debug(" bridges are now %s", self.bridges) │ │ │ │ │ │ │ │ │ │ - # If the currently active bridge config is not in the new set of configs, stop it │ │ │ │ │ - if self.peer is not None: │ │ │ │ │ - if self.peer.config not in self.superuser_configs: │ │ │ │ │ - logger.debug(" stopping superuser bridge '%s': it disappeared from configs", self.peer.config.name) │ │ │ │ │ - self.stop() │ │ │ │ │ +class ConfiguredPeer(Peer): │ │ │ │ │ + config: BridgeConfig │ │ │ │ │ + args: Sequence[str] │ │ │ │ │ + env: Sequence[str] │ │ │ │ │ │ │ │ │ │ - def cancel_prompt(self): │ │ │ │ │ - if self.pending_prompt is not None: │ │ │ │ │ - self.pending_prompt.cancel() │ │ │ │ │ - self.pending_prompt = None │ │ │ │ │ + def __init__(self, router: Router, config: BridgeConfig): │ │ │ │ │ + self.config = config │ │ │ │ │ + self.args = config.spawn │ │ │ │ │ + self.env = config.environ │ │ │ │ │ + super().__init__(router) │ │ │ │ │ │ │ │ │ │ - def shutdown(self): │ │ │ │ │ - self.cancel_prompt() │ │ │ │ │ + async def do_connect_transport(self) -> None: │ │ │ │ │ + await self.spawn(self.args, self.env) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class PeerRoutingRule(RoutingRule): │ │ │ │ │ + config: BridgeConfig │ │ │ │ │ + match: JsonObject │ │ │ │ │ + peer: Optional[Peer] │ │ │ │ │ + │ │ │ │ │ + def __init__(self, router: Router, config: BridgeConfig): │ │ │ │ │ + super().__init__(router) │ │ │ │ │ + self.config = config │ │ │ │ │ + self.match = config.match │ │ │ │ │ + self.peer = None │ │ │ │ │ + │ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Peer]: │ │ │ │ │ + # Check that we match │ │ │ │ │ │ │ │ │ │ + for key, value in self.match.items(): │ │ │ │ │ + if key not in options: │ │ │ │ │ + logger.debug(' rejecting because key %s is missing', key) │ │ │ │ │ + return None │ │ │ │ │ + if value is not None and options[key] != value: │ │ │ │ │ + logger.debug(' rejecting because key %s has wrong value %s (vs %s)', key, options[key], value) │ │ │ │ │ + return None │ │ │ │ │ + │ │ │ │ │ + # Start the peer if it's not running already │ │ │ │ │ + if self.peer is None: │ │ │ │ │ + self.peer = ConfiguredPeer(self.router, self.config) │ │ │ │ │ + self.peer.add_done_callback(self.peer_closed) │ │ │ │ │ + assert self.router.init_host │ │ │ │ │ + self.peer.start_in_background(init_host=self.router.init_host) │ │ │ │ │ + │ │ │ │ │ + return self.peer │ │ │ │ │ + │ │ │ │ │ + def peer_closed(self): │ │ │ │ │ + self.peer = None │ │ │ │ │ + │ │ │ │ │ + def shutdown(self): │ │ │ │ │ if self.peer is not None: │ │ │ │ │ self.peer.close() │ │ │ │ │ │ │ │ │ │ - # close() should have disconnected the peer immediately │ │ │ │ │ - assert self.peer is None │ │ │ │ │ │ │ │ │ │ - # Connect-on-startup functionality │ │ │ │ │ - def init(self, params: JsonObject) -> None: │ │ │ │ │ - name = get_str(params, 'id', 'any') │ │ │ │ │ - responder = AuthorizeResponder(self.router) │ │ │ │ │ - self._init_task = asyncio.create_task(self.go(name, responder)) │ │ │ │ │ - self._init_task.add_done_callback(self._init_done) │ │ │ │ │ +class PeersRoutingRule(RoutingRule): │ │ │ │ │ + rules: List[PeerRoutingRule] = [] │ │ │ │ │ │ │ │ │ │ - def _init_done(self, task: 'asyncio.Task[None]') -> None: │ │ │ │ │ - logger.debug('superuser init done! %s', task.exception()) │ │ │ │ │ - self.router.write_control(command='superuser-init-done') │ │ │ │ │ - del self._init_task │ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Endpoint]: │ │ │ │ │ + logger.debug(' considering %d rules', len(self.rules)) │ │ │ │ │ + for rule in self.rules: │ │ │ │ │ + logger.debug(' considering %s', rule.config.name) │ │ │ │ │ + endpoint = rule.apply_rule(options) │ │ │ │ │ + if endpoint is not None: │ │ │ │ │ + logger.debug(' selected') │ │ │ │ │ + return endpoint │ │ │ │ │ + logger.debug(' no peer rules matched') │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ - # D-Bus methods │ │ │ │ │ - @bus.Interface.Method(in_types=['s']) │ │ │ │ │ - async def start(self, name: str) -> None: │ │ │ │ │ - await self.go(name, self) │ │ │ │ │ + def set_configs(self, bridge_configs: Sequence[BridgeConfig]) -> None: │ │ │ │ │ + old_rules = self.rules │ │ │ │ │ + self.rules = [] │ │ │ │ │ │ │ │ │ │ - @bus.Interface.Method() │ │ │ │ │ - def stop(self) -> None: │ │ │ │ │ - self.shutdown() │ │ │ │ │ + for config in bridge_configs: │ │ │ │ │ + # Those are handled elsewhere... │ │ │ │ │ + if config.privileged or 'host' in config.match: │ │ │ │ │ + continue │ │ │ │ │ │ │ │ │ │ - @bus.Interface.Method(in_types=['s']) │ │ │ │ │ - def answer(self, reply: str) -> None: │ │ │ │ │ - if self.pending_prompt is not None: │ │ │ │ │ - logger.debug('responding to pending prompt') │ │ │ │ │ - self.pending_prompt.set_result(reply) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug('got Answer, but no prompt pending') │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/polyfills.py': br'''# This file is part of Cockpit. │ │ │ │ │ + # Try to reuse an existing rule, if one exists... │ │ │ │ │ + for rule in list(old_rules): │ │ │ │ │ + if rule.config == config: │ │ │ │ │ + old_rules.remove(rule) │ │ │ │ │ + break │ │ │ │ │ + else: │ │ │ │ │ + # ... otherwise, create a new one. │ │ │ │ │ + rule = PeerRoutingRule(self.router, config) │ │ │ │ │ + │ │ │ │ │ + self.rules.append(rule) │ │ │ │ │ + │ │ │ │ │ + # close down the old rules that didn't get reclaimed │ │ │ │ │ + for rule in old_rules: │ │ │ │ │ + rule.shutdown() │ │ │ │ │ + │ │ │ │ │ + def shutdown(self): │ │ │ │ │ + for rule in self.rules: │ │ │ │ │ + rule.shutdown() │ │ │ │ │ +'''.encode('utf-8'), │ │ │ │ │ + 'cockpit/router.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2023 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import contextlib │ │ │ │ │ -import socket │ │ │ │ │ +import asyncio │ │ │ │ │ +import collections │ │ │ │ │ +import logging │ │ │ │ │ +from typing import Dict, List, Optional │ │ │ │ │ + │ │ │ │ │ +from .jsonutil import JsonObject, JsonValue │ │ │ │ │ +from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer │ │ │ │ │ │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ -def install(): │ │ │ │ │ - """Add shims for older Python versions""" │ │ │ │ │ │ │ │ │ │ - # introduced in 3.9 │ │ │ │ │ - if not hasattr(socket, 'recv_fds'): │ │ │ │ │ - import array │ │ │ │ │ +class ExecutionQueue: │ │ │ │ │ + """Temporarily delay calls to a given set of class methods. │ │ │ │ │ │ │ │ │ │ - import _socket │ │ │ │ │ + Functions by replacing the named function at the instance __dict__ │ │ │ │ │ + level, effectively providing an override for exactly one instance │ │ │ │ │ + of `method`'s object. │ │ │ │ │ + Queues the invocations. Run them later with .run(), which also reverses │ │ │ │ │ + the redirection by deleting the named methods from the instance. │ │ │ │ │ + """ │ │ │ │ │ + def __init__(self, methods): │ │ │ │ │ + self.queue = collections.deque() │ │ │ │ │ + self.methods = methods │ │ │ │ │ │ │ │ │ │ - def recv_fds(sock, bufsize, maxfds, flags=0): │ │ │ │ │ - fds = array.array("i") │ │ │ │ │ - msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize)) │ │ │ │ │ - for cmsg_level, cmsg_type, cmsg_data in ancdata: │ │ │ │ │ - if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS): │ │ │ │ │ - fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)]) │ │ │ │ │ - return msg, list(fds), flags, addr │ │ │ │ │ + for method in self.methods: │ │ │ │ │ + self._wrap(method) │ │ │ │ │ │ │ │ │ │ - socket.recv_fds = recv_fds │ │ │ │ │ + def _wrap(self, method): │ │ │ │ │ + # NB: this function is stored in the instance dict and therefore │ │ │ │ │ + # doesn't function as a descriptor, isn't a method, doesn't get bound, │ │ │ │ │ + # and therefore doesn't receive a self parameter │ │ │ │ │ + setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args))) │ │ │ │ │ │ │ │ │ │ - # introduced in 3.7 │ │ │ │ │ - if not hasattr(contextlib, 'AsyncExitStack'): │ │ │ │ │ - class AsyncExitStack: │ │ │ │ │ - async def __aenter__(self): │ │ │ │ │ - self.cms = [] │ │ │ │ │ - return self │ │ │ │ │ + def run(self): │ │ │ │ │ + logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue)) │ │ │ │ │ + for method, args in self.queue: │ │ │ │ │ + method(*args) │ │ │ │ │ │ │ │ │ │ - async def enter_async_context(self, cm): │ │ │ │ │ - result = await cm.__aenter__() │ │ │ │ │ - self.cms.append(cm) │ │ │ │ │ - return result │ │ │ │ │ + for method in self.methods: │ │ │ │ │ + delattr(method.__self__, method.__func__.__name__) │ │ │ │ │ │ │ │ │ │ - async def __aexit__(self, exc_type, exc_value, traceback): │ │ │ │ │ - for cm in self.cms: │ │ │ │ │ - cm.__aexit__(exc_type, exc_value, traceback) │ │ │ │ │ │ │ │ │ │ - contextlib.AsyncExitStack = AsyncExitStack │ │ │ │ │ +class Endpoint: │ │ │ │ │ + router: 'Router' │ │ │ │ │ + __endpoint_frozen_queue: Optional[ExecutionQueue] = None │ │ │ │ │ + │ │ │ │ │ + def __init__(self, router: 'Router'): │ │ │ │ │ + router.add_endpoint(self) │ │ │ │ │ + self.router = router │ │ │ │ │ + │ │ │ │ │ + def freeze_endpoint(self): │ │ │ │ │ + assert self.__endpoint_frozen_queue is None │ │ │ │ │ + logger.debug('Freezing endpoint %s', self) │ │ │ │ │ + self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill}) │ │ │ │ │ + │ │ │ │ │ + def thaw_endpoint(self): │ │ │ │ │ + assert self.__endpoint_frozen_queue is not None │ │ │ │ │ + logger.debug('Thawing endpoint %s', self) │ │ │ │ │ + self.__endpoint_frozen_queue.run() │ │ │ │ │ + self.__endpoint_frozen_queue = None │ │ │ │ │ + │ │ │ │ │ + # interface for receiving messages │ │ │ │ │ + def do_close(self): │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def do_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + # interface for sending messages │ │ │ │ │ + def send_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ + self.router.write_channel_data(channel, data) │ │ │ │ │ + │ │ │ │ │ + def send_channel_control( │ │ │ │ │ + self, channel: str, command: str, _msg: 'JsonObject | None', **kwargs: JsonValue │ │ │ │ │ + ) -> None: │ │ │ │ │ + self.router.write_control(_msg, channel=channel, command=command, **kwargs) │ │ │ │ │ + if command == 'close': │ │ │ │ │ + self.router.endpoints[self].remove(channel) │ │ │ │ │ + self.router.drop_channel(channel) │ │ │ │ │ + │ │ │ │ │ + def shutdown_endpoint(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None: │ │ │ │ │ + self.router.shutdown_endpoint(self, _msg, **kwargs) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class RoutingError(CockpitProblem): │ │ │ │ │ + pass │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class RoutingRule: │ │ │ │ │ + router: 'Router' │ │ │ │ │ + │ │ │ │ │ + def __init__(self, router: 'Router'): │ │ │ │ │ + self.router = router │ │ │ │ │ + │ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Endpoint]: │ │ │ │ │ + """Check if a routing rule applies to a given 'open' message. │ │ │ │ │ + │ │ │ │ │ + This should inspect the options dictionary and do one of the following three things: │ │ │ │ │ + │ │ │ │ │ + - return an Endpoint to handle this channel │ │ │ │ │ + - raise a RoutingError to indicate that the open should be rejected │ │ │ │ │ + - return None to let the next rule run │ │ │ │ │ + """ │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def shutdown(self): │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class Router(CockpitProtocolServer): │ │ │ │ │ + routing_rules: List[RoutingRule] │ │ │ │ │ + open_channels: Dict[str, Endpoint] │ │ │ │ │ + endpoints: 'dict[Endpoint, set[str]]' │ │ │ │ │ + no_endpoints: asyncio.Event # set if endpoints dict is empty │ │ │ │ │ + _eof: bool = False │ │ │ │ │ + │ │ │ │ │ + def __init__(self, routing_rules: List[RoutingRule]): │ │ │ │ │ + for rule in routing_rules: │ │ │ │ │ + rule.router = self │ │ │ │ │ + self.routing_rules = routing_rules │ │ │ │ │ + self.open_channels = {} │ │ │ │ │ + self.endpoints = {} │ │ │ │ │ + self.no_endpoints = asyncio.Event() │ │ │ │ │ + self.no_endpoints.set() # at first there are no endpoints │ │ │ │ │ + │ │ │ │ │ + def check_rules(self, options: JsonObject) -> Endpoint: │ │ │ │ │ + for rule in self.routing_rules: │ │ │ │ │ + logger.debug(' applying rule %s', rule) │ │ │ │ │ + endpoint = rule.apply_rule(options) │ │ │ │ │ + if endpoint is not None: │ │ │ │ │ + logger.debug(' resulting endpoint is %s', endpoint) │ │ │ │ │ + return endpoint │ │ │ │ │ + else: │ │ │ │ │ + logger.debug(' No rules matched') │ │ │ │ │ + raise RoutingError('not-supported') │ │ │ │ │ + │ │ │ │ │ + def drop_channel(self, channel: str) -> None: │ │ │ │ │ + try: │ │ │ │ │ + self.open_channels.pop(channel) │ │ │ │ │ + logger.debug('router dropped channel %s', channel) │ │ │ │ │ + except KeyError: │ │ │ │ │ + logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels) │ │ │ │ │ + │ │ │ │ │ + def add_endpoint(self, endpoint: Endpoint) -> None: │ │ │ │ │ + self.endpoints[endpoint] = set() │ │ │ │ │ + self.no_endpoints.clear() │ │ │ │ │ + │ │ │ │ │ + def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None: │ │ │ │ │ + channels = self.endpoints.pop(endpoint) │ │ │ │ │ + logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels) │ │ │ │ │ + for channel in channels: │ │ │ │ │ + self.write_control(_msg, command='close', channel=channel, **kwargs) │ │ │ │ │ + self.drop_channel(channel) │ │ │ │ │ + │ │ │ │ │ + if not self.endpoints: │ │ │ │ │ + self.no_endpoints.set() │ │ │ │ │ + │ │ │ │ │ + # were we waiting to exit? │ │ │ │ │ + if self._eof: │ │ │ │ │ + logger.debug(' endpoints remaining: %r', self.endpoints) │ │ │ │ │ + if not self.endpoints and self.transport: │ │ │ │ │ + logger.debug(' close transport') │ │ │ │ │ + self.transport.close() │ │ │ │ │ + │ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ + endpoints = set(self.endpoints) │ │ │ │ │ + logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints)) │ │ │ │ │ + for endpoint in endpoints: │ │ │ │ │ + endpoint.do_kill(host, group, message) │ │ │ │ │ + │ │ │ │ │ + def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ + # If this is an open message then we need to apply the routing rules to │ │ │ │ │ + # figure out the correct endpoint to connect. If it's not an open │ │ │ │ │ + # message, then we expect the endpoint to already exist. │ │ │ │ │ + if command == 'open': │ │ │ │ │ + if channel in self.open_channels: │ │ │ │ │ + raise CockpitProtocolError('channel is already open') │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload')) │ │ │ │ │ + endpoint = self.check_rules(message) │ │ │ │ │ + except RoutingError as exc: │ │ │ │ │ + self.write_control(exc.attrs, command='close', channel=channel) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + self.open_channels[channel] = endpoint │ │ │ │ │ + self.endpoints[endpoint].add(channel) │ │ │ │ │ + else: │ │ │ │ │ + try: │ │ │ │ │ + endpoint = self.open_channels[channel] │ │ │ │ │ + except KeyError: │ │ │ │ │ + # sending to a non-existent channel can happen due to races and is not an error │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + # At this point, we have the endpoint. Route the message. │ │ │ │ │ + endpoint.do_channel_control(channel, command, message) │ │ │ │ │ + │ │ │ │ │ + def channel_data_received(self, channel: str, data: bytes) -> None: │ │ │ │ │ + try: │ │ │ │ │ + endpoint = self.open_channels[channel] │ │ │ │ │ + except KeyError: │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + endpoint.do_channel_data(channel, data) │ │ │ │ │ + │ │ │ │ │ + def eof_received(self) -> bool: │ │ │ │ │ + logger.debug('eof_received(%r)', self) │ │ │ │ │ + │ │ │ │ │ + endpoints = set(self.endpoints) │ │ │ │ │ + for endpoint in endpoints: │ │ │ │ │ + endpoint.do_close() │ │ │ │ │ + │ │ │ │ │ + self._eof = True │ │ │ │ │ + logger.debug(' endpoints remaining: %r', self.endpoints) │ │ │ │ │ + return bool(self.endpoints) │ │ │ │ │ + │ │ │ │ │ + _communication_done: Optional[asyncio.Future] = None │ │ │ │ │ + │ │ │ │ │ + def do_closed(self, exc: Optional[Exception]) -> None: │ │ │ │ │ + # If we didn't send EOF yet, do it now. │ │ │ │ │ + if not self._eof: │ │ │ │ │ + self.eof_received() │ │ │ │ │ + │ │ │ │ │ + if self._communication_done is not None: │ │ │ │ │ + if exc is None: │ │ │ │ │ + self._communication_done.set_result(None) │ │ │ │ │ + else: │ │ │ │ │ + self._communication_done.set_exception(exc) │ │ │ │ │ + │ │ │ │ │ + async def communicate(self) -> None: │ │ │ │ │ + """Wait until communication is complete on the router and all endpoints are done.""" │ │ │ │ │ + assert self._communication_done is None │ │ │ │ │ + self._communication_done = asyncio.get_running_loop().create_future() │ │ │ │ │ + try: │ │ │ │ │ + await self._communication_done │ │ │ │ │ + except (BrokenPipeError, ConnectionResetError): │ │ │ │ │ + pass # these are normal occurrences when closed from the other side │ │ │ │ │ + finally: │ │ │ │ │ + self._communication_done = None │ │ │ │ │ + │ │ │ │ │ + # In an orderly exit, this is already done, but in case it wasn't │ │ │ │ │ + # orderly, we need to make sure the endpoints shut down anyway... │ │ │ │ │ + await self.no_endpoints.wait() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/transports.py': br'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/channel.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ @@ -385,551 +679,526 @@ │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -"""Bi-directional asyncio.Transport implementations based on file descriptors.""" │ │ │ │ │ - │ │ │ │ │ import asyncio │ │ │ │ │ -import collections │ │ │ │ │ -import ctypes │ │ │ │ │ -import errno │ │ │ │ │ -import fcntl │ │ │ │ │ +import json │ │ │ │ │ import logging │ │ │ │ │ -import os │ │ │ │ │ -import select │ │ │ │ │ -import signal │ │ │ │ │ -import struct │ │ │ │ │ -import subprocess │ │ │ │ │ -import termios │ │ │ │ │ -from typing import Any, ClassVar, Sequence │ │ │ │ │ +from typing import BinaryIO, ClassVar, Dict, Generator, List, Optional, Sequence, Set, Tuple, Type │ │ │ │ │ │ │ │ │ │ -from .jsonutil import JsonObject, get_int │ │ │ │ │ +from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_bool, get_str │ │ │ │ │ +from .protocol import CockpitProblem │ │ │ │ │ +from .router import Endpoint, Router, RoutingRule │ │ │ │ │ │ │ │ │ │ -libc6 = ctypes.cdll.LoadLibrary('libc.so.6') │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def prctl(*args: int) -> None: │ │ │ │ │ - if libc6.prctl(*args) != 0: │ │ │ │ │ - raise OSError('prctl() failed') │ │ │ │ │ +class ChannelRoutingRule(RoutingRule): │ │ │ │ │ + table: Dict[str, List[Type['Channel']]] │ │ │ │ │ │ │ │ │ │ + def __init__(self, router: Router, channel_types: List[Type['Channel']]): │ │ │ │ │ + super().__init__(router) │ │ │ │ │ + self.table = {} │ │ │ │ │ │ │ │ │ │ -SET_PDEATHSIG = 1 │ │ │ │ │ + # Sort the channels into buckets by payload type │ │ │ │ │ + for cls in channel_types: │ │ │ │ │ + entry = self.table.setdefault(cls.payload, []) │ │ │ │ │ + entry.append(cls) │ │ │ │ │ │ │ │ │ │ + # Within each bucket, sort the channels so those with more │ │ │ │ │ + # restrictions are considered first. │ │ │ │ │ + for entry in self.table.values(): │ │ │ │ │ + entry.sort(key=lambda cls: len(cls.restrictions), reverse=True) │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ -IOV_MAX = 1024 # man 2 writev │ │ │ │ │ + def check_restrictions(self, restrictions: Sequence[Tuple[str, object]], options: JsonObject) -> bool: │ │ │ │ │ + for key, expected_value in restrictions: │ │ │ │ │ + our_value = options.get(key) │ │ │ │ │ │ │ │ │ │ + # If the match rule specifies that a value must be present and │ │ │ │ │ + # we don't have it, then fail. │ │ │ │ │ + if our_value is None: │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ -class _Transport(asyncio.Transport): │ │ │ │ │ - BLOCK_SIZE: ClassVar[int] = 1024 * 1024 │ │ │ │ │ + # If the match rule specified a specific expected value, and │ │ │ │ │ + # our value doesn't match it, then fail. │ │ │ │ │ + if expected_value is not None and our_value != expected_value: │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ - # A transport always has a loop and a protocol │ │ │ │ │ - _loop: asyncio.AbstractEventLoop │ │ │ │ │ - _protocol: asyncio.Protocol │ │ │ │ │ + # Everything checked out │ │ │ │ │ + return True │ │ │ │ │ │ │ │ │ │ - _queue: 'collections.deque[bytes] | None' │ │ │ │ │ - _in_fd: int │ │ │ │ │ - _out_fd: int │ │ │ │ │ - _closing: bool │ │ │ │ │ - _is_reading: bool │ │ │ │ │ - _eof: bool │ │ │ │ │ - _eio_is_eof: bool = False │ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional['Channel']: │ │ │ │ │ + assert self.router is not None │ │ │ │ │ │ │ │ │ │ - def __init__(self, │ │ │ │ │ - loop: asyncio.AbstractEventLoop, │ │ │ │ │ - protocol: asyncio.Protocol, │ │ │ │ │ - in_fd: int = -1, out_fd: int = -1, │ │ │ │ │ - extra: 'dict[str, object] | None' = None): │ │ │ │ │ - super().__init__(extra) │ │ │ │ │ + payload = options.get('payload') │ │ │ │ │ + if not isinstance(payload, str): │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ - self._loop = loop │ │ │ │ │ - self._protocol = protocol │ │ │ │ │ + for cls in self.table.get(payload, []): │ │ │ │ │ + if self.check_restrictions(cls.restrictions, options): │ │ │ │ │ + return cls(self.router) │ │ │ │ │ + else: │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ - logger.debug('Created transport %s for protocol %s, fds %d %d', self, protocol, in_fd, out_fd) │ │ │ │ │ + def shutdown(self): │ │ │ │ │ + pass # we don't hold any state │ │ │ │ │ │ │ │ │ │ - self._queue = None │ │ │ │ │ - self._is_reading = False │ │ │ │ │ - self._eof = False │ │ │ │ │ - self._closing = False │ │ │ │ │ │ │ │ │ │ - self._in_fd = in_fd │ │ │ │ │ - self._out_fd = out_fd │ │ │ │ │ +class ChannelError(CockpitProblem): │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - os.set_blocking(in_fd, False) │ │ │ │ │ - if out_fd != in_fd: │ │ │ │ │ - os.set_blocking(out_fd, False) │ │ │ │ │ │ │ │ │ │ - self._protocol.connection_made(self) │ │ │ │ │ - self.resume_reading() │ │ │ │ │ +class Channel(Endpoint): │ │ │ │ │ + # Values borrowed from C implementation │ │ │ │ │ + BLOCK_SIZE = 16 * 1024 │ │ │ │ │ + SEND_WINDOW = 2 * 1024 * 1024 │ │ │ │ │ │ │ │ │ │ - def _read_ready(self) -> None: │ │ │ │ │ - logger.debug('Read ready on %s %s %d', self, self._protocol, self._in_fd) │ │ │ │ │ - try: │ │ │ │ │ - data = os.read(self._in_fd, _Transport.BLOCK_SIZE) │ │ │ │ │ - except BlockingIOError: # pragma: no cover │ │ │ │ │ - return │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - if self._eio_is_eof and exc.errno == errno.EIO: │ │ │ │ │ - # PTY devices return EIO to mean "EOF" │ │ │ │ │ - data = b'' │ │ │ │ │ - else: │ │ │ │ │ - # Other errors: terminate the connection │ │ │ │ │ - self.abort(exc) │ │ │ │ │ - return │ │ │ │ │ + # Flow control book-keeping │ │ │ │ │ + _send_pings: bool = False │ │ │ │ │ + _out_sequence: int = 0 │ │ │ │ │ + _out_window: int = SEND_WINDOW │ │ │ │ │ │ │ │ │ │ - if data != b'': │ │ │ │ │ - logger.debug(' read %d bytes', len(data)) │ │ │ │ │ - self._protocol.data_received(data) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug(' got EOF') │ │ │ │ │ - self._close_reader() │ │ │ │ │ - keep_open = self._protocol.eof_received() │ │ │ │ │ - if not keep_open: │ │ │ │ │ - self.close() │ │ │ │ │ + # Task management │ │ │ │ │ + _tasks: Set[asyncio.Task] │ │ │ │ │ + _close_args: Optional[JsonObject] = None │ │ │ │ │ │ │ │ │ │ - def is_reading(self) -> bool: │ │ │ │ │ - return self._is_reading │ │ │ │ │ + # Must be filled in by the channel implementation │ │ │ │ │ + payload: ClassVar[str] │ │ │ │ │ + restrictions: ClassVar[Sequence[Tuple[str, object]]] = () │ │ │ │ │ │ │ │ │ │ - def _close_reader(self) -> None: │ │ │ │ │ - self.pause_reading() │ │ │ │ │ - self._in_fd = -1 │ │ │ │ │ + # These get filled in from .do_open() │ │ │ │ │ + channel = '' │ │ │ │ │ + group = '' │ │ │ │ │ │ │ │ │ │ - def pause_reading(self) -> None: │ │ │ │ │ - if self._is_reading: │ │ │ │ │ - self._loop.remove_reader(self._in_fd) │ │ │ │ │ - self._is_reading = False │ │ │ │ │ + # input │ │ │ │ │ + def do_control(self, command, message): │ │ │ │ │ + # Break the various different kinds of control messages out into the │ │ │ │ │ + # things that our subclass may be interested in handling. We drop the │ │ │ │ │ + # 'message' field for handlers that don't need it. │ │ │ │ │ + if command == 'open': │ │ │ │ │ + self._tasks = set() │ │ │ │ │ + self.channel = message['channel'] │ │ │ │ │ + if get_bool(message, 'flow-control', default=False): │ │ │ │ │ + self._send_pings = True │ │ │ │ │ + self.group = get_str(message, 'group', 'default') │ │ │ │ │ + self.freeze_endpoint() │ │ │ │ │ + self.do_open(message) │ │ │ │ │ + elif command == 'ready': │ │ │ │ │ + self.do_ready() │ │ │ │ │ + elif command == 'done': │ │ │ │ │ + self.do_done() │ │ │ │ │ + elif command == 'close': │ │ │ │ │ + self.do_close() │ │ │ │ │ + elif command == 'ping': │ │ │ │ │ + self.do_ping(message) │ │ │ │ │ + elif command == 'pong': │ │ │ │ │ + self.do_pong(message) │ │ │ │ │ + elif command == 'options': │ │ │ │ │ + self.do_options(message) │ │ │ │ │ │ │ │ │ │ - def resume_reading(self) -> None: │ │ │ │ │ - # It's possible that the Protocol could decide to attempt to unpause │ │ │ │ │ - # reading after _close_reader() got called. Check that the fd is != -1 │ │ │ │ │ - # before actually resuming. │ │ │ │ │ - if not self._is_reading and self._in_fd != -1: │ │ │ │ │ - self._loop.add_reader(self._in_fd, self._read_ready) │ │ │ │ │ - self._is_reading = True │ │ │ │ │ + def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ + # Already closing? Ignore. │ │ │ │ │ + if self._close_args is not None: │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - def _close(self) -> None: │ │ │ │ │ - pass │ │ │ │ │ + # Catch errors and turn them into close messages │ │ │ │ │ + try: │ │ │ │ │ + try: │ │ │ │ │ + self.do_control(command, message) │ │ │ │ │ + except JsonError as exc: │ │ │ │ │ + raise ChannelError('protocol-error', message=str(exc)) from exc │ │ │ │ │ + except ChannelError as exc: │ │ │ │ │ + self.close(exc.attrs) │ │ │ │ │ │ │ │ │ │ - def abort(self, exc: 'Exception | None' = None) -> None: │ │ │ │ │ - self._closing = True │ │ │ │ │ - self._close_reader() │ │ │ │ │ - self._remove_write_queue() │ │ │ │ │ - self._protocol.connection_lost(exc) │ │ │ │ │ - self._close() │ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', _message: JsonObject) -> None: │ │ │ │ │ + # Already closing? Ignore. │ │ │ │ │ + if self._close_args is not None: │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - def can_write_eof(self) -> bool: │ │ │ │ │ + if host is not None: │ │ │ │ │ + return │ │ │ │ │ + if group is not None and self.group != group: │ │ │ │ │ + return │ │ │ │ │ + self.do_close() │ │ │ │ │ + │ │ │ │ │ + # At least this one really ought to be implemented... │ │ │ │ │ + def do_open(self, options: JsonObject) -> None: │ │ │ │ │ raise NotImplementedError │ │ │ │ │ │ │ │ │ │ - def write_eof(self) -> None: │ │ │ │ │ - assert not self._eof │ │ │ │ │ - self._eof = True │ │ │ │ │ - if self._queue is None: │ │ │ │ │ - logger.debug('%s got EOF. closing backend.', self) │ │ │ │ │ - self._write_eof_now() │ │ │ │ │ - else: │ │ │ │ │ - logger.debug('%s got EOF. bytes in queue, deferring close', self) │ │ │ │ │ + # ... but many subclasses may reasonably want to ignore some of these. │ │ │ │ │ + def do_ready(self) -> None: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - def get_write_buffer_size(self) -> int: │ │ │ │ │ - if self._queue is None: │ │ │ │ │ - return 0 │ │ │ │ │ - return sum(len(block) for block in self._queue) │ │ │ │ │ + def do_done(self) -> None: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - def get_write_buffer_limits(self) -> 'tuple[int, int]': │ │ │ │ │ - return (0, 0) │ │ │ │ │ + def do_close(self) -> None: │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None: │ │ │ │ │ - assert high is None or high == 0 │ │ │ │ │ - assert low is None or low == 0 │ │ │ │ │ + def do_options(self, message: JsonObject) -> None: │ │ │ │ │ + raise ChannelError('not-supported', message='This channel does not implement "options"') │ │ │ │ │ │ │ │ │ │ - def _write_eof_now(self) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ + # 'reasonable' default, overridden in other channels for receive-side flow control │ │ │ │ │ + def do_ping(self, message: JsonObject) -> None: │ │ │ │ │ + self.send_pong(message) │ │ │ │ │ │ │ │ │ │ - def _write_ready(self) -> None: │ │ │ │ │ - logger.debug('%s _write_ready', self) │ │ │ │ │ - assert self._queue is not None │ │ │ │ │ + def do_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ + # Already closing? Ignore. │ │ │ │ │ + if self._close_args is not None: │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ + # Catch errors and turn them into close messages │ │ │ │ │ try: │ │ │ │ │ - n_bytes = os.writev(self._out_fd, self._queue) │ │ │ │ │ - except BlockingIOError: # pragma: no cover │ │ │ │ │ - n_bytes = 0 │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - self.abort(exc) │ │ │ │ │ - return │ │ │ │ │ + self.do_data(data) │ │ │ │ │ + except ChannelError as exc: │ │ │ │ │ + self.close(exc.attrs) │ │ │ │ │ │ │ │ │ │ - logger.debug(' successfully wrote %d bytes from the queue', n_bytes) │ │ │ │ │ + def do_data(self, _data: bytes) -> None: │ │ │ │ │ + # By default, channels can't receive data. │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - while n_bytes: │ │ │ │ │ - block = self._queue.popleft() │ │ │ │ │ - if len(block) > n_bytes: │ │ │ │ │ - # This block wasn't completely written. │ │ │ │ │ - logger.debug(' incomplete block. Stop.') │ │ │ │ │ - self._queue.appendleft(block[n_bytes:]) │ │ │ │ │ - break │ │ │ │ │ - n_bytes -= len(block) │ │ │ │ │ - logger.debug(' removed complete block. %d remains.', n_bytes) │ │ │ │ │ + # output │ │ │ │ │ + def ready(self, **kwargs: JsonValue) -> None: │ │ │ │ │ + self.thaw_endpoint() │ │ │ │ │ + self.send_control(command='ready', **kwargs) │ │ │ │ │ │ │ │ │ │ - if not self._queue: │ │ │ │ │ - logger.debug('%s queue drained.') │ │ │ │ │ - self._remove_write_queue() │ │ │ │ │ - if self._eof: │ │ │ │ │ - logger.debug('%s queue drained. closing backend now.') │ │ │ │ │ - self._write_eof_now() │ │ │ │ │ - if self._closing: │ │ │ │ │ - self.abort() │ │ │ │ │ + def done(self) -> None: │ │ │ │ │ + self.send_control(command='done') │ │ │ │ │ │ │ │ │ │ - def _remove_write_queue(self) -> None: │ │ │ │ │ - if self._queue is not None: │ │ │ │ │ - self._protocol.resume_writing() │ │ │ │ │ - self._loop.remove_writer(self._out_fd) │ │ │ │ │ - self._queue = None │ │ │ │ │ + # tasks and close management │ │ │ │ │ + def is_closing(self) -> bool: │ │ │ │ │ + return self._close_args is not None │ │ │ │ │ │ │ │ │ │ - def _create_write_queue(self, data: bytes) -> None: │ │ │ │ │ - logger.debug('%s creating write queue for fd %s', self, self._out_fd) │ │ │ │ │ - assert self._queue is None │ │ │ │ │ - self._loop.add_writer(self._out_fd, self._write_ready) │ │ │ │ │ - self._queue = collections.deque((data,)) │ │ │ │ │ - self._protocol.pause_writing() │ │ │ │ │ + def _close_now(self) -> None: │ │ │ │ │ + self.shutdown_endpoint(self._close_args) │ │ │ │ │ │ │ │ │ │ - def write(self, data: bytes) -> None: │ │ │ │ │ - # this is a race condition with subprocesses: if we get and process the the "exited" │ │ │ │ │ - # event before seeing BrokenPipeError, we'll try to write to a closed pipe. │ │ │ │ │ - # Do what the standard library does and ignore, instead of assert │ │ │ │ │ - if self._closing: │ │ │ │ │ - logger.debug('ignoring write() to closing transport fd %i', self._out_fd) │ │ │ │ │ - return │ │ │ │ │ + def _task_done(self, task): │ │ │ │ │ + # Strictly speaking, we should read the result and check for exceptions but: │ │ │ │ │ + # - exceptions bubbling out of the task are programming errors │ │ │ │ │ + # - the only thing we'd do with it anyway, is to show it │ │ │ │ │ + # - Python already does that with its "Task exception was never retrieved" messages │ │ │ │ │ + self._tasks.remove(task) │ │ │ │ │ + if self._close_args is not None and not self._tasks: │ │ │ │ │ + self._close_now() │ │ │ │ │ │ │ │ │ │ - assert not self._eof │ │ │ │ │ + def create_task(self, coroutine, name=None): │ │ │ │ │ + """Create a task associated with the channel. │ │ │ │ │ │ │ │ │ │ - if self._queue is not None: │ │ │ │ │ - self._queue.append(data) │ │ │ │ │ + All tasks must exit before the channel can close. You may not create │ │ │ │ │ + new tasks after calling .close(). │ │ │ │ │ + """ │ │ │ │ │ + assert self._close_args is None │ │ │ │ │ + task = asyncio.create_task(coroutine) │ │ │ │ │ + self._tasks.add(task) │ │ │ │ │ + task.add_done_callback(self._task_done) │ │ │ │ │ + return task │ │ │ │ │ │ │ │ │ │ - # writev() will complain if the queue is too long. Consolidate it. │ │ │ │ │ - if len(self._queue) > IOV_MAX: │ │ │ │ │ - all_data = b''.join(self._queue) │ │ │ │ │ - self._queue.clear() │ │ │ │ │ - self._queue.append(all_data) │ │ │ │ │ + def close(self, close_args: 'JsonObject | None' = None) -> None: │ │ │ │ │ + """Requests the channel to be closed. │ │ │ │ │ │ │ │ │ │ - return │ │ │ │ │ + After you call this method, you won't get anymore `.do_*()` calls. │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - n_bytes = os.write(self._out_fd, data) │ │ │ │ │ - except BlockingIOError: │ │ │ │ │ - n_bytes = 0 │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - self.abort(exc) │ │ │ │ │ + This will wait for any running tasks to complete before sending the │ │ │ │ │ + close message. │ │ │ │ │ + """ │ │ │ │ │ + if self._close_args is not None: │ │ │ │ │ + # close already requested │ │ │ │ │ return │ │ │ │ │ + self._close_args = close_args or {} │ │ │ │ │ + if not self._tasks: │ │ │ │ │ + self._close_now() │ │ │ │ │ │ │ │ │ │ - if n_bytes != len(data): │ │ │ │ │ - self._create_write_queue(data[n_bytes:]) │ │ │ │ │ + def send_data(self, data: bytes) -> bool: │ │ │ │ │ + """Send data and handle book-keeping for flow control. │ │ │ │ │ │ │ │ │ │ - def close(self) -> None: │ │ │ │ │ - if self._closing: │ │ │ │ │ - return │ │ │ │ │ + The flow control is "advisory". The data is sent immediately, even if │ │ │ │ │ + it's larger than the window. In general you should try to send packets │ │ │ │ │ + which are approximately Channel.BLOCK_SIZE in size. │ │ │ │ │ │ │ │ │ │ - self._closing = True │ │ │ │ │ - self._close_reader() │ │ │ │ │ + Returns True if there is still room in the window, or False if you │ │ │ │ │ + should stop writing for now. In that case, `.do_resume_send()` will be │ │ │ │ │ + called later when there is more room. │ │ │ │ │ + """ │ │ │ │ │ + self.send_channel_data(self.channel, data) │ │ │ │ │ │ │ │ │ │ - if self._queue is not None: │ │ │ │ │ - # abort() will be called from _write_ready() when it's done │ │ │ │ │ - return │ │ │ │ │ + if self._send_pings: │ │ │ │ │ + out_sequence = self._out_sequence + len(data) │ │ │ │ │ + if self._out_sequence // Channel.BLOCK_SIZE != out_sequence // Channel.BLOCK_SIZE: │ │ │ │ │ + self.send_control(command='ping', sequence=out_sequence) │ │ │ │ │ + self._out_sequence = out_sequence │ │ │ │ │ │ │ │ │ │ - self.abort() │ │ │ │ │ + return self._out_sequence < self._out_window │ │ │ │ │ │ │ │ │ │ - def get_protocol(self) -> asyncio.BaseProtocol: │ │ │ │ │ - return self._protocol │ │ │ │ │ + def do_pong(self, message): │ │ │ │ │ + if not self._send_pings: # huh? │ │ │ │ │ + logger.warning("Got wild pong on channel %s", self.channel) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - def is_closing(self) -> bool: │ │ │ │ │ - return self._closing │ │ │ │ │ + self._out_window = message['sequence'] + Channel.SEND_WINDOW │ │ │ │ │ + if self._out_sequence < self._out_window: │ │ │ │ │ + self.do_resume_send() │ │ │ │ │ │ │ │ │ │ - def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ + def do_resume_send(self) -> None: │ │ │ │ │ + """Called to indicate that the channel may start sending again.""" │ │ │ │ │ + # change to `raise NotImplementedError` after everyone implements it │ │ │ │ │ │ │ │ │ │ - def __del__(self) -> None: │ │ │ │ │ - self._close() │ │ │ │ │ + json_encoder: ClassVar[json.JSONEncoder] = json.JSONEncoder(indent=2) │ │ │ │ │ │ │ │ │ │ + def send_json(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> bool: │ │ │ │ │ + pretty = self.json_encoder.encode(create_object(_msg, kwargs)) + '\n' │ │ │ │ │ + return self.send_data(pretty.encode()) │ │ │ │ │ │ │ │ │ │ -class SubprocessProtocol(asyncio.Protocol): │ │ │ │ │ - """An extension to asyncio.Protocol for use with SubprocessTransport.""" │ │ │ │ │ - def process_exited(self) -> None: │ │ │ │ │ - """Called when subprocess has exited.""" │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ + def send_control(self, command: str, **kwargs: JsonValue) -> None: │ │ │ │ │ + self.send_channel_control(self.channel, command, None, **kwargs) │ │ │ │ │ │ │ │ │ │ + def send_pong(self, message: JsonObject) -> None: │ │ │ │ │ + self.send_channel_control(self.channel, 'pong', message) │ │ │ │ │ │ │ │ │ │ -class WindowSize: │ │ │ │ │ - def __init__(self, value: JsonObject): │ │ │ │ │ - self.rows = get_int(value, 'rows') │ │ │ │ │ - self.cols = get_int(value, 'cols') │ │ │ │ │ │ │ │ │ │ +class ProtocolChannel(Channel, asyncio.Protocol): │ │ │ │ │ + """A channel subclass that implements the asyncio Protocol interface. │ │ │ │ │ │ │ │ │ │ -class SubprocessTransport(_Transport, asyncio.SubprocessTransport): │ │ │ │ │ - """A bi-directional transport speaking with stdin/out of a subprocess. │ │ │ │ │ + In effect, data sent to this channel will be written to the connected │ │ │ │ │ + transport, and vice-versa. Flow control is supported. │ │ │ │ │ │ │ │ │ │ - Note: this is not really a normal SubprocessTransport. Although it │ │ │ │ │ - implements the entire API of asyncio.SubprocessTransport, it is not │ │ │ │ │ - designed to be used with asyncio.SubprocessProtocol objects. Instead, it │ │ │ │ │ - pair with normal Protocol objects which also implement the │ │ │ │ │ - SubprocessProtocol defined in this module (which only has a │ │ │ │ │ - process_exited() method). Whatever the protocol writes is sent to stdin, │ │ │ │ │ - and whatever comes from stdout is given to the Protocol via the │ │ │ │ │ - .data_received() function. │ │ │ │ │ + The default implementation of the .do_open() method calls the │ │ │ │ │ + .create_transport() abstract method. This method should return a transport │ │ │ │ │ + which will be used for communication on the channel. │ │ │ │ │ │ │ │ │ │ - If stderr is configured as a pipe, the transport will separately collect │ │ │ │ │ - data from it, making it available via the .get_stderr() method. │ │ │ │ │ + Otherwise, if the subclass implements .do_open() itself, it is responsible │ │ │ │ │ + for setting up the connection and ensuring that .connection_made() is called. │ │ │ │ │ """ │ │ │ │ │ + _transport: Optional[asyncio.Transport] │ │ │ │ │ + _loop: Optional[asyncio.AbstractEventLoop] │ │ │ │ │ + _send_pongs: bool = True │ │ │ │ │ + _last_ping: Optional[JsonObject] = None │ │ │ │ │ + _create_transport_task = None │ │ │ │ │ │ │ │ │ │ - _returncode: 'int | None' = None │ │ │ │ │ - │ │ │ │ │ - _pty_fd: 'int | None' = None │ │ │ │ │ - _process: 'subprocess.Popen[bytes] | None' = None │ │ │ │ │ - _stderr: 'Spooler | None' │ │ │ │ │ - │ │ │ │ │ - @staticmethod │ │ │ │ │ - def _create_watcher() -> asyncio.AbstractChildWatcher: │ │ │ │ │ - try: │ │ │ │ │ - os.close(os.pidfd_open(os.getpid(), 0)) # check for kernel support │ │ │ │ │ - return asyncio.PidfdChildWatcher() │ │ │ │ │ - except (AttributeError, OSError): │ │ │ │ │ - pass │ │ │ │ │ + # read-side EOF handling │ │ │ │ │ + _close_on_eof: bool = False │ │ │ │ │ + _eof: bool = False │ │ │ │ │ │ │ │ │ │ - return asyncio.SafeChildWatcher() │ │ │ │ │ + async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport: │ │ │ │ │ + """Creates the transport for this channel, according to options. │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def _get_watcher(loop: asyncio.AbstractEventLoop) -> asyncio.AbstractChildWatcher: │ │ │ │ │ - quark = '_cockpit_transports_child_watcher' │ │ │ │ │ - watcher = getattr(loop, quark, None) │ │ │ │ │ + The event loop for the transport is passed to the function. The │ │ │ │ │ + protocol for the transport is the channel object, itself (self). │ │ │ │ │ │ │ │ │ │ - if watcher is None: │ │ │ │ │ - watcher = SubprocessTransport._create_watcher() │ │ │ │ │ - watcher.attach_loop(loop) │ │ │ │ │ - setattr(loop, quark, watcher) │ │ │ │ │ + This needs to be implemented by the subclass. │ │ │ │ │ + """ │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ │ │ │ │ │ - return watcher │ │ │ │ │ + def do_open(self, options: JsonObject) -> None: │ │ │ │ │ + loop = asyncio.get_running_loop() │ │ │ │ │ + self._create_transport_task = asyncio.create_task(self.create_transport(loop, options)) │ │ │ │ │ + self._create_transport_task.add_done_callback(self.create_transport_done) │ │ │ │ │ │ │ │ │ │ - def get_stderr(self, *, reset: bool = False) -> str: │ │ │ │ │ - if self._stderr is not None: │ │ │ │ │ - return self._stderr.get(reset=reset).decode(errors='replace') │ │ │ │ │ - else: │ │ │ │ │ - return '' │ │ │ │ │ + def create_transport_done(self, task: 'asyncio.Task[asyncio.Transport]') -> None: │ │ │ │ │ + assert task is self._create_transport_task │ │ │ │ │ + self._create_transport_task = None │ │ │ │ │ + try: │ │ │ │ │ + transport = task.result() │ │ │ │ │ + except ChannelError as exc: │ │ │ │ │ + self.close(exc.attrs) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - def _exited(self, pid: int, code: int) -> None: │ │ │ │ │ - # NB: per AbstractChildWatcher API, this handler should be thread-safe, │ │ │ │ │ - # but we only ever use non-threaded child watcher implementations, so │ │ │ │ │ - # we can assume we'll always be called in the main thread. │ │ │ │ │ + self.connection_made(transport) │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - # NB: the subprocess is going to want to waitpid() itself as well, but │ │ │ │ │ - # will get ECHILD since we already reaped it. Fortunately, since │ │ │ │ │ - # Python 3.2 this is supported, and process gets a return status of │ │ │ │ │ - # zero. For that reason, we need to store our own copy of the return │ │ │ │ │ - # status. See https://github.com/python/cpython/issues/59960 │ │ │ │ │ - assert isinstance(self._protocol, SubprocessProtocol) │ │ │ │ │ - assert self._process is not None │ │ │ │ │ - assert self._process.pid == pid │ │ │ │ │ - self._returncode = code │ │ │ │ │ - logger.debug('Process exited with status %d', self._returncode) │ │ │ │ │ - if not self._closing: │ │ │ │ │ - self._protocol.process_exited() │ │ │ │ │ + def connection_made(self, transport: asyncio.BaseTransport) -> None: │ │ │ │ │ + assert isinstance(transport, asyncio.Transport) │ │ │ │ │ + self._transport = transport │ │ │ │ │ │ │ │ │ │ - def __init__(self, │ │ │ │ │ - loop: asyncio.AbstractEventLoop, │ │ │ │ │ - protocol: SubprocessProtocol, │ │ │ │ │ - args: Sequence[str], │ │ │ │ │ - *, │ │ │ │ │ - pty: bool = False, │ │ │ │ │ - window: 'WindowSize | None' = None, │ │ │ │ │ - **kwargs: Any): │ │ │ │ │ + def _get_close_args(self) -> JsonObject: │ │ │ │ │ + return {} │ │ │ │ │ │ │ │ │ │ - # go down as a team -- we don't want any leaked processes when the bridge terminates │ │ │ │ │ - def preexec_fn() -> None: │ │ │ │ │ - prctl(SET_PDEATHSIG, signal.SIGTERM) │ │ │ │ │ - if pty: │ │ │ │ │ - fcntl.ioctl(0, termios.TIOCSCTTY, 0) │ │ │ │ │ + def connection_lost(self, exc: Optional[Exception]) -> None: │ │ │ │ │ + self.close(self._get_close_args()) │ │ │ │ │ │ │ │ │ │ - if pty: │ │ │ │ │ - self._pty_fd, session_fd = os.openpty() │ │ │ │ │ + def do_data(self, data: bytes) -> None: │ │ │ │ │ + assert self._transport is not None │ │ │ │ │ + self._transport.write(data) │ │ │ │ │ │ │ │ │ │ - if window is not None: │ │ │ │ │ - self.set_window_size(window) │ │ │ │ │ + def do_done(self) -> None: │ │ │ │ │ + assert self._transport is not None │ │ │ │ │ + if self._transport.can_write_eof(): │ │ │ │ │ + self._transport.write_eof() │ │ │ │ │ │ │ │ │ │ - kwargs['stderr'] = session_fd │ │ │ │ │ - self._process = subprocess.Popen(args, │ │ │ │ │ - stdin=session_fd, stdout=session_fd, │ │ │ │ │ - preexec_fn=preexec_fn, start_new_session=True, **kwargs) │ │ │ │ │ - os.close(session_fd) │ │ │ │ │ + def do_close(self) -> None: │ │ │ │ │ + if self._transport is not None: │ │ │ │ │ + self._transport.close() │ │ │ │ │ │ │ │ │ │ - in_fd, out_fd = self._pty_fd, self._pty_fd │ │ │ │ │ - self._eio_is_eof = True │ │ │ │ │ + def data_received(self, data: bytes) -> None: │ │ │ │ │ + assert self._transport is not None │ │ │ │ │ + if not self.send_data(data): │ │ │ │ │ + self._transport.pause_reading() │ │ │ │ │ │ │ │ │ │ - else: │ │ │ │ │ - self._process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, │ │ │ │ │ - preexec_fn=preexec_fn, **kwargs) │ │ │ │ │ - assert self._process.stdin │ │ │ │ │ - assert self._process.stdout │ │ │ │ │ - in_fd = self._process.stdout.fileno() │ │ │ │ │ - out_fd = self._process.stdin.fileno() │ │ │ │ │ + def do_resume_send(self) -> None: │ │ │ │ │ + assert self._transport is not None │ │ │ │ │ + self._transport.resume_reading() │ │ │ │ │ │ │ │ │ │ - if self._process.stderr is not None: │ │ │ │ │ - self._stderr = Spooler(loop, self._process.stderr.fileno()) │ │ │ │ │ - else: │ │ │ │ │ - self._stderr = None │ │ │ │ │ + def close_on_eof(self) -> None: │ │ │ │ │ + """Mark the channel to be closed on EOF. │ │ │ │ │ │ │ │ │ │ - super().__init__(loop, protocol, in_fd, out_fd) │ │ │ │ │ + Normally, ProtocolChannel tries to keep the channel half-open after │ │ │ │ │ + receiving EOF from the transport. This instructs that the channel │ │ │ │ │ + should be closed on EOF. │ │ │ │ │ │ │ │ │ │ - self._get_watcher(loop).add_child_handler(self._process.pid, self._exited) │ │ │ │ │ + If EOF was already received, then calling this function will close the │ │ │ │ │ + channel immediately. │ │ │ │ │ │ │ │ │ │ - def set_window_size(self, size: WindowSize) -> None: │ │ │ │ │ - assert self._pty_fd is not None │ │ │ │ │ - fcntl.ioctl(self._pty_fd, termios.TIOCSWINSZ, struct.pack('2H4x', size.rows, size.cols)) │ │ │ │ │ + If you don't call this function, you are responsible for closing the │ │ │ │ │ + channel yourself. │ │ │ │ │ + """ │ │ │ │ │ + self._close_on_eof = True │ │ │ │ │ + if self._eof: │ │ │ │ │ + assert self._transport is not None │ │ │ │ │ + self._transport.close() │ │ │ │ │ │ │ │ │ │ - def can_write_eof(self) -> bool: │ │ │ │ │ - assert self._process is not None │ │ │ │ │ - return self._process.stdin is not None │ │ │ │ │ + def eof_received(self) -> bool: │ │ │ │ │ + self._eof = True │ │ │ │ │ + self.done() │ │ │ │ │ + return not self._close_on_eof │ │ │ │ │ │ │ │ │ │ - def _write_eof_now(self) -> None: │ │ │ │ │ - assert self._process is not None │ │ │ │ │ - assert self._process.stdin is not None │ │ │ │ │ - self._process.stdin.close() │ │ │ │ │ - self._out_fd = -1 │ │ │ │ │ + # Channel receive-side flow control │ │ │ │ │ + def do_ping(self, message): │ │ │ │ │ + if self._send_pongs: │ │ │ │ │ + self.send_pong(message) │ │ │ │ │ + else: │ │ │ │ │ + # we'll have to pong later │ │ │ │ │ + self._last_ping = message │ │ │ │ │ │ │ │ │ │ - def get_pid(self) -> int: │ │ │ │ │ - assert self._process is not None │ │ │ │ │ - return self._process.pid │ │ │ │ │ + def pause_writing(self) -> None: │ │ │ │ │ + # We can't actually stop writing, but we can stop replying to pings │ │ │ │ │ + self._send_pongs = False │ │ │ │ │ │ │ │ │ │ - def get_returncode(self) -> 'int | None': │ │ │ │ │ - return self._returncode │ │ │ │ │ + def resume_writing(self) -> None: │ │ │ │ │ + self._send_pongs = True │ │ │ │ │ + if self._last_ping is not None: │ │ │ │ │ + self.send_pong(self._last_ping) │ │ │ │ │ + self._last_ping = None │ │ │ │ │ │ │ │ │ │ - def get_pipe_transport(self, fd: int) -> asyncio.Transport: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ │ │ │ │ │ - def send_signal(self, sig: signal.Signals) -> None: # type: ignore[override] # mypy/issues/13885 │ │ │ │ │ - assert self._process is not None │ │ │ │ │ - # We try to avoid using subprocess.send_signal(). It contains a call │ │ │ │ │ - # to waitpid() internally to avoid signalling the wrong process (if a │ │ │ │ │ - # PID gets reused), but: │ │ │ │ │ - # │ │ │ │ │ - # - we already detect the process exiting via our PidfdChildWatcher │ │ │ │ │ - # │ │ │ │ │ - # - the check is actually harmful since collecting the process via │ │ │ │ │ - # waitpid() prevents the PidfdChildWatcher from doing the same, │ │ │ │ │ - # resulting in an error. │ │ │ │ │ - # │ │ │ │ │ - # It's on us now to check it, but that's easy: │ │ │ │ │ - if self._returncode is not None: │ │ │ │ │ - logger.debug("won't attempt %s to process %i. It exited already.", sig, self._process.pid) │ │ │ │ │ - return │ │ │ │ │ +class AsyncChannel(Channel): │ │ │ │ │ + """A subclass for async/await-style implementation of channels, with flow control │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - os.kill(self._process.pid, sig) │ │ │ │ │ - logger.debug('sent %s to process %i', sig, self._process.pid) │ │ │ │ │ - except ProcessLookupError: │ │ │ │ │ - # already gone? fine │ │ │ │ │ - logger.debug("can't send %s to process %i. It's exited just now.", sig, self._process.pid) │ │ │ │ │ + This subclass provides asynchronous `read()` and `write()` calls for │ │ │ │ │ + subclasses, with familiar semantics. `write()` doesn't buffer, so the │ │ │ │ │ + `done()` method on the base channel class can be used in a way similar to │ │ │ │ │ + `shutdown()`. A high-level `sendfile()` method is available to send the │ │ │ │ │ + entire contents of a binary-mode file-like object. │ │ │ │ │ │ │ │ │ │ - def terminate(self) -> None: │ │ │ │ │ - self.send_signal(signal.SIGTERM) │ │ │ │ │ + The subclass must provide an async `run()` function, which will be spawned │ │ │ │ │ + as a task. │ │ │ │ │ │ │ │ │ │ - def kill(self) -> None: │ │ │ │ │ - self.send_signal(signal.SIGKILL) │ │ │ │ │ + On the receiving side, the channel will respond to flow control pings to │ │ │ │ │ + indicate that it has received the data, but only after it has been consumed │ │ │ │ │ + by `read()`. │ │ │ │ │ │ │ │ │ │ - def _close(self) -> None: │ │ │ │ │ - if self._pty_fd is not None: │ │ │ │ │ - os.close(self._pty_fd) │ │ │ │ │ - self._pty_fd = None │ │ │ │ │ + On the sending side, write() will block if the channel backs up. │ │ │ │ │ + """ │ │ │ │ │ │ │ │ │ │ - if self._process is not None: │ │ │ │ │ - if self._process.stdin is not None: │ │ │ │ │ - self._process.stdin.close() │ │ │ │ │ - self._process.stdin = None │ │ │ │ │ - try: │ │ │ │ │ - self.terminate() # best effort... │ │ │ │ │ - except PermissionError: │ │ │ │ │ - logger.debug("can't kill %i due to EPERM", self._process.pid) │ │ │ │ │ + # Receive-side flow control: intermix pings and data in the queue and reply │ │ │ │ │ + # to pings as we dequeue them. This is a buffer: since we need to handle │ │ │ │ │ + # do_data() without blocking, we have no choice. │ │ │ │ │ + receive_queue = None │ │ │ │ │ │ │ │ │ │ + # Send-side flow control │ │ │ │ │ + write_waiter = None │ │ │ │ │ │ │ │ │ │ -class StdioTransport(_Transport): │ │ │ │ │ - """A bi-directional transport that corresponds to stdin/out. │ │ │ │ │ + async def run(self, options): │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ │ │ │ │ │ - Can talk to just about anything: │ │ │ │ │ - - files │ │ │ │ │ - - pipes │ │ │ │ │ - - character devices (including terminals) │ │ │ │ │ - - sockets │ │ │ │ │ - """ │ │ │ │ │ + async def run_wrapper(self, options): │ │ │ │ │ + try: │ │ │ │ │ + await self.run(options) │ │ │ │ │ + self.close() │ │ │ │ │ + except ChannelError as exc: │ │ │ │ │ + self.close(exc.attrs) │ │ │ │ │ │ │ │ │ │ - def __init__(self, loop: asyncio.AbstractEventLoop, protocol: asyncio.Protocol, stdin: int = 0, stdout: int = 1): │ │ │ │ │ - super().__init__(loop, protocol, stdin, stdout) │ │ │ │ │ + async def read(self): │ │ │ │ │ + while True: │ │ │ │ │ + item = await self.receive_queue.get() │ │ │ │ │ + if isinstance(item, bytes): │ │ │ │ │ + return item │ │ │ │ │ + self.send_pong(item) │ │ │ │ │ │ │ │ │ │ - def can_write_eof(self) -> bool: │ │ │ │ │ - return False │ │ │ │ │ + async def write(self, data): │ │ │ │ │ + if not self.send_data(data): │ │ │ │ │ + self.write_waiter = asyncio.get_running_loop().create_future() │ │ │ │ │ + await self.write_waiter │ │ │ │ │ │ │ │ │ │ - def _write_eof_now(self) -> None: │ │ │ │ │ - raise RuntimeError("Can't write EOF to stdout") │ │ │ │ │ + async def sendfile(self, stream: BinaryIO) -> None: │ │ │ │ │ + loop = asyncio.get_running_loop() │ │ │ │ │ + with stream: │ │ │ │ │ + while True: │ │ │ │ │ + data = await loop.run_in_executor(None, stream.read, Channel.BLOCK_SIZE) │ │ │ │ │ + if data == b'': │ │ │ │ │ + break │ │ │ │ │ + await self.write(data) │ │ │ │ │ │ │ │ │ │ + self.done() │ │ │ │ │ │ │ │ │ │ -class Spooler: │ │ │ │ │ - """Consumes data from an fd, storing it in a buffer. │ │ │ │ │ + def do_resume_send(self) -> None: │ │ │ │ │ + if self.write_waiter is not None: │ │ │ │ │ + self.write_waiter.set_result(None) │ │ │ │ │ + self.write_waiter = None │ │ │ │ │ │ │ │ │ │ - This makes a copy of the fd, so you don't have to worry about holding it │ │ │ │ │ - open. │ │ │ │ │ - """ │ │ │ │ │ + def do_open(self, options): │ │ │ │ │ + self.receive_queue = asyncio.Queue() │ │ │ │ │ + self.create_task(self.run_wrapper(options), name=f'{self.__class__.__name__}.run_wrapper({options})') │ │ │ │ │ │ │ │ │ │ - _loop: asyncio.AbstractEventLoop │ │ │ │ │ - _fd: int │ │ │ │ │ - _contents: 'list[bytes]' │ │ │ │ │ + def do_done(self): │ │ │ │ │ + self.receive_queue.put_nowait(b'') │ │ │ │ │ │ │ │ │ │ - def __init__(self, loop: asyncio.AbstractEventLoop, fd: int): │ │ │ │ │ - self._loop = loop │ │ │ │ │ - self._fd = -1 # in case dup() raises an exception │ │ │ │ │ - self._contents = [] │ │ │ │ │ + def do_close(self): │ │ │ │ │ + # we might have already sent EOF for done, but two EOFs won't hurt anyone │ │ │ │ │ + self.receive_queue.put_nowait(b'') │ │ │ │ │ │ │ │ │ │ - self._fd = os.dup(fd) │ │ │ │ │ + def do_ping(self, message): │ │ │ │ │ + self.receive_queue.put_nowait(message) │ │ │ │ │ │ │ │ │ │ - os.set_blocking(self._fd, False) │ │ │ │ │ - loop.add_reader(self._fd, self._read_ready) │ │ │ │ │ + def do_data(self, data): │ │ │ │ │ + if not isinstance(data, bytes): │ │ │ │ │ + # this will persist past this callback, so make sure we take our │ │ │ │ │ + # own copy, in case this was a memoryview into a bytearray. │ │ │ │ │ + data = bytes(data) │ │ │ │ │ │ │ │ │ │ - def _read_ready(self) -> None: │ │ │ │ │ - try: │ │ │ │ │ - data = os.read(self._fd, 8192) │ │ │ │ │ - except BlockingIOError: # pragma: no cover │ │ │ │ │ - return │ │ │ │ │ - except OSError: │ │ │ │ │ - # all other errors -> EOF │ │ │ │ │ - data = b'' │ │ │ │ │ + self.receive_queue.put_nowait(data) │ │ │ │ │ │ │ │ │ │ - if data != b'': │ │ │ │ │ - self._contents.append(data) │ │ │ │ │ - else: │ │ │ │ │ - self.close() │ │ │ │ │ │ │ │ │ │ - def _is_ready(self) -> bool: │ │ │ │ │ - if self._fd == -1: │ │ │ │ │ - return False │ │ │ │ │ - return select.select([self._fd], [], [], 0) != ([], [], []) │ │ │ │ │ +class GeneratorChannel(Channel): │ │ │ │ │ + """A trivial Channel subclass for sending data from a generator with flow control. │ │ │ │ │ │ │ │ │ │ - def get(self, *, reset: bool = False) -> bytes: │ │ │ │ │ - while self._is_ready(): │ │ │ │ │ - self._read_ready() │ │ │ │ │ + Calls the .do_yield_data() generator with the options from the open message │ │ │ │ │ + and sends the data which it yields. If the generator returns a value it │ │ │ │ │ + will be used for the close message. │ │ │ │ │ + """ │ │ │ │ │ + DataGenerator = Generator[bytes, None, Optional[JsonObject]] │ │ │ │ │ + __generator: DataGenerator │ │ │ │ │ │ │ │ │ │ - result = b''.join(self._contents) │ │ │ │ │ - if reset: │ │ │ │ │ - self._contents = [] │ │ │ │ │ - return result │ │ │ │ │ + def do_yield_data(self, options: JsonObject) -> 'DataGenerator': │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ │ │ │ │ │ - def close(self) -> None: │ │ │ │ │ - if self._fd != -1: │ │ │ │ │ - self._loop.remove_reader(self._fd) │ │ │ │ │ - os.close(self._fd) │ │ │ │ │ - self._fd = -1 │ │ │ │ │ + def do_open(self, options: JsonObject) -> None: │ │ │ │ │ + self.__generator = self.do_yield_data(options) │ │ │ │ │ + self.do_resume_send() │ │ │ │ │ │ │ │ │ │ - def __del__(self) -> None: │ │ │ │ │ - self.close() │ │ │ │ │ + def do_resume_send(self) -> None: │ │ │ │ │ + try: │ │ │ │ │ + while self.send_data(next(self.__generator)): │ │ │ │ │ + pass │ │ │ │ │ + except StopIteration as stop: │ │ │ │ │ + self.done() │ │ │ │ │ + self.close(stop.value) │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/config.py': br'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/jsonutil.py': r'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2023 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ @@ -938,404 +1207,412 @@ │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import configparser │ │ │ │ │ -import logging │ │ │ │ │ -import os │ │ │ │ │ -from pathlib import Path │ │ │ │ │ +from enum import Enum │ │ │ │ │ +from typing import Callable, Dict, List, Mapping, Optional, Sequence, Type, TypeVar, Union │ │ │ │ │ │ │ │ │ │ -from cockpit._vendor.systemd_ctypes import bus │ │ │ │ │ +JsonLiteral = Union[str, float, bool, None] │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ +# immutable │ │ │ │ │ +JsonValue = Union['JsonObject', Sequence['JsonValue'], JsonLiteral] │ │ │ │ │ +JsonObject = Mapping[str, JsonValue] │ │ │ │ │ │ │ │ │ │ -XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config')) │ │ │ │ │ -DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit' │ │ │ │ │ +# mutable │ │ │ │ │ +JsonDocument = Union['JsonDict', 'JsonList', JsonLiteral] │ │ │ │ │ +JsonDict = Dict[str, JsonDocument] │ │ │ │ │ +JsonList = List[JsonDocument] │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def lookup_config(filename: str) -> Path: │ │ │ │ │ - config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc').split(':') │ │ │ │ │ - fallback = None │ │ │ │ │ - for config_dir in config_dirs: │ │ │ │ │ - config_path = Path(config_dir, 'cockpit', filename) │ │ │ │ │ - if not fallback: │ │ │ │ │ - fallback = config_path │ │ │ │ │ - if config_path.exists(): │ │ │ │ │ - logger.debug('lookup_config(%s): found %s', filename, config_path) │ │ │ │ │ - return config_path │ │ │ │ │ +DT = TypeVar('DT') │ │ │ │ │ +T = TypeVar('T') │ │ │ │ │ │ │ │ │ │ - # default to the first entry in XDG_CONFIG_DIRS; that's not according to the spec, │ │ │ │ │ - # but what Cockpit has done for years │ │ │ │ │ - logger.debug('lookup_config(%s): defaulting to %s', filename, fallback) │ │ │ │ │ - assert fallback # mypy; config_dirs always has at least one string │ │ │ │ │ - return fallback │ │ │ │ │ │ │ │ │ │ +class JsonError(Exception): │ │ │ │ │ + value: object │ │ │ │ │ │ │ │ │ │ -class Config(bus.Object, interface='cockpit.Config'): │ │ │ │ │ - def __init__(self): │ │ │ │ │ - self.reload() │ │ │ │ │ + def __init__(self, value: object, msg: str): │ │ │ │ │ + super().__init__(msg) │ │ │ │ │ + self.value = value │ │ │ │ │ │ │ │ │ │ - @bus.Interface.Method(out_types='s', in_types='ss') │ │ │ │ │ - def get_string(self, section, key): │ │ │ │ │ - try: │ │ │ │ │ - return self.config[section][key] │ │ │ │ │ - except KeyError as exc: │ │ │ │ │ - raise bus.BusError('cockpit.Config.KeyError', f'key {key} in section {section} does not exist') from exc │ │ │ │ │ │ │ │ │ │ - @bus.Interface.Method(out_types='u', in_types='ssuuu') │ │ │ │ │ - def get_u_int(self, section, key, default, maximum, minimum): │ │ │ │ │ - try: │ │ │ │ │ - value = self.config[section][key] │ │ │ │ │ - except KeyError: │ │ │ │ │ - return default │ │ │ │ │ +def typechecked(value: JsonValue, expected_type: Type[T]) -> T: │ │ │ │ │ + """Ensure a JSON value has the expected type, returning it if so.""" │ │ │ │ │ + if not isinstance(value, expected_type): │ │ │ │ │ + raise JsonError(value, f'must have type {expected_type.__name__}') │ │ │ │ │ + return value │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - int_val = int(value) │ │ │ │ │ - except ValueError: │ │ │ │ │ - logger.warning('cockpit.conf: [%s] %s is not an integer', section, key) │ │ │ │ │ + │ │ │ │ │ +# We can't use None as a sentinel because it's often the actual default value │ │ │ │ │ +# EllipsisType is difficult because it's not available before 3.10. │ │ │ │ │ +# See https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions │ │ │ │ │ +class _Empty(Enum): │ │ │ │ │ + TOKEN = 0 │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +_empty = _Empty.TOKEN │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def _get(obj: JsonObject, cast: Callable[[JsonValue], T], key: str, default: Union[DT, _Empty]) -> Union[T, DT]: │ │ │ │ │ + try: │ │ │ │ │ + return cast(obj[key]) │ │ │ │ │ + except KeyError: │ │ │ │ │ + if default is not _empty: │ │ │ │ │ return default │ │ │ │ │ + raise JsonError(obj, f"attribute '{key}' required") from None │ │ │ │ │ + except JsonError as exc: │ │ │ │ │ + target = f"attribute '{key}'" + (' elements:' if exc.value is not obj[key] else ':') │ │ │ │ │ + raise JsonError(obj, f"{target} {exc!s}") from exc │ │ │ │ │ │ │ │ │ │ - return min(max(int_val, minimum), maximum) │ │ │ │ │ │ │ │ │ │ - @bus.Interface.Method() │ │ │ │ │ - def reload(self): │ │ │ │ │ - self.config = configparser.ConfigParser(interpolation=None) │ │ │ │ │ - cockpit_conf = lookup_config('cockpit.conf') │ │ │ │ │ - logger.debug("cockpit.Config: loading %s", cockpit_conf) │ │ │ │ │ - # this may not exist, but it's ok to not have a config file and thus leave self.config empty │ │ │ │ │ - self.config.read(cockpit_conf) │ │ │ │ │ +def get_bool(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, bool]: │ │ │ │ │ + return _get(obj, lambda v: typechecked(v, bool), key, default) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class Environment(bus.Object, interface='cockpit.Environment'): │ │ │ │ │ - variables = bus.Interface.Property('a{ss}') │ │ │ │ │ +def get_int(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, int]: │ │ │ │ │ + return _get(obj, lambda v: typechecked(v, int), key, default) │ │ │ │ │ │ │ │ │ │ - @variables.getter │ │ │ │ │ - def get_variables(self): │ │ │ │ │ - return os.environ.copy() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/bridge.py': r'''# This file is part of Cockpit. │ │ │ │ │ + │ │ │ │ │ +def get_str(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, str]: │ │ │ │ │ + return _get(obj, lambda v: typechecked(v, str), key, default) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Optional[str]: │ │ │ │ │ + return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]: │ │ │ │ │ + return _get(obj, lambda v: typechecked(v, dict), key, default) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def get_object( │ │ │ │ │ + obj: JsonObject, │ │ │ │ │ + key: str, │ │ │ │ │ + constructor: Callable[[JsonObject], T], │ │ │ │ │ + default: Union[DT, _Empty] = _empty │ │ │ │ │ +) -> Union[DT, T]: │ │ │ │ │ + return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def get_strv(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, Sequence[str]]: │ │ │ │ │ + def as_strv(value: JsonValue) -> Sequence[str]: │ │ │ │ │ + return tuple(typechecked(item, str) for item in typechecked(value, list)) │ │ │ │ │ + return _get(obj, as_strv, key, default) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def get_objv(obj: JsonObject, key: str, constructor: Callable[[JsonObject], T]) -> Union[DT, Sequence[T]]: │ │ │ │ │ + def as_objv(value: JsonValue) -> Sequence[T]: │ │ │ │ │ + return tuple(constructor(typechecked(item, dict)) for item in typechecked(value, list)) │ │ │ │ │ + return _get(obj, as_objv, key, ()) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def create_object(message: 'JsonObject | None', kwargs: JsonObject) -> JsonObject: │ │ │ │ │ + """Constructs a JSON object based on message and kwargs. │ │ │ │ │ + │ │ │ │ │ + If only message is given, it is returned, unmodified. If message is None, │ │ │ │ │ + it is equivalent to an empty dictionary. A copy is always made. │ │ │ │ │ + │ │ │ │ │ + If kwargs are present, then any underscore ('_') present in a key name is │ │ │ │ │ + rewritten to a dash ('-'). This is intended to bridge between the required │ │ │ │ │ + Python syntax when providing kwargs and idiomatic JSON (which uses '-' for │ │ │ │ │ + attributes). These values override values in message. │ │ │ │ │ + │ │ │ │ │ + The idea is that `message` should be used for passing data along, and │ │ │ │ │ + kwargs used for data originating at a given call site, possibly including │ │ │ │ │ + modifications to an original message. │ │ │ │ │ + """ │ │ │ │ │ + result = dict(message or {}) │ │ │ │ │ + │ │ │ │ │ + for key, value in kwargs.items(): │ │ │ │ │ + # rewrite '_' (necessary in Python syntax kwargs list) to '-' (idiomatic JSON) │ │ │ │ │ + json_key = key.replace('_', '-') │ │ │ │ │ + result[json_key] = value │ │ │ │ │ + │ │ │ │ │ + return result │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def json_merge_patch(current: JsonObject, patch: JsonObject) -> JsonObject: │ │ │ │ │ + """Perform a JSON merge patch (RFC 7396) using 'current' and 'patch'. │ │ │ │ │ + Neither of the original dictionaries is modified — the result is returned. │ │ │ │ │ + """ │ │ │ │ │ + # Always take a copy ('result') — we never modify the input ('current') │ │ │ │ │ + result = dict(current) │ │ │ │ │ + for key, patch_value in patch.items(): │ │ │ │ │ + if isinstance(patch_value, Mapping): │ │ │ │ │ + current_value = current.get(key, None) │ │ │ │ │ + if not isinstance(current_value, Mapping): │ │ │ │ │ + current_value = {} │ │ │ │ │ + result[key] = json_merge_patch(current_value, patch_value) │ │ │ │ │ + elif patch_value is not None: │ │ │ │ │ + result[key] = patch_value │ │ │ │ │ + else: │ │ │ │ │ + result.pop(key, None) │ │ │ │ │ + │ │ │ │ │ + return result │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def json_merge_and_filter_patch(current: JsonDict, patch: JsonDict) -> None: │ │ │ │ │ + """Perform a JSON merge patch (RFC 7396) modifying 'current' with 'patch'. │ │ │ │ │ + Also modifies 'patch' to remove redundant operations. │ │ │ │ │ + """ │ │ │ │ │ + for key, patch_value in tuple(patch.items()): │ │ │ │ │ + current_value = current.get(key, None) │ │ │ │ │ + │ │ │ │ │ + if isinstance(patch_value, dict): │ │ │ │ │ + if not isinstance(current_value, dict): │ │ │ │ │ + current[key] = current_value = {} │ │ │ │ │ + json_merge_and_filter_patch(current_value, patch_value) │ │ │ │ │ + else: │ │ │ │ │ + json_merge_and_filter_patch(current_value, patch_value) │ │ │ │ │ + if not patch_value: │ │ │ │ │ + del patch[key] │ │ │ │ │ + elif current_value == patch_value: │ │ │ │ │ + del patch[key] │ │ │ │ │ + elif patch_value is not None: │ │ │ │ │ + current[key] = patch_value │ │ │ │ │ + else: │ │ │ │ │ + del current[key] │ │ │ │ │ +'''.encode('utf-8'), │ │ │ │ │ + 'cockpit/beipack.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import argparse │ │ │ │ │ -import asyncio │ │ │ │ │ -import contextlib │ │ │ │ │ -import json │ │ │ │ │ import logging │ │ │ │ │ -import os │ │ │ │ │ -import pwd │ │ │ │ │ -import shlex │ │ │ │ │ -import socket │ │ │ │ │ -import stat │ │ │ │ │ -import subprocess │ │ │ │ │ -from typing import Iterable, List, Optional, Sequence, Tuple, Type │ │ │ │ │ +import lzma │ │ │ │ │ +from typing import List, Sequence, Tuple │ │ │ │ │ │ │ │ │ │ -from cockpit._vendor.ferny import interaction_client │ │ │ │ │ -from cockpit._vendor.systemd_ctypes import bus, run_async │ │ │ │ │ +from cockpit._vendor import ferny │ │ │ │ │ +from cockpit._vendor.bei import beipack │ │ │ │ │ │ │ │ │ │ -from . import polyfills │ │ │ │ │ -from ._version import __version__ │ │ │ │ │ -from .channel import ChannelRoutingRule │ │ │ │ │ -from .channels import CHANNEL_TYPES │ │ │ │ │ -from .config import Config, Environment │ │ │ │ │ -from .internal_endpoints import EXPORTS │ │ │ │ │ -from .jsonutil import JsonError, JsonObject, get_dict │ │ │ │ │ -from .packages import BridgeConfig, Packages, PackagesListener │ │ │ │ │ -from .peer import PeersRoutingRule │ │ │ │ │ -from .remote import HostRoutingRule │ │ │ │ │ -from .router import Router │ │ │ │ │ -from .superuser import SuperuserRoutingRule │ │ │ │ │ -from .transports import StdioTransport │ │ │ │ │ +from .data import read_cockpit_data_file │ │ │ │ │ +from .peer import Peer, PeerError │ │ │ │ │ │ │ │ │ │ logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class InternalBus: │ │ │ │ │ - exportees: List[bus.Slot] │ │ │ │ │ - │ │ │ │ │ - def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]): │ │ │ │ │ - client_socket, server_socket = socket.socketpair() │ │ │ │ │ - self.client = bus.Bus.new(fd=client_socket.detach()) │ │ │ │ │ - self.server = bus.Bus.new(fd=server_socket.detach(), server=True) │ │ │ │ │ - self.exportees = [self.server.add_object(path, cls()) for path, cls in exports] │ │ │ │ │ - │ │ │ │ │ - def export(self, path: str, obj: bus.BaseObject) -> None: │ │ │ │ │ - self.exportees.append(self.server.add_object(path, obj)) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class Bridge(Router, PackagesListener): │ │ │ │ │ - internal_bus: InternalBus │ │ │ │ │ - packages: Optional[Packages] │ │ │ │ │ - bridge_configs: Sequence[BridgeConfig] │ │ │ │ │ - args: argparse.Namespace │ │ │ │ │ - │ │ │ │ │ - def __init__(self, args: argparse.Namespace): │ │ │ │ │ - self.internal_bus = InternalBus(EXPORTS) │ │ │ │ │ - self.bridge_configs = [] │ │ │ │ │ - self.args = args │ │ │ │ │ +def get_bridge_beipack_xz() -> Tuple[str, bytes]: │ │ │ │ │ + try: │ │ │ │ │ + bridge_beipack_xz = read_cockpit_data_file('cockpit-bridge.beipack.xz') │ │ │ │ │ + logger.debug('Got pre-built cockpit-bridge.beipack.xz') │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + logger.debug('Pre-built cockpit-bridge.beipack.xz; building our own.') │ │ │ │ │ + # beipack ourselves │ │ │ │ │ + cockpit_contents = beipack.collect_module('cockpit', recursive=True) │ │ │ │ │ + bridge_beipack = beipack.pack(cockpit_contents, entrypoint='cockpit.bridge:main', args='beipack=True') │ │ │ │ │ + bridge_beipack_xz = lzma.compress(bridge_beipack.encode()) │ │ │ │ │ + logger.debug(' ... done!') │ │ │ │ │ │ │ │ │ │ - self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged) │ │ │ │ │ - self.internal_bus.export('/superuser', self.superuser_rule) │ │ │ │ │ + return 'cockpit/data/cockpit-bridge.beipack.xz', bridge_beipack_xz │ │ │ │ │ │ │ │ │ │ - self.internal_bus.export('/config', Config()) │ │ │ │ │ - self.internal_bus.export('/environment', Environment()) │ │ │ │ │ │ │ │ │ │ - self.peers_rule = PeersRoutingRule(self) │ │ │ │ │ +class BridgeBeibootHelper(ferny.InteractionHandler): │ │ │ │ │ + # ferny.InteractionHandler ClassVar │ │ │ │ │ + commands = ['beiboot.provide', 'beiboot.exc'] │ │ │ │ │ │ │ │ │ │ - if args.beipack: │ │ │ │ │ - # Some special stuff for beipack │ │ │ │ │ - self.superuser_rule.set_configs(( │ │ │ │ │ - BridgeConfig({ │ │ │ │ │ - "privileged": True, │ │ │ │ │ - "spawn": ["sudo", "-k", "-A", "python3", "-ic", "# cockpit-bridge", "--privileged"], │ │ │ │ │ - "environ": ["SUDO_ASKPASS=ferny-askpass"], │ │ │ │ │ - }), │ │ │ │ │ - )) │ │ │ │ │ - self.packages = None │ │ │ │ │ - elif args.privileged: │ │ │ │ │ - self.packages = None │ │ │ │ │ - else: │ │ │ │ │ - self.packages = Packages(self) │ │ │ │ │ - self.internal_bus.export('/packages', self.packages) │ │ │ │ │ - self.packages_loaded() │ │ │ │ │ + peer: Peer │ │ │ │ │ + payload: bytes │ │ │ │ │ + steps: Sequence[Tuple[str, Sequence[object]]] │ │ │ │ │ │ │ │ │ │ - super().__init__([ │ │ │ │ │ - HostRoutingRule(self), │ │ │ │ │ - self.superuser_rule, │ │ │ │ │ - ChannelRoutingRule(self, CHANNEL_TYPES), │ │ │ │ │ - self.peers_rule, │ │ │ │ │ - ]) │ │ │ │ │ + def __init__(self, peer: Peer, args: Sequence[str] = ()) -> None: │ │ │ │ │ + filename, payload = get_bridge_beipack_xz() │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def get_os_release(): │ │ │ │ │ - try: │ │ │ │ │ - file = open('/etc/os-release', encoding='utf-8') │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - try: │ │ │ │ │ - file = open('/usr/lib/os-release', encoding='utf-8') │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - logger.warning("Neither /etc/os-release nor /usr/lib/os-release exists") │ │ │ │ │ - return {} │ │ │ │ │ + self.peer = peer │ │ │ │ │ + self.payload = payload │ │ │ │ │ + self.steps = (('boot_xz', (filename, len(payload), tuple(args))),) │ │ │ │ │ │ │ │ │ │ - os_release = {} │ │ │ │ │ - for line in file.readlines(): │ │ │ │ │ - line = line.strip() │ │ │ │ │ - if not line or line.startswith('#'): │ │ │ │ │ - continue │ │ │ │ │ + async def run_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None: │ │ │ │ │ + logger.debug('Got ferny request %s %s %s %s', command, args, fds, stderr) │ │ │ │ │ + if command == 'beiboot.provide': │ │ │ │ │ try: │ │ │ │ │ - k, v = line.split('=') │ │ │ │ │ - (v_parsed, ) = shlex.split(v) # expect exactly one token │ │ │ │ │ - except ValueError: │ │ │ │ │ - logger.warning('Ignoring invalid line in os-release: %r', line) │ │ │ │ │ - continue │ │ │ │ │ - os_release[k] = v_parsed │ │ │ │ │ - return os_release │ │ │ │ │ - │ │ │ │ │ - def do_init(self, message: JsonObject) -> None: │ │ │ │ │ - # we're only interested in the case where this is a dict, but │ │ │ │ │ - # 'superuser' may well be `False` and that's not an error │ │ │ │ │ - with contextlib.suppress(JsonError): │ │ │ │ │ - superuser = get_dict(message, 'superuser') │ │ │ │ │ - self.superuser_rule.init(superuser) │ │ │ │ │ - │ │ │ │ │ - def do_send_init(self) -> None: │ │ │ │ │ - init_args = { │ │ │ │ │ - 'capabilities': {'explicit-superuser': True}, │ │ │ │ │ - 'command': 'init', │ │ │ │ │ - 'os-release': self.get_os_release(), │ │ │ │ │ - 'version': 1, │ │ │ │ │ - } │ │ │ │ │ - │ │ │ │ │ - if self.packages is not None: │ │ │ │ │ - init_args['packages'] = {p: None for p in self.packages.packages} │ │ │ │ │ - │ │ │ │ │ - self.write_control(init_args) │ │ │ │ │ - │ │ │ │ │ - # PackagesListener interface │ │ │ │ │ - def packages_loaded(self) -> None: │ │ │ │ │ - assert self.packages │ │ │ │ │ - bridge_configs = self.packages.get_bridge_configs() │ │ │ │ │ - if self.bridge_configs != bridge_configs: │ │ │ │ │ - self.superuser_rule.set_configs(bridge_configs) │ │ │ │ │ - self.peers_rule.set_configs(bridge_configs) │ │ │ │ │ - self.bridge_configs = bridge_configs │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -async def run(args) -> None: │ │ │ │ │ - logger.debug("Hi. How are you today?") │ │ │ │ │ - │ │ │ │ │ - # Unit tests require this │ │ │ │ │ - me = pwd.getpwuid(os.getuid()) │ │ │ │ │ - os.environ['HOME'] = me.pw_dir │ │ │ │ │ - os.environ['SHELL'] = me.pw_shell │ │ │ │ │ - os.environ['USER'] = me.pw_name │ │ │ │ │ - │ │ │ │ │ - logger.debug('Starting the router.') │ │ │ │ │ - router = Bridge(args) │ │ │ │ │ - StdioTransport(asyncio.get_running_loop(), router) │ │ │ │ │ + size, = args │ │ │ │ │ + assert size == len(self.payload) │ │ │ │ │ + except (AssertionError, ValueError) as exc: │ │ │ │ │ + raise PeerError('internal-error', message=f'ferny interaction error {exc!s}') from exc │ │ │ │ │ │ │ │ │ │ - logger.debug('Startup done. Looping until connection closes.') │ │ │ │ │ + assert self.peer.transport is not None │ │ │ │ │ + logger.debug('Writing %d bytes of payload', len(self.payload)) │ │ │ │ │ + self.peer.transport.write(self.payload) │ │ │ │ │ + elif command == 'beiboot.exc': │ │ │ │ │ + raise PeerError('internal-error', message=f'Remote exception: {args[0]}') │ │ │ │ │ + else: │ │ │ │ │ + raise PeerError('internal-error', message=f'Unexpected ferny interaction command {command}') │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/internal_endpoints.py': br'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - await router.communicate() │ │ │ │ │ - except (BrokenPipeError, ConnectionResetError): │ │ │ │ │ - # not unexpected if the peer doesn't hang up cleanly │ │ │ │ │ - pass │ │ │ │ │ +import asyncio │ │ │ │ │ +import glob │ │ │ │ │ +import grp │ │ │ │ │ +import json │ │ │ │ │ +import logging │ │ │ │ │ +import os │ │ │ │ │ +import pwd │ │ │ │ │ +from pathlib import Path │ │ │ │ │ +from typing import Dict, Optional │ │ │ │ │ │ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch │ │ │ │ │ │ │ │ │ │ -def try_to_receive_stderr(): │ │ │ │ │ - try: │ │ │ │ │ - ours, theirs = socket.socketpair() │ │ │ │ │ - with ours: │ │ │ │ │ - with theirs: │ │ │ │ │ - interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()]) │ │ │ │ │ - _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1) │ │ │ │ │ - except OSError: │ │ │ │ │ - return │ │ │ │ │ +from . import config │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - stderr_fd, = fds │ │ │ │ │ - # We're about to abruptly drop our end of the stderr socketpair that we │ │ │ │ │ - # share with the ferny agent. ferny would normally treat that as an │ │ │ │ │ - # unexpected error. Instruct it to do a clean exit, instead. │ │ │ │ │ - interaction_client.command(2, 'ferny.end') │ │ │ │ │ - os.dup2(stderr_fd, 2) │ │ │ │ │ - finally: │ │ │ │ │ - for fd in fds: │ │ │ │ │ - os.close(fd) │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def setup_journald() -> bool: │ │ │ │ │ - # If stderr is a socket, prefer systemd-journal logging. This covers the │ │ │ │ │ - # case we're already connected to the journal but also the case where we're │ │ │ │ │ - # talking to the ferny agent, while leaving logging to file or terminal │ │ │ │ │ - # unaffected. │ │ │ │ │ - if not stat.S_ISSOCK(os.fstat(2).st_mode): │ │ │ │ │ - # not a socket? Don't redirect. │ │ │ │ │ - return False │ │ │ │ │ +class cockpit_LoginMessages(bus.Object): │ │ │ │ │ + messages: Optional[str] = None │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - import systemd.journal # type: ignore[import] │ │ │ │ │ - except ImportError: │ │ │ │ │ - # No python3-systemd? Don't redirect. │ │ │ │ │ - return False │ │ │ │ │ + def __init__(self): │ │ │ │ │ + fdstr = os.environ.pop('COCKPIT_LOGIN_MESSAGES_MEMFD', None) │ │ │ │ │ + if fdstr is None: │ │ │ │ │ + logger.debug("COCKPIT_LOGIN_MESSAGES_MEMFD wasn't set. No login messages today.") │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - logging.root.addHandler(systemd.journal.JournalHandler()) │ │ │ │ │ - return True │ │ │ │ │ + logger.debug("Trying to read login messages from fd %s", fdstr) │ │ │ │ │ + try: │ │ │ │ │ + with open(int(fdstr), 'r') as login_messages: │ │ │ │ │ + login_messages.seek(0) │ │ │ │ │ + self.messages = login_messages.read() │ │ │ │ │ + except (ValueError, OSError, UnicodeDecodeError) as exc: │ │ │ │ │ + # ValueError - the envvar wasn't an int │ │ │ │ │ + # OSError - the fd wasn't open, or other read failure │ │ │ │ │ + # UnicodeDecodeError - didn't contain utf-8 │ │ │ │ │ + # For all of these, we simply failed to get the message. │ │ │ │ │ + logger.debug("Reading login messages failed: %s", exc) │ │ │ │ │ + else: │ │ │ │ │ + logger.debug("Successfully read login messages: %s", self.messages) │ │ │ │ │ │ │ │ │ │ + @bus.Interface.Method(out_types=['s']) │ │ │ │ │ + def get(self): │ │ │ │ │ + return self.messages or '{}' │ │ │ │ │ │ │ │ │ │ -def setup_logging(*, debug: bool) -> None: │ │ │ │ │ - """Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set""" │ │ │ │ │ + @bus.Interface.Method(out_types=[]) │ │ │ │ │ + def dismiss(self): │ │ │ │ │ + self.messages = None │ │ │ │ │ │ │ │ │ │ - modules = os.getenv('COCKPIT_DEBUG', '') │ │ │ │ │ │ │ │ │ │ - # Either setup logging via journal or via formatted messages to stderr │ │ │ │ │ - if not setup_journald(): │ │ │ │ │ - logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s') │ │ │ │ │ +class cockpit_Machines(bus.Object): │ │ │ │ │ + path: Path │ │ │ │ │ + watch: pathwatch.PathWatch │ │ │ │ │ + pending_notify: Optional[asyncio.Handle] │ │ │ │ │ │ │ │ │ │ - if debug or modules == 'all': │ │ │ │ │ - logging.getLogger().setLevel(level=logging.DEBUG) │ │ │ │ │ - elif modules: │ │ │ │ │ - for module in modules.split(','): │ │ │ │ │ - module = module.strip() │ │ │ │ │ - if not module: │ │ │ │ │ - continue │ │ │ │ │ + # D-Bus implementation │ │ │ │ │ + machines = bus.Interface.Property('a{sa{sv}}') │ │ │ │ │ │ │ │ │ │ - logging.getLogger(module).setLevel(logging.DEBUG) │ │ │ │ │ + @machines.getter │ │ │ │ │ + def get_machines(self) -> Dict[str, Dict[str, Variant]]: │ │ │ │ │ + results: Dict[str, Dict[str, Variant]] = {} │ │ │ │ │ │ │ │ │ │ + for filename in glob.glob(f'{self.path}/*.json'): │ │ │ │ │ + with open(filename) as fp: │ │ │ │ │ + try: │ │ │ │ │ + contents = json.load(fp) │ │ │ │ │ + except json.JSONDecodeError: │ │ │ │ │ + logger.warning('Invalid JSON in file %s. Ignoring.', filename) │ │ │ │ │ + continue │ │ │ │ │ + # merge │ │ │ │ │ + for hostname, attrs in contents.items(): │ │ │ │ │ + results[hostname] = {key: Variant(value) for key, value in attrs.items()} │ │ │ │ │ │ │ │ │ │ -def start_ssh_agent() -> None: │ │ │ │ │ - # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust, │ │ │ │ │ - # but it gets cleared on setgid ssh-agent, which some distros still do │ │ │ │ │ - try: │ │ │ │ │ - proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'], │ │ │ │ │ - stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True) │ │ │ │ │ - assert proc.stdout is not None │ │ │ │ │ + return results │ │ │ │ │ │ │ │ │ │ - # Wait for the agent to write at least one line and look for the │ │ │ │ │ - # listener socket. If we fail to find it, kill the agent — something │ │ │ │ │ - # went wrong. │ │ │ │ │ - for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True): │ │ │ │ │ - if token.startswith('SSH_AUTH_SOCK='): │ │ │ │ │ - os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1) │ │ │ │ │ - break │ │ │ │ │ - else: │ │ │ │ │ - proc.terminate() │ │ │ │ │ - proc.wait() │ │ │ │ │ + @bus.Interface.Method(in_types=['s', 's', 'a{sv}']) │ │ │ │ │ + def update(self, filename: str, hostname: str, attrs: Dict[str, Variant]) -> None: │ │ │ │ │ + try: │ │ │ │ │ + with self.path.joinpath(filename).open() as fp: │ │ │ │ │ + contents = json.load(fp) │ │ │ │ │ + except json.JSONDecodeError as exc: │ │ │ │ │ + # Refuse to replace corrupted file │ │ │ │ │ + raise bus.BusError('cockpit.Machines.Error', f'File {filename} is in invalid format: {exc}.') from exc │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + # But an empty file is an expected case │ │ │ │ │ + contents = {} │ │ │ │ │ │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - logger.debug("Couldn't start ssh-agent (FileNotFoundError)") │ │ │ │ │ + contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()}) │ │ │ │ │ │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - logger.warning("Could not start ssh-agent: %s", exc) │ │ │ │ │ + self.path.mkdir(parents=True, exist_ok=True) │ │ │ │ │ + with open(self.path.joinpath(filename), 'w') as fp: │ │ │ │ │ + json.dump(contents, fp, indent=2) │ │ │ │ │ │ │ │ │ │ + def notify(self): │ │ │ │ │ + def _notify_now(): │ │ │ │ │ + self.properties_changed('cockpit.Machines', {}, ['Machines']) │ │ │ │ │ + self.pending_notify = None │ │ │ │ │ │ │ │ │ │ -def main(*, beipack: bool = False) -> None: │ │ │ │ │ - polyfills.install() │ │ │ │ │ + # avoid a flurry of update notifications │ │ │ │ │ + if self.pending_notify is None: │ │ │ │ │ + self.pending_notify = asyncio.get_running_loop().call_later(1.0, _notify_now) │ │ │ │ │ │ │ │ │ │ - parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.') │ │ │ │ │ - parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge') │ │ │ │ │ - parser.add_argument('--packages', action='store_true', help='Show Cockpit package information') │ │ │ │ │ - parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information') │ │ │ │ │ - parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)') │ │ │ │ │ - parser.add_argument('--version', action='store_true', help='Show Cockpit version information') │ │ │ │ │ - args = parser.parse_args() │ │ │ │ │ + # inotify events │ │ │ │ │ + def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None: │ │ │ │ │ + self.notify() │ │ │ │ │ │ │ │ │ │ - # This is determined by who calls us │ │ │ │ │ - args.beipack = beipack │ │ │ │ │ + def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None: │ │ │ │ │ + self.notify() │ │ │ │ │ │ │ │ │ │ - # If we were run with --privileged then our stderr is currently being │ │ │ │ │ - # consumed by the main bridge looking for startup-related error messages. │ │ │ │ │ - # Let's switch back to the original stderr stream, which has a side-effect │ │ │ │ │ - # of indicating that our startup is more or less complete. Any errors │ │ │ │ │ - # after this point will land in the journal. │ │ │ │ │ - if args.privileged: │ │ │ │ │ - try_to_receive_stderr() │ │ │ │ │ + def __init__(self): │ │ │ │ │ + self.path = config.lookup_config('machines.d') │ │ │ │ │ │ │ │ │ │ - setup_logging(debug=args.debug) │ │ │ │ │ + # ignore the first callback │ │ │ │ │ + self.pending_notify = ... │ │ │ │ │ + self.watch = pathwatch.PathWatch(str(self.path), self) │ │ │ │ │ + self.pending_notify = None │ │ │ │ │ │ │ │ │ │ - # Special modes │ │ │ │ │ - if args.packages: │ │ │ │ │ - Packages().show() │ │ │ │ │ - return │ │ │ │ │ - elif args.version: │ │ │ │ │ - print(f'Version: {__version__}\nProtocol: 1') │ │ │ │ │ - return │ │ │ │ │ - elif args.bridges: │ │ │ │ │ - print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2)) │ │ │ │ │ - return │ │ │ │ │ │ │ │ │ │ - # The privileged bridge doesn't need ssh-agent, but the main one does │ │ │ │ │ - if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged: │ │ │ │ │ - start_ssh_agent() │ │ │ │ │ +class cockpit_User(bus.Object): │ │ │ │ │ + name = bus.Interface.Property('s', value='') │ │ │ │ │ + full = bus.Interface.Property('s', value='') │ │ │ │ │ + id = bus.Interface.Property('i', value=0) │ │ │ │ │ + home = bus.Interface.Property('s', value='') │ │ │ │ │ + shell = bus.Interface.Property('s', value='') │ │ │ │ │ + groups = bus.Interface.Property('as', value=[]) │ │ │ │ │ │ │ │ │ │ - # asyncio.run() shim for Python 3.6 support │ │ │ │ │ - run_async(run(args), debug=args.debug) │ │ │ │ │ + def __init__(self): │ │ │ │ │ + user = pwd.getpwuid(os.getuid()) │ │ │ │ │ + self.name = user.pw_name │ │ │ │ │ + self.full = user.pw_gecos │ │ │ │ │ + self.id = user.pw_uid │ │ │ │ │ + self.home = user.pw_dir │ │ │ │ │ + self.shell = user.pw_shell │ │ │ │ │ + self.groups = [gr.gr_name for gr in grp.getgrall() if user.pw_name in gr.gr_mem] │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -if __name__ == '__main__': │ │ │ │ │ - main() │ │ │ │ │ -'''.encode('utf-8'), │ │ │ │ │ - 'cockpit/_version.py': br'''__version__ = '311' │ │ │ │ │ +EXPORTS = [ │ │ │ │ │ + ('/LoginMessages', cockpit_LoginMessages), │ │ │ │ │ + ('/machines', cockpit_Machines), │ │ │ │ │ + ('/user', cockpit_User), │ │ │ │ │ +] │ │ │ │ │ ''', │ │ │ │ │ 'cockpit/polkit.py': r'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2023 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ @@ -1501,171 +1778,71 @@ │ │ │ │ │ 'org.freedesktop.PolicyKit1.Authority', │ │ │ │ │ 'UnregisterAuthenticationAgent', │ │ │ │ │ '(sa{sv})s', │ │ │ │ │ self.subject, AGENT_DBUS_PATH) │ │ │ │ │ self.agent_slot.cancel() │ │ │ │ │ logger.debug('Unregistered agent for %r', self.subject) │ │ │ │ │ '''.encode('utf-8'), │ │ │ │ │ - 'cockpit/internal_endpoints.py': br'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/polyfills.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import asyncio │ │ │ │ │ -import glob │ │ │ │ │ -import grp │ │ │ │ │ -import json │ │ │ │ │ -import logging │ │ │ │ │ -import os │ │ │ │ │ -import pwd │ │ │ │ │ -from pathlib import Path │ │ │ │ │ -from typing import Dict, Optional │ │ │ │ │ - │ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch │ │ │ │ │ - │ │ │ │ │ -from . import config │ │ │ │ │ - │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class cockpit_LoginMessages(bus.Object): │ │ │ │ │ - messages: Optional[str] = None │ │ │ │ │ - │ │ │ │ │ - def __init__(self): │ │ │ │ │ - fdstr = os.environ.pop('COCKPIT_LOGIN_MESSAGES_MEMFD', None) │ │ │ │ │ - if fdstr is None: │ │ │ │ │ - logger.debug("COCKPIT_LOGIN_MESSAGES_MEMFD wasn't set. No login messages today.") │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - logger.debug("Trying to read login messages from fd %s", fdstr) │ │ │ │ │ - try: │ │ │ │ │ - with open(int(fdstr), 'r') as login_messages: │ │ │ │ │ - login_messages.seek(0) │ │ │ │ │ - self.messages = login_messages.read() │ │ │ │ │ - except (ValueError, OSError, UnicodeDecodeError) as exc: │ │ │ │ │ - # ValueError - the envvar wasn't an int │ │ │ │ │ - # OSError - the fd wasn't open, or other read failure │ │ │ │ │ - # UnicodeDecodeError - didn't contain utf-8 │ │ │ │ │ - # For all of these, we simply failed to get the message. │ │ │ │ │ - logger.debug("Reading login messages failed: %s", exc) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug("Successfully read login messages: %s", self.messages) │ │ │ │ │ - │ │ │ │ │ - @bus.Interface.Method(out_types=['s']) │ │ │ │ │ - def get(self): │ │ │ │ │ - return self.messages or '{}' │ │ │ │ │ - │ │ │ │ │ - @bus.Interface.Method(out_types=[]) │ │ │ │ │ - def dismiss(self): │ │ │ │ │ - self.messages = None │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class cockpit_Machines(bus.Object): │ │ │ │ │ - path: Path │ │ │ │ │ - watch: pathwatch.PathWatch │ │ │ │ │ - pending_notify: Optional[asyncio.Handle] │ │ │ │ │ - │ │ │ │ │ - # D-Bus implementation │ │ │ │ │ - machines = bus.Interface.Property('a{sa{sv}}') │ │ │ │ │ - │ │ │ │ │ - @machines.getter │ │ │ │ │ - def get_machines(self) -> Dict[str, Dict[str, Variant]]: │ │ │ │ │ - results: Dict[str, Dict[str, Variant]] = {} │ │ │ │ │ - │ │ │ │ │ - for filename in glob.glob(f'{self.path}/*.json'): │ │ │ │ │ - with open(filename) as fp: │ │ │ │ │ - try: │ │ │ │ │ - contents = json.load(fp) │ │ │ │ │ - except json.JSONDecodeError: │ │ │ │ │ - logger.warning('Invalid JSON in file %s. Ignoring.', filename) │ │ │ │ │ - continue │ │ │ │ │ - # merge │ │ │ │ │ - for hostname, attrs in contents.items(): │ │ │ │ │ - results[hostname] = {key: Variant(value) for key, value in attrs.items()} │ │ │ │ │ - │ │ │ │ │ - return results │ │ │ │ │ - │ │ │ │ │ - @bus.Interface.Method(in_types=['s', 's', 'a{sv}']) │ │ │ │ │ - def update(self, filename: str, hostname: str, attrs: Dict[str, Variant]) -> None: │ │ │ │ │ - try: │ │ │ │ │ - with self.path.joinpath(filename).open() as fp: │ │ │ │ │ - contents = json.load(fp) │ │ │ │ │ - except json.JSONDecodeError as exc: │ │ │ │ │ - # Refuse to replace corrupted file │ │ │ │ │ - raise bus.BusError('cockpit.Machines.Error', f'File {filename} is in invalid format: {exc}.') from exc │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - # But an empty file is an expected case │ │ │ │ │ - contents = {} │ │ │ │ │ - │ │ │ │ │ - contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()}) │ │ │ │ │ - │ │ │ │ │ - self.path.mkdir(parents=True, exist_ok=True) │ │ │ │ │ - with open(self.path.joinpath(filename), 'w') as fp: │ │ │ │ │ - json.dump(contents, fp, indent=2) │ │ │ │ │ - │ │ │ │ │ - def notify(self): │ │ │ │ │ - def _notify_now(): │ │ │ │ │ - self.properties_changed('cockpit.Machines', {}, ['Machines']) │ │ │ │ │ - self.pending_notify = None │ │ │ │ │ +import contextlib │ │ │ │ │ +import socket │ │ │ │ │ │ │ │ │ │ - # avoid a flurry of update notifications │ │ │ │ │ - if self.pending_notify is None: │ │ │ │ │ - self.pending_notify = asyncio.get_running_loop().call_later(1.0, _notify_now) │ │ │ │ │ │ │ │ │ │ - # inotify events │ │ │ │ │ - def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None: │ │ │ │ │ - self.notify() │ │ │ │ │ +def install(): │ │ │ │ │ + """Add shims for older Python versions""" │ │ │ │ │ │ │ │ │ │ - def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None: │ │ │ │ │ - self.notify() │ │ │ │ │ + # introduced in 3.9 │ │ │ │ │ + if not hasattr(socket, 'recv_fds'): │ │ │ │ │ + import array │ │ │ │ │ │ │ │ │ │ - def __init__(self): │ │ │ │ │ - self.path = config.lookup_config('machines.d') │ │ │ │ │ + import _socket │ │ │ │ │ │ │ │ │ │ - # ignore the first callback │ │ │ │ │ - self.pending_notify = ... │ │ │ │ │ - self.watch = pathwatch.PathWatch(str(self.path), self) │ │ │ │ │ - self.pending_notify = None │ │ │ │ │ + def recv_fds(sock, bufsize, maxfds, flags=0): │ │ │ │ │ + fds = array.array("i") │ │ │ │ │ + msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize)) │ │ │ │ │ + for cmsg_level, cmsg_type, cmsg_data in ancdata: │ │ │ │ │ + if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS): │ │ │ │ │ + fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)]) │ │ │ │ │ + return msg, list(fds), flags, addr │ │ │ │ │ │ │ │ │ │ + socket.recv_fds = recv_fds │ │ │ │ │ │ │ │ │ │ -class cockpit_User(bus.Object): │ │ │ │ │ - name = bus.Interface.Property('s', value='') │ │ │ │ │ - full = bus.Interface.Property('s', value='') │ │ │ │ │ - id = bus.Interface.Property('i', value=0) │ │ │ │ │ - home = bus.Interface.Property('s', value='') │ │ │ │ │ - shell = bus.Interface.Property('s', value='') │ │ │ │ │ - groups = bus.Interface.Property('as', value=[]) │ │ │ │ │ + # introduced in 3.7 │ │ │ │ │ + if not hasattr(contextlib, 'AsyncExitStack'): │ │ │ │ │ + class AsyncExitStack: │ │ │ │ │ + async def __aenter__(self): │ │ │ │ │ + self.cms = [] │ │ │ │ │ + return self │ │ │ │ │ │ │ │ │ │ - def __init__(self): │ │ │ │ │ - user = pwd.getpwuid(os.getuid()) │ │ │ │ │ - self.name = user.pw_name │ │ │ │ │ - self.full = user.pw_gecos │ │ │ │ │ - self.id = user.pw_uid │ │ │ │ │ - self.home = user.pw_dir │ │ │ │ │ - self.shell = user.pw_shell │ │ │ │ │ - self.groups = [gr.gr_name for gr in grp.getgrall() if user.pw_name in gr.gr_mem] │ │ │ │ │ + async def enter_async_context(self, cm): │ │ │ │ │ + result = await cm.__aenter__() │ │ │ │ │ + self.cms.append(cm) │ │ │ │ │ + return result │ │ │ │ │ │ │ │ │ │ + async def __aexit__(self, exc_type, exc_value, traceback): │ │ │ │ │ + for cm in self.cms: │ │ │ │ │ + cm.__aexit__(exc_type, exc_value, traceback) │ │ │ │ │ │ │ │ │ │ -EXPORTS = [ │ │ │ │ │ - ('/LoginMessages', cockpit_LoginMessages), │ │ │ │ │ - ('/machines', cockpit_Machines), │ │ │ │ │ - ('/user', cockpit_User), │ │ │ │ │ -] │ │ │ │ │ + contextlib.AsyncExitStack = AsyncExitStack │ │ │ │ │ ''', │ │ │ │ │ 'cockpit/protocol.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ @@ -1908,15 +2085,575 @@ │ │ │ │ │ │ │ │ │ │ if self.authorizations is None or cookie not in self.authorizations: │ │ │ │ │ logger.warning('no matching authorize request') │ │ │ │ │ return │ │ │ │ │ │ │ │ │ │ self.authorizations[cookie].set_result(response) │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/__init__.py': br'''from ._version import __version__ # noqa: F401 │ │ │ │ │ + 'cockpit/bridge.py': r'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ + │ │ │ │ │ +import argparse │ │ │ │ │ +import asyncio │ │ │ │ │ +import contextlib │ │ │ │ │ +import json │ │ │ │ │ +import logging │ │ │ │ │ +import os │ │ │ │ │ +import pwd │ │ │ │ │ +import shlex │ │ │ │ │ +import socket │ │ │ │ │ +import stat │ │ │ │ │ +import subprocess │ │ │ │ │ +from typing import Iterable, List, Optional, Sequence, Tuple, Type │ │ │ │ │ + │ │ │ │ │ +from cockpit._vendor.ferny import interaction_client │ │ │ │ │ +from cockpit._vendor.systemd_ctypes import bus, run_async │ │ │ │ │ + │ │ │ │ │ +from . import polyfills │ │ │ │ │ +from ._version import __version__ │ │ │ │ │ +from .channel import ChannelRoutingRule │ │ │ │ │ +from .channels import CHANNEL_TYPES │ │ │ │ │ +from .config import Config, Environment │ │ │ │ │ +from .internal_endpoints import EXPORTS │ │ │ │ │ +from .jsonutil import JsonError, JsonObject, get_dict │ │ │ │ │ +from .packages import BridgeConfig, Packages, PackagesListener │ │ │ │ │ +from .peer import PeersRoutingRule │ │ │ │ │ +from .remote import HostRoutingRule │ │ │ │ │ +from .router import Router │ │ │ │ │ +from .superuser import SuperuserRoutingRule │ │ │ │ │ +from .transports import StdioTransport │ │ │ │ │ + │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class InternalBus: │ │ │ │ │ + exportees: List[bus.Slot] │ │ │ │ │ + │ │ │ │ │ + def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]): │ │ │ │ │ + client_socket, server_socket = socket.socketpair() │ │ │ │ │ + self.client = bus.Bus.new(fd=client_socket.detach()) │ │ │ │ │ + self.server = bus.Bus.new(fd=server_socket.detach(), server=True) │ │ │ │ │ + self.exportees = [self.server.add_object(path, cls()) for path, cls in exports] │ │ │ │ │ + │ │ │ │ │ + def export(self, path: str, obj: bus.BaseObject) -> None: │ │ │ │ │ + self.exportees.append(self.server.add_object(path, obj)) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class Bridge(Router, PackagesListener): │ │ │ │ │ + internal_bus: InternalBus │ │ │ │ │ + packages: Optional[Packages] │ │ │ │ │ + bridge_configs: Sequence[BridgeConfig] │ │ │ │ │ + args: argparse.Namespace │ │ │ │ │ + │ │ │ │ │ + def __init__(self, args: argparse.Namespace): │ │ │ │ │ + self.internal_bus = InternalBus(EXPORTS) │ │ │ │ │ + self.bridge_configs = [] │ │ │ │ │ + self.args = args │ │ │ │ │ + │ │ │ │ │ + self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged) │ │ │ │ │ + self.internal_bus.export('/superuser', self.superuser_rule) │ │ │ │ │ + │ │ │ │ │ + self.internal_bus.export('/config', Config()) │ │ │ │ │ + self.internal_bus.export('/environment', Environment()) │ │ │ │ │ + │ │ │ │ │ + self.peers_rule = PeersRoutingRule(self) │ │ │ │ │ + │ │ │ │ │ + if args.beipack: │ │ │ │ │ + # Some special stuff for beipack │ │ │ │ │ + self.superuser_rule.set_configs(( │ │ │ │ │ + BridgeConfig({ │ │ │ │ │ + "privileged": True, │ │ │ │ │ + "spawn": ["sudo", "-k", "-A", "python3", "-ic", "# cockpit-bridge", "--privileged"], │ │ │ │ │ + "environ": ["SUDO_ASKPASS=ferny-askpass"], │ │ │ │ │ + }), │ │ │ │ │ + )) │ │ │ │ │ + self.packages = None │ │ │ │ │ + elif args.privileged: │ │ │ │ │ + self.packages = None │ │ │ │ │ + else: │ │ │ │ │ + self.packages = Packages(self) │ │ │ │ │ + self.internal_bus.export('/packages', self.packages) │ │ │ │ │ + self.packages_loaded() │ │ │ │ │ + │ │ │ │ │ + super().__init__([ │ │ │ │ │ + HostRoutingRule(self), │ │ │ │ │ + self.superuser_rule, │ │ │ │ │ + ChannelRoutingRule(self, CHANNEL_TYPES), │ │ │ │ │ + self.peers_rule, │ │ │ │ │ + ]) │ │ │ │ │ + │ │ │ │ │ + @staticmethod │ │ │ │ │ + def get_os_release(): │ │ │ │ │ + try: │ │ │ │ │ + file = open('/etc/os-release', encoding='utf-8') │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + try: │ │ │ │ │ + file = open('/usr/lib/os-release', encoding='utf-8') │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + logger.warning("Neither /etc/os-release nor /usr/lib/os-release exists") │ │ │ │ │ + return {} │ │ │ │ │ + │ │ │ │ │ + os_release = {} │ │ │ │ │ + for line in file.readlines(): │ │ │ │ │ + line = line.strip() │ │ │ │ │ + if not line or line.startswith('#'): │ │ │ │ │ + continue │ │ │ │ │ + try: │ │ │ │ │ + k, v = line.split('=') │ │ │ │ │ + (v_parsed, ) = shlex.split(v) # expect exactly one token │ │ │ │ │ + except ValueError: │ │ │ │ │ + logger.warning('Ignoring invalid line in os-release: %r', line) │ │ │ │ │ + continue │ │ │ │ │ + os_release[k] = v_parsed │ │ │ │ │ + return os_release │ │ │ │ │ + │ │ │ │ │ + def do_init(self, message: JsonObject) -> None: │ │ │ │ │ + # we're only interested in the case where this is a dict, but │ │ │ │ │ + # 'superuser' may well be `False` and that's not an error │ │ │ │ │ + with contextlib.suppress(JsonError): │ │ │ │ │ + superuser = get_dict(message, 'superuser') │ │ │ │ │ + self.superuser_rule.init(superuser) │ │ │ │ │ + │ │ │ │ │ + def do_send_init(self) -> None: │ │ │ │ │ + init_args = { │ │ │ │ │ + 'capabilities': {'explicit-superuser': True}, │ │ │ │ │ + 'command': 'init', │ │ │ │ │ + 'os-release': self.get_os_release(), │ │ │ │ │ + 'version': 1, │ │ │ │ │ + } │ │ │ │ │ + │ │ │ │ │ + if self.packages is not None: │ │ │ │ │ + init_args['packages'] = {p: None for p in self.packages.packages} │ │ │ │ │ + │ │ │ │ │ + self.write_control(init_args) │ │ │ │ │ + │ │ │ │ │ + # PackagesListener interface │ │ │ │ │ + def packages_loaded(self) -> None: │ │ │ │ │ + assert self.packages │ │ │ │ │ + bridge_configs = self.packages.get_bridge_configs() │ │ │ │ │ + if self.bridge_configs != bridge_configs: │ │ │ │ │ + self.superuser_rule.set_configs(bridge_configs) │ │ │ │ │ + self.peers_rule.set_configs(bridge_configs) │ │ │ │ │ + self.bridge_configs = bridge_configs │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +async def run(args) -> None: │ │ │ │ │ + logger.debug("Hi. How are you today?") │ │ │ │ │ + │ │ │ │ │ + # Unit tests require this │ │ │ │ │ + me = pwd.getpwuid(os.getuid()) │ │ │ │ │ + os.environ['HOME'] = me.pw_dir │ │ │ │ │ + os.environ['SHELL'] = me.pw_shell │ │ │ │ │ + os.environ['USER'] = me.pw_name │ │ │ │ │ + │ │ │ │ │ + logger.debug('Starting the router.') │ │ │ │ │ + router = Bridge(args) │ │ │ │ │ + StdioTransport(asyncio.get_running_loop(), router) │ │ │ │ │ + │ │ │ │ │ + logger.debug('Startup done. Looping until connection closes.') │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + await router.communicate() │ │ │ │ │ + except (BrokenPipeError, ConnectionResetError): │ │ │ │ │ + # not unexpected if the peer doesn't hang up cleanly │ │ │ │ │ + pass │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def try_to_receive_stderr(): │ │ │ │ │ + try: │ │ │ │ │ + ours, theirs = socket.socketpair() │ │ │ │ │ + with ours: │ │ │ │ │ + with theirs: │ │ │ │ │ + interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()]) │ │ │ │ │ + _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1) │ │ │ │ │ + except OSError: │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + stderr_fd, = fds │ │ │ │ │ + # We're about to abruptly drop our end of the stderr socketpair that we │ │ │ │ │ + # share with the ferny agent. ferny would normally treat that as an │ │ │ │ │ + # unexpected error. Instruct it to do a clean exit, instead. │ │ │ │ │ + interaction_client.command(2, 'ferny.end') │ │ │ │ │ + os.dup2(stderr_fd, 2) │ │ │ │ │ + finally: │ │ │ │ │ + for fd in fds: │ │ │ │ │ + os.close(fd) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def setup_journald() -> bool: │ │ │ │ │ + # If stderr is a socket, prefer systemd-journal logging. This covers the │ │ │ │ │ + # case we're already connected to the journal but also the case where we're │ │ │ │ │ + # talking to the ferny agent, while leaving logging to file or terminal │ │ │ │ │ + # unaffected. │ │ │ │ │ + if not stat.S_ISSOCK(os.fstat(2).st_mode): │ │ │ │ │ + # not a socket? Don't redirect. │ │ │ │ │ + return False │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + import systemd.journal # type: ignore[import] │ │ │ │ │ + except ImportError: │ │ │ │ │ + # No python3-systemd? Don't redirect. │ │ │ │ │ + return False │ │ │ │ │ + │ │ │ │ │ + logging.root.addHandler(systemd.journal.JournalHandler()) │ │ │ │ │ + return True │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def setup_logging(*, debug: bool) -> None: │ │ │ │ │ + """Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set""" │ │ │ │ │ + │ │ │ │ │ + modules = os.getenv('COCKPIT_DEBUG', '') │ │ │ │ │ + │ │ │ │ │ + # Either setup logging via journal or via formatted messages to stderr │ │ │ │ │ + if not setup_journald(): │ │ │ │ │ + logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s') │ │ │ │ │ + │ │ │ │ │ + if debug or modules == 'all': │ │ │ │ │ + logging.getLogger().setLevel(level=logging.DEBUG) │ │ │ │ │ + elif modules: │ │ │ │ │ + for module in modules.split(','): │ │ │ │ │ + module = module.strip() │ │ │ │ │ + if not module: │ │ │ │ │ + continue │ │ │ │ │ + │ │ │ │ │ + logging.getLogger(module).setLevel(logging.DEBUG) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def start_ssh_agent() -> None: │ │ │ │ │ + # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust, │ │ │ │ │ + # but it gets cleared on setgid ssh-agent, which some distros still do │ │ │ │ │ + try: │ │ │ │ │ + proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'], │ │ │ │ │ + stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True) │ │ │ │ │ + assert proc.stdout is not None │ │ │ │ │ + │ │ │ │ │ + # Wait for the agent to write at least one line and look for the │ │ │ │ │ + # listener socket. If we fail to find it, kill the agent — something │ │ │ │ │ + # went wrong. │ │ │ │ │ + for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True): │ │ │ │ │ + if token.startswith('SSH_AUTH_SOCK='): │ │ │ │ │ + os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1) │ │ │ │ │ + break │ │ │ │ │ + else: │ │ │ │ │ + proc.terminate() │ │ │ │ │ + proc.wait() │ │ │ │ │ + │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + logger.debug("Couldn't start ssh-agent (FileNotFoundError)") │ │ │ │ │ + │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + logger.warning("Could not start ssh-agent: %s", exc) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def main(*, beipack: bool = False) -> None: │ │ │ │ │ + polyfills.install() │ │ │ │ │ + │ │ │ │ │ + parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.') │ │ │ │ │ + parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge') │ │ │ │ │ + parser.add_argument('--packages', action='store_true', help='Show Cockpit package information') │ │ │ │ │ + parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information') │ │ │ │ │ + parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)') │ │ │ │ │ + parser.add_argument('--version', action='store_true', help='Show Cockpit version information') │ │ │ │ │ + args = parser.parse_args() │ │ │ │ │ + │ │ │ │ │ + # This is determined by who calls us │ │ │ │ │ + args.beipack = beipack │ │ │ │ │ + │ │ │ │ │ + # If we were run with --privileged then our stderr is currently being │ │ │ │ │ + # consumed by the main bridge looking for startup-related error messages. │ │ │ │ │ + # Let's switch back to the original stderr stream, which has a side-effect │ │ │ │ │ + # of indicating that our startup is more or less complete. Any errors │ │ │ │ │ + # after this point will land in the journal. │ │ │ │ │ + if args.privileged: │ │ │ │ │ + try_to_receive_stderr() │ │ │ │ │ + │ │ │ │ │ + setup_logging(debug=args.debug) │ │ │ │ │ + │ │ │ │ │ + # Special modes │ │ │ │ │ + if args.packages: │ │ │ │ │ + Packages().show() │ │ │ │ │ + return │ │ │ │ │ + elif args.version: │ │ │ │ │ + print(f'Version: {__version__}\nProtocol: 1') │ │ │ │ │ + return │ │ │ │ │ + elif args.bridges: │ │ │ │ │ + print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2)) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + # The privileged bridge doesn't need ssh-agent, but the main one does │ │ │ │ │ + if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged: │ │ │ │ │ + start_ssh_agent() │ │ │ │ │ + │ │ │ │ │ + # asyncio.run() shim for Python 3.6 support │ │ │ │ │ + run_async(run(args), debug=args.debug) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +if __name__ == '__main__': │ │ │ │ │ + main() │ │ │ │ │ +'''.encode('utf-8'), │ │ │ │ │ + 'cockpit/superuser.py': br'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ + │ │ │ │ │ +import array │ │ │ │ │ +import asyncio │ │ │ │ │ +import contextlib │ │ │ │ │ +import getpass │ │ │ │ │ +import logging │ │ │ │ │ +import os │ │ │ │ │ +import socket │ │ │ │ │ +from tempfile import TemporaryDirectory │ │ │ │ │ +from typing import List, Optional, Sequence, Tuple │ │ │ │ │ + │ │ │ │ │ +from cockpit._vendor import ferny │ │ │ │ │ +from cockpit._vendor.bei.bootloader import make_bootloader │ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Variant, bus │ │ │ │ │ + │ │ │ │ │ +from .beipack import BridgeBeibootHelper │ │ │ │ │ +from .jsonutil import JsonObject, get_str │ │ │ │ │ +from .packages import BridgeConfig │ │ │ │ │ +from .peer import ConfiguredPeer, Peer, PeerError │ │ │ │ │ +from .polkit import PolkitAgent │ │ │ │ │ +from .router import Router, RoutingError, RoutingRule │ │ │ │ │ + │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class SuperuserPeer(ConfiguredPeer): │ │ │ │ │ + responder: ferny.AskpassHandler │ │ │ │ │ + │ │ │ │ │ + def __init__(self, router: Router, config: BridgeConfig, responder: ferny.AskpassHandler): │ │ │ │ │ + super().__init__(router, config) │ │ │ │ │ + self.responder = responder │ │ │ │ │ + │ │ │ │ │ + async def do_connect_transport(self) -> None: │ │ │ │ │ + async with contextlib.AsyncExitStack() as context: │ │ │ │ │ + if 'pkexec' in self.args: │ │ │ │ │ + logger.debug('connecting polkit superuser peer transport %r', self.args) │ │ │ │ │ + await context.enter_async_context(PolkitAgent(self.responder)) │ │ │ │ │ + else: │ │ │ │ │ + logger.debug('connecting non-polkit superuser peer transport %r', self.args) │ │ │ │ │ + │ │ │ │ │ + responders: 'list[ferny.InteractionHandler]' = [self.responder] │ │ │ │ │ + │ │ │ │ │ + if '# cockpit-bridge' in self.args: │ │ │ │ │ + logger.debug('going to beiboot superuser bridge %r', self.args) │ │ │ │ │ + helper = BridgeBeibootHelper(self, ['--privileged']) │ │ │ │ │ + responders.append(helper) │ │ │ │ │ + stage1 = make_bootloader(helper.steps, gadgets=ferny.BEIBOOT_GADGETS).encode() │ │ │ │ │ + else: │ │ │ │ │ + stage1 = None │ │ │ │ │ + │ │ │ │ │ + agent = ferny.InteractionAgent(responders) │ │ │ │ │ + │ │ │ │ │ + if 'SUDO_ASKPASS=ferny-askpass' in self.env: │ │ │ │ │ + tmpdir = context.enter_context(TemporaryDirectory()) │ │ │ │ │ + ferny_askpass = ferny.write_askpass_to_tmpdir(tmpdir) │ │ │ │ │ + env: Sequence[str] = [f'SUDO_ASKPASS={ferny_askpass}'] │ │ │ │ │ + else: │ │ │ │ │ + env = self.env │ │ │ │ │ + │ │ │ │ │ + transport = await self.spawn(self.args, env, stderr=agent, start_new_session=True) │ │ │ │ │ + │ │ │ │ │ + if stage1 is not None: │ │ │ │ │ + transport.write(stage1) │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + await agent.communicate() │ │ │ │ │ + except ferny.InteractionError as exc: │ │ │ │ │ + raise PeerError('authentication-failed', message=str(exc)) from exc │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class CockpitResponder(ferny.AskpassHandler): │ │ │ │ │ + commands = ('ferny.askpass', 'cockpit.send-stderr') │ │ │ │ │ + │ │ │ │ │ + async def do_custom_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None: │ │ │ │ │ + if command == 'cockpit.send-stderr': │ │ │ │ │ + with socket.socket(fileno=fds[0]) as sock: │ │ │ │ │ + fds.pop(0) │ │ │ │ │ + # socket.send_fds(sock, [b'\0'], [2]) # New in Python 3.9 │ │ │ │ │ + sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", [2]))]) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class AuthorizeResponder(CockpitResponder): │ │ │ │ │ + def __init__(self, router: Router): │ │ │ │ │ + self.router = router │ │ │ │ │ + │ │ │ │ │ + async def do_askpass(self, messages: str, prompt: str, hint: str) -> str: │ │ │ │ │ + hexuser = ''.join(f'{c:02x}' for c in getpass.getuser().encode('ascii')) │ │ │ │ │ + return await self.router.request_authorization(f'plain1:{hexuser}') │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class SuperuserRoutingRule(RoutingRule, CockpitResponder, bus.Object, interface='cockpit.Superuser'): │ │ │ │ │ + superuser_configs: Sequence[BridgeConfig] = () │ │ │ │ │ + pending_prompt: Optional[asyncio.Future] │ │ │ │ │ + peer: Optional[SuperuserPeer] │ │ │ │ │ + │ │ │ │ │ + # D-Bus signals │ │ │ │ │ + prompt = bus.Interface.Signal('s', 's', 's', 'b', 's') # message, prompt, default, echo, error │ │ │ │ │ + │ │ │ │ │ + # D-Bus properties │ │ │ │ │ + bridges = bus.Interface.Property('as', value=[]) │ │ │ │ │ + current = bus.Interface.Property('s', value='none') │ │ │ │ │ + methods = bus.Interface.Property('a{sv}', value={}) │ │ │ │ │ + │ │ │ │ │ + # RoutingRule │ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Peer]: │ │ │ │ │ + superuser = options.get('superuser') │ │ │ │ │ + │ │ │ │ │ + if not superuser or self.current == 'root': │ │ │ │ │ + # superuser not requested, or already superuser? Next rule. │ │ │ │ │ + return None │ │ │ │ │ + elif self.peer or superuser == 'try': │ │ │ │ │ + # superuser requested and active? Return it. │ │ │ │ │ + # 'try' requested? Either return the peer, or None. │ │ │ │ │ + return self.peer │ │ │ │ │ + else: │ │ │ │ │ + # superuser requested, but not active? That's an error. │ │ │ │ │ + raise RoutingError('access-denied') │ │ │ │ │ + │ │ │ │ │ + # ferny.AskpassHandler │ │ │ │ │ + async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]: │ │ │ │ │ + assert self.pending_prompt is None │ │ │ │ │ + echo = hint == "confirm" │ │ │ │ │ + self.pending_prompt = asyncio.get_running_loop().create_future() │ │ │ │ │ + try: │ │ │ │ │ + logger.debug('prompting for %s', prompt) │ │ │ │ │ + # with sudo, all stderr messages are treated as warning/errors by the UI │ │ │ │ │ + # (such as the lecture or "wrong password"), so pass them in the "error" field │ │ │ │ │ + self.prompt('', prompt, '', echo, messages) │ │ │ │ │ + return await self.pending_prompt │ │ │ │ │ + finally: │ │ │ │ │ + self.pending_prompt = None │ │ │ │ │ + │ │ │ │ │ + def __init__(self, router: Router, *, privileged: bool = False): │ │ │ │ │ + super().__init__(router) │ │ │ │ │ + │ │ │ │ │ + self.pending_prompt = None │ │ │ │ │ + self.peer = None │ │ │ │ │ + self.startup = None │ │ │ │ │ + │ │ │ │ │ + if privileged or os.getuid() == 0: │ │ │ │ │ + self.current = 'root' │ │ │ │ │ + │ │ │ │ │ + def peer_done(self): │ │ │ │ │ + self.current = 'none' │ │ │ │ │ + self.peer = None │ │ │ │ │ + │ │ │ │ │ + async def go(self, name: str, responder: ferny.AskpassHandler) -> None: │ │ │ │ │ + if self.current != 'none': │ │ │ │ │ + raise bus.BusError('cockpit.Superuser.Error', 'Superuser bridge already running') │ │ │ │ │ + │ │ │ │ │ + assert self.peer is None │ │ │ │ │ + assert self.startup is None │ │ │ │ │ + │ │ │ │ │ + for config in self.superuser_configs: │ │ │ │ │ + if name in (config.name, 'any'): │ │ │ │ │ + break │ │ │ │ │ + else: │ │ │ │ │ + raise bus.BusError('cockpit.Superuser.Error', f'Unknown superuser bridge type "{name}"') │ │ │ │ │ + │ │ │ │ │ + self.current = 'init' │ │ │ │ │ + self.peer = SuperuserPeer(self.router, config, responder) │ │ │ │ │ + self.peer.add_done_callback(self.peer_done) │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + await self.peer.start(init_host=self.router.init_host) │ │ │ │ │ + except asyncio.CancelledError: │ │ │ │ │ + raise bus.BusError('cockpit.Superuser.Error.Cancelled', 'Operation aborted') from None │ │ │ │ │ + except (OSError, PeerError) as exc: │ │ │ │ │ + raise bus.BusError('cockpit.Superuser.Error', str(exc)) from exc │ │ │ │ │ + │ │ │ │ │ + self.current = self.peer.config.name │ │ │ │ │ + │ │ │ │ │ + def set_configs(self, configs: Sequence[BridgeConfig]): │ │ │ │ │ + logger.debug("set_configs() with %d items", len(configs)) │ │ │ │ │ + configs = [config for config in configs if config.privileged] │ │ │ │ │ + self.superuser_configs = tuple(configs) │ │ │ │ │ + self.bridges = [config.name for config in self.superuser_configs] │ │ │ │ │ + self.methods = {c.label: Variant({'label': Variant(c.label)}, 'a{sv}') for c in configs if c.label} │ │ │ │ │ + │ │ │ │ │ + logger.debug(" bridges are now %s", self.bridges) │ │ │ │ │ + │ │ │ │ │ + # If the currently active bridge config is not in the new set of configs, stop it │ │ │ │ │ + if self.peer is not None: │ │ │ │ │ + if self.peer.config not in self.superuser_configs: │ │ │ │ │ + logger.debug(" stopping superuser bridge '%s': it disappeared from configs", self.peer.config.name) │ │ │ │ │ + self.stop() │ │ │ │ │ + │ │ │ │ │ + def cancel_prompt(self): │ │ │ │ │ + if self.pending_prompt is not None: │ │ │ │ │ + self.pending_prompt.cancel() │ │ │ │ │ + self.pending_prompt = None │ │ │ │ │ + │ │ │ │ │ + def shutdown(self): │ │ │ │ │ + self.cancel_prompt() │ │ │ │ │ + │ │ │ │ │ + if self.peer is not None: │ │ │ │ │ + self.peer.close() │ │ │ │ │ + │ │ │ │ │ + # close() should have disconnected the peer immediately │ │ │ │ │ + assert self.peer is None │ │ │ │ │ + │ │ │ │ │ + # Connect-on-startup functionality │ │ │ │ │ + def init(self, params: JsonObject) -> None: │ │ │ │ │ + name = get_str(params, 'id', 'any') │ │ │ │ │ + responder = AuthorizeResponder(self.router) │ │ │ │ │ + self._init_task = asyncio.create_task(self.go(name, responder)) │ │ │ │ │ + self._init_task.add_done_callback(self._init_done) │ │ │ │ │ + │ │ │ │ │ + def _init_done(self, task: 'asyncio.Task[None]') -> None: │ │ │ │ │ + logger.debug('superuser init done! %s', task.exception()) │ │ │ │ │ + self.router.write_control(command='superuser-init-done') │ │ │ │ │ + del self._init_task │ │ │ │ │ + │ │ │ │ │ + # D-Bus methods │ │ │ │ │ + @bus.Interface.Method(in_types=['s']) │ │ │ │ │ + async def start(self, name: str) -> None: │ │ │ │ │ + await self.go(name, self) │ │ │ │ │ + │ │ │ │ │ + @bus.Interface.Method() │ │ │ │ │ + def stop(self) -> None: │ │ │ │ │ + self.shutdown() │ │ │ │ │ + │ │ │ │ │ + @bus.Interface.Method(in_types=['s']) │ │ │ │ │ + def answer(self, reply: str) -> None: │ │ │ │ │ + if self.pending_prompt is not None: │ │ │ │ │ + logger.debug('responding to pending prompt') │ │ │ │ │ + self.pending_prompt.set_result(reply) │ │ │ │ │ + else: │ │ │ │ │ + logger.debug('got Answer, but no prompt pending') │ │ │ │ │ ''', │ │ │ │ │ 'cockpit/packages.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ @@ -2491,14 +3228,571 @@ │ │ │ │ │ elif filename == 'manifests.js': │ │ │ │ │ return self.load_manifests_js(headers) │ │ │ │ │ elif filename == 'manifests.json': │ │ │ │ │ return self.load_manifests_json() │ │ │ │ │ else: │ │ │ │ │ raise KeyError │ │ │ │ │ ''', │ │ │ │ │ + 'cockpit/__init__.py': br'''from ._version import __version__ # noqa: F401 │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_version.py': br'''__version__ = '311' │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/transports.py': br'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ + │ │ │ │ │ +"""Bi-directional asyncio.Transport implementations based on file descriptors.""" │ │ │ │ │ + │ │ │ │ │ +import asyncio │ │ │ │ │ +import collections │ │ │ │ │ +import ctypes │ │ │ │ │ +import errno │ │ │ │ │ +import fcntl │ │ │ │ │ +import logging │ │ │ │ │ +import os │ │ │ │ │ +import select │ │ │ │ │ +import signal │ │ │ │ │ +import struct │ │ │ │ │ +import subprocess │ │ │ │ │ +import termios │ │ │ │ │ +from typing import Any, ClassVar, Sequence │ │ │ │ │ + │ │ │ │ │ +from .jsonutil import JsonObject, get_int │ │ │ │ │ + │ │ │ │ │ +libc6 = ctypes.cdll.LoadLibrary('libc.so.6') │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def prctl(*args: int) -> None: │ │ │ │ │ + if libc6.prctl(*args) != 0: │ │ │ │ │ + raise OSError('prctl() failed') │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +SET_PDEATHSIG = 1 │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ +IOV_MAX = 1024 # man 2 writev │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class _Transport(asyncio.Transport): │ │ │ │ │ + BLOCK_SIZE: ClassVar[int] = 1024 * 1024 │ │ │ │ │ + │ │ │ │ │ + # A transport always has a loop and a protocol │ │ │ │ │ + _loop: asyncio.AbstractEventLoop │ │ │ │ │ + _protocol: asyncio.Protocol │ │ │ │ │ + │ │ │ │ │ + _queue: 'collections.deque[bytes] | None' │ │ │ │ │ + _in_fd: int │ │ │ │ │ + _out_fd: int │ │ │ │ │ + _closing: bool │ │ │ │ │ + _is_reading: bool │ │ │ │ │ + _eof: bool │ │ │ │ │ + _eio_is_eof: bool = False │ │ │ │ │ + │ │ │ │ │ + def __init__(self, │ │ │ │ │ + loop: asyncio.AbstractEventLoop, │ │ │ │ │ + protocol: asyncio.Protocol, │ │ │ │ │ + in_fd: int = -1, out_fd: int = -1, │ │ │ │ │ + extra: 'dict[str, object] | None' = None): │ │ │ │ │ + super().__init__(extra) │ │ │ │ │ + │ │ │ │ │ + self._loop = loop │ │ │ │ │ + self._protocol = protocol │ │ │ │ │ + │ │ │ │ │ + logger.debug('Created transport %s for protocol %s, fds %d %d', self, protocol, in_fd, out_fd) │ │ │ │ │ + │ │ │ │ │ + self._queue = None │ │ │ │ │ + self._is_reading = False │ │ │ │ │ + self._eof = False │ │ │ │ │ + self._closing = False │ │ │ │ │ + │ │ │ │ │ + self._in_fd = in_fd │ │ │ │ │ + self._out_fd = out_fd │ │ │ │ │ + │ │ │ │ │ + os.set_blocking(in_fd, False) │ │ │ │ │ + if out_fd != in_fd: │ │ │ │ │ + os.set_blocking(out_fd, False) │ │ │ │ │ + │ │ │ │ │ + self._protocol.connection_made(self) │ │ │ │ │ + self.resume_reading() │ │ │ │ │ + │ │ │ │ │ + def _read_ready(self) -> None: │ │ │ │ │ + logger.debug('Read ready on %s %s %d', self, self._protocol, self._in_fd) │ │ │ │ │ + try: │ │ │ │ │ + data = os.read(self._in_fd, _Transport.BLOCK_SIZE) │ │ │ │ │ + except BlockingIOError: # pragma: no cover │ │ │ │ │ + return │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + if self._eio_is_eof and exc.errno == errno.EIO: │ │ │ │ │ + # PTY devices return EIO to mean "EOF" │ │ │ │ │ + data = b'' │ │ │ │ │ + else: │ │ │ │ │ + # Other errors: terminate the connection │ │ │ │ │ + self.abort(exc) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + if data != b'': │ │ │ │ │ + logger.debug(' read %d bytes', len(data)) │ │ │ │ │ + self._protocol.data_received(data) │ │ │ │ │ + else: │ │ │ │ │ + logger.debug(' got EOF') │ │ │ │ │ + self._close_reader() │ │ │ │ │ + keep_open = self._protocol.eof_received() │ │ │ │ │ + if not keep_open: │ │ │ │ │ + self.close() │ │ │ │ │ + │ │ │ │ │ + def is_reading(self) -> bool: │ │ │ │ │ + return self._is_reading │ │ │ │ │ + │ │ │ │ │ + def _close_reader(self) -> None: │ │ │ │ │ + self.pause_reading() │ │ │ │ │ + self._in_fd = -1 │ │ │ │ │ + │ │ │ │ │ + def pause_reading(self) -> None: │ │ │ │ │ + if self._is_reading: │ │ │ │ │ + self._loop.remove_reader(self._in_fd) │ │ │ │ │ + self._is_reading = False │ │ │ │ │ + │ │ │ │ │ + def resume_reading(self) -> None: │ │ │ │ │ + # It's possible that the Protocol could decide to attempt to unpause │ │ │ │ │ + # reading after _close_reader() got called. Check that the fd is != -1 │ │ │ │ │ + # before actually resuming. │ │ │ │ │ + if not self._is_reading and self._in_fd != -1: │ │ │ │ │ + self._loop.add_reader(self._in_fd, self._read_ready) │ │ │ │ │ + self._is_reading = True │ │ │ │ │ + │ │ │ │ │ + def _close(self) -> None: │ │ │ │ │ + pass │ │ │ │ │ + │ │ │ │ │ + def abort(self, exc: 'Exception | None' = None) -> None: │ │ │ │ │ + self._closing = True │ │ │ │ │ + self._close_reader() │ │ │ │ │ + self._remove_write_queue() │ │ │ │ │ + self._protocol.connection_lost(exc) │ │ │ │ │ + self._close() │ │ │ │ │ + │ │ │ │ │ + def can_write_eof(self) -> bool: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def write_eof(self) -> None: │ │ │ │ │ + assert not self._eof │ │ │ │ │ + self._eof = True │ │ │ │ │ + if self._queue is None: │ │ │ │ │ + logger.debug('%s got EOF. closing backend.', self) │ │ │ │ │ + self._write_eof_now() │ │ │ │ │ + else: │ │ │ │ │ + logger.debug('%s got EOF. bytes in queue, deferring close', self) │ │ │ │ │ + │ │ │ │ │ + def get_write_buffer_size(self) -> int: │ │ │ │ │ + if self._queue is None: │ │ │ │ │ + return 0 │ │ │ │ │ + return sum(len(block) for block in self._queue) │ │ │ │ │ + │ │ │ │ │ + def get_write_buffer_limits(self) -> 'tuple[int, int]': │ │ │ │ │ + return (0, 0) │ │ │ │ │ + │ │ │ │ │ + def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None: │ │ │ │ │ + assert high is None or high == 0 │ │ │ │ │ + assert low is None or low == 0 │ │ │ │ │ + │ │ │ │ │ + def _write_eof_now(self) -> None: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def _write_ready(self) -> None: │ │ │ │ │ + logger.debug('%s _write_ready', self) │ │ │ │ │ + assert self._queue is not None │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + n_bytes = os.writev(self._out_fd, self._queue) │ │ │ │ │ + except BlockingIOError: # pragma: no cover │ │ │ │ │ + n_bytes = 0 │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + self.abort(exc) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + logger.debug(' successfully wrote %d bytes from the queue', n_bytes) │ │ │ │ │ + │ │ │ │ │ + while n_bytes: │ │ │ │ │ + block = self._queue.popleft() │ │ │ │ │ + if len(block) > n_bytes: │ │ │ │ │ + # This block wasn't completely written. │ │ │ │ │ + logger.debug(' incomplete block. Stop.') │ │ │ │ │ + self._queue.appendleft(block[n_bytes:]) │ │ │ │ │ + break │ │ │ │ │ + n_bytes -= len(block) │ │ │ │ │ + logger.debug(' removed complete block. %d remains.', n_bytes) │ │ │ │ │ + │ │ │ │ │ + if not self._queue: │ │ │ │ │ + logger.debug('%s queue drained.') │ │ │ │ │ + self._remove_write_queue() │ │ │ │ │ + if self._eof: │ │ │ │ │ + logger.debug('%s queue drained. closing backend now.') │ │ │ │ │ + self._write_eof_now() │ │ │ │ │ + if self._closing: │ │ │ │ │ + self.abort() │ │ │ │ │ + │ │ │ │ │ + def _remove_write_queue(self) -> None: │ │ │ │ │ + if self._queue is not None: │ │ │ │ │ + self._protocol.resume_writing() │ │ │ │ │ + self._loop.remove_writer(self._out_fd) │ │ │ │ │ + self._queue = None │ │ │ │ │ + │ │ │ │ │ + def _create_write_queue(self, data: bytes) -> None: │ │ │ │ │ + logger.debug('%s creating write queue for fd %s', self, self._out_fd) │ │ │ │ │ + assert self._queue is None │ │ │ │ │ + self._loop.add_writer(self._out_fd, self._write_ready) │ │ │ │ │ + self._queue = collections.deque((data,)) │ │ │ │ │ + self._protocol.pause_writing() │ │ │ │ │ + │ │ │ │ │ + def write(self, data: bytes) -> None: │ │ │ │ │ + # this is a race condition with subprocesses: if we get and process the the "exited" │ │ │ │ │ + # event before seeing BrokenPipeError, we'll try to write to a closed pipe. │ │ │ │ │ + # Do what the standard library does and ignore, instead of assert │ │ │ │ │ + if self._closing: │ │ │ │ │ + logger.debug('ignoring write() to closing transport fd %i', self._out_fd) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + assert not self._eof │ │ │ │ │ + │ │ │ │ │ + if self._queue is not None: │ │ │ │ │ + self._queue.append(data) │ │ │ │ │ + │ │ │ │ │ + # writev() will complain if the queue is too long. Consolidate it. │ │ │ │ │ + if len(self._queue) > IOV_MAX: │ │ │ │ │ + all_data = b''.join(self._queue) │ │ │ │ │ + self._queue.clear() │ │ │ │ │ + self._queue.append(all_data) │ │ │ │ │ + │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + n_bytes = os.write(self._out_fd, data) │ │ │ │ │ + except BlockingIOError: │ │ │ │ │ + n_bytes = 0 │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + self.abort(exc) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + if n_bytes != len(data): │ │ │ │ │ + self._create_write_queue(data[n_bytes:]) │ │ │ │ │ + │ │ │ │ │ + def close(self) -> None: │ │ │ │ │ + if self._closing: │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + self._closing = True │ │ │ │ │ + self._close_reader() │ │ │ │ │ + │ │ │ │ │ + if self._queue is not None: │ │ │ │ │ + # abort() will be called from _write_ready() when it's done │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + self.abort() │ │ │ │ │ + │ │ │ │ │ + def get_protocol(self) -> asyncio.BaseProtocol: │ │ │ │ │ + return self._protocol │ │ │ │ │ + │ │ │ │ │ + def is_closing(self) -> bool: │ │ │ │ │ + return self._closing │ │ │ │ │ + │ │ │ │ │ + def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def __del__(self) -> None: │ │ │ │ │ + self._close() │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class SubprocessProtocol(asyncio.Protocol): │ │ │ │ │ + """An extension to asyncio.Protocol for use with SubprocessTransport.""" │ │ │ │ │ + def process_exited(self) -> None: │ │ │ │ │ + """Called when subprocess has exited.""" │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class WindowSize: │ │ │ │ │ + def __init__(self, value: JsonObject): │ │ │ │ │ + self.rows = get_int(value, 'rows') │ │ │ │ │ + self.cols = get_int(value, 'cols') │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class SubprocessTransport(_Transport, asyncio.SubprocessTransport): │ │ │ │ │ + """A bi-directional transport speaking with stdin/out of a subprocess. │ │ │ │ │ + │ │ │ │ │ + Note: this is not really a normal SubprocessTransport. Although it │ │ │ │ │ + implements the entire API of asyncio.SubprocessTransport, it is not │ │ │ │ │ + designed to be used with asyncio.SubprocessProtocol objects. Instead, it │ │ │ │ │ + pair with normal Protocol objects which also implement the │ │ │ │ │ + SubprocessProtocol defined in this module (which only has a │ │ │ │ │ + process_exited() method). Whatever the protocol writes is sent to stdin, │ │ │ │ │ + and whatever comes from stdout is given to the Protocol via the │ │ │ │ │ + .data_received() function. │ │ │ │ │ + │ │ │ │ │ + If stderr is configured as a pipe, the transport will separately collect │ │ │ │ │ + data from it, making it available via the .get_stderr() method. │ │ │ │ │ + """ │ │ │ │ │ + │ │ │ │ │ + _returncode: 'int | None' = None │ │ │ │ │ + │ │ │ │ │ + _pty_fd: 'int | None' = None │ │ │ │ │ + _process: 'subprocess.Popen[bytes] | None' = None │ │ │ │ │ + _stderr: 'Spooler | None' │ │ │ │ │ + │ │ │ │ │ + @staticmethod │ │ │ │ │ + def _create_watcher() -> asyncio.AbstractChildWatcher: │ │ │ │ │ + try: │ │ │ │ │ + os.close(os.pidfd_open(os.getpid(), 0)) # check for kernel support │ │ │ │ │ + return asyncio.PidfdChildWatcher() │ │ │ │ │ + except (AttributeError, OSError): │ │ │ │ │ + pass │ │ │ │ │ + │ │ │ │ │ + return asyncio.SafeChildWatcher() │ │ │ │ │ + │ │ │ │ │ + @staticmethod │ │ │ │ │ + def _get_watcher(loop: asyncio.AbstractEventLoop) -> asyncio.AbstractChildWatcher: │ │ │ │ │ + quark = '_cockpit_transports_child_watcher' │ │ │ │ │ + watcher = getattr(loop, quark, None) │ │ │ │ │ + │ │ │ │ │ + if watcher is None: │ │ │ │ │ + watcher = SubprocessTransport._create_watcher() │ │ │ │ │ + watcher.attach_loop(loop) │ │ │ │ │ + setattr(loop, quark, watcher) │ │ │ │ │ + │ │ │ │ │ + return watcher │ │ │ │ │ + │ │ │ │ │ + def get_stderr(self, *, reset: bool = False) -> str: │ │ │ │ │ + if self._stderr is not None: │ │ │ │ │ + return self._stderr.get(reset=reset).decode(errors='replace') │ │ │ │ │ + else: │ │ │ │ │ + return '' │ │ │ │ │ + │ │ │ │ │ + def _exited(self, pid: int, code: int) -> None: │ │ │ │ │ + # NB: per AbstractChildWatcher API, this handler should be thread-safe, │ │ │ │ │ + # but we only ever use non-threaded child watcher implementations, so │ │ │ │ │ + # we can assume we'll always be called in the main thread. │ │ │ │ │ + │ │ │ │ │ + # NB: the subprocess is going to want to waitpid() itself as well, but │ │ │ │ │ + # will get ECHILD since we already reaped it. Fortunately, since │ │ │ │ │ + # Python 3.2 this is supported, and process gets a return status of │ │ │ │ │ + # zero. For that reason, we need to store our own copy of the return │ │ │ │ │ + # status. See https://github.com/python/cpython/issues/59960 │ │ │ │ │ + assert isinstance(self._protocol, SubprocessProtocol) │ │ │ │ │ + assert self._process is not None │ │ │ │ │ + assert self._process.pid == pid │ │ │ │ │ + self._returncode = code │ │ │ │ │ + logger.debug('Process exited with status %d', self._returncode) │ │ │ │ │ + if not self._closing: │ │ │ │ │ + self._protocol.process_exited() │ │ │ │ │ + │ │ │ │ │ + def __init__(self, │ │ │ │ │ + loop: asyncio.AbstractEventLoop, │ │ │ │ │ + protocol: SubprocessProtocol, │ │ │ │ │ + args: Sequence[str], │ │ │ │ │ + *, │ │ │ │ │ + pty: bool = False, │ │ │ │ │ + window: 'WindowSize | None' = None, │ │ │ │ │ + **kwargs: Any): │ │ │ │ │ + │ │ │ │ │ + # go down as a team -- we don't want any leaked processes when the bridge terminates │ │ │ │ │ + def preexec_fn() -> None: │ │ │ │ │ + prctl(SET_PDEATHSIG, signal.SIGTERM) │ │ │ │ │ + if pty: │ │ │ │ │ + fcntl.ioctl(0, termios.TIOCSCTTY, 0) │ │ │ │ │ + │ │ │ │ │ + if pty: │ │ │ │ │ + self._pty_fd, session_fd = os.openpty() │ │ │ │ │ + │ │ │ │ │ + if window is not None: │ │ │ │ │ + self.set_window_size(window) │ │ │ │ │ + │ │ │ │ │ + kwargs['stderr'] = session_fd │ │ │ │ │ + self._process = subprocess.Popen(args, │ │ │ │ │ + stdin=session_fd, stdout=session_fd, │ │ │ │ │ + preexec_fn=preexec_fn, start_new_session=True, **kwargs) │ │ │ │ │ + os.close(session_fd) │ │ │ │ │ + │ │ │ │ │ + in_fd, out_fd = self._pty_fd, self._pty_fd │ │ │ │ │ + self._eio_is_eof = True │ │ │ │ │ + │ │ │ │ │ + else: │ │ │ │ │ + self._process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, │ │ │ │ │ + preexec_fn=preexec_fn, **kwargs) │ │ │ │ │ + assert self._process.stdin │ │ │ │ │ + assert self._process.stdout │ │ │ │ │ + in_fd = self._process.stdout.fileno() │ │ │ │ │ + out_fd = self._process.stdin.fileno() │ │ │ │ │ + │ │ │ │ │ + if self._process.stderr is not None: │ │ │ │ │ + self._stderr = Spooler(loop, self._process.stderr.fileno()) │ │ │ │ │ + else: │ │ │ │ │ + self._stderr = None │ │ │ │ │ + │ │ │ │ │ + super().__init__(loop, protocol, in_fd, out_fd) │ │ │ │ │ + │ │ │ │ │ + self._get_watcher(loop).add_child_handler(self._process.pid, self._exited) │ │ │ │ │ + │ │ │ │ │ + def set_window_size(self, size: WindowSize) -> None: │ │ │ │ │ + assert self._pty_fd is not None │ │ │ │ │ + fcntl.ioctl(self._pty_fd, termios.TIOCSWINSZ, struct.pack('2H4x', size.rows, size.cols)) │ │ │ │ │ + │ │ │ │ │ + def can_write_eof(self) -> bool: │ │ │ │ │ + assert self._process is not None │ │ │ │ │ + return self._process.stdin is not None │ │ │ │ │ + │ │ │ │ │ + def _write_eof_now(self) -> None: │ │ │ │ │ + assert self._process is not None │ │ │ │ │ + assert self._process.stdin is not None │ │ │ │ │ + self._process.stdin.close() │ │ │ │ │ + self._out_fd = -1 │ │ │ │ │ + │ │ │ │ │ + def get_pid(self) -> int: │ │ │ │ │ + assert self._process is not None │ │ │ │ │ + return self._process.pid │ │ │ │ │ + │ │ │ │ │ + def get_returncode(self) -> 'int | None': │ │ │ │ │ + return self._returncode │ │ │ │ │ + │ │ │ │ │ + def get_pipe_transport(self, fd: int) -> asyncio.Transport: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ + │ │ │ │ │ + def send_signal(self, sig: signal.Signals) -> None: # type: ignore[override] # mypy/issues/13885 │ │ │ │ │ + assert self._process is not None │ │ │ │ │ + # We try to avoid using subprocess.send_signal(). It contains a call │ │ │ │ │ + # to waitpid() internally to avoid signalling the wrong process (if a │ │ │ │ │ + # PID gets reused), but: │ │ │ │ │ + # │ │ │ │ │ + # - we already detect the process exiting via our PidfdChildWatcher │ │ │ │ │ + # │ │ │ │ │ + # - the check is actually harmful since collecting the process via │ │ │ │ │ + # waitpid() prevents the PidfdChildWatcher from doing the same, │ │ │ │ │ + # resulting in an error. │ │ │ │ │ + # │ │ │ │ │ + # It's on us now to check it, but that's easy: │ │ │ │ │ + if self._returncode is not None: │ │ │ │ │ + logger.debug("won't attempt %s to process %i. It exited already.", sig, self._process.pid) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + os.kill(self._process.pid, sig) │ │ │ │ │ + logger.debug('sent %s to process %i', sig, self._process.pid) │ │ │ │ │ + except ProcessLookupError: │ │ │ │ │ + # already gone? fine │ │ │ │ │ + logger.debug("can't send %s to process %i. It's exited just now.", sig, self._process.pid) │ │ │ │ │ + │ │ │ │ │ + def terminate(self) -> None: │ │ │ │ │ + self.send_signal(signal.SIGTERM) │ │ │ │ │ + │ │ │ │ │ + def kill(self) -> None: │ │ │ │ │ + self.send_signal(signal.SIGKILL) │ │ │ │ │ + │ │ │ │ │ + def _close(self) -> None: │ │ │ │ │ + if self._pty_fd is not None: │ │ │ │ │ + os.close(self._pty_fd) │ │ │ │ │ + self._pty_fd = None │ │ │ │ │ + │ │ │ │ │ + if self._process is not None: │ │ │ │ │ + if self._process.stdin is not None: │ │ │ │ │ + self._process.stdin.close() │ │ │ │ │ + self._process.stdin = None │ │ │ │ │ + try: │ │ │ │ │ + self.terminate() # best effort... │ │ │ │ │ + except PermissionError: │ │ │ │ │ + logger.debug("can't kill %i due to EPERM", self._process.pid) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class StdioTransport(_Transport): │ │ │ │ │ + """A bi-directional transport that corresponds to stdin/out. │ │ │ │ │ + │ │ │ │ │ + Can talk to just about anything: │ │ │ │ │ + - files │ │ │ │ │ + - pipes │ │ │ │ │ + - character devices (including terminals) │ │ │ │ │ + - sockets │ │ │ │ │ + """ │ │ │ │ │ + │ │ │ │ │ + def __init__(self, loop: asyncio.AbstractEventLoop, protocol: asyncio.Protocol, stdin: int = 0, stdout: int = 1): │ │ │ │ │ + super().__init__(loop, protocol, stdin, stdout) │ │ │ │ │ + │ │ │ │ │ + def can_write_eof(self) -> bool: │ │ │ │ │ + return False │ │ │ │ │ + │ │ │ │ │ + def _write_eof_now(self) -> None: │ │ │ │ │ + raise RuntimeError("Can't write EOF to stdout") │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class Spooler: │ │ │ │ │ + """Consumes data from an fd, storing it in a buffer. │ │ │ │ │ + │ │ │ │ │ + This makes a copy of the fd, so you don't have to worry about holding it │ │ │ │ │ + open. │ │ │ │ │ + """ │ │ │ │ │ + │ │ │ │ │ + _loop: asyncio.AbstractEventLoop │ │ │ │ │ + _fd: int │ │ │ │ │ + _contents: 'list[bytes]' │ │ │ │ │ + │ │ │ │ │ + def __init__(self, loop: asyncio.AbstractEventLoop, fd: int): │ │ │ │ │ + self._loop = loop │ │ │ │ │ + self._fd = -1 # in case dup() raises an exception │ │ │ │ │ + self._contents = [] │ │ │ │ │ + │ │ │ │ │ + self._fd = os.dup(fd) │ │ │ │ │ + │ │ │ │ │ + os.set_blocking(self._fd, False) │ │ │ │ │ + loop.add_reader(self._fd, self._read_ready) │ │ │ │ │ + │ │ │ │ │ + def _read_ready(self) -> None: │ │ │ │ │ + try: │ │ │ │ │ + data = os.read(self._fd, 8192) │ │ │ │ │ + except BlockingIOError: # pragma: no cover │ │ │ │ │ + return │ │ │ │ │ + except OSError: │ │ │ │ │ + # all other errors -> EOF │ │ │ │ │ + data = b'' │ │ │ │ │ + │ │ │ │ │ + if data != b'': │ │ │ │ │ + self._contents.append(data) │ │ │ │ │ + else: │ │ │ │ │ + self.close() │ │ │ │ │ + │ │ │ │ │ + def _is_ready(self) -> bool: │ │ │ │ │ + if self._fd == -1: │ │ │ │ │ + return False │ │ │ │ │ + return select.select([self._fd], [], [], 0) != ([], [], []) │ │ │ │ │ + │ │ │ │ │ + def get(self, *, reset: bool = False) -> bytes: │ │ │ │ │ + while self._is_ready(): │ │ │ │ │ + self._read_ready() │ │ │ │ │ + │ │ │ │ │ + result = b''.join(self._contents) │ │ │ │ │ + if reset: │ │ │ │ │ + self._contents = [] │ │ │ │ │ + return result │ │ │ │ │ + │ │ │ │ │ + def close(self) -> None: │ │ │ │ │ + if self._fd != -1: │ │ │ │ │ + self._loop.remove_reader(self._fd) │ │ │ │ │ + os.close(self._fd) │ │ │ │ │ + self._fd = -1 │ │ │ │ │ + │ │ │ │ │ + def __del__(self) -> None: │ │ │ │ │ + self.close() │ │ │ │ │ +''', │ │ │ │ │ 'cockpit/beiboot.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ @@ -2832,1374 +4126,14 @@ │ │ │ │ │ │ │ │ │ │ asyncio.run(run(args), debug=args.debug) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ if __name__ == '__main__': │ │ │ │ │ main() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/remote.py': r'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ - │ │ │ │ │ -import getpass │ │ │ │ │ -import logging │ │ │ │ │ -import re │ │ │ │ │ -import socket │ │ │ │ │ -from typing import Dict, List, Optional, Tuple │ │ │ │ │ - │ │ │ │ │ -from cockpit._vendor import ferny │ │ │ │ │ - │ │ │ │ │ -from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none │ │ │ │ │ -from .peer import Peer, PeerError │ │ │ │ │ -from .router import Router, RoutingRule │ │ │ │ │ - │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class PasswordResponder(ferny.AskpassHandler): │ │ │ │ │ - PASSPHRASE_RE = re.compile(r"Enter passphrase for key '(.*)': ") │ │ │ │ │ - │ │ │ │ │ - password: Optional[str] │ │ │ │ │ - │ │ │ │ │ - hostkeys_seen: List[Tuple[str, str, str, str, str]] │ │ │ │ │ - error_message: Optional[str] │ │ │ │ │ - password_attempts: int │ │ │ │ │ - │ │ │ │ │ - def __init__(self, password: Optional[str]): │ │ │ │ │ - self.password = password │ │ │ │ │ - │ │ │ │ │ - self.hostkeys_seen = [] │ │ │ │ │ - self.error_message = None │ │ │ │ │ - self.password_attempts = 0 │ │ │ │ │ - │ │ │ │ │ - async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool: │ │ │ │ │ - self.hostkeys_seen.append((reason, host, algorithm, key, fingerprint)) │ │ │ │ │ - return False │ │ │ │ │ - │ │ │ │ │ - async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]: │ │ │ │ │ - logger.debug('Got askpass(%s): %s', hint, prompt) │ │ │ │ │ - │ │ │ │ │ - match = PasswordResponder.PASSPHRASE_RE.fullmatch(prompt) │ │ │ │ │ - if match is not None: │ │ │ │ │ - # We never unlock private keys — we rather need to throw a │ │ │ │ │ - # specially-formatted error message which will cause the frontend │ │ │ │ │ - # to load the named key into the agent for us and try again. │ │ │ │ │ - path = match.group(1) │ │ │ │ │ - logger.debug("This is a passphrase request for %s, but we don't do those. Abort.", path) │ │ │ │ │ - self.error_message = f'locked identity: {path}' │ │ │ │ │ - return None │ │ │ │ │ - │ │ │ │ │ - assert self.password is not None │ │ │ │ │ - assert self.password_attempts == 0 │ │ │ │ │ - self.password_attempts += 1 │ │ │ │ │ - return self.password │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class SshPeer(Peer): │ │ │ │ │ - session: Optional[ferny.Session] = None │ │ │ │ │ - host: str │ │ │ │ │ - user: Optional[str] │ │ │ │ │ - password: Optional[str] │ │ │ │ │ - private: bool │ │ │ │ │ - │ │ │ │ │ - async def do_connect_transport(self) -> None: │ │ │ │ │ - assert self.session is not None │ │ │ │ │ - logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private) │ │ │ │ │ - │ │ │ │ │ - basename, colon, portstr = self.host.rpartition(':') │ │ │ │ │ - if colon and portstr.isdigit(): │ │ │ │ │ - host = basename │ │ │ │ │ - port = int(portstr) │ │ │ │ │ - else: │ │ │ │ │ - host = self.host │ │ │ │ │ - port = None │ │ │ │ │ - │ │ │ │ │ - responder = PasswordResponder(self.password) │ │ │ │ │ - options = {"StrictHostKeyChecking": 'yes'} │ │ │ │ │ - │ │ │ │ │ - if self.password is not None: │ │ │ │ │ - options.update(NumberOfPasswordPrompts='1') │ │ │ │ │ - else: │ │ │ │ │ - options.update(PasswordAuthentication="no", KbdInteractiveAuthentication="no") │ │ │ │ │ - │ │ │ │ │ - try: │ │ │ │ │ - await self.session.connect(host, login_name=self.user, port=port, │ │ │ │ │ - handle_host_key=self.private, options=options, │ │ │ │ │ - interaction_responder=responder) │ │ │ │ │ - except (OSError, socket.gaierror) as exc: │ │ │ │ │ - logger.debug('connecting to host %s failed: %s', host, exc) │ │ │ │ │ - raise PeerError('no-host', error='no-host', message=str(exc)) from exc │ │ │ │ │ - │ │ │ │ │ - except ferny.SshHostKeyError as exc: │ │ │ │ │ - if responder.hostkeys_seen: │ │ │ │ │ - # If we saw a hostkey then we can issue a detailed error message │ │ │ │ │ - # containing the key that would need to be accepted. That will │ │ │ │ │ - # cause the front-end to present a dialog. │ │ │ │ │ - _reason, host, algorithm, key, fingerprint = responder.hostkeys_seen[0] │ │ │ │ │ - error_args = {'host-key': f'{host} {algorithm} {key}', 'host-fingerprint': fingerprint} │ │ │ │ │ - else: │ │ │ │ │ - error_args = {} │ │ │ │ │ - │ │ │ │ │ - if isinstance(exc, ferny.SshChangedHostKeyError): │ │ │ │ │ - error = 'invalid-hostkey' │ │ │ │ │ - elif self.private: │ │ │ │ │ - error = 'unknown-hostkey' │ │ │ │ │ - else: │ │ │ │ │ - # non-private session case. throw a generic error. │ │ │ │ │ - error = 'unknown-host' │ │ │ │ │ - │ │ │ │ │ - logger.debug('SshPeer got a %s %s; private %s, seen hostkeys %r; raising %s with extra args %r', │ │ │ │ │ - type(exc), exc, self.private, responder.hostkeys_seen, error, error_args) │ │ │ │ │ - raise PeerError(error, error_args, error=error, auth_method_results={}) from exc │ │ │ │ │ - │ │ │ │ │ - except ferny.SshAuthenticationError as exc: │ │ │ │ │ - logger.debug('authentication to host %s failed: %s', host, exc) │ │ │ │ │ - │ │ │ │ │ - results = {method: 'not-provided' for method in exc.methods} │ │ │ │ │ - if 'password' in results and self.password is not None: │ │ │ │ │ - if responder.password_attempts == 0: │ │ │ │ │ - results['password'] = 'not-tried' │ │ │ │ │ - else: │ │ │ │ │ - results['password'] = 'denied' │ │ │ │ │ - │ │ │ │ │ - raise PeerError('authentication-failed', │ │ │ │ │ - error=responder.error_message or 'authentication-failed', │ │ │ │ │ - auth_method_results=results) from exc │ │ │ │ │ - │ │ │ │ │ - except ferny.SshError as exc: │ │ │ │ │ - logger.debug('unknown failure connecting to host %s: %s', host, exc) │ │ │ │ │ - raise PeerError('internal-error', message=str(exc)) from exc │ │ │ │ │ - │ │ │ │ │ - args = self.session.wrap_subprocess_args(['cockpit-bridge']) │ │ │ │ │ - await self.spawn(args, []) │ │ │ │ │ - │ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ - if host == self.host: │ │ │ │ │ - self.close() │ │ │ │ │ - elif host is None: │ │ │ │ │ - super().do_kill(host, group, message) │ │ │ │ │ - │ │ │ │ │ - def do_authorize(self, message: JsonObject) -> None: │ │ │ │ │ - if get_str(message, 'challenge').startswith('plain1:'): │ │ │ │ │ - cookie = get_str(message, 'cookie') │ │ │ │ │ - self.write_control(command='authorize', cookie=cookie, response=self.password or '') │ │ │ │ │ - self.password = None # once is enough... │ │ │ │ │ - │ │ │ │ │ - def do_superuser_init_done(self) -> None: │ │ │ │ │ - self.password = None │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None: │ │ │ │ │ - super().__init__(router) │ │ │ │ │ - self.host = host │ │ │ │ │ - self.user = user │ │ │ │ │ - self.password = get_str(options, 'password', None) │ │ │ │ │ - self.private = private │ │ │ │ │ - │ │ │ │ │ - self.session = ferny.Session() │ │ │ │ │ - │ │ │ │ │ - superuser: JsonValue │ │ │ │ │ - init_superuser = get_str_or_none(options, 'init-superuser', None) │ │ │ │ │ - if init_superuser in (None, 'none'): │ │ │ │ │ - superuser = False │ │ │ │ │ - else: │ │ │ │ │ - superuser = {'id': init_superuser} │ │ │ │ │ - │ │ │ │ │ - self.start_in_background(init_host=host, superuser=superuser) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class HostRoutingRule(RoutingRule): │ │ │ │ │ - remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer] │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router): │ │ │ │ │ - super().__init__(router) │ │ │ │ │ - self.remotes = {} │ │ │ │ │ - │ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Peer]: │ │ │ │ │ - assert self.router is not None │ │ │ │ │ - assert self.router.init_host is not None │ │ │ │ │ - │ │ │ │ │ - host = get_str(options, 'host', self.router.init_host) │ │ │ │ │ - if host == self.router.init_host: │ │ │ │ │ - return None │ │ │ │ │ - │ │ │ │ │ - user = get_str(options, 'user', None) │ │ │ │ │ - # HACK: the front-end relies on this for tracking connections without an explicit user name; │ │ │ │ │ - # the user will then be determined by SSH (`User` in the config or the current user) │ │ │ │ │ - # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c │ │ │ │ │ - if user == getpass.getuser(): │ │ │ │ │ - user = None │ │ │ │ │ - if not user: │ │ │ │ │ - user_from_host, _, _ = host.rpartition('@') │ │ │ │ │ - user = user_from_host or None # avoid '' │ │ │ │ │ - │ │ │ │ │ - if get_str(options, 'session', None) == 'private': │ │ │ │ │ - nonce = get_str(options, 'channel') │ │ │ │ │ - else: │ │ │ │ │ - nonce = None │ │ │ │ │ - │ │ │ │ │ - assert isinstance(host, str) │ │ │ │ │ - assert user is None or isinstance(user, str) │ │ │ │ │ - assert nonce is None or isinstance(nonce, str) │ │ │ │ │ - │ │ │ │ │ - key = host, user, nonce │ │ │ │ │ - │ │ │ │ │ - logger.debug('Request for channel %s is remote.', options) │ │ │ │ │ - logger.debug('key=%s', key) │ │ │ │ │ - │ │ │ │ │ - if key not in self.remotes: │ │ │ │ │ - logger.debug('%s is not among the existing remotes %s. Opening a new connection.', key, self.remotes) │ │ │ │ │ - peer = SshPeer(self.router, host, user, options, private=nonce is not None) │ │ │ │ │ - peer.add_done_callback(lambda: self.remotes.__delitem__(key)) │ │ │ │ │ - self.remotes[key] = peer │ │ │ │ │ - │ │ │ │ │ - return self.remotes[key] │ │ │ │ │ - │ │ │ │ │ - def shutdown(self): │ │ │ │ │ - for peer in set(self.remotes.values()): │ │ │ │ │ - peer.close() │ │ │ │ │ -'''.encode('utf-8'), │ │ │ │ │ - 'cockpit/channel.py': br'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ - │ │ │ │ │ -import asyncio │ │ │ │ │ -import json │ │ │ │ │ -import logging │ │ │ │ │ -from typing import BinaryIO, ClassVar, Dict, Generator, List, Optional, Sequence, Set, Tuple, Type │ │ │ │ │ - │ │ │ │ │ -from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_bool, get_str │ │ │ │ │ -from .protocol import CockpitProblem │ │ │ │ │ -from .router import Endpoint, Router, RoutingRule │ │ │ │ │ - │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class ChannelRoutingRule(RoutingRule): │ │ │ │ │ - table: Dict[str, List[Type['Channel']]] │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router: Router, channel_types: List[Type['Channel']]): │ │ │ │ │ - super().__init__(router) │ │ │ │ │ - self.table = {} │ │ │ │ │ - │ │ │ │ │ - # Sort the channels into buckets by payload type │ │ │ │ │ - for cls in channel_types: │ │ │ │ │ - entry = self.table.setdefault(cls.payload, []) │ │ │ │ │ - entry.append(cls) │ │ │ │ │ - │ │ │ │ │ - # Within each bucket, sort the channels so those with more │ │ │ │ │ - # restrictions are considered first. │ │ │ │ │ - for entry in self.table.values(): │ │ │ │ │ - entry.sort(key=lambda cls: len(cls.restrictions), reverse=True) │ │ │ │ │ - │ │ │ │ │ - def check_restrictions(self, restrictions: Sequence[Tuple[str, object]], options: JsonObject) -> bool: │ │ │ │ │ - for key, expected_value in restrictions: │ │ │ │ │ - our_value = options.get(key) │ │ │ │ │ - │ │ │ │ │ - # If the match rule specifies that a value must be present and │ │ │ │ │ - # we don't have it, then fail. │ │ │ │ │ - if our_value is None: │ │ │ │ │ - return False │ │ │ │ │ - │ │ │ │ │ - # If the match rule specified a specific expected value, and │ │ │ │ │ - # our value doesn't match it, then fail. │ │ │ │ │ - if expected_value is not None and our_value != expected_value: │ │ │ │ │ - return False │ │ │ │ │ - │ │ │ │ │ - # Everything checked out │ │ │ │ │ - return True │ │ │ │ │ - │ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional['Channel']: │ │ │ │ │ - assert self.router is not None │ │ │ │ │ - │ │ │ │ │ - payload = options.get('payload') │ │ │ │ │ - if not isinstance(payload, str): │ │ │ │ │ - return None │ │ │ │ │ - │ │ │ │ │ - for cls in self.table.get(payload, []): │ │ │ │ │ - if self.check_restrictions(cls.restrictions, options): │ │ │ │ │ - return cls(self.router) │ │ │ │ │ - else: │ │ │ │ │ - return None │ │ │ │ │ - │ │ │ │ │ - def shutdown(self): │ │ │ │ │ - pass # we don't hold any state │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class ChannelError(CockpitProblem): │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class Channel(Endpoint): │ │ │ │ │ - # Values borrowed from C implementation │ │ │ │ │ - BLOCK_SIZE = 16 * 1024 │ │ │ │ │ - SEND_WINDOW = 2 * 1024 * 1024 │ │ │ │ │ - │ │ │ │ │ - # Flow control book-keeping │ │ │ │ │ - _send_pings: bool = False │ │ │ │ │ - _out_sequence: int = 0 │ │ │ │ │ - _out_window: int = SEND_WINDOW │ │ │ │ │ - │ │ │ │ │ - # Task management │ │ │ │ │ - _tasks: Set[asyncio.Task] │ │ │ │ │ - _close_args: Optional[JsonObject] = None │ │ │ │ │ - │ │ │ │ │ - # Must be filled in by the channel implementation │ │ │ │ │ - payload: ClassVar[str] │ │ │ │ │ - restrictions: ClassVar[Sequence[Tuple[str, object]]] = () │ │ │ │ │ - │ │ │ │ │ - # These get filled in from .do_open() │ │ │ │ │ - channel = '' │ │ │ │ │ - group = '' │ │ │ │ │ - │ │ │ │ │ - # input │ │ │ │ │ - def do_control(self, command, message): │ │ │ │ │ - # Break the various different kinds of control messages out into the │ │ │ │ │ - # things that our subclass may be interested in handling. We drop the │ │ │ │ │ - # 'message' field for handlers that don't need it. │ │ │ │ │ - if command == 'open': │ │ │ │ │ - self._tasks = set() │ │ │ │ │ - self.channel = message['channel'] │ │ │ │ │ - if get_bool(message, 'flow-control', default=False): │ │ │ │ │ - self._send_pings = True │ │ │ │ │ - self.group = get_str(message, 'group', 'default') │ │ │ │ │ - self.freeze_endpoint() │ │ │ │ │ - self.do_open(message) │ │ │ │ │ - elif command == 'ready': │ │ │ │ │ - self.do_ready() │ │ │ │ │ - elif command == 'done': │ │ │ │ │ - self.do_done() │ │ │ │ │ - elif command == 'close': │ │ │ │ │ - self.do_close() │ │ │ │ │ - elif command == 'ping': │ │ │ │ │ - self.do_ping(message) │ │ │ │ │ - elif command == 'pong': │ │ │ │ │ - self.do_pong(message) │ │ │ │ │ - elif command == 'options': │ │ │ │ │ - self.do_options(message) │ │ │ │ │ - │ │ │ │ │ - def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ - # Already closing? Ignore. │ │ │ │ │ - if self._close_args is not None: │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - # Catch errors and turn them into close messages │ │ │ │ │ - try: │ │ │ │ │ - try: │ │ │ │ │ - self.do_control(command, message) │ │ │ │ │ - except JsonError as exc: │ │ │ │ │ - raise ChannelError('protocol-error', message=str(exc)) from exc │ │ │ │ │ - except ChannelError as exc: │ │ │ │ │ - self.close(exc.attrs) │ │ │ │ │ - │ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', _message: JsonObject) -> None: │ │ │ │ │ - # Already closing? Ignore. │ │ │ │ │ - if self._close_args is not None: │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - if host is not None: │ │ │ │ │ - return │ │ │ │ │ - if group is not None and self.group != group: │ │ │ │ │ - return │ │ │ │ │ - self.do_close() │ │ │ │ │ - │ │ │ │ │ - # At least this one really ought to be implemented... │ │ │ │ │ - def do_open(self, options: JsonObject) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - # ... but many subclasses may reasonably want to ignore some of these. │ │ │ │ │ - def do_ready(self) -> None: │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - def do_done(self) -> None: │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - def do_close(self) -> None: │ │ │ │ │ - self.close() │ │ │ │ │ - │ │ │ │ │ - def do_options(self, message: JsonObject) -> None: │ │ │ │ │ - raise ChannelError('not-supported', message='This channel does not implement "options"') │ │ │ │ │ - │ │ │ │ │ - # 'reasonable' default, overridden in other channels for receive-side flow control │ │ │ │ │ - def do_ping(self, message: JsonObject) -> None: │ │ │ │ │ - self.send_pong(message) │ │ │ │ │ - │ │ │ │ │ - def do_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ - # Already closing? Ignore. │ │ │ │ │ - if self._close_args is not None: │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - # Catch errors and turn them into close messages │ │ │ │ │ - try: │ │ │ │ │ - self.do_data(data) │ │ │ │ │ - except ChannelError as exc: │ │ │ │ │ - self.close(exc.attrs) │ │ │ │ │ - │ │ │ │ │ - def do_data(self, _data: bytes) -> None: │ │ │ │ │ - # By default, channels can't receive data. │ │ │ │ │ - self.close() │ │ │ │ │ - │ │ │ │ │ - # output │ │ │ │ │ - def ready(self, **kwargs: JsonValue) -> None: │ │ │ │ │ - self.thaw_endpoint() │ │ │ │ │ - self.send_control(command='ready', **kwargs) │ │ │ │ │ - │ │ │ │ │ - def done(self) -> None: │ │ │ │ │ - self.send_control(command='done') │ │ │ │ │ - │ │ │ │ │ - # tasks and close management │ │ │ │ │ - def is_closing(self) -> bool: │ │ │ │ │ - return self._close_args is not None │ │ │ │ │ - │ │ │ │ │ - def _close_now(self) -> None: │ │ │ │ │ - self.shutdown_endpoint(self._close_args) │ │ │ │ │ - │ │ │ │ │ - def _task_done(self, task): │ │ │ │ │ - # Strictly speaking, we should read the result and check for exceptions but: │ │ │ │ │ - # - exceptions bubbling out of the task are programming errors │ │ │ │ │ - # - the only thing we'd do with it anyway, is to show it │ │ │ │ │ - # - Python already does that with its "Task exception was never retrieved" messages │ │ │ │ │ - self._tasks.remove(task) │ │ │ │ │ - if self._close_args is not None and not self._tasks: │ │ │ │ │ - self._close_now() │ │ │ │ │ - │ │ │ │ │ - def create_task(self, coroutine, name=None): │ │ │ │ │ - """Create a task associated with the channel. │ │ │ │ │ - │ │ │ │ │ - All tasks must exit before the channel can close. You may not create │ │ │ │ │ - new tasks after calling .close(). │ │ │ │ │ - """ │ │ │ │ │ - assert self._close_args is None │ │ │ │ │ - task = asyncio.create_task(coroutine) │ │ │ │ │ - self._tasks.add(task) │ │ │ │ │ - task.add_done_callback(self._task_done) │ │ │ │ │ - return task │ │ │ │ │ - │ │ │ │ │ - def close(self, close_args: 'JsonObject | None' = None) -> None: │ │ │ │ │ - """Requests the channel to be closed. │ │ │ │ │ - │ │ │ │ │ - After you call this method, you won't get anymore `.do_*()` calls. │ │ │ │ │ - │ │ │ │ │ - This will wait for any running tasks to complete before sending the │ │ │ │ │ - close message. │ │ │ │ │ - """ │ │ │ │ │ - if self._close_args is not None: │ │ │ │ │ - # close already requested │ │ │ │ │ - return │ │ │ │ │ - self._close_args = close_args or {} │ │ │ │ │ - if not self._tasks: │ │ │ │ │ - self._close_now() │ │ │ │ │ - │ │ │ │ │ - def send_data(self, data: bytes) -> bool: │ │ │ │ │ - """Send data and handle book-keeping for flow control. │ │ │ │ │ - │ │ │ │ │ - The flow control is "advisory". The data is sent immediately, even if │ │ │ │ │ - it's larger than the window. In general you should try to send packets │ │ │ │ │ - which are approximately Channel.BLOCK_SIZE in size. │ │ │ │ │ - │ │ │ │ │ - Returns True if there is still room in the window, or False if you │ │ │ │ │ - should stop writing for now. In that case, `.do_resume_send()` will be │ │ │ │ │ - called later when there is more room. │ │ │ │ │ - """ │ │ │ │ │ - self.send_channel_data(self.channel, data) │ │ │ │ │ - │ │ │ │ │ - if self._send_pings: │ │ │ │ │ - out_sequence = self._out_sequence + len(data) │ │ │ │ │ - if self._out_sequence // Channel.BLOCK_SIZE != out_sequence // Channel.BLOCK_SIZE: │ │ │ │ │ - self.send_control(command='ping', sequence=out_sequence) │ │ │ │ │ - self._out_sequence = out_sequence │ │ │ │ │ - │ │ │ │ │ - return self._out_sequence < self._out_window │ │ │ │ │ - │ │ │ │ │ - def do_pong(self, message): │ │ │ │ │ - if not self._send_pings: # huh? │ │ │ │ │ - logger.warning("Got wild pong on channel %s", self.channel) │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - self._out_window = message['sequence'] + Channel.SEND_WINDOW │ │ │ │ │ - if self._out_sequence < self._out_window: │ │ │ │ │ - self.do_resume_send() │ │ │ │ │ - │ │ │ │ │ - def do_resume_send(self) -> None: │ │ │ │ │ - """Called to indicate that the channel may start sending again.""" │ │ │ │ │ - # change to `raise NotImplementedError` after everyone implements it │ │ │ │ │ - │ │ │ │ │ - json_encoder: ClassVar[json.JSONEncoder] = json.JSONEncoder(indent=2) │ │ │ │ │ - │ │ │ │ │ - def send_json(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> bool: │ │ │ │ │ - pretty = self.json_encoder.encode(create_object(_msg, kwargs)) + '\n' │ │ │ │ │ - return self.send_data(pretty.encode()) │ │ │ │ │ - │ │ │ │ │ - def send_control(self, command: str, **kwargs: JsonValue) -> None: │ │ │ │ │ - self.send_channel_control(self.channel, command, None, **kwargs) │ │ │ │ │ - │ │ │ │ │ - def send_pong(self, message: JsonObject) -> None: │ │ │ │ │ - self.send_channel_control(self.channel, 'pong', message) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class ProtocolChannel(Channel, asyncio.Protocol): │ │ │ │ │ - """A channel subclass that implements the asyncio Protocol interface. │ │ │ │ │ - │ │ │ │ │ - In effect, data sent to this channel will be written to the connected │ │ │ │ │ - transport, and vice-versa. Flow control is supported. │ │ │ │ │ - │ │ │ │ │ - The default implementation of the .do_open() method calls the │ │ │ │ │ - .create_transport() abstract method. This method should return a transport │ │ │ │ │ - which will be used for communication on the channel. │ │ │ │ │ - │ │ │ │ │ - Otherwise, if the subclass implements .do_open() itself, it is responsible │ │ │ │ │ - for setting up the connection and ensuring that .connection_made() is called. │ │ │ │ │ - """ │ │ │ │ │ - _transport: Optional[asyncio.Transport] │ │ │ │ │ - _loop: Optional[asyncio.AbstractEventLoop] │ │ │ │ │ - _send_pongs: bool = True │ │ │ │ │ - _last_ping: Optional[JsonObject] = None │ │ │ │ │ - _create_transport_task = None │ │ │ │ │ - │ │ │ │ │ - # read-side EOF handling │ │ │ │ │ - _close_on_eof: bool = False │ │ │ │ │ - _eof: bool = False │ │ │ │ │ - │ │ │ │ │ - async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport: │ │ │ │ │ - """Creates the transport for this channel, according to options. │ │ │ │ │ - │ │ │ │ │ - The event loop for the transport is passed to the function. The │ │ │ │ │ - protocol for the transport is the channel object, itself (self). │ │ │ │ │ - │ │ │ │ │ - This needs to be implemented by the subclass. │ │ │ │ │ - """ │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - def do_open(self, options: JsonObject) -> None: │ │ │ │ │ - loop = asyncio.get_running_loop() │ │ │ │ │ - self._create_transport_task = asyncio.create_task(self.create_transport(loop, options)) │ │ │ │ │ - self._create_transport_task.add_done_callback(self.create_transport_done) │ │ │ │ │ - │ │ │ │ │ - def create_transport_done(self, task: 'asyncio.Task[asyncio.Transport]') -> None: │ │ │ │ │ - assert task is self._create_transport_task │ │ │ │ │ - self._create_transport_task = None │ │ │ │ │ - try: │ │ │ │ │ - transport = task.result() │ │ │ │ │ - except ChannelError as exc: │ │ │ │ │ - self.close(exc.attrs) │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - self.connection_made(transport) │ │ │ │ │ - self.ready() │ │ │ │ │ - │ │ │ │ │ - def connection_made(self, transport: asyncio.BaseTransport) -> None: │ │ │ │ │ - assert isinstance(transport, asyncio.Transport) │ │ │ │ │ - self._transport = transport │ │ │ │ │ - │ │ │ │ │ - def _get_close_args(self) -> JsonObject: │ │ │ │ │ - return {} │ │ │ │ │ - │ │ │ │ │ - def connection_lost(self, exc: Optional[Exception]) -> None: │ │ │ │ │ - self.close(self._get_close_args()) │ │ │ │ │ - │ │ │ │ │ - def do_data(self, data: bytes) -> None: │ │ │ │ │ - assert self._transport is not None │ │ │ │ │ - self._transport.write(data) │ │ │ │ │ - │ │ │ │ │ - def do_done(self) -> None: │ │ │ │ │ - assert self._transport is not None │ │ │ │ │ - if self._transport.can_write_eof(): │ │ │ │ │ - self._transport.write_eof() │ │ │ │ │ - │ │ │ │ │ - def do_close(self) -> None: │ │ │ │ │ - if self._transport is not None: │ │ │ │ │ - self._transport.close() │ │ │ │ │ - │ │ │ │ │ - def data_received(self, data: bytes) -> None: │ │ │ │ │ - assert self._transport is not None │ │ │ │ │ - if not self.send_data(data): │ │ │ │ │ - self._transport.pause_reading() │ │ │ │ │ - │ │ │ │ │ - def do_resume_send(self) -> None: │ │ │ │ │ - assert self._transport is not None │ │ │ │ │ - self._transport.resume_reading() │ │ │ │ │ - │ │ │ │ │ - def close_on_eof(self) -> None: │ │ │ │ │ - """Mark the channel to be closed on EOF. │ │ │ │ │ - │ │ │ │ │ - Normally, ProtocolChannel tries to keep the channel half-open after │ │ │ │ │ - receiving EOF from the transport. This instructs that the channel │ │ │ │ │ - should be closed on EOF. │ │ │ │ │ - │ │ │ │ │ - If EOF was already received, then calling this function will close the │ │ │ │ │ - channel immediately. │ │ │ │ │ - │ │ │ │ │ - If you don't call this function, you are responsible for closing the │ │ │ │ │ - channel yourself. │ │ │ │ │ - """ │ │ │ │ │ - self._close_on_eof = True │ │ │ │ │ - if self._eof: │ │ │ │ │ - assert self._transport is not None │ │ │ │ │ - self._transport.close() │ │ │ │ │ - │ │ │ │ │ - def eof_received(self) -> bool: │ │ │ │ │ - self._eof = True │ │ │ │ │ - self.done() │ │ │ │ │ - return not self._close_on_eof │ │ │ │ │ - │ │ │ │ │ - # Channel receive-side flow control │ │ │ │ │ - def do_ping(self, message): │ │ │ │ │ - if self._send_pongs: │ │ │ │ │ - self.send_pong(message) │ │ │ │ │ - else: │ │ │ │ │ - # we'll have to pong later │ │ │ │ │ - self._last_ping = message │ │ │ │ │ - │ │ │ │ │ - def pause_writing(self) -> None: │ │ │ │ │ - # We can't actually stop writing, but we can stop replying to pings │ │ │ │ │ - self._send_pongs = False │ │ │ │ │ - │ │ │ │ │ - def resume_writing(self) -> None: │ │ │ │ │ - self._send_pongs = True │ │ │ │ │ - if self._last_ping is not None: │ │ │ │ │ - self.send_pong(self._last_ping) │ │ │ │ │ - self._last_ping = None │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class AsyncChannel(Channel): │ │ │ │ │ - """A subclass for async/await-style implementation of channels, with flow control │ │ │ │ │ - │ │ │ │ │ - This subclass provides asynchronous `read()` and `write()` calls for │ │ │ │ │ - subclasses, with familiar semantics. `write()` doesn't buffer, so the │ │ │ │ │ - `done()` method on the base channel class can be used in a way similar to │ │ │ │ │ - `shutdown()`. A high-level `sendfile()` method is available to send the │ │ │ │ │ - entire contents of a binary-mode file-like object. │ │ │ │ │ - │ │ │ │ │ - The subclass must provide an async `run()` function, which will be spawned │ │ │ │ │ - as a task. │ │ │ │ │ - │ │ │ │ │ - On the receiving side, the channel will respond to flow control pings to │ │ │ │ │ - indicate that it has received the data, but only after it has been consumed │ │ │ │ │ - by `read()`. │ │ │ │ │ - │ │ │ │ │ - On the sending side, write() will block if the channel backs up. │ │ │ │ │ - """ │ │ │ │ │ - │ │ │ │ │ - # Receive-side flow control: intermix pings and data in the queue and reply │ │ │ │ │ - # to pings as we dequeue them. This is a buffer: since we need to handle │ │ │ │ │ - # do_data() without blocking, we have no choice. │ │ │ │ │ - receive_queue = None │ │ │ │ │ - │ │ │ │ │ - # Send-side flow control │ │ │ │ │ - write_waiter = None │ │ │ │ │ - │ │ │ │ │ - async def run(self, options): │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - async def run_wrapper(self, options): │ │ │ │ │ - try: │ │ │ │ │ - await self.run(options) │ │ │ │ │ - self.close() │ │ │ │ │ - except ChannelError as exc: │ │ │ │ │ - self.close(exc.attrs) │ │ │ │ │ - │ │ │ │ │ - async def read(self): │ │ │ │ │ - while True: │ │ │ │ │ - item = await self.receive_queue.get() │ │ │ │ │ - if isinstance(item, bytes): │ │ │ │ │ - return item │ │ │ │ │ - self.send_pong(item) │ │ │ │ │ - │ │ │ │ │ - async def write(self, data): │ │ │ │ │ - if not self.send_data(data): │ │ │ │ │ - self.write_waiter = asyncio.get_running_loop().create_future() │ │ │ │ │ - await self.write_waiter │ │ │ │ │ - │ │ │ │ │ - async def sendfile(self, stream: BinaryIO) -> None: │ │ │ │ │ - loop = asyncio.get_running_loop() │ │ │ │ │ - with stream: │ │ │ │ │ - while True: │ │ │ │ │ - data = await loop.run_in_executor(None, stream.read, Channel.BLOCK_SIZE) │ │ │ │ │ - if data == b'': │ │ │ │ │ - break │ │ │ │ │ - await self.write(data) │ │ │ │ │ - │ │ │ │ │ - self.done() │ │ │ │ │ - │ │ │ │ │ - def do_resume_send(self) -> None: │ │ │ │ │ - if self.write_waiter is not None: │ │ │ │ │ - self.write_waiter.set_result(None) │ │ │ │ │ - self.write_waiter = None │ │ │ │ │ - │ │ │ │ │ - def do_open(self, options): │ │ │ │ │ - self.receive_queue = asyncio.Queue() │ │ │ │ │ - self.create_task(self.run_wrapper(options), name=f'{self.__class__.__name__}.run_wrapper({options})') │ │ │ │ │ - │ │ │ │ │ - def do_done(self): │ │ │ │ │ - self.receive_queue.put_nowait(b'') │ │ │ │ │ - │ │ │ │ │ - def do_close(self): │ │ │ │ │ - # we might have already sent EOF for done, but two EOFs won't hurt anyone │ │ │ │ │ - self.receive_queue.put_nowait(b'') │ │ │ │ │ - │ │ │ │ │ - def do_ping(self, message): │ │ │ │ │ - self.receive_queue.put_nowait(message) │ │ │ │ │ - │ │ │ │ │ - def do_data(self, data): │ │ │ │ │ - if not isinstance(data, bytes): │ │ │ │ │ - # this will persist past this callback, so make sure we take our │ │ │ │ │ - # own copy, in case this was a memoryview into a bytearray. │ │ │ │ │ - data = bytes(data) │ │ │ │ │ - │ │ │ │ │ - self.receive_queue.put_nowait(data) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class GeneratorChannel(Channel): │ │ │ │ │ - """A trivial Channel subclass for sending data from a generator with flow control. │ │ │ │ │ - │ │ │ │ │ - Calls the .do_yield_data() generator with the options from the open message │ │ │ │ │ - and sends the data which it yields. If the generator returns a value it │ │ │ │ │ - will be used for the close message. │ │ │ │ │ - """ │ │ │ │ │ - DataGenerator = Generator[bytes, None, Optional[JsonObject]] │ │ │ │ │ - __generator: DataGenerator │ │ │ │ │ - │ │ │ │ │ - def do_yield_data(self, options: JsonObject) -> 'DataGenerator': │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - def do_open(self, options: JsonObject) -> None: │ │ │ │ │ - self.__generator = self.do_yield_data(options) │ │ │ │ │ - self.do_resume_send() │ │ │ │ │ - │ │ │ │ │ - def do_resume_send(self) -> None: │ │ │ │ │ - try: │ │ │ │ │ - while self.send_data(next(self.__generator)): │ │ │ │ │ - pass │ │ │ │ │ - except StopIteration as stop: │ │ │ │ │ - self.done() │ │ │ │ │ - self.close(stop.value) │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/router.py': br'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ - │ │ │ │ │ -import asyncio │ │ │ │ │ -import collections │ │ │ │ │ -import logging │ │ │ │ │ -from typing import Dict, List, Optional │ │ │ │ │ - │ │ │ │ │ -from .jsonutil import JsonObject, JsonValue │ │ │ │ │ -from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer │ │ │ │ │ - │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class ExecutionQueue: │ │ │ │ │ - """Temporarily delay calls to a given set of class methods. │ │ │ │ │ - │ │ │ │ │ - Functions by replacing the named function at the instance __dict__ │ │ │ │ │ - level, effectively providing an override for exactly one instance │ │ │ │ │ - of `method`'s object. │ │ │ │ │ - Queues the invocations. Run them later with .run(), which also reverses │ │ │ │ │ - the redirection by deleting the named methods from the instance. │ │ │ │ │ - """ │ │ │ │ │ - def __init__(self, methods): │ │ │ │ │ - self.queue = collections.deque() │ │ │ │ │ - self.methods = methods │ │ │ │ │ - │ │ │ │ │ - for method in self.methods: │ │ │ │ │ - self._wrap(method) │ │ │ │ │ - │ │ │ │ │ - def _wrap(self, method): │ │ │ │ │ - # NB: this function is stored in the instance dict and therefore │ │ │ │ │ - # doesn't function as a descriptor, isn't a method, doesn't get bound, │ │ │ │ │ - # and therefore doesn't receive a self parameter │ │ │ │ │ - setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args))) │ │ │ │ │ - │ │ │ │ │ - def run(self): │ │ │ │ │ - logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue)) │ │ │ │ │ - for method, args in self.queue: │ │ │ │ │ - method(*args) │ │ │ │ │ - │ │ │ │ │ - for method in self.methods: │ │ │ │ │ - delattr(method.__self__, method.__func__.__name__) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class Endpoint: │ │ │ │ │ - router: 'Router' │ │ │ │ │ - __endpoint_frozen_queue: Optional[ExecutionQueue] = None │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router: 'Router'): │ │ │ │ │ - router.add_endpoint(self) │ │ │ │ │ - self.router = router │ │ │ │ │ - │ │ │ │ │ - def freeze_endpoint(self): │ │ │ │ │ - assert self.__endpoint_frozen_queue is None │ │ │ │ │ - logger.debug('Freezing endpoint %s', self) │ │ │ │ │ - self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill}) │ │ │ │ │ - │ │ │ │ │ - def thaw_endpoint(self): │ │ │ │ │ - assert self.__endpoint_frozen_queue is not None │ │ │ │ │ - logger.debug('Thawing endpoint %s', self) │ │ │ │ │ - self.__endpoint_frozen_queue.run() │ │ │ │ │ - self.__endpoint_frozen_queue = None │ │ │ │ │ - │ │ │ │ │ - # interface for receiving messages │ │ │ │ │ - def do_close(self): │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - def do_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - # interface for sending messages │ │ │ │ │ - def send_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ - self.router.write_channel_data(channel, data) │ │ │ │ │ - │ │ │ │ │ - def send_channel_control( │ │ │ │ │ - self, channel: str, command: str, _msg: 'JsonObject | None', **kwargs: JsonValue │ │ │ │ │ - ) -> None: │ │ │ │ │ - self.router.write_control(_msg, channel=channel, command=command, **kwargs) │ │ │ │ │ - if command == 'close': │ │ │ │ │ - self.router.endpoints[self].remove(channel) │ │ │ │ │ - self.router.drop_channel(channel) │ │ │ │ │ - │ │ │ │ │ - def shutdown_endpoint(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None: │ │ │ │ │ - self.router.shutdown_endpoint(self, _msg, **kwargs) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class RoutingError(CockpitProblem): │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class RoutingRule: │ │ │ │ │ - router: 'Router' │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router: 'Router'): │ │ │ │ │ - self.router = router │ │ │ │ │ - │ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Endpoint]: │ │ │ │ │ - """Check if a routing rule applies to a given 'open' message. │ │ │ │ │ - │ │ │ │ │ - This should inspect the options dictionary and do one of the following three things: │ │ │ │ │ - │ │ │ │ │ - - return an Endpoint to handle this channel │ │ │ │ │ - - raise a RoutingError to indicate that the open should be rejected │ │ │ │ │ - - return None to let the next rule run │ │ │ │ │ - """ │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - def shutdown(self): │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class Router(CockpitProtocolServer): │ │ │ │ │ - routing_rules: List[RoutingRule] │ │ │ │ │ - open_channels: Dict[str, Endpoint] │ │ │ │ │ - endpoints: 'dict[Endpoint, set[str]]' │ │ │ │ │ - no_endpoints: asyncio.Event # set if endpoints dict is empty │ │ │ │ │ - _eof: bool = False │ │ │ │ │ - │ │ │ │ │ - def __init__(self, routing_rules: List[RoutingRule]): │ │ │ │ │ - for rule in routing_rules: │ │ │ │ │ - rule.router = self │ │ │ │ │ - self.routing_rules = routing_rules │ │ │ │ │ - self.open_channels = {} │ │ │ │ │ - self.endpoints = {} │ │ │ │ │ - self.no_endpoints = asyncio.Event() │ │ │ │ │ - self.no_endpoints.set() # at first there are no endpoints │ │ │ │ │ - │ │ │ │ │ - def check_rules(self, options: JsonObject) -> Endpoint: │ │ │ │ │ - for rule in self.routing_rules: │ │ │ │ │ - logger.debug(' applying rule %s', rule) │ │ │ │ │ - endpoint = rule.apply_rule(options) │ │ │ │ │ - if endpoint is not None: │ │ │ │ │ - logger.debug(' resulting endpoint is %s', endpoint) │ │ │ │ │ - return endpoint │ │ │ │ │ - else: │ │ │ │ │ - logger.debug(' No rules matched') │ │ │ │ │ - raise RoutingError('not-supported') │ │ │ │ │ - │ │ │ │ │ - def drop_channel(self, channel: str) -> None: │ │ │ │ │ - try: │ │ │ │ │ - self.open_channels.pop(channel) │ │ │ │ │ - logger.debug('router dropped channel %s', channel) │ │ │ │ │ - except KeyError: │ │ │ │ │ - logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels) │ │ │ │ │ - │ │ │ │ │ - def add_endpoint(self, endpoint: Endpoint) -> None: │ │ │ │ │ - self.endpoints[endpoint] = set() │ │ │ │ │ - self.no_endpoints.clear() │ │ │ │ │ - │ │ │ │ │ - def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None: │ │ │ │ │ - channels = self.endpoints.pop(endpoint) │ │ │ │ │ - logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels) │ │ │ │ │ - for channel in channels: │ │ │ │ │ - self.write_control(_msg, command='close', channel=channel, **kwargs) │ │ │ │ │ - self.drop_channel(channel) │ │ │ │ │ - │ │ │ │ │ - if not self.endpoints: │ │ │ │ │ - self.no_endpoints.set() │ │ │ │ │ - │ │ │ │ │ - # were we waiting to exit? │ │ │ │ │ - if self._eof: │ │ │ │ │ - logger.debug(' endpoints remaining: %r', self.endpoints) │ │ │ │ │ - if not self.endpoints and self.transport: │ │ │ │ │ - logger.debug(' close transport') │ │ │ │ │ - self.transport.close() │ │ │ │ │ - │ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ - endpoints = set(self.endpoints) │ │ │ │ │ - logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints)) │ │ │ │ │ - for endpoint in endpoints: │ │ │ │ │ - endpoint.do_kill(host, group, message) │ │ │ │ │ - │ │ │ │ │ - def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ - # If this is an open message then we need to apply the routing rules to │ │ │ │ │ - # figure out the correct endpoint to connect. If it's not an open │ │ │ │ │ - # message, then we expect the endpoint to already exist. │ │ │ │ │ - if command == 'open': │ │ │ │ │ - if channel in self.open_channels: │ │ │ │ │ - raise CockpitProtocolError('channel is already open') │ │ │ │ │ - │ │ │ │ │ - try: │ │ │ │ │ - logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload')) │ │ │ │ │ - endpoint = self.check_rules(message) │ │ │ │ │ - except RoutingError as exc: │ │ │ │ │ - self.write_control(exc.attrs, command='close', channel=channel) │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - self.open_channels[channel] = endpoint │ │ │ │ │ - self.endpoints[endpoint].add(channel) │ │ │ │ │ - else: │ │ │ │ │ - try: │ │ │ │ │ - endpoint = self.open_channels[channel] │ │ │ │ │ - except KeyError: │ │ │ │ │ - # sending to a non-existent channel can happen due to races and is not an error │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - # At this point, we have the endpoint. Route the message. │ │ │ │ │ - endpoint.do_channel_control(channel, command, message) │ │ │ │ │ - │ │ │ │ │ - def channel_data_received(self, channel: str, data: bytes) -> None: │ │ │ │ │ - try: │ │ │ │ │ - endpoint = self.open_channels[channel] │ │ │ │ │ - except KeyError: │ │ │ │ │ - return │ │ │ │ │ - │ │ │ │ │ - endpoint.do_channel_data(channel, data) │ │ │ │ │ - │ │ │ │ │ - def eof_received(self) -> bool: │ │ │ │ │ - logger.debug('eof_received(%r)', self) │ │ │ │ │ - │ │ │ │ │ - endpoints = set(self.endpoints) │ │ │ │ │ - for endpoint in endpoints: │ │ │ │ │ - endpoint.do_close() │ │ │ │ │ - │ │ │ │ │ - self._eof = True │ │ │ │ │ - logger.debug(' endpoints remaining: %r', self.endpoints) │ │ │ │ │ - return bool(self.endpoints) │ │ │ │ │ - │ │ │ │ │ - _communication_done: Optional[asyncio.Future] = None │ │ │ │ │ - │ │ │ │ │ - def do_closed(self, exc: Optional[Exception]) -> None: │ │ │ │ │ - # If we didn't send EOF yet, do it now. │ │ │ │ │ - if not self._eof: │ │ │ │ │ - self.eof_received() │ │ │ │ │ - │ │ │ │ │ - if self._communication_done is not None: │ │ │ │ │ - if exc is None: │ │ │ │ │ - self._communication_done.set_result(None) │ │ │ │ │ - else: │ │ │ │ │ - self._communication_done.set_exception(exc) │ │ │ │ │ - │ │ │ │ │ - async def communicate(self) -> None: │ │ │ │ │ - """Wait until communication is complete on the router and all endpoints are done.""" │ │ │ │ │ - assert self._communication_done is None │ │ │ │ │ - self._communication_done = asyncio.get_running_loop().create_future() │ │ │ │ │ - try: │ │ │ │ │ - await self._communication_done │ │ │ │ │ - except (BrokenPipeError, ConnectionResetError): │ │ │ │ │ - pass # these are normal occurrences when closed from the other side │ │ │ │ │ - finally: │ │ │ │ │ - self._communication_done = None │ │ │ │ │ - │ │ │ │ │ - # In an orderly exit, this is already done, but in case it wasn't │ │ │ │ │ - # orderly, we need to make sure the endpoints shut down anyway... │ │ │ │ │ - await self.no_endpoints.wait() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/peer.py': r'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ - │ │ │ │ │ -import asyncio │ │ │ │ │ -import logging │ │ │ │ │ -import os │ │ │ │ │ -from typing import Callable, List, Optional, Sequence │ │ │ │ │ - │ │ │ │ │ -from .jsonutil import JsonObject, JsonValue │ │ │ │ │ -from .packages import BridgeConfig │ │ │ │ │ -from .protocol import CockpitProblem, CockpitProtocol, CockpitProtocolError │ │ │ │ │ -from .router import Endpoint, Router, RoutingRule │ │ │ │ │ -from .transports import SubprocessProtocol, SubprocessTransport │ │ │ │ │ - │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class PeerError(CockpitProblem): │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class PeerExited(Exception): │ │ │ │ │ - def __init__(self, exit_code: int): │ │ │ │ │ - self.exit_code = exit_code │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class Peer(CockpitProtocol, SubprocessProtocol, Endpoint): │ │ │ │ │ - done_callbacks: List[Callable[[], None]] │ │ │ │ │ - init_future: Optional[asyncio.Future] │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router: Router): │ │ │ │ │ - super().__init__(router) │ │ │ │ │ - │ │ │ │ │ - # All Peers start out frozen — we only unfreeze after we see the first 'init' message │ │ │ │ │ - self.freeze_endpoint() │ │ │ │ │ - │ │ │ │ │ - self.init_future = asyncio.get_running_loop().create_future() │ │ │ │ │ - self.done_callbacks = [] │ │ │ │ │ - │ │ │ │ │ - # Initialization │ │ │ │ │ - async def do_connect_transport(self) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ - │ │ │ │ │ - async def spawn(self, argv: Sequence[str], env: Sequence[str], **kwargs) -> asyncio.Transport: │ │ │ │ │ - # Not actually async... │ │ │ │ │ - loop = asyncio.get_running_loop() │ │ │ │ │ - user_env = dict(e.split('=', 1) for e in env) │ │ │ │ │ - return SubprocessTransport(loop, self, argv, env=dict(os.environ, **user_env), **kwargs) │ │ │ │ │ - │ │ │ │ │ - async def start(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> JsonObject: │ │ │ │ │ - """Request that the Peer is started and connected to the router. │ │ │ │ │ - │ │ │ │ │ - Creates the transport, connects it to the protocol, and participates in │ │ │ │ │ - exchanging of init messages. If anything goes wrong, the connection │ │ │ │ │ - will be closed and an exception will be raised. │ │ │ │ │ - │ │ │ │ │ - The Peer starts out in a frozen state (ie: attempts to send messages to │ │ │ │ │ - it will initially be queued). If init_host is not None then an init │ │ │ │ │ - message is sent with the given 'host' field, plus any extra kwargs, and │ │ │ │ │ - the queue is thawed. Otherwise, the caller is responsible for sending │ │ │ │ │ - the init message and thawing the peer. │ │ │ │ │ - │ │ │ │ │ - In any case, the return value is the init message from the peer. │ │ │ │ │ - """ │ │ │ │ │ - assert self.init_future is not None │ │ │ │ │ - │ │ │ │ │ - def _connect_task_done(task: asyncio.Task) -> None: │ │ │ │ │ - assert task is connect_task │ │ │ │ │ - try: │ │ │ │ │ - task.result() │ │ │ │ │ - except asyncio.CancelledError: # we did that (below) │ │ │ │ │ - pass # we want to ignore it │ │ │ │ │ - except Exception as exc: │ │ │ │ │ - self.close(exc) │ │ │ │ │ - │ │ │ │ │ - connect_task = asyncio.create_task(self.do_connect_transport()) │ │ │ │ │ - connect_task.add_done_callback(_connect_task_done) │ │ │ │ │ - │ │ │ │ │ - try: │ │ │ │ │ - # Wait for something to happen: │ │ │ │ │ - # - exception from our connection function │ │ │ │ │ - # - receiving "init" from the other side │ │ │ │ │ - # - receiving EOF from the other side │ │ │ │ │ - # - .close() was called │ │ │ │ │ - # - other transport exception │ │ │ │ │ - init_message = await self.init_future │ │ │ │ │ - │ │ │ │ │ - except (PeerExited, BrokenPipeError): │ │ │ │ │ - # These are fairly generic errors. PeerExited means that we observed the process exiting. │ │ │ │ │ - # BrokenPipeError means that we got EPIPE when attempting to write() to it. In both cases, │ │ │ │ │ - # the process is gone, but it's not clear why. If the connection process is still running, │ │ │ │ │ - # perhaps we'd get a better error message from it. │ │ │ │ │ - await connect_task │ │ │ │ │ - # Otherwise, re-raise │ │ │ │ │ - raise │ │ │ │ │ - │ │ │ │ │ - finally: │ │ │ │ │ - self.init_future = None │ │ │ │ │ - │ │ │ │ │ - # In any case (failure or success) make sure this is done. │ │ │ │ │ - if not connect_task.done(): │ │ │ │ │ - connect_task.cancel() │ │ │ │ │ - │ │ │ │ │ - if init_host is not None: │ │ │ │ │ - logger.debug(' sending init message back, host %s', init_host) │ │ │ │ │ - # Send "init" back │ │ │ │ │ - self.write_control(None, command='init', version=1, host=init_host, **kwargs) │ │ │ │ │ - │ │ │ │ │ - # Thaw the queued messages │ │ │ │ │ - self.thaw_endpoint() │ │ │ │ │ - │ │ │ │ │ - return init_message │ │ │ │ │ - │ │ │ │ │ - # Background initialization │ │ │ │ │ - def start_in_background(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> None: │ │ │ │ │ - def _start_task_done(task: asyncio.Task) -> None: │ │ │ │ │ - assert task is start_task │ │ │ │ │ - │ │ │ │ │ - try: │ │ │ │ │ - task.result() │ │ │ │ │ - except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError): │ │ │ │ │ - pass # Those are expected. Others will throw. │ │ │ │ │ - │ │ │ │ │ - start_task = asyncio.create_task(self.start(init_host, **kwargs)) │ │ │ │ │ - start_task.add_done_callback(_start_task_done) │ │ │ │ │ - │ │ │ │ │ - # Shutdown │ │ │ │ │ - def add_done_callback(self, callback: Callable[[], None]) -> None: │ │ │ │ │ - self.done_callbacks.append(callback) │ │ │ │ │ - │ │ │ │ │ - # Handling of interesting events │ │ │ │ │ - def do_superuser_init_done(self) -> None: │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - def do_authorize(self, message: JsonObject) -> None: │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - def transport_control_received(self, command: str, message: JsonObject) -> None: │ │ │ │ │ - if command == 'init' and self.init_future is not None: │ │ │ │ │ - logger.debug('Got init message with active init_future. Setting result.') │ │ │ │ │ - self.init_future.set_result(message) │ │ │ │ │ - elif command == 'authorize': │ │ │ │ │ - self.do_authorize(message) │ │ │ │ │ - elif command == 'superuser-init-done': │ │ │ │ │ - self.do_superuser_init_done() │ │ │ │ │ - else: │ │ │ │ │ - raise CockpitProtocolError(f'Received unexpected control message {command}') │ │ │ │ │ - │ │ │ │ │ - def eof_received(self) -> bool: │ │ │ │ │ - # We always expect to be the ones to close the connection, so if we get │ │ │ │ │ - # an EOF, then we consider it to be an error. This allows us to │ │ │ │ │ - # distinguish close caused by unexpected EOF (but no errno from a │ │ │ │ │ - # syscall failure) vs. close caused by calling .close() on our side. │ │ │ │ │ - # The process is still running at this point, so keep it and handle │ │ │ │ │ - # the error in process_exited(). │ │ │ │ │ - logger.debug('Peer %s received unexpected EOF', self.__class__.__name__) │ │ │ │ │ - return True │ │ │ │ │ - │ │ │ │ │ - def do_closed(self, exc: Optional[Exception]) -> None: │ │ │ │ │ - logger.debug('Peer %s connection lost %s %s', self.__class__.__name__, type(exc), exc) │ │ │ │ │ - │ │ │ │ │ - if exc is None: │ │ │ │ │ - self.shutdown_endpoint(problem='terminated') │ │ │ │ │ - elif isinstance(exc, PeerExited): │ │ │ │ │ - # a common case is that the called peer does not exist │ │ │ │ │ - if exc.exit_code == 127: │ │ │ │ │ - self.shutdown_endpoint(problem='no-cockpit') │ │ │ │ │ - else: │ │ │ │ │ - self.shutdown_endpoint(problem='terminated', message=f'Peer exited with status {exc.exit_code}') │ │ │ │ │ - elif isinstance(exc, CockpitProblem): │ │ │ │ │ - self.shutdown_endpoint(exc.attrs) │ │ │ │ │ - else: │ │ │ │ │ - self.shutdown_endpoint(problem='internal-error', │ │ │ │ │ - message=f"[{exc.__class__.__name__}] {exc!s}") │ │ │ │ │ - │ │ │ │ │ - # If .start() is running, we need to make sure it stops running, │ │ │ │ │ - # raising the correct exception. │ │ │ │ │ - if self.init_future is not None and not self.init_future.done(): │ │ │ │ │ - if exc is not None: │ │ │ │ │ - self.init_future.set_exception(exc) │ │ │ │ │ - else: │ │ │ │ │ - self.init_future.cancel() │ │ │ │ │ - │ │ │ │ │ - for callback in self.done_callbacks: │ │ │ │ │ - callback() │ │ │ │ │ - │ │ │ │ │ - def process_exited(self) -> None: │ │ │ │ │ - assert isinstance(self.transport, SubprocessTransport) │ │ │ │ │ - logger.debug('Peer %s exited, status %d', self.__class__.__name__, self.transport.get_returncode()) │ │ │ │ │ - returncode = self.transport.get_returncode() │ │ │ │ │ - assert isinstance(returncode, int) │ │ │ │ │ - self.close(PeerExited(returncode)) │ │ │ │ │ - │ │ │ │ │ - # Forwarding data: from the peer to the router │ │ │ │ │ - def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ - if self.init_future is not None: │ │ │ │ │ - raise CockpitProtocolError('Received unexpected channel control message before init') │ │ │ │ │ - self.send_channel_control(channel, command, message) │ │ │ │ │ - │ │ │ │ │ - def channel_data_received(self, channel: str, data: bytes) -> None: │ │ │ │ │ - if self.init_future is not None: │ │ │ │ │ - raise CockpitProtocolError('Received unexpected channel data before init') │ │ │ │ │ - self.send_channel_data(channel, data) │ │ │ │ │ - │ │ │ │ │ - # Forwarding data: from the router to the peer │ │ │ │ │ - def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None: │ │ │ │ │ - assert self.init_future is None │ │ │ │ │ - self.write_control(message) │ │ │ │ │ - │ │ │ │ │ - def do_channel_data(self, channel: str, data: bytes) -> None: │ │ │ │ │ - assert self.init_future is None │ │ │ │ │ - self.write_channel_data(channel, data) │ │ │ │ │ - │ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ - assert self.init_future is None │ │ │ │ │ - self.write_control(message) │ │ │ │ │ - │ │ │ │ │ - def do_close(self) -> None: │ │ │ │ │ - self.close() │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class ConfiguredPeer(Peer): │ │ │ │ │ - config: BridgeConfig │ │ │ │ │ - args: Sequence[str] │ │ │ │ │ - env: Sequence[str] │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router: Router, config: BridgeConfig): │ │ │ │ │ - self.config = config │ │ │ │ │ - self.args = config.spawn │ │ │ │ │ - self.env = config.environ │ │ │ │ │ - super().__init__(router) │ │ │ │ │ - │ │ │ │ │ - async def do_connect_transport(self) -> None: │ │ │ │ │ - await self.spawn(self.args, self.env) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class PeerRoutingRule(RoutingRule): │ │ │ │ │ - config: BridgeConfig │ │ │ │ │ - match: JsonObject │ │ │ │ │ - peer: Optional[Peer] │ │ │ │ │ - │ │ │ │ │ - def __init__(self, router: Router, config: BridgeConfig): │ │ │ │ │ - super().__init__(router) │ │ │ │ │ - self.config = config │ │ │ │ │ - self.match = config.match │ │ │ │ │ - self.peer = None │ │ │ │ │ - │ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Peer]: │ │ │ │ │ - # Check that we match │ │ │ │ │ - │ │ │ │ │ - for key, value in self.match.items(): │ │ │ │ │ - if key not in options: │ │ │ │ │ - logger.debug(' rejecting because key %s is missing', key) │ │ │ │ │ - return None │ │ │ │ │ - if value is not None and options[key] != value: │ │ │ │ │ - logger.debug(' rejecting because key %s has wrong value %s (vs %s)', key, options[key], value) │ │ │ │ │ - return None │ │ │ │ │ - │ │ │ │ │ - # Start the peer if it's not running already │ │ │ │ │ - if self.peer is None: │ │ │ │ │ - self.peer = ConfiguredPeer(self.router, self.config) │ │ │ │ │ - self.peer.add_done_callback(self.peer_closed) │ │ │ │ │ - assert self.router.init_host │ │ │ │ │ - self.peer.start_in_background(init_host=self.router.init_host) │ │ │ │ │ - │ │ │ │ │ - return self.peer │ │ │ │ │ - │ │ │ │ │ - def peer_closed(self): │ │ │ │ │ - self.peer = None │ │ │ │ │ - │ │ │ │ │ - def shutdown(self): │ │ │ │ │ - if self.peer is not None: │ │ │ │ │ - self.peer.close() │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class PeersRoutingRule(RoutingRule): │ │ │ │ │ - rules: List[PeerRoutingRule] = [] │ │ │ │ │ - │ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Endpoint]: │ │ │ │ │ - logger.debug(' considering %d rules', len(self.rules)) │ │ │ │ │ - for rule in self.rules: │ │ │ │ │ - logger.debug(' considering %s', rule.config.name) │ │ │ │ │ - endpoint = rule.apply_rule(options) │ │ │ │ │ - if endpoint is not None: │ │ │ │ │ - logger.debug(' selected') │ │ │ │ │ - return endpoint │ │ │ │ │ - logger.debug(' no peer rules matched') │ │ │ │ │ - return None │ │ │ │ │ - │ │ │ │ │ - def set_configs(self, bridge_configs: Sequence[BridgeConfig]) -> None: │ │ │ │ │ - old_rules = self.rules │ │ │ │ │ - self.rules = [] │ │ │ │ │ - │ │ │ │ │ - for config in bridge_configs: │ │ │ │ │ - # Those are handled elsewhere... │ │ │ │ │ - if config.privileged or 'host' in config.match: │ │ │ │ │ - continue │ │ │ │ │ - │ │ │ │ │ - # Try to reuse an existing rule, if one exists... │ │ │ │ │ - for rule in list(old_rules): │ │ │ │ │ - if rule.config == config: │ │ │ │ │ - old_rules.remove(rule) │ │ │ │ │ - break │ │ │ │ │ - else: │ │ │ │ │ - # ... otherwise, create a new one. │ │ │ │ │ - rule = PeerRoutingRule(self.router, config) │ │ │ │ │ - │ │ │ │ │ - self.rules.append(rule) │ │ │ │ │ - │ │ │ │ │ - # close down the old rules that didn't get reclaimed │ │ │ │ │ - for rule in old_rules: │ │ │ │ │ - rule.shutdown() │ │ │ │ │ - │ │ │ │ │ - def shutdown(self): │ │ │ │ │ - for rule in self.rules: │ │ │ │ │ - rule.shutdown() │ │ │ │ │ -'''.encode('utf-8'), │ │ │ │ │ 'cockpit/samples.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ @@ -4631,15 +4565,15 @@ │ │ │ │ │ CPUTemperatureSampler, │ │ │ │ │ DiskSampler, │ │ │ │ │ MemorySampler, │ │ │ │ │ MountSampler, │ │ │ │ │ NetworkSampler, │ │ │ │ │ ] │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/beipack.py': br'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/config.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2023 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ @@ -4648,2372 +4582,2421 @@ │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ +import configparser │ │ │ │ │ import logging │ │ │ │ │ -import lzma │ │ │ │ │ -from typing import List, Sequence, Tuple │ │ │ │ │ - │ │ │ │ │ -from cockpit._vendor import ferny │ │ │ │ │ -from cockpit._vendor.bei import beipack │ │ │ │ │ +import os │ │ │ │ │ +from pathlib import Path │ │ │ │ │ │ │ │ │ │ -from .data import read_cockpit_data_file │ │ │ │ │ -from .peer import Peer, PeerError │ │ │ │ │ +from cockpit._vendor.systemd_ctypes import bus │ │ │ │ │ │ │ │ │ │ logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ +XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config')) │ │ │ │ │ +DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit' │ │ │ │ │ │ │ │ │ │ -def get_bridge_beipack_xz() -> Tuple[str, bytes]: │ │ │ │ │ - try: │ │ │ │ │ - bridge_beipack_xz = read_cockpit_data_file('cockpit-bridge.beipack.xz') │ │ │ │ │ - logger.debug('Got pre-built cockpit-bridge.beipack.xz') │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - logger.debug('Pre-built cockpit-bridge.beipack.xz; building our own.') │ │ │ │ │ - # beipack ourselves │ │ │ │ │ - cockpit_contents = beipack.collect_module('cockpit', recursive=True) │ │ │ │ │ - bridge_beipack = beipack.pack(cockpit_contents, entrypoint='cockpit.bridge:main', args='beipack=True') │ │ │ │ │ - bridge_beipack_xz = lzma.compress(bridge_beipack.encode()) │ │ │ │ │ - logger.debug(' ... done!') │ │ │ │ │ │ │ │ │ │ - return 'cockpit/data/cockpit-bridge.beipack.xz', bridge_beipack_xz │ │ │ │ │ +def lookup_config(filename: str) -> Path: │ │ │ │ │ + config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc').split(':') │ │ │ │ │ + fallback = None │ │ │ │ │ + for config_dir in config_dirs: │ │ │ │ │ + config_path = Path(config_dir, 'cockpit', filename) │ │ │ │ │ + if not fallback: │ │ │ │ │ + fallback = config_path │ │ │ │ │ + if config_path.exists(): │ │ │ │ │ + logger.debug('lookup_config(%s): found %s', filename, config_path) │ │ │ │ │ + return config_path │ │ │ │ │ │ │ │ │ │ + # default to the first entry in XDG_CONFIG_DIRS; that's not according to the spec, │ │ │ │ │ + # but what Cockpit has done for years │ │ │ │ │ + logger.debug('lookup_config(%s): defaulting to %s', filename, fallback) │ │ │ │ │ + assert fallback # mypy; config_dirs always has at least one string │ │ │ │ │ + return fallback │ │ │ │ │ │ │ │ │ │ -class BridgeBeibootHelper(ferny.InteractionHandler): │ │ │ │ │ - # ferny.InteractionHandler ClassVar │ │ │ │ │ - commands = ['beiboot.provide', 'beiboot.exc'] │ │ │ │ │ │ │ │ │ │ - peer: Peer │ │ │ │ │ - payload: bytes │ │ │ │ │ - steps: Sequence[Tuple[str, Sequence[object]]] │ │ │ │ │ +class Config(bus.Object, interface='cockpit.Config'): │ │ │ │ │ + def __init__(self): │ │ │ │ │ + self.reload() │ │ │ │ │ │ │ │ │ │ - def __init__(self, peer: Peer, args: Sequence[str] = ()) -> None: │ │ │ │ │ - filename, payload = get_bridge_beipack_xz() │ │ │ │ │ + @bus.Interface.Method(out_types='s', in_types='ss') │ │ │ │ │ + def get_string(self, section, key): │ │ │ │ │ + try: │ │ │ │ │ + return self.config[section][key] │ │ │ │ │ + except KeyError as exc: │ │ │ │ │ + raise bus.BusError('cockpit.Config.KeyError', f'key {key} in section {section} does not exist') from exc │ │ │ │ │ │ │ │ │ │ - self.peer = peer │ │ │ │ │ - self.payload = payload │ │ │ │ │ - self.steps = (('boot_xz', (filename, len(payload), tuple(args))),) │ │ │ │ │ + @bus.Interface.Method(out_types='u', in_types='ssuuu') │ │ │ │ │ + def get_u_int(self, section, key, default, maximum, minimum): │ │ │ │ │ + try: │ │ │ │ │ + value = self.config[section][key] │ │ │ │ │ + except KeyError: │ │ │ │ │ + return default │ │ │ │ │ │ │ │ │ │ - async def run_command(self, command: str, args: Tuple, fds: List[int], stderr: str) -> None: │ │ │ │ │ - logger.debug('Got ferny request %s %s %s %s', command, args, fds, stderr) │ │ │ │ │ - if command == 'beiboot.provide': │ │ │ │ │ - try: │ │ │ │ │ - size, = args │ │ │ │ │ - assert size == len(self.payload) │ │ │ │ │ - except (AssertionError, ValueError) as exc: │ │ │ │ │ - raise PeerError('internal-error', message=f'ferny interaction error {exc!s}') from exc │ │ │ │ │ + try: │ │ │ │ │ + int_val = int(value) │ │ │ │ │ + except ValueError: │ │ │ │ │ + logger.warning('cockpit.conf: [%s] %s is not an integer', section, key) │ │ │ │ │ + return default │ │ │ │ │ │ │ │ │ │ - assert self.peer.transport is not None │ │ │ │ │ - logger.debug('Writing %d bytes of payload', len(self.payload)) │ │ │ │ │ - self.peer.transport.write(self.payload) │ │ │ │ │ - elif command == 'beiboot.exc': │ │ │ │ │ - raise PeerError('internal-error', message=f'Remote exception: {args[0]}') │ │ │ │ │ - else: │ │ │ │ │ - raise PeerError('internal-error', message=f'Unexpected ferny interaction command {command}') │ │ │ │ │ + return min(max(int_val, minimum), maximum) │ │ │ │ │ + │ │ │ │ │ + @bus.Interface.Method() │ │ │ │ │ + def reload(self): │ │ │ │ │ + self.config = configparser.ConfigParser(interpolation=None) │ │ │ │ │ + cockpit_conf = lookup_config('cockpit.conf') │ │ │ │ │ + logger.debug("cockpit.Config: loading %s", cockpit_conf) │ │ │ │ │ + # this may not exist, but it's ok to not have a config file and thus leave self.config empty │ │ │ │ │ + self.config.read(cockpit_conf) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class Environment(bus.Object, interface='cockpit.Environment'): │ │ │ │ │ + variables = bus.Interface.Property('a{ss}') │ │ │ │ │ + │ │ │ │ │ + @variables.getter │ │ │ │ │ + def get_variables(self): │ │ │ │ │ + return os.environ.copy() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/jsonutil.py': r'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/remote.py': r'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2023 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -from enum import Enum │ │ │ │ │ -from typing import Callable, Dict, List, Mapping, Optional, Sequence, Type, TypeVar, Union │ │ │ │ │ +import getpass │ │ │ │ │ +import logging │ │ │ │ │ +import re │ │ │ │ │ +import socket │ │ │ │ │ +from typing import Dict, List, Optional, Tuple │ │ │ │ │ │ │ │ │ │ -JsonLiteral = Union[str, float, bool, None] │ │ │ │ │ +from cockpit._vendor import ferny │ │ │ │ │ │ │ │ │ │ -# immutable │ │ │ │ │ -JsonValue = Union['JsonObject', Sequence['JsonValue'], JsonLiteral] │ │ │ │ │ -JsonObject = Mapping[str, JsonValue] │ │ │ │ │ +from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none │ │ │ │ │ +from .peer import Peer, PeerError │ │ │ │ │ +from .router import Router, RoutingRule │ │ │ │ │ │ │ │ │ │ -# mutable │ │ │ │ │ -JsonDocument = Union['JsonDict', 'JsonList', JsonLiteral] │ │ │ │ │ -JsonDict = Dict[str, JsonDocument] │ │ │ │ │ -JsonList = List[JsonDocument] │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -DT = TypeVar('DT') │ │ │ │ │ -T = TypeVar('T') │ │ │ │ │ +class PasswordResponder(ferny.AskpassHandler): │ │ │ │ │ + PASSPHRASE_RE = re.compile(r"Enter passphrase for key '(.*)': ") │ │ │ │ │ │ │ │ │ │ + password: Optional[str] │ │ │ │ │ │ │ │ │ │ -class JsonError(Exception): │ │ │ │ │ - value: object │ │ │ │ │ + hostkeys_seen: List[Tuple[str, str, str, str, str]] │ │ │ │ │ + error_message: Optional[str] │ │ │ │ │ + password_attempts: int │ │ │ │ │ │ │ │ │ │ - def __init__(self, value: object, msg: str): │ │ │ │ │ - super().__init__(msg) │ │ │ │ │ - self.value = value │ │ │ │ │ + def __init__(self, password: Optional[str]): │ │ │ │ │ + self.password = password │ │ │ │ │ │ │ │ │ │ + self.hostkeys_seen = [] │ │ │ │ │ + self.error_message = None │ │ │ │ │ + self.password_attempts = 0 │ │ │ │ │ │ │ │ │ │ -def typechecked(value: JsonValue, expected_type: Type[T]) -> T: │ │ │ │ │ - """Ensure a JSON value has the expected type, returning it if so.""" │ │ │ │ │ - if not isinstance(value, expected_type): │ │ │ │ │ - raise JsonError(value, f'must have type {expected_type.__name__}') │ │ │ │ │ - return value │ │ │ │ │ + async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool: │ │ │ │ │ + self.hostkeys_seen.append((reason, host, algorithm, key, fingerprint)) │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ + async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]: │ │ │ │ │ + logger.debug('Got askpass(%s): %s', hint, prompt) │ │ │ │ │ │ │ │ │ │ -# We can't use None as a sentinel because it's often the actual default value │ │ │ │ │ -# EllipsisType is difficult because it's not available before 3.10. │ │ │ │ │ -# See https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions │ │ │ │ │ -class _Empty(Enum): │ │ │ │ │ - TOKEN = 0 │ │ │ │ │ + match = PasswordResponder.PASSPHRASE_RE.fullmatch(prompt) │ │ │ │ │ + if match is not None: │ │ │ │ │ + # We never unlock private keys — we rather need to throw a │ │ │ │ │ + # specially-formatted error message which will cause the frontend │ │ │ │ │ + # to load the named key into the agent for us and try again. │ │ │ │ │ + path = match.group(1) │ │ │ │ │ + logger.debug("This is a passphrase request for %s, but we don't do those. Abort.", path) │ │ │ │ │ + self.error_message = f'locked identity: {path}' │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ + assert self.password is not None │ │ │ │ │ + assert self.password_attempts == 0 │ │ │ │ │ + self.password_attempts += 1 │ │ │ │ │ + return self.password │ │ │ │ │ │ │ │ │ │ -_empty = _Empty.TOKEN │ │ │ │ │ │ │ │ │ │ +class SshPeer(Peer): │ │ │ │ │ + session: Optional[ferny.Session] = None │ │ │ │ │ + host: str │ │ │ │ │ + user: Optional[str] │ │ │ │ │ + password: Optional[str] │ │ │ │ │ + private: bool │ │ │ │ │ │ │ │ │ │ -def _get(obj: JsonObject, cast: Callable[[JsonValue], T], key: str, default: Union[DT, _Empty]) -> Union[T, DT]: │ │ │ │ │ - try: │ │ │ │ │ - return cast(obj[key]) │ │ │ │ │ - except KeyError: │ │ │ │ │ - if default is not _empty: │ │ │ │ │ - return default │ │ │ │ │ - raise JsonError(obj, f"attribute '{key}' required") from None │ │ │ │ │ - except JsonError as exc: │ │ │ │ │ - target = f"attribute '{key}'" + (' elements:' if exc.value is not obj[key] else ':') │ │ │ │ │ - raise JsonError(obj, f"{target} {exc!s}") from exc │ │ │ │ │ + async def do_connect_transport(self) -> None: │ │ │ │ │ + assert self.session is not None │ │ │ │ │ + logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private) │ │ │ │ │ │ │ │ │ │ + basename, colon, portstr = self.host.rpartition(':') │ │ │ │ │ + if colon and portstr.isdigit(): │ │ │ │ │ + host = basename │ │ │ │ │ + port = int(portstr) │ │ │ │ │ + else: │ │ │ │ │ + host = self.host │ │ │ │ │ + port = None │ │ │ │ │ │ │ │ │ │ -def get_bool(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, bool]: │ │ │ │ │ - return _get(obj, lambda v: typechecked(v, bool), key, default) │ │ │ │ │ + responder = PasswordResponder(self.password) │ │ │ │ │ + options = {"StrictHostKeyChecking": 'yes'} │ │ │ │ │ │ │ │ │ │ + if self.password is not None: │ │ │ │ │ + options.update(NumberOfPasswordPrompts='1') │ │ │ │ │ + else: │ │ │ │ │ + options.update(PasswordAuthentication="no", KbdInteractiveAuthentication="no") │ │ │ │ │ │ │ │ │ │ -def get_int(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, int]: │ │ │ │ │ - return _get(obj, lambda v: typechecked(v, int), key, default) │ │ │ │ │ + try: │ │ │ │ │ + await self.session.connect(host, login_name=self.user, port=port, │ │ │ │ │ + handle_host_key=self.private, options=options, │ │ │ │ │ + interaction_responder=responder) │ │ │ │ │ + except (OSError, socket.gaierror) as exc: │ │ │ │ │ + logger.debug('connecting to host %s failed: %s', host, exc) │ │ │ │ │ + raise PeerError('no-host', error='no-host', message=str(exc)) from exc │ │ │ │ │ │ │ │ │ │ + except ferny.SshHostKeyError as exc: │ │ │ │ │ + if responder.hostkeys_seen: │ │ │ │ │ + # If we saw a hostkey then we can issue a detailed error message │ │ │ │ │ + # containing the key that would need to be accepted. That will │ │ │ │ │ + # cause the front-end to present a dialog. │ │ │ │ │ + _reason, host, algorithm, key, fingerprint = responder.hostkeys_seen[0] │ │ │ │ │ + error_args = {'host-key': f'{host} {algorithm} {key}', 'host-fingerprint': fingerprint} │ │ │ │ │ + else: │ │ │ │ │ + error_args = {} │ │ │ │ │ │ │ │ │ │ -def get_str(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, str]: │ │ │ │ │ - return _get(obj, lambda v: typechecked(v, str), key, default) │ │ │ │ │ + if isinstance(exc, ferny.SshChangedHostKeyError): │ │ │ │ │ + error = 'invalid-hostkey' │ │ │ │ │ + elif self.private: │ │ │ │ │ + error = 'unknown-hostkey' │ │ │ │ │ + else: │ │ │ │ │ + # non-private session case. throw a generic error. │ │ │ │ │ + error = 'unknown-host' │ │ │ │ │ │ │ │ │ │ + logger.debug('SshPeer got a %s %s; private %s, seen hostkeys %r; raising %s with extra args %r', │ │ │ │ │ + type(exc), exc, self.private, responder.hostkeys_seen, error, error_args) │ │ │ │ │ + raise PeerError(error, error_args, error=error, auth_method_results={}) from exc │ │ │ │ │ │ │ │ │ │ -def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Optional[str]: │ │ │ │ │ - return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default) │ │ │ │ │ + except ferny.SshAuthenticationError as exc: │ │ │ │ │ + logger.debug('authentication to host %s failed: %s', host, exc) │ │ │ │ │ │ │ │ │ │ + results = {method: 'not-provided' for method in exc.methods} │ │ │ │ │ + if 'password' in results and self.password is not None: │ │ │ │ │ + if responder.password_attempts == 0: │ │ │ │ │ + results['password'] = 'not-tried' │ │ │ │ │ + else: │ │ │ │ │ + results['password'] = 'denied' │ │ │ │ │ │ │ │ │ │ -def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]: │ │ │ │ │ - return _get(obj, lambda v: typechecked(v, dict), key, default) │ │ │ │ │ + raise PeerError('authentication-failed', │ │ │ │ │ + error=responder.error_message or 'authentication-failed', │ │ │ │ │ + auth_method_results=results) from exc │ │ │ │ │ │ │ │ │ │ + except ferny.SshError as exc: │ │ │ │ │ + logger.debug('unknown failure connecting to host %s: %s', host, exc) │ │ │ │ │ + raise PeerError('internal-error', message=str(exc)) from exc │ │ │ │ │ │ │ │ │ │ -def get_object( │ │ │ │ │ - obj: JsonObject, │ │ │ │ │ - key: str, │ │ │ │ │ - constructor: Callable[[JsonObject], T], │ │ │ │ │ - default: Union[DT, _Empty] = _empty │ │ │ │ │ -) -> Union[DT, T]: │ │ │ │ │ - return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default) │ │ │ │ │ + args = self.session.wrap_subprocess_args(['cockpit-bridge']) │ │ │ │ │ + await self.spawn(args, []) │ │ │ │ │ + │ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None: │ │ │ │ │ + if host == self.host: │ │ │ │ │ + self.close() │ │ │ │ │ + elif host is None: │ │ │ │ │ + super().do_kill(host, group, message) │ │ │ │ │ │ │ │ │ │ + def do_authorize(self, message: JsonObject) -> None: │ │ │ │ │ + if get_str(message, 'challenge').startswith('plain1:'): │ │ │ │ │ + cookie = get_str(message, 'cookie') │ │ │ │ │ + self.write_control(command='authorize', cookie=cookie, response=self.password or '') │ │ │ │ │ + self.password = None # once is enough... │ │ │ │ │ │ │ │ │ │ -def get_strv(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, Sequence[str]]: │ │ │ │ │ - def as_strv(value: JsonValue) -> Sequence[str]: │ │ │ │ │ - return tuple(typechecked(item, str) for item in typechecked(value, list)) │ │ │ │ │ - return _get(obj, as_strv, key, default) │ │ │ │ │ + def do_superuser_init_done(self) -> None: │ │ │ │ │ + self.password = None │ │ │ │ │ │ │ │ │ │ + def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None: │ │ │ │ │ + super().__init__(router) │ │ │ │ │ + self.host = host │ │ │ │ │ + self.user = user │ │ │ │ │ + self.password = get_str(options, 'password', None) │ │ │ │ │ + self.private = private │ │ │ │ │ │ │ │ │ │ -def get_objv(obj: JsonObject, key: str, constructor: Callable[[JsonObject], T]) -> Union[DT, Sequence[T]]: │ │ │ │ │ - def as_objv(value: JsonValue) -> Sequence[T]: │ │ │ │ │ - return tuple(constructor(typechecked(item, dict)) for item in typechecked(value, list)) │ │ │ │ │ - return _get(obj, as_objv, key, ()) │ │ │ │ │ + self.session = ferny.Session() │ │ │ │ │ │ │ │ │ │ + superuser: JsonValue │ │ │ │ │ + init_superuser = get_str_or_none(options, 'init-superuser', None) │ │ │ │ │ + if init_superuser in (None, 'none'): │ │ │ │ │ + superuser = False │ │ │ │ │ + else: │ │ │ │ │ + superuser = {'id': init_superuser} │ │ │ │ │ │ │ │ │ │ -def create_object(message: 'JsonObject | None', kwargs: JsonObject) -> JsonObject: │ │ │ │ │ - """Constructs a JSON object based on message and kwargs. │ │ │ │ │ + self.start_in_background(init_host=host, superuser=superuser) │ │ │ │ │ │ │ │ │ │ - If only message is given, it is returned, unmodified. If message is None, │ │ │ │ │ - it is equivalent to an empty dictionary. A copy is always made. │ │ │ │ │ │ │ │ │ │ - If kwargs are present, then any underscore ('_') present in a key name is │ │ │ │ │ - rewritten to a dash ('-'). This is intended to bridge between the required │ │ │ │ │ - Python syntax when providing kwargs and idiomatic JSON (which uses '-' for │ │ │ │ │ - attributes). These values override values in message. │ │ │ │ │ +class HostRoutingRule(RoutingRule): │ │ │ │ │ + remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer] │ │ │ │ │ │ │ │ │ │ - The idea is that `message` should be used for passing data along, and │ │ │ │ │ - kwargs used for data originating at a given call site, possibly including │ │ │ │ │ - modifications to an original message. │ │ │ │ │ - """ │ │ │ │ │ - result = dict(message or {}) │ │ │ │ │ + def __init__(self, router): │ │ │ │ │ + super().__init__(router) │ │ │ │ │ + self.remotes = {} │ │ │ │ │ │ │ │ │ │ - for key, value in kwargs.items(): │ │ │ │ │ - # rewrite '_' (necessary in Python syntax kwargs list) to '-' (idiomatic JSON) │ │ │ │ │ - json_key = key.replace('_', '-') │ │ │ │ │ - result[json_key] = value │ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Peer]: │ │ │ │ │ + assert self.router is not None │ │ │ │ │ + assert self.router.init_host is not None │ │ │ │ │ │ │ │ │ │ - return result │ │ │ │ │ + host = get_str(options, 'host', self.router.init_host) │ │ │ │ │ + if host == self.router.init_host: │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ + user = get_str(options, 'user', None) │ │ │ │ │ + # HACK: the front-end relies on this for tracking connections without an explicit user name; │ │ │ │ │ + # the user will then be determined by SSH (`User` in the config or the current user) │ │ │ │ │ + # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c │ │ │ │ │ + if user == getpass.getuser(): │ │ │ │ │ + user = None │ │ │ │ │ + if not user: │ │ │ │ │ + user_from_host, _, _ = host.rpartition('@') │ │ │ │ │ + user = user_from_host or None # avoid '' │ │ │ │ │ │ │ │ │ │ -def json_merge_patch(current: JsonObject, patch: JsonObject) -> JsonObject: │ │ │ │ │ - """Perform a JSON merge patch (RFC 7396) using 'current' and 'patch'. │ │ │ │ │ - Neither of the original dictionaries is modified — the result is returned. │ │ │ │ │ - """ │ │ │ │ │ - # Always take a copy ('result') — we never modify the input ('current') │ │ │ │ │ - result = dict(current) │ │ │ │ │ - for key, patch_value in patch.items(): │ │ │ │ │ - if isinstance(patch_value, Mapping): │ │ │ │ │ - current_value = current.get(key, None) │ │ │ │ │ - if not isinstance(current_value, Mapping): │ │ │ │ │ - current_value = {} │ │ │ │ │ - result[key] = json_merge_patch(current_value, patch_value) │ │ │ │ │ - elif patch_value is not None: │ │ │ │ │ - result[key] = patch_value │ │ │ │ │ + if get_str(options, 'session', None) == 'private': │ │ │ │ │ + nonce = get_str(options, 'channel') │ │ │ │ │ else: │ │ │ │ │ - result.pop(key, None) │ │ │ │ │ + nonce = None │ │ │ │ │ │ │ │ │ │ - return result │ │ │ │ │ + assert isinstance(host, str) │ │ │ │ │ + assert user is None or isinstance(user, str) │ │ │ │ │ + assert nonce is None or isinstance(nonce, str) │ │ │ │ │ │ │ │ │ │ + key = host, user, nonce │ │ │ │ │ │ │ │ │ │ -def json_merge_and_filter_patch(current: JsonDict, patch: JsonDict) -> None: │ │ │ │ │ - """Perform a JSON merge patch (RFC 7396) modifying 'current' with 'patch'. │ │ │ │ │ - Also modifies 'patch' to remove redundant operations. │ │ │ │ │ - """ │ │ │ │ │ - for key, patch_value in tuple(patch.items()): │ │ │ │ │ - current_value = current.get(key, None) │ │ │ │ │ + logger.debug('Request for channel %s is remote.', options) │ │ │ │ │ + logger.debug('key=%s', key) │ │ │ │ │ │ │ │ │ │ - if isinstance(patch_value, dict): │ │ │ │ │ - if not isinstance(current_value, dict): │ │ │ │ │ - current[key] = current_value = {} │ │ │ │ │ - json_merge_and_filter_patch(current_value, patch_value) │ │ │ │ │ - else: │ │ │ │ │ - json_merge_and_filter_patch(current_value, patch_value) │ │ │ │ │ - if not patch_value: │ │ │ │ │ - del patch[key] │ │ │ │ │ - elif current_value == patch_value: │ │ │ │ │ - del patch[key] │ │ │ │ │ - elif patch_value is not None: │ │ │ │ │ - current[key] = patch_value │ │ │ │ │ - else: │ │ │ │ │ - del current[key] │ │ │ │ │ + if key not in self.remotes: │ │ │ │ │ + logger.debug('%s is not among the existing remotes %s. Opening a new connection.', key, self.remotes) │ │ │ │ │ + peer = SshPeer(self.router, host, user, options, private=nonce is not None) │ │ │ │ │ + peer.add_done_callback(lambda: self.remotes.__delitem__(key)) │ │ │ │ │ + self.remotes[key] = peer │ │ │ │ │ + │ │ │ │ │ + return self.remotes[key] │ │ │ │ │ + │ │ │ │ │ + def shutdown(self): │ │ │ │ │ + for peer in set(self.remotes.values()): │ │ │ │ │ + peer.close() │ │ │ │ │ '''.encode('utf-8'), │ │ │ │ │ - 'cockpit/data/fail.html': br''' │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ - @@message@@ │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -
│ │ │ │ │ - │ │ │ │ │ -

@@message@@

│ │ │ │ │ -
│ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/data/__init__.py': br'''import sys │ │ │ │ │ + 'cockpit/_vendor/__init__.py': br'''''', │ │ │ │ │ + 'cockpit/_vendor/bei/bootloader.py': br'''# beiboot - Remote bootloader for Python │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -if sys.version_info >= (3, 11): │ │ │ │ │ - import importlib.resources │ │ │ │ │ +import textwrap │ │ │ │ │ +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple │ │ │ │ │ │ │ │ │ │ - def read_cockpit_data_file(filename: str) -> bytes: │ │ │ │ │ - return (importlib.resources.files('cockpit.data') / filename).read_bytes() │ │ │ │ │ +GADGETS = { │ │ │ │ │ + "_frame": r""" │ │ │ │ │ + import sys │ │ │ │ │ + import traceback │ │ │ │ │ + try: │ │ │ │ │ + ... │ │ │ │ │ + except SystemExit: │ │ │ │ │ + raise │ │ │ │ │ + except BaseException: │ │ │ │ │ + command('beiboot.exc', traceback.format_exc()) │ │ │ │ │ + sys.exit(37) │ │ │ │ │ + """, │ │ │ │ │ + "try_exec": r""" │ │ │ │ │ + import contextlib │ │ │ │ │ + import os │ │ │ │ │ + def try_exec(argv): │ │ │ │ │ + with contextlib.suppress(OSError): │ │ │ │ │ + os.execvp(argv[0], argv) │ │ │ │ │ + """, │ │ │ │ │ + "boot_xz": r""" │ │ │ │ │ + import lzma │ │ │ │ │ + import sys │ │ │ │ │ + def boot_xz(filename, size, args=[], send_end=False): │ │ │ │ │ + command('beiboot.provide', size) │ │ │ │ │ + src_xz = sys.stdin.buffer.read(size) │ │ │ │ │ + src = lzma.decompress(src_xz) │ │ │ │ │ + sys.argv = [filename, *args] │ │ │ │ │ + if send_end: │ │ │ │ │ + end() │ │ │ │ │ + exec(src, { │ │ │ │ │ + '__name__': '__main__', │ │ │ │ │ + '__self_source__': src_xz, │ │ │ │ │ + '__file__': filename}) │ │ │ │ │ + sys.exit() │ │ │ │ │ + """, │ │ │ │ │ +} │ │ │ │ │ │ │ │ │ │ -else: │ │ │ │ │ - import importlib.abc │ │ │ │ │ │ │ │ │ │ - def read_cockpit_data_file(filename: str) -> bytes: │ │ │ │ │ - # https://github.com/python/mypy/issues/4182 │ │ │ │ │ - loader = __loader__ # type: ignore[name-defined] │ │ │ │ │ - assert isinstance(loader, importlib.abc.ResourceLoader) │ │ │ │ │ +def split_code(code: str, imports: Set[str]) -> Iterable[Tuple[str, str]]: │ │ │ │ │ + for line in textwrap.dedent(code).splitlines(): │ │ │ │ │ + text = line.lstrip(" ") │ │ │ │ │ + if text.startswith("import "): │ │ │ │ │ + imports.add(text) │ │ │ │ │ + elif text: │ │ │ │ │ + spaces = len(line) - len(text) │ │ │ │ │ + assert (spaces % 4) == 0 │ │ │ │ │ + yield "\t" * (spaces // 4), text │ │ │ │ │ │ │ │ │ │ - path = __file__.replace('__init__.py', filename) │ │ │ │ │ - return loader.get_data(path) │ │ │ │ │ + │ │ │ │ │ +def yield_body(user_gadgets: Dict[str, str], │ │ │ │ │ + steps: Sequence[Tuple[str, Sequence[object]]], │ │ │ │ │ + imports: Set[str]) -> Iterable[Tuple[str, str]]: │ │ │ │ │ + # Allow the caller to override our gadgets, but keep the original │ │ │ │ │ + # variable for use in the next step. │ │ │ │ │ + gadgets = dict(GADGETS, **user_gadgets) │ │ │ │ │ + │ │ │ │ │ + # First emit the gadgets. Emit all gadgets provided by the caller, │ │ │ │ │ + # plus any referred to by the caller's list of steps. │ │ │ │ │ + provided_gadgets = set(user_gadgets) │ │ │ │ │ + step_gadgets = {name for name, _args in steps} │ │ │ │ │ + for name in provided_gadgets | step_gadgets: │ │ │ │ │ + yield from split_code(gadgets[name], imports) │ │ │ │ │ + │ │ │ │ │ + # Yield functions mentioned in steps from the caller │ │ │ │ │ + for name, args in steps: │ │ │ │ │ + yield '', name + repr(tuple(args)) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def make_bootloader(steps: Sequence[Tuple[str, Sequence[object]]], │ │ │ │ │ + gadgets: Optional[Dict[str, str]] = None) -> str: │ │ │ │ │ + imports: Set[str] = set() │ │ │ │ │ + lines: List[str] = [] │ │ │ │ │ + │ │ │ │ │ + for frame_spaces, frame_text in split_code(GADGETS["_frame"], imports): │ │ │ │ │ + if frame_text == "...": │ │ │ │ │ + for spaces, text in yield_body(gadgets or {}, steps, imports): │ │ │ │ │ + lines.append(frame_spaces + spaces + text) │ │ │ │ │ + else: │ │ │ │ │ + lines.append(frame_spaces + frame_text) │ │ │ │ │ + │ │ │ │ │ + return "".join(f"{line}\n" for line in [*imports, *lines]) + "\n" │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -# Missing stuff compared to the C bridge that we should probably add: │ │ │ │ │ -# │ │ │ │ │ -# - removing matches │ │ │ │ │ -# - removing watches │ │ │ │ │ -# - emitting of signals │ │ │ │ │ -# - publishing of objects │ │ │ │ │ -# - failing more gracefully in some cases (during open, etc) │ │ │ │ │ -# │ │ │ │ │ -# Stuff we might or might not do: │ │ │ │ │ -# │ │ │ │ │ -# - using non-default service names │ │ │ │ │ -# │ │ │ │ │ -# Stuff we should probably not do: │ │ │ │ │ -# │ │ │ │ │ -# - emulation of ObjectManager via recursive introspection │ │ │ │ │ -# - automatic detection of ObjectManager below the given path_namespace │ │ │ │ │ -# - recursive scraping of properties for new object paths │ │ │ │ │ -# (for path_namespace watches that don't hit an ObjectManager) │ │ │ │ │ +import argparse │ │ │ │ │ +import binascii │ │ │ │ │ +import lzma │ │ │ │ │ +import os │ │ │ │ │ +import sys │ │ │ │ │ +import tempfile │ │ │ │ │ +import zipfile │ │ │ │ │ +from typing import Dict, Iterable, List, Optional, Set, Tuple │ │ │ │ │ │ │ │ │ │ -import asyncio │ │ │ │ │ -import errno │ │ │ │ │ -import json │ │ │ │ │ -import logging │ │ │ │ │ -import traceback │ │ │ │ │ -import xml.etree.ElementTree as ET │ │ │ │ │ +from .data import read_data_file │ │ │ │ │ │ │ │ │ │ -from cockpit._vendor import systemd_ctypes │ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection │ │ │ │ │ │ │ │ │ │ -from ..channel import Channel, ChannelError │ │ │ │ │ +def escape_string(data: str) -> str: │ │ │ │ │ + # Avoid mentioning ' ' ' literally, to make our own packing a bit prettier │ │ │ │ │ + triplequote = "'" * 3 │ │ │ │ │ + if triplequote not in data: │ │ │ │ │ + return "r" + triplequote + data + triplequote │ │ │ │ │ + if '"""' not in data: │ │ │ │ │ + return 'r"""' + data + '"""' │ │ │ │ │ + return repr(data) │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ -# The dbusjson3 payload │ │ │ │ │ -# │ │ │ │ │ -# This channel payload type translates JSON encoded messages on a │ │ │ │ │ -# Cockpit channel to D-Bus messages, in a mostly straightforward way. │ │ │ │ │ -# See doc/protocol.md for a description of the basics. │ │ │ │ │ -# │ │ │ │ │ -# However, dbusjson3 offers some advanced features as well that are │ │ │ │ │ -# meant to support the "magic" DBusProxy objects implemented by │ │ │ │ │ -# cockpit.js. Those proxy objects "magically" expose all the methods │ │ │ │ │ -# and properties of a D-Bus interface without requiring any explicit │ │ │ │ │ -# binding code to be generated for a JavaScript client. A dbusjson3 │ │ │ │ │ -# channel does this by doing automatic introspection and property │ │ │ │ │ -# retrieval without much direction from the JavaScript client. │ │ │ │ │ -# │ │ │ │ │ -# The details of what exactly is done is not specified very strictly, │ │ │ │ │ -# and the Python bridge will likely differ from the C bridge │ │ │ │ │ -# significantly. This will be informed by what existing code actually │ │ │ │ │ -# needs, and we might end up with a more concrete description of what │ │ │ │ │ -# a client can actually expect. │ │ │ │ │ -# │ │ │ │ │ -# Here is an example of a more complex scenario: │ │ │ │ │ -# │ │ │ │ │ -# - The client adds a "watch" for a path namespace. There is a │ │ │ │ │ -# ObjectManager at the given path and the bridge emits "meta" and │ │ │ │ │ -# "notify" messages to describe all interfaces and objects reported │ │ │ │ │ -# by that ObjectManager. │ │ │ │ │ -# │ │ │ │ │ -# - The client makes a method call that causes a new object with a new │ │ │ │ │ -# interface to appear at the ObjectManager. The bridge will send a │ │ │ │ │ -# "meta" and "notify" message to describe this new object. │ │ │ │ │ -# │ │ │ │ │ -# - Since the InterfacesAdded signal was emitted before the method │ │ │ │ │ -# reply, the bridge must send the "meta" and "notify" messages │ │ │ │ │ -# before the method reply message. │ │ │ │ │ -# │ │ │ │ │ -# - However, in order to construct the "meta" message, the bridge must │ │ │ │ │ -# perform a Introspect call, and consequently must delay sending the │ │ │ │ │ -# method reply until that call has finished. │ │ │ │ │ -# │ │ │ │ │ -# The Python bridge implements this delaying of messages with │ │ │ │ │ -# coroutines and a fair mutex. Every message coming from D-Bus will │ │ │ │ │ -# wait on the mutex for its turn to send its message on the Cockpit │ │ │ │ │ -# channel, and will keep that mutex locked until it is done with │ │ │ │ │ -# sending. Since the mutex is fair, everyone will nicely wait in line │ │ │ │ │ -# without messages getting re-ordered. │ │ │ │ │ -# │ │ │ │ │ -# The scenario above will play out like this: │ │ │ │ │ -# │ │ │ │ │ -# - While adding the initial "watch", the lock is held until the │ │ │ │ │ -# "meta" and "notify" messages have been sent. │ │ │ │ │ -# │ │ │ │ │ -# - Later, when the InterfacesAdded signal comes in that has been │ │ │ │ │ -# triggered by the method call, the mutex will be locked while the │ │ │ │ │ -# necessary introspection is going on. │ │ │ │ │ -# │ │ │ │ │ -# - The method reply will likely come while the mutex is locked, and │ │ │ │ │ -# the task for sending that reply on the Cockpit channel will enter │ │ │ │ │ -# the wait queue of the mutex. │ │ │ │ │ -# │ │ │ │ │ -# - Once the introspection is done and the new "meta" and "notify" │ │ │ │ │ -# messages have been sent, the mutex is unlocked, the method reply │ │ │ │ │ -# task acquires it, and sends its message. │ │ │ │ │ +def ascii_bytes_repr(data: bytes) -> str: │ │ │ │ │ + return 'b' + escape_string(data.decode('ascii')) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class InterfaceCache: │ │ │ │ │ - def __init__(self): │ │ │ │ │ - self.cache = {} │ │ │ │ │ - self.old = set() # Interfaces already returned by get_interface_if_new │ │ │ │ │ +def utf8_bytes_repr(data: bytes) -> str: │ │ │ │ │ + return escape_string(data.decode('utf-8')) + ".encode('utf-8')" │ │ │ │ │ │ │ │ │ │ - def inject(self, interfaces): │ │ │ │ │ - self.cache.update(interfaces) │ │ │ │ │ │ │ │ │ │ - async def introspect_path(self, bus, destination, object_path): │ │ │ │ │ - xml, = await bus.call_method_async(destination, object_path, │ │ │ │ │ - 'org.freedesktop.DBus.Introspectable', │ │ │ │ │ - 'Introspect') │ │ │ │ │ +def base64_bytes_repr(data: bytes, imports: Set[str]) -> str: │ │ │ │ │ + # base85 is smaller, but base64 is in C, and ~20x faster. │ │ │ │ │ + # when compressing with `xz -e` the size difference is marginal. │ │ │ │ │ + imports.add('from binascii import a2b_base64') │ │ │ │ │ + encoded = binascii.b2a_base64(data).decode('ascii').strip() │ │ │ │ │ + return f'a2b_base64("{encoded}")' │ │ │ │ │ │ │ │ │ │ - et = ET.fromstring(xml) │ │ │ │ │ │ │ │ │ │ - interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')} │ │ │ │ │ +def bytes_repr(data: bytes, imports: Set[str]) -> str: │ │ │ │ │ + # Strategy: │ │ │ │ │ + # if the file is ascii, encode it directly as bytes │ │ │ │ │ + # otherwise, if it's UTF-8, use a unicode string and encode │ │ │ │ │ + # otherwise, base64 │ │ │ │ │ │ │ │ │ │ - # Add all interfaces we found: we might use them later │ │ │ │ │ - self.inject(interfaces) │ │ │ │ │ + try: │ │ │ │ │ + return ascii_bytes_repr(data) │ │ │ │ │ + except UnicodeDecodeError: │ │ │ │ │ + # it's not ascii │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - return interfaces │ │ │ │ │ + # utf-8 │ │ │ │ │ + try: │ │ │ │ │ + return utf8_bytes_repr(data) │ │ │ │ │ + except UnicodeDecodeError: │ │ │ │ │ + # it's not utf-8 │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - async def get_interface(self, interface_name, bus=None, destination=None, object_path=None): │ │ │ │ │ - try: │ │ │ │ │ - return self.cache[interface_name] │ │ │ │ │ - except KeyError: │ │ │ │ │ - pass │ │ │ │ │ + return base64_bytes_repr(data, imports) │ │ │ │ │ │ │ │ │ │ - if bus and object_path: │ │ │ │ │ - try: │ │ │ │ │ - await self.introspect_path(bus, destination, object_path) │ │ │ │ │ - except BusError: │ │ │ │ │ - pass │ │ │ │ │ │ │ │ │ │ - return self.cache.get(interface_name) │ │ │ │ │ +def dict_repr(contents: Dict[str, bytes], imports: Set[str]) -> str: │ │ │ │ │ + return ('{\n' + │ │ │ │ │ + ''.join(f' {repr(k)}: {bytes_repr(v, imports)},\n' │ │ │ │ │ + for k, v in contents.items()) + │ │ │ │ │ + '}') │ │ │ │ │ │ │ │ │ │ - async def get_interface_if_new(self, interface_name, bus, destination, object_path): │ │ │ │ │ - if interface_name in self.old: │ │ │ │ │ - return None │ │ │ │ │ - self.old.add(interface_name) │ │ │ │ │ - return await self.get_interface(interface_name, bus, destination, object_path) │ │ │ │ │ │ │ │ │ │ - async def get_signature(self, interface_name, method, bus=None, destination=None, object_path=None): │ │ │ │ │ - interface = await self.get_interface(interface_name, bus, destination, object_path) │ │ │ │ │ - if interface is None: │ │ │ │ │ - raise KeyError(f'Interface {interface_name} is not found') │ │ │ │ │ +def pack(contents: Dict[str, bytes], │ │ │ │ │ + entrypoint: Optional[str] = None, │ │ │ │ │ + args: str = '') -> str: │ │ │ │ │ + """Creates a beipack with the given `contents`. │ │ │ │ │ │ │ │ │ │ - return ''.join(interface['methods'][method]['in']) │ │ │ │ │ + If `entrypoint` is given, it should be an entry point which is run as the │ │ │ │ │ + "main" function. It is given in the `package.module:func format` such that │ │ │ │ │ + the following code is emitted: │ │ │ │ │ │ │ │ │ │ + from package.module import func as main │ │ │ │ │ + main() │ │ │ │ │ │ │ │ │ │ -def notify_update(notify, path, interface_name, props): │ │ │ │ │ - notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()} │ │ │ │ │ + Additionally, if `args` is given, it is written verbatim between the parens │ │ │ │ │ + of the call to main (ie: it should already be in Python syntax). │ │ │ │ │ + """ │ │ │ │ │ │ │ │ │ │ + loader = read_data_file('beipack_loader.py') │ │ │ │ │ + lines = [line for line in loader.splitlines() if line] │ │ │ │ │ + lines.append('') │ │ │ │ │ │ │ │ │ │ -class DBusChannel(Channel): │ │ │ │ │ - json_encoder = systemd_ctypes.JSONEncoder(indent=2) │ │ │ │ │ - payload = 'dbus-json3' │ │ │ │ │ + imports = {'import sys'} │ │ │ │ │ + contents_txt = dict_repr(contents, imports) │ │ │ │ │ + lines.extend(imports) │ │ │ │ │ + lines.append(f'sys.meta_path.insert(0, BeipackLoader({contents_txt}))') │ │ │ │ │ │ │ │ │ │ - matches = None │ │ │ │ │ - name = None │ │ │ │ │ - bus = None │ │ │ │ │ - owner = None │ │ │ │ │ + if entrypoint: │ │ │ │ │ + package, main = entrypoint.split(':') │ │ │ │ │ + lines.append(f'from {package} import {main} as main') │ │ │ │ │ + lines.append(f'main({args})') │ │ │ │ │ │ │ │ │ │ - async def setup_name_owner_tracking(self): │ │ │ │ │ - def send_owner(owner): │ │ │ │ │ - # We must be careful not to send duplicate owner │ │ │ │ │ - # notifications. cockpit.js relies on that. │ │ │ │ │ - if self.owner != owner: │ │ │ │ │ - self.owner = owner │ │ │ │ │ - self.send_json(owner=owner) │ │ │ │ │ + return ''.join(f'{line}\n' for line in lines) │ │ │ │ │ │ │ │ │ │ - def handler(message): │ │ │ │ │ - name, old, new = message.get_body() │ │ │ │ │ - send_owner(owner=new if new != "" else None) │ │ │ │ │ - self.add_signal_handler(handler, │ │ │ │ │ - sender='org.freedesktop.DBus', │ │ │ │ │ - path='/org/freedesktop/DBus', │ │ │ │ │ - interface='org.freedesktop.DBus', │ │ │ │ │ - member='NameOwnerChanged', │ │ │ │ │ - arg0=self.name) │ │ │ │ │ - try: │ │ │ │ │ - unique_name, = await self.bus.call_method_async("org.freedesktop.DBus", │ │ │ │ │ - "/org/freedesktop/DBus", │ │ │ │ │ - "org.freedesktop.DBus", │ │ │ │ │ - "GetNameOwner", "s", self.name) │ │ │ │ │ - except BusError as error: │ │ │ │ │ - if error.name == "org.freedesktop.DBus.Error.NameHasNoOwner": │ │ │ │ │ - # Try to start it. If it starts successfully, we will │ │ │ │ │ - # get a NameOwnerChanged signal (which will set │ │ │ │ │ - # self.owner) before StartServiceByName returns. │ │ │ │ │ - try: │ │ │ │ │ - await self.bus.call_method_async("org.freedesktop.DBus", │ │ │ │ │ - "/org/freedesktop/DBus", │ │ │ │ │ - "org.freedesktop.DBus", │ │ │ │ │ - "StartServiceByName", "su", self.name, 0) │ │ │ │ │ - except BusError as start_error: │ │ │ │ │ - logger.debug("Failed to start service '%s': %s", self.name, start_error.message) │ │ │ │ │ - self.send_json(owner=None) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug("Failed to get owner of service '%s': %s", self.name, error.message) │ │ │ │ │ - else: │ │ │ │ │ - send_owner(unique_name) │ │ │ │ │ │ │ │ │ │ - def do_open(self, options): │ │ │ │ │ - self.cache = InterfaceCache() │ │ │ │ │ - self.name = options.get('name') │ │ │ │ │ - self.matches = [] │ │ │ │ │ +def collect_contents(filenames: List[str], │ │ │ │ │ + relative_to: Optional[str] = None) -> Dict[str, bytes]: │ │ │ │ │ + contents: Dict[str, bytes] = {} │ │ │ │ │ │ │ │ │ │ - bus = options.get('bus') │ │ │ │ │ - address = options.get('address') │ │ │ │ │ + for filename in filenames: │ │ │ │ │ + with open(filename, 'rb') as file: │ │ │ │ │ + contents[os.path.relpath(filename, start=relative_to)] = file.read() │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - if address is not None: │ │ │ │ │ - if bus is not None and bus != 'none': │ │ │ │ │ - raise ChannelError('protocol-error', message='only one of "bus" and "address" can be specified') │ │ │ │ │ - logger.debug('get bus with address %s for %s', address, self.name) │ │ │ │ │ - self.bus = Bus.new(address=address, bus_client=self.name is not None) │ │ │ │ │ - elif bus == 'internal': │ │ │ │ │ - logger.debug('get internal bus for %s', self.name) │ │ │ │ │ - self.bus = self.router.internal_bus.client │ │ │ │ │ - else: │ │ │ │ │ - if bus == 'session': │ │ │ │ │ - logger.debug('get session bus for %s', self.name) │ │ │ │ │ - self.bus = Bus.default_user() │ │ │ │ │ - elif bus == 'system' or bus is None: │ │ │ │ │ - logger.debug('get system bus for %s', self.name) │ │ │ │ │ - self.bus = Bus.default_system() │ │ │ │ │ - else: │ │ │ │ │ - raise ChannelError('protocol-error', message=f'invalid bus "{bus}"') │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc │ │ │ │ │ + return contents │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - self.bus.attach_event(None, 0) │ │ │ │ │ - except OSError as err: │ │ │ │ │ - if err.errno != errno.EBUSY: │ │ │ │ │ - raise │ │ │ │ │ │ │ │ │ │ - # This needs to be a fair mutex so that outgoing messages don't │ │ │ │ │ - # get re-ordered. asyncio.Lock is fair. │ │ │ │ │ - self.watch_processing_lock = asyncio.Lock() │ │ │ │ │ +def collect_module(name: str, *, recursive: bool) -> Dict[str, bytes]: │ │ │ │ │ + import importlib.resources │ │ │ │ │ + from importlib.resources.abc import Traversable │ │ │ │ │ │ │ │ │ │ - if self.name is not None: │ │ │ │ │ - async def get_ready(): │ │ │ │ │ - async with self.watch_processing_lock: │ │ │ │ │ - await self.setup_name_owner_tracking() │ │ │ │ │ - if self.owner: │ │ │ │ │ - self.ready(unique_name=self.owner) │ │ │ │ │ - else: │ │ │ │ │ - self.close({'problem': 'not-found'}) │ │ │ │ │ - self.create_task(get_ready()) │ │ │ │ │ - else: │ │ │ │ │ - self.ready() │ │ │ │ │ + def walk(path: str, entry: Traversable) -> Iterable[Tuple[str, bytes]]: │ │ │ │ │ + for item in entry.iterdir(): │ │ │ │ │ + itemname = f'{path}/{item.name}' │ │ │ │ │ + if item.is_file(): │ │ │ │ │ + yield itemname, item.read_bytes() │ │ │ │ │ + elif recursive and item.name != '__pycache__': │ │ │ │ │ + yield from walk(itemname, item) │ │ │ │ │ │ │ │ │ │ - def add_signal_handler(self, handler, **kwargs): │ │ │ │ │ - r = dict(**kwargs) │ │ │ │ │ - r['type'] = 'signal' │ │ │ │ │ - if 'sender' not in r and self.name is not None: │ │ │ │ │ - r['sender'] = self.name │ │ │ │ │ - # HACK - https://github.com/bus1/dbus-broker/issues/309 │ │ │ │ │ - # path_namespace='/' in a rule does not work. │ │ │ │ │ - if r.get('path_namespace') == "/": │ │ │ │ │ - del r['path_namespace'] │ │ │ │ │ + return dict(walk(name.replace('.', '/'), importlib.resources.files(name))) │ │ │ │ │ │ │ │ │ │ - def filter_owner(message): │ │ │ │ │ - if self.owner is not None and self.owner == message.get_sender(): │ │ │ │ │ - handler(message) │ │ │ │ │ │ │ │ │ │ - if self.name is not None and 'sender' in r and r['sender'] == self.name: │ │ │ │ │ - func = filter_owner │ │ │ │ │ - else: │ │ │ │ │ - func = handler │ │ │ │ │ - r_string = ','.join(f"{key}='{value}'" for key, value in r.items()) │ │ │ │ │ - if not self.is_closing(): │ │ │ │ │ - # this gets an EINTR very often especially on RHEL 8 │ │ │ │ │ - while True: │ │ │ │ │ - try: │ │ │ │ │ - match = self.bus.add_match(r_string, func) │ │ │ │ │ - break │ │ │ │ │ - except InterruptedError: │ │ │ │ │ - pass │ │ │ │ │ +def collect_zip(filename: str) -> Dict[str, bytes]: │ │ │ │ │ + contents = {} │ │ │ │ │ │ │ │ │ │ - self.matches.append(match) │ │ │ │ │ + with zipfile.ZipFile(filename) as file: │ │ │ │ │ + for entry in file.filelist: │ │ │ │ │ + if '.dist-info/' in entry.filename: │ │ │ │ │ + continue │ │ │ │ │ + contents[entry.filename] = file.read(entry) │ │ │ │ │ │ │ │ │ │ - def add_async_signal_handler(self, handler, **kwargs): │ │ │ │ │ - def sync_handler(message): │ │ │ │ │ - self.create_task(handler(message)) │ │ │ │ │ - self.add_signal_handler(sync_handler, **kwargs) │ │ │ │ │ + return contents │ │ │ │ │ │ │ │ │ │ - async def do_call(self, message): │ │ │ │ │ - path, iface, method, args = message['call'] │ │ │ │ │ - cookie = message.get('id') │ │ │ │ │ - flags = message.get('flags') │ │ │ │ │ │ │ │ │ │ - timeout = message.get('timeout') │ │ │ │ │ - if timeout is not None: │ │ │ │ │ - # sd_bus timeout is µs, cockpit API timeout is ms │ │ │ │ │ - timeout *= 1000 │ │ │ │ │ - else: │ │ │ │ │ - # sd_bus has no "indefinite" timeout, so use MAX_UINT64 │ │ │ │ │ - timeout = 2 ** 64 - 1 │ │ │ │ │ +def collect_pep517(path: str) -> Dict[str, bytes]: │ │ │ │ │ + with tempfile.TemporaryDirectory() as tmpdir: │ │ │ │ │ + import build │ │ │ │ │ + builder = build.ProjectBuilder(path) │ │ │ │ │ + wheel = builder.build('wheel', tmpdir) │ │ │ │ │ + return collect_zip(wheel) │ │ │ │ │ │ │ │ │ │ - # We have to figure out the signature of the call. Either we got told it: │ │ │ │ │ - signature = message.get('type') │ │ │ │ │ │ │ │ │ │ - # ... or there aren't any arguments │ │ │ │ │ - if signature is None and len(args) == 0: │ │ │ │ │ - signature = '' │ │ │ │ │ +def main() -> None: │ │ │ │ │ + parser = argparse.ArgumentParser() │ │ │ │ │ + parser.add_argument('--python', '-p', │ │ │ │ │ + help="add a #!python3 interpreter line using the given path") │ │ │ │ │ + parser.add_argument('--xz', '-J', action='store_true', │ │ │ │ │ + help="compress the output with `xz`") │ │ │ │ │ + parser.add_argument('--topdir', │ │ │ │ │ + help="toplevel directory (paths are stored relative to this)") │ │ │ │ │ + parser.add_argument('--output', '-o', │ │ │ │ │ + help="write output to a file (default: stdout)") │ │ │ │ │ + parser.add_argument('--main', '-m', metavar='MODULE:FUNC', │ │ │ │ │ + help="use FUNC from MODULE as the main function") │ │ │ │ │ + parser.add_argument('--main-args', metavar='ARGS', │ │ │ │ │ + help="arguments to main() in Python syntax", default='') │ │ │ │ │ + parser.add_argument('--module', action='append', default=[], │ │ │ │ │ + help="collect installed modules (recursively)") │ │ │ │ │ + parser.add_argument('--zip', '-z', action='append', default=[], │ │ │ │ │ + help="include files from a zipfile (or wheel)") │ │ │ │ │ + parser.add_argument('--build', metavar='DIR', action='append', default=[], │ │ │ │ │ + help="PEP-517 from a given source directory") │ │ │ │ │ + parser.add_argument('files', nargs='*', │ │ │ │ │ + help="files to include in the beipack") │ │ │ │ │ + args = parser.parse_args() │ │ │ │ │ │ │ │ │ │ - # ... or we need to introspect │ │ │ │ │ - if signature is None: │ │ │ │ │ - try: │ │ │ │ │ - logger.debug('Doing introspection request for %s %s', iface, method) │ │ │ │ │ - signature = await self.cache.get_signature(iface, method, self.bus, self.name, path) │ │ │ │ │ - except BusError as error: │ │ │ │ │ - self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie) │ │ │ │ │ - return │ │ │ │ │ - except KeyError: │ │ │ │ │ - self.send_json( │ │ │ │ │ - error=[ │ │ │ │ │ - "org.freedesktop.DBus.Error.UnknownMethod", │ │ │ │ │ - [f"Introspection data for method {iface} {method} not available"]], │ │ │ │ │ - id=cookie) │ │ │ │ │ - return │ │ │ │ │ - except Exception as exc: │ │ │ │ │ - self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie) │ │ │ │ │ - return │ │ │ │ │ + contents = collect_contents(args.files, relative_to=args.topdir) │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args) │ │ │ │ │ - reply = await self.bus.call_async(method_call, timeout=timeout) │ │ │ │ │ - # If the method call has kicked off any signals related to │ │ │ │ │ - # watch processing, wait for that to be done. │ │ │ │ │ - async with self.watch_processing_lock: │ │ │ │ │ - # TODO: stop hard-coding the endian flag here. │ │ │ │ │ - self.send_json( │ │ │ │ │ - reply=[reply.get_body()], id=cookie, │ │ │ │ │ - flags="<" if flags is not None else None, │ │ │ │ │ - type=reply.get_signature(True)) # noqa: FBT003 │ │ │ │ │ - except BusError as error: │ │ │ │ │ - # actually, should send the fields from the message body │ │ │ │ │ - self.send_json(error=[error.name, [error.message]], id=cookie) │ │ │ │ │ - except Exception: │ │ │ │ │ - logger.exception("do_call(%s): generic exception", message) │ │ │ │ │ - self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie) │ │ │ │ │ + for file in args.zip: │ │ │ │ │ + contents.update(collect_zip(file)) │ │ │ │ │ │ │ │ │ │ - async def do_add_match(self, message): │ │ │ │ │ - add_match = message['add-match'] │ │ │ │ │ - logger.debug('adding match %s', add_match) │ │ │ │ │ + for name in args.module: │ │ │ │ │ + contents.update(collect_module(name, recursive=True)) │ │ │ │ │ │ │ │ │ │ - async def match_hit(message): │ │ │ │ │ - logger.debug('got match') │ │ │ │ │ - async with self.watch_processing_lock: │ │ │ │ │ - self.send_json(signal=[ │ │ │ │ │ - message.get_path(), │ │ │ │ │ - message.get_interface(), │ │ │ │ │ - message.get_member(), │ │ │ │ │ - list(message.get_body()) │ │ │ │ │ - ]) │ │ │ │ │ + for path in args.build: │ │ │ │ │ + contents.update(collect_pep517(path)) │ │ │ │ │ │ │ │ │ │ - self.add_async_signal_handler(match_hit, **add_match) │ │ │ │ │ + result = pack(contents, args.main, args.main_args).encode('utf-8') │ │ │ │ │ │ │ │ │ │ - async def setup_objectmanager_watch(self, path, interface_name, meta, notify): │ │ │ │ │ - # Watch the objects managed by the ObjectManager at "path". │ │ │ │ │ - # Properties are not watched, that is done by setup_path_watch │ │ │ │ │ - # below via recursive_props == True. │ │ │ │ │ + if args.python: │ │ │ │ │ + result = b'#!' + args.python.encode('ascii') + b'\n' + result │ │ │ │ │ │ │ │ │ │ - async def handler(message): │ │ │ │ │ - member = message.get_member() │ │ │ │ │ - if member == "InterfacesAdded": │ │ │ │ │ - (path, interface_props) = message.get_body() │ │ │ │ │ - logger.debug('interfaces added %s %s', path, interface_props) │ │ │ │ │ - meta = {} │ │ │ │ │ - notify = {} │ │ │ │ │ - async with self.watch_processing_lock: │ │ │ │ │ - for name, props in interface_props.items(): │ │ │ │ │ - if interface_name is None or name == interface_name: │ │ │ │ │ - mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path) │ │ │ │ │ - if mm: │ │ │ │ │ - meta.update({name: mm}) │ │ │ │ │ - notify_update(notify, path, name, props) │ │ │ │ │ - self.send_json(meta=meta) │ │ │ │ │ - self.send_json(notify=notify) │ │ │ │ │ - elif member == "InterfacesRemoved": │ │ │ │ │ - (path, interfaces) = message.get_body() │ │ │ │ │ - logger.debug('interfaces removed %s %s', path, interfaces) │ │ │ │ │ - async with self.watch_processing_lock: │ │ │ │ │ - notify = {path: {name: None for name in interfaces}} │ │ │ │ │ - self.send_json(notify=notify) │ │ │ │ │ + if args.xz: │ │ │ │ │ + result = lzma.compress(result, preset=lzma.PRESET_EXTREME) │ │ │ │ │ │ │ │ │ │ - self.add_async_signal_handler(handler, │ │ │ │ │ - path=path, │ │ │ │ │ - interface="org.freedesktop.DBus.ObjectManager") │ │ │ │ │ - objects, = await self.bus.call_method_async(self.name, path, │ │ │ │ │ - 'org.freedesktop.DBus.ObjectManager', │ │ │ │ │ - 'GetManagedObjects') │ │ │ │ │ - for p, ifaces in objects.items(): │ │ │ │ │ - for iface, props in ifaces.items(): │ │ │ │ │ - if interface_name is None or iface == interface_name: │ │ │ │ │ - mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p) │ │ │ │ │ - if mm: │ │ │ │ │ - meta.update({iface: mm}) │ │ │ │ │ - notify_update(notify, p, iface, props) │ │ │ │ │ + if args.output: │ │ │ │ │ + with open(args.output, 'wb') as file: │ │ │ │ │ + file.write(result) │ │ │ │ │ + else: │ │ │ │ │ + if args.xz and os.isatty(1): │ │ │ │ │ + sys.exit('refusing to write compressed output to a terminal') │ │ │ │ │ + sys.stdout.buffer.write(result) │ │ │ │ │ │ │ │ │ │ - async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify): │ │ │ │ │ - # Watch a single object at "path", but maybe also watch for │ │ │ │ │ - # property changes for all objects below "path". │ │ │ │ │ │ │ │ │ │ - async def handler(message): │ │ │ │ │ - async with self.watch_processing_lock: │ │ │ │ │ - path = message.get_path() │ │ │ │ │ - name, props, invalids = message.get_body() │ │ │ │ │ - logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids) │ │ │ │ │ - for inv in invalids: │ │ │ │ │ - try: │ │ │ │ │ - reply, = await self.bus.call_method_async(self.name, path, │ │ │ │ │ - 'org.freedesktop.DBus.Properties', 'Get', │ │ │ │ │ - 'ss', name, inv) │ │ │ │ │ - except BusError as exc: │ │ │ │ │ - logger.debug('failed to fetch property %s.%s on %s %s: %s', │ │ │ │ │ - name, inv, self.name, path, str(exc)) │ │ │ │ │ - continue │ │ │ │ │ - props[inv] = reply │ │ │ │ │ - notify = {} │ │ │ │ │ - notify_update(notify, path, name, props) │ │ │ │ │ - self.send_json(notify=notify) │ │ │ │ │ +if __name__ == '__main__': │ │ │ │ │ + main() │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/bei/spawn.py': br'''"""Helper to create a beipack to spawn a command with files in a tmpdir""" │ │ │ │ │ │ │ │ │ │ - this_meta = await self.cache.introspect_path(self.bus, self.name, path) │ │ │ │ │ - if interface_name is not None: │ │ │ │ │ - interface = this_meta.get(interface_name) │ │ │ │ │ - this_meta = {interface_name: interface} │ │ │ │ │ - meta.update(this_meta) │ │ │ │ │ - if recursive_props: │ │ │ │ │ - self.add_async_signal_handler(handler, │ │ │ │ │ - interface="org.freedesktop.DBus.Properties", │ │ │ │ │ - path_namespace=path) │ │ │ │ │ - else: │ │ │ │ │ - self.add_async_signal_handler(handler, │ │ │ │ │ - interface="org.freedesktop.DBus.Properties", │ │ │ │ │ - path=path) │ │ │ │ │ +import argparse │ │ │ │ │ +import os │ │ │ │ │ +import sys │ │ │ │ │ │ │ │ │ │ - for name in meta: │ │ │ │ │ - if name.startswith("org.freedesktop.DBus."): │ │ │ │ │ - continue │ │ │ │ │ - try: │ │ │ │ │ - props, = await self.bus.call_method_async(self.name, path, │ │ │ │ │ - 'org.freedesktop.DBus.Properties', │ │ │ │ │ - 'GetAll', 's', name) │ │ │ │ │ - notify_update(notify, path, name, props) │ │ │ │ │ - except BusError: │ │ │ │ │ - pass │ │ │ │ │ +from . import pack, tmpfs │ │ │ │ │ │ │ │ │ │ - async def do_watch(self, message): │ │ │ │ │ - watch = message['watch'] │ │ │ │ │ - path = watch.get('path') │ │ │ │ │ - path_namespace = watch.get('path_namespace') │ │ │ │ │ - interface_name = watch.get('interface') │ │ │ │ │ - cookie = message.get('id') │ │ │ │ │ │ │ │ │ │ - path = path or path_namespace │ │ │ │ │ - recursive = path == path_namespace │ │ │ │ │ +def main() -> None: │ │ │ │ │ + parser = argparse.ArgumentParser() │ │ │ │ │ + parser.add_argument('--file', '-f', action='append') │ │ │ │ │ + parser.add_argument('command', nargs='+', help='The command to execute') │ │ │ │ │ + args = parser.parse_args() │ │ │ │ │ │ │ │ │ │ - if path is None or cookie is None: │ │ │ │ │ - logger.debug('ignored incomplete watch request %s', message) │ │ │ │ │ - self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie) │ │ │ │ │ - self.send_json(reply=[], id=cookie) │ │ │ │ │ - return │ │ │ │ │ + contents = { │ │ │ │ │ + '_beitmpfs.py': tmpfs.__spec__.loader.get_data(tmpfs.__spec__.origin) │ │ │ │ │ + } │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - async with self.watch_processing_lock: │ │ │ │ │ - meta = {} │ │ │ │ │ - notify = {} │ │ │ │ │ - await self.setup_path_watch(path, interface_name, recursive, meta, notify) │ │ │ │ │ - if recursive: │ │ │ │ │ - await self.setup_objectmanager_watch(path, interface_name, meta, notify) │ │ │ │ │ - self.send_json(meta=meta) │ │ │ │ │ - self.send_json(notify=notify) │ │ │ │ │ - self.send_json(reply=[], id=message['id']) │ │ │ │ │ - except BusError as error: │ │ │ │ │ - logger.debug("do_watch(%s) caught D-Bus error: %s", message, error.message) │ │ │ │ │ - self.send_json(error=[error.name, [error.message]], id=cookie) │ │ │ │ │ + if args.file is not None: │ │ │ │ │ + files = args.file │ │ │ │ │ + else: │ │ │ │ │ + file = args.command[-1] │ │ │ │ │ + files = [file] │ │ │ │ │ + args.command[-1] = './' + os.path.basename(file) │ │ │ │ │ │ │ │ │ │ - async def do_meta(self, message): │ │ │ │ │ - self.cache.inject(message['meta']) │ │ │ │ │ + for filename in files: │ │ │ │ │ + with open(filename, 'rb') as file: │ │ │ │ │ + basename = os.path.basename(filename) │ │ │ │ │ + contents[f'tmpfs/{basename}'] = file.read() │ │ │ │ │ │ │ │ │ │ - def do_data(self, data): │ │ │ │ │ - message = json.loads(data) │ │ │ │ │ - logger.debug('receive dbus request %s %s', self.name, message) │ │ │ │ │ + script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command)) │ │ │ │ │ + sys.stdout.write(script) │ │ │ │ │ │ │ │ │ │ - if 'call' in message: │ │ │ │ │ - self.create_task(self.do_call(message)) │ │ │ │ │ - elif 'add-match' in message: │ │ │ │ │ - self.create_task(self.do_add_match(message)) │ │ │ │ │ - elif 'watch' in message: │ │ │ │ │ - self.create_task(self.do_watch(message)) │ │ │ │ │ - elif 'meta' in message: │ │ │ │ │ - self.create_task(self.do_meta(message)) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug('ignored dbus request %s', message) │ │ │ │ │ - return │ │ │ │ │ │ │ │ │ │ - def do_close(self): │ │ │ │ │ - for slot in self.matches: │ │ │ │ │ - slot.cancel() │ │ │ │ │ - self.matches = [] │ │ │ │ │ - self.close() │ │ │ │ │ -'''.encode('utf-8'), │ │ │ │ │ - 'cockpit/channels/http.py': br'''# This file is part of Cockpit. │ │ │ │ │ +if __name__ == '__main__': │ │ │ │ │ + main() │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/bei/__init__.py': br'''''', │ │ │ │ │ + 'cockpit/_vendor/bei/beiboot.py': br"""# beiboot - Remote bootloader for Python │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ +import argparse │ │ │ │ │ import asyncio │ │ │ │ │ -import http.client │ │ │ │ │ -import logging │ │ │ │ │ -import socket │ │ │ │ │ -import ssl │ │ │ │ │ +import os │ │ │ │ │ +import shlex │ │ │ │ │ +import subprocess │ │ │ │ │ +import sys │ │ │ │ │ +import threading │ │ │ │ │ +from typing import IO, List, Sequence, Tuple │ │ │ │ │ │ │ │ │ │ -from ..channel import AsyncChannel, ChannelError │ │ │ │ │ -from ..jsonutil import JsonObject, get_dict, get_int, get_object, get_str, typechecked │ │ │ │ │ +from .bootloader import make_bootloader │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ +def get_python_command(local: bool = False, │ │ │ │ │ + tty: bool = False, │ │ │ │ │ + sh: bool = False) -> Sequence[str]: │ │ │ │ │ + interpreter = sys.executable if local else 'python3' │ │ │ │ │ + command: Sequence[str] │ │ │ │ │ │ │ │ │ │ -class HttpChannel(AsyncChannel): │ │ │ │ │ - payload = 'http-stream2' │ │ │ │ │ + if tty: │ │ │ │ │ + command = (interpreter, '-iq') │ │ │ │ │ + else: │ │ │ │ │ + command = ( │ │ │ │ │ + interpreter, '-ic', │ │ │ │ │ + # https://github.com/python/cpython/issues/93139 │ │ │ │ │ + '''" - beiboot - "; import sys; sys.ps1 = ''; sys.ps2 = '';''' │ │ │ │ │ + ) │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def get_headers(response: http.client.HTTPResponse, binary: 'str | None') -> JsonObject: │ │ │ │ │ - # Never send these headers │ │ │ │ │ - remove = {'Connection', 'Transfer-Encoding'} │ │ │ │ │ + if sh: │ │ │ │ │ + command = (' '.join(shlex.quote(arg) for arg in command),) │ │ │ │ │ │ │ │ │ │ - if binary != 'raw': │ │ │ │ │ - # Only send these headers for raw binary streams │ │ │ │ │ - remove.update({'Content-Length', 'Range'}) │ │ │ │ │ + return command │ │ │ │ │ │ │ │ │ │ - return {key: value for key, value in response.getheaders() if key not in remove} │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def create_client(options: JsonObject) -> http.client.HTTPConnection: │ │ │ │ │ - opt_address = get_str(options, 'address', 'localhost') │ │ │ │ │ - opt_tls = get_dict(options, 'tls', None) │ │ │ │ │ - opt_unix = get_str(options, 'unix', None) │ │ │ │ │ - opt_port = get_int(options, 'port', None) │ │ │ │ │ +def get_ssh_command(*args: str, tty: bool = False) -> Sequence[str]: │ │ │ │ │ + return ('ssh', │ │ │ │ │ + *(['-t'] if tty else ()), │ │ │ │ │ + *args, │ │ │ │ │ + *get_python_command(tty=tty, sh=True)) │ │ │ │ │ │ │ │ │ │ - if opt_tls is not None and opt_unix is not None: │ │ │ │ │ - raise ChannelError('protocol-error', message='TLS on Unix socket is not supported') │ │ │ │ │ - if opt_port is None and opt_unix is None: │ │ │ │ │ - raise ChannelError('protocol-error', message='no "port" or "unix" option for channel') │ │ │ │ │ - if opt_port is not None and opt_unix is not None: │ │ │ │ │ - raise ChannelError('protocol-error', message='cannot specify both "port" and "unix" options') │ │ │ │ │ │ │ │ │ │ - if opt_tls is not None: │ │ │ │ │ - authority = get_dict(opt_tls, 'authority', None) │ │ │ │ │ - if authority is not None: │ │ │ │ │ - data = get_str(authority, 'data', None) │ │ │ │ │ - if data is not None: │ │ │ │ │ - context = ssl.create_default_context(cadata=data) │ │ │ │ │ - else: │ │ │ │ │ - context = ssl.create_default_context(cafile=get_str(authority, 'file')) │ │ │ │ │ - else: │ │ │ │ │ - context = ssl.create_default_context() │ │ │ │ │ +def get_container_command(*args: str, tty: bool = False) -> Sequence[str]: │ │ │ │ │ + return ('podman', 'exec', '--interactive', │ │ │ │ │ + *(['--tty'] if tty else ()), │ │ │ │ │ + *args, │ │ │ │ │ + *get_python_command(tty=tty)) │ │ │ │ │ │ │ │ │ │ - if 'validate' in opt_tls and not opt_tls['validate']: │ │ │ │ │ - context.check_hostname = False │ │ │ │ │ - context.verify_mode = ssl.VerifyMode.CERT_NONE │ │ │ │ │ │ │ │ │ │ - # See https://github.com/python/typeshed/issues/11057 │ │ │ │ │ - return http.client.HTTPSConnection(opt_address, port=opt_port, context=context) # type: ignore[arg-type] │ │ │ │ │ +def get_command(*args: str, tty: bool = False, sh: bool = False) -> Sequence[str]: │ │ │ │ │ + return (*args, *get_python_command(local=True, tty=tty, sh=sh)) │ │ │ │ │ │ │ │ │ │ - else: │ │ │ │ │ - return http.client.HTTPConnection(opt_address, port=opt_port) │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def connect(connection: http.client.HTTPConnection, opt_unix: 'str | None') -> None: │ │ │ │ │ - # Blocks. Runs in a thread. │ │ │ │ │ - if opt_unix: │ │ │ │ │ - # create the connection's socket so that it won't call .connect() internally (which only supports TCP) │ │ │ │ │ - connection.sock = socket.socket(socket.AF_UNIX) │ │ │ │ │ - connection.sock.connect(opt_unix) │ │ │ │ │ - else: │ │ │ │ │ - # explicitly call connect(), so that we can do proper error handling │ │ │ │ │ - connection.connect() │ │ │ │ │ +def splice_in_thread(src: int, dst: IO[bytes]) -> None: │ │ │ │ │ + def _thread() -> None: │ │ │ │ │ + # os.splice() only in Python 3.10 │ │ │ │ │ + with dst: │ │ │ │ │ + block_size = 1 << 20 │ │ │ │ │ + while True: │ │ │ │ │ + data = os.read(src, block_size) │ │ │ │ │ + if not data: │ │ │ │ │ + break │ │ │ │ │ + dst.write(data) │ │ │ │ │ + dst.flush() │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def request( │ │ │ │ │ - connection: http.client.HTTPConnection, method: str, path: str, headers: 'dict[str, str]', body: bytes │ │ │ │ │ - ) -> http.client.HTTPResponse: │ │ │ │ │ - # Blocks. Runs in a thread. │ │ │ │ │ - connection.request(method, path, headers=headers or {}, body=body) │ │ │ │ │ - return connection.getresponse() │ │ │ │ │ + threading.Thread(target=_thread, daemon=True).start() │ │ │ │ │ │ │ │ │ │ - async def run(self, options: JsonObject) -> None: │ │ │ │ │ - logger.debug('open %s', options) │ │ │ │ │ │ │ │ │ │ - binary = get_str(options, 'binary', None) │ │ │ │ │ - method = get_str(options, 'method') │ │ │ │ │ - path = get_str(options, 'path') │ │ │ │ │ - headers = get_object(options, 'headers', lambda d: {k: typechecked(v, str) for k, v in d.items()}, None) │ │ │ │ │ +def send_and_splice(command: Sequence[str], script: bytes) -> None: │ │ │ │ │ + with subprocess.Popen(command, stdin=subprocess.PIPE) as proc: │ │ │ │ │ + assert proc.stdin is not None │ │ │ │ │ + proc.stdin.write(script) │ │ │ │ │ │ │ │ │ │ - if 'connection' in options: │ │ │ │ │ - raise ChannelError('protocol-error', message='connection sharing is not implemented on this bridge') │ │ │ │ │ + splice_in_thread(0, proc.stdin) │ │ │ │ │ + sys.exit(proc.wait()) │ │ │ │ │ │ │ │ │ │ - loop = asyncio.get_running_loop() │ │ │ │ │ - connection = self.create_client(options) │ │ │ │ │ │ │ │ │ │ - self.ready() │ │ │ │ │ +def send_xz_and_splice(command: Sequence[str], script: bytes) -> None: │ │ │ │ │ + import ferny │ │ │ │ │ │ │ │ │ │ - body = b'' │ │ │ │ │ - while True: │ │ │ │ │ - data = await self.read() │ │ │ │ │ - if data == b'': │ │ │ │ │ - break │ │ │ │ │ - body += data │ │ │ │ │ + class Responder(ferny.InteractionResponder): │ │ │ │ │ + async def do_custom_command(self, │ │ │ │ │ + command: str, │ │ │ │ │ + args: Tuple, │ │ │ │ │ + fds: List[int], │ │ │ │ │ + stderr: str) -> None: │ │ │ │ │ + assert proc.stdin is not None │ │ │ │ │ + if command == 'beiboot.provide': │ │ │ │ │ + proc.stdin.write(script) │ │ │ │ │ + proc.stdin.flush() │ │ │ │ │ │ │ │ │ │ - # Connect in a thread and handle errors │ │ │ │ │ - try: │ │ │ │ │ - await loop.run_in_executor(None, self.connect, connection, get_str(options, 'unix', None)) │ │ │ │ │ - except ssl.SSLCertVerificationError as exc: │ │ │ │ │ - raise ChannelError('unknown-hostkey', message=str(exc)) from exc │ │ │ │ │ - except (OSError, IOError) as exc: │ │ │ │ │ - raise ChannelError('not-found', message=str(exc)) from exc │ │ │ │ │ + agent = ferny.InteractionAgent(Responder()) │ │ │ │ │ + with subprocess.Popen(command, stdin=subprocess.PIPE, stderr=agent) as proc: │ │ │ │ │ + assert proc.stdin is not None │ │ │ │ │ + proc.stdin.write(make_bootloader([ │ │ │ │ │ + ('boot_xz', ('script.py.xz', len(script), [], True)), │ │ │ │ │ + ], gadgets=ferny.BEIBOOT_GADGETS).encode()) │ │ │ │ │ + proc.stdin.flush() │ │ │ │ │ │ │ │ │ │ - # Submit request in a thread and handle errors │ │ │ │ │ - try: │ │ │ │ │ - response = await loop.run_in_executor(None, self.request, connection, method, path, headers or {}, body) │ │ │ │ │ - except (http.client.HTTPException, OSError) as exc: │ │ │ │ │ - raise ChannelError('terminated', message=str(exc)) from exc │ │ │ │ │ + asyncio.run(agent.communicate()) │ │ │ │ │ + splice_in_thread(0, proc.stdin) │ │ │ │ │ + sys.exit(proc.wait()) │ │ │ │ │ │ │ │ │ │ - self.send_control(command='response', │ │ │ │ │ - status=response.status, │ │ │ │ │ - reason=response.reason, │ │ │ │ │ - headers=self.get_headers(response, binary)) │ │ │ │ │ │ │ │ │ │ - # Receive the body and finish up │ │ │ │ │ - try: │ │ │ │ │ - while True: │ │ │ │ │ - block = await loop.run_in_executor(None, response.read1, self.BLOCK_SIZE) │ │ │ │ │ - if not block: │ │ │ │ │ - break │ │ │ │ │ - await self.write(block) │ │ │ │ │ +def main() -> None: │ │ │ │ │ + parser = argparse.ArgumentParser() │ │ │ │ │ + parser.add_argument('--sh', action='store_true', │ │ │ │ │ + help='Pass Python interpreter command as shell-script') │ │ │ │ │ + parser.add_argument('--xz', help="the xz to run remotely") │ │ │ │ │ + parser.add_argument('--script', │ │ │ │ │ + help="the script to run remotely (must be repl-friendly)") │ │ │ │ │ + parser.add_argument('command', nargs='*') │ │ │ │ │ │ │ │ │ │ - logger.debug('reading response done') │ │ │ │ │ - # this returns immediately and does not read anything more, but updates the http.client's │ │ │ │ │ - # internal state machine to "response done" │ │ │ │ │ - block = response.read() │ │ │ │ │ - assert block == b'' │ │ │ │ │ + args = parser.parse_args() │ │ │ │ │ + tty = not args.script and os.isatty(0) │ │ │ │ │ │ │ │ │ │ - await loop.run_in_executor(None, connection.close) │ │ │ │ │ - except (http.client.HTTPException, OSError) as exc: │ │ │ │ │ - raise ChannelError('terminated', message=str(exc)) from exc │ │ │ │ │ + if args.command == []: │ │ │ │ │ + command = get_python_command(tty=tty) │ │ │ │ │ + elif args.command[0] == 'ssh': │ │ │ │ │ + command = get_ssh_command(*args.command[1:], tty=tty) │ │ │ │ │ + elif args.command[0] == 'container': │ │ │ │ │ + command = get_container_command(*args.command[1:], tty=tty) │ │ │ │ │ + else: │ │ │ │ │ + command = get_command(*args.command, tty=tty, sh=args.sh) │ │ │ │ │ │ │ │ │ │ - self.done() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/channels/stream.py': br'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ + if args.script: │ │ │ │ │ + with open(args.script, 'rb') as file: │ │ │ │ │ + script = file.read() │ │ │ │ │ │ │ │ │ │ -import asyncio │ │ │ │ │ -import logging │ │ │ │ │ -import os │ │ │ │ │ + send_and_splice(command, script) │ │ │ │ │ + │ │ │ │ │ + elif args.xz: │ │ │ │ │ + with open(args.xz, 'rb') as file: │ │ │ │ │ + script = file.read() │ │ │ │ │ + │ │ │ │ │ + send_xz_and_splice(command, script) │ │ │ │ │ + │ │ │ │ │ + else: │ │ │ │ │ + # If we're streaming from stdin then this is a lot easier... │ │ │ │ │ + os.execlp(command[0], *command) │ │ │ │ │ + │ │ │ │ │ + # Otherwise, "full strength" │ │ │ │ │ + │ │ │ │ │ +if __name__ == '__main__': │ │ │ │ │ + main() │ │ │ │ │ +""", │ │ │ │ │ + 'cockpit/_vendor/bei/tmpfs.py': br'''import os │ │ │ │ │ import subprocess │ │ │ │ │ -from typing import Dict │ │ │ │ │ +import sys │ │ │ │ │ +import tempfile │ │ │ │ │ │ │ │ │ │ -from ..channel import ChannelError, ProtocolChannel │ │ │ │ │ -from ..jsonutil import JsonDict, JsonObject, get_bool, get_int, get_object, get_str, get_strv │ │ │ │ │ -from ..transports import SubprocessProtocol, SubprocessTransport, WindowSize │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ +def main(*command: str) -> None: │ │ │ │ │ + with tempfile.TemporaryDirectory() as tmpdir: │ │ │ │ │ + os.chdir(tmpdir) │ │ │ │ │ │ │ │ │ │ + for key, value in __loader__.get_contents().items(): │ │ │ │ │ + if key.startswith('tmpfs/'): │ │ │ │ │ + subdir = os.path.dirname(key) │ │ │ │ │ + os.makedirs(subdir, exist_ok=True) │ │ │ │ │ + with open(key, 'wb') as fp: │ │ │ │ │ + fp.write(value) │ │ │ │ │ │ │ │ │ │ -class SocketStreamChannel(ProtocolChannel): │ │ │ │ │ - payload = 'stream' │ │ │ │ │ + os.chdir('tmpfs') │ │ │ │ │ │ │ │ │ │ - async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport: │ │ │ │ │ - if 'unix' in options and 'port' in options: │ │ │ │ │ - raise ChannelError('protocol-error', message='cannot specify both "port" and "unix" options') │ │ │ │ │ + result = subprocess.run(command, check=False) │ │ │ │ │ + sys.exit(result.returncode) │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/bei/data/__init__.py': br'''import sys │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - # Unix │ │ │ │ │ - if 'unix' in options: │ │ │ │ │ - path = get_str(options, 'unix') │ │ │ │ │ - label = f'Unix socket {path}' │ │ │ │ │ - transport, _ = await loop.create_unix_connection(lambda: self, path) │ │ │ │ │ +if sys.version_info >= (3, 9): │ │ │ │ │ + import importlib.abc │ │ │ │ │ + import importlib.resources │ │ │ │ │ │ │ │ │ │ - # TCP │ │ │ │ │ - elif 'port' in options: │ │ │ │ │ - port = get_int(options, 'port') │ │ │ │ │ - host = get_str(options, 'address', 'localhost') │ │ │ │ │ - label = f'TCP socket {host}:{port}' │ │ │ │ │ + def read_data_file(filename: str) -> str: │ │ │ │ │ + return (importlib.resources.files(__name__) / filename).read_text() │ │ │ │ │ +else: │ │ │ │ │ + def read_data_file(filename: str) -> str: │ │ │ │ │ + loader = __loader__ # type: ignore[name-defined] │ │ │ │ │ + data = loader.get_data(__file__.replace('__init__.py', filename)) │ │ │ │ │ + return data.decode('utf-8') │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack │ │ │ │ │ │ │ │ │ │ - transport, _ = await loop.create_connection(lambda: self, host, port) │ │ │ │ │ - else: │ │ │ │ │ - raise ChannelError('protocol-error', │ │ │ │ │ - message='no "port" or "unix" or other address option for channel') │ │ │ │ │ +import importlib.abc │ │ │ │ │ +import importlib.util │ │ │ │ │ +import io │ │ │ │ │ +import sys │ │ │ │ │ +from types import ModuleType │ │ │ │ │ +from typing import BinaryIO, Dict, Iterator, Optional, Sequence │ │ │ │ │ │ │ │ │ │ - logger.debug('SocketStreamChannel: connected to %s', label) │ │ │ │ │ - except OSError as error: │ │ │ │ │ - logger.info('SocketStreamChannel: connecting to %s failed: %s', label, error) │ │ │ │ │ - if isinstance(error, ConnectionRefusedError): │ │ │ │ │ - problem = 'not-found' │ │ │ │ │ - else: │ │ │ │ │ - problem = 'terminated' │ │ │ │ │ - raise ChannelError(problem, message=str(error)) from error │ │ │ │ │ - self.close_on_eof() │ │ │ │ │ - assert isinstance(transport, asyncio.Transport) │ │ │ │ │ - return transport │ │ │ │ │ │ │ │ │ │ +class BeipackLoader(importlib.abc.SourceLoader, importlib.abc.MetaPathFinder): │ │ │ │ │ + if sys.version_info >= (3, 11): │ │ │ │ │ + from importlib.resources.abc import ResourceReader as AbstractResourceReader │ │ │ │ │ + else: │ │ │ │ │ + AbstractResourceReader = object │ │ │ │ │ │ │ │ │ │ -class SubprocessStreamChannel(ProtocolChannel, SubprocessProtocol): │ │ │ │ │ - payload = 'stream' │ │ │ │ │ - restrictions = (('spawn', None),) │ │ │ │ │ + class ResourceReader(AbstractResourceReader): │ │ │ │ │ + def __init__(self, contents: Dict[str, bytes], filename: str) -> None: │ │ │ │ │ + self._contents = contents │ │ │ │ │ + self._dir = f'{filename}/' │ │ │ │ │ │ │ │ │ │ - def process_exited(self) -> None: │ │ │ │ │ - self.close_on_eof() │ │ │ │ │ + def is_resource(self, resource: str) -> bool: │ │ │ │ │ + return f'{self._dir}{resource}' in self._contents │ │ │ │ │ │ │ │ │ │ - def _get_close_args(self) -> JsonObject: │ │ │ │ │ - assert isinstance(self._transport, SubprocessTransport) │ │ │ │ │ - args: JsonDict = {'exit-status': self._transport.get_returncode()} │ │ │ │ │ - stderr = self._transport.get_stderr() │ │ │ │ │ - if stderr is not None: │ │ │ │ │ - args['message'] = stderr │ │ │ │ │ - return args │ │ │ │ │ + def open_resource(self, resource: str) -> BinaryIO: │ │ │ │ │ + return io.BytesIO(self._contents[f'{self._dir}{resource}']) │ │ │ │ │ │ │ │ │ │ - def do_options(self, options): │ │ │ │ │ - window = get_object(options, 'window', WindowSize, None) │ │ │ │ │ - if window is not None: │ │ │ │ │ - self._transport.set_window_size(window) │ │ │ │ │ + def resource_path(self, resource: str) -> str: │ │ │ │ │ + raise FileNotFoundError │ │ │ │ │ │ │ │ │ │ - async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> SubprocessTransport: │ │ │ │ │ - args = get_strv(options, 'spawn') │ │ │ │ │ - err = get_str(options, 'err', 'msg') │ │ │ │ │ - cwd = get_str(options, 'directory', '.') │ │ │ │ │ - pty = get_bool(options, 'pty', default=False) │ │ │ │ │ - window = get_object(options, 'window', WindowSize, None) │ │ │ │ │ - environ = get_strv(options, 'environ', []) │ │ │ │ │ + def contents(self) -> Iterator[str]: │ │ │ │ │ + dir_length = len(self._dir) │ │ │ │ │ + result = set() │ │ │ │ │ │ │ │ │ │ - if err == 'out': │ │ │ │ │ - stderr = subprocess.STDOUT │ │ │ │ │ - elif err == 'ignore': │ │ │ │ │ - stderr = subprocess.DEVNULL │ │ │ │ │ - else: │ │ │ │ │ - stderr = subprocess.PIPE │ │ │ │ │ + for filename in self._contents: │ │ │ │ │ + if filename.startswith(self._dir): │ │ │ │ │ + try: │ │ │ │ │ + next_slash = filename.index('/', dir_length) │ │ │ │ │ + except ValueError: │ │ │ │ │ + next_slash = None │ │ │ │ │ + result.add(filename[dir_length:next_slash]) │ │ │ │ │ │ │ │ │ │ - env: Dict[str, str] = dict(os.environ) │ │ │ │ │ - try: │ │ │ │ │ - env.update(dict(e.split('=', 1) for e in environ)) │ │ │ │ │ - except ValueError: │ │ │ │ │ - raise ChannelError('protocol-error', message='invalid "environ" option for stream channel') from None │ │ │ │ │ + return iter(result) │ │ │ │ │ │ │ │ │ │ + contents: Dict[str, bytes] │ │ │ │ │ + modules: Dict[str, str] │ │ │ │ │ + │ │ │ │ │ + def __init__(self, contents: Dict[str, bytes]) -> None: │ │ │ │ │ try: │ │ │ │ │ - transport = SubprocessTransport(loop, self, args, pty=pty, window=window, env=env, cwd=cwd, stderr=stderr) │ │ │ │ │ - logger.debug('Spawned process args=%s pid=%i', args, transport.get_pid()) │ │ │ │ │ - return transport │ │ │ │ │ - except FileNotFoundError as error: │ │ │ │ │ - raise ChannelError('not-found') from error │ │ │ │ │ - except PermissionError as error: │ │ │ │ │ - raise ChannelError('access-denied') from error │ │ │ │ │ - except OSError as error: │ │ │ │ │ - logger.info("Failed to spawn %s: %s", args, str(error)) │ │ │ │ │ - raise ChannelError('internal-error') from error │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/channels/__init__.py': br'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ + contents[__file__] = __self_source__ # type: ignore[name-defined] │ │ │ │ │ + except NameError: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ -from .dbus import DBusChannel │ │ │ │ │ -from .filesystem import FsInfoChannel, FsListChannel, FsReadChannel, FsReplaceChannel, FsWatchChannel │ │ │ │ │ -from .http import HttpChannel │ │ │ │ │ -from .metrics import InternalMetricsChannel │ │ │ │ │ -from .packages import PackagesChannel │ │ │ │ │ -from .stream import SocketStreamChannel, SubprocessStreamChannel │ │ │ │ │ -from .trivial import EchoChannel, NullChannel │ │ │ │ │ + self.contents = contents │ │ │ │ │ + self.modules = { │ │ │ │ │ + self.get_fullname(filename): filename │ │ │ │ │ + for filename in contents │ │ │ │ │ + if filename.endswith(".py") │ │ │ │ │ + } │ │ │ │ │ │ │ │ │ │ -CHANNEL_TYPES = [ │ │ │ │ │ - DBusChannel, │ │ │ │ │ - EchoChannel, │ │ │ │ │ - FsInfoChannel, │ │ │ │ │ - FsListChannel, │ │ │ │ │ - FsReadChannel, │ │ │ │ │ - FsReplaceChannel, │ │ │ │ │ - FsWatchChannel, │ │ │ │ │ - HttpChannel, │ │ │ │ │ - InternalMetricsChannel, │ │ │ │ │ - NullChannel, │ │ │ │ │ - PackagesChannel, │ │ │ │ │ - SubprocessStreamChannel, │ │ │ │ │ - SocketStreamChannel, │ │ │ │ │ -] │ │ │ │ │ + def get_fullname(self, filename: str) -> str: │ │ │ │ │ + assert filename.endswith(".py") │ │ │ │ │ + filename = filename[:-3] │ │ │ │ │ + if filename.endswith("/__init__"): │ │ │ │ │ + filename = filename[:-9] │ │ │ │ │ + return filename.replace("/", ".") │ │ │ │ │ + │ │ │ │ │ + def get_resource_reader(self, fullname: str) -> ResourceReader: │ │ │ │ │ + return BeipackLoader.ResourceReader(self.contents, fullname.replace('.', '/')) │ │ │ │ │ + │ │ │ │ │ + def get_data(self, path: str) -> bytes: │ │ │ │ │ + return self.contents[path] │ │ │ │ │ + │ │ │ │ │ + def get_filename(self, fullname: str) -> str: │ │ │ │ │ + return self.modules[fullname] │ │ │ │ │ + │ │ │ │ │ + def find_spec( │ │ │ │ │ + self, │ │ │ │ │ + fullname: str, │ │ │ │ │ + path: Optional[Sequence[str]], │ │ │ │ │ + target: Optional[ModuleType] = None │ │ │ │ │ + ) -> Optional[importlib.machinery.ModuleSpec]: │ │ │ │ │ + if fullname not in self.modules: │ │ │ │ │ + return None │ │ │ │ │ + return importlib.util.spec_from_loader(fullname, self) │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ +import array │ │ │ │ │ +import ast │ │ │ │ │ import asyncio │ │ │ │ │ -import json │ │ │ │ │ +import contextlib │ │ │ │ │ import logging │ │ │ │ │ -import sys │ │ │ │ │ -import time │ │ │ │ │ -from collections import defaultdict │ │ │ │ │ -from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union │ │ │ │ │ +import os │ │ │ │ │ +import re │ │ │ │ │ +import socket │ │ │ │ │ +import tempfile │ │ │ │ │ +from typing import Any, Callable, ClassVar, Generator, Sequence │ │ │ │ │ │ │ │ │ │ -from ..channel import AsyncChannel, ChannelError │ │ │ │ │ -from ..jsonutil import JsonList │ │ │ │ │ -from ..samples import SAMPLERS, SampleDescription, Sampler, Samples │ │ │ │ │ +from . import interaction_client │ │ │ │ │ │ │ │ │ │ logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class MetricInfo(NamedTuple): │ │ │ │ │ - derive: Optional[str] │ │ │ │ │ - desc: SampleDescription │ │ │ │ │ +COMMAND_RE = re.compile(b'\0ferny\0([^\n]*)\0\0\n') │ │ │ │ │ +COMMAND_TEMPLATE = '\0ferny\0{(command, args)!r}\0\0\n' │ │ │ │ │ │ │ │ │ │ +BEIBOOT_GADGETS = { │ │ │ │ │ + "command": fr""" │ │ │ │ │ + import sys │ │ │ │ │ + def command(command, *args): │ │ │ │ │ + sys.stderr.write(f{COMMAND_TEMPLATE!r}) │ │ │ │ │ + sys.stderr.flush() │ │ │ │ │ + """, │ │ │ │ │ + "end": r""" │ │ │ │ │ + def end(): │ │ │ │ │ + command('ferny.end') │ │ │ │ │ + """, │ │ │ │ │ +} │ │ │ │ │ │ │ │ │ │ -class InternalMetricsChannel(AsyncChannel): │ │ │ │ │ - payload = 'metrics1' │ │ │ │ │ - restrictions = [('source', 'internal')] │ │ │ │ │ │ │ │ │ │ - metrics: List[MetricInfo] │ │ │ │ │ - samplers: Set │ │ │ │ │ - samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None │ │ │ │ │ +class InteractionError(Exception): │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - interval: int = 1000 │ │ │ │ │ - need_meta: bool = True │ │ │ │ │ - last_timestamp: float = 0 │ │ │ │ │ - next_timestamp: float = 0 │ │ │ │ │ │ │ │ │ │ - @classmethod │ │ │ │ │ - def ensure_samplers(cls): │ │ │ │ │ - if cls.samplers_cache is None: │ │ │ │ │ - cls.samplers_cache = {desc.name: (sampler, desc) for sampler in SAMPLERS for desc in sampler.descriptions} │ │ │ │ │ +try: │ │ │ │ │ + recv_fds = socket.recv_fds │ │ │ │ │ +except AttributeError: │ │ │ │ │ + # Python < 3.9 │ │ │ │ │ │ │ │ │ │ - def parse_options(self, options): │ │ │ │ │ - logger.debug('metrics internal open: %s, channel: %s', options, self.channel) │ │ │ │ │ + def recv_fds( │ │ │ │ │ + sock: socket.socket, bufsize: int, maxfds: int, flags: int = 0 │ │ │ │ │ + ) -> 'tuple[bytes, list[int], int, None]': │ │ │ │ │ + fds = array.array("i") │ │ │ │ │ + msg, ancdata, flags, addr = sock.recvmsg(bufsize, socket.CMSG_LEN(maxfds * fds.itemsize)) │ │ │ │ │ + for cmsg_level, cmsg_type, cmsg_data in ancdata: │ │ │ │ │ + if (cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS): │ │ │ │ │ + fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)]) │ │ │ │ │ + return msg, list(fds), flags, addr │ │ │ │ │ │ │ │ │ │ - interval = options.get('interval', self.interval) │ │ │ │ │ - if not isinstance(interval, int) or interval <= 0 or interval > sys.maxsize: │ │ │ │ │ - raise ChannelError('protocol-error', message=f'invalid "interval" value: {interval}') │ │ │ │ │ │ │ │ │ │ - self.interval = interval │ │ │ │ │ +def get_running_loop() -> asyncio.AbstractEventLoop: │ │ │ │ │ + try: │ │ │ │ │ + return asyncio.get_running_loop() │ │ │ │ │ + except AttributeError: │ │ │ │ │ + # Python 3.6 │ │ │ │ │ + return asyncio.get_event_loop() │ │ │ │ │ │ │ │ │ │ - metrics = options.get('metrics') │ │ │ │ │ - if not isinstance(metrics, list) or len(metrics) == 0: │ │ │ │ │ - logger.error('invalid "metrics" value: %s', metrics) │ │ │ │ │ - raise ChannelError('protocol-error', message='invalid "metrics" option was specified (not an array)') │ │ │ │ │ │ │ │ │ │ - sampler_classes = set() │ │ │ │ │ - for metric in metrics: │ │ │ │ │ - # validate it's an object │ │ │ │ │ - name = metric.get('name') │ │ │ │ │ - units = metric.get('units') │ │ │ │ │ - derive = metric.get('derive') │ │ │ │ │ +class InteractionHandler: │ │ │ │ │ + commands: ClassVar[Sequence[str]] │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - sampler, desc = self.samplers_cache[name] │ │ │ │ │ - except KeyError as exc: │ │ │ │ │ - logger.error('unsupported metric: %s', name) │ │ │ │ │ - raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc │ │ │ │ │ + async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ │ │ │ │ │ - if units and units != desc.units: │ │ │ │ │ - raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}') │ │ │ │ │ │ │ │ │ │ - sampler_classes.add(sampler) │ │ │ │ │ - self.metrics.append(MetricInfo(derive=derive, desc=desc)) │ │ │ │ │ +class AskpassHandler(InteractionHandler): │ │ │ │ │ + commands: ClassVar[Sequence[str]] = ('ferny.askpass',) │ │ │ │ │ │ │ │ │ │ - self.samplers = {cls() for cls in sampler_classes} │ │ │ │ │ + async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None': │ │ │ │ │ + """Prompt the user for an authentication or confirmation interaction. │ │ │ │ │ │ │ │ │ │ - def send_meta(self, samples: Samples, timestamp: float): │ │ │ │ │ - metrics: JsonList = [] │ │ │ │ │ - for metricinfo in self.metrics: │ │ │ │ │ - if metricinfo.desc.instanced: │ │ │ │ │ - metrics.append({ │ │ │ │ │ - 'name': metricinfo.desc.name, │ │ │ │ │ - 'units': metricinfo.desc.units, │ │ │ │ │ - 'instances': list(samples[metricinfo.desc.name].keys()), │ │ │ │ │ - 'semantics': metricinfo.desc.semantics │ │ │ │ │ - }) │ │ │ │ │ - else: │ │ │ │ │ - metrics.append({ │ │ │ │ │ - 'name': metricinfo.desc.name, │ │ │ │ │ - 'derive': metricinfo.derive, # type: ignore[dict-item] │ │ │ │ │ - 'units': metricinfo.desc.units, │ │ │ │ │ - 'semantics': metricinfo.desc.semantics │ │ │ │ │ - }) │ │ │ │ │ + 'messages' is data that was sent to stderr before the interaction was requested. │ │ │ │ │ + 'prompt' is the interaction prompt. │ │ │ │ │ │ │ │ │ │ - self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics) │ │ │ │ │ - self.need_meta = False │ │ │ │ │ + The expected response type depends on hint: │ │ │ │ │ │ │ │ │ │ - def sample(self): │ │ │ │ │ - samples = defaultdict(dict) │ │ │ │ │ - for sampler in self.samplers: │ │ │ │ │ - sampler.sample(samples) │ │ │ │ │ - return samples │ │ │ │ │ + - "confirm": ask for permission, returning "yes" if accepted │ │ │ │ │ + - example: authorizing agent operation │ │ │ │ │ │ │ │ │ │ - def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Union[float, bool]: │ │ │ │ │ - if old_value is not None and self.last_timestamp: │ │ │ │ │ - return (value - old_value) / (self.next_timestamp - self.last_timestamp) │ │ │ │ │ - else: │ │ │ │ │ - return False │ │ │ │ │ + - "none": show a request without need for a response │ │ │ │ │ + - example: please touch your authentication token │ │ │ │ │ │ │ │ │ │ - def send_updates(self, samples: Samples, last_samples: Samples): │ │ │ │ │ - data: List[Union[float, List[Optional[Union[float, bool]]]]] = [] │ │ │ │ │ - timestamp = time.time() │ │ │ │ │ - self.next_timestamp = timestamp │ │ │ │ │ + - otherwise: return a password or other form of text token │ │ │ │ │ + - examples: enter password, unlock private key │ │ │ │ │ │ │ │ │ │ - for metricinfo in self.metrics: │ │ │ │ │ - value = samples[metricinfo.desc.name] │ │ │ │ │ + In any case, the function should properly handle cancellation. For the │ │ │ │ │ + "none" case, this will be the normal way to dismiss the dialog. │ │ │ │ │ + """ │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ - if metricinfo.desc.instanced: │ │ │ │ │ - old_value = last_samples[metricinfo.desc.name] │ │ │ │ │ - assert isinstance(value, dict) │ │ │ │ │ - assert isinstance(old_value, dict) │ │ │ │ │ + async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool: │ │ │ │ │ + """Prompt the user for a decision regarding acceptance of a host key. │ │ │ │ │ │ │ │ │ │ - # If we have less or more keys the data changed, send a meta message. │ │ │ │ │ - if value.keys() != old_value.keys(): │ │ │ │ │ - self.need_meta = True │ │ │ │ │ + The "reason" will be either "HOSTNAME" or "ADDRESS" (if `CheckHostIP` is enabled). │ │ │ │ │ │ │ │ │ │ - if metricinfo.derive == 'rate': │ │ │ │ │ - instances: List[Optional[Union[float, bool]]] = [] │ │ │ │ │ - for key, val in value.items(): │ │ │ │ │ - instances.append(self.calculate_sample_rate(val, old_value.get(key))) │ │ │ │ │ + The host, algorithm, and key parameters are the values in the form that │ │ │ │ │ + they would appear one a single line in the known hosts file. The │ │ │ │ │ + fingerprint is the key fingerprint in the format that ssh would │ │ │ │ │ + normally present it to the user. │ │ │ │ │ │ │ │ │ │ - data.append(instances) │ │ │ │ │ - else: │ │ │ │ │ - data.append(list(value.values())) │ │ │ │ │ - else: │ │ │ │ │ - old_value = last_samples.get(metricinfo.desc.name) │ │ │ │ │ - assert not isinstance(value, dict) │ │ │ │ │ - assert not isinstance(old_value, dict) │ │ │ │ │ + In case the host key should be accepted, this function needs to return │ │ │ │ │ + True. Returning False means that ssh implements its default logic. To │ │ │ │ │ + interrupt the connection, raise an exception. │ │ │ │ │ + """ │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ - if metricinfo.derive == 'rate': │ │ │ │ │ - data.append(self.calculate_sample_rate(value, old_value)) │ │ │ │ │ - else: │ │ │ │ │ - data.append(value) │ │ │ │ │ + async def do_custom_command( │ │ │ │ │ + self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str │ │ │ │ │ + ) -> None: │ │ │ │ │ + """Handle a custom command. │ │ │ │ │ │ │ │ │ │ - if self.need_meta: │ │ │ │ │ - self.send_meta(samples, timestamp) │ │ │ │ │ + The command name, its arguments, the passed fds, and the stderr leading │ │ │ │ │ + up to the command invocation are all provided. │ │ │ │ │ │ │ │ │ │ - self.last_timestamp = self.next_timestamp │ │ │ │ │ - self.send_data(json.dumps([data]).encode()) │ │ │ │ │ + See doc/interaction-protocol.md │ │ │ │ │ + """ │ │ │ │ │ │ │ │ │ │ - async def run(self, options): │ │ │ │ │ - self.metrics = [] │ │ │ │ │ - self.samplers = set() │ │ │ │ │ + async def _askpass_command(self, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None: │ │ │ │ │ + logger.debug('_askpass_command(%s, %s, %s)', args, fds, stderr) │ │ │ │ │ + try: │ │ │ │ │ + argv, env = args │ │ │ │ │ + assert isinstance(argv, list) │ │ │ │ │ + assert all(isinstance(arg, str) for arg in argv) │ │ │ │ │ + assert isinstance(env, dict) │ │ │ │ │ + assert all(isinstance(key, str) and isinstance(val, str) for key, val in env.items()) │ │ │ │ │ + assert len(fds) == 2 │ │ │ │ │ + except (ValueError, TypeError, AssertionError) as exc: │ │ │ │ │ + logger.error('Invalid arguments to askpass interaction: %s, %s: %s', args, fds, exc) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - InternalMetricsChannel.ensure_samplers() │ │ │ │ │ + with open(fds.pop(0), 'w') as status, open(fds.pop(0), 'w') as stdout: │ │ │ │ │ + try: │ │ │ │ │ + loop = get_running_loop() │ │ │ │ │ + try: │ │ │ │ │ + task = asyncio.current_task() │ │ │ │ │ + except AttributeError: │ │ │ │ │ + task = asyncio.Task.current_task() # type:ignore[attr-defined] # (Python 3.6) │ │ │ │ │ + assert task is not None │ │ │ │ │ + loop.add_reader(status, task.cancel) │ │ │ │ │ │ │ │ │ │ - self.parse_options(options) │ │ │ │ │ - self.ready() │ │ │ │ │ + if len(argv) == 2: │ │ │ │ │ + # normal askpass │ │ │ │ │ + prompt = argv[1] │ │ │ │ │ + hint = env.get('SSH_ASKPASS_PROMPT', '') │ │ │ │ │ + logger.debug('do_askpass(%r, %r, %r)', stderr, prompt, hint) │ │ │ │ │ + answer = await self.do_askpass(stderr, prompt, hint) │ │ │ │ │ + logger.debug('do_askpass answer %r', answer) │ │ │ │ │ + if answer is not None: │ │ │ │ │ + print(answer, file=stdout) │ │ │ │ │ + print(0, file=status) │ │ │ │ │ │ │ │ │ │ - last_samples = defaultdict(dict) │ │ │ │ │ - while True: │ │ │ │ │ - samples = self.sample() │ │ │ │ │ - self.send_updates(samples, last_samples) │ │ │ │ │ - last_samples = samples │ │ │ │ │ + elif len(argv) == 6: │ │ │ │ │ + # KnownHostsCommand │ │ │ │ │ + argv0, reason, host, algorithm, key, fingerprint = argv │ │ │ │ │ + if reason in ['ADDRESS', 'HOSTNAME']: │ │ │ │ │ + logger.debug('do_hostkey(%r, %r, %r, %r, %r)', reason, host, algorithm, key, fingerprint) │ │ │ │ │ + if await self.do_hostkey(reason, host, algorithm, key, fingerprint): │ │ │ │ │ + print(host, algorithm, key, file=stdout) │ │ │ │ │ + else: │ │ │ │ │ + logger.debug('ignoring KnownHostsCommand reason %r', reason) │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - await asyncio.wait_for(self.read(), self.interval / 1000) │ │ │ │ │ - return │ │ │ │ │ - except asyncio.TimeoutError: │ │ │ │ │ - # Continue the while loop, we use wait_for as an interval timer. │ │ │ │ │ - continue │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/channels/trivial.py': br'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ + print(0, file=status) │ │ │ │ │ │ │ │ │ │ -import logging │ │ │ │ │ + else: │ │ │ │ │ + logger.error('Incorrect number of command-line arguments to ferny-askpass: %s', argv) │ │ │ │ │ + finally: │ │ │ │ │ + loop.remove_reader(status) │ │ │ │ │ │ │ │ │ │ -from ..channel import Channel │ │ │ │ │ + async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None: │ │ │ │ │ + logger.debug('run_command(%s, %s, %s, %s)', command, args, fds, stderr) │ │ │ │ │ + if command == 'ferny.askpass': │ │ │ │ │ + await self._askpass_command(args, fds, stderr) │ │ │ │ │ + else: │ │ │ │ │ + await self.do_custom_command(command, args, fds, stderr) │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ +class InteractionAgent: │ │ │ │ │ + _handlers: 'dict[str, InteractionHandler]' │ │ │ │ │ │ │ │ │ │ -class EchoChannel(Channel): │ │ │ │ │ - payload = 'echo' │ │ │ │ │ + _loop: asyncio.AbstractEventLoop │ │ │ │ │ │ │ │ │ │ - def do_open(self, options): │ │ │ │ │ - self.ready() │ │ │ │ │ + _tasks: 'set[asyncio.Task]' │ │ │ │ │ │ │ │ │ │ - def do_data(self, data): │ │ │ │ │ - self.send_data(data) │ │ │ │ │ + _buffer: bytearray │ │ │ │ │ + _ours: socket.socket │ │ │ │ │ + _theirs: socket.socket │ │ │ │ │ │ │ │ │ │ - def do_done(self): │ │ │ │ │ - self.done() │ │ │ │ │ - self.close() │ │ │ │ │ + _completion_future: 'asyncio.Future[str]' │ │ │ │ │ + _pending_result: 'None | str | Exception' = None │ │ │ │ │ + _end: bool = False │ │ │ │ │ │ │ │ │ │ + def _consider_completion(self) -> None: │ │ │ │ │ + logger.debug('_consider_completion(%r)', self) │ │ │ │ │ │ │ │ │ │ -class NullChannel(Channel): │ │ │ │ │ - payload = 'null' │ │ │ │ │ + if self._pending_result is None or self._tasks: │ │ │ │ │ + logger.debug(' but not ready yet') │ │ │ │ │ │ │ │ │ │ - def do_open(self, options): │ │ │ │ │ - self.ready() │ │ │ │ │ + elif self._completion_future.done(): │ │ │ │ │ + logger.debug(' but already complete') │ │ │ │ │ │ │ │ │ │ - def do_close(self): │ │ │ │ │ - self.close() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/channels/packages.py': br'''# This file is part of Cockpit. │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ + elif isinstance(self._pending_result, str): │ │ │ │ │ + logger.debug(' submitting stderr (%r) to completion_future', self._pending_result) │ │ │ │ │ + self._completion_future.set_result(self._pending_result) │ │ │ │ │ │ │ │ │ │ -import logging │ │ │ │ │ -from typing import Optional │ │ │ │ │ + else: │ │ │ │ │ + logger.debug(' submitting exception (%r) to completion_future') │ │ │ │ │ + self._completion_future.set_exception(self._pending_result) │ │ │ │ │ │ │ │ │ │ -from ..channel import AsyncChannel │ │ │ │ │ -from ..data import read_cockpit_data_file │ │ │ │ │ -from ..jsonutil import JsonObject, get_dict, get_str │ │ │ │ │ -from ..packages import Packages │ │ │ │ │ + def _result(self, result: 'str | Exception') -> None: │ │ │ │ │ + logger.debug('_result(%r, %r)', self, result) │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ + if self._pending_result is None: │ │ │ │ │ + self._pending_result = result │ │ │ │ │ │ │ │ │ │ + if self._ours.fileno() != -1: │ │ │ │ │ + logger.debug(' remove_reader(%r)', self._ours) │ │ │ │ │ + self._loop.remove_reader(self._ours.fileno()) │ │ │ │ │ │ │ │ │ │ -class PackagesChannel(AsyncChannel): │ │ │ │ │ - payload = 'http-stream1' │ │ │ │ │ - restrictions = [("internal", "packages")] │ │ │ │ │ + for task in self._tasks: │ │ │ │ │ + logger.debug(' cancel(%r)', task) │ │ │ │ │ + task.cancel() │ │ │ │ │ │ │ │ │ │ - # used to carry data forward from open to done │ │ │ │ │ - options: Optional[JsonObject] = None │ │ │ │ │ + logger.debug(' closing sockets') │ │ │ │ │ + self._theirs.close() # idempotent │ │ │ │ │ + self._ours.close() │ │ │ │ │ │ │ │ │ │ - def http_error(self, status: int, message: str) -> None: │ │ │ │ │ - template = read_cockpit_data_file('fail.html') │ │ │ │ │ - self.send_json(status=status, reason='ERROR', headers={'Content-Type': 'text/html; charset=utf-8'}) │ │ │ │ │ - self.send_data(template.replace(b'@@message@@', message.encode('utf-8'))) │ │ │ │ │ - self.done() │ │ │ │ │ - self.close() │ │ │ │ │ + self._consider_completion() │ │ │ │ │ │ │ │ │ │ - async def run(self, options: JsonObject) -> None: │ │ │ │ │ - packages: Packages = self.router.packages # type: ignore[attr-defined] # yes, this is evil │ │ │ │ │ + def _invoke_command(self, stderr: bytes, command_blob: bytes, fds: 'list[int]') -> None: │ │ │ │ │ + logger.debug('_invoke_command(%r, %r, %r)', stderr, command_blob, fds) │ │ │ │ │ + try: │ │ │ │ │ + command, args = ast.literal_eval(command_blob.decode()) │ │ │ │ │ + if not isinstance(command, str) or not isinstance(args, tuple): │ │ │ │ │ + raise TypeError('Invalid argument types') │ │ │ │ │ + except (UnicodeDecodeError, SyntaxError, ValueError, TypeError) as exc: │ │ │ │ │ + logger.error('Received invalid ferny command: %s: %s', command_blob, exc) │ │ │ │ │ + return │ │ │ │ │ + │ │ │ │ │ + if command == 'ferny.end': │ │ │ │ │ + self._end = True │ │ │ │ │ + self._result(self._buffer.decode(errors='replace')) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ try: │ │ │ │ │ - if get_str(options, 'method') != 'GET': │ │ │ │ │ - raise ValueError(f'Unsupported HTTP method {options["method"]}') │ │ │ │ │ + handler = self._handlers[command] │ │ │ │ │ + except KeyError: │ │ │ │ │ + logger.error('Received unhandled ferny command: %s', command) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - self.ready() │ │ │ │ │ - if await self.read() != b'': │ │ │ │ │ - raise ValueError('Received unexpected data') │ │ │ │ │ + # The task is responsible for the list of fds and removing itself │ │ │ │ │ + # from the set. │ │ │ │ │ + task_fds = list(fds) │ │ │ │ │ + task = self._loop.create_task(handler.run_command(command, args, task_fds, stderr.decode())) │ │ │ │ │ │ │ │ │ │ - path = get_str(options, 'path') │ │ │ │ │ - headers = get_dict(options, 'headers') │ │ │ │ │ - document = packages.load_path(path, headers) │ │ │ │ │ + def bottom_half(completed_task: asyncio.Task) -> None: │ │ │ │ │ + assert completed_task is task │ │ │ │ │ + while task_fds: │ │ │ │ │ + os.close(task_fds.pop()) │ │ │ │ │ + self._tasks.remove(task) │ │ │ │ │ │ │ │ │ │ - # Note: we can't cache documents right now. See │ │ │ │ │ - # https://github.com/cockpit-project/cockpit/issues/19071 │ │ │ │ │ - # for future plans. │ │ │ │ │ - out_headers = { │ │ │ │ │ - 'Cache-Control': 'no-cache, no-store', │ │ │ │ │ - 'Content-Type': document.content_type, │ │ │ │ │ - } │ │ │ │ │ + try: │ │ │ │ │ + task.result() │ │ │ │ │ + logger.debug('%r completed cleanly', handler) │ │ │ │ │ + except asyncio.CancelledError: │ │ │ │ │ + # this is not an error — it just means ferny-askpass exited via signal │ │ │ │ │ + logger.debug('%r was cancelled', handler) │ │ │ │ │ + except Exception as exc: │ │ │ │ │ + logger.debug('%r raised %r', handler, exc) │ │ │ │ │ + self._result(exc) │ │ │ │ │ │ │ │ │ │ - if document.content_encoding is not None: │ │ │ │ │ - out_headers['Content-Encoding'] = document.content_encoding │ │ │ │ │ + self._consider_completion() │ │ │ │ │ │ │ │ │ │ - if document.content_security_policy is not None: │ │ │ │ │ - policy = document.content_security_policy │ │ │ │ │ + task.add_done_callback(bottom_half) │ │ │ │ │ + self._tasks.add(task) │ │ │ │ │ + fds[:] = [] │ │ │ │ │ │ │ │ │ │ - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src │ │ │ │ │ - # │ │ │ │ │ - # Note: connect-src 'self' does not resolve to websocket │ │ │ │ │ - # schemes in all browsers, more info in this issue. │ │ │ │ │ - # │ │ │ │ │ - # https://github.com/w3c/webappsec-csp/issues/7 │ │ │ │ │ - if "connect-src 'self';" in policy: │ │ │ │ │ - protocol = headers.get('X-Forwarded-Proto') │ │ │ │ │ - host = headers.get('X-Forwarded-Host') │ │ │ │ │ - if not isinstance(protocol, str) or not isinstance(host, str): │ │ │ │ │ - raise ValueError('Invalid host or protocol header') │ │ │ │ │ + def _got_data(self, data: bytes, fds: 'list[int]') -> None: │ │ │ │ │ + logger.debug('_got_data(%r, %r)', data, fds) │ │ │ │ │ │ │ │ │ │ - websocket_scheme = "wss" if protocol == "https" else "ws" │ │ │ │ │ - websocket_origin = f"{websocket_scheme}://{host}" │ │ │ │ │ - policy = policy.replace("connect-src 'self';", f"connect-src {websocket_origin} 'self';") │ │ │ │ │ + if data == b'': │ │ │ │ │ + self._result(self._buffer.decode(errors='replace')) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - out_headers['Content-Security-Policy'] = policy │ │ │ │ │ + self._buffer.extend(data) │ │ │ │ │ │ │ │ │ │ - except ValueError as exc: │ │ │ │ │ - self.http_error(400, str(exc)) │ │ │ │ │ + # Read zero or more "remote" messages │ │ │ │ │ + chunks = COMMAND_RE.split(self._buffer) │ │ │ │ │ + self._buffer = bytearray(chunks.pop()) │ │ │ │ │ + while len(chunks) > 1: │ │ │ │ │ + self._invoke_command(chunks[0], chunks[1], []) │ │ │ │ │ + chunks = chunks[2:] │ │ │ │ │ │ │ │ │ │ - except KeyError: │ │ │ │ │ - self.http_error(404, 'Not found') │ │ │ │ │ + # Maybe read one "local" message │ │ │ │ │ + if fds: │ │ │ │ │ + assert self._buffer.endswith(b'\0'), self._buffer │ │ │ │ │ + stderr = self._buffer[:-1] │ │ │ │ │ + self._buffer = bytearray(b'') │ │ │ │ │ + with open(fds.pop(0), 'rb') as command_channel: │ │ │ │ │ + command = command_channel.read() │ │ │ │ │ + self._invoke_command(stderr, command, fds) │ │ │ │ │ │ │ │ │ │ + def _read_ready(self) -> None: │ │ │ │ │ + try: │ │ │ │ │ + data, fds, _flags, _addr = recv_fds(self._ours, 4096, 10, flags=socket.MSG_DONTWAIT) │ │ │ │ │ + except BlockingIOError: │ │ │ │ │ + return │ │ │ │ │ except OSError as exc: │ │ │ │ │ - self.http_error(500, f'Internal error: {exc!s}') │ │ │ │ │ + self._result(exc) │ │ │ │ │ + else: │ │ │ │ │ + self._got_data(data, fds) │ │ │ │ │ + finally: │ │ │ │ │ + while fds: │ │ │ │ │ + os.close(fds.pop()) │ │ │ │ │ + │ │ │ │ │ + def __init__( │ │ │ │ │ + self, │ │ │ │ │ + handlers: Sequence[InteractionHandler], │ │ │ │ │ + loop: 'asyncio.AbstractEventLoop | None' = None, │ │ │ │ │ + done_callback: 'Callable[[asyncio.Future[str]], None] | None' = None, │ │ │ │ │ + ) -> None: │ │ │ │ │ + self._loop = loop or get_running_loop() │ │ │ │ │ + self._completion_future = self._loop.create_future() │ │ │ │ │ + self._tasks = set() │ │ │ │ │ + self._handlers = {} │ │ │ │ │ │ │ │ │ │ + for handler in handlers: │ │ │ │ │ + for command in handler.commands: │ │ │ │ │ + self._handlers[command] = handler │ │ │ │ │ + │ │ │ │ │ + if done_callback is not None: │ │ │ │ │ + self._completion_future.add_done_callback(done_callback) │ │ │ │ │ + │ │ │ │ │ + self._theirs, self._ours = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) │ │ │ │ │ + self._buffer = bytearray() │ │ │ │ │ + │ │ │ │ │ + def fileno(self) -> int: │ │ │ │ │ + return self._theirs.fileno() │ │ │ │ │ + │ │ │ │ │ + def start(self) -> None: │ │ │ │ │ + logger.debug('start(%r)', self) │ │ │ │ │ + if self._ours.fileno() != -1: │ │ │ │ │ + logger.debug(' add_reader(%r)', self._ours) │ │ │ │ │ + self._loop.add_reader(self._ours.fileno(), self._read_ready) │ │ │ │ │ else: │ │ │ │ │ - self.send_json(status=200, reason='OK', headers=out_headers) │ │ │ │ │ - await self.sendfile(document.data) │ │ │ │ │ + logger.debug(' ...but agent is already finished.') │ │ │ │ │ + │ │ │ │ │ + logger.debug(' close(%r)', self._theirs) │ │ │ │ │ + self._theirs.close() │ │ │ │ │ + │ │ │ │ │ + def force_completion(self) -> None: │ │ │ │ │ + logger.debug('force_completion(%r)', self) │ │ │ │ │ + │ │ │ │ │ + # read any residual data on stderr, but don't process commands, and │ │ │ │ │ + # don't block │ │ │ │ │ + try: │ │ │ │ │ + if self._ours.fileno() != -1: │ │ │ │ │ + logger.debug(' draining pending stderr data (non-blocking)') │ │ │ │ │ + with contextlib.suppress(BlockingIOError): │ │ │ │ │ + while True: │ │ │ │ │ + data = self._ours.recv(4096, socket.MSG_DONTWAIT) │ │ │ │ │ + logger.debug(' got %d bytes', len(data)) │ │ │ │ │ + if not data: │ │ │ │ │ + break │ │ │ │ │ + self._buffer.extend(data) │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + self._result(exc) │ │ │ │ │ + else: │ │ │ │ │ + self._result(self._buffer.decode(errors='replace')) │ │ │ │ │ + │ │ │ │ │ + async def communicate(self) -> None: │ │ │ │ │ + logger.debug('_communicate(%r)', self) │ │ │ │ │ + try: │ │ │ │ │ + self.start() │ │ │ │ │ + # We assume that we are the only ones to write to │ │ │ │ │ + # self._completion_future. If we directly await it, though, it can │ │ │ │ │ + # also have a asyncio.CancelledError posted to it from outside. │ │ │ │ │ + # Shield it to prevent that from happening. │ │ │ │ │ + stderr = await asyncio.shield(self._completion_future) │ │ │ │ │ + logger.debug('_communicate(%r) stderr result is %r', self, stderr) │ │ │ │ │ + finally: │ │ │ │ │ + logger.debug('_communicate finished. Ensuring completion.') │ │ │ │ │ + self.force_completion() │ │ │ │ │ + if not self._end: │ │ │ │ │ + logger.debug('_communicate never saw ferny.end. raising InteractionError.') │ │ │ │ │ + raise InteractionError(stderr.strip()) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def write_askpass_to_tmpdir(tmpdir: str) -> str: │ │ │ │ │ + askpass_path = os.path.join(tmpdir, 'ferny-askpass') │ │ │ │ │ + fd = os.open(askpass_path, os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC | os.O_EXCL | os.O_NOFOLLOW, 0o777) │ │ │ │ │ + try: │ │ │ │ │ + os.write(fd, __loader__.get_data(interaction_client.__file__)) # type: ignore │ │ │ │ │ + finally: │ │ │ │ │ + os.close(fd) │ │ │ │ │ + return askpass_path │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +@contextlib.contextmanager │ │ │ │ │ +def temporary_askpass(**kwargs: Any) -> Generator[str, None, None]: │ │ │ │ │ + with tempfile.TemporaryDirectory(**kwargs) as directory: │ │ │ │ │ + yield write_askpass_to_tmpdir(directory) │ │ │ │ │ +'''.encode('utf-8'), │ │ │ │ │ + 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3 │ │ │ │ │ + │ │ │ │ │ +import array │ │ │ │ │ +import io │ │ │ │ │ +import os │ │ │ │ │ +import socket │ │ │ │ │ +import sys │ │ │ │ │ +from typing import Sequence │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None: │ │ │ │ │ + cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')] │ │ │ │ │ + │ │ │ │ │ + with cmd_write: │ │ │ │ │ + with cmd_read: │ │ │ │ │ + with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock: │ │ │ │ │ + fd_array = array.array('i', (cmd_read.fileno(), *fds)) │ │ │ │ │ + sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)]) │ │ │ │ │ + │ │ │ │ │ + cmd_write.write(repr((command, args))) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int: │ │ │ │ │ + ours, theirs = socket.socketpair() │ │ │ │ │ + │ │ │ │ │ + with theirs: │ │ │ │ │ + command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd)) │ │ │ │ │ + │ │ │ │ │ + with ours: │ │ │ │ │ + return int(ours.recv(16) or b'1') │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def main() -> None: │ │ │ │ │ + if len(sys.argv) == 1: │ │ │ │ │ + command(2, 'ferny.end', []) │ │ │ │ │ + else: │ │ │ │ │ + sys.exit(askpass(2, 1, sys.argv, dict(os.environ))) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +if __name__ == '__main__': │ │ │ │ │ + main() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/channels/filesystem.py': r'''# This file is part of Cockpit. │ │ │ │ │ + 'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main │ │ │ │ │ + │ │ │ │ │ +if __name__ == '__main__': │ │ │ │ │ + main() │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import asyncio │ │ │ │ │ -import contextlib │ │ │ │ │ -import enum │ │ │ │ │ +import ctypes │ │ │ │ │ import errno │ │ │ │ │ -import fnmatch │ │ │ │ │ -import functools │ │ │ │ │ -import grp │ │ │ │ │ -import logging │ │ │ │ │ import os │ │ │ │ │ -import pwd │ │ │ │ │ -import random │ │ │ │ │ -import stat │ │ │ │ │ -from typing import Callable, Iterable │ │ │ │ │ - │ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Handle, PathWatch │ │ │ │ │ -from cockpit._vendor.systemd_ctypes.inotify import Event as InotifyEvent │ │ │ │ │ -from cockpit._vendor.systemd_ctypes.pathwatch import Listener as PathWatchListener │ │ │ │ │ +import re │ │ │ │ │ +import socket │ │ │ │ │ +from typing import ClassVar, Iterable, Match, Pattern │ │ │ │ │ │ │ │ │ │ -from ..channel import Channel, ChannelError, GeneratorChannel │ │ │ │ │ -from ..jsonutil import ( │ │ │ │ │ - JsonDict, │ │ │ │ │ - JsonDocument, │ │ │ │ │ - JsonError, │ │ │ │ │ - JsonObject, │ │ │ │ │ - get_bool, │ │ │ │ │ - get_int, │ │ │ │ │ - get_str, │ │ │ │ │ - get_strv, │ │ │ │ │ - json_merge_and_filter_patch, │ │ │ │ │ -) │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ +class SshError(Exception): │ │ │ │ │ + PATTERN: ClassVar[Pattern] │ │ │ │ │ │ │ │ │ │ + def __init__(self, match: 'Match | None', stderr: str) -> None: │ │ │ │ │ + super().__init__(match.group(0) if match is not None else stderr) │ │ │ │ │ + self.stderr = stderr │ │ │ │ │ │ │ │ │ │ -def tag_from_stat(buf): │ │ │ │ │ - return f'1:{buf.st_ino}-{buf.st_mtime}' │ │ │ │ │ │ │ │ │ │ +class SshAuthenticationError(SshError): │ │ │ │ │ + PATTERN = re.compile(r'^([^:]+): Permission denied \(([^()]+)\)\.$', re.M) │ │ │ │ │ │ │ │ │ │ -def tag_from_path(path): │ │ │ │ │ - try: │ │ │ │ │ - return tag_from_stat(os.stat(path)) │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - return '-' │ │ │ │ │ - except OSError: │ │ │ │ │ - return None │ │ │ │ │ + def __init__(self, match: Match, stderr: str) -> None: │ │ │ │ │ + super().__init__(match, stderr) │ │ │ │ │ + self.destination = match.group(1) │ │ │ │ │ + self.methods = match.group(2).split(',') │ │ │ │ │ + self.message = match.group(0) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def tag_from_fd(fd): │ │ │ │ │ - try: │ │ │ │ │ - return tag_from_stat(os.fstat(fd)) │ │ │ │ │ - except OSError: │ │ │ │ │ - return None │ │ │ │ │ +# generic host key error for OSes without KnownHostsCommand support │ │ │ │ │ +class SshHostKeyError(SshError): │ │ │ │ │ + PATTERN = re.compile(r'^Host key verification failed.$', re.M) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class FsListChannel(Channel): │ │ │ │ │ - payload = 'fslist1' │ │ │ │ │ +# specific errors for OSes with KnownHostsCommand │ │ │ │ │ +class SshUnknownHostKeyError(SshHostKeyError): │ │ │ │ │ + PATTERN = re.compile(r'^No .* host key is known.*Host key verification failed.$', re.S | re.M) │ │ │ │ │ │ │ │ │ │ - def send_entry(self, event, entry): │ │ │ │ │ - if entry.is_symlink(): │ │ │ │ │ - mode = 'link' │ │ │ │ │ - elif entry.is_file(): │ │ │ │ │ - mode = 'file' │ │ │ │ │ - elif entry.is_dir(): │ │ │ │ │ - mode = 'directory' │ │ │ │ │ - else: │ │ │ │ │ - mode = 'special' │ │ │ │ │ │ │ │ │ │ - self.send_json(event=event, path=entry.name, type=mode) │ │ │ │ │ +class SshChangedHostKeyError(SshHostKeyError): │ │ │ │ │ + PATTERN = re.compile(r'warning.*remote host identification has changed', re.I) │ │ │ │ │ │ │ │ │ │ - def do_open(self, options): │ │ │ │ │ - path = options.get('path') │ │ │ │ │ - watch = options.get('watch', True) │ │ │ │ │ │ │ │ │ │ - if watch: │ │ │ │ │ - raise ChannelError('not-supported', message='watching is not implemented, use fswatch1') │ │ │ │ │ +# Functionality for mapping getaddrinfo()-family error messages to their │ │ │ │ │ +# equivalent Python exceptions. │ │ │ │ │ +def make_gaierror_map() -> 'Iterable[tuple[str, int]]': │ │ │ │ │ + libc = ctypes.CDLL(None) │ │ │ │ │ + libc.gai_strerror.restype = ctypes.c_char_p │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - scan_dir = os.scandir(path) │ │ │ │ │ - except FileNotFoundError as error: │ │ │ │ │ - raise ChannelError('not-found', message=str(error)) from error │ │ │ │ │ - except PermissionError as error: │ │ │ │ │ - raise ChannelError('access-denied', message=str(error)) from error │ │ │ │ │ - except OSError as error: │ │ │ │ │ - raise ChannelError('internal-error', message=str(error)) from error │ │ │ │ │ + for key in dir(socket): │ │ │ │ │ + if key.startswith('EAI_'): │ │ │ │ │ + errnum = getattr(socket, key) │ │ │ │ │ + yield libc.gai_strerror(errnum).decode('utf-8'), errnum │ │ │ │ │ │ │ │ │ │ - self.ready() │ │ │ │ │ - for entry in scan_dir: │ │ │ │ │ - self.send_entry("present", entry) │ │ │ │ │ │ │ │ │ │ - if not watch: │ │ │ │ │ - self.done() │ │ │ │ │ - self.close() │ │ │ │ │ +gaierror_map = dict(make_gaierror_map()) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class FsReadChannel(GeneratorChannel): │ │ │ │ │ - payload = 'fsread1' │ │ │ │ │ +# Functionality for passing strerror() error messages to their equivalent │ │ │ │ │ +# Python exceptions. │ │ │ │ │ +# There doesn't seem to be an official API for turning an errno into the │ │ │ │ │ +# correct subtype of OSError, and the list that cpython uses is hidden fairly │ │ │ │ │ +# deeply inside of the implementation. This is basically copied from the │ │ │ │ │ +# ADD_ERRNO() lines in _PyExc_InitState in cpython/Objects/exceptions.c │ │ │ │ │ +oserror_subclass_map = dict((errnum, cls) for cls, errnum in [ │ │ │ │ │ + (BlockingIOError, errno.EAGAIN), │ │ │ │ │ + (BlockingIOError, errno.EALREADY), │ │ │ │ │ + (BlockingIOError, errno.EINPROGRESS), │ │ │ │ │ + (BlockingIOError, errno.EWOULDBLOCK), │ │ │ │ │ + (BrokenPipeError, errno.EPIPE), │ │ │ │ │ + (BrokenPipeError, errno.ESHUTDOWN), │ │ │ │ │ + (ChildProcessError, errno.ECHILD), │ │ │ │ │ + (ConnectionAbortedError, errno.ECONNABORTED), │ │ │ │ │ + (ConnectionRefusedError, errno.ECONNREFUSED), │ │ │ │ │ + (ConnectionResetError, errno.ECONNRESET), │ │ │ │ │ + (FileExistsError, errno.EEXIST), │ │ │ │ │ + (FileNotFoundError, errno.ENOENT), │ │ │ │ │ + (IsADirectoryError, errno.EISDIR), │ │ │ │ │ + (NotADirectoryError, errno.ENOTDIR), │ │ │ │ │ + (InterruptedError, errno.EINTR), │ │ │ │ │ + (PermissionError, errno.EACCES), │ │ │ │ │ + (PermissionError, errno.EPERM), │ │ │ │ │ + (ProcessLookupError, errno.ESRCH), │ │ │ │ │ + (TimeoutError, errno.ETIMEDOUT), │ │ │ │ │ +]) │ │ │ │ │ │ │ │ │ │ - def do_yield_data(self, options: JsonObject) -> GeneratorChannel.DataGenerator: │ │ │ │ │ - path = get_str(options, 'path') │ │ │ │ │ - binary = get_str(options, 'binary', None) │ │ │ │ │ - max_read_size = get_int(options, 'max_read_size', None) │ │ │ │ │ │ │ │ │ │ - logger.debug('Opening file "%s" for reading', path) │ │ │ │ │ - │ │ │ │ │ - try: │ │ │ │ │ - with open(path, 'rb') as filep: │ │ │ │ │ - buf = os.stat(filep.fileno()) │ │ │ │ │ - if max_read_size is not None and buf.st_size > max_read_size: │ │ │ │ │ - raise ChannelError('too-large') │ │ │ │ │ +def get_exception_for_ssh_stderr(stderr: str) -> Exception: │ │ │ │ │ + stderr = stderr.replace('\r\n', '\n') # fix line separators │ │ │ │ │ │ │ │ │ │ - if binary and stat.S_ISREG(buf.st_mode): │ │ │ │ │ - self.ready(size_hint=buf.st_size) │ │ │ │ │ - else: │ │ │ │ │ - self.ready() │ │ │ │ │ + # check for the specific error messages first, then for generic SshHostKeyError │ │ │ │ │ + for ssh_cls in [SshAuthenticationError, SshChangedHostKeyError, SshUnknownHostKeyError, SshHostKeyError]: │ │ │ │ │ + match = ssh_cls.PATTERN.search(stderr) │ │ │ │ │ + if match is not None: │ │ │ │ │ + return ssh_cls(match, stderr) │ │ │ │ │ │ │ │ │ │ - while True: │ │ │ │ │ - data = filep.read1(Channel.BLOCK_SIZE) │ │ │ │ │ - if data == b'': │ │ │ │ │ - break │ │ │ │ │ - logger.debug(' ...sending %d bytes', len(data)) │ │ │ │ │ - if not binary: │ │ │ │ │ - data = data.replace(b'\0', b'').decode('utf-8', errors='ignore').encode('utf-8') │ │ │ │ │ - yield data │ │ │ │ │ + before, colon, after = stderr.rpartition(':') │ │ │ │ │ + if colon and after: │ │ │ │ │ + potential_strerror = after.strip() │ │ │ │ │ │ │ │ │ │ - return {'tag': tag_from_stat(buf)} │ │ │ │ │ + # DNS lookup errors │ │ │ │ │ + if potential_strerror in gaierror_map: │ │ │ │ │ + errnum = gaierror_map[potential_strerror] │ │ │ │ │ + return socket.gaierror(errnum, stderr) │ │ │ │ │ │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - return {'tag': '-'} │ │ │ │ │ - except PermissionError as exc: │ │ │ │ │ - raise ChannelError('access-denied') from exc │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - raise ChannelError('internal-error', message=str(exc)) from exc │ │ │ │ │ + # Network connect errors │ │ │ │ │ + for errnum in errno.errorcode: │ │ │ │ │ + if os.strerror(errnum) == potential_strerror: │ │ │ │ │ + os_cls = oserror_subclass_map.get(errnum, OSError) │ │ │ │ │ + return os_cls(errnum, stderr) │ │ │ │ │ │ │ │ │ │ + # No match? Generic. │ │ │ │ │ + return SshError(None, stderr) │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/ferny/py.typed': br'''''', │ │ │ │ │ + 'cockpit/_vendor/ferny/session.py': r'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -class FsReplaceChannel(Channel): │ │ │ │ │ - payload = 'fsreplace1' │ │ │ │ │ +import asyncio │ │ │ │ │ +import ctypes │ │ │ │ │ +import functools │ │ │ │ │ +import logging │ │ │ │ │ +import os │ │ │ │ │ +import shlex │ │ │ │ │ +import signal │ │ │ │ │ +import subprocess │ │ │ │ │ +import tempfile │ │ │ │ │ +from typing import Mapping, Sequence │ │ │ │ │ │ │ │ │ │ - _path = None │ │ │ │ │ - _tag = None │ │ │ │ │ - _tempfile = None │ │ │ │ │ - _temppath = None │ │ │ │ │ +from . import ssh_errors │ │ │ │ │ +from .interaction_agent import InteractionAgent, InteractionError, InteractionHandler, write_askpass_to_tmpdir │ │ │ │ │ │ │ │ │ │ - def unlink_temppath(self): │ │ │ │ │ - try: │ │ │ │ │ - os.unlink(self._temppath) │ │ │ │ │ - except OSError: │ │ │ │ │ - pass # might have been removed from outside │ │ │ │ │ +prctl = ctypes.cdll.LoadLibrary('libc.so.6').prctl │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ +PR_SET_PDEATHSIG = 1 │ │ │ │ │ │ │ │ │ │ - def do_open(self, options): │ │ │ │ │ - self._path = options.get('path') │ │ │ │ │ - self._tag = options.get('tag') │ │ │ │ │ - self.ready() │ │ │ │ │ │ │ │ │ │ - def do_data(self, data): │ │ │ │ │ - if self._tempfile is None: │ │ │ │ │ - # keep this bounded, in case anything unexpected goes wrong │ │ │ │ │ - for _ in range(10): │ │ │ │ │ - suffix = ''.join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789_", k=6)) │ │ │ │ │ - self._temppath = f'{self._path}.cockpit-tmp.{suffix}' │ │ │ │ │ - try: │ │ │ │ │ - fd = os.open(self._temppath, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o666) │ │ │ │ │ - break │ │ │ │ │ - except FileExistsError: │ │ │ │ │ - continue │ │ │ │ │ - except PermissionError as exc: │ │ │ │ │ - raise ChannelError('access-denied') from exc │ │ │ │ │ - except FileNotFoundError as exc: │ │ │ │ │ - # directory of path does not exist │ │ │ │ │ - raise ChannelError('not-found') from exc │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - raise ChannelError('internal-error', message=str(exc)) from exc │ │ │ │ │ - else: │ │ │ │ │ - raise ChannelError('internal-error', │ │ │ │ │ - message=f"Could not find unique file name for replacing {self._path}") │ │ │ │ │ +@functools.lru_cache() │ │ │ │ │ +def has_feature(feature: str, teststr: str = 'x') -> bool: │ │ │ │ │ + try: │ │ │ │ │ + subprocess.check_output(['ssh', f'-o{feature} {teststr}', '-G', 'nonexisting'], stderr=subprocess.DEVNULL) │ │ │ │ │ + return True │ │ │ │ │ + except subprocess.CalledProcessError: │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - self._tempfile = os.fdopen(fd, 'wb') │ │ │ │ │ - except OSError: │ │ │ │ │ - # Should Not Happen™, but let's be safe and avoid fd leak │ │ │ │ │ - os.close(fd) │ │ │ │ │ - self.unlink_temppath() │ │ │ │ │ - raise │ │ │ │ │ │ │ │ │ │ - self._tempfile.write(data) │ │ │ │ │ +class SubprocessContext: │ │ │ │ │ + def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]: │ │ │ │ │ + """Return the args required to launch a process in the given context. │ │ │ │ │ │ │ │ │ │ - def do_done(self): │ │ │ │ │ - if self._tempfile is None: │ │ │ │ │ - try: │ │ │ │ │ - os.unlink(self._path) │ │ │ │ │ - # crash on other errors, as they are unexpected │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - pass │ │ │ │ │ - else: │ │ │ │ │ - self._tempfile.flush() │ │ │ │ │ + For example, this might return a vector with │ │ │ │ │ + ["sudo"] │ │ │ │ │ + or │ │ │ │ │ + ["flatpak-spawn", "--host"] │ │ │ │ │ + prepended. │ │ │ │ │ │ │ │ │ │ - if self._tag and self._tag != tag_from_path(self._path): │ │ │ │ │ - raise ChannelError('change-conflict') │ │ │ │ │ + It is also possible that more substantial changes may be performed. │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - os.rename(self._temppath, self._path) │ │ │ │ │ - # ensure to not leave the temp file behind │ │ │ │ │ - except FileNotFoundError as exc: │ │ │ │ │ - self.unlink_temppath() │ │ │ │ │ - raise ChannelError('not-found', message=str(exc)) from exc │ │ │ │ │ - except IsADirectoryError as exc: │ │ │ │ │ - self.unlink_temppath() │ │ │ │ │ - # not ideal, but the closest code we have │ │ │ │ │ - raise ChannelError('access-denied', message=str(exc)) from exc │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - self.unlink_temppath() │ │ │ │ │ - raise ChannelError('internal-error', message=str(exc)) from exc │ │ │ │ │ + This function is not permitted to modify its argument, although it may │ │ │ │ │ + (optionally) return it unmodified, if no changes are required. │ │ │ │ │ + """ │ │ │ │ │ + return args │ │ │ │ │ │ │ │ │ │ - self._tempfile.close() │ │ │ │ │ - self._tempfile = None │ │ │ │ │ + def wrap_subprocess_env(self, env: Mapping[str, str]) -> Mapping[str, str]: │ │ │ │ │ + """Return the envp required to launch a process in the given context. │ │ │ │ │ │ │ │ │ │ - self.done() │ │ │ │ │ - self.close({'tag': tag_from_path(self._path)}) │ │ │ │ │ + For example, this might set the "SUDO_ASKPASS" environment variable, if │ │ │ │ │ + needed. │ │ │ │ │ │ │ │ │ │ - def do_close(self): │ │ │ │ │ - if self._tempfile is not None: │ │ │ │ │ - self._tempfile.close() │ │ │ │ │ - self.unlink_temppath() │ │ │ │ │ - self._tempfile = None │ │ │ │ │ + As with wrap_subprocess_args(), this function is not permitted to │ │ │ │ │ + modify its argument, although it may (optionally) return it unmodified │ │ │ │ │ + if no changes are required. │ │ │ │ │ + """ │ │ │ │ │ + return env │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class FsWatchChannel(Channel): │ │ │ │ │ - payload = 'fswatch1' │ │ │ │ │ - _tag = None │ │ │ │ │ - _path = None │ │ │ │ │ - _watch = None │ │ │ │ │ +class Session(SubprocessContext, InteractionHandler): │ │ │ │ │ + # Set after .connect() called, even if failed │ │ │ │ │ + _controldir: 'tempfile.TemporaryDirectory | None' = None │ │ │ │ │ + _controlsock: 'str | None' = None │ │ │ │ │ │ │ │ │ │ - # The C bridge doesn't send the initial event, and the JS calls read() │ │ │ │ │ - # instead to figure out the initial state of the file. If we send the │ │ │ │ │ - # initial state then we cause the event to get delivered twice. │ │ │ │ │ - # Ideally we'll sort that out at some point, but for now, suppress it. │ │ │ │ │ - _active = False │ │ │ │ │ + # Set if connected, else None │ │ │ │ │ + _process: 'asyncio.subprocess.Process | None' = None │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def mask_to_event_and_type(mask): │ │ │ │ │ - if (InotifyEvent.CREATE or InotifyEvent.MOVED_TO) in mask: │ │ │ │ │ - return 'created', 'directory' if InotifyEvent.ISDIR in mask else 'file' │ │ │ │ │ - elif InotifyEvent.MOVED_FROM in mask or InotifyEvent.DELETE in mask or InotifyEvent.DELETE_SELF in mask: │ │ │ │ │ - return 'deleted', None │ │ │ │ │ - elif InotifyEvent.ATTRIB in mask: │ │ │ │ │ - return 'attribute-changed', None │ │ │ │ │ - elif InotifyEvent.CLOSE_WRITE in mask: │ │ │ │ │ - return 'done-hint', None │ │ │ │ │ - else: │ │ │ │ │ - return 'changed', None │ │ │ │ │ + async def connect(self, │ │ │ │ │ + destination: str, │ │ │ │ │ + handle_host_key: bool = False, │ │ │ │ │ + configfile: 'str | None' = None, │ │ │ │ │ + identity_file: 'str | None' = None, │ │ │ │ │ + login_name: 'str | None' = None, │ │ │ │ │ + options: 'Mapping[str, str] | None' = None, │ │ │ │ │ + pkcs11: 'str | None' = None, │ │ │ │ │ + port: 'int | None' = None, │ │ │ │ │ + interaction_responder: 'InteractionHandler | None' = None) -> None: │ │ │ │ │ + rundir = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/run'), 'ferny') │ │ │ │ │ + os.makedirs(rundir, exist_ok=True) │ │ │ │ │ + self._controldir = tempfile.TemporaryDirectory(dir=rundir) │ │ │ │ │ + self._controlsock = f'{self._controldir.name}/socket' │ │ │ │ │ │ │ │ │ │ - def do_inotify_event(self, mask, _cookie, name): │ │ │ │ │ - logger.debug("do_inotify_event(%s): mask %X name %s", self._path, mask, name) │ │ │ │ │ - event, type_ = self.mask_to_event_and_type(mask) │ │ │ │ │ - if name: │ │ │ │ │ - # file inside watched directory changed │ │ │ │ │ - path = os.path.join(self._path, name.decode()) │ │ │ │ │ - tag = tag_from_path(path) │ │ │ │ │ - self.send_json(event=event, path=path, tag=tag, type=type_) │ │ │ │ │ - else: │ │ │ │ │ - # the watched path itself changed; filter out duplicate events │ │ │ │ │ - tag = tag_from_path(self._path) │ │ │ │ │ - if tag == self._tag: │ │ │ │ │ - return │ │ │ │ │ - self._tag = tag │ │ │ │ │ - self.send_json(event=event, path=self._path, tag=self._tag, type=type_) │ │ │ │ │ + # In general, we can't guarantee an accessible and executable version │ │ │ │ │ + # of this file, but since it's small and we're making a temporary │ │ │ │ │ + # directory anyway, let's just copy it into place and use it from │ │ │ │ │ + # there. │ │ │ │ │ + askpass_path = write_askpass_to_tmpdir(self._controldir.name) │ │ │ │ │ │ │ │ │ │ - def do_identity_changed(self, fd, err): │ │ │ │ │ - logger.debug("do_identity_changed(%s): fd %s, err %s", self._path, str(fd), err) │ │ │ │ │ - self._tag = tag_from_fd(fd) if fd else '-' │ │ │ │ │ - if self._active: │ │ │ │ │ - self.send_json(event='created' if fd else 'deleted', path=self._path, tag=self._tag) │ │ │ │ │ + env = dict(os.environ) │ │ │ │ │ + env['SSH_ASKPASS'] = askpass_path │ │ │ │ │ + env['SSH_ASKPASS_REQUIRE'] = 'force' │ │ │ │ │ + # old SSH doesn't understand SSH_ASKPASS_REQUIRE and guesses based on DISPLAY instead │ │ │ │ │ + env['DISPLAY'] = '-' │ │ │ │ │ │ │ │ │ │ - def do_open(self, options): │ │ │ │ │ - self._path = options['path'] │ │ │ │ │ - self._tag = None │ │ │ │ │ + args = [ │ │ │ │ │ + '-M', │ │ │ │ │ + '-N', │ │ │ │ │ + '-S', self._controlsock, │ │ │ │ │ + '-o', 'PermitLocalCommand=yes', │ │ │ │ │ + '-o', f'LocalCommand={askpass_path}', │ │ │ │ │ + ] │ │ │ │ │ │ │ │ │ │ - self._active = False │ │ │ │ │ - self._watch = PathWatch(self._path, self) │ │ │ │ │ - self._active = True │ │ │ │ │ + if configfile is not None: │ │ │ │ │ + args.append(f'-F{configfile}') │ │ │ │ │ │ │ │ │ │ - self.ready() │ │ │ │ │ + if identity_file is not None: │ │ │ │ │ + args.append(f'-i{identity_file}') │ │ │ │ │ │ │ │ │ │ - def do_close(self): │ │ │ │ │ - self._watch.close() │ │ │ │ │ - self._watch = None │ │ │ │ │ - self.close() │ │ │ │ │ + if options is not None: │ │ │ │ │ + for key in options: # Note: Mapping may not have .items() │ │ │ │ │ + args.append(f'-o{key} {options[key]}') │ │ │ │ │ │ │ │ │ │ + if pkcs11 is not None: │ │ │ │ │ + args.append(f'-I{pkcs11}') │ │ │ │ │ │ │ │ │ │ -class Follow(enum.Enum): │ │ │ │ │ - NO = False │ │ │ │ │ - YES = True │ │ │ │ │ + if port is not None: │ │ │ │ │ + args.append(f'-p{port}') │ │ │ │ │ │ │ │ │ │ + if login_name is not None: │ │ │ │ │ + args.append(f'-l{login_name}') │ │ │ │ │ │ │ │ │ │ -class FsInfoChannel(Channel, PathWatchListener): │ │ │ │ │ - payload = 'fsinfo' │ │ │ │ │ + if handle_host_key and has_feature('KnownHostsCommand'): │ │ │ │ │ + args.extend([ │ │ │ │ │ + '-o', f'KnownHostsCommand={askpass_path} %I %H %t %K %f', │ │ │ │ │ + '-o', 'StrictHostKeyChecking=yes', │ │ │ │ │ + ]) │ │ │ │ │ │ │ │ │ │ - # Options (all get set in `do_open()`) │ │ │ │ │ - path: str │ │ │ │ │ - attrs: 'set[str]' │ │ │ │ │ - fnmatch: str │ │ │ │ │ - targets: bool │ │ │ │ │ - follow: bool │ │ │ │ │ - watch: bool │ │ │ │ │ + agent = InteractionAgent([interaction_responder] if interaction_responder is not None else []) │ │ │ │ │ │ │ │ │ │ - # State │ │ │ │ │ - current_value: JsonDict │ │ │ │ │ - effective_fnmatch: str = '' │ │ │ │ │ - fd: 'Handle | None' = None │ │ │ │ │ - pending: 'set[str] | None' = None │ │ │ │ │ - path_watch: 'PathWatch | None' = None │ │ │ │ │ - getattrs: 'Callable[[int, str, Follow], JsonDocument]' │ │ │ │ │ + # SSH_ASKPASS_REQUIRE is not generally available, so use setsid │ │ │ │ │ + process = await asyncio.create_subprocess_exec( │ │ │ │ │ + *('/usr/bin/ssh', *args, destination), env=env, │ │ │ │ │ + start_new_session=True, stdin=asyncio.subprocess.DEVNULL, │ │ │ │ │ + stdout=asyncio.subprocess.DEVNULL, stderr=agent, # type: ignore │ │ │ │ │ + preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL)) │ │ │ │ │ │ │ │ │ │ - @staticmethod │ │ │ │ │ - def make_getattrs(attrs: Iterable[str]) -> 'Callable[[int, str, Follow], JsonDocument | None]': │ │ │ │ │ - # Cached for the duration of the closure we're creating │ │ │ │ │ - @functools.lru_cache() │ │ │ │ │ - def get_user(uid: int) -> 'str | int': │ │ │ │ │ + # This is tricky: we need to clean up the subprocess, but only in case │ │ │ │ │ + # if failure. Otherwise, we keep it around. │ │ │ │ │ + try: │ │ │ │ │ + await agent.communicate() │ │ │ │ │ + assert os.path.exists(self._controlsock) │ │ │ │ │ + self._process = process │ │ │ │ │ + except InteractionError as exc: │ │ │ │ │ + await process.wait() │ │ │ │ │ + raise ssh_errors.get_exception_for_ssh_stderr(str(exc)) from None │ │ │ │ │ + except BaseException: │ │ │ │ │ + # If we get here because the InteractionHandler raised an │ │ │ │ │ + # exception then SSH might still be running, and may even attempt │ │ │ │ │ + # further interactions (ie: 2nd attempt for password). We already │ │ │ │ │ + # have our exception and don't need any more info. Kill it. │ │ │ │ │ try: │ │ │ │ │ - return pwd.getpwuid(uid).pw_name │ │ │ │ │ - except KeyError: │ │ │ │ │ - return uid │ │ │ │ │ + process.kill() │ │ │ │ │ + except ProcessLookupError: │ │ │ │ │ + pass # already exited? good. │ │ │ │ │ + await process.wait() │ │ │ │ │ + raise │ │ │ │ │ │ │ │ │ │ - @functools.lru_cache() │ │ │ │ │ - def get_group(gid: int) -> 'str | int': │ │ │ │ │ - try: │ │ │ │ │ - return grp.getgrgid(gid).gr_name │ │ │ │ │ - except KeyError: │ │ │ │ │ - return gid │ │ │ │ │ + def is_connected(self) -> bool: │ │ │ │ │ + return self._process is not None │ │ │ │ │ │ │ │ │ │ - stat_types = {stat.S_IFREG: 'reg', stat.S_IFDIR: 'dir', stat.S_IFLNK: 'lnk', stat.S_IFCHR: 'chr', │ │ │ │ │ - stat.S_IFBLK: 'blk', stat.S_IFIFO: 'fifo', stat.S_IFSOCK: 'sock'} │ │ │ │ │ - available_stat_getters = { │ │ │ │ │ - 'type': lambda buf: stat_types.get(stat.S_IFMT(buf.st_mode)), │ │ │ │ │ - 'tag': tag_from_stat, │ │ │ │ │ - 'mode': lambda buf: stat.S_IMODE(buf.st_mode), │ │ │ │ │ - 'size': lambda buf: buf.st_size, │ │ │ │ │ - 'uid': lambda buf: buf.st_uid, │ │ │ │ │ - 'gid': lambda buf: buf.st_gid, │ │ │ │ │ - 'mtime': lambda buf: buf.st_mtime, │ │ │ │ │ - 'user': lambda buf: get_user(buf.st_uid), │ │ │ │ │ - 'group': lambda buf: get_group(buf.st_gid), │ │ │ │ │ - } │ │ │ │ │ - stat_getters = tuple((key, available_stat_getters.get(key, lambda _: None)) for key in attrs) │ │ │ │ │ + async def wait(self) -> None: │ │ │ │ │ + assert self._process is not None │ │ │ │ │ + await self._process.wait() │ │ │ │ │ │ │ │ │ │ - def get_attrs(fd: int, name: str, follow: Follow) -> 'JsonDict | None': │ │ │ │ │ - try: │ │ │ │ │ - buf = os.stat(name, follow_symlinks=follow.value, dir_fd=fd) if name else os.fstat(fd) │ │ │ │ │ - except FileNotFoundError: │ │ │ │ │ - return None │ │ │ │ │ - except OSError: │ │ │ │ │ - return {name: None for name, func in stat_getters} │ │ │ │ │ + def exit(self) -> None: │ │ │ │ │ + assert self._process is not None │ │ │ │ │ + self._process.terminate() │ │ │ │ │ │ │ │ │ │ - result = {key: func(buf) for key, func in stat_getters} │ │ │ │ │ + async def disconnect(self) -> None: │ │ │ │ │ + self.exit() │ │ │ │ │ + await self.wait() │ │ │ │ │ │ │ │ │ │ - if 'target' in result and stat.S_IFMT(buf.st_mode) == stat.S_IFLNK: │ │ │ │ │ - with contextlib.suppress(OSError): │ │ │ │ │ - result['target'] = os.readlink(name, dir_fd=fd) │ │ │ │ │ + # Launching of processes │ │ │ │ │ + def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]: │ │ │ │ │ + assert self._controlsock is not None │ │ │ │ │ + # 1. We specify the hostname as the empty string: it will be ignored │ │ │ │ │ + # when ssh is trying to use the control socket, but in case the │ │ │ │ │ + # socket has stopped working, ssh will try to fall back to directly │ │ │ │ │ + # connecting, in which case an empty hostname will prevent that. │ │ │ │ │ + # 2. We need to quote the arguments — ssh will paste them together │ │ │ │ │ + # using only spaces, executing the result using the user's shell. │ │ │ │ │ + return ('ssh', '-S', self._controlsock, '', *map(shlex.quote, args)) │ │ │ │ │ +'''.encode('utf-8'), │ │ │ │ │ + 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import ( │ │ │ │ │ + BEIBOOT_GADGETS, │ │ │ │ │ + COMMAND_TEMPLATE, │ │ │ │ │ + AskpassHandler, │ │ │ │ │ + InteractionAgent, │ │ │ │ │ + InteractionError, │ │ │ │ │ + InteractionHandler, │ │ │ │ │ + temporary_askpass, │ │ │ │ │ + write_askpass_to_tmpdir, │ │ │ │ │ +) │ │ │ │ │ +from .session import Session │ │ │ │ │ +from .ssh_askpass import ( │ │ │ │ │ + AskpassPrompt, │ │ │ │ │ + SshAskpassResponder, │ │ │ │ │ + SshFIDOPINPrompt, │ │ │ │ │ + SshFIDOUserPresencePrompt, │ │ │ │ │ + SshHostKeyPrompt, │ │ │ │ │ + SshPassphrasePrompt, │ │ │ │ │ + SshPasswordPrompt, │ │ │ │ │ + SshPKCS11PINPrompt, │ │ │ │ │ +) │ │ │ │ │ +from .ssh_errors import ( │ │ │ │ │ + SshAuthenticationError, │ │ │ │ │ + SshChangedHostKeyError, │ │ │ │ │ + SshError, │ │ │ │ │ + SshHostKeyError, │ │ │ │ │ + SshUnknownHostKeyError, │ │ │ │ │ +) │ │ │ │ │ +from .transport import FernyTransport, SubprocessError │ │ │ │ │ │ │ │ │ │ - return result │ │ │ │ │ +__all__ = [ │ │ │ │ │ + 'AskpassHandler', │ │ │ │ │ + 'AskpassPrompt', │ │ │ │ │ + 'AuthenticationError', │ │ │ │ │ + 'BEIBOOT_GADGETS', │ │ │ │ │ + 'COMMAND_TEMPLATE', │ │ │ │ │ + 'ChangedHostKeyError', │ │ │ │ │ + 'FernyTransport', │ │ │ │ │ + 'HostKeyError', │ │ │ │ │ + 'InteractionAgent', │ │ │ │ │ + 'InteractionError', │ │ │ │ │ + 'InteractionHandler', │ │ │ │ │ + 'Session', │ │ │ │ │ + 'SshAskpassResponder', │ │ │ │ │ + 'SshAuthenticationError', │ │ │ │ │ + 'SshChangedHostKeyError', │ │ │ │ │ + 'SshError', │ │ │ │ │ + 'SshFIDOPINPrompt', │ │ │ │ │ + 'SshFIDOUserPresencePrompt', │ │ │ │ │ + 'SshHostKeyError', │ │ │ │ │ + 'SshHostKeyPrompt', │ │ │ │ │ + 'SshPKCS11PINPrompt', │ │ │ │ │ + 'SshPassphrasePrompt', │ │ │ │ │ + 'SshPasswordPrompt', │ │ │ │ │ + 'SshUnknownHostKeyError', │ │ │ │ │ + 'SubprocessError', │ │ │ │ │ + 'temporary_askpass', │ │ │ │ │ + 'write_askpass_to_tmpdir', │ │ │ │ │ +] │ │ │ │ │ │ │ │ │ │ - return get_attrs │ │ │ │ │ +__version__ = '0' │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging │ │ │ │ │ +import re │ │ │ │ │ +from typing import ClassVar, Match, Sequence │ │ │ │ │ │ │ │ │ │ - def send_update(self, updates: JsonDict, *, reset: bool = False) -> None: │ │ │ │ │ - if reset: │ │ │ │ │ - if set(self.current_value) & set(updates): │ │ │ │ │ - # if we have an overlap, we need to do a proper reset │ │ │ │ │ - self.send_json({name: None for name in self.current_value}, partial=True) │ │ │ │ │ - self.current_value = {'partial': True} │ │ │ │ │ - updates.update(partial=None) │ │ │ │ │ - else: │ │ │ │ │ - # otherwise there's no overlap: we can just remove the old keys │ │ │ │ │ - updates.update({key: None for key in self.current_value}) │ │ │ │ │ +from .interaction_agent import AskpassHandler │ │ │ │ │ │ │ │ │ │ - json_merge_and_filter_patch(self.current_value, updates) │ │ │ │ │ - if updates: │ │ │ │ │ - self.send_json(updates) │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ - def process_update(self, updates: 'set[str]', *, reset: bool = False) -> None: │ │ │ │ │ - assert self.fd is not None │ │ │ │ │ │ │ │ │ │ - entries: JsonDict = {name: self.getattrs(self.fd, name, Follow.NO) for name in updates} │ │ │ │ │ +class AskpassPrompt: │ │ │ │ │ + """An askpass prompt resulting from a call to ferny-askpass. │ │ │ │ │ │ │ │ │ │ - info = entries.pop('', {}) │ │ │ │ │ - assert isinstance(info, dict) # fstat() will never fail with FileNotFoundError │ │ │ │ │ + stderr: the contents of stderr from before ferny-askpass was called. │ │ │ │ │ + Likely related to previous failed operations. │ │ │ │ │ + messages: all but the last line of the prompt as handed to ferny-askpass. │ │ │ │ │ + Usually contains context about the question. │ │ │ │ │ + prompt: the last line handed to ferny-askpass. The prompt itself. │ │ │ │ │ + """ │ │ │ │ │ + stderr: str │ │ │ │ │ + messages: str │ │ │ │ │ + prompt: str │ │ │ │ │ │ │ │ │ │ - if self.effective_fnmatch: │ │ │ │ │ - info['entries'] = entries │ │ │ │ │ + def __init__(self, prompt: str, messages: str, stderr: str) -> None: │ │ │ │ │ + self.stderr = stderr │ │ │ │ │ + self.messages = messages │ │ │ │ │ + self.prompt = prompt │ │ │ │ │ │ │ │ │ │ - if self.targets: │ │ │ │ │ - info['targets'] = targets = {} │ │ │ │ │ - for name in {e.get('target') for e in entries.values() if isinstance(e, dict)}: │ │ │ │ │ - if isinstance(name, str) and ('/' in name or not self.interesting(name)): │ │ │ │ │ - # if this target is a string that we wouldn't otherwise │ │ │ │ │ - # report, then report it via our "targets" attribute. │ │ │ │ │ - targets[name] = self.getattrs(self.fd, name, Follow.YES) │ │ │ │ │ + def reply(self, response: str) -> None: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - self.send_update({'info': info}, reset=reset) │ │ │ │ │ + def close(self) -> None: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - def process_pending_updates(self) -> None: │ │ │ │ │ - assert self.pending is not None │ │ │ │ │ - if self.pending: │ │ │ │ │ - self.process_update(self.pending) │ │ │ │ │ - self.pending = None │ │ │ │ │ + async def handle_via(self, responder: 'SshAskpassResponder') -> None: │ │ │ │ │ + try: │ │ │ │ │ + response = await self.dispatch(responder) │ │ │ │ │ + if response is not None: │ │ │ │ │ + self.reply(response) │ │ │ │ │ + finally: │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - def interesting(self, name: str) -> bool: │ │ │ │ │ - if name == '': │ │ │ │ │ - return True │ │ │ │ │ - else: │ │ │ │ │ - # only report updates on entry filenames if we match them │ │ │ │ │ - return fnmatch.fnmatch(name, self.effective_fnmatch) │ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ + return await responder.do_prompt(self) │ │ │ │ │ │ │ │ │ │ - def schedule_update(self, name: str) -> None: │ │ │ │ │ - if not self.interesting(name): │ │ │ │ │ - return │ │ │ │ │ │ │ │ │ │ - if self.pending is None: │ │ │ │ │ - asyncio.get_running_loop().call_later(0.1, self.process_pending_updates) │ │ │ │ │ - self.pending = set() │ │ │ │ │ +class SSHAskpassPrompt(AskpassPrompt): │ │ │ │ │ + # The valid answers to prompts of this type. If this is None then any │ │ │ │ │ + # answer is permitted. If it's a sequence then only answers from the │ │ │ │ │ + # sequence are permitted. If it's an empty sequence, then no answer is │ │ │ │ │ + # permitted (ie: the askpass callback should never return). │ │ │ │ │ + answers: 'ClassVar[Sequence[str] | None]' = None │ │ │ │ │ │ │ │ │ │ - self.pending.add(name) │ │ │ │ │ + # Patterns to capture. `_pattern` *must* match. │ │ │ │ │ + _pattern: ClassVar[str] │ │ │ │ │ + # `_extra_patterns` can fill in extra class attributes if they match. │ │ │ │ │ + _extra_patterns: ClassVar[Sequence[str]] = () │ │ │ │ │ │ │ │ │ │ - def report_error(self, err: int) -> None: │ │ │ │ │ - if err == errno.ENOENT: │ │ │ │ │ - problem = 'not-found' │ │ │ │ │ - elif err in (errno.EPERM, errno.EACCES): │ │ │ │ │ - problem = 'access-denied' │ │ │ │ │ - elif err == errno.ENOTDIR: │ │ │ │ │ - problem = 'not-directory' │ │ │ │ │ - else: │ │ │ │ │ - problem = 'internal-error' │ │ │ │ │ + def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None: │ │ │ │ │ + super().__init__(prompt, messages, stderr) │ │ │ │ │ + self.__dict__.update(match.groupdict()) │ │ │ │ │ │ │ │ │ │ - self.send_update({'error': { │ │ │ │ │ - 'problem': problem, 'message': os.strerror(err), 'errno': errno.errorcode[err] │ │ │ │ │ - }}, reset=True) │ │ │ │ │ + for pattern in self._extra_patterns: │ │ │ │ │ + extra_match = re.search(with_helpers(pattern), messages, re.M) │ │ │ │ │ + if extra_match is not None: │ │ │ │ │ + self.__dict__.update(extra_match.groupdict()) │ │ │ │ │ │ │ │ │ │ - def flag_onlydir_error(self, fd: Handle) -> bool: │ │ │ │ │ - # If our requested path ended with '/' then make sure we got a │ │ │ │ │ - # directory, or else it's an error. open() will have already flagged │ │ │ │ │ - # that for us, but systemd_ctypes doesn't do that (yet). │ │ │ │ │ - if not self.watch or not self.path.endswith('/'): │ │ │ │ │ - return False │ │ │ │ │ │ │ │ │ │ - buf = os.fstat(fd) # this should never fail │ │ │ │ │ - if stat.S_IFMT(buf.st_mode) != stat.S_IFDIR: │ │ │ │ │ - self.report_error(errno.ENOTDIR) │ │ │ │ │ - return True │ │ │ │ │ +# Specific prompts │ │ │ │ │ +HELPERS = { │ │ │ │ │ + "%{algorithm}": r"(?P\b[-\w]+\b)", │ │ │ │ │ + "%{filename}": r"(?P.+)", │ │ │ │ │ + "%{fingerprint}": r"(?PSHA256:[0-9A-Za-z+/]{43})", │ │ │ │ │ + "%{hostname}": r"(?P[^ @']+)", │ │ │ │ │ + "%{pkcs11_id}": r"(?P.+)", │ │ │ │ │ + "%{username}": r"(?P[^ @']+)", │ │ │ │ │ +} │ │ │ │ │ │ │ │ │ │ - return False │ │ │ │ │ │ │ │ │ │ - def report_initial_state(self, fd: Handle) -> None: │ │ │ │ │ - if self.flag_onlydir_error(fd): │ │ │ │ │ - return │ │ │ │ │ +class SshPasswordPrompt(SSHAskpassPrompt): │ │ │ │ │ + _pattern = r"%{username}@%{hostname}'s password: " │ │ │ │ │ + username: 'str | None' = None │ │ │ │ │ + hostname: 'str | None' = None │ │ │ │ │ │ │ │ │ │ - self.fd = fd │ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ + return await responder.do_password_prompt(self) │ │ │ │ │ │ │ │ │ │ - entries = {''} │ │ │ │ │ - if self.fnmatch: │ │ │ │ │ - try: │ │ │ │ │ - entries.update(os.listdir(f'/proc/self/fd/{self.fd}')) │ │ │ │ │ - self.effective_fnmatch = self.fnmatch │ │ │ │ │ - except OSError: │ │ │ │ │ - # If we failed to get an initial list, then report nothing from now on │ │ │ │ │ - self.effective_fnmatch = '' │ │ │ │ │ │ │ │ │ │ - self.process_update({e for e in entries if self.interesting(e)}, reset=True) │ │ │ │ │ +class SshPassphrasePrompt(SSHAskpassPrompt): │ │ │ │ │ + _pattern = r"Enter passphrase for key '%{filename}': " │ │ │ │ │ + filename: str │ │ │ │ │ │ │ │ │ │ - def do_inotify_event(self, mask: InotifyEvent, cookie: int, rawname: 'bytes | None') -> None: │ │ │ │ │ - logger.debug('do_inotify_event(%r, %r, %r)', mask, cookie, rawname) │ │ │ │ │ - name = (rawname or b'').decode(errors='surrogateescape') │ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ + return await responder.do_passphrase_prompt(self) │ │ │ │ │ │ │ │ │ │ - self.schedule_update(name) │ │ │ │ │ │ │ │ │ │ - if name and mask | (InotifyEvent.CREATE | InotifyEvent.DELETE | │ │ │ │ │ - InotifyEvent.MOVED_TO | InotifyEvent.MOVED_FROM): │ │ │ │ │ - # These events change the mtime of the directory │ │ │ │ │ - self.schedule_update('') │ │ │ │ │ +class SshFIDOPINPrompt(SSHAskpassPrompt): │ │ │ │ │ + _pattern = r"Enter PIN for %{algorithm} key %{filename}: " │ │ │ │ │ + algorithm: str │ │ │ │ │ + filename: str │ │ │ │ │ │ │ │ │ │ - def do_identity_changed(self, fd: 'Handle | None', err: 'int | None') -> None: │ │ │ │ │ - logger.debug('do_identity_changed(%r, %r)', fd, err) │ │ │ │ │ - # If there were previously pending changes, they are now irrelevant. │ │ │ │ │ - if self.pending is not None: │ │ │ │ │ - # Note: don't set to None, since the handler is still pending │ │ │ │ │ - self.pending.clear() │ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ + return await responder.do_fido_pin_prompt(self) │ │ │ │ │ │ │ │ │ │ - if err is None: │ │ │ │ │ - assert fd is not None │ │ │ │ │ - self.report_initial_state(fd) │ │ │ │ │ - else: │ │ │ │ │ - self.report_error(err) │ │ │ │ │ │ │ │ │ │ - def do_close(self) -> None: │ │ │ │ │ - # non-watch channels close immediately — if we get this, we're watching │ │ │ │ │ - assert self.path_watch is not None │ │ │ │ │ - self.path_watch.close() │ │ │ │ │ - self.close() │ │ │ │ │ +class SshFIDOUserPresencePrompt(SSHAskpassPrompt): │ │ │ │ │ + _pattern = r"Confirm user presence for key %{algorithm} %{fingerprint}" │ │ │ │ │ + answers = () │ │ │ │ │ + algorithm: str │ │ │ │ │ + fingerprint: str │ │ │ │ │ │ │ │ │ │ - def do_open(self, options: JsonObject) -> None: │ │ │ │ │ - self.path = get_str(options, 'path') │ │ │ │ │ - if not os.path.isabs(self.path): │ │ │ │ │ - raise JsonError(options, '"path" must be an absolute path') │ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ + return await responder.do_fido_user_presence_prompt(self) │ │ │ │ │ │ │ │ │ │ - attrs = set(get_strv(options, 'attrs')) │ │ │ │ │ - self.getattrs = self.make_getattrs(attrs - {'targets', 'entries'}) │ │ │ │ │ - self.fnmatch = get_str(options, 'fnmatch', '*' if 'entries' in attrs else '') │ │ │ │ │ - self.targets = 'targets' in attrs │ │ │ │ │ - self.follow = get_bool(options, 'follow', default=True) │ │ │ │ │ - self.watch = get_bool(options, 'watch', default=False) │ │ │ │ │ - if self.watch and not self.follow: │ │ │ │ │ - raise JsonError(options, '"watch: true" and "follow: false" are (currently) incompatible') │ │ │ │ │ - if self.targets and not self.follow: │ │ │ │ │ - raise JsonError(options, '`targets: "stat"` and `follow: false` are (currently) incompatible') │ │ │ │ │ │ │ │ │ │ - self.current_value = {} │ │ │ │ │ - self.ready() │ │ │ │ │ +class SshPKCS11PINPrompt(SSHAskpassPrompt): │ │ │ │ │ + _pattern = r"Enter PIN for '%{pkcs11_id}': " │ │ │ │ │ + pkcs11_id: str │ │ │ │ │ │ │ │ │ │ - if not self.watch: │ │ │ │ │ - try: │ │ │ │ │ - fd = Handle.open(self.path, os.O_PATH if self.follow else os.O_PATH | os.O_NOFOLLOW) │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - self.report_error(exc.errno) │ │ │ │ │ - else: │ │ │ │ │ - self.report_initial_state(fd) │ │ │ │ │ - fd.close() │ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ + return await responder.do_pkcs11_pin_prompt(self) │ │ │ │ │ │ │ │ │ │ - self.done() │ │ │ │ │ - self.close() │ │ │ │ │ │ │ │ │ │ - else: │ │ │ │ │ - # PathWatch will call do_identity_changed(), which does the same as │ │ │ │ │ - # above: calls either report_initial_state() or report_error(), │ │ │ │ │ - # depending on if it was provided with an fd or an error code. │ │ │ │ │ - self.path_watch = PathWatch(self.path, self) │ │ │ │ │ -'''.encode('utf-8'), │ │ │ │ │ - 'cockpit/_vendor/__init__.py': br'''''', │ │ │ │ │ - 'cockpit/_vendor/bei/spawn.py': br'''"""Helper to create a beipack to spawn a command with files in a tmpdir""" │ │ │ │ │ +class SshHostKeyPrompt(SSHAskpassPrompt): │ │ │ │ │ + _pattern = r"Are you sure you want to continue connecting \(yes/no(/\[fingerprint\])?\)\? " │ │ │ │ │ + _extra_patterns = [ │ │ │ │ │ + r"%{fingerprint}[.]$", │ │ │ │ │ + r"^%{algorithm} key fingerprint is", │ │ │ │ │ + r"^The fingerprint for the %{algorithm} key sent by the remote host is$" │ │ │ │ │ + ] │ │ │ │ │ + answers = ('yes', 'no') │ │ │ │ │ + algorithm: str │ │ │ │ │ + fingerprint: str │ │ │ │ │ │ │ │ │ │ -import argparse │ │ │ │ │ -import os │ │ │ │ │ -import sys │ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ + return await responder.do_host_key_prompt(self) │ │ │ │ │ │ │ │ │ │ -from . import pack, tmpfs │ │ │ │ │ │ │ │ │ │ +def with_helpers(pattern: str) -> str: │ │ │ │ │ + for name, helper in HELPERS.items(): │ │ │ │ │ + pattern = pattern.replace(name, helper) │ │ │ │ │ │ │ │ │ │ -def main() -> None: │ │ │ │ │ - parser = argparse.ArgumentParser() │ │ │ │ │ - parser.add_argument('--file', '-f', action='append') │ │ │ │ │ - parser.add_argument('command', nargs='+', help='The command to execute') │ │ │ │ │ - args = parser.parse_args() │ │ │ │ │ + assert '%{' not in pattern │ │ │ │ │ + return pattern │ │ │ │ │ │ │ │ │ │ - contents = { │ │ │ │ │ - '_beitmpfs.py': tmpfs.__spec__.loader.get_data(tmpfs.__spec__.origin) │ │ │ │ │ - } │ │ │ │ │ │ │ │ │ │ - if args.file is not None: │ │ │ │ │ - files = args.file │ │ │ │ │ +def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt: │ │ │ │ │ + classes = [ │ │ │ │ │ + SshFIDOPINPrompt, │ │ │ │ │ + SshFIDOUserPresencePrompt, │ │ │ │ │ + SshHostKeyPrompt, │ │ │ │ │ + SshPKCS11PINPrompt, │ │ │ │ │ + SshPassphrasePrompt, │ │ │ │ │ + SshPasswordPrompt, │ │ │ │ │ + ] │ │ │ │ │ + │ │ │ │ │ + # The last line is the line after the last newline character, excluding the │ │ │ │ │ + # optional final newline character. eg: "x\ny\nLAST\n" or "x\ny\nLAST" │ │ │ │ │ + second_last_newline = string.rfind('\n', 0, -1) │ │ │ │ │ + if second_last_newline >= 0: │ │ │ │ │ + last_line = string[second_last_newline + 1:] │ │ │ │ │ + extras = string[:second_last_newline + 1] │ │ │ │ │ else: │ │ │ │ │ - file = args.command[-1] │ │ │ │ │ - files = [file] │ │ │ │ │ - args.command[-1] = './' + os.path.basename(file) │ │ │ │ │ + last_line = string │ │ │ │ │ + extras = '' │ │ │ │ │ │ │ │ │ │ - for filename in files: │ │ │ │ │ - with open(filename, 'rb') as file: │ │ │ │ │ - basename = os.path.basename(filename) │ │ │ │ │ - contents[f'tmpfs/{basename}'] = file.read() │ │ │ │ │ + for cls in classes: │ │ │ │ │ + pattern = with_helpers(cls._pattern) │ │ │ │ │ + match = re.fullmatch(pattern, last_line) │ │ │ │ │ + if match is not None: │ │ │ │ │ + return cls(last_line, extras, stderr, match) │ │ │ │ │ │ │ │ │ │ - script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command)) │ │ │ │ │ - sys.stdout.write(script) │ │ │ │ │ + return AskpassPrompt(last_line, extras, stderr) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -if __name__ == '__main__': │ │ │ │ │ - main() │ │ │ │ │ +class SshAskpassResponder(AskpassHandler): │ │ │ │ │ + async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None': │ │ │ │ │ + return await categorize_ssh_prompt(prompt, stderr).dispatch(self) │ │ │ │ │ + │ │ │ │ │ + async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None': │ │ │ │ │ + # Default fallback for unrecognised message types: unimplemented │ │ │ │ │ + return None │ │ │ │ │ + │ │ │ │ │ + async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None': │ │ │ │ │ + return await self.do_prompt(prompt) │ │ │ │ │ + │ │ │ │ │ + async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None': │ │ │ │ │ + return await self.do_prompt(prompt) │ │ │ │ │ + │ │ │ │ │ + async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None': │ │ │ │ │ + return await self.do_prompt(prompt) │ │ │ │ │ + │ │ │ │ │ + async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None': │ │ │ │ │ + return await self.do_prompt(prompt) │ │ │ │ │ + │ │ │ │ │ + async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None': │ │ │ │ │ + return await self.do_prompt(prompt) │ │ │ │ │ + │ │ │ │ │ + async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None': │ │ │ │ │ + return await self.do_prompt(prompt) │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/_vendor/bei/__init__.py': br'''''', │ │ │ │ │ - 'cockpit/_vendor/bei/beiboot.py': br"""# beiboot - Remote bootloader for Python │ │ │ │ │ + 'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ +# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import argparse │ │ │ │ │ import asyncio │ │ │ │ │ -import os │ │ │ │ │ -import shlex │ │ │ │ │ -import subprocess │ │ │ │ │ -import sys │ │ │ │ │ -import threading │ │ │ │ │ -from typing import IO, List, Sequence, Tuple │ │ │ │ │ +import contextlib │ │ │ │ │ +import logging │ │ │ │ │ +import typing │ │ │ │ │ +from typing import Any, Callable, Iterable, Sequence, TypeVar │ │ │ │ │ + │ │ │ │ │ +from .interaction_agent import InteractionAgent, InteractionHandler, get_running_loop │ │ │ │ │ +from .ssh_errors import get_exception_for_ssh_stderr │ │ │ │ │ + │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ + │ │ │ │ │ +P = TypeVar('P', bound=asyncio.Protocol) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class SubprocessError(Exception): │ │ │ │ │ + returncode: int │ │ │ │ │ + stderr: str │ │ │ │ │ + │ │ │ │ │ + def __init__(self, returncode: int, stderr: str) -> None: │ │ │ │ │ + super().__init__(returncode, stderr) │ │ │ │ │ + self.returncode = returncode │ │ │ │ │ + self.stderr = stderr │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +class FernyTransport(asyncio.Transport, asyncio.SubprocessProtocol): │ │ │ │ │ + _agent: InteractionAgent │ │ │ │ │ + _exec_task: 'asyncio.Task[tuple[asyncio.SubprocessTransport, FernyTransport]]' │ │ │ │ │ + _is_ssh: bool │ │ │ │ │ + _protocol: asyncio.Protocol │ │ │ │ │ + _protocol_disconnected: bool = False │ │ │ │ │ + │ │ │ │ │ + # These get initialized in connection_made() and once set, never get unset. │ │ │ │ │ + _subprocess_transport: 'asyncio.SubprocessTransport | None' = None │ │ │ │ │ + _stdin_transport: 'asyncio.WriteTransport | None' = None │ │ │ │ │ + _stdout_transport: 'asyncio.ReadTransport | None' = None │ │ │ │ │ + │ │ │ │ │ + # We record events that might build towards a connection termination here │ │ │ │ │ + # and consider them from _consider_disconnect() in order to try to get the │ │ │ │ │ + # best possible Exception for the protocol, rather than just taking the │ │ │ │ │ + # first one (which is likely to be somewhat random). │ │ │ │ │ + _exception: 'Exception | None' = None │ │ │ │ │ + _stderr_output: 'str | None' = None │ │ │ │ │ + _returncode: 'int | None' = None │ │ │ │ │ + _transport_disconnected: bool = False │ │ │ │ │ + _closed: bool = False │ │ │ │ │ + │ │ │ │ │ + @classmethod │ │ │ │ │ + def spawn( │ │ │ │ │ + cls: 'type[typing.Self]', │ │ │ │ │ + protocol_factory: Callable[[], P], │ │ │ │ │ + args: Sequence[str], │ │ │ │ │ + loop: 'asyncio.AbstractEventLoop | None' = None, │ │ │ │ │ + interaction_handlers: Sequence[InteractionHandler] = (), │ │ │ │ │ + is_ssh: bool = True, │ │ │ │ │ + **kwargs: Any │ │ │ │ │ + ) -> 'tuple[typing.Self, P]': │ │ │ │ │ + """Connects a FernyTransport to a protocol, using the given command. │ │ │ │ │ + │ │ │ │ │ + This spawns an external command and connects the stdin and stdout of │ │ │ │ │ + the command to the protocol returned by the factory. │ │ │ │ │ + │ │ │ │ │ + An instance of ferny.InteractionAgent is created and attached to the │ │ │ │ │ + stderr of the spawned process, using the provided handlers. It is the │ │ │ │ │ + responsibility of the caller to ensure that: │ │ │ │ │ + - a `ferny-askpass` client program is installed somewhere; and │ │ │ │ │ + - any relevant command-line arguments or environment variables are │ │ │ │ │ + passed correctly to the program to be spawned │ │ │ │ │ + │ │ │ │ │ + This function returns immediately and never raises exceptions, assuming │ │ │ │ │ + all preconditions are met. │ │ │ │ │ + │ │ │ │ │ + If spawning the process fails then connection_lost() will be │ │ │ │ │ + called with the relevant OSError, even before connection_made() is │ │ │ │ │ + called. This is somewhat non-standard behaviour, but is the easiest │ │ │ │ │ + way to report these errors without making this function async. │ │ │ │ │ + │ │ │ │ │ + Once the process is successfully executed, connection_made() will be │ │ │ │ │ + called and the transport can be used as normal. connection_lost() will │ │ │ │ │ + be called if the process exits or another error occurs. │ │ │ │ │ + │ │ │ │ │ + The return value of this function is the transport, but it exists in a │ │ │ │ │ + semi-initialized state. You can call .close() on it, but nothing else. │ │ │ │ │ + Once .connection_made() is called, you can call all the other │ │ │ │ │ + functions. │ │ │ │ │ + │ │ │ │ │ + After you call this function, `.connection_lost()` will be called on │ │ │ │ │ + your Protocol, exactly once, no matter what. Until that happens, you │ │ │ │ │ + are responsible for holding a reference to the returned transport. │ │ │ │ │ + │ │ │ │ │ + :param args: the full argv of the command to spawn │ │ │ │ │ + :param loop: the event loop to use. If none is provided, we use the │ │ │ │ │ + one which is (read: must be) currently running. │ │ │ │ │ + :param interaction_handlers: the handlers passed to the │ │ │ │ │ + InteractionAgent │ │ │ │ │ + :param is_ssh: whether we should attempt to interpret stderr as ssh │ │ │ │ │ + error messages │ │ │ │ │ + :param kwargs: anything else is passed through to `subprocess_exec()` │ │ │ │ │ + :returns: the usual `(Transport, Protocol)` pair │ │ │ │ │ + """ │ │ │ │ │ + logger.debug('spawn(%r, %r, %r)', cls, protocol_factory, args) │ │ │ │ │ │ │ │ │ │ -from .bootloader import make_bootloader │ │ │ │ │ + protocol = protocol_factory() │ │ │ │ │ + self = cls(protocol) │ │ │ │ │ + self._is_ssh = is_ssh │ │ │ │ │ │ │ │ │ │ + if loop is None: │ │ │ │ │ + loop = get_running_loop() │ │ │ │ │ │ │ │ │ │ -def get_python_command(local: bool = False, │ │ │ │ │ - tty: bool = False, │ │ │ │ │ - sh: bool = False) -> Sequence[str]: │ │ │ │ │ - interpreter = sys.executable if local else 'python3' │ │ │ │ │ - command: Sequence[str] │ │ │ │ │ + self._agent = InteractionAgent(interaction_handlers, loop, self._interaction_completed) │ │ │ │ │ + kwargs.setdefault('stderr', self._agent.fileno()) │ │ │ │ │ │ │ │ │ │ - if tty: │ │ │ │ │ - command = (interpreter, '-iq') │ │ │ │ │ - else: │ │ │ │ │ - command = ( │ │ │ │ │ - interpreter, '-ic', │ │ │ │ │ - # https://github.com/python/cpython/issues/93139 │ │ │ │ │ - '''" - beiboot - "; import sys; sys.ps1 = ''; sys.ps2 = '';''' │ │ │ │ │ - ) │ │ │ │ │ + # As of Python 3.12 this isn't really asynchronous (since it uses the │ │ │ │ │ + # subprocess module, which blocks while waiting for the exec() to │ │ │ │ │ + # complete in the child), but we have to deal with the complication of │ │ │ │ │ + # the async interface anyway. Since we, ourselves, want to export a │ │ │ │ │ + # non-async interface, that means that we need a task here and a │ │ │ │ │ + # bottom-half handler below. │ │ │ │ │ + self._exec_task = loop.create_task(loop.subprocess_exec(lambda: self, *args, **kwargs)) │ │ │ │ │ │ │ │ │ │ - if sh: │ │ │ │ │ - command = (' '.join(shlex.quote(arg) for arg in command),) │ │ │ │ │ + def exec_completed(task: asyncio.Task) -> None: │ │ │ │ │ + logger.debug('exec_completed(%r, %r)', self, task) │ │ │ │ │ + assert task is self._exec_task │ │ │ │ │ + try: │ │ │ │ │ + transport, me = task.result() │ │ │ │ │ + assert me is self │ │ │ │ │ + logger.debug(' success.') │ │ │ │ │ + except asyncio.CancelledError: │ │ │ │ │ + return # in that case, do nothing │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + logger.debug(' OSError %r', exc) │ │ │ │ │ + self.close(exc) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - return command │ │ │ │ │ + # Our own .connection_made() handler should have gotten called by │ │ │ │ │ + # now. Make sure everything got filled in properly. │ │ │ │ │ + assert self._subprocess_transport is transport │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + assert self._stdout_transport is not None │ │ │ │ │ │ │ │ │ │ + # Ask the InteractionAgent to start processing stderr. │ │ │ │ │ + self._agent.start() │ │ │ │ │ │ │ │ │ │ -def get_ssh_command(*args: str, tty: bool = False) -> Sequence[str]: │ │ │ │ │ - return ('ssh', │ │ │ │ │ - *(['-t'] if tty else ()), │ │ │ │ │ - *args, │ │ │ │ │ - *get_python_command(tty=tty, sh=True)) │ │ │ │ │ + self._exec_task.add_done_callback(exec_completed) │ │ │ │ │ │ │ │ │ │ + return self, protocol │ │ │ │ │ │ │ │ │ │ -def get_container_command(*args: str, tty: bool = False) -> Sequence[str]: │ │ │ │ │ - return ('podman', 'exec', '--interactive', │ │ │ │ │ - *(['--tty'] if tty else ()), │ │ │ │ │ - *args, │ │ │ │ │ - *get_python_command(tty=tty)) │ │ │ │ │ + def __init__(self, protocol: asyncio.Protocol) -> None: │ │ │ │ │ + self._protocol = protocol │ │ │ │ │ │ │ │ │ │ + def _consider_disconnect(self) -> None: │ │ │ │ │ + logger.debug('_consider_disconnect(%r)', self) │ │ │ │ │ + # We cannot disconnect as long as any of these three things are happening │ │ │ │ │ + if not self._exec_task.done(): │ │ │ │ │ + logger.debug(' exec_task still running %r', self._exec_task) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ -def get_command(*args: str, tty: bool = False, sh: bool = False) -> Sequence[str]: │ │ │ │ │ - return (*args, *get_python_command(local=True, tty=tty, sh=sh)) │ │ │ │ │ + if self._subprocess_transport is not None and not self._transport_disconnected: │ │ │ │ │ + logger.debug(' transport still connected %r', self._subprocess_transport) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ + if self._stderr_output is None: │ │ │ │ │ + logger.debug(' agent still running') │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ -def splice_in_thread(src: int, dst: IO[bytes]) -> None: │ │ │ │ │ - def _thread() -> None: │ │ │ │ │ - # os.splice() only in Python 3.10 │ │ │ │ │ - with dst: │ │ │ │ │ - block_size = 1 << 20 │ │ │ │ │ - while True: │ │ │ │ │ - data = os.read(src, block_size) │ │ │ │ │ - if not data: │ │ │ │ │ - break │ │ │ │ │ - dst.write(data) │ │ │ │ │ - dst.flush() │ │ │ │ │ + # All conditions for disconnection are satisfied. │ │ │ │ │ + if self._protocol_disconnected: │ │ │ │ │ + logger.debug(' already disconnected') │ │ │ │ │ + return │ │ │ │ │ + self._protocol_disconnected = True │ │ │ │ │ │ │ │ │ │ - threading.Thread(target=_thread, daemon=True).start() │ │ │ │ │ + # Now we just need to determine what we report to the protocol... │ │ │ │ │ + if self._exception is not None: │ │ │ │ │ + # If we got an exception reported, that's our reason for closing. │ │ │ │ │ + logger.debug(' disconnect with exception %r', self._exception) │ │ │ │ │ + self._protocol.connection_lost(self._exception) │ │ │ │ │ + elif self._returncode == 0 or self._closed: │ │ │ │ │ + # If we called close() or have a zero return status, that's a clean │ │ │ │ │ + # exit, regardless of noise that might have landed in stderr. │ │ │ │ │ + logger.debug(' clean disconnect') │ │ │ │ │ + self._protocol.connection_lost(None) │ │ │ │ │ + elif self._is_ssh and self._returncode == 255: │ │ │ │ │ + # This is an error code due to an SSH failure. Try to interpret it. │ │ │ │ │ + logger.debug(' disconnect with ssh error %r', self._stderr_output) │ │ │ │ │ + self._protocol.connection_lost(get_exception_for_ssh_stderr(self._stderr_output)) │ │ │ │ │ + else: │ │ │ │ │ + # Otherwise, report the stderr text and return code. │ │ │ │ │ + logger.debug(' disconnect with exit code %r, stderr %r', self._returncode, self._stderr_output) │ │ │ │ │ + # We surely have _returncode set here, since otherwise: │ │ │ │ │ + # - exec_task failed with an exception (which we handle above); or │ │ │ │ │ + # - we're still connected... │ │ │ │ │ + assert self._returncode is not None │ │ │ │ │ + self._protocol.connection_lost(SubprocessError(self._returncode, self._stderr_output)) │ │ │ │ │ │ │ │ │ │ + def _interaction_completed(self, future: 'asyncio.Future[str]') -> None: │ │ │ │ │ + logger.debug('_interaction_completed(%r, %r)', self, future) │ │ │ │ │ + try: │ │ │ │ │ + self._stderr_output = future.result() │ │ │ │ │ + logger.debug(' stderr: %r', self._stderr_output) │ │ │ │ │ + except Exception as exc: │ │ │ │ │ + logger.debug(' exception: %r', exc) │ │ │ │ │ + self._stderr_output = '' # we need to set this in order to complete │ │ │ │ │ + self.close(exc) │ │ │ │ │ │ │ │ │ │ -def send_and_splice(command: Sequence[str], script: bytes) -> None: │ │ │ │ │ - with subprocess.Popen(command, stdin=subprocess.PIPE) as proc: │ │ │ │ │ - assert proc.stdin is not None │ │ │ │ │ - proc.stdin.write(script) │ │ │ │ │ + self._consider_disconnect() │ │ │ │ │ │ │ │ │ │ - splice_in_thread(0, proc.stdin) │ │ │ │ │ - sys.exit(proc.wait()) │ │ │ │ │ + # BaseProtocol implementation │ │ │ │ │ + def connection_made(self, transport: asyncio.BaseTransport) -> None: │ │ │ │ │ + logger.debug('connection_made(%r, %r)', self, transport) │ │ │ │ │ + assert isinstance(transport, asyncio.SubprocessTransport) │ │ │ │ │ + self._subprocess_transport = transport │ │ │ │ │ │ │ │ │ │ + stdin_transport = transport.get_pipe_transport(0) │ │ │ │ │ + assert isinstance(stdin_transport, asyncio.WriteTransport) │ │ │ │ │ + self._stdin_transport = stdin_transport │ │ │ │ │ │ │ │ │ │ -def send_xz_and_splice(command: Sequence[str], script: bytes) -> None: │ │ │ │ │ - import ferny │ │ │ │ │ + stdout_transport = transport.get_pipe_transport(1) │ │ │ │ │ + assert isinstance(stdout_transport, asyncio.ReadTransport) │ │ │ │ │ + self._stdout_transport = stdout_transport │ │ │ │ │ │ │ │ │ │ - class Responder(ferny.InteractionResponder): │ │ │ │ │ - async def do_custom_command(self, │ │ │ │ │ - command: str, │ │ │ │ │ - args: Tuple, │ │ │ │ │ - fds: List[int], │ │ │ │ │ - stderr: str) -> None: │ │ │ │ │ - assert proc.stdin is not None │ │ │ │ │ - if command == 'beiboot.provide': │ │ │ │ │ - proc.stdin.write(script) │ │ │ │ │ - proc.stdin.flush() │ │ │ │ │ + stderr_transport = transport.get_pipe_transport(2) │ │ │ │ │ + assert stderr_transport is None │ │ │ │ │ │ │ │ │ │ - agent = ferny.InteractionAgent(Responder()) │ │ │ │ │ - with subprocess.Popen(command, stdin=subprocess.PIPE, stderr=agent) as proc: │ │ │ │ │ - assert proc.stdin is not None │ │ │ │ │ - proc.stdin.write(make_bootloader([ │ │ │ │ │ - ('boot_xz', ('script.py.xz', len(script), [], True)), │ │ │ │ │ - ], gadgets=ferny.BEIBOOT_GADGETS).encode()) │ │ │ │ │ - proc.stdin.flush() │ │ │ │ │ + logger.debug('calling connection_made(%r, %r)', self, self._protocol) │ │ │ │ │ + self._protocol.connection_made(self) │ │ │ │ │ │ │ │ │ │ - asyncio.run(agent.communicate()) │ │ │ │ │ - splice_in_thread(0, proc.stdin) │ │ │ │ │ - sys.exit(proc.wait()) │ │ │ │ │ + def connection_lost(self, exc: 'Exception | None') -> None: │ │ │ │ │ + logger.debug('connection_lost(%r, %r)', self, exc) │ │ │ │ │ + if self._exception is None: │ │ │ │ │ + self._exception = exc │ │ │ │ │ + self._transport_disconnected = True │ │ │ │ │ + self._consider_disconnect() │ │ │ │ │ │ │ │ │ │ + # SubprocessProtocol implementation │ │ │ │ │ + def pipe_data_received(self, fd: int, data: bytes) -> None: │ │ │ │ │ + logger.debug('pipe_data_received(%r, %r, %r)', self, fd, len(data)) │ │ │ │ │ + assert fd == 1 # stderr is handled separately │ │ │ │ │ + self._protocol.data_received(data) │ │ │ │ │ │ │ │ │ │ -def main() -> None: │ │ │ │ │ - parser = argparse.ArgumentParser() │ │ │ │ │ - parser.add_argument('--sh', action='store_true', │ │ │ │ │ - help='Pass Python interpreter command as shell-script') │ │ │ │ │ - parser.add_argument('--xz', help="the xz to run remotely") │ │ │ │ │ - parser.add_argument('--script', │ │ │ │ │ - help="the script to run remotely (must be repl-friendly)") │ │ │ │ │ - parser.add_argument('command', nargs='*') │ │ │ │ │ + def pipe_connection_lost(self, fd: int, exc: 'Exception | None') -> None: │ │ │ │ │ + logger.debug('pipe_connection_lost(%r, %r, %r)', self, fd, exc) │ │ │ │ │ + assert fd in (0, 1) # stderr is handled separately │ │ │ │ │ │ │ │ │ │ - args = parser.parse_args() │ │ │ │ │ - tty = not args.script and os.isatty(0) │ │ │ │ │ + # We treat this as a clean close │ │ │ │ │ + if isinstance(exc, BrokenPipeError): │ │ │ │ │ + exc = None │ │ │ │ │ │ │ │ │ │ - if args.command == []: │ │ │ │ │ - command = get_python_command(tty=tty) │ │ │ │ │ - elif args.command[0] == 'ssh': │ │ │ │ │ - command = get_ssh_command(*args.command[1:], tty=tty) │ │ │ │ │ - elif args.command[0] == 'container': │ │ │ │ │ - command = get_container_command(*args.command[1:], tty=tty) │ │ │ │ │ - else: │ │ │ │ │ - command = get_command(*args.command, tty=tty, sh=args.sh) │ │ │ │ │ + # Record serious errors to propagate them to the protocol │ │ │ │ │ + # If this is a clean exit on stdout, report an EOF │ │ │ │ │ + if exc is not None: │ │ │ │ │ + self.close(exc) │ │ │ │ │ + elif fd == 1 and not self._closed: │ │ │ │ │ + if not self._protocol.eof_received(): │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - if args.script: │ │ │ │ │ - with open(args.script, 'rb') as file: │ │ │ │ │ - script = file.read() │ │ │ │ │ + def process_exited(self) -> None: │ │ │ │ │ + logger.debug('process_exited(%r)', self) │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + self._returncode = self._subprocess_transport.get_returncode() │ │ │ │ │ + logger.debug(' ._returncode = %r', self._returncode) │ │ │ │ │ + self._agent.force_completion() │ │ │ │ │ │ │ │ │ │ - send_and_splice(command, script) │ │ │ │ │ + def pause_writing(self) -> None: │ │ │ │ │ + logger.debug('pause_writing(%r)', self) │ │ │ │ │ + self._protocol.pause_writing() │ │ │ │ │ │ │ │ │ │ - elif args.xz: │ │ │ │ │ - with open(args.xz, 'rb') as file: │ │ │ │ │ - script = file.read() │ │ │ │ │ + def resume_writing(self) -> None: │ │ │ │ │ + logger.debug('resume_writing(%r)', self) │ │ │ │ │ + self._protocol.resume_writing() │ │ │ │ │ │ │ │ │ │ - send_xz_and_splice(command, script) │ │ │ │ │ + # Transport implementation. Most of this is straight delegation. │ │ │ │ │ + def close(self, exc: 'Exception | None' = None) -> None: │ │ │ │ │ + logger.debug('close(%r, %r)', self, exc) │ │ │ │ │ + self._closed = True │ │ │ │ │ + if self._exception is None: │ │ │ │ │ + logger.debug(' setting exception %r', exc) │ │ │ │ │ + self._exception = exc │ │ │ │ │ + if not self._exec_task.done(): │ │ │ │ │ + logger.debug(' cancelling _exec_task') │ │ │ │ │ + self._exec_task.cancel() │ │ │ │ │ + if self._subprocess_transport is not None: │ │ │ │ │ + logger.debug(' closing _subprocess_transport') │ │ │ │ │ + # https://github.com/python/cpython/issues/112800 │ │ │ │ │ + with contextlib.suppress(PermissionError): │ │ │ │ │ + self._subprocess_transport.close() │ │ │ │ │ + self._agent.force_completion() │ │ │ │ │ │ │ │ │ │ - else: │ │ │ │ │ - # If we're streaming from stdin then this is a lot easier... │ │ │ │ │ - os.execlp(command[0], *command) │ │ │ │ │ + def is_closing(self) -> bool: │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + return self._subprocess_transport.is_closing() │ │ │ │ │ │ │ │ │ │ - # Otherwise, "full strength" │ │ │ │ │ + def get_extra_info(self, name: str, default: object = None) -> object: │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + return self._subprocess_transport.get_extra_info(name, default) │ │ │ │ │ │ │ │ │ │ -if __name__ == '__main__': │ │ │ │ │ - main() │ │ │ │ │ -""", │ │ │ │ │ - 'cockpit/_vendor/bei/tmpfs.py': br'''import os │ │ │ │ │ -import subprocess │ │ │ │ │ -import sys │ │ │ │ │ -import tempfile │ │ │ │ │ + def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: │ │ │ │ │ + assert isinstance(protocol, asyncio.Protocol) │ │ │ │ │ + self._protocol = protocol │ │ │ │ │ │ │ │ │ │ + def get_protocol(self) -> asyncio.Protocol: │ │ │ │ │ + return self._protocol │ │ │ │ │ │ │ │ │ │ -def main(*command: str) -> None: │ │ │ │ │ - with tempfile.TemporaryDirectory() as tmpdir: │ │ │ │ │ - os.chdir(tmpdir) │ │ │ │ │ + def is_reading(self) -> bool: │ │ │ │ │ + assert self._stdout_transport is not None │ │ │ │ │ + try: │ │ │ │ │ + return self._stdout_transport.is_reading() │ │ │ │ │ + except NotImplementedError: │ │ │ │ │ + # This is (incorrectly) unimplemented before Python 3.11 │ │ │ │ │ + return not self._stdout_transport._paused # type:ignore[attr-defined] │ │ │ │ │ + except AttributeError: │ │ │ │ │ + # ...and in Python 3.6 it's even worse │ │ │ │ │ + try: │ │ │ │ │ + selector = self._stdout_transport._loop._selector # type:ignore[attr-defined] │ │ │ │ │ + selector.get_key(self._stdout_transport._fileno) # type:ignore[attr-defined] │ │ │ │ │ + return True │ │ │ │ │ + except KeyError: │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ - for key, value in __loader__.get_contents().items(): │ │ │ │ │ - if key.startswith('tmpfs/'): │ │ │ │ │ - subdir = os.path.dirname(key) │ │ │ │ │ - os.makedirs(subdir, exist_ok=True) │ │ │ │ │ - with open(key, 'wb') as fp: │ │ │ │ │ - fp.write(value) │ │ │ │ │ + def pause_reading(self) -> None: │ │ │ │ │ + assert self._stdout_transport is not None │ │ │ │ │ + self._stdout_transport.pause_reading() │ │ │ │ │ │ │ │ │ │ - os.chdir('tmpfs') │ │ │ │ │ + def resume_reading(self) -> None: │ │ │ │ │ + assert self._stdout_transport is not None │ │ │ │ │ + self._stdout_transport.resume_reading() │ │ │ │ │ │ │ │ │ │ - result = subprocess.run(command, check=False) │ │ │ │ │ - sys.exit(result.returncode) │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/bei/bootloader.py': br'''# beiboot - Remote bootloader for Python │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ + def abort(self) -> None: │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + self._stdin_transport.abort() │ │ │ │ │ + self._subprocess_transport.kill() │ │ │ │ │ │ │ │ │ │ -import textwrap │ │ │ │ │ -from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple │ │ │ │ │ + def can_write_eof(self) -> bool: │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + return self._stdin_transport.can_write_eof() # will always be True │ │ │ │ │ │ │ │ │ │ -GADGETS = { │ │ │ │ │ - "_frame": r""" │ │ │ │ │ - import sys │ │ │ │ │ - import traceback │ │ │ │ │ - try: │ │ │ │ │ - ... │ │ │ │ │ - except SystemExit: │ │ │ │ │ - raise │ │ │ │ │ - except BaseException: │ │ │ │ │ - command('beiboot.exc', traceback.format_exc()) │ │ │ │ │ - sys.exit(37) │ │ │ │ │ - """, │ │ │ │ │ - "try_exec": r""" │ │ │ │ │ - import contextlib │ │ │ │ │ - import os │ │ │ │ │ - def try_exec(argv): │ │ │ │ │ - with contextlib.suppress(OSError): │ │ │ │ │ - os.execvp(argv[0], argv) │ │ │ │ │ - """, │ │ │ │ │ - "boot_xz": r""" │ │ │ │ │ - import lzma │ │ │ │ │ - import sys │ │ │ │ │ - def boot_xz(filename, size, args=[], send_end=False): │ │ │ │ │ - command('beiboot.provide', size) │ │ │ │ │ - src_xz = sys.stdin.buffer.read(size) │ │ │ │ │ - src = lzma.decompress(src_xz) │ │ │ │ │ - sys.argv = [filename, *args] │ │ │ │ │ - if send_end: │ │ │ │ │ - end() │ │ │ │ │ - exec(src, { │ │ │ │ │ - '__name__': '__main__', │ │ │ │ │ - '__self_source__': src_xz, │ │ │ │ │ - '__file__': filename}) │ │ │ │ │ - sys.exit() │ │ │ │ │ - """, │ │ │ │ │ -} │ │ │ │ │ + def get_write_buffer_size(self) -> int: │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + return self._stdin_transport.get_write_buffer_size() │ │ │ │ │ │ │ │ │ │ + def get_write_buffer_limits(self) -> 'tuple[int, int]': │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + return self._stdin_transport.get_write_buffer_limits() │ │ │ │ │ │ │ │ │ │ -def split_code(code: str, imports: Set[str]) -> Iterable[Tuple[str, str]]: │ │ │ │ │ - for line in textwrap.dedent(code).splitlines(): │ │ │ │ │ - text = line.lstrip(" ") │ │ │ │ │ - if text.startswith("import "): │ │ │ │ │ - imports.add(text) │ │ │ │ │ - elif text: │ │ │ │ │ - spaces = len(line) - len(text) │ │ │ │ │ - assert (spaces % 4) == 0 │ │ │ │ │ - yield "\t" * (spaces // 4), text │ │ │ │ │ + def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None: │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + return self._stdin_transport.set_write_buffer_limits(high, low) │ │ │ │ │ │ │ │ │ │ + def write(self, data: bytes) -> None: │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + return self._stdin_transport.write(data) │ │ │ │ │ │ │ │ │ │ -def yield_body(user_gadgets: Dict[str, str], │ │ │ │ │ - steps: Sequence[Tuple[str, Sequence[object]]], │ │ │ │ │ - imports: Set[str]) -> Iterable[Tuple[str, str]]: │ │ │ │ │ - # Allow the caller to override our gadgets, but keep the original │ │ │ │ │ - # variable for use in the next step. │ │ │ │ │ - gadgets = dict(GADGETS, **user_gadgets) │ │ │ │ │ + def writelines(self, list_of_data: Iterable[bytes]) -> None: │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + return self._stdin_transport.writelines(list_of_data) │ │ │ │ │ │ │ │ │ │ - # First emit the gadgets. Emit all gadgets provided by the caller, │ │ │ │ │ - # plus any referred to by the caller's list of steps. │ │ │ │ │ - provided_gadgets = set(user_gadgets) │ │ │ │ │ - step_gadgets = {name for name, _args in steps} │ │ │ │ │ - for name in provided_gadgets | step_gadgets: │ │ │ │ │ - yield from split_code(gadgets[name], imports) │ │ │ │ │ + def write_eof(self) -> None: │ │ │ │ │ + assert self._stdin_transport is not None │ │ │ │ │ + return self._stdin_transport.write_eof() │ │ │ │ │ │ │ │ │ │ - # Yield functions mentioned in steps from the caller │ │ │ │ │ - for name, args in steps: │ │ │ │ │ - yield '', name + repr(tuple(args)) │ │ │ │ │ + # We don't really implement SubprocessTransport, but provide these as │ │ │ │ │ + # "extras" to our user. │ │ │ │ │ + def get_pid(self) -> int: │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + return self._subprocess_transport.get_pid() │ │ │ │ │ │ │ │ │ │ + def get_returncode(self) -> 'int | None': │ │ │ │ │ + return self._returncode │ │ │ │ │ │ │ │ │ │ -def make_bootloader(steps: Sequence[Tuple[str, Sequence[object]]], │ │ │ │ │ - gadgets: Optional[Dict[str, str]] = None) -> str: │ │ │ │ │ - imports: Set[str] = set() │ │ │ │ │ - lines: List[str] = [] │ │ │ │ │ + def kill(self) -> None: │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + self._subprocess_transport.kill() │ │ │ │ │ │ │ │ │ │ - for frame_spaces, frame_text in split_code(GADGETS["_frame"], imports): │ │ │ │ │ - if frame_text == "...": │ │ │ │ │ - for spaces, text in yield_body(gadgets or {}, steps, imports): │ │ │ │ │ - lines.append(frame_spaces + spaces + text) │ │ │ │ │ - else: │ │ │ │ │ - lines.append(frame_spaces + frame_text) │ │ │ │ │ + def send_signal(self, number: int) -> None: │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + self._subprocess_transport.send_signal(number) │ │ │ │ │ │ │ │ │ │ - return "".join(f"{line}\n" for line in [*imports, *lines]) + "\n" │ │ │ │ │ + def terminate(self) -> None: │ │ │ │ │ + assert self._subprocess_transport is not None │ │ │ │ │ + self._subprocess_transport.terminate() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python │ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/librarywrapper.py': br'''# systemd_ctypes │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ @@ -7022,314 +7005,345 @@ │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import argparse │ │ │ │ │ -import binascii │ │ │ │ │ -import lzma │ │ │ │ │ +import ctypes │ │ │ │ │ +import inspect │ │ │ │ │ +import logging │ │ │ │ │ import os │ │ │ │ │ import sys │ │ │ │ │ -import tempfile │ │ │ │ │ -import zipfile │ │ │ │ │ -from typing import Dict, Iterable, List, Optional, Set, Tuple │ │ │ │ │ - │ │ │ │ │ -from .data import read_data_file │ │ │ │ │ +import types │ │ │ │ │ +from typing import ( │ │ │ │ │ + Any, │ │ │ │ │ + Callable, │ │ │ │ │ + Dict, │ │ │ │ │ + Generic, │ │ │ │ │ + NewType, │ │ │ │ │ + NoReturn, │ │ │ │ │ + Optional, │ │ │ │ │ + Tuple, │ │ │ │ │ + Type, │ │ │ │ │ + TypeVar, │ │ │ │ │ + Union, │ │ │ │ │ +) │ │ │ │ │ │ │ │ │ │ +from . import typing │ │ │ │ │ │ │ │ │ │ -def escape_string(data: str) -> str: │ │ │ │ │ - # Avoid mentioning ' ' ' literally, to make our own packing a bit prettier │ │ │ │ │ - triplequote = "'" * 3 │ │ │ │ │ - if triplequote not in data: │ │ │ │ │ - return "r" + triplequote + data + triplequote │ │ │ │ │ - if '"""' not in data: │ │ │ │ │ - return 'r"""' + data + '"""' │ │ │ │ │ - return repr(data) │ │ │ │ │ +# First in 3.10, and conditional import gives type errors │ │ │ │ │ +NoneType = type(None) │ │ │ │ │ │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ -def ascii_bytes_repr(data: bytes) -> str: │ │ │ │ │ - return 'b' + escape_string(data.decode('ascii')) │ │ │ │ │ +if typing.TYPE_CHECKING: │ │ │ │ │ + CType = TypeVar("CType", bound=ctypes._CData) │ │ │ │ │ + Callback = ctypes._FuncPointer │ │ │ │ │ +else: │ │ │ │ │ + CType = TypeVar("CType") │ │ │ │ │ + Callback = ctypes.c_void_p │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def utf8_bytes_repr(data: bytes) -> str: │ │ │ │ │ - return escape_string(data.decode('utf-8')) + ".encode('utf-8')" │ │ │ │ │ +if typing.TYPE_CHECKING: │ │ │ │ │ + class Reference(Generic[CType], ctypes._Pointer[CType]): │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ + def byref(x: CType) -> Reference[CType]: │ │ │ │ │ + raise NotImplementedError │ │ │ │ │ +else: │ │ │ │ │ + class Reference(Generic[CType]): │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ -def base64_bytes_repr(data: bytes, imports: Set[str]) -> str: │ │ │ │ │ - # base85 is smaller, but base64 is in C, and ~20x faster. │ │ │ │ │ - # when compressing with `xz -e` the size difference is marginal. │ │ │ │ │ - imports.add('from binascii import a2b_base64') │ │ │ │ │ - encoded = binascii.b2a_base64(data).decode('ascii').strip() │ │ │ │ │ - return f'a2b_base64("{encoded}")' │ │ │ │ │ + byref = ctypes.byref │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def bytes_repr(data: bytes, imports: Set[str]) -> str: │ │ │ │ │ - # Strategy: │ │ │ │ │ - # if the file is ascii, encode it directly as bytes │ │ │ │ │ - # otherwise, if it's UTF-8, use a unicode string and encode │ │ │ │ │ - # otherwise, base64 │ │ │ │ │ +UserData = Optional[ctypes.c_void_p] │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - return ascii_bytes_repr(data) │ │ │ │ │ - except UnicodeDecodeError: │ │ │ │ │ - # it's not ascii │ │ │ │ │ - pass │ │ │ │ │ │ │ │ │ │ - # utf-8 │ │ │ │ │ - try: │ │ │ │ │ - return utf8_bytes_repr(data) │ │ │ │ │ - except UnicodeDecodeError: │ │ │ │ │ - # it's not utf-8 │ │ │ │ │ - pass │ │ │ │ │ +class negative_errno(ctypes.c_int): │ │ │ │ │ + def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> int: │ │ │ │ │ + result = self.value │ │ │ │ │ + if result < 0: │ │ │ │ │ + raise OSError(-result, f"{func.__name__}: {os.strerror(-result)}") │ │ │ │ │ + return result │ │ │ │ │ │ │ │ │ │ - return base64_bytes_repr(data, imports) │ │ │ │ │ │ │ │ │ │ +class utf8(ctypes.c_char_p): │ │ │ │ │ + def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> str: │ │ │ │ │ + assert self.value is not None │ │ │ │ │ + return self.value.decode() │ │ │ │ │ │ │ │ │ │ -def dict_repr(contents: Dict[str, bytes], imports: Set[str]) -> str: │ │ │ │ │ - return ('{\n' + │ │ │ │ │ - ''.join(f' {repr(k)}: {bytes_repr(v, imports)},\n' │ │ │ │ │ - for k, v in contents.items()) + │ │ │ │ │ - '}') │ │ │ │ │ + @classmethod │ │ │ │ │ + def from_param(cls, value: str) -> 'utf8': │ │ │ │ │ + return cls(value.encode()) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def pack(contents: Dict[str, bytes], │ │ │ │ │ - entrypoint: Optional[str] = None, │ │ │ │ │ - args: str = '') -> str: │ │ │ │ │ - """Creates a beipack with the given `contents`. │ │ │ │ │ +class utf8_or_null(ctypes.c_char_p): │ │ │ │ │ + def errcheck(self, │ │ │ │ │ + func: Callable[..., object], │ │ │ │ │ + _args: Tuple[object, ...]) -> Optional[str]: │ │ │ │ │ + return self.value.decode() if self.value is not None else None │ │ │ │ │ │ │ │ │ │ - If `entrypoint` is given, it should be an entry point which is run as the │ │ │ │ │ - "main" function. It is given in the `package.module:func format` such that │ │ │ │ │ - the following code is emitted: │ │ │ │ │ + @classmethod │ │ │ │ │ + def from_param(cls, value: Optional[str]) -> 'utf8_or_null': │ │ │ │ │ + return cls(value.encode() if value is not None else None) │ │ │ │ │ │ │ │ │ │ - from package.module import func as main │ │ │ │ │ - main() │ │ │ │ │ │ │ │ │ │ - Additionally, if `args` is given, it is written verbatim between the parens │ │ │ │ │ - of the call to main (ie: it should already be in Python syntax). │ │ │ │ │ - """ │ │ │ │ │ +class boolint(ctypes.c_int): │ │ │ │ │ + def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> bool: │ │ │ │ │ + return bool(self.value) │ │ │ │ │ │ │ │ │ │ - loader = read_data_file('beipack_loader.py') │ │ │ │ │ - lines = [line for line in loader.splitlines() if line] │ │ │ │ │ - lines.append('') │ │ │ │ │ │ │ │ │ │ - imports = {'import sys'} │ │ │ │ │ - contents_txt = dict_repr(contents, imports) │ │ │ │ │ - lines.extend(imports) │ │ │ │ │ - lines.append(f'sys.meta_path.insert(0, BeipackLoader({contents_txt}))') │ │ │ │ │ +WeakReference = NewType("WeakReference", int) │ │ │ │ │ +Errno = typing.Annotated[NoReturn, "errno"] │ │ │ │ │ │ │ │ │ │ - if entrypoint: │ │ │ │ │ - package, main = entrypoint.split(':') │ │ │ │ │ - lines.append(f'from {package} import {main} as main') │ │ │ │ │ - lines.append(f'main({args})') │ │ │ │ │ │ │ │ │ │ - return ''.join(f'{line}\n' for line in lines) │ │ │ │ │ +type_map = { │ │ │ │ │ + Union[None, Errno]: negative_errno, # technically returns int │ │ │ │ │ + Union[bool, Errno]: negative_errno, # technically returns int │ │ │ │ │ + Union[int, Errno]: negative_errno, │ │ │ │ │ + bool: boolint, │ │ │ │ │ + Optional[str]: utf8_or_null, │ │ │ │ │ + str: utf8, │ │ │ │ │ + int: ctypes.c_int, │ │ │ │ │ + WeakReference: ctypes.c_void_p │ │ │ │ │ +} │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def collect_contents(filenames: List[str], │ │ │ │ │ - relative_to: Optional[str] = None) -> Dict[str, bytes]: │ │ │ │ │ - contents: Dict[str, bytes] = {} │ │ │ │ │ +def map_type(annotation: Any, global_vars: Dict[str, object]) -> Any: │ │ │ │ │ + try: │ │ │ │ │ + return type_map[annotation] │ │ │ │ │ + except KeyError: │ │ │ │ │ + pass # ... and try more cases below │ │ │ │ │ │ │ │ │ │ - for filename in filenames: │ │ │ │ │ - with open(filename, 'rb') as file: │ │ │ │ │ - contents[os.path.relpath(filename, start=relative_to)] = file.read() │ │ │ │ │ + if isinstance(annotation, typing.ForwardRef): │ │ │ │ │ + annotation = annotation.__forward_arg__ │ │ │ │ │ │ │ │ │ │ - return contents │ │ │ │ │ + if isinstance(annotation, str): │ │ │ │ │ + annotation = global_vars[annotation] │ │ │ │ │ │ │ │ │ │ + origin = typing.get_origin(annotation) │ │ │ │ │ + args = typing.get_args(annotation) │ │ │ │ │ │ │ │ │ │ -def collect_module(name: str, *, recursive: bool) -> Dict[str, bytes]: │ │ │ │ │ - import importlib.resources │ │ │ │ │ - from importlib.resources.abc import Traversable │ │ │ │ │ + if origin is Reference: │ │ │ │ │ + return ctypes.POINTER(map_type(args[0], global_vars)) │ │ │ │ │ │ │ │ │ │ - def walk(path: str, entry: Traversable) -> Iterable[Tuple[str, bytes]]: │ │ │ │ │ - for item in entry.iterdir(): │ │ │ │ │ - itemname = f'{path}/{item.name}' │ │ │ │ │ - if item.is_file(): │ │ │ │ │ - yield itemname, item.read_bytes() │ │ │ │ │ - elif recursive and item.name != '__pycache__': │ │ │ │ │ - yield from walk(itemname, item) │ │ │ │ │ + elif origin is Union and NoneType in args: │ │ │ │ │ + # the C pointer types are already nullable │ │ │ │ │ + other_arg, = set(args) - {NoneType} │ │ │ │ │ + return map_type(other_arg, global_vars) │ │ │ │ │ │ │ │ │ │ - return dict(walk(name.replace('.', '/'), importlib.resources.files(name))) │ │ │ │ │ + elif origin is typing.Annotated: │ │ │ │ │ + return args[1] │ │ │ │ │ │ │ │ │ │ + else: │ │ │ │ │ + assert origin is None, origin │ │ │ │ │ + return annotation │ │ │ │ │ │ │ │ │ │ -def collect_zip(filename: str) -> Dict[str, bytes]: │ │ │ │ │ - contents = {} │ │ │ │ │ │ │ │ │ │ - with zipfile.ZipFile(filename) as file: │ │ │ │ │ - for entry in file.filelist: │ │ │ │ │ - if '.dist-info/' in entry.filename: │ │ │ │ │ +class ReferenceType(ctypes.c_void_p): │ │ │ │ │ + @classmethod │ │ │ │ │ + def _install_cfuncs(cls, cdll: ctypes.CDLL) -> None: │ │ │ │ │ + logger.debug('Installing stubs for %s:', cls) │ │ │ │ │ + stubs = tuple(cls.__dict__.items()) │ │ │ │ │ + for name, stub in stubs: │ │ │ │ │ + if name.startswith("__"): │ │ │ │ │ continue │ │ │ │ │ - contents[entry.filename] = file.read(entry) │ │ │ │ │ + cls._wrap(cdll, stub) │ │ │ │ │ │ │ │ │ │ - return contents │ │ │ │ │ + cls._wrap(cdll, cls._ref) │ │ │ │ │ + cls._wrap(cdll, cls._unref) │ │ │ │ │ │ │ │ │ │ + @classmethod │ │ │ │ │ + def _wrap(cls, cdll: ctypes.CDLL, stub: object) -> None: │ │ │ │ │ + stub_type = type(stub) │ │ │ │ │ + if isinstance(stub, staticmethod): │ │ │ │ │ + # In older Python versions, staticmethod() isn't considered │ │ │ │ │ + # callable, doesn't have a name, and can't be introspected with │ │ │ │ │ + # inspect.signature(). Unwrap it. │ │ │ │ │ + stub = stub.__func__ │ │ │ │ │ + assert isinstance(stub, types.FunctionType) │ │ │ │ │ + name = stub.__name__ │ │ │ │ │ + signature = inspect.signature(stub) │ │ │ │ │ + stub_globals = sys.modules.get(cls.__module__).__dict__ │ │ │ │ │ │ │ │ │ │ -def collect_pep517(path: str) -> Dict[str, bytes]: │ │ │ │ │ - with tempfile.TemporaryDirectory() as tmpdir: │ │ │ │ │ - import build │ │ │ │ │ - builder = build.ProjectBuilder(path) │ │ │ │ │ - wheel = builder.build('wheel', tmpdir) │ │ │ │ │ - return collect_zip(wheel) │ │ │ │ │ + func = cdll[f'{cls.__name__}_{name.lstrip("_")}'] │ │ │ │ │ + func.argtypes = tuple( │ │ │ │ │ + map_type(parameter.annotation, stub_globals) │ │ │ │ │ + for parameter in signature.parameters.values() │ │ │ │ │ + ) │ │ │ │ │ + func.restype = map_type(signature.return_annotation, stub_globals) │ │ │ │ │ + errcheck = getattr(func.restype, 'errcheck', None) │ │ │ │ │ + if errcheck is not None: │ │ │ │ │ + func.errcheck = errcheck │ │ │ │ │ │ │ │ │ │ + logger.debug(' create wrapper %s.%s%s', cls.__name__, name, signature) │ │ │ │ │ + logger.debug(' args %s res %s', func.argtypes, func.restype) │ │ │ │ │ │ │ │ │ │ -def main() -> None: │ │ │ │ │ - parser = argparse.ArgumentParser() │ │ │ │ │ - parser.add_argument('--python', '-p', │ │ │ │ │ - help="add a #!python3 interpreter line using the given path") │ │ │ │ │ - parser.add_argument('--xz', '-J', action='store_true', │ │ │ │ │ - help="compress the output with `xz`") │ │ │ │ │ - parser.add_argument('--topdir', │ │ │ │ │ - help="toplevel directory (paths are stored relative to this)") │ │ │ │ │ - parser.add_argument('--output', '-o', │ │ │ │ │ - help="write output to a file (default: stdout)") │ │ │ │ │ - parser.add_argument('--main', '-m', metavar='MODULE:FUNC', │ │ │ │ │ - help="use FUNC from MODULE as the main function") │ │ │ │ │ - parser.add_argument('--main-args', metavar='ARGS', │ │ │ │ │ - help="arguments to main() in Python syntax", default='') │ │ │ │ │ - parser.add_argument('--module', action='append', default=[], │ │ │ │ │ - help="collect installed modules (recursively)") │ │ │ │ │ - parser.add_argument('--zip', '-z', action='append', default=[], │ │ │ │ │ - help="include files from a zipfile (or wheel)") │ │ │ │ │ - parser.add_argument('--build', metavar='DIR', action='append', default=[], │ │ │ │ │ - help="PEP-517 from a given source directory") │ │ │ │ │ - parser.add_argument('files', nargs='*', │ │ │ │ │ - help="files to include in the beipack") │ │ │ │ │ - args = parser.parse_args() │ │ │ │ │ + # ctypes function pointer objects don't implement the usual function │ │ │ │ │ + # descriptor logic, which means they won't bind as methods. For static │ │ │ │ │ + # methods, that's good, but for instance methods, we add a wrapper as │ │ │ │ │ + # the easiest and most performant way to get the binding behaviour. │ │ │ │ │ + if stub_type is not staticmethod: │ │ │ │ │ + setattr(cls, name, lambda *args: func(*args)) │ │ │ │ │ + else: │ │ │ │ │ + setattr(cls, name, func) │ │ │ │ │ │ │ │ │ │ - contents = collect_contents(args.files, relative_to=args.topdir) │ │ │ │ │ + def _unref(self: 'ReferenceType') -> None: │ │ │ │ │ + ... │ │ │ │ │ │ │ │ │ │ - for file in args.zip: │ │ │ │ │ - contents.update(collect_zip(file)) │ │ │ │ │ + def _ref(self: 'ReferenceType') -> None: │ │ │ │ │ + ... │ │ │ │ │ │ │ │ │ │ - for name in args.module: │ │ │ │ │ - contents.update(collect_module(name, recursive=True)) │ │ │ │ │ + T = TypeVar("T", bound='ReferenceType') │ │ │ │ │ │ │ │ │ │ - for path in args.build: │ │ │ │ │ - contents.update(collect_pep517(path)) │ │ │ │ │ + @classmethod │ │ │ │ │ + def ref(cls: Type[T], origin: WeakReference) -> T: │ │ │ │ │ + self = cls(origin) │ │ │ │ │ + self._ref() │ │ │ │ │ + return self │ │ │ │ │ │ │ │ │ │ - result = pack(contents, args.main, args.main_args).encode('utf-8') │ │ │ │ │ + def __del__(self) -> None: │ │ │ │ │ + if self.value is not None: │ │ │ │ │ + self._unref() │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ - if args.python: │ │ │ │ │ - result = b'#!' + args.python.encode('ascii') + b'\n' + result │ │ │ │ │ +import ctypes │ │ │ │ │ +from enum import IntFlag, auto │ │ │ │ │ +from typing import Optional │ │ │ │ │ │ │ │ │ │ - if args.xz: │ │ │ │ │ - result = lzma.compress(result, preset=lzma.PRESET_EXTREME) │ │ │ │ │ │ │ │ │ │ - if args.output: │ │ │ │ │ - with open(args.output, 'wb') as file: │ │ │ │ │ - file.write(result) │ │ │ │ │ - else: │ │ │ │ │ - if args.xz and os.isatty(1): │ │ │ │ │ - sys.exit('refusing to write compressed output to a terminal') │ │ │ │ │ - sys.stdout.buffer.write(result) │ │ │ │ │ +class inotify_event(ctypes.Structure): │ │ │ │ │ + _fields_ = ( │ │ │ │ │ + ('wd', ctypes.c_int32), │ │ │ │ │ + ('mask', ctypes.c_uint32), │ │ │ │ │ + ('cookie', ctypes.c_uint32), │ │ │ │ │ + ('len', ctypes.c_uint32), │ │ │ │ │ + ) │ │ │ │ │ │ │ │ │ │ + @property │ │ │ │ │ + def name(self) -> Optional[bytes]: │ │ │ │ │ + if self.len == 0: │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ -if __name__ == '__main__': │ │ │ │ │ - main() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack │ │ │ │ │ + class event_with_name(ctypes.Structure): │ │ │ │ │ + _fields_ = (*inotify_event._fields_, ('name', ctypes.c_char * self.len)) │ │ │ │ │ │ │ │ │ │ -import importlib.abc │ │ │ │ │ -import importlib.util │ │ │ │ │ -import io │ │ │ │ │ -import sys │ │ │ │ │ -from types import ModuleType │ │ │ │ │ -from typing import BinaryIO, Dict, Iterator, Optional, Sequence │ │ │ │ │ + name = ctypes.cast(ctypes.addressof(self), ctypes.POINTER(event_with_name)).contents.name │ │ │ │ │ + assert isinstance(name, bytes) │ │ │ │ │ + return name │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class BeipackLoader(importlib.abc.SourceLoader, importlib.abc.MetaPathFinder): │ │ │ │ │ - if sys.version_info >= (3, 11): │ │ │ │ │ - from importlib.resources.abc import ResourceReader as AbstractResourceReader │ │ │ │ │ - else: │ │ │ │ │ - AbstractResourceReader = object │ │ │ │ │ +class Event(IntFlag): │ │ │ │ │ + ACCESS = auto() │ │ │ │ │ + MODIFY = auto() │ │ │ │ │ + ATTRIB = auto() │ │ │ │ │ + CLOSE_WRITE = auto() │ │ │ │ │ + CLOSE_NOWRITE = auto() │ │ │ │ │ + OPEN = auto() │ │ │ │ │ + MOVED_FROM = auto() │ │ │ │ │ + MOVED_TO = auto() │ │ │ │ │ + CREATE = auto() │ │ │ │ │ + DELETE = auto() │ │ │ │ │ + DELETE_SELF = auto() │ │ │ │ │ + MOVE_SELF = auto() │ │ │ │ │ │ │ │ │ │ - class ResourceReader(AbstractResourceReader): │ │ │ │ │ - def __init__(self, contents: Dict[str, bytes], filename: str) -> None: │ │ │ │ │ - self._contents = contents │ │ │ │ │ - self._dir = f'{filename}/' │ │ │ │ │ + UNMOUNT = 1 << 13 │ │ │ │ │ + Q_OVERFLOW = auto() │ │ │ │ │ + IGNORED = auto() │ │ │ │ │ │ │ │ │ │ - def is_resource(self, resource: str) -> bool: │ │ │ │ │ - return f'{self._dir}{resource}' in self._contents │ │ │ │ │ + ONLYDIR = 1 << 24 │ │ │ │ │ + DONT_FOLLOW = auto() │ │ │ │ │ + EXCL_UNLINK = auto() │ │ │ │ │ │ │ │ │ │ - def open_resource(self, resource: str) -> BinaryIO: │ │ │ │ │ - return io.BytesIO(self._contents[f'{self._dir}{resource}']) │ │ │ │ │ + MASK_CREATE = 1 << 28 │ │ │ │ │ + MASK_ADD = auto() │ │ │ │ │ + ISDIR = auto() │ │ │ │ │ + ONESHOT = auto() │ │ │ │ │ │ │ │ │ │ - def resource_path(self, resource: str) -> str: │ │ │ │ │ - raise FileNotFoundError │ │ │ │ │ + CLOSE = CLOSE_WRITE | CLOSE_NOWRITE │ │ │ │ │ + MOVE = MOVED_FROM | MOVED_TO │ │ │ │ │ + CHANGED = (MODIFY | ATTRIB | CLOSE_WRITE | MOVE | │ │ │ │ │ + CREATE | DELETE | DELETE_SELF | MOVE_SELF) │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing │ │ │ │ │ +from typing import TYPE_CHECKING │ │ │ │ │ │ │ │ │ │ - def contents(self) -> Iterator[str]: │ │ │ │ │ - dir_length = len(self._dir) │ │ │ │ │ - result = set() │ │ │ │ │ +# The goal here is to continue to work on Python 3.6 while pretending to have │ │ │ │ │ +# access to some modern typing features. The shims provided here are only │ │ │ │ │ +# enough for what we need for systemd_ctypes to work at runtime. │ │ │ │ │ │ │ │ │ │ - for filename in self._contents: │ │ │ │ │ - if filename.startswith(self._dir): │ │ │ │ │ - try: │ │ │ │ │ - next_slash = filename.index('/', dir_length) │ │ │ │ │ - except ValueError: │ │ │ │ │ - next_slash = None │ │ │ │ │ - result.add(filename[dir_length:next_slash]) │ │ │ │ │ │ │ │ │ │ - return iter(result) │ │ │ │ │ +if TYPE_CHECKING: │ │ │ │ │ + # See https://github.com/python/mypy/issues/1153 for why we do this separately │ │ │ │ │ + from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin │ │ │ │ │ │ │ │ │ │ - contents: Dict[str, bytes] │ │ │ │ │ - modules: Dict[str, str] │ │ │ │ │ +else: │ │ │ │ │ + # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated │ │ │ │ │ + # arrived in 3.9. Unfortunately, it's difficult to implement a mocked up │ │ │ │ │ + # version of Annotated which works with the real typing.get_args() and │ │ │ │ │ + # .get_origin() in Python 3.8, so we use our own versions there as well. │ │ │ │ │ + try: │ │ │ │ │ + from typing import Annotated, get_args, get_origin │ │ │ │ │ + except ImportError: │ │ │ │ │ + class AnnotatedMeta(type): │ │ │ │ │ + def __getitem__(cls, params): │ │ │ │ │ + class AnnotatedType: │ │ │ │ │ + __origin__ = Annotated │ │ │ │ │ + __args__ = params │ │ │ │ │ + return AnnotatedType │ │ │ │ │ │ │ │ │ │ - def __init__(self, contents: Dict[str, bytes]) -> None: │ │ │ │ │ - try: │ │ │ │ │ - contents[__file__] = __self_source__ # type: ignore[name-defined] │ │ │ │ │ - except NameError: │ │ │ │ │ + class Annotated(metaclass=AnnotatedMeta): │ │ │ │ │ pass │ │ │ │ │ │ │ │ │ │ - self.contents = contents │ │ │ │ │ - self.modules = { │ │ │ │ │ - self.get_fullname(filename): filename │ │ │ │ │ - for filename in contents │ │ │ │ │ - if filename.endswith(".py") │ │ │ │ │ - } │ │ │ │ │ - │ │ │ │ │ - def get_fullname(self, filename: str) -> str: │ │ │ │ │ - assert filename.endswith(".py") │ │ │ │ │ - filename = filename[:-3] │ │ │ │ │ - if filename.endswith("/__init__"): │ │ │ │ │ - filename = filename[:-9] │ │ │ │ │ - return filename.replace("/", ".") │ │ │ │ │ + def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]: │ │ │ │ │ + return getattr(annotation, '__args__', ()) │ │ │ │ │ │ │ │ │ │ - def get_resource_reader(self, fullname: str) -> ResourceReader: │ │ │ │ │ - return BeipackLoader.ResourceReader(self.contents, fullname.replace('.', '/')) │ │ │ │ │ + def get_origin(annotation: typing.Any) -> typing.Any: │ │ │ │ │ + return getattr(annotation, '__origin__', None) │ │ │ │ │ │ │ │ │ │ - def get_data(self, path: str) -> bytes: │ │ │ │ │ - return self.contents[path] │ │ │ │ │ + try: │ │ │ │ │ + from typing import ForwardRef │ │ │ │ │ + except ImportError: │ │ │ │ │ + from typing import _ForwardRef as ForwardRef │ │ │ │ │ │ │ │ │ │ - def get_filename(self, fullname: str) -> str: │ │ │ │ │ - return self.modules[fullname] │ │ │ │ │ + try: │ │ │ │ │ + from typing import TypeGuard │ │ │ │ │ + except ImportError: │ │ │ │ │ + T = typing.TypeVar('T') │ │ │ │ │ │ │ │ │ │ - def find_spec( │ │ │ │ │ - self, │ │ │ │ │ - fullname: str, │ │ │ │ │ - path: Optional[Sequence[str]], │ │ │ │ │ - target: Optional[ModuleType] = None │ │ │ │ │ - ) -> Optional[importlib.machinery.ModuleSpec]: │ │ │ │ │ - if fullname not in self.modules: │ │ │ │ │ - return None │ │ │ │ │ - return importlib.util.spec_from_loader(fullname, self) │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/bei/data/__init__.py': br'''import sys │ │ │ │ │ + class TypeGuard(typing.Generic[T]): │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ -if sys.version_info >= (3, 9): │ │ │ │ │ - import importlib.abc │ │ │ │ │ - import importlib.resources │ │ │ │ │ │ │ │ │ │ - def read_data_file(filename: str) -> str: │ │ │ │ │ - return (importlib.resources.files(__name__) / filename).read_text() │ │ │ │ │ -else: │ │ │ │ │ - def read_data_file(filename: str) -> str: │ │ │ │ │ - loader = __loader__ # type: ignore[name-defined] │ │ │ │ │ - data = loader.get_data(__file__.replace('__init__.py', filename)) │ │ │ │ │ - return data.decode('utf-8') │ │ │ │ │ +__all__ = ( │ │ │ │ │ + 'Annotated', │ │ │ │ │ + 'ForwardRef', │ │ │ │ │ + 'TypeGuard', │ │ │ │ │ + 'get_args', │ │ │ │ │ + 'get_origin', │ │ │ │ │ + 'TYPE_CHECKING', │ │ │ │ │ +) │ │ │ │ │ ''', │ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/bus.py': br'''# systemd_ctypes │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ @@ -8185,14 +8199,108 @@ │ │ │ │ │ try: │ │ │ │ │ method = self._find_member(interface, 'methods', name) │ │ │ │ │ assert isinstance(method, Interface.Method) │ │ │ │ │ return method._invoke(self, message) │ │ │ │ │ except Object.Method.Unhandled: │ │ │ │ │ return False │ │ │ │ │ ''', │ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/py.typed': br'''''', │ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/introspection.py': br'''# systemd_ctypes │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ + │ │ │ │ │ +import xml.etree.ElementTree as ET │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def parse_method(method): │ │ │ │ │ + return { │ │ │ │ │ + "in": [tag.attrib['type'] for tag in method.findall("arg") if tag.get('direction', 'in') == 'in'], │ │ │ │ │ + "out": [tag.attrib['type'] for tag in method.findall("arg[@direction='out']")] │ │ │ │ │ + } │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def parse_property(prop): │ │ │ │ │ + return { │ │ │ │ │ + "flags": 'w' if prop.attrib.get('access') == 'write' else 'r', │ │ │ │ │ + "type": prop.attrib['type'] │ │ │ │ │ + } │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def parse_signal(signal): │ │ │ │ │ + return {"in": [tag.attrib['type'] for tag in signal.findall("arg")]} │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def parse_interface(interface): │ │ │ │ │ + return { │ │ │ │ │ + "methods": {tag.attrib['name']: parse_method(tag) for tag in interface.findall('method')}, │ │ │ │ │ + "properties": {tag.attrib['name']: parse_property(tag) for tag in interface.findall('property')}, │ │ │ │ │ + "signals": {tag.attrib['name']: parse_signal(tag) for tag in interface.findall('signal')} │ │ │ │ │ + } │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def parse_xml(xml): │ │ │ │ │ + et = ET.fromstring(xml) │ │ │ │ │ + return {tag.attrib['name']: parse_interface(tag) for tag in et.findall('interface')} │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +# Pretend like this is a little bit functional │ │ │ │ │ +def element(tag, children=(), **kwargs): │ │ │ │ │ + tag = ET.Element(tag, kwargs) │ │ │ │ │ + tag.extend(children) │ │ │ │ │ + return tag │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def method_to_xml(name, method_info): │ │ │ │ │ + return element('method', name=name, │ │ │ │ │ + children=[ │ │ │ │ │ + element('arg', type=arg_type, direction=direction) │ │ │ │ │ + for direction in ['in', 'out'] │ │ │ │ │ + for arg_type in method_info[direction] │ │ │ │ │ + ]) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def property_to_xml(name, property_info): │ │ │ │ │ + return element('property', name=name, │ │ │ │ │ + access='write' if property_info['flags'] == 'w' else 'read', │ │ │ │ │ + type=property_info['type']) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def signal_to_xml(name, signal_info): │ │ │ │ │ + return element('signal', name=name, │ │ │ │ │ + children=[ │ │ │ │ │ + element('arg', type=arg_type) for arg_type in signal_info['in'] │ │ │ │ │ + ]) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def interface_to_xml(name, interface_info): │ │ │ │ │ + return element('interface', name=name, │ │ │ │ │ + children=[ │ │ │ │ │ + *(method_to_xml(name, info) for name, info in interface_info['methods'].items()), │ │ │ │ │ + *(property_to_xml(name, info) for name, info in interface_info['properties'].items()), │ │ │ │ │ + *(signal_to_xml(name, info) for name, info in interface_info['signals'].items()), │ │ │ │ │ + ]) │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +def to_xml(interfaces): │ │ │ │ │ + node = element('node', children=(interface_to_xml(name, members) for name, members in interfaces.items())) │ │ │ │ │ + return ET.tostring(node, encoding='unicode') │ │ │ │ │ +''', │ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/libsystemd.py': r'''# systemd_ctypes │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ @@ -8520,364 +8628,15 @@ │ │ │ │ │ sd_bus_message, │ │ │ │ │ sd_bus_slot, │ │ │ │ │ sd_event, │ │ │ │ │ sd_event_source, │ │ │ │ │ }: │ │ │ │ │ cls._install_cfuncs(libsystemd) │ │ │ │ │ '''.encode('utf-8'), │ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ - │ │ │ │ │ -import ctypes │ │ │ │ │ -from enum import IntFlag, auto │ │ │ │ │ -from typing import Optional │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class inotify_event(ctypes.Structure): │ │ │ │ │ - _fields_ = ( │ │ │ │ │ - ('wd', ctypes.c_int32), │ │ │ │ │ - ('mask', ctypes.c_uint32), │ │ │ │ │ - ('cookie', ctypes.c_uint32), │ │ │ │ │ - ('len', ctypes.c_uint32), │ │ │ │ │ - ) │ │ │ │ │ - │ │ │ │ │ - @property │ │ │ │ │ - def name(self) -> Optional[bytes]: │ │ │ │ │ - if self.len == 0: │ │ │ │ │ - return None │ │ │ │ │ - │ │ │ │ │ - class event_with_name(ctypes.Structure): │ │ │ │ │ - _fields_ = (*inotify_event._fields_, ('name', ctypes.c_char * self.len)) │ │ │ │ │ - │ │ │ │ │ - name = ctypes.cast(ctypes.addressof(self), ctypes.POINTER(event_with_name)).contents.name │ │ │ │ │ - assert isinstance(name, bytes) │ │ │ │ │ - return name │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class Event(IntFlag): │ │ │ │ │ - ACCESS = auto() │ │ │ │ │ - MODIFY = auto() │ │ │ │ │ - ATTRIB = auto() │ │ │ │ │ - CLOSE_WRITE = auto() │ │ │ │ │ - CLOSE_NOWRITE = auto() │ │ │ │ │ - OPEN = auto() │ │ │ │ │ - MOVED_FROM = auto() │ │ │ │ │ - MOVED_TO = auto() │ │ │ │ │ - CREATE = auto() │ │ │ │ │ - DELETE = auto() │ │ │ │ │ - DELETE_SELF = auto() │ │ │ │ │ - MOVE_SELF = auto() │ │ │ │ │ - │ │ │ │ │ - UNMOUNT = 1 << 13 │ │ │ │ │ - Q_OVERFLOW = auto() │ │ │ │ │ - IGNORED = auto() │ │ │ │ │ - │ │ │ │ │ - ONLYDIR = 1 << 24 │ │ │ │ │ - DONT_FOLLOW = auto() │ │ │ │ │ - EXCL_UNLINK = auto() │ │ │ │ │ - │ │ │ │ │ - MASK_CREATE = 1 << 28 │ │ │ │ │ - MASK_ADD = auto() │ │ │ │ │ - ISDIR = auto() │ │ │ │ │ - ONESHOT = auto() │ │ │ │ │ - │ │ │ │ │ - CLOSE = CLOSE_WRITE | CLOSE_NOWRITE │ │ │ │ │ - MOVE = MOVED_FROM | MOVED_TO │ │ │ │ │ - CHANGED = (MODIFY | ATTRIB | CLOSE_WRITE | MOVE | │ │ │ │ │ - CREATE | DELETE | DELETE_SELF | MOVE_SELF) │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing │ │ │ │ │ -from typing import TYPE_CHECKING │ │ │ │ │ - │ │ │ │ │ -# The goal here is to continue to work on Python 3.6 while pretending to have │ │ │ │ │ -# access to some modern typing features. The shims provided here are only │ │ │ │ │ -# enough for what we need for systemd_ctypes to work at runtime. │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -if TYPE_CHECKING: │ │ │ │ │ - # See https://github.com/python/mypy/issues/1153 for why we do this separately │ │ │ │ │ - from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin │ │ │ │ │ - │ │ │ │ │ -else: │ │ │ │ │ - # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated │ │ │ │ │ - # arrived in 3.9. Unfortunately, it's difficult to implement a mocked up │ │ │ │ │ - # version of Annotated which works with the real typing.get_args() and │ │ │ │ │ - # .get_origin() in Python 3.8, so we use our own versions there as well. │ │ │ │ │ - try: │ │ │ │ │ - from typing import Annotated, get_args, get_origin │ │ │ │ │ - except ImportError: │ │ │ │ │ - class AnnotatedMeta(type): │ │ │ │ │ - def __getitem__(cls, params): │ │ │ │ │ - class AnnotatedType: │ │ │ │ │ - __origin__ = Annotated │ │ │ │ │ - __args__ = params │ │ │ │ │ - return AnnotatedType │ │ │ │ │ - │ │ │ │ │ - class Annotated(metaclass=AnnotatedMeta): │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]: │ │ │ │ │ - return getattr(annotation, '__args__', ()) │ │ │ │ │ - │ │ │ │ │ - def get_origin(annotation: typing.Any) -> typing.Any: │ │ │ │ │ - return getattr(annotation, '__origin__', None) │ │ │ │ │ - │ │ │ │ │ - try: │ │ │ │ │ - from typing import ForwardRef │ │ │ │ │ - except ImportError: │ │ │ │ │ - from typing import _ForwardRef as ForwardRef │ │ │ │ │ - │ │ │ │ │ - try: │ │ │ │ │ - from typing import TypeGuard │ │ │ │ │ - except ImportError: │ │ │ │ │ - T = typing.TypeVar('T') │ │ │ │ │ - │ │ │ │ │ - class TypeGuard(typing.Generic[T]): │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -__all__ = ( │ │ │ │ │ - 'Annotated', │ │ │ │ │ - 'ForwardRef', │ │ │ │ │ - 'TypeGuard', │ │ │ │ │ - 'get_args', │ │ │ │ │ - 'get_origin', │ │ │ │ │ - 'TYPE_CHECKING', │ │ │ │ │ -) │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/librarywrapper.py': br'''# systemd_ctypes │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ - │ │ │ │ │ -import ctypes │ │ │ │ │ -import inspect │ │ │ │ │ -import logging │ │ │ │ │ -import os │ │ │ │ │ -import sys │ │ │ │ │ -import types │ │ │ │ │ -from typing import ( │ │ │ │ │ - Any, │ │ │ │ │ - Callable, │ │ │ │ │ - Dict, │ │ │ │ │ - Generic, │ │ │ │ │ - NewType, │ │ │ │ │ - NoReturn, │ │ │ │ │ - Optional, │ │ │ │ │ - Tuple, │ │ │ │ │ - Type, │ │ │ │ │ - TypeVar, │ │ │ │ │ - Union, │ │ │ │ │ -) │ │ │ │ │ - │ │ │ │ │ -from . import typing │ │ │ │ │ - │ │ │ │ │ -# First in 3.10, and conditional import gives type errors │ │ │ │ │ -NoneType = type(None) │ │ │ │ │ - │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ - │ │ │ │ │ -if typing.TYPE_CHECKING: │ │ │ │ │ - CType = TypeVar("CType", bound=ctypes._CData) │ │ │ │ │ - Callback = ctypes._FuncPointer │ │ │ │ │ -else: │ │ │ │ │ - CType = TypeVar("CType") │ │ │ │ │ - Callback = ctypes.c_void_p │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -if typing.TYPE_CHECKING: │ │ │ │ │ - class Reference(Generic[CType], ctypes._Pointer[CType]): │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - def byref(x: CType) -> Reference[CType]: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ -else: │ │ │ │ │ - class Reference(Generic[CType]): │ │ │ │ │ - pass │ │ │ │ │ - │ │ │ │ │ - byref = ctypes.byref │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -UserData = Optional[ctypes.c_void_p] │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class negative_errno(ctypes.c_int): │ │ │ │ │ - def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> int: │ │ │ │ │ - result = self.value │ │ │ │ │ - if result < 0: │ │ │ │ │ - raise OSError(-result, f"{func.__name__}: {os.strerror(-result)}") │ │ │ │ │ - return result │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class utf8(ctypes.c_char_p): │ │ │ │ │ - def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> str: │ │ │ │ │ - assert self.value is not None │ │ │ │ │ - return self.value.decode() │ │ │ │ │ - │ │ │ │ │ - @classmethod │ │ │ │ │ - def from_param(cls, value: str) -> 'utf8': │ │ │ │ │ - return cls(value.encode()) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class utf8_or_null(ctypes.c_char_p): │ │ │ │ │ - def errcheck(self, │ │ │ │ │ - func: Callable[..., object], │ │ │ │ │ - _args: Tuple[object, ...]) -> Optional[str]: │ │ │ │ │ - return self.value.decode() if self.value is not None else None │ │ │ │ │ - │ │ │ │ │ - @classmethod │ │ │ │ │ - def from_param(cls, value: Optional[str]) -> 'utf8_or_null': │ │ │ │ │ - return cls(value.encode() if value is not None else None) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class boolint(ctypes.c_int): │ │ │ │ │ - def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> bool: │ │ │ │ │ - return bool(self.value) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -WeakReference = NewType("WeakReference", int) │ │ │ │ │ -Errno = typing.Annotated[NoReturn, "errno"] │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -type_map = { │ │ │ │ │ - Union[None, Errno]: negative_errno, # technically returns int │ │ │ │ │ - Union[bool, Errno]: negative_errno, # technically returns int │ │ │ │ │ - Union[int, Errno]: negative_errno, │ │ │ │ │ - bool: boolint, │ │ │ │ │ - Optional[str]: utf8_or_null, │ │ │ │ │ - str: utf8, │ │ │ │ │ - int: ctypes.c_int, │ │ │ │ │ - WeakReference: ctypes.c_void_p │ │ │ │ │ -} │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def map_type(annotation: Any, global_vars: Dict[str, object]) -> Any: │ │ │ │ │ - try: │ │ │ │ │ - return type_map[annotation] │ │ │ │ │ - except KeyError: │ │ │ │ │ - pass # ... and try more cases below │ │ │ │ │ - │ │ │ │ │ - if isinstance(annotation, typing.ForwardRef): │ │ │ │ │ - annotation = annotation.__forward_arg__ │ │ │ │ │ - │ │ │ │ │ - if isinstance(annotation, str): │ │ │ │ │ - annotation = global_vars[annotation] │ │ │ │ │ - │ │ │ │ │ - origin = typing.get_origin(annotation) │ │ │ │ │ - args = typing.get_args(annotation) │ │ │ │ │ - │ │ │ │ │ - if origin is Reference: │ │ │ │ │ - return ctypes.POINTER(map_type(args[0], global_vars)) │ │ │ │ │ - │ │ │ │ │ - elif origin is Union and NoneType in args: │ │ │ │ │ - # the C pointer types are already nullable │ │ │ │ │ - other_arg, = set(args) - {NoneType} │ │ │ │ │ - return map_type(other_arg, global_vars) │ │ │ │ │ - │ │ │ │ │ - elif origin is typing.Annotated: │ │ │ │ │ - return args[1] │ │ │ │ │ - │ │ │ │ │ - else: │ │ │ │ │ - assert origin is None, origin │ │ │ │ │ - return annotation │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class ReferenceType(ctypes.c_void_p): │ │ │ │ │ - @classmethod │ │ │ │ │ - def _install_cfuncs(cls, cdll: ctypes.CDLL) -> None: │ │ │ │ │ - logger.debug('Installing stubs for %s:', cls) │ │ │ │ │ - stubs = tuple(cls.__dict__.items()) │ │ │ │ │ - for name, stub in stubs: │ │ │ │ │ - if name.startswith("__"): │ │ │ │ │ - continue │ │ │ │ │ - cls._wrap(cdll, stub) │ │ │ │ │ - │ │ │ │ │ - cls._wrap(cdll, cls._ref) │ │ │ │ │ - cls._wrap(cdll, cls._unref) │ │ │ │ │ - │ │ │ │ │ - @classmethod │ │ │ │ │ - def _wrap(cls, cdll: ctypes.CDLL, stub: object) -> None: │ │ │ │ │ - stub_type = type(stub) │ │ │ │ │ - if isinstance(stub, staticmethod): │ │ │ │ │ - # In older Python versions, staticmethod() isn't considered │ │ │ │ │ - # callable, doesn't have a name, and can't be introspected with │ │ │ │ │ - # inspect.signature(). Unwrap it. │ │ │ │ │ - stub = stub.__func__ │ │ │ │ │ - assert isinstance(stub, types.FunctionType) │ │ │ │ │ - name = stub.__name__ │ │ │ │ │ - signature = inspect.signature(stub) │ │ │ │ │ - stub_globals = sys.modules.get(cls.__module__).__dict__ │ │ │ │ │ - │ │ │ │ │ - func = cdll[f'{cls.__name__}_{name.lstrip("_")}'] │ │ │ │ │ - func.argtypes = tuple( │ │ │ │ │ - map_type(parameter.annotation, stub_globals) │ │ │ │ │ - for parameter in signature.parameters.values() │ │ │ │ │ - ) │ │ │ │ │ - func.restype = map_type(signature.return_annotation, stub_globals) │ │ │ │ │ - errcheck = getattr(func.restype, 'errcheck', None) │ │ │ │ │ - if errcheck is not None: │ │ │ │ │ - func.errcheck = errcheck │ │ │ │ │ - │ │ │ │ │ - logger.debug(' create wrapper %s.%s%s', cls.__name__, name, signature) │ │ │ │ │ - logger.debug(' args %s res %s', func.argtypes, func.restype) │ │ │ │ │ - │ │ │ │ │ - # ctypes function pointer objects don't implement the usual function │ │ │ │ │ - # descriptor logic, which means they won't bind as methods. For static │ │ │ │ │ - # methods, that's good, but for instance methods, we add a wrapper as │ │ │ │ │ - # the easiest and most performant way to get the binding behaviour. │ │ │ │ │ - if stub_type is not staticmethod: │ │ │ │ │ - setattr(cls, name, lambda *args: func(*args)) │ │ │ │ │ - else: │ │ │ │ │ - setattr(cls, name, func) │ │ │ │ │ - │ │ │ │ │ - def _unref(self: 'ReferenceType') -> None: │ │ │ │ │ - ... │ │ │ │ │ - │ │ │ │ │ - def _ref(self: 'ReferenceType') -> None: │ │ │ │ │ - ... │ │ │ │ │ - │ │ │ │ │ - T = TypeVar("T", bound='ReferenceType') │ │ │ │ │ - │ │ │ │ │ - @classmethod │ │ │ │ │ - def ref(cls: Type[T], origin: WeakReference) -> T: │ │ │ │ │ - self = cls(origin) │ │ │ │ │ - self._ref() │ │ │ │ │ - return self │ │ │ │ │ - │ │ │ │ │ - def __del__(self) -> None: │ │ │ │ │ - if self.value is not None: │ │ │ │ │ - self._unref() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/introspection.py': br'''# systemd_ctypes │ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/__init__.py': br'''# systemd_ctypes │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ @@ -8886,89 +8645,36 @@ │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ # along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import xml.etree.ElementTree as ET │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def parse_method(method): │ │ │ │ │ - return { │ │ │ │ │ - "in": [tag.attrib['type'] for tag in method.findall("arg") if tag.get('direction', 'in') == 'in'], │ │ │ │ │ - "out": [tag.attrib['type'] for tag in method.findall("arg[@direction='out']")] │ │ │ │ │ - } │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def parse_property(prop): │ │ │ │ │ - return { │ │ │ │ │ - "flags": 'w' if prop.attrib.get('access') == 'write' else 'r', │ │ │ │ │ - "type": prop.attrib['type'] │ │ │ │ │ - } │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def parse_signal(signal): │ │ │ │ │ - return {"in": [tag.attrib['type'] for tag in signal.findall("arg")]} │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def parse_interface(interface): │ │ │ │ │ - return { │ │ │ │ │ - "methods": {tag.attrib['name']: parse_method(tag) for tag in interface.findall('method')}, │ │ │ │ │ - "properties": {tag.attrib['name']: parse_property(tag) for tag in interface.findall('property')}, │ │ │ │ │ - "signals": {tag.attrib['name']: parse_signal(tag) for tag in interface.findall('signal')} │ │ │ │ │ - } │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def parse_xml(xml): │ │ │ │ │ - et = ET.fromstring(xml) │ │ │ │ │ - return {tag.attrib['name']: parse_interface(tag) for tag in et.findall('interface')} │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -# Pretend like this is a little bit functional │ │ │ │ │ -def element(tag, children=(), **kwargs): │ │ │ │ │ - tag = ET.Element(tag, kwargs) │ │ │ │ │ - tag.extend(children) │ │ │ │ │ - return tag │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def method_to_xml(name, method_info): │ │ │ │ │ - return element('method', name=name, │ │ │ │ │ - children=[ │ │ │ │ │ - element('arg', type=arg_type, direction=direction) │ │ │ │ │ - for direction in ['in', 'out'] │ │ │ │ │ - for arg_type in method_info[direction] │ │ │ │ │ - ]) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def property_to_xml(name, property_info): │ │ │ │ │ - return element('property', name=name, │ │ │ │ │ - access='write' if property_info['flags'] == 'w' else 'read', │ │ │ │ │ - type=property_info['type']) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -def signal_to_xml(name, signal_info): │ │ │ │ │ - return element('signal', name=name, │ │ │ │ │ - children=[ │ │ │ │ │ - element('arg', type=arg_type) for arg_type in signal_info['in'] │ │ │ │ │ - ]) │ │ │ │ │ - │ │ │ │ │ +"""systemd_ctypes""" │ │ │ │ │ │ │ │ │ │ -def interface_to_xml(name, interface_info): │ │ │ │ │ - return element('interface', name=name, │ │ │ │ │ - children=[ │ │ │ │ │ - *(method_to_xml(name, info) for name, info in interface_info['methods'].items()), │ │ │ │ │ - *(property_to_xml(name, info) for name, info in interface_info['properties'].items()), │ │ │ │ │ - *(signal_to_xml(name, info) for name, info in interface_info['signals'].items()), │ │ │ │ │ - ]) │ │ │ │ │ +__version__ = "0" │ │ │ │ │ │ │ │ │ │ +from .bus import Bus, BusError, BusMessage │ │ │ │ │ +from .bustypes import BusType, JSONEncoder, Variant │ │ │ │ │ +from .event import Event, EventLoopPolicy, run_async │ │ │ │ │ +from .pathwatch import Handle, PathWatch │ │ │ │ │ │ │ │ │ │ -def to_xml(interfaces): │ │ │ │ │ - node = element('node', children=(interface_to_xml(name, members) for name, members in interfaces.items())) │ │ │ │ │ - return ET.tostring(node, encoding='unicode') │ │ │ │ │ +__all__ = [ │ │ │ │ │ + "Bus", │ │ │ │ │ + "BusError", │ │ │ │ │ + "BusMessage", │ │ │ │ │ + "BusType", │ │ │ │ │ + "Event", │ │ │ │ │ + "EventLoopPolicy", │ │ │ │ │ + "Handle", │ │ │ │ │ + "JSONEncoder", │ │ │ │ │ + "PathWatch", │ │ │ │ │ + "Variant", │ │ │ │ │ + "run_async", │ │ │ │ │ +] │ │ │ │ │ ''', │ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/bustypes.py': br'''# systemd_ctypes │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ @@ -9514,54 +9220,14 @@ │ │ │ │ │ def default(self, obj: object) -> object: │ │ │ │ │ if isinstance(obj, Variant): │ │ │ │ │ return {"t": obj.type.typestring, "v": obj.value} │ │ │ │ │ elif isinstance(obj, bytes): │ │ │ │ │ return binascii.b2a_base64(obj, newline=False).decode('ascii') │ │ │ │ │ return super().default(obj) │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/__init__.py': br'''# systemd_ctypes │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ - │ │ │ │ │ -"""systemd_ctypes""" │ │ │ │ │ - │ │ │ │ │ -__version__ = "0" │ │ │ │ │ - │ │ │ │ │ -from .bus import Bus, BusError, BusMessage │ │ │ │ │ -from .bustypes import BusType, JSONEncoder, Variant │ │ │ │ │ -from .event import Event, EventLoopPolicy, run_async │ │ │ │ │ -from .pathwatch import Handle, PathWatch │ │ │ │ │ - │ │ │ │ │ -__all__ = [ │ │ │ │ │ - "Bus", │ │ │ │ │ - "BusError", │ │ │ │ │ - "BusMessage", │ │ │ │ │ - "BusType", │ │ │ │ │ - "Event", │ │ │ │ │ - "EventLoopPolicy", │ │ │ │ │ - "Handle", │ │ │ │ │ - "JSONEncoder", │ │ │ │ │ - "PathWatch", │ │ │ │ │ - "Variant", │ │ │ │ │ - "run_async", │ │ │ │ │ -] │ │ │ │ │ -''', │ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/event.py': br'''# systemd_ctypes │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ @@ -9693,15 +9359,14 @@ │ │ │ │ │ asyncio._systemd_ctypes_polyfills = True # type: ignore[attr-defined] │ │ │ │ │ │ │ │ │ │ asyncio.run(main, debug=debug) │ │ │ │ │ │ │ │ │ │ if polyfill: │ │ │ │ │ del asyncio.create_task, asyncio.get_running_loop, asyncio.run │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/py.typed': br'''''', │ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes │ │ │ │ │ # │ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ @@ -9977,1466 +9642,1801 @@ │ │ │ │ │ def close(self) -> None: │ │ │ │ │ for invalidator in self._invalidators: │ │ │ │ │ invalidator.close() │ │ │ │ │ self._invalidators = [] │ │ │ │ │ self._source = None │ │ │ │ │ self._fd.close() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ + 'cockpit/data/__init__.py': br'''import sys │ │ │ │ │ + │ │ │ │ │ +if sys.version_info >= (3, 11): │ │ │ │ │ + import importlib.resources │ │ │ │ │ + │ │ │ │ │ + def read_cockpit_data_file(filename: str) -> bytes: │ │ │ │ │ + return (importlib.resources.files('cockpit.data') / filename).read_bytes() │ │ │ │ │ + │ │ │ │ │ +else: │ │ │ │ │ + import importlib.abc │ │ │ │ │ + │ │ │ │ │ + def read_cockpit_data_file(filename: str) -> bytes: │ │ │ │ │ + # https://github.com/python/mypy/issues/4182 │ │ │ │ │ + loader = __loader__ # type: ignore[name-defined] │ │ │ │ │ + assert isinstance(loader, importlib.abc.ResourceLoader) │ │ │ │ │ + │ │ │ │ │ + path = __file__.replace('__init__.py', filename) │ │ │ │ │ + return loader.get_data(path) │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/data/fail.html': br''' │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ + @@message@@ │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +
│ │ │ │ │ + │ │ │ │ │ +

@@message@@

│ │ │ │ │ +
│ │ │ │ │ + │ │ │ │ │ + │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import ctypes │ │ │ │ │ +# Missing stuff compared to the C bridge that we should probably add: │ │ │ │ │ +# │ │ │ │ │ +# - removing matches │ │ │ │ │ +# - removing watches │ │ │ │ │ +# - emitting of signals │ │ │ │ │ +# - publishing of objects │ │ │ │ │ +# - failing more gracefully in some cases (during open, etc) │ │ │ │ │ +# │ │ │ │ │ +# Stuff we might or might not do: │ │ │ │ │ +# │ │ │ │ │ +# - using non-default service names │ │ │ │ │ +# │ │ │ │ │ +# Stuff we should probably not do: │ │ │ │ │ +# │ │ │ │ │ +# - emulation of ObjectManager via recursive introspection │ │ │ │ │ +# - automatic detection of ObjectManager below the given path_namespace │ │ │ │ │ +# - recursive scraping of properties for new object paths │ │ │ │ │ +# (for path_namespace watches that don't hit an ObjectManager) │ │ │ │ │ + │ │ │ │ │ +import asyncio │ │ │ │ │ import errno │ │ │ │ │ -import os │ │ │ │ │ -import re │ │ │ │ │ -import socket │ │ │ │ │ -from typing import ClassVar, Iterable, Match, Pattern │ │ │ │ │ +import json │ │ │ │ │ +import logging │ │ │ │ │ +import traceback │ │ │ │ │ +import xml.etree.ElementTree as ET │ │ │ │ │ │ │ │ │ │ +from cockpit._vendor import systemd_ctypes │ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection │ │ │ │ │ │ │ │ │ │ -class SshError(Exception): │ │ │ │ │ - PATTERN: ClassVar[Pattern] │ │ │ │ │ +from ..channel import Channel, ChannelError │ │ │ │ │ │ │ │ │ │ - def __init__(self, match: 'Match | None', stderr: str) -> None: │ │ │ │ │ - super().__init__(match.group(0) if match is not None else stderr) │ │ │ │ │ - self.stderr = stderr │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ +# The dbusjson3 payload │ │ │ │ │ +# │ │ │ │ │ +# This channel payload type translates JSON encoded messages on a │ │ │ │ │ +# Cockpit channel to D-Bus messages, in a mostly straightforward way. │ │ │ │ │ +# See doc/protocol.md for a description of the basics. │ │ │ │ │ +# │ │ │ │ │ +# However, dbusjson3 offers some advanced features as well that are │ │ │ │ │ +# meant to support the "magic" DBusProxy objects implemented by │ │ │ │ │ +# cockpit.js. Those proxy objects "magically" expose all the methods │ │ │ │ │ +# and properties of a D-Bus interface without requiring any explicit │ │ │ │ │ +# binding code to be generated for a JavaScript client. A dbusjson3 │ │ │ │ │ +# channel does this by doing automatic introspection and property │ │ │ │ │ +# retrieval without much direction from the JavaScript client. │ │ │ │ │ +# │ │ │ │ │ +# The details of what exactly is done is not specified very strictly, │ │ │ │ │ +# and the Python bridge will likely differ from the C bridge │ │ │ │ │ +# significantly. This will be informed by what existing code actually │ │ │ │ │ +# needs, and we might end up with a more concrete description of what │ │ │ │ │ +# a client can actually expect. │ │ │ │ │ +# │ │ │ │ │ +# Here is an example of a more complex scenario: │ │ │ │ │ +# │ │ │ │ │ +# - The client adds a "watch" for a path namespace. There is a │ │ │ │ │ +# ObjectManager at the given path and the bridge emits "meta" and │ │ │ │ │ +# "notify" messages to describe all interfaces and objects reported │ │ │ │ │ +# by that ObjectManager. │ │ │ │ │ +# │ │ │ │ │ +# - The client makes a method call that causes a new object with a new │ │ │ │ │ +# interface to appear at the ObjectManager. The bridge will send a │ │ │ │ │ +# "meta" and "notify" message to describe this new object. │ │ │ │ │ +# │ │ │ │ │ +# - Since the InterfacesAdded signal was emitted before the method │ │ │ │ │ +# reply, the bridge must send the "meta" and "notify" messages │ │ │ │ │ +# before the method reply message. │ │ │ │ │ +# │ │ │ │ │ +# - However, in order to construct the "meta" message, the bridge must │ │ │ │ │ +# perform a Introspect call, and consequently must delay sending the │ │ │ │ │ +# method reply until that call has finished. │ │ │ │ │ +# │ │ │ │ │ +# The Python bridge implements this delaying of messages with │ │ │ │ │ +# coroutines and a fair mutex. Every message coming from D-Bus will │ │ │ │ │ +# wait on the mutex for its turn to send its message on the Cockpit │ │ │ │ │ +# channel, and will keep that mutex locked until it is done with │ │ │ │ │ +# sending. Since the mutex is fair, everyone will nicely wait in line │ │ │ │ │ +# without messages getting re-ordered. │ │ │ │ │ +# │ │ │ │ │ +# The scenario above will play out like this: │ │ │ │ │ +# │ │ │ │ │ +# - While adding the initial "watch", the lock is held until the │ │ │ │ │ +# "meta" and "notify" messages have been sent. │ │ │ │ │ +# │ │ │ │ │ +# - Later, when the InterfacesAdded signal comes in that has been │ │ │ │ │ +# triggered by the method call, the mutex will be locked while the │ │ │ │ │ +# necessary introspection is going on. │ │ │ │ │ +# │ │ │ │ │ +# - The method reply will likely come while the mutex is locked, and │ │ │ │ │ +# the task for sending that reply on the Cockpit channel will enter │ │ │ │ │ +# the wait queue of the mutex. │ │ │ │ │ +# │ │ │ │ │ +# - Once the introspection is done and the new "meta" and "notify" │ │ │ │ │ +# messages have been sent, the mutex is unlocked, the method reply │ │ │ │ │ +# task acquires it, and sends its message. │ │ │ │ │ │ │ │ │ │ -class SshAuthenticationError(SshError): │ │ │ │ │ - PATTERN = re.compile(r'^([^:]+): Permission denied \(([^()]+)\)\.$', re.M) │ │ │ │ │ │ │ │ │ │ - def __init__(self, match: Match, stderr: str) -> None: │ │ │ │ │ - super().__init__(match, stderr) │ │ │ │ │ - self.destination = match.group(1) │ │ │ │ │ - self.methods = match.group(2).split(',') │ │ │ │ │ - self.message = match.group(0) │ │ │ │ │ +class InterfaceCache: │ │ │ │ │ + def __init__(self): │ │ │ │ │ + self.cache = {} │ │ │ │ │ + self.old = set() # Interfaces already returned by get_interface_if_new │ │ │ │ │ │ │ │ │ │ + def inject(self, interfaces): │ │ │ │ │ + self.cache.update(interfaces) │ │ │ │ │ │ │ │ │ │ -# generic host key error for OSes without KnownHostsCommand support │ │ │ │ │ -class SshHostKeyError(SshError): │ │ │ │ │ - PATTERN = re.compile(r'^Host key verification failed.$', re.M) │ │ │ │ │ + async def introspect_path(self, bus, destination, object_path): │ │ │ │ │ + xml, = await bus.call_method_async(destination, object_path, │ │ │ │ │ + 'org.freedesktop.DBus.Introspectable', │ │ │ │ │ + 'Introspect') │ │ │ │ │ │ │ │ │ │ + et = ET.fromstring(xml) │ │ │ │ │ │ │ │ │ │ -# specific errors for OSes with KnownHostsCommand │ │ │ │ │ -class SshUnknownHostKeyError(SshHostKeyError): │ │ │ │ │ - PATTERN = re.compile(r'^No .* host key is known.*Host key verification failed.$', re.S | re.M) │ │ │ │ │ + interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')} │ │ │ │ │ │ │ │ │ │ + # Add all interfaces we found: we might use them later │ │ │ │ │ + self.inject(interfaces) │ │ │ │ │ │ │ │ │ │ -class SshChangedHostKeyError(SshHostKeyError): │ │ │ │ │ - PATTERN = re.compile(r'warning.*remote host identification has changed', re.I) │ │ │ │ │ + return interfaces │ │ │ │ │ │ │ │ │ │ + async def get_interface(self, interface_name, bus=None, destination=None, object_path=None): │ │ │ │ │ + try: │ │ │ │ │ + return self.cache[interface_name] │ │ │ │ │ + except KeyError: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ -# Functionality for mapping getaddrinfo()-family error messages to their │ │ │ │ │ -# equivalent Python exceptions. │ │ │ │ │ -def make_gaierror_map() -> 'Iterable[tuple[str, int]]': │ │ │ │ │ - libc = ctypes.CDLL(None) │ │ │ │ │ - libc.gai_strerror.restype = ctypes.c_char_p │ │ │ │ │ + if bus and object_path: │ │ │ │ │ + try: │ │ │ │ │ + await self.introspect_path(bus, destination, object_path) │ │ │ │ │ + except BusError: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - for key in dir(socket): │ │ │ │ │ - if key.startswith('EAI_'): │ │ │ │ │ - errnum = getattr(socket, key) │ │ │ │ │ - yield libc.gai_strerror(errnum).decode('utf-8'), errnum │ │ │ │ │ + return self.cache.get(interface_name) │ │ │ │ │ │ │ │ │ │ + async def get_interface_if_new(self, interface_name, bus, destination, object_path): │ │ │ │ │ + if interface_name in self.old: │ │ │ │ │ + return None │ │ │ │ │ + self.old.add(interface_name) │ │ │ │ │ + return await self.get_interface(interface_name, bus, destination, object_path) │ │ │ │ │ │ │ │ │ │ -gaierror_map = dict(make_gaierror_map()) │ │ │ │ │ + async def get_signature(self, interface_name, method, bus=None, destination=None, object_path=None): │ │ │ │ │ + interface = await self.get_interface(interface_name, bus, destination, object_path) │ │ │ │ │ + if interface is None: │ │ │ │ │ + raise KeyError(f'Interface {interface_name} is not found') │ │ │ │ │ │ │ │ │ │ + return ''.join(interface['methods'][method]['in']) │ │ │ │ │ │ │ │ │ │ -# Functionality for passing strerror() error messages to their equivalent │ │ │ │ │ -# Python exceptions. │ │ │ │ │ -# There doesn't seem to be an official API for turning an errno into the │ │ │ │ │ -# correct subtype of OSError, and the list that cpython uses is hidden fairly │ │ │ │ │ -# deeply inside of the implementation. This is basically copied from the │ │ │ │ │ -# ADD_ERRNO() lines in _PyExc_InitState in cpython/Objects/exceptions.c │ │ │ │ │ -oserror_subclass_map = dict((errnum, cls) for cls, errnum in [ │ │ │ │ │ - (BlockingIOError, errno.EAGAIN), │ │ │ │ │ - (BlockingIOError, errno.EALREADY), │ │ │ │ │ - (BlockingIOError, errno.EINPROGRESS), │ │ │ │ │ - (BlockingIOError, errno.EWOULDBLOCK), │ │ │ │ │ - (BrokenPipeError, errno.EPIPE), │ │ │ │ │ - (BrokenPipeError, errno.ESHUTDOWN), │ │ │ │ │ - (ChildProcessError, errno.ECHILD), │ │ │ │ │ - (ConnectionAbortedError, errno.ECONNABORTED), │ │ │ │ │ - (ConnectionRefusedError, errno.ECONNREFUSED), │ │ │ │ │ - (ConnectionResetError, errno.ECONNRESET), │ │ │ │ │ - (FileExistsError, errno.EEXIST), │ │ │ │ │ - (FileNotFoundError, errno.ENOENT), │ │ │ │ │ - (IsADirectoryError, errno.EISDIR), │ │ │ │ │ - (NotADirectoryError, errno.ENOTDIR), │ │ │ │ │ - (InterruptedError, errno.EINTR), │ │ │ │ │ - (PermissionError, errno.EACCES), │ │ │ │ │ - (PermissionError, errno.EPERM), │ │ │ │ │ - (ProcessLookupError, errno.ESRCH), │ │ │ │ │ - (TimeoutError, errno.ETIMEDOUT), │ │ │ │ │ -]) │ │ │ │ │ │ │ │ │ │ +def notify_update(notify, path, interface_name, props): │ │ │ │ │ + notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()} │ │ │ │ │ │ │ │ │ │ -def get_exception_for_ssh_stderr(stderr: str) -> Exception: │ │ │ │ │ - stderr = stderr.replace('\r\n', '\n') # fix line separators │ │ │ │ │ │ │ │ │ │ - # check for the specific error messages first, then for generic SshHostKeyError │ │ │ │ │ - for ssh_cls in [SshAuthenticationError, SshChangedHostKeyError, SshUnknownHostKeyError, SshHostKeyError]: │ │ │ │ │ - match = ssh_cls.PATTERN.search(stderr) │ │ │ │ │ - if match is not None: │ │ │ │ │ - return ssh_cls(match, stderr) │ │ │ │ │ +class DBusChannel(Channel): │ │ │ │ │ + json_encoder = systemd_ctypes.JSONEncoder(indent=2) │ │ │ │ │ + payload = 'dbus-json3' │ │ │ │ │ │ │ │ │ │ - before, colon, after = stderr.rpartition(':') │ │ │ │ │ - if colon and after: │ │ │ │ │ - potential_strerror = after.strip() │ │ │ │ │ + matches = None │ │ │ │ │ + name = None │ │ │ │ │ + bus = None │ │ │ │ │ + owner = None │ │ │ │ │ │ │ │ │ │ - # DNS lookup errors │ │ │ │ │ - if potential_strerror in gaierror_map: │ │ │ │ │ - errnum = gaierror_map[potential_strerror] │ │ │ │ │ - return socket.gaierror(errnum, stderr) │ │ │ │ │ + async def setup_name_owner_tracking(self): │ │ │ │ │ + def send_owner(owner): │ │ │ │ │ + # We must be careful not to send duplicate owner │ │ │ │ │ + # notifications. cockpit.js relies on that. │ │ │ │ │ + if self.owner != owner: │ │ │ │ │ + self.owner = owner │ │ │ │ │ + self.send_json(owner=owner) │ │ │ │ │ │ │ │ │ │ - # Network connect errors │ │ │ │ │ - for errnum in errno.errorcode: │ │ │ │ │ - if os.strerror(errnum) == potential_strerror: │ │ │ │ │ - os_cls = oserror_subclass_map.get(errnum, OSError) │ │ │ │ │ - return os_cls(errnum, stderr) │ │ │ │ │ + def handler(message): │ │ │ │ │ + name, old, new = message.get_body() │ │ │ │ │ + send_owner(owner=new if new != "" else None) │ │ │ │ │ + self.add_signal_handler(handler, │ │ │ │ │ + sender='org.freedesktop.DBus', │ │ │ │ │ + path='/org/freedesktop/DBus', │ │ │ │ │ + interface='org.freedesktop.DBus', │ │ │ │ │ + member='NameOwnerChanged', │ │ │ │ │ + arg0=self.name) │ │ │ │ │ + try: │ │ │ │ │ + unique_name, = await self.bus.call_method_async("org.freedesktop.DBus", │ │ │ │ │ + "/org/freedesktop/DBus", │ │ │ │ │ + "org.freedesktop.DBus", │ │ │ │ │ + "GetNameOwner", "s", self.name) │ │ │ │ │ + except BusError as error: │ │ │ │ │ + if error.name == "org.freedesktop.DBus.Error.NameHasNoOwner": │ │ │ │ │ + # Try to start it. If it starts successfully, we will │ │ │ │ │ + # get a NameOwnerChanged signal (which will set │ │ │ │ │ + # self.owner) before StartServiceByName returns. │ │ │ │ │ + try: │ │ │ │ │ + await self.bus.call_method_async("org.freedesktop.DBus", │ │ │ │ │ + "/org/freedesktop/DBus", │ │ │ │ │ + "org.freedesktop.DBus", │ │ │ │ │ + "StartServiceByName", "su", self.name, 0) │ │ │ │ │ + except BusError as start_error: │ │ │ │ │ + logger.debug("Failed to start service '%s': %s", self.name, start_error.message) │ │ │ │ │ + self.send_json(owner=None) │ │ │ │ │ + else: │ │ │ │ │ + logger.debug("Failed to get owner of service '%s': %s", self.name, error.message) │ │ │ │ │ + else: │ │ │ │ │ + send_owner(unique_name) │ │ │ │ │ │ │ │ │ │ - # No match? Generic. │ │ │ │ │ - return SshError(None, stderr) │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/ferny/session.py': r'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ -# │ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya │ │ │ │ │ -# │ │ │ │ │ -# This program is free software: you can redistribute it and/or modify │ │ │ │ │ -# it under the terms of the GNU General Public License as published by │ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ -# (at your option) any later version. │ │ │ │ │ -# │ │ │ │ │ -# This program is distributed in the hope that it will be useful, │ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ -# GNU General Public License for more details. │ │ │ │ │ -# │ │ │ │ │ -# You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ + def do_open(self, options): │ │ │ │ │ + self.cache = InterfaceCache() │ │ │ │ │ + self.name = options.get('name') │ │ │ │ │ + self.matches = [] │ │ │ │ │ │ │ │ │ │ -import asyncio │ │ │ │ │ -import ctypes │ │ │ │ │ -import functools │ │ │ │ │ -import logging │ │ │ │ │ -import os │ │ │ │ │ -import shlex │ │ │ │ │ -import signal │ │ │ │ │ -import subprocess │ │ │ │ │ -import tempfile │ │ │ │ │ -from typing import Mapping, Sequence │ │ │ │ │ + bus = options.get('bus') │ │ │ │ │ + address = options.get('address') │ │ │ │ │ │ │ │ │ │ -from . import ssh_errors │ │ │ │ │ -from .interaction_agent import InteractionAgent, InteractionError, InteractionHandler, write_askpass_to_tmpdir │ │ │ │ │ + try: │ │ │ │ │ + if address is not None: │ │ │ │ │ + if bus is not None and bus != 'none': │ │ │ │ │ + raise ChannelError('protocol-error', message='only one of "bus" and "address" can be specified') │ │ │ │ │ + logger.debug('get bus with address %s for %s', address, self.name) │ │ │ │ │ + self.bus = Bus.new(address=address, bus_client=self.name is not None) │ │ │ │ │ + elif bus == 'internal': │ │ │ │ │ + logger.debug('get internal bus for %s', self.name) │ │ │ │ │ + self.bus = self.router.internal_bus.client │ │ │ │ │ + else: │ │ │ │ │ + if bus == 'session': │ │ │ │ │ + logger.debug('get session bus for %s', self.name) │ │ │ │ │ + self.bus = Bus.default_user() │ │ │ │ │ + elif bus == 'system' or bus is None: │ │ │ │ │ + logger.debug('get system bus for %s', self.name) │ │ │ │ │ + self.bus = Bus.default_system() │ │ │ │ │ + else: │ │ │ │ │ + raise ChannelError('protocol-error', message=f'invalid bus "{bus}"') │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc │ │ │ │ │ │ │ │ │ │ -prctl = ctypes.cdll.LoadLibrary('libc.so.6').prctl │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ -PR_SET_PDEATHSIG = 1 │ │ │ │ │ + try: │ │ │ │ │ + self.bus.attach_event(None, 0) │ │ │ │ │ + except OSError as err: │ │ │ │ │ + if err.errno != errno.EBUSY: │ │ │ │ │ + raise │ │ │ │ │ │ │ │ │ │ + # This needs to be a fair mutex so that outgoing messages don't │ │ │ │ │ + # get re-ordered. asyncio.Lock is fair. │ │ │ │ │ + self.watch_processing_lock = asyncio.Lock() │ │ │ │ │ │ │ │ │ │ -@functools.lru_cache() │ │ │ │ │ -def has_feature(feature: str, teststr: str = 'x') -> bool: │ │ │ │ │ - try: │ │ │ │ │ - subprocess.check_output(['ssh', f'-o{feature} {teststr}', '-G', 'nonexisting'], stderr=subprocess.DEVNULL) │ │ │ │ │ - return True │ │ │ │ │ - except subprocess.CalledProcessError: │ │ │ │ │ - return False │ │ │ │ │ + if self.name is not None: │ │ │ │ │ + async def get_ready(): │ │ │ │ │ + async with self.watch_processing_lock: │ │ │ │ │ + await self.setup_name_owner_tracking() │ │ │ │ │ + if self.owner: │ │ │ │ │ + self.ready(unique_name=self.owner) │ │ │ │ │ + else: │ │ │ │ │ + self.close({'problem': 'not-found'}) │ │ │ │ │ + self.create_task(get_ready()) │ │ │ │ │ + else: │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ + def add_signal_handler(self, handler, **kwargs): │ │ │ │ │ + r = dict(**kwargs) │ │ │ │ │ + r['type'] = 'signal' │ │ │ │ │ + if 'sender' not in r and self.name is not None: │ │ │ │ │ + r['sender'] = self.name │ │ │ │ │ + # HACK - https://github.com/bus1/dbus-broker/issues/309 │ │ │ │ │ + # path_namespace='/' in a rule does not work. │ │ │ │ │ + if r.get('path_namespace') == "/": │ │ │ │ │ + del r['path_namespace'] │ │ │ │ │ │ │ │ │ │ -class SubprocessContext: │ │ │ │ │ - def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]: │ │ │ │ │ - """Return the args required to launch a process in the given context. │ │ │ │ │ + def filter_owner(message): │ │ │ │ │ + if self.owner is not None and self.owner == message.get_sender(): │ │ │ │ │ + handler(message) │ │ │ │ │ │ │ │ │ │ - For example, this might return a vector with │ │ │ │ │ - ["sudo"] │ │ │ │ │ - or │ │ │ │ │ - ["flatpak-spawn", "--host"] │ │ │ │ │ - prepended. │ │ │ │ │ + if self.name is not None and 'sender' in r and r['sender'] == self.name: │ │ │ │ │ + func = filter_owner │ │ │ │ │ + else: │ │ │ │ │ + func = handler │ │ │ │ │ + r_string = ','.join(f"{key}='{value}'" for key, value in r.items()) │ │ │ │ │ + if not self.is_closing(): │ │ │ │ │ + # this gets an EINTR very often especially on RHEL 8 │ │ │ │ │ + while True: │ │ │ │ │ + try: │ │ │ │ │ + match = self.bus.add_match(r_string, func) │ │ │ │ │ + break │ │ │ │ │ + except InterruptedError: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - It is also possible that more substantial changes may be performed. │ │ │ │ │ + self.matches.append(match) │ │ │ │ │ │ │ │ │ │ - This function is not permitted to modify its argument, although it may │ │ │ │ │ - (optionally) return it unmodified, if no changes are required. │ │ │ │ │ - """ │ │ │ │ │ - return args │ │ │ │ │ + def add_async_signal_handler(self, handler, **kwargs): │ │ │ │ │ + def sync_handler(message): │ │ │ │ │ + self.create_task(handler(message)) │ │ │ │ │ + self.add_signal_handler(sync_handler, **kwargs) │ │ │ │ │ │ │ │ │ │ - def wrap_subprocess_env(self, env: Mapping[str, str]) -> Mapping[str, str]: │ │ │ │ │ - """Return the envp required to launch a process in the given context. │ │ │ │ │ + async def do_call(self, message): │ │ │ │ │ + path, iface, method, args = message['call'] │ │ │ │ │ + cookie = message.get('id') │ │ │ │ │ + flags = message.get('flags') │ │ │ │ │ │ │ │ │ │ - For example, this might set the "SUDO_ASKPASS" environment variable, if │ │ │ │ │ - needed. │ │ │ │ │ + timeout = message.get('timeout') │ │ │ │ │ + if timeout is not None: │ │ │ │ │ + # sd_bus timeout is µs, cockpit API timeout is ms │ │ │ │ │ + timeout *= 1000 │ │ │ │ │ + else: │ │ │ │ │ + # sd_bus has no "indefinite" timeout, so use MAX_UINT64 │ │ │ │ │ + timeout = 2 ** 64 - 1 │ │ │ │ │ │ │ │ │ │ - As with wrap_subprocess_args(), this function is not permitted to │ │ │ │ │ - modify its argument, although it may (optionally) return it unmodified │ │ │ │ │ - if no changes are required. │ │ │ │ │ - """ │ │ │ │ │ - return env │ │ │ │ │ + # We have to figure out the signature of the call. Either we got told it: │ │ │ │ │ + signature = message.get('type') │ │ │ │ │ │ │ │ │ │ + # ... or there aren't any arguments │ │ │ │ │ + if signature is None and len(args) == 0: │ │ │ │ │ + signature = '' │ │ │ │ │ │ │ │ │ │ -class Session(SubprocessContext, InteractionHandler): │ │ │ │ │ - # Set after .connect() called, even if failed │ │ │ │ │ - _controldir: 'tempfile.TemporaryDirectory | None' = None │ │ │ │ │ - _controlsock: 'str | None' = None │ │ │ │ │ + # ... or we need to introspect │ │ │ │ │ + if signature is None: │ │ │ │ │ + try: │ │ │ │ │ + logger.debug('Doing introspection request for %s %s', iface, method) │ │ │ │ │ + signature = await self.cache.get_signature(iface, method, self.bus, self.name, path) │ │ │ │ │ + except BusError as error: │ │ │ │ │ + self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie) │ │ │ │ │ + return │ │ │ │ │ + except KeyError: │ │ │ │ │ + self.send_json( │ │ │ │ │ + error=[ │ │ │ │ │ + "org.freedesktop.DBus.Error.UnknownMethod", │ │ │ │ │ + [f"Introspection data for method {iface} {method} not available"]], │ │ │ │ │ + id=cookie) │ │ │ │ │ + return │ │ │ │ │ + except Exception as exc: │ │ │ │ │ + self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - # Set if connected, else None │ │ │ │ │ - _process: 'asyncio.subprocess.Process | None' = None │ │ │ │ │ + try: │ │ │ │ │ + method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args) │ │ │ │ │ + reply = await self.bus.call_async(method_call, timeout=timeout) │ │ │ │ │ + # If the method call has kicked off any signals related to │ │ │ │ │ + # watch processing, wait for that to be done. │ │ │ │ │ + async with self.watch_processing_lock: │ │ │ │ │ + # TODO: stop hard-coding the endian flag here. │ │ │ │ │ + self.send_json( │ │ │ │ │ + reply=[reply.get_body()], id=cookie, │ │ │ │ │ + flags="<" if flags is not None else None, │ │ │ │ │ + type=reply.get_signature(True)) # noqa: FBT003 │ │ │ │ │ + except BusError as error: │ │ │ │ │ + # actually, should send the fields from the message body │ │ │ │ │ + self.send_json(error=[error.name, [error.message]], id=cookie) │ │ │ │ │ + except Exception: │ │ │ │ │ + logger.exception("do_call(%s): generic exception", message) │ │ │ │ │ + self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie) │ │ │ │ │ │ │ │ │ │ - async def connect(self, │ │ │ │ │ - destination: str, │ │ │ │ │ - handle_host_key: bool = False, │ │ │ │ │ - configfile: 'str | None' = None, │ │ │ │ │ - identity_file: 'str | None' = None, │ │ │ │ │ - login_name: 'str | None' = None, │ │ │ │ │ - options: 'Mapping[str, str] | None' = None, │ │ │ │ │ - pkcs11: 'str | None' = None, │ │ │ │ │ - port: 'int | None' = None, │ │ │ │ │ - interaction_responder: 'InteractionHandler | None' = None) -> None: │ │ │ │ │ - rundir = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/run'), 'ferny') │ │ │ │ │ - os.makedirs(rundir, exist_ok=True) │ │ │ │ │ - self._controldir = tempfile.TemporaryDirectory(dir=rundir) │ │ │ │ │ - self._controlsock = f'{self._controldir.name}/socket' │ │ │ │ │ + async def do_add_match(self, message): │ │ │ │ │ + add_match = message['add-match'] │ │ │ │ │ + logger.debug('adding match %s', add_match) │ │ │ │ │ │ │ │ │ │ - # In general, we can't guarantee an accessible and executable version │ │ │ │ │ - # of this file, but since it's small and we're making a temporary │ │ │ │ │ - # directory anyway, let's just copy it into place and use it from │ │ │ │ │ - # there. │ │ │ │ │ - askpass_path = write_askpass_to_tmpdir(self._controldir.name) │ │ │ │ │ + async def match_hit(message): │ │ │ │ │ + logger.debug('got match') │ │ │ │ │ + async with self.watch_processing_lock: │ │ │ │ │ + self.send_json(signal=[ │ │ │ │ │ + message.get_path(), │ │ │ │ │ + message.get_interface(), │ │ │ │ │ + message.get_member(), │ │ │ │ │ + list(message.get_body()) │ │ │ │ │ + ]) │ │ │ │ │ │ │ │ │ │ - env = dict(os.environ) │ │ │ │ │ - env['SSH_ASKPASS'] = askpass_path │ │ │ │ │ - env['SSH_ASKPASS_REQUIRE'] = 'force' │ │ │ │ │ - # old SSH doesn't understand SSH_ASKPASS_REQUIRE and guesses based on DISPLAY instead │ │ │ │ │ - env['DISPLAY'] = '-' │ │ │ │ │ + self.add_async_signal_handler(match_hit, **add_match) │ │ │ │ │ │ │ │ │ │ - args = [ │ │ │ │ │ - '-M', │ │ │ │ │ - '-N', │ │ │ │ │ - '-S', self._controlsock, │ │ │ │ │ - '-o', 'PermitLocalCommand=yes', │ │ │ │ │ - '-o', f'LocalCommand={askpass_path}', │ │ │ │ │ - ] │ │ │ │ │ + async def setup_objectmanager_watch(self, path, interface_name, meta, notify): │ │ │ │ │ + # Watch the objects managed by the ObjectManager at "path". │ │ │ │ │ + # Properties are not watched, that is done by setup_path_watch │ │ │ │ │ + # below via recursive_props == True. │ │ │ │ │ │ │ │ │ │ - if configfile is not None: │ │ │ │ │ - args.append(f'-F{configfile}') │ │ │ │ │ + async def handler(message): │ │ │ │ │ + member = message.get_member() │ │ │ │ │ + if member == "InterfacesAdded": │ │ │ │ │ + (path, interface_props) = message.get_body() │ │ │ │ │ + logger.debug('interfaces added %s %s', path, interface_props) │ │ │ │ │ + meta = {} │ │ │ │ │ + notify = {} │ │ │ │ │ + async with self.watch_processing_lock: │ │ │ │ │ + for name, props in interface_props.items(): │ │ │ │ │ + if interface_name is None or name == interface_name: │ │ │ │ │ + mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path) │ │ │ │ │ + if mm: │ │ │ │ │ + meta.update({name: mm}) │ │ │ │ │ + notify_update(notify, path, name, props) │ │ │ │ │ + self.send_json(meta=meta) │ │ │ │ │ + self.send_json(notify=notify) │ │ │ │ │ + elif member == "InterfacesRemoved": │ │ │ │ │ + (path, interfaces) = message.get_body() │ │ │ │ │ + logger.debug('interfaces removed %s %s', path, interfaces) │ │ │ │ │ + async with self.watch_processing_lock: │ │ │ │ │ + notify = {path: {name: None for name in interfaces}} │ │ │ │ │ + self.send_json(notify=notify) │ │ │ │ │ │ │ │ │ │ - if identity_file is not None: │ │ │ │ │ - args.append(f'-i{identity_file}') │ │ │ │ │ + self.add_async_signal_handler(handler, │ │ │ │ │ + path=path, │ │ │ │ │ + interface="org.freedesktop.DBus.ObjectManager") │ │ │ │ │ + objects, = await self.bus.call_method_async(self.name, path, │ │ │ │ │ + 'org.freedesktop.DBus.ObjectManager', │ │ │ │ │ + 'GetManagedObjects') │ │ │ │ │ + for p, ifaces in objects.items(): │ │ │ │ │ + for iface, props in ifaces.items(): │ │ │ │ │ + if interface_name is None or iface == interface_name: │ │ │ │ │ + mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p) │ │ │ │ │ + if mm: │ │ │ │ │ + meta.update({iface: mm}) │ │ │ │ │ + notify_update(notify, p, iface, props) │ │ │ │ │ │ │ │ │ │ - if options is not None: │ │ │ │ │ - for key in options: # Note: Mapping may not have .items() │ │ │ │ │ - args.append(f'-o{key} {options[key]}') │ │ │ │ │ + async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify): │ │ │ │ │ + # Watch a single object at "path", but maybe also watch for │ │ │ │ │ + # property changes for all objects below "path". │ │ │ │ │ │ │ │ │ │ - if pkcs11 is not None: │ │ │ │ │ - args.append(f'-I{pkcs11}') │ │ │ │ │ + async def handler(message): │ │ │ │ │ + async with self.watch_processing_lock: │ │ │ │ │ + path = message.get_path() │ │ │ │ │ + name, props, invalids = message.get_body() │ │ │ │ │ + logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids) │ │ │ │ │ + for inv in invalids: │ │ │ │ │ + try: │ │ │ │ │ + reply, = await self.bus.call_method_async(self.name, path, │ │ │ │ │ + 'org.freedesktop.DBus.Properties', 'Get', │ │ │ │ │ + 'ss', name, inv) │ │ │ │ │ + except BusError as exc: │ │ │ │ │ + logger.debug('failed to fetch property %s.%s on %s %s: %s', │ │ │ │ │ + name, inv, self.name, path, str(exc)) │ │ │ │ │ + continue │ │ │ │ │ + props[inv] = reply │ │ │ │ │ + notify = {} │ │ │ │ │ + notify_update(notify, path, name, props) │ │ │ │ │ + self.send_json(notify=notify) │ │ │ │ │ │ │ │ │ │ - if port is not None: │ │ │ │ │ - args.append(f'-p{port}') │ │ │ │ │ + this_meta = await self.cache.introspect_path(self.bus, self.name, path) │ │ │ │ │ + if interface_name is not None: │ │ │ │ │ + interface = this_meta.get(interface_name) │ │ │ │ │ + this_meta = {interface_name: interface} │ │ │ │ │ + meta.update(this_meta) │ │ │ │ │ + if recursive_props: │ │ │ │ │ + self.add_async_signal_handler(handler, │ │ │ │ │ + interface="org.freedesktop.DBus.Properties", │ │ │ │ │ + path_namespace=path) │ │ │ │ │ + else: │ │ │ │ │ + self.add_async_signal_handler(handler, │ │ │ │ │ + interface="org.freedesktop.DBus.Properties", │ │ │ │ │ + path=path) │ │ │ │ │ │ │ │ │ │ - if login_name is not None: │ │ │ │ │ - args.append(f'-l{login_name}') │ │ │ │ │ + for name in meta: │ │ │ │ │ + if name.startswith("org.freedesktop.DBus."): │ │ │ │ │ + continue │ │ │ │ │ + try: │ │ │ │ │ + props, = await self.bus.call_method_async(self.name, path, │ │ │ │ │ + 'org.freedesktop.DBus.Properties', │ │ │ │ │ + 'GetAll', 's', name) │ │ │ │ │ + notify_update(notify, path, name, props) │ │ │ │ │ + except BusError: │ │ │ │ │ + pass │ │ │ │ │ │ │ │ │ │ - if handle_host_key and has_feature('KnownHostsCommand'): │ │ │ │ │ - args.extend([ │ │ │ │ │ - '-o', f'KnownHostsCommand={askpass_path} %I %H %t %K %f', │ │ │ │ │ - '-o', 'StrictHostKeyChecking=yes', │ │ │ │ │ - ]) │ │ │ │ │ + async def do_watch(self, message): │ │ │ │ │ + watch = message['watch'] │ │ │ │ │ + path = watch.get('path') │ │ │ │ │ + path_namespace = watch.get('path_namespace') │ │ │ │ │ + interface_name = watch.get('interface') │ │ │ │ │ + cookie = message.get('id') │ │ │ │ │ │ │ │ │ │ - agent = InteractionAgent([interaction_responder] if interaction_responder is not None else []) │ │ │ │ │ + path = path or path_namespace │ │ │ │ │ + recursive = path == path_namespace │ │ │ │ │ │ │ │ │ │ - # SSH_ASKPASS_REQUIRE is not generally available, so use setsid │ │ │ │ │ - process = await asyncio.create_subprocess_exec( │ │ │ │ │ - *('/usr/bin/ssh', *args, destination), env=env, │ │ │ │ │ - start_new_session=True, stdin=asyncio.subprocess.DEVNULL, │ │ │ │ │ - stdout=asyncio.subprocess.DEVNULL, stderr=agent, # type: ignore │ │ │ │ │ - preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL)) │ │ │ │ │ + if path is None or cookie is None: │ │ │ │ │ + logger.debug('ignored incomplete watch request %s', message) │ │ │ │ │ + self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie) │ │ │ │ │ + self.send_json(reply=[], id=cookie) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - # This is tricky: we need to clean up the subprocess, but only in case │ │ │ │ │ - # if failure. Otherwise, we keep it around. │ │ │ │ │ try: │ │ │ │ │ - await agent.communicate() │ │ │ │ │ - assert os.path.exists(self._controlsock) │ │ │ │ │ - self._process = process │ │ │ │ │ - except InteractionError as exc: │ │ │ │ │ - await process.wait() │ │ │ │ │ - raise ssh_errors.get_exception_for_ssh_stderr(str(exc)) from None │ │ │ │ │ - except BaseException: │ │ │ │ │ - # If we get here because the InteractionHandler raised an │ │ │ │ │ - # exception then SSH might still be running, and may even attempt │ │ │ │ │ - # further interactions (ie: 2nd attempt for password). We already │ │ │ │ │ - # have our exception and don't need any more info. Kill it. │ │ │ │ │ - try: │ │ │ │ │ - process.kill() │ │ │ │ │ - except ProcessLookupError: │ │ │ │ │ - pass # already exited? good. │ │ │ │ │ - await process.wait() │ │ │ │ │ - raise │ │ │ │ │ - │ │ │ │ │ - def is_connected(self) -> bool: │ │ │ │ │ - return self._process is not None │ │ │ │ │ + async with self.watch_processing_lock: │ │ │ │ │ + meta = {} │ │ │ │ │ + notify = {} │ │ │ │ │ + await self.setup_path_watch(path, interface_name, recursive, meta, notify) │ │ │ │ │ + if recursive: │ │ │ │ │ + await self.setup_objectmanager_watch(path, interface_name, meta, notify) │ │ │ │ │ + self.send_json(meta=meta) │ │ │ │ │ + self.send_json(notify=notify) │ │ │ │ │ + self.send_json(reply=[], id=message['id']) │ │ │ │ │ + except BusError as error: │ │ │ │ │ + logger.debug("do_watch(%s) caught D-Bus error: %s", message, error.message) │ │ │ │ │ + self.send_json(error=[error.name, [error.message]], id=cookie) │ │ │ │ │ │ │ │ │ │ - async def wait(self) -> None: │ │ │ │ │ - assert self._process is not None │ │ │ │ │ - await self._process.wait() │ │ │ │ │ + async def do_meta(self, message): │ │ │ │ │ + self.cache.inject(message['meta']) │ │ │ │ │ │ │ │ │ │ - def exit(self) -> None: │ │ │ │ │ - assert self._process is not None │ │ │ │ │ - self._process.terminate() │ │ │ │ │ + def do_data(self, data): │ │ │ │ │ + message = json.loads(data) │ │ │ │ │ + logger.debug('receive dbus request %s %s', self.name, message) │ │ │ │ │ │ │ │ │ │ - async def disconnect(self) -> None: │ │ │ │ │ - self.exit() │ │ │ │ │ - await self.wait() │ │ │ │ │ + if 'call' in message: │ │ │ │ │ + self.create_task(self.do_call(message)) │ │ │ │ │ + elif 'add-match' in message: │ │ │ │ │ + self.create_task(self.do_add_match(message)) │ │ │ │ │ + elif 'watch' in message: │ │ │ │ │ + self.create_task(self.do_watch(message)) │ │ │ │ │ + elif 'meta' in message: │ │ │ │ │ + self.create_task(self.do_meta(message)) │ │ │ │ │ + else: │ │ │ │ │ + logger.debug('ignored dbus request %s', message) │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ - # Launching of processes │ │ │ │ │ - def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]: │ │ │ │ │ - assert self._controlsock is not None │ │ │ │ │ - # 1. We specify the hostname as the empty string: it will be ignored │ │ │ │ │ - # when ssh is trying to use the control socket, but in case the │ │ │ │ │ - # socket has stopped working, ssh will try to fall back to directly │ │ │ │ │ - # connecting, in which case an empty hostname will prevent that. │ │ │ │ │ - # 2. We need to quote the arguments — ssh will paste them together │ │ │ │ │ - # using only spaces, executing the result using the user's shell. │ │ │ │ │ - return ('ssh', '-S', self._controlsock, '', *map(shlex.quote, args)) │ │ │ │ │ + def do_close(self): │ │ │ │ │ + for slot in self.matches: │ │ │ │ │ + slot.cancel() │ │ │ │ │ + self.matches = [] │ │ │ │ │ + self.close() │ │ │ │ │ '''.encode('utf-8'), │ │ │ │ │ - 'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ + 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ import asyncio │ │ │ │ │ -import contextlib │ │ │ │ │ +import json │ │ │ │ │ import logging │ │ │ │ │ -import typing │ │ │ │ │ -from typing import Any, Callable, Iterable, Sequence, TypeVar │ │ │ │ │ +import sys │ │ │ │ │ +import time │ │ │ │ │ +from collections import defaultdict │ │ │ │ │ +from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union │ │ │ │ │ │ │ │ │ │ -from .interaction_agent import InteractionAgent, InteractionHandler, get_running_loop │ │ │ │ │ -from .ssh_errors import get_exception_for_ssh_stderr │ │ │ │ │ +from ..channel import AsyncChannel, ChannelError │ │ │ │ │ +from ..jsonutil import JsonList │ │ │ │ │ +from ..samples import SAMPLERS, SampleDescription, Sampler, Samples │ │ │ │ │ │ │ │ │ │ logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ -P = TypeVar('P', bound=asyncio.Protocol) │ │ │ │ │ - │ │ │ │ │ - │ │ │ │ │ -class SubprocessError(Exception): │ │ │ │ │ - returncode: int │ │ │ │ │ - stderr: str │ │ │ │ │ │ │ │ │ │ - def __init__(self, returncode: int, stderr: str) -> None: │ │ │ │ │ - super().__init__(returncode, stderr) │ │ │ │ │ - self.returncode = returncode │ │ │ │ │ - self.stderr = stderr │ │ │ │ │ +class MetricInfo(NamedTuple): │ │ │ │ │ + derive: Optional[str] │ │ │ │ │ + desc: SampleDescription │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -class FernyTransport(asyncio.Transport, asyncio.SubprocessProtocol): │ │ │ │ │ - _agent: InteractionAgent │ │ │ │ │ - _exec_task: 'asyncio.Task[tuple[asyncio.SubprocessTransport, FernyTransport]]' │ │ │ │ │ - _is_ssh: bool │ │ │ │ │ - _protocol: asyncio.Protocol │ │ │ │ │ - _protocol_disconnected: bool = False │ │ │ │ │ +class InternalMetricsChannel(AsyncChannel): │ │ │ │ │ + payload = 'metrics1' │ │ │ │ │ + restrictions = [('source', 'internal')] │ │ │ │ │ │ │ │ │ │ - # These get initialized in connection_made() and once set, never get unset. │ │ │ │ │ - _subprocess_transport: 'asyncio.SubprocessTransport | None' = None │ │ │ │ │ - _stdin_transport: 'asyncio.WriteTransport | None' = None │ │ │ │ │ - _stdout_transport: 'asyncio.ReadTransport | None' = None │ │ │ │ │ + metrics: List[MetricInfo] │ │ │ │ │ + samplers: Set │ │ │ │ │ + samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None │ │ │ │ │ │ │ │ │ │ - # We record events that might build towards a connection termination here │ │ │ │ │ - # and consider them from _consider_disconnect() in order to try to get the │ │ │ │ │ - # best possible Exception for the protocol, rather than just taking the │ │ │ │ │ - # first one (which is likely to be somewhat random). │ │ │ │ │ - _exception: 'Exception | None' = None │ │ │ │ │ - _stderr_output: 'str | None' = None │ │ │ │ │ - _returncode: 'int | None' = None │ │ │ │ │ - _transport_disconnected: bool = False │ │ │ │ │ - _closed: bool = False │ │ │ │ │ + interval: int = 1000 │ │ │ │ │ + need_meta: bool = True │ │ │ │ │ + last_timestamp: float = 0 │ │ │ │ │ + next_timestamp: float = 0 │ │ │ │ │ │ │ │ │ │ @classmethod │ │ │ │ │ - def spawn( │ │ │ │ │ - cls: 'type[typing.Self]', │ │ │ │ │ - protocol_factory: Callable[[], P], │ │ │ │ │ - args: Sequence[str], │ │ │ │ │ - loop: 'asyncio.AbstractEventLoop | None' = None, │ │ │ │ │ - interaction_handlers: Sequence[InteractionHandler] = (), │ │ │ │ │ - is_ssh: bool = True, │ │ │ │ │ - **kwargs: Any │ │ │ │ │ - ) -> 'tuple[typing.Self, P]': │ │ │ │ │ - """Connects a FernyTransport to a protocol, using the given command. │ │ │ │ │ - │ │ │ │ │ - This spawns an external command and connects the stdin and stdout of │ │ │ │ │ - the command to the protocol returned by the factory. │ │ │ │ │ - │ │ │ │ │ - An instance of ferny.InteractionAgent is created and attached to the │ │ │ │ │ - stderr of the spawned process, using the provided handlers. It is the │ │ │ │ │ - responsibility of the caller to ensure that: │ │ │ │ │ - - a `ferny-askpass` client program is installed somewhere; and │ │ │ │ │ - - any relevant command-line arguments or environment variables are │ │ │ │ │ - passed correctly to the program to be spawned │ │ │ │ │ - │ │ │ │ │ - This function returns immediately and never raises exceptions, assuming │ │ │ │ │ - all preconditions are met. │ │ │ │ │ - │ │ │ │ │ - If spawning the process fails then connection_lost() will be │ │ │ │ │ - called with the relevant OSError, even before connection_made() is │ │ │ │ │ - called. This is somewhat non-standard behaviour, but is the easiest │ │ │ │ │ - way to report these errors without making this function async. │ │ │ │ │ - │ │ │ │ │ - Once the process is successfully executed, connection_made() will be │ │ │ │ │ - called and the transport can be used as normal. connection_lost() will │ │ │ │ │ - be called if the process exits or another error occurs. │ │ │ │ │ + def ensure_samplers(cls): │ │ │ │ │ + if cls.samplers_cache is None: │ │ │ │ │ + cls.samplers_cache = {desc.name: (sampler, desc) for sampler in SAMPLERS for desc in sampler.descriptions} │ │ │ │ │ │ │ │ │ │ - The return value of this function is the transport, but it exists in a │ │ │ │ │ - semi-initialized state. You can call .close() on it, but nothing else. │ │ │ │ │ - Once .connection_made() is called, you can call all the other │ │ │ │ │ - functions. │ │ │ │ │ + def parse_options(self, options): │ │ │ │ │ + logger.debug('metrics internal open: %s, channel: %s', options, self.channel) │ │ │ │ │ │ │ │ │ │ - After you call this function, `.connection_lost()` will be called on │ │ │ │ │ - your Protocol, exactly once, no matter what. Until that happens, you │ │ │ │ │ - are responsible for holding a reference to the returned transport. │ │ │ │ │ + interval = options.get('interval', self.interval) │ │ │ │ │ + if not isinstance(interval, int) or interval <= 0 or interval > sys.maxsize: │ │ │ │ │ + raise ChannelError('protocol-error', message=f'invalid "interval" value: {interval}') │ │ │ │ │ │ │ │ │ │ - :param args: the full argv of the command to spawn │ │ │ │ │ - :param loop: the event loop to use. If none is provided, we use the │ │ │ │ │ - one which is (read: must be) currently running. │ │ │ │ │ - :param interaction_handlers: the handlers passed to the │ │ │ │ │ - InteractionAgent │ │ │ │ │ - :param is_ssh: whether we should attempt to interpret stderr as ssh │ │ │ │ │ - error messages │ │ │ │ │ - :param kwargs: anything else is passed through to `subprocess_exec()` │ │ │ │ │ - :returns: the usual `(Transport, Protocol)` pair │ │ │ │ │ - """ │ │ │ │ │ - logger.debug('spawn(%r, %r, %r)', cls, protocol_factory, args) │ │ │ │ │ + self.interval = interval │ │ │ │ │ │ │ │ │ │ - protocol = protocol_factory() │ │ │ │ │ - self = cls(protocol) │ │ │ │ │ - self._is_ssh = is_ssh │ │ │ │ │ + metrics = options.get('metrics') │ │ │ │ │ + if not isinstance(metrics, list) or len(metrics) == 0: │ │ │ │ │ + logger.error('invalid "metrics" value: %s', metrics) │ │ │ │ │ + raise ChannelError('protocol-error', message='invalid "metrics" option was specified (not an array)') │ │ │ │ │ │ │ │ │ │ - if loop is None: │ │ │ │ │ - loop = get_running_loop() │ │ │ │ │ + sampler_classes = set() │ │ │ │ │ + for metric in metrics: │ │ │ │ │ + # validate it's an object │ │ │ │ │ + name = metric.get('name') │ │ │ │ │ + units = metric.get('units') │ │ │ │ │ + derive = metric.get('derive') │ │ │ │ │ │ │ │ │ │ - self._agent = InteractionAgent(interaction_handlers, loop, self._interaction_completed) │ │ │ │ │ - kwargs.setdefault('stderr', self._agent.fileno()) │ │ │ │ │ + try: │ │ │ │ │ + sampler, desc = self.samplers_cache[name] │ │ │ │ │ + except KeyError as exc: │ │ │ │ │ + logger.error('unsupported metric: %s', name) │ │ │ │ │ + raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc │ │ │ │ │ │ │ │ │ │ - # As of Python 3.12 this isn't really asynchronous (since it uses the │ │ │ │ │ - # subprocess module, which blocks while waiting for the exec() to │ │ │ │ │ - # complete in the child), but we have to deal with the complication of │ │ │ │ │ - # the async interface anyway. Since we, ourselves, want to export a │ │ │ │ │ - # non-async interface, that means that we need a task here and a │ │ │ │ │ - # bottom-half handler below. │ │ │ │ │ - self._exec_task = loop.create_task(loop.subprocess_exec(lambda: self, *args, **kwargs)) │ │ │ │ │ + if units and units != desc.units: │ │ │ │ │ + raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}') │ │ │ │ │ │ │ │ │ │ - def exec_completed(task: asyncio.Task) -> None: │ │ │ │ │ - logger.debug('exec_completed(%r, %r)', self, task) │ │ │ │ │ - assert task is self._exec_task │ │ │ │ │ - try: │ │ │ │ │ - transport, me = task.result() │ │ │ │ │ - assert me is self │ │ │ │ │ - logger.debug(' success.') │ │ │ │ │ - except asyncio.CancelledError: │ │ │ │ │ - return # in that case, do nothing │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - logger.debug(' OSError %r', exc) │ │ │ │ │ - self.close(exc) │ │ │ │ │ - return │ │ │ │ │ + sampler_classes.add(sampler) │ │ │ │ │ + self.metrics.append(MetricInfo(derive=derive, desc=desc)) │ │ │ │ │ │ │ │ │ │ - # Our own .connection_made() handler should have gotten called by │ │ │ │ │ - # now. Make sure everything got filled in properly. │ │ │ │ │ - assert self._subprocess_transport is transport │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - assert self._stdout_transport is not None │ │ │ │ │ + self.samplers = {cls() for cls in sampler_classes} │ │ │ │ │ │ │ │ │ │ - # Ask the InteractionAgent to start processing stderr. │ │ │ │ │ - self._agent.start() │ │ │ │ │ + def send_meta(self, samples: Samples, timestamp: float): │ │ │ │ │ + metrics: JsonList = [] │ │ │ │ │ + for metricinfo in self.metrics: │ │ │ │ │ + if metricinfo.desc.instanced: │ │ │ │ │ + metrics.append({ │ │ │ │ │ + 'name': metricinfo.desc.name, │ │ │ │ │ + 'units': metricinfo.desc.units, │ │ │ │ │ + 'instances': list(samples[metricinfo.desc.name].keys()), │ │ │ │ │ + 'semantics': metricinfo.desc.semantics │ │ │ │ │ + }) │ │ │ │ │ + else: │ │ │ │ │ + metrics.append({ │ │ │ │ │ + 'name': metricinfo.desc.name, │ │ │ │ │ + 'derive': metricinfo.derive, # type: ignore[dict-item] │ │ │ │ │ + 'units': metricinfo.desc.units, │ │ │ │ │ + 'semantics': metricinfo.desc.semantics │ │ │ │ │ + }) │ │ │ │ │ │ │ │ │ │ - self._exec_task.add_done_callback(exec_completed) │ │ │ │ │ + self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics) │ │ │ │ │ + self.need_meta = False │ │ │ │ │ │ │ │ │ │ - return self, protocol │ │ │ │ │ + def sample(self): │ │ │ │ │ + samples = defaultdict(dict) │ │ │ │ │ + for sampler in self.samplers: │ │ │ │ │ + sampler.sample(samples) │ │ │ │ │ + return samples │ │ │ │ │ │ │ │ │ │ - def __init__(self, protocol: asyncio.Protocol) -> None: │ │ │ │ │ - self._protocol = protocol │ │ │ │ │ + def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Union[float, bool]: │ │ │ │ │ + if old_value is not None and self.last_timestamp: │ │ │ │ │ + return (value - old_value) / (self.next_timestamp - self.last_timestamp) │ │ │ │ │ + else: │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ - def _consider_disconnect(self) -> None: │ │ │ │ │ - logger.debug('_consider_disconnect(%r)', self) │ │ │ │ │ - # We cannot disconnect as long as any of these three things are happening │ │ │ │ │ - if not self._exec_task.done(): │ │ │ │ │ - logger.debug(' exec_task still running %r', self._exec_task) │ │ │ │ │ - return │ │ │ │ │ + def send_updates(self, samples: Samples, last_samples: Samples): │ │ │ │ │ + data: List[Union[float, List[Optional[Union[float, bool]]]]] = [] │ │ │ │ │ + timestamp = time.time() │ │ │ │ │ + self.next_timestamp = timestamp │ │ │ │ │ │ │ │ │ │ - if self._subprocess_transport is not None and not self._transport_disconnected: │ │ │ │ │ - logger.debug(' transport still connected %r', self._subprocess_transport) │ │ │ │ │ - return │ │ │ │ │ + for metricinfo in self.metrics: │ │ │ │ │ + value = samples[metricinfo.desc.name] │ │ │ │ │ │ │ │ │ │ - if self._stderr_output is None: │ │ │ │ │ - logger.debug(' agent still running') │ │ │ │ │ - return │ │ │ │ │ + if metricinfo.desc.instanced: │ │ │ │ │ + old_value = last_samples[metricinfo.desc.name] │ │ │ │ │ + assert isinstance(value, dict) │ │ │ │ │ + assert isinstance(old_value, dict) │ │ │ │ │ │ │ │ │ │ - # All conditions for disconnection are satisfied. │ │ │ │ │ - if self._protocol_disconnected: │ │ │ │ │ - logger.debug(' already disconnected') │ │ │ │ │ - return │ │ │ │ │ - self._protocol_disconnected = True │ │ │ │ │ + # If we have less or more keys the data changed, send a meta message. │ │ │ │ │ + if value.keys() != old_value.keys(): │ │ │ │ │ + self.need_meta = True │ │ │ │ │ │ │ │ │ │ - # Now we just need to determine what we report to the protocol... │ │ │ │ │ - if self._exception is not None: │ │ │ │ │ - # If we got an exception reported, that's our reason for closing. │ │ │ │ │ - logger.debug(' disconnect with exception %r', self._exception) │ │ │ │ │ - self._protocol.connection_lost(self._exception) │ │ │ │ │ - elif self._returncode == 0 or self._closed: │ │ │ │ │ - # If we called close() or have a zero return status, that's a clean │ │ │ │ │ - # exit, regardless of noise that might have landed in stderr. │ │ │ │ │ - logger.debug(' clean disconnect') │ │ │ │ │ - self._protocol.connection_lost(None) │ │ │ │ │ - elif self._is_ssh and self._returncode == 255: │ │ │ │ │ - # This is an error code due to an SSH failure. Try to interpret it. │ │ │ │ │ - logger.debug(' disconnect with ssh error %r', self._stderr_output) │ │ │ │ │ - self._protocol.connection_lost(get_exception_for_ssh_stderr(self._stderr_output)) │ │ │ │ │ - else: │ │ │ │ │ - # Otherwise, report the stderr text and return code. │ │ │ │ │ - logger.debug(' disconnect with exit code %r, stderr %r', self._returncode, self._stderr_output) │ │ │ │ │ - # We surely have _returncode set here, since otherwise: │ │ │ │ │ - # - exec_task failed with an exception (which we handle above); or │ │ │ │ │ - # - we're still connected... │ │ │ │ │ - assert self._returncode is not None │ │ │ │ │ - self._protocol.connection_lost(SubprocessError(self._returncode, self._stderr_output)) │ │ │ │ │ + if metricinfo.derive == 'rate': │ │ │ │ │ + instances: List[Optional[Union[float, bool]]] = [] │ │ │ │ │ + for key, val in value.items(): │ │ │ │ │ + instances.append(self.calculate_sample_rate(val, old_value.get(key))) │ │ │ │ │ │ │ │ │ │ - def _interaction_completed(self, future: 'asyncio.Future[str]') -> None: │ │ │ │ │ - logger.debug('_interaction_completed(%r, %r)', self, future) │ │ │ │ │ - try: │ │ │ │ │ - self._stderr_output = future.result() │ │ │ │ │ - logger.debug(' stderr: %r', self._stderr_output) │ │ │ │ │ - except Exception as exc: │ │ │ │ │ - logger.debug(' exception: %r', exc) │ │ │ │ │ - self._stderr_output = '' # we need to set this in order to complete │ │ │ │ │ - self.close(exc) │ │ │ │ │ + data.append(instances) │ │ │ │ │ + else: │ │ │ │ │ + data.append(list(value.values())) │ │ │ │ │ + else: │ │ │ │ │ + old_value = last_samples.get(metricinfo.desc.name) │ │ │ │ │ + assert not isinstance(value, dict) │ │ │ │ │ + assert not isinstance(old_value, dict) │ │ │ │ │ │ │ │ │ │ - self._consider_disconnect() │ │ │ │ │ + if metricinfo.derive == 'rate': │ │ │ │ │ + data.append(self.calculate_sample_rate(value, old_value)) │ │ │ │ │ + else: │ │ │ │ │ + data.append(value) │ │ │ │ │ │ │ │ │ │ - # BaseProtocol implementation │ │ │ │ │ - def connection_made(self, transport: asyncio.BaseTransport) -> None: │ │ │ │ │ - logger.debug('connection_made(%r, %r)', self, transport) │ │ │ │ │ - assert isinstance(transport, asyncio.SubprocessTransport) │ │ │ │ │ - self._subprocess_transport = transport │ │ │ │ │ + if self.need_meta: │ │ │ │ │ + self.send_meta(samples, timestamp) │ │ │ │ │ │ │ │ │ │ - stdin_transport = transport.get_pipe_transport(0) │ │ │ │ │ - assert isinstance(stdin_transport, asyncio.WriteTransport) │ │ │ │ │ - self._stdin_transport = stdin_transport │ │ │ │ │ + self.last_timestamp = self.next_timestamp │ │ │ │ │ + self.send_data(json.dumps([data]).encode()) │ │ │ │ │ │ │ │ │ │ - stdout_transport = transport.get_pipe_transport(1) │ │ │ │ │ - assert isinstance(stdout_transport, asyncio.ReadTransport) │ │ │ │ │ - self._stdout_transport = stdout_transport │ │ │ │ │ + async def run(self, options): │ │ │ │ │ + self.metrics = [] │ │ │ │ │ + self.samplers = set() │ │ │ │ │ │ │ │ │ │ - stderr_transport = transport.get_pipe_transport(2) │ │ │ │ │ - assert stderr_transport is None │ │ │ │ │ + InternalMetricsChannel.ensure_samplers() │ │ │ │ │ │ │ │ │ │ - logger.debug('calling connection_made(%r, %r)', self, self._protocol) │ │ │ │ │ - self._protocol.connection_made(self) │ │ │ │ │ + self.parse_options(options) │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - def connection_lost(self, exc: 'Exception | None') -> None: │ │ │ │ │ - logger.debug('connection_lost(%r, %r)', self, exc) │ │ │ │ │ - if self._exception is None: │ │ │ │ │ - self._exception = exc │ │ │ │ │ - self._transport_disconnected = True │ │ │ │ │ - self._consider_disconnect() │ │ │ │ │ + last_samples = defaultdict(dict) │ │ │ │ │ + while True: │ │ │ │ │ + samples = self.sample() │ │ │ │ │ + self.send_updates(samples, last_samples) │ │ │ │ │ + last_samples = samples │ │ │ │ │ │ │ │ │ │ - # SubprocessProtocol implementation │ │ │ │ │ - def pipe_data_received(self, fd: int, data: bytes) -> None: │ │ │ │ │ - logger.debug('pipe_data_received(%r, %r, %r)', self, fd, len(data)) │ │ │ │ │ - assert fd == 1 # stderr is handled separately │ │ │ │ │ - self._protocol.data_received(data) │ │ │ │ │ + try: │ │ │ │ │ + await asyncio.wait_for(self.read(), self.interval / 1000) │ │ │ │ │ + return │ │ │ │ │ + except asyncio.TimeoutError: │ │ │ │ │ + # Continue the while loop, we use wait_for as an interval timer. │ │ │ │ │ + continue │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/channels/http.py': br'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ - def pipe_connection_lost(self, fd: int, exc: 'Exception | None') -> None: │ │ │ │ │ - logger.debug('pipe_connection_lost(%r, %r, %r)', self, fd, exc) │ │ │ │ │ - assert fd in (0, 1) # stderr is handled separately │ │ │ │ │ +import asyncio │ │ │ │ │ +import http.client │ │ │ │ │ +import logging │ │ │ │ │ +import socket │ │ │ │ │ +import ssl │ │ │ │ │ │ │ │ │ │ - # We treat this as a clean close │ │ │ │ │ - if isinstance(exc, BrokenPipeError): │ │ │ │ │ - exc = None │ │ │ │ │ +from ..channel import AsyncChannel, ChannelError │ │ │ │ │ +from ..jsonutil import JsonObject, get_dict, get_int, get_object, get_str, typechecked │ │ │ │ │ │ │ │ │ │ - # Record serious errors to propagate them to the protocol │ │ │ │ │ - # If this is a clean exit on stdout, report an EOF │ │ │ │ │ - if exc is not None: │ │ │ │ │ - self.close(exc) │ │ │ │ │ - elif fd == 1 and not self._closed: │ │ │ │ │ - if not self._protocol.eof_received(): │ │ │ │ │ - self.close() │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ - def process_exited(self) -> None: │ │ │ │ │ - logger.debug('process_exited(%r)', self) │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - self._returncode = self._subprocess_transport.get_returncode() │ │ │ │ │ - logger.debug(' ._returncode = %r', self._returncode) │ │ │ │ │ - self._agent.force_completion() │ │ │ │ │ │ │ │ │ │ - def pause_writing(self) -> None: │ │ │ │ │ - logger.debug('pause_writing(%r)', self) │ │ │ │ │ - self._protocol.pause_writing() │ │ │ │ │ +class HttpChannel(AsyncChannel): │ │ │ │ │ + payload = 'http-stream2' │ │ │ │ │ │ │ │ │ │ - def resume_writing(self) -> None: │ │ │ │ │ - logger.debug('resume_writing(%r)', self) │ │ │ │ │ - self._protocol.resume_writing() │ │ │ │ │ + @staticmethod │ │ │ │ │ + def get_headers(response: http.client.HTTPResponse, binary: 'str | None') -> JsonObject: │ │ │ │ │ + # Never send these headers │ │ │ │ │ + remove = {'Connection', 'Transfer-Encoding'} │ │ │ │ │ │ │ │ │ │ - # Transport implementation. Most of this is straight delegation. │ │ │ │ │ - def close(self, exc: 'Exception | None' = None) -> None: │ │ │ │ │ - logger.debug('close(%r, %r)', self, exc) │ │ │ │ │ - self._closed = True │ │ │ │ │ - if self._exception is None: │ │ │ │ │ - logger.debug(' setting exception %r', exc) │ │ │ │ │ - self._exception = exc │ │ │ │ │ - if not self._exec_task.done(): │ │ │ │ │ - logger.debug(' cancelling _exec_task') │ │ │ │ │ - self._exec_task.cancel() │ │ │ │ │ - if self._subprocess_transport is not None: │ │ │ │ │ - logger.debug(' closing _subprocess_transport') │ │ │ │ │ - # https://github.com/python/cpython/issues/112800 │ │ │ │ │ - with contextlib.suppress(PermissionError): │ │ │ │ │ - self._subprocess_transport.close() │ │ │ │ │ - self._agent.force_completion() │ │ │ │ │ + if binary != 'raw': │ │ │ │ │ + # Only send these headers for raw binary streams │ │ │ │ │ + remove.update({'Content-Length', 'Range'}) │ │ │ │ │ │ │ │ │ │ - def is_closing(self) -> bool: │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - return self._subprocess_transport.is_closing() │ │ │ │ │ + return {key: value for key, value in response.getheaders() if key not in remove} │ │ │ │ │ │ │ │ │ │ - def get_extra_info(self, name: str, default: object = None) -> object: │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - return self._subprocess_transport.get_extra_info(name, default) │ │ │ │ │ + @staticmethod │ │ │ │ │ + def create_client(options: JsonObject) -> http.client.HTTPConnection: │ │ │ │ │ + opt_address = get_str(options, 'address', 'localhost') │ │ │ │ │ + opt_tls = get_dict(options, 'tls', None) │ │ │ │ │ + opt_unix = get_str(options, 'unix', None) │ │ │ │ │ + opt_port = get_int(options, 'port', None) │ │ │ │ │ │ │ │ │ │ - def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: │ │ │ │ │ - assert isinstance(protocol, asyncio.Protocol) │ │ │ │ │ - self._protocol = protocol │ │ │ │ │ + if opt_tls is not None and opt_unix is not None: │ │ │ │ │ + raise ChannelError('protocol-error', message='TLS on Unix socket is not supported') │ │ │ │ │ + if opt_port is None and opt_unix is None: │ │ │ │ │ + raise ChannelError('protocol-error', message='no "port" or "unix" option for channel') │ │ │ │ │ + if opt_port is not None and opt_unix is not None: │ │ │ │ │ + raise ChannelError('protocol-error', message='cannot specify both "port" and "unix" options') │ │ │ │ │ │ │ │ │ │ - def get_protocol(self) -> asyncio.Protocol: │ │ │ │ │ - return self._protocol │ │ │ │ │ + if opt_tls is not None: │ │ │ │ │ + authority = get_dict(opt_tls, 'authority', None) │ │ │ │ │ + if authority is not None: │ │ │ │ │ + data = get_str(authority, 'data', None) │ │ │ │ │ + if data is not None: │ │ │ │ │ + context = ssl.create_default_context(cadata=data) │ │ │ │ │ + else: │ │ │ │ │ + context = ssl.create_default_context(cafile=get_str(authority, 'file')) │ │ │ │ │ + else: │ │ │ │ │ + context = ssl.create_default_context() │ │ │ │ │ │ │ │ │ │ - def is_reading(self) -> bool: │ │ │ │ │ - assert self._stdout_transport is not None │ │ │ │ │ - try: │ │ │ │ │ - return self._stdout_transport.is_reading() │ │ │ │ │ - except NotImplementedError: │ │ │ │ │ - # This is (incorrectly) unimplemented before Python 3.11 │ │ │ │ │ - return not self._stdout_transport._paused # type:ignore[attr-defined] │ │ │ │ │ - except AttributeError: │ │ │ │ │ - # ...and in Python 3.6 it's even worse │ │ │ │ │ - try: │ │ │ │ │ - selector = self._stdout_transport._loop._selector # type:ignore[attr-defined] │ │ │ │ │ - selector.get_key(self._stdout_transport._fileno) # type:ignore[attr-defined] │ │ │ │ │ - return True │ │ │ │ │ - except KeyError: │ │ │ │ │ - return False │ │ │ │ │ + if 'validate' in opt_tls and not opt_tls['validate']: │ │ │ │ │ + context.check_hostname = False │ │ │ │ │ + context.verify_mode = ssl.VerifyMode.CERT_NONE │ │ │ │ │ │ │ │ │ │ - def pause_reading(self) -> None: │ │ │ │ │ - assert self._stdout_transport is not None │ │ │ │ │ - self._stdout_transport.pause_reading() │ │ │ │ │ + # See https://github.com/python/typeshed/issues/11057 │ │ │ │ │ + return http.client.HTTPSConnection(opt_address, port=opt_port, context=context) # type: ignore[arg-type] │ │ │ │ │ │ │ │ │ │ - def resume_reading(self) -> None: │ │ │ │ │ - assert self._stdout_transport is not None │ │ │ │ │ - self._stdout_transport.resume_reading() │ │ │ │ │ + else: │ │ │ │ │ + return http.client.HTTPConnection(opt_address, port=opt_port) │ │ │ │ │ │ │ │ │ │ - def abort(self) -> None: │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - self._stdin_transport.abort() │ │ │ │ │ - self._subprocess_transport.kill() │ │ │ │ │ + @staticmethod │ │ │ │ │ + def connect(connection: http.client.HTTPConnection, opt_unix: 'str | None') -> None: │ │ │ │ │ + # Blocks. Runs in a thread. │ │ │ │ │ + if opt_unix: │ │ │ │ │ + # create the connection's socket so that it won't call .connect() internally (which only supports TCP) │ │ │ │ │ + connection.sock = socket.socket(socket.AF_UNIX) │ │ │ │ │ + connection.sock.connect(opt_unix) │ │ │ │ │ + else: │ │ │ │ │ + # explicitly call connect(), so that we can do proper error handling │ │ │ │ │ + connection.connect() │ │ │ │ │ │ │ │ │ │ - def can_write_eof(self) -> bool: │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - return self._stdin_transport.can_write_eof() # will always be True │ │ │ │ │ + @staticmethod │ │ │ │ │ + def request( │ │ │ │ │ + connection: http.client.HTTPConnection, method: str, path: str, headers: 'dict[str, str]', body: bytes │ │ │ │ │ + ) -> http.client.HTTPResponse: │ │ │ │ │ + # Blocks. Runs in a thread. │ │ │ │ │ + connection.request(method, path, headers=headers or {}, body=body) │ │ │ │ │ + return connection.getresponse() │ │ │ │ │ │ │ │ │ │ - def get_write_buffer_size(self) -> int: │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - return self._stdin_transport.get_write_buffer_size() │ │ │ │ │ + async def run(self, options: JsonObject) -> None: │ │ │ │ │ + logger.debug('open %s', options) │ │ │ │ │ │ │ │ │ │ - def get_write_buffer_limits(self) -> 'tuple[int, int]': │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - return self._stdin_transport.get_write_buffer_limits() │ │ │ │ │ + binary = get_str(options, 'binary', None) │ │ │ │ │ + method = get_str(options, 'method') │ │ │ │ │ + path = get_str(options, 'path') │ │ │ │ │ + headers = get_object(options, 'headers', lambda d: {k: typechecked(v, str) for k, v in d.items()}, None) │ │ │ │ │ │ │ │ │ │ - def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None: │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - return self._stdin_transport.set_write_buffer_limits(high, low) │ │ │ │ │ + if 'connection' in options: │ │ │ │ │ + raise ChannelError('protocol-error', message='connection sharing is not implemented on this bridge') │ │ │ │ │ │ │ │ │ │ - def write(self, data: bytes) -> None: │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - return self._stdin_transport.write(data) │ │ │ │ │ + loop = asyncio.get_running_loop() │ │ │ │ │ + connection = self.create_client(options) │ │ │ │ │ │ │ │ │ │ - def writelines(self, list_of_data: Iterable[bytes]) -> None: │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - return self._stdin_transport.writelines(list_of_data) │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - def write_eof(self) -> None: │ │ │ │ │ - assert self._stdin_transport is not None │ │ │ │ │ - return self._stdin_transport.write_eof() │ │ │ │ │ + body = b'' │ │ │ │ │ + while True: │ │ │ │ │ + data = await self.read() │ │ │ │ │ + if data == b'': │ │ │ │ │ + break │ │ │ │ │ + body += data │ │ │ │ │ │ │ │ │ │ - # We don't really implement SubprocessTransport, but provide these as │ │ │ │ │ - # "extras" to our user. │ │ │ │ │ - def get_pid(self) -> int: │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - return self._subprocess_transport.get_pid() │ │ │ │ │ + # Connect in a thread and handle errors │ │ │ │ │ + try: │ │ │ │ │ + await loop.run_in_executor(None, self.connect, connection, get_str(options, 'unix', None)) │ │ │ │ │ + except ssl.SSLCertVerificationError as exc: │ │ │ │ │ + raise ChannelError('unknown-hostkey', message=str(exc)) from exc │ │ │ │ │ + except (OSError, IOError) as exc: │ │ │ │ │ + raise ChannelError('not-found', message=str(exc)) from exc │ │ │ │ │ │ │ │ │ │ - def get_returncode(self) -> 'int | None': │ │ │ │ │ - return self._returncode │ │ │ │ │ + # Submit request in a thread and handle errors │ │ │ │ │ + try: │ │ │ │ │ + response = await loop.run_in_executor(None, self.request, connection, method, path, headers or {}, body) │ │ │ │ │ + except (http.client.HTTPException, OSError) as exc: │ │ │ │ │ + raise ChannelError('terminated', message=str(exc)) from exc │ │ │ │ │ │ │ │ │ │ - def kill(self) -> None: │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - self._subprocess_transport.kill() │ │ │ │ │ + self.send_control(command='response', │ │ │ │ │ + status=response.status, │ │ │ │ │ + reason=response.reason, │ │ │ │ │ + headers=self.get_headers(response, binary)) │ │ │ │ │ │ │ │ │ │ - def send_signal(self, number: int) -> None: │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - self._subprocess_transport.send_signal(number) │ │ │ │ │ + # Receive the body and finish up │ │ │ │ │ + try: │ │ │ │ │ + while True: │ │ │ │ │ + block = await loop.run_in_executor(None, response.read1, self.BLOCK_SIZE) │ │ │ │ │ + if not block: │ │ │ │ │ + break │ │ │ │ │ + await self.write(block) │ │ │ │ │ │ │ │ │ │ - def terminate(self) -> None: │ │ │ │ │ - assert self._subprocess_transport is not None │ │ │ │ │ - self._subprocess_transport.terminate() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import ( │ │ │ │ │ - BEIBOOT_GADGETS, │ │ │ │ │ - COMMAND_TEMPLATE, │ │ │ │ │ - AskpassHandler, │ │ │ │ │ - InteractionAgent, │ │ │ │ │ - InteractionError, │ │ │ │ │ - InteractionHandler, │ │ │ │ │ - temporary_askpass, │ │ │ │ │ - write_askpass_to_tmpdir, │ │ │ │ │ -) │ │ │ │ │ -from .session import Session │ │ │ │ │ -from .ssh_askpass import ( │ │ │ │ │ - AskpassPrompt, │ │ │ │ │ - SshAskpassResponder, │ │ │ │ │ - SshFIDOPINPrompt, │ │ │ │ │ - SshFIDOUserPresencePrompt, │ │ │ │ │ - SshHostKeyPrompt, │ │ │ │ │ - SshPassphrasePrompt, │ │ │ │ │ - SshPasswordPrompt, │ │ │ │ │ - SshPKCS11PINPrompt, │ │ │ │ │ -) │ │ │ │ │ -from .ssh_errors import ( │ │ │ │ │ - SshAuthenticationError, │ │ │ │ │ - SshChangedHostKeyError, │ │ │ │ │ - SshError, │ │ │ │ │ - SshHostKeyError, │ │ │ │ │ - SshUnknownHostKeyError, │ │ │ │ │ -) │ │ │ │ │ -from .transport import FernyTransport, SubprocessError │ │ │ │ │ + logger.debug('reading response done') │ │ │ │ │ + # this returns immediately and does not read anything more, but updates the http.client's │ │ │ │ │ + # internal state machine to "response done" │ │ │ │ │ + block = response.read() │ │ │ │ │ + assert block == b'' │ │ │ │ │ │ │ │ │ │ -__all__ = [ │ │ │ │ │ - 'AskpassHandler', │ │ │ │ │ - 'AskpassPrompt', │ │ │ │ │ - 'AuthenticationError', │ │ │ │ │ - 'BEIBOOT_GADGETS', │ │ │ │ │ - 'COMMAND_TEMPLATE', │ │ │ │ │ - 'ChangedHostKeyError', │ │ │ │ │ - 'FernyTransport', │ │ │ │ │ - 'HostKeyError', │ │ │ │ │ - 'InteractionAgent', │ │ │ │ │ - 'InteractionError', │ │ │ │ │ - 'InteractionHandler', │ │ │ │ │ - 'Session', │ │ │ │ │ - 'SshAskpassResponder', │ │ │ │ │ - 'SshAuthenticationError', │ │ │ │ │ - 'SshChangedHostKeyError', │ │ │ │ │ - 'SshError', │ │ │ │ │ - 'SshFIDOPINPrompt', │ │ │ │ │ - 'SshFIDOUserPresencePrompt', │ │ │ │ │ - 'SshHostKeyError', │ │ │ │ │ - 'SshHostKeyPrompt', │ │ │ │ │ - 'SshPKCS11PINPrompt', │ │ │ │ │ - 'SshPassphrasePrompt', │ │ │ │ │ - 'SshPasswordPrompt', │ │ │ │ │ - 'SshUnknownHostKeyError', │ │ │ │ │ - 'SubprocessError', │ │ │ │ │ - 'temporary_askpass', │ │ │ │ │ - 'write_askpass_to_tmpdir', │ │ │ │ │ -] │ │ │ │ │ + await loop.run_in_executor(None, connection.close) │ │ │ │ │ + except (http.client.HTTPException, OSError) as exc: │ │ │ │ │ + raise ChannelError('terminated', message=str(exc)) from exc │ │ │ │ │ │ │ │ │ │ -__version__ = '0' │ │ │ │ │ + self.done() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1) │ │ │ │ │ + 'cockpit/channels/packages.py': br'''# This file is part of Cockpit. │ │ │ │ │ # │ │ │ │ │ -# Copyright (C) 2023 Allison Karlitskaya │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ # │ │ │ │ │ # This program is free software: you can redistribute it and/or modify │ │ │ │ │ # it under the terms of the GNU General Public License as published by │ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ # (at your option) any later version. │ │ │ │ │ # │ │ │ │ │ # This program is distributed in the hope that it will be useful, │ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ # GNU General Public License for more details. │ │ │ │ │ # │ │ │ │ │ # You should have received a copy of the GNU General Public License │ │ │ │ │ -# along with this program. If not, see . │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ -import array │ │ │ │ │ -import ast │ │ │ │ │ -import asyncio │ │ │ │ │ -import contextlib │ │ │ │ │ import logging │ │ │ │ │ -import os │ │ │ │ │ -import re │ │ │ │ │ -import socket │ │ │ │ │ -import tempfile │ │ │ │ │ -from typing import Any, Callable, ClassVar, Generator, Sequence │ │ │ │ │ +from typing import Optional │ │ │ │ │ │ │ │ │ │ -from . import interaction_client │ │ │ │ │ +from ..channel import AsyncChannel │ │ │ │ │ +from ..data import read_cockpit_data_file │ │ │ │ │ +from ..jsonutil import JsonObject, get_dict, get_str │ │ │ │ │ +from ..packages import Packages │ │ │ │ │ │ │ │ │ │ logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -COMMAND_RE = re.compile(b'\0ferny\0([^\n]*)\0\0\n') │ │ │ │ │ -COMMAND_TEMPLATE = '\0ferny\0{(command, args)!r}\0\0\n' │ │ │ │ │ +class PackagesChannel(AsyncChannel): │ │ │ │ │ + payload = 'http-stream1' │ │ │ │ │ + restrictions = [("internal", "packages")] │ │ │ │ │ │ │ │ │ │ -BEIBOOT_GADGETS = { │ │ │ │ │ - "command": fr""" │ │ │ │ │ - import sys │ │ │ │ │ - def command(command, *args): │ │ │ │ │ - sys.stderr.write(f{COMMAND_TEMPLATE!r}) │ │ │ │ │ - sys.stderr.flush() │ │ │ │ │ - """, │ │ │ │ │ - "end": r""" │ │ │ │ │ - def end(): │ │ │ │ │ - command('ferny.end') │ │ │ │ │ - """, │ │ │ │ │ -} │ │ │ │ │ + # used to carry data forward from open to done │ │ │ │ │ + options: Optional[JsonObject] = None │ │ │ │ │ │ │ │ │ │ + def http_error(self, status: int, message: str) -> None: │ │ │ │ │ + template = read_cockpit_data_file('fail.html') │ │ │ │ │ + self.send_json(status=status, reason='ERROR', headers={'Content-Type': 'text/html; charset=utf-8'}) │ │ │ │ │ + self.send_data(template.replace(b'@@message@@', message.encode('utf-8'))) │ │ │ │ │ + self.done() │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ -class InteractionError(Exception): │ │ │ │ │ - pass │ │ │ │ │ + async def run(self, options: JsonObject) -> None: │ │ │ │ │ + packages: Packages = self.router.packages # type: ignore[attr-defined] # yes, this is evil │ │ │ │ │ │ │ │ │ │ + try: │ │ │ │ │ + if get_str(options, 'method') != 'GET': │ │ │ │ │ + raise ValueError(f'Unsupported HTTP method {options["method"]}') │ │ │ │ │ │ │ │ │ │ -try: │ │ │ │ │ - recv_fds = socket.recv_fds │ │ │ │ │ -except AttributeError: │ │ │ │ │ - # Python < 3.9 │ │ │ │ │ + self.ready() │ │ │ │ │ + if await self.read() != b'': │ │ │ │ │ + raise ValueError('Received unexpected data') │ │ │ │ │ │ │ │ │ │ - def recv_fds( │ │ │ │ │ - sock: socket.socket, bufsize: int, maxfds: int, flags: int = 0 │ │ │ │ │ - ) -> 'tuple[bytes, list[int], int, None]': │ │ │ │ │ - fds = array.array("i") │ │ │ │ │ - msg, ancdata, flags, addr = sock.recvmsg(bufsize, socket.CMSG_LEN(maxfds * fds.itemsize)) │ │ │ │ │ - for cmsg_level, cmsg_type, cmsg_data in ancdata: │ │ │ │ │ - if (cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS): │ │ │ │ │ - fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)]) │ │ │ │ │ - return msg, list(fds), flags, addr │ │ │ │ │ + path = get_str(options, 'path') │ │ │ │ │ + headers = get_dict(options, 'headers') │ │ │ │ │ + document = packages.load_path(path, headers) │ │ │ │ │ │ │ │ │ │ + # Note: we can't cache documents right now. See │ │ │ │ │ + # https://github.com/cockpit-project/cockpit/issues/19071 │ │ │ │ │ + # for future plans. │ │ │ │ │ + out_headers = { │ │ │ │ │ + 'Cache-Control': 'no-cache, no-store', │ │ │ │ │ + 'Content-Type': document.content_type, │ │ │ │ │ + } │ │ │ │ │ │ │ │ │ │ -def get_running_loop() -> asyncio.AbstractEventLoop: │ │ │ │ │ - try: │ │ │ │ │ - return asyncio.get_running_loop() │ │ │ │ │ - except AttributeError: │ │ │ │ │ - # Python 3.6 │ │ │ │ │ - return asyncio.get_event_loop() │ │ │ │ │ + if document.content_encoding is not None: │ │ │ │ │ + out_headers['Content-Encoding'] = document.content_encoding │ │ │ │ │ │ │ │ │ │ + if document.content_security_policy is not None: │ │ │ │ │ + policy = document.content_security_policy │ │ │ │ │ │ │ │ │ │ -class InteractionHandler: │ │ │ │ │ - commands: ClassVar[Sequence[str]] │ │ │ │ │ + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src │ │ │ │ │ + # │ │ │ │ │ + # Note: connect-src 'self' does not resolve to websocket │ │ │ │ │ + # schemes in all browsers, more info in this issue. │ │ │ │ │ + # │ │ │ │ │ + # https://github.com/w3c/webappsec-csp/issues/7 │ │ │ │ │ + if "connect-src 'self';" in policy: │ │ │ │ │ + protocol = headers.get('X-Forwarded-Proto') │ │ │ │ │ + host = headers.get('X-Forwarded-Host') │ │ │ │ │ + if not isinstance(protocol, str) or not isinstance(host, str): │ │ │ │ │ + raise ValueError('Invalid host or protocol header') │ │ │ │ │ │ │ │ │ │ - async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None: │ │ │ │ │ - raise NotImplementedError │ │ │ │ │ + websocket_scheme = "wss" if protocol == "https" else "ws" │ │ │ │ │ + websocket_origin = f"{websocket_scheme}://{host}" │ │ │ │ │ + policy = policy.replace("connect-src 'self';", f"connect-src {websocket_origin} 'self';") │ │ │ │ │ │ │ │ │ │ + out_headers['Content-Security-Policy'] = policy │ │ │ │ │ │ │ │ │ │ -class AskpassHandler(InteractionHandler): │ │ │ │ │ - commands: ClassVar[Sequence[str]] = ('ferny.askpass',) │ │ │ │ │ + except ValueError as exc: │ │ │ │ │ + self.http_error(400, str(exc)) │ │ │ │ │ │ │ │ │ │ - async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None': │ │ │ │ │ - """Prompt the user for an authentication or confirmation interaction. │ │ │ │ │ + except KeyError: │ │ │ │ │ + self.http_error(404, 'Not found') │ │ │ │ │ │ │ │ │ │ - 'messages' is data that was sent to stderr before the interaction was requested. │ │ │ │ │ - 'prompt' is the interaction prompt. │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + self.http_error(500, f'Internal error: {exc!s}') │ │ │ │ │ │ │ │ │ │ - The expected response type depends on hint: │ │ │ │ │ + else: │ │ │ │ │ + self.send_json(status=200, reason='OK', headers=out_headers) │ │ │ │ │ + await self.sendfile(document.data) │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/channels/__init__.py': br'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ - - "confirm": ask for permission, returning "yes" if accepted │ │ │ │ │ - - example: authorizing agent operation │ │ │ │ │ +from .dbus import DBusChannel │ │ │ │ │ +from .filesystem import FsInfoChannel, FsListChannel, FsReadChannel, FsReplaceChannel, FsWatchChannel │ │ │ │ │ +from .http import HttpChannel │ │ │ │ │ +from .metrics import InternalMetricsChannel │ │ │ │ │ +from .packages import PackagesChannel │ │ │ │ │ +from .stream import SocketStreamChannel, SubprocessStreamChannel │ │ │ │ │ +from .trivial import EchoChannel, NullChannel │ │ │ │ │ │ │ │ │ │ - - "none": show a request without need for a response │ │ │ │ │ - - example: please touch your authentication token │ │ │ │ │ +CHANNEL_TYPES = [ │ │ │ │ │ + DBusChannel, │ │ │ │ │ + EchoChannel, │ │ │ │ │ + FsInfoChannel, │ │ │ │ │ + FsListChannel, │ │ │ │ │ + FsReadChannel, │ │ │ │ │ + FsReplaceChannel, │ │ │ │ │ + FsWatchChannel, │ │ │ │ │ + HttpChannel, │ │ │ │ │ + InternalMetricsChannel, │ │ │ │ │ + NullChannel, │ │ │ │ │ + PackagesChannel, │ │ │ │ │ + SubprocessStreamChannel, │ │ │ │ │ + SocketStreamChannel, │ │ │ │ │ +] │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/channels/filesystem.py': r'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ - - otherwise: return a password or other form of text token │ │ │ │ │ - - examples: enter password, unlock private key │ │ │ │ │ +import asyncio │ │ │ │ │ +import contextlib │ │ │ │ │ +import enum │ │ │ │ │ +import errno │ │ │ │ │ +import fnmatch │ │ │ │ │ +import functools │ │ │ │ │ +import grp │ │ │ │ │ +import logging │ │ │ │ │ +import os │ │ │ │ │ +import pwd │ │ │ │ │ +import random │ │ │ │ │ +import stat │ │ │ │ │ +from typing import Callable, Iterable │ │ │ │ │ │ │ │ │ │ - In any case, the function should properly handle cancellation. For the │ │ │ │ │ - "none" case, this will be the normal way to dismiss the dialog. │ │ │ │ │ - """ │ │ │ │ │ - return None │ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Handle, PathWatch │ │ │ │ │ +from cockpit._vendor.systemd_ctypes.inotify import Event as InotifyEvent │ │ │ │ │ +from cockpit._vendor.systemd_ctypes.pathwatch import Listener as PathWatchListener │ │ │ │ │ │ │ │ │ │ - async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool: │ │ │ │ │ - """Prompt the user for a decision regarding acceptance of a host key. │ │ │ │ │ +from ..channel import Channel, ChannelError, GeneratorChannel │ │ │ │ │ +from ..jsonutil import ( │ │ │ │ │ + JsonDict, │ │ │ │ │ + JsonDocument, │ │ │ │ │ + JsonError, │ │ │ │ │ + JsonObject, │ │ │ │ │ + get_bool, │ │ │ │ │ + get_int, │ │ │ │ │ + get_str, │ │ │ │ │ + get_strv, │ │ │ │ │ + json_merge_and_filter_patch, │ │ │ │ │ +) │ │ │ │ │ │ │ │ │ │ - The "reason" will be either "HOSTNAME" or "ADDRESS" (if `CheckHostIP` is enabled). │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ - The host, algorithm, and key parameters are the values in the form that │ │ │ │ │ - they would appear one a single line in the known hosts file. The │ │ │ │ │ - fingerprint is the key fingerprint in the format that ssh would │ │ │ │ │ - normally present it to the user. │ │ │ │ │ │ │ │ │ │ - In case the host key should be accepted, this function needs to return │ │ │ │ │ - True. Returning False means that ssh implements its default logic. To │ │ │ │ │ - interrupt the connection, raise an exception. │ │ │ │ │ - """ │ │ │ │ │ - return False │ │ │ │ │ +def tag_from_stat(buf): │ │ │ │ │ + return f'1:{buf.st_ino}-{buf.st_mtime}' │ │ │ │ │ │ │ │ │ │ - async def do_custom_command( │ │ │ │ │ - self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str │ │ │ │ │ - ) -> None: │ │ │ │ │ - """Handle a custom command. │ │ │ │ │ │ │ │ │ │ - The command name, its arguments, the passed fds, and the stderr leading │ │ │ │ │ - up to the command invocation are all provided. │ │ │ │ │ +def tag_from_path(path): │ │ │ │ │ + try: │ │ │ │ │ + return tag_from_stat(os.stat(path)) │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + return '-' │ │ │ │ │ + except OSError: │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ - See doc/interaction-protocol.md │ │ │ │ │ - """ │ │ │ │ │ │ │ │ │ │ - async def _askpass_command(self, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None: │ │ │ │ │ - logger.debug('_askpass_command(%s, %s, %s)', args, fds, stderr) │ │ │ │ │ - try: │ │ │ │ │ - argv, env = args │ │ │ │ │ - assert isinstance(argv, list) │ │ │ │ │ - assert all(isinstance(arg, str) for arg in argv) │ │ │ │ │ - assert isinstance(env, dict) │ │ │ │ │ - assert all(isinstance(key, str) and isinstance(val, str) for key, val in env.items()) │ │ │ │ │ - assert len(fds) == 2 │ │ │ │ │ - except (ValueError, TypeError, AssertionError) as exc: │ │ │ │ │ - logger.error('Invalid arguments to askpass interaction: %s, %s: %s', args, fds, exc) │ │ │ │ │ - return │ │ │ │ │ +def tag_from_fd(fd): │ │ │ │ │ + try: │ │ │ │ │ + return tag_from_stat(os.fstat(fd)) │ │ │ │ │ + except OSError: │ │ │ │ │ + return None │ │ │ │ │ │ │ │ │ │ - with open(fds.pop(0), 'w') as status, open(fds.pop(0), 'w') as stdout: │ │ │ │ │ - try: │ │ │ │ │ - loop = get_running_loop() │ │ │ │ │ - try: │ │ │ │ │ - task = asyncio.current_task() │ │ │ │ │ - except AttributeError: │ │ │ │ │ - task = asyncio.Task.current_task() # type:ignore[attr-defined] # (Python 3.6) │ │ │ │ │ - assert task is not None │ │ │ │ │ - loop.add_reader(status, task.cancel) │ │ │ │ │ │ │ │ │ │ - if len(argv) == 2: │ │ │ │ │ - # normal askpass │ │ │ │ │ - prompt = argv[1] │ │ │ │ │ - hint = env.get('SSH_ASKPASS_PROMPT', '') │ │ │ │ │ - logger.debug('do_askpass(%r, %r, %r)', stderr, prompt, hint) │ │ │ │ │ - answer = await self.do_askpass(stderr, prompt, hint) │ │ │ │ │ - logger.debug('do_askpass answer %r', answer) │ │ │ │ │ - if answer is not None: │ │ │ │ │ - print(answer, file=stdout) │ │ │ │ │ - print(0, file=status) │ │ │ │ │ +class FsListChannel(Channel): │ │ │ │ │ + payload = 'fslist1' │ │ │ │ │ │ │ │ │ │ - elif len(argv) == 6: │ │ │ │ │ - # KnownHostsCommand │ │ │ │ │ - argv0, reason, host, algorithm, key, fingerprint = argv │ │ │ │ │ - if reason in ['ADDRESS', 'HOSTNAME']: │ │ │ │ │ - logger.debug('do_hostkey(%r, %r, %r, %r, %r)', reason, host, algorithm, key, fingerprint) │ │ │ │ │ - if await self.do_hostkey(reason, host, algorithm, key, fingerprint): │ │ │ │ │ - print(host, algorithm, key, file=stdout) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug('ignoring KnownHostsCommand reason %r', reason) │ │ │ │ │ + def send_entry(self, event, entry): │ │ │ │ │ + if entry.is_symlink(): │ │ │ │ │ + mode = 'link' │ │ │ │ │ + elif entry.is_file(): │ │ │ │ │ + mode = 'file' │ │ │ │ │ + elif entry.is_dir(): │ │ │ │ │ + mode = 'directory' │ │ │ │ │ + else: │ │ │ │ │ + mode = 'special' │ │ │ │ │ │ │ │ │ │ - print(0, file=status) │ │ │ │ │ + self.send_json(event=event, path=entry.name, type=mode) │ │ │ │ │ │ │ │ │ │ - else: │ │ │ │ │ - logger.error('Incorrect number of command-line arguments to ferny-askpass: %s', argv) │ │ │ │ │ - finally: │ │ │ │ │ - loop.remove_reader(status) │ │ │ │ │ + def do_open(self, options): │ │ │ │ │ + path = options.get('path') │ │ │ │ │ + watch = options.get('watch', True) │ │ │ │ │ │ │ │ │ │ - async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None: │ │ │ │ │ - logger.debug('run_command(%s, %s, %s, %s)', command, args, fds, stderr) │ │ │ │ │ - if command == 'ferny.askpass': │ │ │ │ │ - await self._askpass_command(args, fds, stderr) │ │ │ │ │ - else: │ │ │ │ │ - await self.do_custom_command(command, args, fds, stderr) │ │ │ │ │ + if watch: │ │ │ │ │ + raise ChannelError('not-supported', message='watching is not implemented, use fswatch1') │ │ │ │ │ │ │ │ │ │ + try: │ │ │ │ │ + scan_dir = os.scandir(path) │ │ │ │ │ + except FileNotFoundError as error: │ │ │ │ │ + raise ChannelError('not-found', message=str(error)) from error │ │ │ │ │ + except PermissionError as error: │ │ │ │ │ + raise ChannelError('access-denied', message=str(error)) from error │ │ │ │ │ + except OSError as error: │ │ │ │ │ + raise ChannelError('internal-error', message=str(error)) from error │ │ │ │ │ │ │ │ │ │ -class InteractionAgent: │ │ │ │ │ - _handlers: 'dict[str, InteractionHandler]' │ │ │ │ │ + self.ready() │ │ │ │ │ + for entry in scan_dir: │ │ │ │ │ + self.send_entry("present", entry) │ │ │ │ │ │ │ │ │ │ - _loop: asyncio.AbstractEventLoop │ │ │ │ │ + if not watch: │ │ │ │ │ + self.done() │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - _tasks: 'set[asyncio.Task]' │ │ │ │ │ │ │ │ │ │ - _buffer: bytearray │ │ │ │ │ - _ours: socket.socket │ │ │ │ │ - _theirs: socket.socket │ │ │ │ │ +class FsReadChannel(GeneratorChannel): │ │ │ │ │ + payload = 'fsread1' │ │ │ │ │ │ │ │ │ │ - _completion_future: 'asyncio.Future[str]' │ │ │ │ │ - _pending_result: 'None | str | Exception' = None │ │ │ │ │ - _end: bool = False │ │ │ │ │ + def do_yield_data(self, options: JsonObject) -> GeneratorChannel.DataGenerator: │ │ │ │ │ + path = get_str(options, 'path') │ │ │ │ │ + binary = get_str(options, 'binary', None) │ │ │ │ │ + max_read_size = get_int(options, 'max_read_size', None) │ │ │ │ │ │ │ │ │ │ - def _consider_completion(self) -> None: │ │ │ │ │ - logger.debug('_consider_completion(%r)', self) │ │ │ │ │ + logger.debug('Opening file "%s" for reading', path) │ │ │ │ │ │ │ │ │ │ - if self._pending_result is None or self._tasks: │ │ │ │ │ - logger.debug(' but not ready yet') │ │ │ │ │ + try: │ │ │ │ │ + with open(path, 'rb') as filep: │ │ │ │ │ + buf = os.stat(filep.fileno()) │ │ │ │ │ + if max_read_size is not None and buf.st_size > max_read_size: │ │ │ │ │ + raise ChannelError('too-large') │ │ │ │ │ │ │ │ │ │ - elif self._completion_future.done(): │ │ │ │ │ - logger.debug(' but already complete') │ │ │ │ │ + if binary and stat.S_ISREG(buf.st_mode): │ │ │ │ │ + self.ready(size_hint=buf.st_size) │ │ │ │ │ + else: │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - elif isinstance(self._pending_result, str): │ │ │ │ │ - logger.debug(' submitting stderr (%r) to completion_future', self._pending_result) │ │ │ │ │ - self._completion_future.set_result(self._pending_result) │ │ │ │ │ + while True: │ │ │ │ │ + data = filep.read1(Channel.BLOCK_SIZE) │ │ │ │ │ + if data == b'': │ │ │ │ │ + break │ │ │ │ │ + logger.debug(' ...sending %d bytes', len(data)) │ │ │ │ │ + if not binary: │ │ │ │ │ + data = data.replace(b'\0', b'').decode('utf-8', errors='ignore').encode('utf-8') │ │ │ │ │ + yield data │ │ │ │ │ │ │ │ │ │ - else: │ │ │ │ │ - logger.debug(' submitting exception (%r) to completion_future') │ │ │ │ │ - self._completion_future.set_exception(self._pending_result) │ │ │ │ │ + return {'tag': tag_from_stat(buf)} │ │ │ │ │ │ │ │ │ │ - def _result(self, result: 'str | Exception') -> None: │ │ │ │ │ - logger.debug('_result(%r, %r)', self, result) │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + return {'tag': '-'} │ │ │ │ │ + except PermissionError as exc: │ │ │ │ │ + raise ChannelError('access-denied') from exc │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + raise ChannelError('internal-error', message=str(exc)) from exc │ │ │ │ │ │ │ │ │ │ - if self._pending_result is None: │ │ │ │ │ - self._pending_result = result │ │ │ │ │ │ │ │ │ │ - if self._ours.fileno() != -1: │ │ │ │ │ - logger.debug(' remove_reader(%r)', self._ours) │ │ │ │ │ - self._loop.remove_reader(self._ours.fileno()) │ │ │ │ │ +class FsReplaceChannel(Channel): │ │ │ │ │ + payload = 'fsreplace1' │ │ │ │ │ │ │ │ │ │ - for task in self._tasks: │ │ │ │ │ - logger.debug(' cancel(%r)', task) │ │ │ │ │ - task.cancel() │ │ │ │ │ + _path = None │ │ │ │ │ + _tag = None │ │ │ │ │ + _tempfile = None │ │ │ │ │ + _temppath = None │ │ │ │ │ │ │ │ │ │ - logger.debug(' closing sockets') │ │ │ │ │ - self._theirs.close() # idempotent │ │ │ │ │ - self._ours.close() │ │ │ │ │ + def unlink_temppath(self): │ │ │ │ │ + try: │ │ │ │ │ + os.unlink(self._temppath) │ │ │ │ │ + except OSError: │ │ │ │ │ + pass # might have been removed from outside │ │ │ │ │ │ │ │ │ │ - self._consider_completion() │ │ │ │ │ + def do_open(self, options): │ │ │ │ │ + self._path = options.get('path') │ │ │ │ │ + self._tag = options.get('tag') │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - def _invoke_command(self, stderr: bytes, command_blob: bytes, fds: 'list[int]') -> None: │ │ │ │ │ - logger.debug('_invoke_command(%r, %r, %r)', stderr, command_blob, fds) │ │ │ │ │ - try: │ │ │ │ │ - command, args = ast.literal_eval(command_blob.decode()) │ │ │ │ │ - if not isinstance(command, str) or not isinstance(args, tuple): │ │ │ │ │ - raise TypeError('Invalid argument types') │ │ │ │ │ - except (UnicodeDecodeError, SyntaxError, ValueError, TypeError) as exc: │ │ │ │ │ - logger.error('Received invalid ferny command: %s: %s', command_blob, exc) │ │ │ │ │ - return │ │ │ │ │ + def do_data(self, data): │ │ │ │ │ + if self._tempfile is None: │ │ │ │ │ + # keep this bounded, in case anything unexpected goes wrong │ │ │ │ │ + for _ in range(10): │ │ │ │ │ + suffix = ''.join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789_", k=6)) │ │ │ │ │ + self._temppath = f'{self._path}.cockpit-tmp.{suffix}' │ │ │ │ │ + try: │ │ │ │ │ + fd = os.open(self._temppath, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o666) │ │ │ │ │ + break │ │ │ │ │ + except FileExistsError: │ │ │ │ │ + continue │ │ │ │ │ + except PermissionError as exc: │ │ │ │ │ + raise ChannelError('access-denied') from exc │ │ │ │ │ + except FileNotFoundError as exc: │ │ │ │ │ + # directory of path does not exist │ │ │ │ │ + raise ChannelError('not-found') from exc │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + raise ChannelError('internal-error', message=str(exc)) from exc │ │ │ │ │ + else: │ │ │ │ │ + raise ChannelError('internal-error', │ │ │ │ │ + message=f"Could not find unique file name for replacing {self._path}") │ │ │ │ │ │ │ │ │ │ - if command == 'ferny.end': │ │ │ │ │ - self._end = True │ │ │ │ │ - self._result(self._buffer.decode(errors='replace')) │ │ │ │ │ - return │ │ │ │ │ + try: │ │ │ │ │ + self._tempfile = os.fdopen(fd, 'wb') │ │ │ │ │ + except OSError: │ │ │ │ │ + # Should Not Happen™, but let's be safe and avoid fd leak │ │ │ │ │ + os.close(fd) │ │ │ │ │ + self.unlink_temppath() │ │ │ │ │ + raise │ │ │ │ │ │ │ │ │ │ - try: │ │ │ │ │ - handler = self._handlers[command] │ │ │ │ │ - except KeyError: │ │ │ │ │ - logger.error('Received unhandled ferny command: %s', command) │ │ │ │ │ - return │ │ │ │ │ + self._tempfile.write(data) │ │ │ │ │ │ │ │ │ │ - # The task is responsible for the list of fds and removing itself │ │ │ │ │ - # from the set. │ │ │ │ │ - task_fds = list(fds) │ │ │ │ │ - task = self._loop.create_task(handler.run_command(command, args, task_fds, stderr.decode())) │ │ │ │ │ + def do_done(self): │ │ │ │ │ + if self._tempfile is None: │ │ │ │ │ + try: │ │ │ │ │ + os.unlink(self._path) │ │ │ │ │ + # crash on other errors, as they are unexpected │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + pass │ │ │ │ │ + else: │ │ │ │ │ + self._tempfile.flush() │ │ │ │ │ │ │ │ │ │ - def bottom_half(completed_task: asyncio.Task) -> None: │ │ │ │ │ - assert completed_task is task │ │ │ │ │ - while task_fds: │ │ │ │ │ - os.close(task_fds.pop()) │ │ │ │ │ - self._tasks.remove(task) │ │ │ │ │ + if self._tag and self._tag != tag_from_path(self._path): │ │ │ │ │ + raise ChannelError('change-conflict') │ │ │ │ │ │ │ │ │ │ try: │ │ │ │ │ - task.result() │ │ │ │ │ - logger.debug('%r completed cleanly', handler) │ │ │ │ │ - except asyncio.CancelledError: │ │ │ │ │ - # this is not an error — it just means ferny-askpass exited via signal │ │ │ │ │ - logger.debug('%r was cancelled', handler) │ │ │ │ │ - except Exception as exc: │ │ │ │ │ - logger.debug('%r raised %r', handler, exc) │ │ │ │ │ - self._result(exc) │ │ │ │ │ + os.rename(self._temppath, self._path) │ │ │ │ │ + # ensure to not leave the temp file behind │ │ │ │ │ + except FileNotFoundError as exc: │ │ │ │ │ + self.unlink_temppath() │ │ │ │ │ + raise ChannelError('not-found', message=str(exc)) from exc │ │ │ │ │ + except IsADirectoryError as exc: │ │ │ │ │ + self.unlink_temppath() │ │ │ │ │ + # not ideal, but the closest code we have │ │ │ │ │ + raise ChannelError('access-denied', message=str(exc)) from exc │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + self.unlink_temppath() │ │ │ │ │ + raise ChannelError('internal-error', message=str(exc)) from exc │ │ │ │ │ │ │ │ │ │ - self._consider_completion() │ │ │ │ │ + self._tempfile.close() │ │ │ │ │ + self._tempfile = None │ │ │ │ │ │ │ │ │ │ - task.add_done_callback(bottom_half) │ │ │ │ │ - self._tasks.add(task) │ │ │ │ │ - fds[:] = [] │ │ │ │ │ + self.done() │ │ │ │ │ + self.close({'tag': tag_from_path(self._path)}) │ │ │ │ │ │ │ │ │ │ - def _got_data(self, data: bytes, fds: 'list[int]') -> None: │ │ │ │ │ - logger.debug('_got_data(%r, %r)', data, fds) │ │ │ │ │ + def do_close(self): │ │ │ │ │ + if self._tempfile is not None: │ │ │ │ │ + self._tempfile.close() │ │ │ │ │ + self.unlink_temppath() │ │ │ │ │ + self._tempfile = None │ │ │ │ │ │ │ │ │ │ - if data == b'': │ │ │ │ │ - self._result(self._buffer.decode(errors='replace')) │ │ │ │ │ - return │ │ │ │ │ │ │ │ │ │ - self._buffer.extend(data) │ │ │ │ │ +class FsWatchChannel(Channel): │ │ │ │ │ + payload = 'fswatch1' │ │ │ │ │ + _tag = None │ │ │ │ │ + _path = None │ │ │ │ │ + _watch = None │ │ │ │ │ │ │ │ │ │ - # Read zero or more "remote" messages │ │ │ │ │ - chunks = COMMAND_RE.split(self._buffer) │ │ │ │ │ - self._buffer = bytearray(chunks.pop()) │ │ │ │ │ - while len(chunks) > 1: │ │ │ │ │ - self._invoke_command(chunks[0], chunks[1], []) │ │ │ │ │ - chunks = chunks[2:] │ │ │ │ │ + # The C bridge doesn't send the initial event, and the JS calls read() │ │ │ │ │ + # instead to figure out the initial state of the file. If we send the │ │ │ │ │ + # initial state then we cause the event to get delivered twice. │ │ │ │ │ + # Ideally we'll sort that out at some point, but for now, suppress it. │ │ │ │ │ + _active = False │ │ │ │ │ │ │ │ │ │ - # Maybe read one "local" message │ │ │ │ │ - if fds: │ │ │ │ │ - assert self._buffer.endswith(b'\0'), self._buffer │ │ │ │ │ - stderr = self._buffer[:-1] │ │ │ │ │ - self._buffer = bytearray(b'') │ │ │ │ │ - with open(fds.pop(0), 'rb') as command_channel: │ │ │ │ │ - command = command_channel.read() │ │ │ │ │ - self._invoke_command(stderr, command, fds) │ │ │ │ │ + @staticmethod │ │ │ │ │ + def mask_to_event_and_type(mask): │ │ │ │ │ + if (InotifyEvent.CREATE or InotifyEvent.MOVED_TO) in mask: │ │ │ │ │ + return 'created', 'directory' if InotifyEvent.ISDIR in mask else 'file' │ │ │ │ │ + elif InotifyEvent.MOVED_FROM in mask or InotifyEvent.DELETE in mask or InotifyEvent.DELETE_SELF in mask: │ │ │ │ │ + return 'deleted', None │ │ │ │ │ + elif InotifyEvent.ATTRIB in mask: │ │ │ │ │ + return 'attribute-changed', None │ │ │ │ │ + elif InotifyEvent.CLOSE_WRITE in mask: │ │ │ │ │ + return 'done-hint', None │ │ │ │ │ + else: │ │ │ │ │ + return 'changed', None │ │ │ │ │ │ │ │ │ │ - def _read_ready(self) -> None: │ │ │ │ │ - try: │ │ │ │ │ - data, fds, _flags, _addr = recv_fds(self._ours, 4096, 10, flags=socket.MSG_DONTWAIT) │ │ │ │ │ - except BlockingIOError: │ │ │ │ │ - return │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - self._result(exc) │ │ │ │ │ + def do_inotify_event(self, mask, _cookie, name): │ │ │ │ │ + logger.debug("do_inotify_event(%s): mask %X name %s", self._path, mask, name) │ │ │ │ │ + event, type_ = self.mask_to_event_and_type(mask) │ │ │ │ │ + if name: │ │ │ │ │ + # file inside watched directory changed │ │ │ │ │ + path = os.path.join(self._path, name.decode()) │ │ │ │ │ + tag = tag_from_path(path) │ │ │ │ │ + self.send_json(event=event, path=path, tag=tag, type=type_) │ │ │ │ │ else: │ │ │ │ │ - self._got_data(data, fds) │ │ │ │ │ - finally: │ │ │ │ │ - while fds: │ │ │ │ │ - os.close(fds.pop()) │ │ │ │ │ + # the watched path itself changed; filter out duplicate events │ │ │ │ │ + tag = tag_from_path(self._path) │ │ │ │ │ + if tag == self._tag: │ │ │ │ │ + return │ │ │ │ │ + self._tag = tag │ │ │ │ │ + self.send_json(event=event, path=self._path, tag=self._tag, type=type_) │ │ │ │ │ │ │ │ │ │ - def __init__( │ │ │ │ │ - self, │ │ │ │ │ - handlers: Sequence[InteractionHandler], │ │ │ │ │ - loop: 'asyncio.AbstractEventLoop | None' = None, │ │ │ │ │ - done_callback: 'Callable[[asyncio.Future[str]], None] | None' = None, │ │ │ │ │ - ) -> None: │ │ │ │ │ - self._loop = loop or get_running_loop() │ │ │ │ │ - self._completion_future = self._loop.create_future() │ │ │ │ │ - self._tasks = set() │ │ │ │ │ - self._handlers = {} │ │ │ │ │ + def do_identity_changed(self, fd, err): │ │ │ │ │ + logger.debug("do_identity_changed(%s): fd %s, err %s", self._path, str(fd), err) │ │ │ │ │ + self._tag = tag_from_fd(fd) if fd else '-' │ │ │ │ │ + if self._active: │ │ │ │ │ + self.send_json(event='created' if fd else 'deleted', path=self._path, tag=self._tag) │ │ │ │ │ │ │ │ │ │ - for handler in handlers: │ │ │ │ │ - for command in handler.commands: │ │ │ │ │ - self._handlers[command] = handler │ │ │ │ │ + def do_open(self, options): │ │ │ │ │ + self._path = options['path'] │ │ │ │ │ + self._tag = None │ │ │ │ │ │ │ │ │ │ - if done_callback is not None: │ │ │ │ │ - self._completion_future.add_done_callback(done_callback) │ │ │ │ │ + self._active = False │ │ │ │ │ + self._watch = PathWatch(self._path, self) │ │ │ │ │ + self._active = True │ │ │ │ │ │ │ │ │ │ - self._theirs, self._ours = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) │ │ │ │ │ - self._buffer = bytearray() │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - def fileno(self) -> int: │ │ │ │ │ - return self._theirs.fileno() │ │ │ │ │ + def do_close(self): │ │ │ │ │ + self._watch.close() │ │ │ │ │ + self._watch = None │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - def start(self) -> None: │ │ │ │ │ - logger.debug('start(%r)', self) │ │ │ │ │ - if self._ours.fileno() != -1: │ │ │ │ │ - logger.debug(' add_reader(%r)', self._ours) │ │ │ │ │ - self._loop.add_reader(self._ours.fileno(), self._read_ready) │ │ │ │ │ - else: │ │ │ │ │ - logger.debug(' ...but agent is already finished.') │ │ │ │ │ │ │ │ │ │ - logger.debug(' close(%r)', self._theirs) │ │ │ │ │ - self._theirs.close() │ │ │ │ │ +class Follow(enum.Enum): │ │ │ │ │ + NO = False │ │ │ │ │ + YES = True │ │ │ │ │ │ │ │ │ │ - def force_completion(self) -> None: │ │ │ │ │ - logger.debug('force_completion(%r)', self) │ │ │ │ │ │ │ │ │ │ - # read any residual data on stderr, but don't process commands, and │ │ │ │ │ - # don't block │ │ │ │ │ - try: │ │ │ │ │ - if self._ours.fileno() != -1: │ │ │ │ │ - logger.debug(' draining pending stderr data (non-blocking)') │ │ │ │ │ - with contextlib.suppress(BlockingIOError): │ │ │ │ │ - while True: │ │ │ │ │ - data = self._ours.recv(4096, socket.MSG_DONTWAIT) │ │ │ │ │ - logger.debug(' got %d bytes', len(data)) │ │ │ │ │ - if not data: │ │ │ │ │ - break │ │ │ │ │ - self._buffer.extend(data) │ │ │ │ │ - except OSError as exc: │ │ │ │ │ - self._result(exc) │ │ │ │ │ - else: │ │ │ │ │ - self._result(self._buffer.decode(errors='replace')) │ │ │ │ │ +class FsInfoChannel(Channel, PathWatchListener): │ │ │ │ │ + payload = 'fsinfo' │ │ │ │ │ │ │ │ │ │ - async def communicate(self) -> None: │ │ │ │ │ - logger.debug('_communicate(%r)', self) │ │ │ │ │ - try: │ │ │ │ │ - self.start() │ │ │ │ │ - # We assume that we are the only ones to write to │ │ │ │ │ - # self._completion_future. If we directly await it, though, it can │ │ │ │ │ - # also have a asyncio.CancelledError posted to it from outside. │ │ │ │ │ - # Shield it to prevent that from happening. │ │ │ │ │ - stderr = await asyncio.shield(self._completion_future) │ │ │ │ │ - logger.debug('_communicate(%r) stderr result is %r', self, stderr) │ │ │ │ │ - finally: │ │ │ │ │ - logger.debug('_communicate finished. Ensuring completion.') │ │ │ │ │ - self.force_completion() │ │ │ │ │ - if not self._end: │ │ │ │ │ - logger.debug('_communicate never saw ferny.end. raising InteractionError.') │ │ │ │ │ - raise InteractionError(stderr.strip()) │ │ │ │ │ + # Options (all get set in `do_open()`) │ │ │ │ │ + path: str │ │ │ │ │ + attrs: 'set[str]' │ │ │ │ │ + fnmatch: str │ │ │ │ │ + targets: bool │ │ │ │ │ + follow: bool │ │ │ │ │ + watch: bool │ │ │ │ │ │ │ │ │ │ + # State │ │ │ │ │ + current_value: JsonDict │ │ │ │ │ + effective_fnmatch: str = '' │ │ │ │ │ + fd: 'Handle | None' = None │ │ │ │ │ + pending: 'set[str] | None' = None │ │ │ │ │ + path_watch: 'PathWatch | None' = None │ │ │ │ │ + getattrs: 'Callable[[int, str, Follow], JsonDocument]' │ │ │ │ │ │ │ │ │ │ -def write_askpass_to_tmpdir(tmpdir: str) -> str: │ │ │ │ │ - askpass_path = os.path.join(tmpdir, 'ferny-askpass') │ │ │ │ │ - fd = os.open(askpass_path, os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC | os.O_EXCL | os.O_NOFOLLOW, 0o777) │ │ │ │ │ - try: │ │ │ │ │ - os.write(fd, __loader__.get_data(interaction_client.__file__)) # type: ignore │ │ │ │ │ - finally: │ │ │ │ │ - os.close(fd) │ │ │ │ │ - return askpass_path │ │ │ │ │ + @staticmethod │ │ │ │ │ + def make_getattrs(attrs: Iterable[str]) -> 'Callable[[int, str, Follow], JsonDocument | None]': │ │ │ │ │ + # Cached for the duration of the closure we're creating │ │ │ │ │ + @functools.lru_cache() │ │ │ │ │ + def get_user(uid: int) -> 'str | int': │ │ │ │ │ + try: │ │ │ │ │ + return pwd.getpwuid(uid).pw_name │ │ │ │ │ + except KeyError: │ │ │ │ │ + return uid │ │ │ │ │ │ │ │ │ │ + @functools.lru_cache() │ │ │ │ │ + def get_group(gid: int) -> 'str | int': │ │ │ │ │ + try: │ │ │ │ │ + return grp.getgrgid(gid).gr_name │ │ │ │ │ + except KeyError: │ │ │ │ │ + return gid │ │ │ │ │ │ │ │ │ │ -@contextlib.contextmanager │ │ │ │ │ -def temporary_askpass(**kwargs: Any) -> Generator[str, None, None]: │ │ │ │ │ - with tempfile.TemporaryDirectory(**kwargs) as directory: │ │ │ │ │ - yield write_askpass_to_tmpdir(directory) │ │ │ │ │ -'''.encode('utf-8'), │ │ │ │ │ - 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3 │ │ │ │ │ + stat_types = {stat.S_IFREG: 'reg', stat.S_IFDIR: 'dir', stat.S_IFLNK: 'lnk', stat.S_IFCHR: 'chr', │ │ │ │ │ + stat.S_IFBLK: 'blk', stat.S_IFIFO: 'fifo', stat.S_IFSOCK: 'sock'} │ │ │ │ │ + available_stat_getters = { │ │ │ │ │ + 'type': lambda buf: stat_types.get(stat.S_IFMT(buf.st_mode)), │ │ │ │ │ + 'tag': tag_from_stat, │ │ │ │ │ + 'mode': lambda buf: stat.S_IMODE(buf.st_mode), │ │ │ │ │ + 'size': lambda buf: buf.st_size, │ │ │ │ │ + 'uid': lambda buf: buf.st_uid, │ │ │ │ │ + 'gid': lambda buf: buf.st_gid, │ │ │ │ │ + 'mtime': lambda buf: buf.st_mtime, │ │ │ │ │ + 'user': lambda buf: get_user(buf.st_uid), │ │ │ │ │ + 'group': lambda buf: get_group(buf.st_gid), │ │ │ │ │ + } │ │ │ │ │ + stat_getters = tuple((key, available_stat_getters.get(key, lambda _: None)) for key in attrs) │ │ │ │ │ │ │ │ │ │ -import array │ │ │ │ │ -import io │ │ │ │ │ -import os │ │ │ │ │ -import socket │ │ │ │ │ -import sys │ │ │ │ │ -from typing import Sequence │ │ │ │ │ + def get_attrs(fd: int, name: str, follow: Follow) -> 'JsonDict | None': │ │ │ │ │ + try: │ │ │ │ │ + buf = os.stat(name, follow_symlinks=follow.value, dir_fd=fd) if name else os.fstat(fd) │ │ │ │ │ + except FileNotFoundError: │ │ │ │ │ + return None │ │ │ │ │ + except OSError: │ │ │ │ │ + return {name: None for name, func in stat_getters} │ │ │ │ │ │ │ │ │ │ + result = {key: func(buf) for key, func in stat_getters} │ │ │ │ │ │ │ │ │ │ -def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None: │ │ │ │ │ - cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')] │ │ │ │ │ + if 'target' in result and stat.S_IFMT(buf.st_mode) == stat.S_IFLNK: │ │ │ │ │ + with contextlib.suppress(OSError): │ │ │ │ │ + result['target'] = os.readlink(name, dir_fd=fd) │ │ │ │ │ │ │ │ │ │ - with cmd_write: │ │ │ │ │ - with cmd_read: │ │ │ │ │ - with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock: │ │ │ │ │ - fd_array = array.array('i', (cmd_read.fileno(), *fds)) │ │ │ │ │ - sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)]) │ │ │ │ │ + return result │ │ │ │ │ │ │ │ │ │ - cmd_write.write(repr((command, args))) │ │ │ │ │ + return get_attrs │ │ │ │ │ │ │ │ │ │ + def send_update(self, updates: JsonDict, *, reset: bool = False) -> None: │ │ │ │ │ + if reset: │ │ │ │ │ + if set(self.current_value) & set(updates): │ │ │ │ │ + # if we have an overlap, we need to do a proper reset │ │ │ │ │ + self.send_json({name: None for name in self.current_value}, partial=True) │ │ │ │ │ + self.current_value = {'partial': True} │ │ │ │ │ + updates.update(partial=None) │ │ │ │ │ + else: │ │ │ │ │ + # otherwise there's no overlap: we can just remove the old keys │ │ │ │ │ + updates.update({key: None for key in self.current_value}) │ │ │ │ │ │ │ │ │ │ -def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int: │ │ │ │ │ - ours, theirs = socket.socketpair() │ │ │ │ │ + json_merge_and_filter_patch(self.current_value, updates) │ │ │ │ │ + if updates: │ │ │ │ │ + self.send_json(updates) │ │ │ │ │ │ │ │ │ │ - with theirs: │ │ │ │ │ - command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd)) │ │ │ │ │ + def process_update(self, updates: 'set[str]', *, reset: bool = False) -> None: │ │ │ │ │ + assert self.fd is not None │ │ │ │ │ │ │ │ │ │ - with ours: │ │ │ │ │ - return int(ours.recv(16) or b'1') │ │ │ │ │ + entries: JsonDict = {name: self.getattrs(self.fd, name, Follow.NO) for name in updates} │ │ │ │ │ │ │ │ │ │ + info = entries.pop('', {}) │ │ │ │ │ + assert isinstance(info, dict) # fstat() will never fail with FileNotFoundError │ │ │ │ │ │ │ │ │ │ -def main() -> None: │ │ │ │ │ - if len(sys.argv) == 1: │ │ │ │ │ - command(2, 'ferny.end', []) │ │ │ │ │ - else: │ │ │ │ │ - sys.exit(askpass(2, 1, sys.argv, dict(os.environ))) │ │ │ │ │ + if self.effective_fnmatch: │ │ │ │ │ + info['entries'] = entries │ │ │ │ │ │ │ │ │ │ + if self.targets: │ │ │ │ │ + info['targets'] = targets = {} │ │ │ │ │ + for name in {e.get('target') for e in entries.values() if isinstance(e, dict)}: │ │ │ │ │ + if isinstance(name, str) and ('/' in name or not self.interesting(name)): │ │ │ │ │ + # if this target is a string that we wouldn't otherwise │ │ │ │ │ + # report, then report it via our "targets" attribute. │ │ │ │ │ + targets[name] = self.getattrs(self.fd, name, Follow.YES) │ │ │ │ │ │ │ │ │ │ -if __name__ == '__main__': │ │ │ │ │ - main() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main │ │ │ │ │ + self.send_update({'info': info}, reset=reset) │ │ │ │ │ │ │ │ │ │ -if __name__ == '__main__': │ │ │ │ │ - main() │ │ │ │ │ -''', │ │ │ │ │ - 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging │ │ │ │ │ -import re │ │ │ │ │ -from typing import ClassVar, Match, Sequence │ │ │ │ │ + def process_pending_updates(self) -> None: │ │ │ │ │ + assert self.pending is not None │ │ │ │ │ + if self.pending: │ │ │ │ │ + self.process_update(self.pending) │ │ │ │ │ + self.pending = None │ │ │ │ │ │ │ │ │ │ -from .interaction_agent import AskpassHandler │ │ │ │ │ + def interesting(self, name: str) -> bool: │ │ │ │ │ + if name == '': │ │ │ │ │ + return True │ │ │ │ │ + else: │ │ │ │ │ + # only report updates on entry filenames if we match them │ │ │ │ │ + return fnmatch.fnmatch(name, self.effective_fnmatch) │ │ │ │ │ │ │ │ │ │ -logger = logging.getLogger(__name__) │ │ │ │ │ + def schedule_update(self, name: str) -> None: │ │ │ │ │ + if not self.interesting(name): │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ + if self.pending is None: │ │ │ │ │ + asyncio.get_running_loop().call_later(0.1, self.process_pending_updates) │ │ │ │ │ + self.pending = set() │ │ │ │ │ │ │ │ │ │ -class AskpassPrompt: │ │ │ │ │ - """An askpass prompt resulting from a call to ferny-askpass. │ │ │ │ │ + self.pending.add(name) │ │ │ │ │ │ │ │ │ │ - stderr: the contents of stderr from before ferny-askpass was called. │ │ │ │ │ - Likely related to previous failed operations. │ │ │ │ │ - messages: all but the last line of the prompt as handed to ferny-askpass. │ │ │ │ │ - Usually contains context about the question. │ │ │ │ │ - prompt: the last line handed to ferny-askpass. The prompt itself. │ │ │ │ │ - """ │ │ │ │ │ - stderr: str │ │ │ │ │ - messages: str │ │ │ │ │ - prompt: str │ │ │ │ │ + def report_error(self, err: int) -> None: │ │ │ │ │ + if err == errno.ENOENT: │ │ │ │ │ + problem = 'not-found' │ │ │ │ │ + elif err in (errno.EPERM, errno.EACCES): │ │ │ │ │ + problem = 'access-denied' │ │ │ │ │ + elif err == errno.ENOTDIR: │ │ │ │ │ + problem = 'not-directory' │ │ │ │ │ + else: │ │ │ │ │ + problem = 'internal-error' │ │ │ │ │ │ │ │ │ │ - def __init__(self, prompt: str, messages: str, stderr: str) -> None: │ │ │ │ │ - self.stderr = stderr │ │ │ │ │ - self.messages = messages │ │ │ │ │ - self.prompt = prompt │ │ │ │ │ + self.send_update({'error': { │ │ │ │ │ + 'problem': problem, 'message': os.strerror(err), 'errno': errno.errorcode[err] │ │ │ │ │ + }}, reset=True) │ │ │ │ │ │ │ │ │ │ - def reply(self, response: str) -> None: │ │ │ │ │ - pass │ │ │ │ │ + def flag_onlydir_error(self, fd: Handle) -> bool: │ │ │ │ │ + # If our requested path ended with '/' then make sure we got a │ │ │ │ │ + # directory, or else it's an error. open() will have already flagged │ │ │ │ │ + # that for us, but systemd_ctypes doesn't do that (yet). │ │ │ │ │ + if not self.watch or not self.path.endswith('/'): │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ - def close(self) -> None: │ │ │ │ │ - pass │ │ │ │ │ + buf = os.fstat(fd) # this should never fail │ │ │ │ │ + if stat.S_IFMT(buf.st_mode) != stat.S_IFDIR: │ │ │ │ │ + self.report_error(errno.ENOTDIR) │ │ │ │ │ + return True │ │ │ │ │ │ │ │ │ │ - async def handle_via(self, responder: 'SshAskpassResponder') -> None: │ │ │ │ │ - try: │ │ │ │ │ - response = await self.dispatch(responder) │ │ │ │ │ - if response is not None: │ │ │ │ │ - self.reply(response) │ │ │ │ │ - finally: │ │ │ │ │ - self.close() │ │ │ │ │ + return False │ │ │ │ │ │ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ - return await responder.do_prompt(self) │ │ │ │ │ + def report_initial_state(self, fd: Handle) -> None: │ │ │ │ │ + if self.flag_onlydir_error(fd): │ │ │ │ │ + return │ │ │ │ │ │ │ │ │ │ + self.fd = fd │ │ │ │ │ │ │ │ │ │ -class SSHAskpassPrompt(AskpassPrompt): │ │ │ │ │ - # The valid answers to prompts of this type. If this is None then any │ │ │ │ │ - # answer is permitted. If it's a sequence then only answers from the │ │ │ │ │ - # sequence are permitted. If it's an empty sequence, then no answer is │ │ │ │ │ - # permitted (ie: the askpass callback should never return). │ │ │ │ │ - answers: 'ClassVar[Sequence[str] | None]' = None │ │ │ │ │ + entries = {''} │ │ │ │ │ + if self.fnmatch: │ │ │ │ │ + try: │ │ │ │ │ + entries.update(os.listdir(f'/proc/self/fd/{self.fd}')) │ │ │ │ │ + self.effective_fnmatch = self.fnmatch │ │ │ │ │ + except OSError: │ │ │ │ │ + # If we failed to get an initial list, then report nothing from now on │ │ │ │ │ + self.effective_fnmatch = '' │ │ │ │ │ │ │ │ │ │ - # Patterns to capture. `_pattern` *must* match. │ │ │ │ │ - _pattern: ClassVar[str] │ │ │ │ │ - # `_extra_patterns` can fill in extra class attributes if they match. │ │ │ │ │ - _extra_patterns: ClassVar[Sequence[str]] = () │ │ │ │ │ + self.process_update({e for e in entries if self.interesting(e)}, reset=True) │ │ │ │ │ │ │ │ │ │ - def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None: │ │ │ │ │ - super().__init__(prompt, messages, stderr) │ │ │ │ │ - self.__dict__.update(match.groupdict()) │ │ │ │ │ + def do_inotify_event(self, mask: InotifyEvent, cookie: int, rawname: 'bytes | None') -> None: │ │ │ │ │ + logger.debug('do_inotify_event(%r, %r, %r)', mask, cookie, rawname) │ │ │ │ │ + name = (rawname or b'').decode(errors='surrogateescape') │ │ │ │ │ │ │ │ │ │ - for pattern in self._extra_patterns: │ │ │ │ │ - extra_match = re.search(with_helpers(pattern), messages, re.M) │ │ │ │ │ - if extra_match is not None: │ │ │ │ │ - self.__dict__.update(extra_match.groupdict()) │ │ │ │ │ + self.schedule_update(name) │ │ │ │ │ │ │ │ │ │ + if name and mask | (InotifyEvent.CREATE | InotifyEvent.DELETE | │ │ │ │ │ + InotifyEvent.MOVED_TO | InotifyEvent.MOVED_FROM): │ │ │ │ │ + # These events change the mtime of the directory │ │ │ │ │ + self.schedule_update('') │ │ │ │ │ │ │ │ │ │ -# Specific prompts │ │ │ │ │ -HELPERS = { │ │ │ │ │ - "%{algorithm}": r"(?P\b[-\w]+\b)", │ │ │ │ │ - "%{filename}": r"(?P.+)", │ │ │ │ │ - "%{fingerprint}": r"(?PSHA256:[0-9A-Za-z+/]{43})", │ │ │ │ │ - "%{hostname}": r"(?P[^ @']+)", │ │ │ │ │ - "%{pkcs11_id}": r"(?P.+)", │ │ │ │ │ - "%{username}": r"(?P[^ @']+)", │ │ │ │ │ -} │ │ │ │ │ + def do_identity_changed(self, fd: 'Handle | None', err: 'int | None') -> None: │ │ │ │ │ + logger.debug('do_identity_changed(%r, %r)', fd, err) │ │ │ │ │ + # If there were previously pending changes, they are now irrelevant. │ │ │ │ │ + if self.pending is not None: │ │ │ │ │ + # Note: don't set to None, since the handler is still pending │ │ │ │ │ + self.pending.clear() │ │ │ │ │ │ │ │ │ │ + if err is None: │ │ │ │ │ + assert fd is not None │ │ │ │ │ + self.report_initial_state(fd) │ │ │ │ │ + else: │ │ │ │ │ + self.report_error(err) │ │ │ │ │ │ │ │ │ │ -class SshPasswordPrompt(SSHAskpassPrompt): │ │ │ │ │ - _pattern = r"%{username}@%{hostname}'s password: " │ │ │ │ │ - username: 'str | None' = None │ │ │ │ │ - hostname: 'str | None' = None │ │ │ │ │ + def do_close(self) -> None: │ │ │ │ │ + # non-watch channels close immediately — if we get this, we're watching │ │ │ │ │ + assert self.path_watch is not None │ │ │ │ │ + self.path_watch.close() │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ - return await responder.do_password_prompt(self) │ │ │ │ │ + def do_open(self, options: JsonObject) -> None: │ │ │ │ │ + self.path = get_str(options, 'path') │ │ │ │ │ + if not os.path.isabs(self.path): │ │ │ │ │ + raise JsonError(options, '"path" must be an absolute path') │ │ │ │ │ │ │ │ │ │ + attrs = set(get_strv(options, 'attrs')) │ │ │ │ │ + self.getattrs = self.make_getattrs(attrs - {'targets', 'entries'}) │ │ │ │ │ + self.fnmatch = get_str(options, 'fnmatch', '*' if 'entries' in attrs else '') │ │ │ │ │ + self.targets = 'targets' in attrs │ │ │ │ │ + self.follow = get_bool(options, 'follow', default=True) │ │ │ │ │ + self.watch = get_bool(options, 'watch', default=False) │ │ │ │ │ + if self.watch and not self.follow: │ │ │ │ │ + raise JsonError(options, '"watch: true" and "follow: false" are (currently) incompatible') │ │ │ │ │ + if self.targets and not self.follow: │ │ │ │ │ + raise JsonError(options, '`targets: "stat"` and `follow: false` are (currently) incompatible') │ │ │ │ │ │ │ │ │ │ -class SshPassphrasePrompt(SSHAskpassPrompt): │ │ │ │ │ - _pattern = r"Enter passphrase for key '%{filename}': " │ │ │ │ │ - filename: str │ │ │ │ │ + self.current_value = {} │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ - return await responder.do_passphrase_prompt(self) │ │ │ │ │ + if not self.watch: │ │ │ │ │ + try: │ │ │ │ │ + fd = Handle.open(self.path, os.O_PATH if self.follow else os.O_PATH | os.O_NOFOLLOW) │ │ │ │ │ + except OSError as exc: │ │ │ │ │ + self.report_error(exc.errno) │ │ │ │ │ + else: │ │ │ │ │ + self.report_initial_state(fd) │ │ │ │ │ + fd.close() │ │ │ │ │ │ │ │ │ │ + self.done() │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ -class SshFIDOPINPrompt(SSHAskpassPrompt): │ │ │ │ │ - _pattern = r"Enter PIN for %{algorithm} key %{filename}: " │ │ │ │ │ - algorithm: str │ │ │ │ │ - filename: str │ │ │ │ │ + else: │ │ │ │ │ + # PathWatch will call do_identity_changed(), which does the same as │ │ │ │ │ + # above: calls either report_initial_state() or report_error(), │ │ │ │ │ + # depending on if it was provided with an fd or an error code. │ │ │ │ │ + self.path_watch = PathWatch(self.path, self) │ │ │ │ │ +'''.encode('utf-8'), │ │ │ │ │ + 'cockpit/channels/stream.py': br'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ - return await responder.do_fido_pin_prompt(self) │ │ │ │ │ +import asyncio │ │ │ │ │ +import logging │ │ │ │ │ +import os │ │ │ │ │ +import subprocess │ │ │ │ │ +from typing import Dict │ │ │ │ │ │ │ │ │ │ +from ..channel import ChannelError, ProtocolChannel │ │ │ │ │ +from ..jsonutil import JsonDict, JsonObject, get_bool, get_int, get_object, get_str, get_strv │ │ │ │ │ +from ..transports import SubprocessProtocol, SubprocessTransport, WindowSize │ │ │ │ │ │ │ │ │ │ -class SshFIDOUserPresencePrompt(SSHAskpassPrompt): │ │ │ │ │ - _pattern = r"Confirm user presence for key %{algorithm} %{fingerprint}" │ │ │ │ │ - answers = () │ │ │ │ │ - algorithm: str │ │ │ │ │ - fingerprint: str │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ - return await responder.do_fido_user_presence_prompt(self) │ │ │ │ │ │ │ │ │ │ +class SocketStreamChannel(ProtocolChannel): │ │ │ │ │ + payload = 'stream' │ │ │ │ │ │ │ │ │ │ -class SshPKCS11PINPrompt(SSHAskpassPrompt): │ │ │ │ │ - _pattern = r"Enter PIN for '%{pkcs11_id}': " │ │ │ │ │ - pkcs11_id: str │ │ │ │ │ + async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport: │ │ │ │ │ + if 'unix' in options and 'port' in options: │ │ │ │ │ + raise ChannelError('protocol-error', message='cannot specify both "port" and "unix" options') │ │ │ │ │ │ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ - return await responder.do_pkcs11_pin_prompt(self) │ │ │ │ │ + try: │ │ │ │ │ + # Unix │ │ │ │ │ + if 'unix' in options: │ │ │ │ │ + path = get_str(options, 'unix') │ │ │ │ │ + label = f'Unix socket {path}' │ │ │ │ │ + transport, _ = await loop.create_unix_connection(lambda: self, path) │ │ │ │ │ │ │ │ │ │ + # TCP │ │ │ │ │ + elif 'port' in options: │ │ │ │ │ + port = get_int(options, 'port') │ │ │ │ │ + host = get_str(options, 'address', 'localhost') │ │ │ │ │ + label = f'TCP socket {host}:{port}' │ │ │ │ │ │ │ │ │ │ -class SshHostKeyPrompt(SSHAskpassPrompt): │ │ │ │ │ - _pattern = r"Are you sure you want to continue connecting \(yes/no(/\[fingerprint\])?\)\? " │ │ │ │ │ - _extra_patterns = [ │ │ │ │ │ - r"%{fingerprint}[.]$", │ │ │ │ │ - r"^%{algorithm} key fingerprint is", │ │ │ │ │ - r"^The fingerprint for the %{algorithm} key sent by the remote host is$" │ │ │ │ │ - ] │ │ │ │ │ - answers = ('yes', 'no') │ │ │ │ │ - algorithm: str │ │ │ │ │ - fingerprint: str │ │ │ │ │ + transport, _ = await loop.create_connection(lambda: self, host, port) │ │ │ │ │ + else: │ │ │ │ │ + raise ChannelError('protocol-error', │ │ │ │ │ + message='no "port" or "unix" or other address option for channel') │ │ │ │ │ │ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None': │ │ │ │ │ - return await responder.do_host_key_prompt(self) │ │ │ │ │ + logger.debug('SocketStreamChannel: connected to %s', label) │ │ │ │ │ + except OSError as error: │ │ │ │ │ + logger.info('SocketStreamChannel: connecting to %s failed: %s', label, error) │ │ │ │ │ + if isinstance(error, ConnectionRefusedError): │ │ │ │ │ + problem = 'not-found' │ │ │ │ │ + else: │ │ │ │ │ + problem = 'terminated' │ │ │ │ │ + raise ChannelError(problem, message=str(error)) from error │ │ │ │ │ + self.close_on_eof() │ │ │ │ │ + assert isinstance(transport, asyncio.Transport) │ │ │ │ │ + return transport │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -def with_helpers(pattern: str) -> str: │ │ │ │ │ - for name, helper in HELPERS.items(): │ │ │ │ │ - pattern = pattern.replace(name, helper) │ │ │ │ │ +class SubprocessStreamChannel(ProtocolChannel, SubprocessProtocol): │ │ │ │ │ + payload = 'stream' │ │ │ │ │ + restrictions = (('spawn', None),) │ │ │ │ │ │ │ │ │ │ - assert '%{' not in pattern │ │ │ │ │ - return pattern │ │ │ │ │ + def process_exited(self) -> None: │ │ │ │ │ + self.close_on_eof() │ │ │ │ │ │ │ │ │ │ + def _get_close_args(self) -> JsonObject: │ │ │ │ │ + assert isinstance(self._transport, SubprocessTransport) │ │ │ │ │ + args: JsonDict = {'exit-status': self._transport.get_returncode()} │ │ │ │ │ + stderr = self._transport.get_stderr() │ │ │ │ │ + if stderr is not None: │ │ │ │ │ + args['message'] = stderr │ │ │ │ │ + return args │ │ │ │ │ │ │ │ │ │ -def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt: │ │ │ │ │ - classes = [ │ │ │ │ │ - SshFIDOPINPrompt, │ │ │ │ │ - SshFIDOUserPresencePrompt, │ │ │ │ │ - SshHostKeyPrompt, │ │ │ │ │ - SshPKCS11PINPrompt, │ │ │ │ │ - SshPassphrasePrompt, │ │ │ │ │ - SshPasswordPrompt, │ │ │ │ │ - ] │ │ │ │ │ + def do_options(self, options): │ │ │ │ │ + window = get_object(options, 'window', WindowSize, None) │ │ │ │ │ + if window is not None: │ │ │ │ │ + self._transport.set_window_size(window) │ │ │ │ │ │ │ │ │ │ - # The last line is the line after the last newline character, excluding the │ │ │ │ │ - # optional final newline character. eg: "x\ny\nLAST\n" or "x\ny\nLAST" │ │ │ │ │ - second_last_newline = string.rfind('\n', 0, -1) │ │ │ │ │ - if second_last_newline >= 0: │ │ │ │ │ - last_line = string[second_last_newline + 1:] │ │ │ │ │ - extras = string[:second_last_newline + 1] │ │ │ │ │ - else: │ │ │ │ │ - last_line = string │ │ │ │ │ - extras = '' │ │ │ │ │ + async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> SubprocessTransport: │ │ │ │ │ + args = get_strv(options, 'spawn') │ │ │ │ │ + err = get_str(options, 'err', 'msg') │ │ │ │ │ + cwd = get_str(options, 'directory', '.') │ │ │ │ │ + pty = get_bool(options, 'pty', default=False) │ │ │ │ │ + window = get_object(options, 'window', WindowSize, None) │ │ │ │ │ + environ = get_strv(options, 'environ', []) │ │ │ │ │ │ │ │ │ │ - for cls in classes: │ │ │ │ │ - pattern = with_helpers(cls._pattern) │ │ │ │ │ - match = re.fullmatch(pattern, last_line) │ │ │ │ │ - if match is not None: │ │ │ │ │ - return cls(last_line, extras, stderr, match) │ │ │ │ │ + if err == 'out': │ │ │ │ │ + stderr = subprocess.STDOUT │ │ │ │ │ + elif err == 'ignore': │ │ │ │ │ + stderr = subprocess.DEVNULL │ │ │ │ │ + else: │ │ │ │ │ + stderr = subprocess.PIPE │ │ │ │ │ │ │ │ │ │ - return AskpassPrompt(last_line, extras, stderr) │ │ │ │ │ + env: Dict[str, str] = dict(os.environ) │ │ │ │ │ + try: │ │ │ │ │ + env.update(dict(e.split('=', 1) for e in environ)) │ │ │ │ │ + except ValueError: │ │ │ │ │ + raise ChannelError('protocol-error', message='invalid "environ" option for stream channel') from None │ │ │ │ │ + │ │ │ │ │ + try: │ │ │ │ │ + transport = SubprocessTransport(loop, self, args, pty=pty, window=window, env=env, cwd=cwd, stderr=stderr) │ │ │ │ │ + logger.debug('Spawned process args=%s pid=%i', args, transport.get_pid()) │ │ │ │ │ + return transport │ │ │ │ │ + except FileNotFoundError as error: │ │ │ │ │ + raise ChannelError('not-found') from error │ │ │ │ │ + except PermissionError as error: │ │ │ │ │ + raise ChannelError('access-denied') from error │ │ │ │ │ + except OSError as error: │ │ │ │ │ + logger.info("Failed to spawn %s: %s", args, str(error)) │ │ │ │ │ + raise ChannelError('internal-error') from error │ │ │ │ │ +''', │ │ │ │ │ + 'cockpit/channels/trivial.py': br'''# This file is part of Cockpit. │ │ │ │ │ +# │ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc. │ │ │ │ │ +# │ │ │ │ │ +# This program is free software: you can redistribute it and/or modify │ │ │ │ │ +# it under the terms of the GNU General Public License as published by │ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or │ │ │ │ │ +# (at your option) any later version. │ │ │ │ │ +# │ │ │ │ │ +# This program is distributed in the hope that it will be useful, │ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of │ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the │ │ │ │ │ +# GNU General Public License for more details. │ │ │ │ │ +# │ │ │ │ │ +# You should have received a copy of the GNU General Public License │ │ │ │ │ +# along with this program. If not, see . │ │ │ │ │ │ │ │ │ │ +import logging │ │ │ │ │ │ │ │ │ │ -class SshAskpassResponder(AskpassHandler): │ │ │ │ │ - async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None': │ │ │ │ │ - return await categorize_ssh_prompt(prompt, stderr).dispatch(self) │ │ │ │ │ +from ..channel import Channel │ │ │ │ │ │ │ │ │ │ - async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None': │ │ │ │ │ - # Default fallback for unrecognised message types: unimplemented │ │ │ │ │ - return None │ │ │ │ │ +logger = logging.getLogger(__name__) │ │ │ │ │ │ │ │ │ │ - async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None': │ │ │ │ │ - return await self.do_prompt(prompt) │ │ │ │ │ │ │ │ │ │ - async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None': │ │ │ │ │ - return await self.do_prompt(prompt) │ │ │ │ │ +class EchoChannel(Channel): │ │ │ │ │ + payload = 'echo' │ │ │ │ │ │ │ │ │ │ - async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None': │ │ │ │ │ - return await self.do_prompt(prompt) │ │ │ │ │ + def do_open(self, options): │ │ │ │ │ + self.ready() │ │ │ │ │ │ │ │ │ │ - async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None': │ │ │ │ │ - return await self.do_prompt(prompt) │ │ │ │ │ + def do_data(self, data): │ │ │ │ │ + self.send_data(data) │ │ │ │ │ │ │ │ │ │ - async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None': │ │ │ │ │ - return await self.do_prompt(prompt) │ │ │ │ │ + def do_done(self): │ │ │ │ │ + self.done() │ │ │ │ │ + self.close() │ │ │ │ │ │ │ │ │ │ - async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None': │ │ │ │ │ - return await self.do_prompt(prompt) │ │ │ │ │ + │ │ │ │ │ +class NullChannel(Channel): │ │ │ │ │ + payload = 'null' │ │ │ │ │ + │ │ │ │ │ + def do_open(self, options): │ │ │ │ │ + self.ready() │ │ │ │ │ + │ │ │ │ │ + def do_close(self): │ │ │ │ │ + self.close() │ │ │ │ │ ''', │ │ │ │ │ - 'cockpit/_vendor/ferny/py.typed': br'''''', │ │ │ │ │ })) │ │ │ │ │ from cockpit.bridge import main as main │ │ │ │ │ main(beipack=True) │ │ │ ├── ./usr/lib/python3/dist-packages/cockpit-311.dist-info/direct_url.json │ │ │ │ ├── Pretty-printed │ │ │ │ │┄ Similarity: 0.90625% │ │ │ │ │┄ Differences: {"'archive_info'": "{'hash': " │ │ │ │ │┄ "'sha256=c68c6cdcbec993def616cf24253c23549d24f52a4f79c07cbf537833834f4da7', " │ │ │ │ │┄ "'hashes': {'sha256': " │ │ │ │ │┄ "'c68c6cdcbec993def616cf24253c23549d24f52a4f79c07cbf537833834f4da7'}}"} │ │ │ │ │ @@ -1,9 +1,9 @@ │ │ │ │ │ { │ │ │ │ │ "archive_info": { │ │ │ │ │ - "hash": "sha256=304e35e273c342b00045b101af7e7d09cec4ec4cd5bed02f0bc77db621c1fc89", │ │ │ │ │ + "hash": "sha256=c68c6cdcbec993def616cf24253c23549d24f52a4f79c07cbf537833834f4da7", │ │ │ │ │ "hashes": { │ │ │ │ │ - "sha256": "304e35e273c342b00045b101af7e7d09cec4ec4cd5bed02f0bc77db621c1fc89" │ │ │ │ │ + "sha256": "c68c6cdcbec993def616cf24253c23549d24f52a4f79c07cbf537833834f4da7" │ │ │ │ │ } │ │ │ │ │ }, │ │ │ │ │ "url": "file:///build/reproducible-path/cockpit-311/tmp/wheel/cockpit-311-py3-none-any.whl" │ │ │ │ │ }