--- /srv/reproducible-results/rbuild-debian/r-b-build.1Aue177E/b1/cockpit_319-1_armhf.changes
+++ /srv/reproducible-results/rbuild-debian/r-b-build.1Aue177E/b2/cockpit_319-1_armhf.changes
├── Files
│ @@ -1,10 +1,10 @@
│
│ 034b567b1aa5c0b712df7c69582115c4 138540 debug optional cockpit-bridge-dbgsym_319-1_armhf.deb
│ - f429ff2483019ab8bb2872c1086af774 356356 admin optional cockpit-bridge_319-1_armhf.deb
│ + c040ebe6c407f813cafd79042fe68df2 356756 admin optional cockpit-bridge_319-1_armhf.deb
│ f3de7834ba899560a10d26cec1515b22 132160 doc optional cockpit-doc_319-1_all.deb
│ 0d78da864e2350727c59928bb282cab5 832280 admin optional cockpit-networkmanager_319-1_all.deb
│ 54418c37ac86a8098b031ec312b2fff8 945804 admin optional cockpit-packagekit_319-1_all.deb
│ ae70b1f02abbc37eb4c451c35968e4d9 560952 admin optional cockpit-sosreport_319-1_all.deb
│ 09ec42a47220741ac1184b676f0277c1 889556 admin optional cockpit-storaged_319-1_all.deb
│ 83bfd3cebe1cfb5d1c97d424c5fdcdc2 3314380 admin optional cockpit-system_319-1_all.deb
│ 8546f7f0bdc3ec6a866bc837aa617439 4716 debug optional cockpit-tests-dbgsym_319-1_armhf.deb
├── cockpit-bridge_319-1_armhf.deb
│ ├── file list
│ │ @@ -1,3 +1,3 @@
│ │ -rw-r--r-- 0 0 0 4 2024-06-26 12:54:15.000000 debian-binary
│ │ -rw-r--r-- 0 0 0 3884 2024-06-26 12:54:15.000000 control.tar.xz
│ │ --rw-r--r-- 0 0 0 352280 2024-06-26 12:54:15.000000 data.tar.xz
│ │ +-rw-r--r-- 0 0 0 352680 2024-06-26 12:54:15.000000 data.tar.xz
│ ├── control.tar.xz
│ │ ├── control.tar
│ │ │ ├── ./md5sums
│ │ │ │ ├── ./md5sums
│ │ │ │ │┄ Files differ
│ ├── data.tar.xz
│ │ ├── data.tar
│ │ │ ├── file list
│ │ │ │ @@ -60,15 +60,15 @@
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 6644 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/channels/metrics.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 4051 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/channels/packages.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 4872 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/channels/stream.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 1187 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/channels/trivial.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 3188 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/config.py
│ │ │ │ drwxr-xr-x 0 root (0) root (0) 0 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/data/
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 574 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/data/__init__.py
│ │ │ │ --rw-r--r-- 0 root (0) root (0) 87188 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz
│ │ │ │ +-rw-r--r-- 0 root (0) root (0) 87568 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/data/cockpit-bridge.beipack.xz
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 3212 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/data/fail.html
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 5875 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/internal_endpoints.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 7242 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/jsonutil.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 21539 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/packages.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 12729 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/peer.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 7580 2024-06-26 12:54:15.000000 ./usr/lib/python3/dist-packages/cockpit/polkit.py
│ │ │ │ -rw-r--r-- 0 root (0) root (0) 2031 2024-06-26 12:54:15.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,14 +64,2335 @@
│ │ │ │ │ ) -> 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/router.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is distributed in the hope that it will be useful,
│ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ +# GNU General Public License for more details.
│ │ │ │ │ +#
│ │ │ │ │ +# You should have received a copy of the GNU General Public License
│ │ │ │ │ +# along with this program. If not, see .
│ │ │ │ │ +
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import collections
│ │ │ │ │ +import logging
│ │ │ │ │ +from typing import Dict, List, Optional
│ │ │ │ │ +
│ │ │ │ │ +from .jsonutil import JsonObject, JsonValue
│ │ │ │ │ +from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class ExecutionQueue:
│ │ │ │ │ + """Temporarily delay calls to a given set of class methods.
│ │ │ │ │ +
│ │ │ │ │ + Functions by replacing the named function at the instance __dict__
│ │ │ │ │ + level, effectively providing an override for exactly one instance
│ │ │ │ │ + of `method`'s object.
│ │ │ │ │ + Queues the invocations. Run them later with .run(), which also reverses
│ │ │ │ │ + the redirection by deleting the named methods from the instance.
│ │ │ │ │ + """
│ │ │ │ │ + def __init__(self, methods):
│ │ │ │ │ + self.queue = collections.deque()
│ │ │ │ │ + self.methods = methods
│ │ │ │ │ +
│ │ │ │ │ + for method in self.methods:
│ │ │ │ │ + self._wrap(method)
│ │ │ │ │ +
│ │ │ │ │ + def _wrap(self, method):
│ │ │ │ │ + # NB: this function is stored in the instance dict and therefore
│ │ │ │ │ + # doesn't function as a descriptor, isn't a method, doesn't get bound,
│ │ │ │ │ + # and therefore doesn't receive a self parameter
│ │ │ │ │ + setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args)))
│ │ │ │ │ +
│ │ │ │ │ + def run(self):
│ │ │ │ │ + logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue))
│ │ │ │ │ + for method, args in self.queue:
│ │ │ │ │ + method(*args)
│ │ │ │ │ +
│ │ │ │ │ + for method in self.methods:
│ │ │ │ │ + delattr(method.__self__, method.__func__.__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Endpoint:
│ │ │ │ │ + router: 'Router'
│ │ │ │ │ + __endpoint_frozen_queue: Optional[ExecutionQueue] = None
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, router: 'Router'):
│ │ │ │ │ + router.add_endpoint(self)
│ │ │ │ │ + self.router = router
│ │ │ │ │ +
│ │ │ │ │ + def freeze_endpoint(self):
│ │ │ │ │ + assert self.__endpoint_frozen_queue is None
│ │ │ │ │ + logger.debug('Freezing endpoint %s', self)
│ │ │ │ │ + self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill})
│ │ │ │ │ +
│ │ │ │ │ + def thaw_endpoint(self):
│ │ │ │ │ + assert self.__endpoint_frozen_queue is not None
│ │ │ │ │ + logger.debug('Thawing endpoint %s', self)
│ │ │ │ │ + self.__endpoint_frozen_queue.run()
│ │ │ │ │ + self.__endpoint_frozen_queue = None
│ │ │ │ │ +
│ │ │ │ │ + # interface for receiving messages
│ │ │ │ │ + def do_close(self):
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + # interface for sending messages
│ │ │ │ │ + def send_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + self.router.write_channel_data(channel, data)
│ │ │ │ │ +
│ │ │ │ │ + def send_channel_control(
│ │ │ │ │ + self, channel: str, command: str, _msg: 'JsonObject | None', **kwargs: JsonValue
│ │ │ │ │ + ) -> None:
│ │ │ │ │ + self.router.write_control(_msg, channel=channel, command=command, **kwargs)
│ │ │ │ │ + if command == 'close':
│ │ │ │ │ + self.router.endpoints[self].remove(channel)
│ │ │ │ │ + self.router.drop_channel(channel)
│ │ │ │ │ +
│ │ │ │ │ + def shutdown_endpoint(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ + self.router.shutdown_endpoint(self, _msg, **kwargs)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class RoutingError(CockpitProblem):
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class RoutingRule:
│ │ │ │ │ + router: 'Router'
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, router: 'Router'):
│ │ │ │ │ + self.router = router
│ │ │ │ │ +
│ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:
│ │ │ │ │ + """Check if a routing rule applies to a given 'open' message.
│ │ │ │ │ +
│ │ │ │ │ + This should inspect the options dictionary and do one of the following three things:
│ │ │ │ │ +
│ │ │ │ │ + - return an Endpoint to handle this channel
│ │ │ │ │ + - raise a RoutingError to indicate that the open should be rejected
│ │ │ │ │ + - return None to let the next rule run
│ │ │ │ │ + """
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def shutdown(self):
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Router(CockpitProtocolServer):
│ │ │ │ │ + routing_rules: List[RoutingRule]
│ │ │ │ │ + open_channels: Dict[str, Endpoint]
│ │ │ │ │ + endpoints: 'dict[Endpoint, set[str]]'
│ │ │ │ │ + no_endpoints: asyncio.Event # set if endpoints dict is empty
│ │ │ │ │ + _eof: bool = False
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, routing_rules: List[RoutingRule]):
│ │ │ │ │ + for rule in routing_rules:
│ │ │ │ │ + rule.router = self
│ │ │ │ │ + self.routing_rules = routing_rules
│ │ │ │ │ + self.open_channels = {}
│ │ │ │ │ + self.endpoints = {}
│ │ │ │ │ + self.no_endpoints = asyncio.Event()
│ │ │ │ │ + self.no_endpoints.set() # at first there are no endpoints
│ │ │ │ │ +
│ │ │ │ │ + def check_rules(self, options: JsonObject) -> Endpoint:
│ │ │ │ │ + for rule in self.routing_rules:
│ │ │ │ │ + logger.debug(' applying rule %s', rule)
│ │ │ │ │ + endpoint = rule.apply_rule(options)
│ │ │ │ │ + if endpoint is not None:
│ │ │ │ │ + logger.debug(' resulting endpoint is %s', endpoint)
│ │ │ │ │ + return endpoint
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug(' No rules matched')
│ │ │ │ │ + raise RoutingError('not-supported')
│ │ │ │ │ +
│ │ │ │ │ + def drop_channel(self, channel: str) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + self.open_channels.pop(channel)
│ │ │ │ │ + logger.debug('router dropped channel %s', channel)
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels)
│ │ │ │ │ +
│ │ │ │ │ + def add_endpoint(self, endpoint: Endpoint) -> None:
│ │ │ │ │ + self.endpoints[endpoint] = set()
│ │ │ │ │ + self.no_endpoints.clear()
│ │ │ │ │ +
│ │ │ │ │ + def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ + channels = self.endpoints.pop(endpoint)
│ │ │ │ │ + logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels)
│ │ │ │ │ + for channel in channels:
│ │ │ │ │ + self.write_control(_msg, command='close', channel=channel, **kwargs)
│ │ │ │ │ + self.drop_channel(channel)
│ │ │ │ │ +
│ │ │ │ │ + if not self.endpoints:
│ │ │ │ │ + self.no_endpoints.set()
│ │ │ │ │ +
│ │ │ │ │ + # were we waiting to exit?
│ │ │ │ │ + if self._eof:
│ │ │ │ │ + logger.debug(' endpoints remaining: %r', self.endpoints)
│ │ │ │ │ + if not self.endpoints and self.transport:
│ │ │ │ │ + logger.debug(' close transport')
│ │ │ │ │ + self.transport.close()
│ │ │ │ │ +
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + endpoints = set(self.endpoints)
│ │ │ │ │ + logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints))
│ │ │ │ │ + for endpoint in endpoints:
│ │ │ │ │ + endpoint.do_kill(host, group, message)
│ │ │ │ │ +
│ │ │ │ │ + def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + # If this is an open message then we need to apply the routing rules to
│ │ │ │ │ + # figure out the correct endpoint to connect. If it's not an open
│ │ │ │ │ + # message, then we expect the endpoint to already exist.
│ │ │ │ │ + if command == 'open':
│ │ │ │ │ + if channel in self.open_channels:
│ │ │ │ │ + raise CockpitProtocolError('channel is already open')
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload'))
│ │ │ │ │ + endpoint = self.check_rules(message)
│ │ │ │ │ + except RoutingError as exc:
│ │ │ │ │ + self.write_control(exc.get_attrs(), command='close', channel=channel)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + self.open_channels[channel] = endpoint
│ │ │ │ │ + self.endpoints[endpoint].add(channel)
│ │ │ │ │ + else:
│ │ │ │ │ + try:
│ │ │ │ │ + endpoint = self.open_channels[channel]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + # sending to a non-existent channel can happen due to races and is not an error
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + # At this point, we have the endpoint. Route the message.
│ │ │ │ │ + endpoint.do_channel_control(channel, command, message)
│ │ │ │ │ +
│ │ │ │ │ + def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + endpoint = self.open_channels[channel]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + 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/polkit.py': r'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (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 locale
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import pwd
│ │ │ │ │ +from typing import Dict, List, Sequence, Tuple
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor.ferny import AskpassHandler
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Variant, bus
│ │ │ │ │ +
│ │ │ │ │ +# that path is valid on at least Debian, Fedora/RHEL, and Arch
│ │ │ │ │ +HELPER_PATH = '/usr/lib/polkit-1/polkit-agent-helper-1'
│ │ │ │ │ +
│ │ │ │ │ +AGENT_DBUS_PATH = '/PolkitAgent'
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +Identity = Tuple[str, Dict[str, Variant]]
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# https://www.freedesktop.org/software/polkit/docs/latest/eggdbus-interface-org.freedesktop.PolicyKit1.AuthenticationAgent.html
│ │ │ │ │ +
│ │ │ │ │ +# Note that we don't implement the CancelAuthentication() API. pkexec gets called in a way that has no opportunity to
│ │ │ │ │ +# cancel an ongoing authentication from the pkexec side. On the UI side cancellation is implemented via the standard
│ │ │ │ │ +# asyncio process mechanism. If we ever need CancelAuthentication(), we could keep a cookie → get_current_task()
│ │ │ │ │ +# mapping, but that method is not available for Python 3.6 yet.
│ │ │ │ │ +
│ │ │ │ │ +class org_freedesktop_PolicyKit1_AuthenticationAgent(bus.Object):
│ │ │ │ │ + def __init__(self, responder: AskpassHandler):
│ │ │ │ │ + super().__init__()
│ │ │ │ │ + self.responder = responder
│ │ │ │ │ +
│ │ │ │ │ + # confusingly named: this actually does the whole authentication dialog, see docs
│ │ │ │ │ + @bus.Interface.Method('', ['s', 's', 's', 'a{ss}', 's', 'a(sa{sv})'])
│ │ │ │ │ + async def begin_authentication(self, action_id: str, message: str, icon_name: str,
│ │ │ │ │ + details: Dict[str, str], cookie: str, identities: Sequence[Identity]) -> None:
│ │ │ │ │ + logger.debug('BeginAuthentication: action %s, message "%s", icon %s, details %s, cookie %s, identities %r',
│ │ │ │ │ + action_id, message, icon_name, details, cookie, identities)
│ │ │ │ │ + # only support authentication as ourselves, as we don't yet have the
│ │ │ │ │ + # protocol plumbing and UI to select an admin user
│ │ │ │ │ + my_uid = os.geteuid()
│ │ │ │ │ + for (auth_type, subject) in identities:
│ │ │ │ │ + if auth_type == 'unix-user' and 'uid' in subject and subject['uid'].value == my_uid:
│ │ │ │ │ + logger.debug('Authentication subject %s matches our uid %d', subject, my_uid)
│ │ │ │ │ + break
│ │ │ │ │ + else:
│ │ │ │ │ + logger.warning('Not supporting authentication as any of %s', identities)
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + user_name = pwd.getpwuid(my_uid).pw_name
│ │ │ │ │ + process = await asyncio.create_subprocess_exec(HELPER_PATH, user_name, cookie,
│ │ │ │ │ + stdin=asyncio.subprocess.PIPE,
│ │ │ │ │ + stdout=asyncio.subprocess.PIPE)
│ │ │ │ │ + try:
│ │ │ │ │ + await self._communicate(process)
│ │ │ │ │ + except asyncio.CancelledError:
│ │ │ │ │ + logger.debug('Cancelled authentication')
│ │ │ │ │ + process.terminate()
│ │ │ │ │ + finally:
│ │ │ │ │ + res = await process.wait()
│ │ │ │ │ + logger.debug('helper exited with code %i', res)
│ │ │ │ │ +
│ │ │ │ │ + async def _communicate(self, process: asyncio.subprocess.Process) -> None:
│ │ │ │ │ + assert process.stdin
│ │ │ │ │ + assert process.stdout
│ │ │ │ │ +
│ │ │ │ │ + messages: List[str] = []
│ │ │ │ │ +
│ │ │ │ │ + async for line in process.stdout:
│ │ │ │ │ + logger.debug('Read line from helper: %s', line)
│ │ │ │ │ + command, _, value = line.strip().decode().partition(' ')
│ │ │ │ │ +
│ │ │ │ │ + # usually: PAM_PROMPT_ECHO_OFF Password: \n
│ │ │ │ │ + if command.startswith('PAM_PROMPT'):
│ │ │ │ │ + # Don't pass this to the UI if it's "Password" (the usual case),
│ │ │ │ │ + # so that superuser.py uses the translated default
│ │ │ │ │ + if value.startswith('Password'):
│ │ │ │ │ + value = ''
│ │ │ │ │ +
│ │ │ │ │ + # flush out accumulated info/error messages
│ │ │ │ │ + passwd = await self.responder.do_askpass('\n'.join(messages), value, '')
│ │ │ │ │ + messages.clear()
│ │ │ │ │ + if passwd is None:
│ │ │ │ │ + logger.debug('got PAM_PROMPT %s, but do_askpass returned None', value)
│ │ │ │ │ + raise asyncio.CancelledError('no password given')
│ │ │ │ │ + logger.debug('got PAM_PROMPT %s, do_askpass returned a password', value)
│ │ │ │ │ + process.stdin.write(passwd.encode())
│ │ │ │ │ + process.stdin.write(b'\n')
│ │ │ │ │ + del passwd # don't keep this around longer than necessary
│ │ │ │ │ + await process.stdin.drain()
│ │ │ │ │ + logger.debug('got PAM_PROMPT, wrote password to helper')
│ │ │ │ │ + elif command in ('PAM_TEXT_INFO', 'PAM_ERROR'):
│ │ │ │ │ + messages.append(value)
│ │ │ │ │ + elif command == 'SUCCESS':
│ │ │ │ │ + logger.debug('Authentication succeeded')
│ │ │ │ │ + break
│ │ │ │ │ + elif command == 'FAILURE':
│ │ │ │ │ + logger.warning('Authentication failed')
│ │ │ │ │ + break
│ │ │ │ │ + else:
│ │ │ │ │ + logger.warning('Unknown line from helper, aborting: %s', line)
│ │ │ │ │ + process.terminate()
│ │ │ │ │ + break
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class PolkitAgent:
│ │ │ │ │ + """Register polkit agent when required
│ │ │ │ │ +
│ │ │ │ │ + Use this as a context manager to ensure that the agent gets unregistered again.
│ │ │ │ │ + """
│ │ │ │ │ + def __init__(self, responder: AskpassHandler):
│ │ │ │ │ + self.responder = responder
│ │ │ │ │ + self.agent_slot = None
│ │ │ │ │ +
│ │ │ │ │ + async def __aenter__(self):
│ │ │ │ │ + try:
│ │ │ │ │ + self.system_bus = bus.Bus.default_system()
│ │ │ │ │ + except OSError as e:
│ │ │ │ │ + logger.warning('cannot connect to system bus, not registering polkit agent: %s', e)
│ │ │ │ │ + return self
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + # may refine that with a D-Bus call to logind
│ │ │ │ │ + self.subject = ('unix-session', {'session-id': Variant(os.environ['XDG_SESSION_ID'], 's')})
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + logger.debug('XDG_SESSION_ID not set, not registering polkit agent')
│ │ │ │ │ + return self
│ │ │ │ │ +
│ │ │ │ │ + agent_object = org_freedesktop_PolicyKit1_AuthenticationAgent(self.responder)
│ │ │ │ │ + self.agent_slot = self.system_bus.add_object(AGENT_DBUS_PATH, agent_object)
│ │ │ │ │ +
│ │ │ │ │ + # register agent
│ │ │ │ │ + locale_name = locale.setlocale(locale.LC_MESSAGES, None)
│ │ │ │ │ + await self.system_bus.call_method_async(
│ │ │ │ │ + 'org.freedesktop.PolicyKit1',
│ │ │ │ │ + '/org/freedesktop/PolicyKit1/Authority',
│ │ │ │ │ + 'org.freedesktop.PolicyKit1.Authority',
│ │ │ │ │ + 'RegisterAuthenticationAgent',
│ │ │ │ │ + '(sa{sv})ss',
│ │ │ │ │ + self.subject, locale_name, AGENT_DBUS_PATH)
│ │ │ │ │ + logger.debug('Registered agent for %r and locale %s', self.subject, locale_name)
│ │ │ │ │ + return self
│ │ │ │ │ +
│ │ │ │ │ + async def __aexit__(self, _exc_type, _exc_value, _traceback):
│ │ │ │ │ + if self.agent_slot:
│ │ │ │ │ + await self.system_bus.call_method_async(
│ │ │ │ │ + 'org.freedesktop.PolicyKit1',
│ │ │ │ │ + '/org/freedesktop/PolicyKit1/Authority',
│ │ │ │ │ + '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/polyfills.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is distributed in the hope that it will be useful,
│ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ +# GNU General Public License for more details.
│ │ │ │ │ +#
│ │ │ │ │ +# You should have received a copy of the GNU General Public License
│ │ │ │ │ +# along with this program. If not, see .
│ │ │ │ │ +
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import socket
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def install():
│ │ │ │ │ + """Add shims for older Python versions"""
│ │ │ │ │ +
│ │ │ │ │ + # introduced in 3.9
│ │ │ │ │ + if not hasattr(socket, 'recv_fds'):
│ │ │ │ │ + import array
│ │ │ │ │ +
│ │ │ │ │ + import _socket
│ │ │ │ │ +
│ │ │ │ │ + def recv_fds(sock, bufsize, maxfds, flags=0):
│ │ │ │ │ + fds = array.array("i")
│ │ │ │ │ + msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize))
│ │ │ │ │ + for cmsg_level, cmsg_type, cmsg_data in ancdata:
│ │ │ │ │ + if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS):
│ │ │ │ │ + fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
│ │ │ │ │ + return msg, list(fds), flags, addr
│ │ │ │ │ +
│ │ │ │ │ + socket.recv_fds = recv_fds
│ │ │ │ │ +
│ │ │ │ │ + # introduced in 3.7
│ │ │ │ │ + if not hasattr(contextlib, 'AsyncExitStack'):
│ │ │ │ │ + class AsyncExitStack:
│ │ │ │ │ + async def __aenter__(self):
│ │ │ │ │ + self.cms = []
│ │ │ │ │ + return self
│ │ │ │ │ +
│ │ │ │ │ + async def enter_async_context(self, cm):
│ │ │ │ │ + result = await cm.__aenter__()
│ │ │ │ │ + self.cms.append(cm)
│ │ │ │ │ + return result
│ │ │ │ │ +
│ │ │ │ │ + async def __aexit__(self, exc_type, exc_value, traceback):
│ │ │ │ │ + for cm in self.cms:
│ │ │ │ │ + cm.__aexit__(exc_type, exc_value, traceback)
│ │ │ │ │ +
│ │ │ │ │ + contextlib.AsyncExitStack = AsyncExitStack
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/samples.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is distributed in the hope that it will be useful,
│ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ +# GNU General Public License for more details.
│ │ │ │ │ +#
│ │ │ │ │ +# You should have received a copy of the GNU General Public License
│ │ │ │ │ +# along with this program. If not, see .
│ │ │ │ │ +
│ │ │ │ │ +import errno
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import re
│ │ │ │ │ +from typing import Any, DefaultDict, Iterable, List, NamedTuple, Optional, Tuple
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Handle
│ │ │ │ │ +
│ │ │ │ │ +USER_HZ = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
│ │ │ │ │ +MS_PER_JIFFY = 1000 / (USER_HZ if (USER_HZ > 0) else 100)
│ │ │ │ │ +HWMON_PATH = '/sys/class/hwmon'
│ │ │ │ │ +
│ │ │ │ │ +# we would like to do this, but mypy complains; https://github.com/python/mypy/issues/2900
│ │ │ │ │ +# Samples = collections.defaultdict[str, Union[float, Dict[str, Union[float, None]]]]
│ │ │ │ │ +Samples = DefaultDict[str, Any]
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def read_int_file(rootfd: int, statfile: str, default: Optional[int] = None, key: bytes = b'') -> Optional[int]:
│ │ │ │ │ + # Not every stat is available, such as cpu.weight
│ │ │ │ │ + try:
│ │ │ │ │ + fd = os.open(statfile, os.O_RDONLY, dir_fd=rootfd)
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + data = os.read(fd, 1024)
│ │ │ │ │ + except OSError as e:
│ │ │ │ │ + # cgroups can disappear between the open and read
│ │ │ │ │ + if e.errno != errno.ENODEV:
│ │ │ │ │ + logger.warning('Failed to read %s: %s', statfile, e)
│ │ │ │ │ + return None
│ │ │ │ │ + finally:
│ │ │ │ │ + os.close(fd)
│ │ │ │ │ +
│ │ │ │ │ + if key:
│ │ │ │ │ + start = data.index(key) + len(key)
│ │ │ │ │ + end = data.index(b'\n', start)
│ │ │ │ │ + data = data[start:end]
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + # 0 often means "none", so replace it with default value
│ │ │ │ │ + return int(data) or default
│ │ │ │ │ + except ValueError:
│ │ │ │ │ + # Some samples such as "memory.max" contains "max" when there is a no limit
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class SampleDescription(NamedTuple):
│ │ │ │ │ + name: str
│ │ │ │ │ + units: str
│ │ │ │ │ + semantics: str
│ │ │ │ │ + instanced: bool
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Sampler:
│ │ │ │ │ + descriptions: List[SampleDescription]
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class CPUSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('cpu.basic.nice', 'millisec', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('cpu.basic.user', 'millisec', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('cpu.basic.system', 'millisec', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('cpu.basic.iowait', 'millisec', 'counter', instanced=False),
│ │ │ │ │ +
│ │ │ │ │ + SampleDescription('cpu.core.nice', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cpu.core.user', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cpu.core.system', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cpu.core.iowait', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/stat') as stat:
│ │ │ │ │ + for line in stat:
│ │ │ │ │ + if not line.startswith('cpu'):
│ │ │ │ │ + continue
│ │ │ │ │ + cpu, user, nice, system, _idle, iowait = line.split()[:6]
│ │ │ │ │ + core = cpu[3:] or None
│ │ │ │ │ + if core:
│ │ │ │ │ + prefix = 'cpu.core'
│ │ │ │ │ + samples[f'{prefix}.nice'][core] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.user'][core] = int(user) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.system'][core] = int(system) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.iowait'][core] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │ + else:
│ │ │ │ │ + prefix = 'cpu.basic'
│ │ │ │ │ + samples[f'{prefix}.nice'] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.user'] = int(user) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.system'] = int(system) * MS_PER_JIFFY
│ │ │ │ │ + samples[f'{prefix}.iowait'] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class MemorySampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('memory.free', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + SampleDescription('memory.used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + SampleDescription('memory.cached', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + SampleDescription('memory.swap-used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/meminfo') as meminfo:
│ │ │ │ │ + items = {k: int(v.strip(' kB\n')) for line in meminfo for k, v in [line.split(':', 1)]}
│ │ │ │ │ +
│ │ │ │ │ + samples['memory.free'] = 1024 * items['MemFree']
│ │ │ │ │ + samples['memory.used'] = 1024 * (items['MemTotal'] - items['MemAvailable'])
│ │ │ │ │ + samples['memory.cached'] = 1024 * (items['Buffers'] + items['Cached'])
│ │ │ │ │ + samples['memory.swap-used'] = 1024 * (items['SwapTotal'] - items['SwapFree'])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class CPUTemperatureSampler(Sampler):
│ │ │ │ │ + # Cache found sensors, as they can't be hotplugged.
│ │ │ │ │ + sensors: Optional[List[str]] = None
│ │ │ │ │ +
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('cpu.temperature', 'celsius', 'instant', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def detect_cpu_sensors(dir_fd: int) -> Iterable[str]:
│ │ │ │ │ + # Read the name file to decide what to do with this directory
│ │ │ │ │ + try:
│ │ │ │ │ + with Handle.open('name', os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ + name = os.read(fd, 1024).decode().strip()
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + if name == 'atk0110':
│ │ │ │ │ + # only sample 'CPU Temperature' in atk0110
│ │ │ │ │ + predicate = (lambda label: label == 'CPU Temperature')
│ │ │ │ │ + elif name == 'cpu_thermal':
│ │ │ │ │ + # labels are not used on ARM
│ │ │ │ │ + predicate = None
│ │ │ │ │ + elif name == 'coretemp':
│ │ │ │ │ + # accept all labels on Intel
│ │ │ │ │ + predicate = None
│ │ │ │ │ + elif name in ['k8temp', 'k10temp']:
│ │ │ │ │ + predicate = None
│ │ │ │ │ + else:
│ │ │ │ │ + # Not a CPU sensor
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + # Now scan the directory for inputs
│ │ │ │ │ + for input_filename in os.listdir(dir_fd):
│ │ │ │ │ + if not input_filename.endswith('_input'):
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + if predicate:
│ │ │ │ │ + # We need to check the label
│ │ │ │ │ + try:
│ │ │ │ │ + label_filename = input_filename.replace('_input', '_label')
│ │ │ │ │ + with Handle.open(label_filename, os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ + label = os.read(fd, 1024).decode().strip()
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + if not predicate(label):
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + yield input_filename
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def scan_sensors() -> Iterable[str]:
│ │ │ │ │ + try:
│ │ │ │ │ + top_fd = Handle.open(HWMON_PATH, os.O_RDONLY | os.O_DIRECTORY)
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + with top_fd:
│ │ │ │ │ + for hwmon_name in os.listdir(top_fd):
│ │ │ │ │ + with Handle.open(hwmon_name, os.O_RDONLY | os.O_DIRECTORY, dir_fd=top_fd) as subdir_fd:
│ │ │ │ │ + for sensor in CPUTemperatureSampler.detect_cpu_sensors(subdir_fd):
│ │ │ │ │ + yield f'{HWMON_PATH}/{hwmon_name}/{sensor}'
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + if self.sensors is None:
│ │ │ │ │ + self.sensors = list(CPUTemperatureSampler.scan_sensors())
│ │ │ │ │ +
│ │ │ │ │ + for sensor_path in self.sensors:
│ │ │ │ │ + with open(sensor_path) as sensor:
│ │ │ │ │ + temperature = int(sensor.read().strip())
│ │ │ │ │ + if temperature == 0:
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + samples['cpu.temperature'][sensor_path] = temperature / 1000
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class DiskSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('disk.all.read', 'bytes', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('disk.all.written', 'bytes', 'counter', instanced=False),
│ │ │ │ │ + SampleDescription('disk.dev.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('disk.dev.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/diskstats') as diskstats:
│ │ │ │ │ + all_read_bytes = 0
│ │ │ │ │ + all_written_bytes = 0
│ │ │ │ │ +
│ │ │ │ │ + for line in diskstats:
│ │ │ │ │ + # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ + fields = line.strip().split()
│ │ │ │ │ + dev_major = fields[0]
│ │ │ │ │ + dev_name = fields[2]
│ │ │ │ │ + num_sectors_read = fields[5]
│ │ │ │ │ + num_sectors_written = fields[9]
│ │ │ │ │ +
│ │ │ │ │ + # ignore mdraid
│ │ │ │ │ + if dev_major == '9':
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + # ignore device-mapper
│ │ │ │ │ + if dev_name.startswith('dm-'):
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + # Skip partitions
│ │ │ │ │ + if dev_name[:2] in ['sd', 'hd', 'vd'] and dev_name[-1].isdigit():
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + # Ignore nvme partitions
│ │ │ │ │ + if dev_name.startswith('nvme') and 'p' in dev_name:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + read_bytes = int(num_sectors_read) * 512
│ │ │ │ │ + written_bytes = int(num_sectors_written) * 512
│ │ │ │ │ +
│ │ │ │ │ + all_read_bytes += read_bytes
│ │ │ │ │ + all_written_bytes += written_bytes
│ │ │ │ │ +
│ │ │ │ │ + samples['disk.dev.read'][dev_name] = read_bytes
│ │ │ │ │ + samples['disk.dev.written'][dev_name] = written_bytes
│ │ │ │ │ +
│ │ │ │ │ + samples['disk.all.read'] = all_read_bytes
│ │ │ │ │ + samples['disk.all.written'] = all_written_bytes
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class CGroupSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('cgroup.memory.usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.memory.limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.memory.sw-usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.memory.sw-limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.cpu.usage', 'millisec', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('cgroup.cpu.shares', 'count', 'instant', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + cgroups_v2: Optional[bool] = None
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + if self.cgroups_v2 is None:
│ │ │ │ │ + self.cgroups_v2 = os.path.exists('/sys/fs/cgroup/cgroup.controllers')
│ │ │ │ │ +
│ │ │ │ │ + if self.cgroups_v2:
│ │ │ │ │ + cgroups_v2_path = '/sys/fs/cgroup/'
│ │ │ │ │ + for path, _, _, rootfd in os.fwalk(cgroups_v2_path):
│ │ │ │ │ + cgroup = path.replace(cgroups_v2_path, '')
│ │ │ │ │ +
│ │ │ │ │ + if not cgroup:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.current', 0)
│ │ │ │ │ + samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.max')
│ │ │ │ │ + samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.swap.current', 0)
│ │ │ │ │ + samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.swap.max')
│ │ │ │ │ + samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.weight')
│ │ │ │ │ + usage_usec = read_int_file(rootfd, 'cpu.stat', 0, key=b'usage_usec')
│ │ │ │ │ + if usage_usec:
│ │ │ │ │ + samples['cgroup.cpu.usage'][cgroup] = usage_usec / 1000
│ │ │ │ │ + else:
│ │ │ │ │ + memory_path = '/sys/fs/cgroup/memory/'
│ │ │ │ │ + for path, _, _, rootfd in os.fwalk(memory_path):
│ │ │ │ │ + cgroup = path.replace(memory_path, '')
│ │ │ │ │ +
│ │ │ │ │ + if not cgroup:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.usage_in_bytes', 0)
│ │ │ │ │ + samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.limit_in_bytes')
│ │ │ │ │ + samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.memsw.usage_in_bytes', 0)
│ │ │ │ │ + samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.memsw.limit_in_bytes')
│ │ │ │ │ +
│ │ │ │ │ + cpu_path = '/sys/fs/cgroup/cpu/'
│ │ │ │ │ + for path, _, _, rootfd in os.fwalk(cpu_path):
│ │ │ │ │ + cgroup = path.replace(cpu_path, '')
│ │ │ │ │ +
│ │ │ │ │ + if not cgroup:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.shares')
│ │ │ │ │ + usage_nsec = read_int_file(rootfd, 'cpuacct.usage')
│ │ │ │ │ + if usage_nsec:
│ │ │ │ │ + samples['cgroup.cpu.usage'][cgroup] = usage_nsec / 1000000
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class CGroupDiskIO(Sampler):
│ │ │ │ │ + IO_RE = re.compile(rb'\bread_bytes: (?P\d+).*\nwrite_bytes: (?P\d+)', flags=re.S)
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('disk.cgroup.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('disk.cgroup.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_cgroup_name(fd: int) -> str:
│ │ │ │ │ + with Handle.open('cgroup', os.O_RDONLY, dir_fd=fd) as cgroup_fd:
│ │ │ │ │ + cgroup_name = os.read(cgroup_fd, 2048).decode().strip()
│ │ │ │ │ +
│ │ │ │ │ + # Skip leading ::0/
│ │ │ │ │ + return cgroup_name[4:]
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_proc_io(fd: int) -> Tuple[int, int]:
│ │ │ │ │ + with Handle.open('io', os.O_RDONLY, dir_fd=fd) as io_fd:
│ │ │ │ │ + data = os.read(io_fd, 4096)
│ │ │ │ │ +
│ │ │ │ │ + match = re.search(CGroupDiskIO.IO_RE, data)
│ │ │ │ │ + if match:
│ │ │ │ │ + proc_read = int(match.group('read'))
│ │ │ │ │ + proc_write = int(match.group('write'))
│ │ │ │ │ +
│ │ │ │ │ + return proc_read, proc_write
│ │ │ │ │ +
│ │ │ │ │ + return 0, 0
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples):
│ │ │ │ │ + with Handle.open('/proc', os.O_RDONLY | os.O_DIRECTORY) as proc_fd:
│ │ │ │ │ + reads = samples['disk.cgroup.read']
│ │ │ │ │ + writes = samples['disk.cgroup.written']
│ │ │ │ │ +
│ │ │ │ │ + for path in os.listdir(proc_fd):
│ │ │ │ │ + # non-pid entries in proc are guaranteed to start with a character a-z
│ │ │ │ │ + if path[0] < '0' or path[0] > '9':
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + with Handle.open(path, os.O_PATH, dir_fd=proc_fd) as pid_fd:
│ │ │ │ │ + cgroup_name = self.get_cgroup_name(pid_fd)
│ │ │ │ │ + proc_read, proc_write = self.get_proc_io(pid_fd)
│ │ │ │ │ + except (FileNotFoundError, PermissionError, ProcessLookupError):
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + reads[cgroup_name] = reads.get(cgroup_name, 0) + proc_read
│ │ │ │ │ + writes[cgroup_name] = writes.get(cgroup_name, 0) + proc_write
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class NetworkSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('network.interface.tx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('network.interface.rx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open("/proc/net/dev") as network_samples:
│ │ │ │ │ + for line in network_samples:
│ │ │ │ │ + fields = line.split()
│ │ │ │ │ +
│ │ │ │ │ + # Skip header line
│ │ │ │ │ + if fields[0][-1] != ':':
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + iface = fields[0][:-1]
│ │ │ │ │ + samples['network.interface.rx'][iface] = int(fields[1])
│ │ │ │ │ + samples['network.interface.tx'][iface] = int(fields[9])
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class MountSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('mount.total', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + SampleDescription('mount.used', 'bytes', 'instant', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/mounts') as mounts:
│ │ │ │ │ + for line in mounts:
│ │ │ │ │ + # Only look at real devices
│ │ │ │ │ + if line[0] != '/':
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + path = line.split()[1]
│ │ │ │ │ + try:
│ │ │ │ │ + res = os.statvfs(path)
│ │ │ │ │ + except OSError:
│ │ │ │ │ + continue
│ │ │ │ │ + frsize = res.f_frsize
│ │ │ │ │ + total = frsize * res.f_blocks
│ │ │ │ │ + samples['mount.total'][path] = total
│ │ │ │ │ + samples['mount.used'][path] = total - frsize * res.f_bfree
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class BlockSampler(Sampler):
│ │ │ │ │ + descriptions = [
│ │ │ │ │ + SampleDescription('block.device.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + SampleDescription('block.device.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ + ]
│ │ │ │ │ +
│ │ │ │ │ + def sample(self, samples: Samples) -> None:
│ │ │ │ │ + with open('/proc/diskstats') as diskstats:
│ │ │ │ │ + for line in diskstats:
│ │ │ │ │ + # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ + [_, _, dev_name, _, _, sectors_read, _, _, _, sectors_written, *_] = line.strip().split()
│ │ │ │ │ +
│ │ │ │ │ + samples['block.device.read'][dev_name] = int(sectors_read) * 512
│ │ │ │ │ + samples['block.device.written'][dev_name] = int(sectors_written) * 512
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +SAMPLERS = [
│ │ │ │ │ + BlockSampler,
│ │ │ │ │ + CGroupSampler,
│ │ │ │ │ + CGroupDiskIO,
│ │ │ │ │ + CPUSampler,
│ │ │ │ │ + CPUTemperatureSampler,
│ │ │ │ │ + DiskSampler,
│ │ │ │ │ + MemorySampler,
│ │ │ │ │ + MountSampler,
│ │ │ │ │ + NetworkSampler,
│ │ │ │ │ +]
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/protocol.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (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
│ │ │ │ │ +import traceback
│ │ │ │ │ +import uuid
│ │ │ │ │ +
│ │ │ │ │ +from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_int, get_str, get_str_or_none, typechecked
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class CockpitProblem(Exception):
│ │ │ │ │ + """A type of exception that carries a problem code and a message.
│ │ │ │ │ +
│ │ │ │ │ + Depending on the scope, this is used to handle shutting down:
│ │ │ │ │ +
│ │ │ │ │ + - an individual channel (sends problem code in the close message)
│ │ │ │ │ + - peer connections (sends problem code in close message for each open channel)
│ │ │ │ │ + - the main stdio interaction with the bridge
│ │ │ │ │ +
│ │ │ │ │ + It is usually thrown in response to some violation of expected protocol
│ │ │ │ │ + when parsing messages, connecting to a peer, or opening a channel.
│ │ │ │ │ + """
│ │ │ │ │ + attrs: JsonObject
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, problem: str, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ + kwargs['problem'] = problem
│ │ │ │ │ + self.attrs = create_object(_msg, kwargs)
│ │ │ │ │ + super().__init__(get_str(self.attrs, 'message', problem))
│ │ │ │ │ +
│ │ │ │ │ + def get_attrs(self) -> JsonObject:
│ │ │ │ │ + if self.attrs['problem'] == 'internal-error' and self.__cause__ is not None:
│ │ │ │ │ + return dict(self.attrs, cause=traceback.format_exception(
│ │ │ │ │ + self.__cause__.__class__, self.__cause__, self.__cause__.__traceback__
│ │ │ │ │ + ))
│ │ │ │ │ + else:
│ │ │ │ │ + return self.attrs
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class CockpitProtocolError(CockpitProblem):
│ │ │ │ │ + def __init__(self, message: str, problem: str = 'protocol-error'):
│ │ │ │ │ + super().__init__(problem, message=message)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class CockpitProtocol(asyncio.Protocol):
│ │ │ │ │ + """A naive implementation of the Cockpit frame protocol
│ │ │ │ │ +
│ │ │ │ │ + We need to use this because Python's SelectorEventLoop doesn't supported
│ │ │ │ │ + buffered protocols.
│ │ │ │ │ + """
│ │ │ │ │ + transport: 'asyncio.Transport | None' = None
│ │ │ │ │ + buffer = b''
│ │ │ │ │ + _closed: bool = False
│ │ │ │ │ + _communication_done: 'asyncio.Future[None] | None' = None
│ │ │ │ │ +
│ │ │ │ │ + def do_ready(self) -> None:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + def do_closed(self, exc: 'Exception | None') -> None:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + def transport_control_received(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def frame_received(self, frame: bytes) -> None:
│ │ │ │ │ + header, _, data = frame.partition(b'\n')
│ │ │ │ │ +
│ │ │ │ │ + if header != b'':
│ │ │ │ │ + channel = header.decode('ascii')
│ │ │ │ │ + logger.debug('data received: %d bytes of data for channel %s', len(data), channel)
│ │ │ │ │ + self.channel_data_received(channel, data)
│ │ │ │ │ +
│ │ │ │ │ + else:
│ │ │ │ │ + self.control_received(data)
│ │ │ │ │ +
│ │ │ │ │ + def control_received(self, data: bytes) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + message = typechecked(json.loads(data), dict)
│ │ │ │ │ + command = get_str(message, 'command')
│ │ │ │ │ + channel = get_str(message, 'channel', None)
│ │ │ │ │ +
│ │ │ │ │ + if channel is not None:
│ │ │ │ │ + logger.debug('channel control received %s', message)
│ │ │ │ │ + self.channel_control_received(channel, command, message)
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug('transport control received %s', message)
│ │ │ │ │ + self.transport_control_received(command, message)
│ │ │ │ │ +
│ │ │ │ │ + except (json.JSONDecodeError, JsonError) as exc:
│ │ │ │ │ + raise CockpitProtocolError(f'control message: {exc!s}') from exc
│ │ │ │ │ +
│ │ │ │ │ + def consume_one_frame(self, data: bytes) -> int:
│ │ │ │ │ + """Consumes a single frame from view.
│ │ │ │ │ +
│ │ │ │ │ + Returns positive if a number of bytes were consumed, or negative if no
│ │ │ │ │ + work can be done because of a given number of bytes missing.
│ │ │ │ │ + """
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + newline = data.index(b'\n')
│ │ │ │ │ + except ValueError as exc:
│ │ │ │ │ + if len(data) < 10:
│ │ │ │ │ + # Let's try reading more
│ │ │ │ │ + return len(data) - 10
│ │ │ │ │ + raise CockpitProtocolError("size line is too long") from exc
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + length = int(data[:newline])
│ │ │ │ │ + except ValueError as exc:
│ │ │ │ │ + raise CockpitProtocolError("frame size is not an integer") from exc
│ │ │ │ │ +
│ │ │ │ │ + start = newline + 1
│ │ │ │ │ + end = start + length
│ │ │ │ │ +
│ │ │ │ │ + if end > len(data):
│ │ │ │ │ + # We need to read more
│ │ │ │ │ + return len(data) - end
│ │ │ │ │ +
│ │ │ │ │ + # We can consume a full frame
│ │ │ │ │ + self.frame_received(data[start:end])
│ │ │ │ │ + return end
│ │ │ │ │ +
│ │ │ │ │ + def connection_made(self, transport: asyncio.BaseTransport) -> None:
│ │ │ │ │ + logger.debug('connection_made(%s)', transport)
│ │ │ │ │ + assert isinstance(transport, asyncio.Transport)
│ │ │ │ │ + self.transport = transport
│ │ │ │ │ + self.do_ready()
│ │ │ │ │ +
│ │ │ │ │ + if self._closed:
│ │ │ │ │ + logger.debug(' but the protocol already was closed, so closing transport')
│ │ │ │ │ + transport.close()
│ │ │ │ │ +
│ │ │ │ │ + def connection_lost(self, exc: 'Exception | None') -> None:
│ │ │ │ │ + logger.debug('connection_lost')
│ │ │ │ │ + assert self.transport is not None
│ │ │ │ │ + self.transport = None
│ │ │ │ │ + self.close(exc)
│ │ │ │ │ +
│ │ │ │ │ + def close(self, exc: 'Exception | None' = None) -> None:
│ │ │ │ │ + if self._closed:
│ │ │ │ │ + return
│ │ │ │ │ + self._closed = True
│ │ │ │ │ +
│ │ │ │ │ + if self.transport:
│ │ │ │ │ + self.transport.close()
│ │ │ │ │ +
│ │ │ │ │ + self.do_closed(exc)
│ │ │ │ │ +
│ │ │ │ │ + def write_channel_data(self, channel: str, payload: bytes) -> None:
│ │ │ │ │ + """Send a given payload (bytes) on channel (string)"""
│ │ │ │ │ + # Channel is certainly ascii (as enforced by .encode() below)
│ │ │ │ │ + frame_length = len(channel + '\n') + len(payload)
│ │ │ │ │ + header = f'{frame_length}\n{channel}\n'.encode('ascii')
│ │ │ │ │ + if self.transport is not None:
│ │ │ │ │ + logger.debug('writing to transport %s', self.transport)
│ │ │ │ │ + self.transport.write(header + payload)
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug('cannot write to closed transport')
│ │ │ │ │ +
│ │ │ │ │ + def write_control(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ + """Write a control message. See jsonutil.create_object() for details."""
│ │ │ │ │ + logger.debug('sending control message %r %r', _msg, kwargs)
│ │ │ │ │ + pretty = json.dumps(create_object(_msg, kwargs), indent=2) + '\n'
│ │ │ │ │ + self.write_channel_data('', pretty.encode())
│ │ │ │ │ +
│ │ │ │ │ + def data_received(self, data: bytes) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + self.buffer += data
│ │ │ │ │ + while self.buffer:
│ │ │ │ │ + result = self.consume_one_frame(self.buffer)
│ │ │ │ │ + if result <= 0:
│ │ │ │ │ + return
│ │ │ │ │ + self.buffer = self.buffer[result:]
│ │ │ │ │ + except CockpitProtocolError as exc:
│ │ │ │ │ + self.close(exc)
│ │ │ │ │ +
│ │ │ │ │ + def eof_received(self) -> bool:
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# Helpful functionality for "server"-side protocol implementations
│ │ │ │ │ +class CockpitProtocolServer(CockpitProtocol):
│ │ │ │ │ + init_host: 'str | None' = None
│ │ │ │ │ + authorizations: 'dict[str, asyncio.Future[str]] | None' = None
│ │ │ │ │ +
│ │ │ │ │ + def do_send_init(self) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def do_init(self, message: JsonObject) -> None:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │ +
│ │ │ │ │ + def transport_control_received(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ + if command == 'init':
│ │ │ │ │ + if get_int(message, 'version') != 1:
│ │ │ │ │ + raise CockpitProtocolError('incorrect version number')
│ │ │ │ │ + self.init_host = get_str(message, 'host')
│ │ │ │ │ + self.do_init(message)
│ │ │ │ │ + elif command == 'kill':
│ │ │ │ │ + self.do_kill(get_str_or_none(message, 'host', None), get_str_or_none(message, 'group', None), message)
│ │ │ │ │ + elif command == 'authorize':
│ │ │ │ │ + self.do_authorize(message)
│ │ │ │ │ + else:
│ │ │ │ │ + raise CockpitProtocolError(f'unexpected control message {command} received')
│ │ │ │ │ +
│ │ │ │ │ + def do_ready(self) -> None:
│ │ │ │ │ + self.do_send_init()
│ │ │ │ │ +
│ │ │ │ │ + # authorize request/response API
│ │ │ │ │ + async def request_authorization(
│ │ │ │ │ + self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue
│ │ │ │ │ + ) -> str:
│ │ │ │ │ + if self.authorizations is None:
│ │ │ │ │ + self.authorizations = {}
│ │ │ │ │ + cookie = str(uuid.uuid4())
│ │ │ │ │ + future = asyncio.get_running_loop().create_future()
│ │ │ │ │ + try:
│ │ │ │ │ + self.authorizations[cookie] = future
│ │ │ │ │ + self.write_control(None, command='authorize', challenge=challenge, cookie=cookie, **kwargs)
│ │ │ │ │ + return await asyncio.wait_for(future, timeout)
│ │ │ │ │ + finally:
│ │ │ │ │ + self.authorizations.pop(cookie)
│ │ │ │ │ +
│ │ │ │ │ + def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ + cookie = get_str(message, 'cookie')
│ │ │ │ │ + response = get_str(message, 'response')
│ │ │ │ │ +
│ │ │ │ │ + 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/bridge.py': r'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (at your option) any later version.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is distributed in the hope that it will be useful,
│ │ │ │ │ +# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ +# GNU General Public License for more details.
│ │ │ │ │ +#
│ │ │ │ │ +# You should have received a copy of the GNU General Public License
│ │ │ │ │ +# along with this program. If not, see .
│ │ │ │ │ +
│ │ │ │ │ +import argparse
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import json
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import pwd
│ │ │ │ │ +import shlex
│ │ │ │ │ +import socket
│ │ │ │ │ +import stat
│ │ │ │ │ +import subprocess
│ │ │ │ │ +from typing import Iterable, List, Optional, Sequence, Tuple, Type
│ │ │ │ │ +
│ │ │ │ │ +from cockpit._vendor.ferny import interaction_client
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import bus, run_async
│ │ │ │ │ +
│ │ │ │ │ +from . import polyfills
│ │ │ │ │ +from ._version import __version__
│ │ │ │ │ +from .channel import ChannelRoutingRule
│ │ │ │ │ +from .channels import CHANNEL_TYPES
│ │ │ │ │ +from .config import Config, Environment
│ │ │ │ │ +from .internal_endpoints import EXPORTS
│ │ │ │ │ +from .jsonutil import JsonError, JsonObject, get_dict
│ │ │ │ │ +from .packages import BridgeConfig, Packages, PackagesListener
│ │ │ │ │ +from .peer import PeersRoutingRule
│ │ │ │ │ +from .remote import HostRoutingRule
│ │ │ │ │ +from .router import Router
│ │ │ │ │ +from .superuser import SuperuserRoutingRule
│ │ │ │ │ +from .transports import StdioTransport
│ │ │ │ │ +
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class InternalBus:
│ │ │ │ │ + exportees: List[bus.Slot]
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]):
│ │ │ │ │ + client_socket, server_socket = socket.socketpair()
│ │ │ │ │ + self.client = bus.Bus.new(fd=client_socket.detach())
│ │ │ │ │ + self.server = bus.Bus.new(fd=server_socket.detach(), server=True)
│ │ │ │ │ + self.exportees = [self.server.add_object(path, cls()) for path, cls in exports]
│ │ │ │ │ +
│ │ │ │ │ + def export(self, path: str, obj: bus.BaseObject) -> None:
│ │ │ │ │ + self.exportees.append(self.server.add_object(path, obj))
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Bridge(Router, PackagesListener):
│ │ │ │ │ + internal_bus: InternalBus
│ │ │ │ │ + packages: Optional[Packages]
│ │ │ │ │ + bridge_configs: Sequence[BridgeConfig]
│ │ │ │ │ + args: argparse.Namespace
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, args: argparse.Namespace):
│ │ │ │ │ + self.internal_bus = InternalBus(EXPORTS)
│ │ │ │ │ + self.bridge_configs = []
│ │ │ │ │ + self.args = args
│ │ │ │ │ +
│ │ │ │ │ + self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged)
│ │ │ │ │ + self.internal_bus.export('/superuser', self.superuser_rule)
│ │ │ │ │ +
│ │ │ │ │ + self.internal_bus.export('/config', Config())
│ │ │ │ │ + self.internal_bus.export('/environment', Environment())
│ │ │ │ │ +
│ │ │ │ │ + self.peers_rule = PeersRoutingRule(self)
│ │ │ │ │ +
│ │ │ │ │ + if args.beipack:
│ │ │ │ │ + # Some special stuff for beipack
│ │ │ │ │ + self.superuser_rule.set_configs((
│ │ │ │ │ + BridgeConfig({
│ │ │ │ │ + "privileged": True,
│ │ │ │ │ + "spawn": ["sudo", "-k", "-A", "python3", "-ic", "# cockpit-bridge", "--privileged"],
│ │ │ │ │ + "environ": ["SUDO_ASKPASS=ferny-askpass"],
│ │ │ │ │ + }),
│ │ │ │ │ + ))
│ │ │ │ │ + self.packages = None
│ │ │ │ │ + elif args.privileged:
│ │ │ │ │ + self.packages = None
│ │ │ │ │ + else:
│ │ │ │ │ + self.packages = Packages(self)
│ │ │ │ │ + self.internal_bus.export('/packages', self.packages)
│ │ │ │ │ + self.packages_loaded()
│ │ │ │ │ +
│ │ │ │ │ + super().__init__([
│ │ │ │ │ + HostRoutingRule(self),
│ │ │ │ │ + self.superuser_rule,
│ │ │ │ │ + ChannelRoutingRule(self, CHANNEL_TYPES),
│ │ │ │ │ + self.peers_rule,
│ │ │ │ │ + ])
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_os_release():
│ │ │ │ │ + try:
│ │ │ │ │ + file = open('/etc/os-release', encoding='utf-8')
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + try:
│ │ │ │ │ + file = open('/usr/lib/os-release', encoding='utf-8')
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + logger.warning("Neither /etc/os-release nor /usr/lib/os-release exists")
│ │ │ │ │ + return {}
│ │ │ │ │ +
│ │ │ │ │ + os_release = {}
│ │ │ │ │ + for line in file.readlines():
│ │ │ │ │ + line = line.strip()
│ │ │ │ │ + if not line or line.startswith('#'):
│ │ │ │ │ + continue
│ │ │ │ │ + try:
│ │ │ │ │ + k, v = line.split('=')
│ │ │ │ │ + (v_parsed, ) = shlex.split(v) # expect exactly one token
│ │ │ │ │ + except ValueError:
│ │ │ │ │ + logger.warning('Ignoring invalid line in os-release: %r', line)
│ │ │ │ │ + continue
│ │ │ │ │ + os_release[k] = v_parsed
│ │ │ │ │ + return os_release
│ │ │ │ │ +
│ │ │ │ │ + def do_init(self, message: JsonObject) -> None:
│ │ │ │ │ + # we're only interested in the case where this is a dict, but
│ │ │ │ │ + # 'superuser' may well be `False` and that's not an error
│ │ │ │ │ + with contextlib.suppress(JsonError):
│ │ │ │ │ + superuser = get_dict(message, 'superuser')
│ │ │ │ │ + self.superuser_rule.init(superuser)
│ │ │ │ │ +
│ │ │ │ │ + def do_send_init(self) -> None:
│ │ │ │ │ + init_args = {
│ │ │ │ │ + 'capabilities': {'explicit-superuser': True},
│ │ │ │ │ + 'command': 'init',
│ │ │ │ │ + 'os-release': self.get_os_release(),
│ │ │ │ │ + 'version': 1,
│ │ │ │ │ + }
│ │ │ │ │ +
│ │ │ │ │ + if self.packages is not None:
│ │ │ │ │ + init_args['packages'] = dict.fromkeys(self.packages.packages)
│ │ │ │ │ +
│ │ │ │ │ + self.write_control(init_args)
│ │ │ │ │ +
│ │ │ │ │ + # PackagesListener interface
│ │ │ │ │ + def packages_loaded(self) -> None:
│ │ │ │ │ + assert self.packages
│ │ │ │ │ + bridge_configs = self.packages.get_bridge_configs()
│ │ │ │ │ + if self.bridge_configs != bridge_configs:
│ │ │ │ │ + self.superuser_rule.set_configs(bridge_configs)
│ │ │ │ │ + self.peers_rule.set_configs(bridge_configs)
│ │ │ │ │ + self.bridge_configs = bridge_configs
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +async def run(args) -> None:
│ │ │ │ │ + logger.debug("Hi. How are you today?")
│ │ │ │ │ +
│ │ │ │ │ + # Unit tests require this
│ │ │ │ │ + me = pwd.getpwuid(os.getuid())
│ │ │ │ │ + os.environ['HOME'] = me.pw_dir
│ │ │ │ │ + os.environ['SHELL'] = me.pw_shell
│ │ │ │ │ + os.environ['USER'] = me.pw_name
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Starting the router.')
│ │ │ │ │ + router = Bridge(args)
│ │ │ │ │ + StdioTransport(asyncio.get_running_loop(), router)
│ │ │ │ │ +
│ │ │ │ │ + logger.debug('Startup done. Looping until connection closes.')
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + await router.communicate()
│ │ │ │ │ + except (BrokenPipeError, ConnectionResetError):
│ │ │ │ │ + # not unexpected if the peer doesn't hang up cleanly
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def try_to_receive_stderr():
│ │ │ │ │ + try:
│ │ │ │ │ + ours, theirs = socket.socketpair()
│ │ │ │ │ + with ours:
│ │ │ │ │ + with theirs:
│ │ │ │ │ + interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()])
│ │ │ │ │ + _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1)
│ │ │ │ │ + except OSError:
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + stderr_fd, = fds
│ │ │ │ │ + # We're about to abruptly drop our end of the stderr socketpair that we
│ │ │ │ │ + # share with the ferny agent. ferny would normally treat that as an
│ │ │ │ │ + # unexpected error. Instruct it to do a clean exit, instead.
│ │ │ │ │ + interaction_client.command(2, 'ferny.end')
│ │ │ │ │ + os.dup2(stderr_fd, 2)
│ │ │ │ │ + finally:
│ │ │ │ │ + for fd in fds:
│ │ │ │ │ + os.close(fd)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def setup_journald() -> bool:
│ │ │ │ │ + # If stderr is a socket, prefer systemd-journal logging. This covers the
│ │ │ │ │ + # case we're already connected to the journal but also the case where we're
│ │ │ │ │ + # talking to the ferny agent, while leaving logging to file or terminal
│ │ │ │ │ + # unaffected.
│ │ │ │ │ + if not stat.S_ISSOCK(os.fstat(2).st_mode):
│ │ │ │ │ + # not a socket? Don't redirect.
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + try:
│ │ │ │ │ + import systemd.journal # type: ignore[import]
│ │ │ │ │ + except ImportError:
│ │ │ │ │ + # No python3-systemd? Don't redirect.
│ │ │ │ │ + return False
│ │ │ │ │ +
│ │ │ │ │ + logging.root.addHandler(systemd.journal.JournalHandler())
│ │ │ │ │ + return True
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def setup_logging(*, debug: bool) -> None:
│ │ │ │ │ + """Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set"""
│ │ │ │ │ +
│ │ │ │ │ + modules = os.getenv('COCKPIT_DEBUG', '')
│ │ │ │ │ +
│ │ │ │ │ + # Either setup logging via journal or via formatted messages to stderr
│ │ │ │ │ + if not setup_journald():
│ │ │ │ │ + logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s')
│ │ │ │ │ +
│ │ │ │ │ + if debug or modules == 'all':
│ │ │ │ │ + logging.getLogger().setLevel(level=logging.DEBUG)
│ │ │ │ │ + elif modules:
│ │ │ │ │ + for module in modules.split(','):
│ │ │ │ │ + module = module.strip()
│ │ │ │ │ + if not module:
│ │ │ │ │ + continue
│ │ │ │ │ +
│ │ │ │ │ + logging.getLogger(module).setLevel(logging.DEBUG)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def start_ssh_agent() -> None:
│ │ │ │ │ + # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust,
│ │ │ │ │ + # but it gets cleared on setgid ssh-agent, which some distros still do
│ │ │ │ │ + try:
│ │ │ │ │ + proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'],
│ │ │ │ │ + stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
│ │ │ │ │ + assert proc.stdout is not None
│ │ │ │ │ +
│ │ │ │ │ + # Wait for the agent to write at least one line and look for the
│ │ │ │ │ + # listener socket. If we fail to find it, kill the agent — something
│ │ │ │ │ + # went wrong.
│ │ │ │ │ + for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True):
│ │ │ │ │ + if token.startswith('SSH_AUTH_SOCK='):
│ │ │ │ │ + os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1)
│ │ │ │ │ + break
│ │ │ │ │ + else:
│ │ │ │ │ + proc.terminate()
│ │ │ │ │ + proc.wait()
│ │ │ │ │ +
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + logger.debug("Couldn't start ssh-agent (FileNotFoundError)")
│ │ │ │ │ +
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + logger.warning("Could not start ssh-agent: %s", exc)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def main(*, beipack: bool = False) -> None:
│ │ │ │ │ + polyfills.install()
│ │ │ │ │ +
│ │ │ │ │ + parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')
│ │ │ │ │ + parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge')
│ │ │ │ │ + parser.add_argument('--packages', action='store_true', help='Show Cockpit package information')
│ │ │ │ │ + parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information')
│ │ │ │ │ + parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)')
│ │ │ │ │ + parser.add_argument('--version', action='store_true', help='Show Cockpit version information')
│ │ │ │ │ + args = parser.parse_args()
│ │ │ │ │ +
│ │ │ │ │ + # This is determined by who calls us
│ │ │ │ │ + args.beipack = beipack
│ │ │ │ │ +
│ │ │ │ │ + # If we were run with --privileged then our stderr is currently being
│ │ │ │ │ + # consumed by the main bridge looking for startup-related error messages.
│ │ │ │ │ + # Let's switch back to the original stderr stream, which has a side-effect
│ │ │ │ │ + # of indicating that our startup is more or less complete. Any errors
│ │ │ │ │ + # after this point will land in the journal.
│ │ │ │ │ + if args.privileged:
│ │ │ │ │ + try_to_receive_stderr()
│ │ │ │ │ +
│ │ │ │ │ + setup_logging(debug=args.debug)
│ │ │ │ │ +
│ │ │ │ │ + # Special modes
│ │ │ │ │ + if args.packages:
│ │ │ │ │ + Packages().show()
│ │ │ │ │ + return
│ │ │ │ │ + elif args.version:
│ │ │ │ │ + print(f'Version: {__version__}\nProtocol: 1')
│ │ │ │ │ + return
│ │ │ │ │ + elif args.bridges:
│ │ │ │ │ + print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2))
│ │ │ │ │ + return
│ │ │ │ │ +
│ │ │ │ │ + # The privileged bridge doesn't need ssh-agent, but the main one does
│ │ │ │ │ + if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged:
│ │ │ │ │ + start_ssh_agent()
│ │ │ │ │ +
│ │ │ │ │ + # asyncio.run() shim for Python 3.6 support
│ │ │ │ │ + run_async(run(args), debug=args.debug)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ + 'cockpit/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 = dict.fromkeys(exc.methods, "not-provided")
│ │ │ │ │ + if 'password' in results and self.password is not None:
│ │ │ │ │ + if responder.password_attempts == 0:
│ │ │ │ │ + results['password'] = 'not-tried'
│ │ │ │ │ + else:
│ │ │ │ │ + results['password'] = 'denied'
│ │ │ │ │ +
│ │ │ │ │ + raise PeerError('authentication-failed',
│ │ │ │ │ + error=responder.error_message or 'authentication-failed',
│ │ │ │ │ + auth_method_results=results) from exc
│ │ │ │ │ +
│ │ │ │ │ + except ferny.SshError as exc:
│ │ │ │ │ + logger.debug('unknown failure connecting to host %s: %s', host, exc)
│ │ │ │ │ + raise PeerError('internal-error', message=str(exc)) from exc
│ │ │ │ │ +
│ │ │ │ │ + args = self.session.wrap_subprocess_args(['cockpit-bridge'])
│ │ │ │ │ + await self.spawn(args, [])
│ │ │ │ │ +
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ + if host == self.host:
│ │ │ │ │ + self.close()
│ │ │ │ │ + elif host is None:
│ │ │ │ │ + super().do_kill(host, group, message)
│ │ │ │ │ +
│ │ │ │ │ + def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ + if get_str(message, 'challenge').startswith('plain1:'):
│ │ │ │ │ + cookie = get_str(message, 'cookie')
│ │ │ │ │ + self.write_control(command='authorize', cookie=cookie, response=self.password or '')
│ │ │ │ │ + self.password = None # once is enough...
│ │ │ │ │ +
│ │ │ │ │ + def do_superuser_init_done(self) -> None:
│ │ │ │ │ + self.password = None
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None:
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │ + self.host = host
│ │ │ │ │ + self.user = user
│ │ │ │ │ + self.password = get_str(options, 'password', None)
│ │ │ │ │ + self.private = private
│ │ │ │ │ +
│ │ │ │ │ + self.session = ferny.Session()
│ │ │ │ │ +
│ │ │ │ │ + superuser: JsonValue
│ │ │ │ │ + init_superuser = get_str_or_none(options, 'init-superuser', None)
│ │ │ │ │ + if init_superuser in (None, 'none'):
│ │ │ │ │ + superuser = False
│ │ │ │ │ + else:
│ │ │ │ │ + superuser = {'id': init_superuser}
│ │ │ │ │ +
│ │ │ │ │ + self.start_in_background(init_host=host, superuser=superuser)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class HostRoutingRule(RoutingRule):
│ │ │ │ │ + remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer]
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, router):
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │ + self.remotes = {}
│ │ │ │ │ +
│ │ │ │ │ + def apply_rule(self, options: JsonObject) -> Optional[Peer]:
│ │ │ │ │ + assert self.router is not None
│ │ │ │ │ + assert self.router.init_host is not None
│ │ │ │ │ +
│ │ │ │ │ + host = get_str(options, 'host', self.router.init_host)
│ │ │ │ │ + if host == self.router.init_host:
│ │ │ │ │ + return None
│ │ │ │ │ +
│ │ │ │ │ + user = get_str(options, 'user', None)
│ │ │ │ │ + # HACK: the front-end relies on this for tracking connections without an explicit user name;
│ │ │ │ │ + # the user will then be determined by SSH (`User` in the config or the current user)
│ │ │ │ │ + # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c
│ │ │ │ │ + if user == getpass.getuser():
│ │ │ │ │ + user = None
│ │ │ │ │ + if not user:
│ │ │ │ │ + user_from_host, _, _ = host.rpartition('@')
│ │ │ │ │ + user = user_from_host or None # avoid ''
│ │ │ │ │ +
│ │ │ │ │ + if get_str(options, 'session', None) == 'private':
│ │ │ │ │ + nonce = get_str(options, 'channel')
│ │ │ │ │ + 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/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/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
│ │ │ │ │ @@ -617,17 +2938,17 @@
│ │ │ │ │ self._loop.remove_reader(self._fd)
│ │ │ │ │ os.close(self._fd)
│ │ │ │ │ self._fd = -1
│ │ │ │ │
│ │ │ │ │ def __del__(self) -> None:
│ │ │ │ │ self.close()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/polkit.py': r'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/internal_endpoints.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,
│ │ │ │ │ @@ -635,168 +2956,163 @@
│ │ │ │ │ # 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 locale
│ │ │ │ │ +import glob
│ │ │ │ │ +import grp
│ │ │ │ │ +import json
│ │ │ │ │ import logging
│ │ │ │ │ import os
│ │ │ │ │ import pwd
│ │ │ │ │ -from typing import Dict, List, Sequence, Tuple
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor.ferny import AskpassHandler
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Variant, bus
│ │ │ │ │ +from pathlib import Path
│ │ │ │ │ +from typing import Dict, Optional
│ │ │ │ │
│ │ │ │ │ -# that path is valid on at least Debian, Fedora/RHEL, and Arch
│ │ │ │ │ -HELPER_PATH = '/usr/lib/polkit-1/polkit-agent-helper-1'
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch
│ │ │ │ │
│ │ │ │ │ -AGENT_DBUS_PATH = '/PolkitAgent'
│ │ │ │ │ +from . import config
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ -Identity = Tuple[str, Dict[str, Variant]]
│ │ │ │ │
│ │ │ │ │ +class cockpit_LoginMessages(bus.Object):
│ │ │ │ │ + messages: Optional[str] = None
│ │ │ │ │
│ │ │ │ │ -# https://www.freedesktop.org/software/polkit/docs/latest/eggdbus-interface-org.freedesktop.PolicyKit1.AuthenticationAgent.html
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ -# Note that we don't implement the CancelAuthentication() API. pkexec gets called in a way that has no opportunity to
│ │ │ │ │ -# cancel an ongoing authentication from the pkexec side. On the UI side cancellation is implemented via the standard
│ │ │ │ │ -# asyncio process mechanism. If we ever need CancelAuthentication(), we could keep a cookie → get_current_task()
│ │ │ │ │ -# mapping, but that method is not available for Python 3.6 yet.
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ -class org_freedesktop_PolicyKit1_AuthenticationAgent(bus.Object):
│ │ │ │ │ - def __init__(self, responder: AskpassHandler):
│ │ │ │ │ - super().__init__()
│ │ │ │ │ - self.responder = responder
│ │ │ │ │ + @bus.Interface.Method(out_types=['s'])
│ │ │ │ │ + def get(self):
│ │ │ │ │ + return self.messages or '{}'
│ │ │ │ │
│ │ │ │ │ - # confusingly named: this actually does the whole authentication dialog, see docs
│ │ │ │ │ - @bus.Interface.Method('', ['s', 's', 's', 'a{ss}', 's', 'a(sa{sv})'])
│ │ │ │ │ - async def begin_authentication(self, action_id: str, message: str, icon_name: str,
│ │ │ │ │ - details: Dict[str, str], cookie: str, identities: Sequence[Identity]) -> None:
│ │ │ │ │ - logger.debug('BeginAuthentication: action %s, message "%s", icon %s, details %s, cookie %s, identities %r',
│ │ │ │ │ - action_id, message, icon_name, details, cookie, identities)
│ │ │ │ │ - # only support authentication as ourselves, as we don't yet have the
│ │ │ │ │ - # protocol plumbing and UI to select an admin user
│ │ │ │ │ - my_uid = os.geteuid()
│ │ │ │ │ - for (auth_type, subject) in identities:
│ │ │ │ │ - if auth_type == 'unix-user' and 'uid' in subject and subject['uid'].value == my_uid:
│ │ │ │ │ - logger.debug('Authentication subject %s matches our uid %d', subject, my_uid)
│ │ │ │ │ - break
│ │ │ │ │ - else:
│ │ │ │ │ - logger.warning('Not supporting authentication as any of %s', identities)
│ │ │ │ │ - return
│ │ │ │ │ + @bus.Interface.Method(out_types=[])
│ │ │ │ │ + def dismiss(self):
│ │ │ │ │ + self.messages = None
│ │ │ │ │
│ │ │ │ │ - user_name = pwd.getpwuid(my_uid).pw_name
│ │ │ │ │ - process = await asyncio.create_subprocess_exec(HELPER_PATH, user_name, cookie,
│ │ │ │ │ - stdin=asyncio.subprocess.PIPE,
│ │ │ │ │ - stdout=asyncio.subprocess.PIPE)
│ │ │ │ │ +
│ │ │ │ │ +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:
│ │ │ │ │ - await self._communicate(process)
│ │ │ │ │ - except asyncio.CancelledError:
│ │ │ │ │ - logger.debug('Cancelled authentication')
│ │ │ │ │ - process.terminate()
│ │ │ │ │ - finally:
│ │ │ │ │ - res = await process.wait()
│ │ │ │ │ - logger.debug('helper exited with code %i', res)
│ │ │ │ │ + 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 = {}
│ │ │ │ │
│ │ │ │ │ - async def _communicate(self, process: asyncio.subprocess.Process) -> None:
│ │ │ │ │ - assert process.stdin
│ │ │ │ │ - assert process.stdout
│ │ │ │ │ + contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()})
│ │ │ │ │
│ │ │ │ │ - messages: List[str] = []
│ │ │ │ │ + self.path.mkdir(parents=True, exist_ok=True)
│ │ │ │ │ + with open(self.path.joinpath(filename), 'w') as fp:
│ │ │ │ │ + json.dump(contents, fp, indent=2)
│ │ │ │ │
│ │ │ │ │ - async for line in process.stdout:
│ │ │ │ │ - logger.debug('Read line from helper: %s', line)
│ │ │ │ │ - command, _, value = line.strip().decode().partition(' ')
│ │ │ │ │ + def notify(self):
│ │ │ │ │ + def _notify_now():
│ │ │ │ │ + self.properties_changed('cockpit.Machines', {}, ['Machines'])
│ │ │ │ │ + self.pending_notify = None
│ │ │ │ │
│ │ │ │ │ - # usually: PAM_PROMPT_ECHO_OFF Password: \n
│ │ │ │ │ - if command.startswith('PAM_PROMPT'):
│ │ │ │ │ - # Don't pass this to the UI if it's "Password" (the usual case),
│ │ │ │ │ - # so that superuser.py uses the translated default
│ │ │ │ │ - if value.startswith('Password'):
│ │ │ │ │ - value = ''
│ │ │ │ │ + # 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)
│ │ │ │ │
│ │ │ │ │ - # flush out accumulated info/error messages
│ │ │ │ │ - passwd = await self.responder.do_askpass('\n'.join(messages), value, '')
│ │ │ │ │ - messages.clear()
│ │ │ │ │ - if passwd is None:
│ │ │ │ │ - logger.debug('got PAM_PROMPT %s, but do_askpass returned None', value)
│ │ │ │ │ - raise asyncio.CancelledError('no password given')
│ │ │ │ │ - logger.debug('got PAM_PROMPT %s, do_askpass returned a password', value)
│ │ │ │ │ - process.stdin.write(passwd.encode())
│ │ │ │ │ - process.stdin.write(b'\n')
│ │ │ │ │ - del passwd # don't keep this around longer than necessary
│ │ │ │ │ - await process.stdin.drain()
│ │ │ │ │ - logger.debug('got PAM_PROMPT, wrote password to helper')
│ │ │ │ │ - elif command in ('PAM_TEXT_INFO', 'PAM_ERROR'):
│ │ │ │ │ - messages.append(value)
│ │ │ │ │ - elif command == 'SUCCESS':
│ │ │ │ │ - logger.debug('Authentication succeeded')
│ │ │ │ │ - break
│ │ │ │ │ - elif command == 'FAILURE':
│ │ │ │ │ - logger.warning('Authentication failed')
│ │ │ │ │ - break
│ │ │ │ │ - else:
│ │ │ │ │ - logger.warning('Unknown line from helper, aborting: %s', line)
│ │ │ │ │ - process.terminate()
│ │ │ │ │ - break
│ │ │ │ │ + # inotify events
│ │ │ │ │ + def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None:
│ │ │ │ │ + self.notify()
│ │ │ │ │
│ │ │ │ │ + def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None:
│ │ │ │ │ + self.notify()
│ │ │ │ │
│ │ │ │ │ -class PolkitAgent:
│ │ │ │ │ - """Register polkit agent when required
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + self.path = config.lookup_config('machines.d')
│ │ │ │ │
│ │ │ │ │ - Use this as a context manager to ensure that the agent gets unregistered again.
│ │ │ │ │ - """
│ │ │ │ │ - def __init__(self, responder: AskpassHandler):
│ │ │ │ │ - self.responder = responder
│ │ │ │ │ - self.agent_slot = None
│ │ │ │ │ + # ignore the first callback
│ │ │ │ │ + self.pending_notify = ...
│ │ │ │ │ + self.watch = pathwatch.PathWatch(str(self.path), self)
│ │ │ │ │ + self.pending_notify = None
│ │ │ │ │
│ │ │ │ │ - async def __aenter__(self):
│ │ │ │ │ - try:
│ │ │ │ │ - self.system_bus = bus.Bus.default_system()
│ │ │ │ │ - except OSError as e:
│ │ │ │ │ - logger.warning('cannot connect to system bus, not registering polkit agent: %s', e)
│ │ │ │ │ - return self
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - # may refine that with a D-Bus call to logind
│ │ │ │ │ - self.subject = ('unix-session', {'session-id': Variant(os.environ['XDG_SESSION_ID'], 's')})
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - logger.debug('XDG_SESSION_ID not set, not registering polkit agent')
│ │ │ │ │ - return self
│ │ │ │ │ +class cockpit_User(bus.Object):
│ │ │ │ │ + name = bus.Interface.Property('s', value='')
│ │ │ │ │ + full = bus.Interface.Property('s', value='')
│ │ │ │ │ + id = bus.Interface.Property('i', value=0)
│ │ │ │ │ + gid = bus.Interface.Property('i', value=0)
│ │ │ │ │ + home = bus.Interface.Property('s', value='')
│ │ │ │ │ + shell = bus.Interface.Property('s', value='')
│ │ │ │ │ + groups = bus.Interface.Property('as', value=[])
│ │ │ │ │
│ │ │ │ │ - agent_object = org_freedesktop_PolicyKit1_AuthenticationAgent(self.responder)
│ │ │ │ │ - self.agent_slot = self.system_bus.add_object(AGENT_DBUS_PATH, agent_object)
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + user = pwd.getpwuid(os.getuid())
│ │ │ │ │ + self.name = user.pw_name
│ │ │ │ │ + self.full = user.pw_gecos
│ │ │ │ │ + self.id = user.pw_uid
│ │ │ │ │ + self.gid = user.pw_gid
│ │ │ │ │ + self.home = user.pw_dir
│ │ │ │ │ + self.shell = user.pw_shell
│ │ │ │ │
│ │ │ │ │ - # register agent
│ │ │ │ │ - locale_name = locale.setlocale(locale.LC_MESSAGES, None)
│ │ │ │ │ - await self.system_bus.call_method_async(
│ │ │ │ │ - 'org.freedesktop.PolicyKit1',
│ │ │ │ │ - '/org/freedesktop/PolicyKit1/Authority',
│ │ │ │ │ - 'org.freedesktop.PolicyKit1.Authority',
│ │ │ │ │ - 'RegisterAuthenticationAgent',
│ │ │ │ │ - '(sa{sv})ss',
│ │ │ │ │ - self.subject, locale_name, AGENT_DBUS_PATH)
│ │ │ │ │ - logger.debug('Registered agent for %r and locale %s', self.subject, locale_name)
│ │ │ │ │ - return self
│ │ │ │ │ + # We want the primary group first in the list, without duplicates.
│ │ │ │ │ + # This is a bit awkward because `set()` is unordered...
│ │ │ │ │ + groups = [grp.getgrgid(user.pw_gid).gr_name]
│ │ │ │ │ + for gr in grp.getgrall():
│ │ │ │ │ + if user.pw_name in gr.gr_mem and gr.gr_name not in groups:
│ │ │ │ │ + groups.append(gr.gr_name)
│ │ │ │ │ + self.groups = groups
│ │ │ │ │
│ │ │ │ │ - async def __aexit__(self, _exc_type, _exc_value, _traceback):
│ │ │ │ │ - if self.agent_slot:
│ │ │ │ │ - await self.system_bus.call_method_async(
│ │ │ │ │ - 'org.freedesktop.PolicyKit1',
│ │ │ │ │ - '/org/freedesktop/PolicyKit1/Authority',
│ │ │ │ │ - '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'),
│ │ │ │ │ +
│ │ │ │ │ +EXPORTS = [
│ │ │ │ │ + ('/LoginMessages', cockpit_LoginMessages),
│ │ │ │ │ + ('/machines', cockpit_Machines),
│ │ │ │ │ + ('/user', cockpit_User),
│ │ │ │ │ +]
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/beipack.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -866,16 +3182,14 @@
│ │ │ │ │ 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/_version.py': br'''__version__ = '319'
│ │ │ │ │ -''',
│ │ │ │ │ '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
│ │ │ │ │ @@ -1061,15 +3375,15 @@
│ │ │ │ │ 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/polyfills.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.
│ │ │ │ │ @@ -1078,321 +3392,92 @@
│ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ # GNU General Public License for more details.
│ │ │ │ │ #
│ │ │ │ │ # You should have received a copy of the GNU General Public License
│ │ │ │ │ # along with this program. If not, see .
│ │ │ │ │
│ │ │ │ │ -import contextlib
│ │ │ │ │ -import socket
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def install():
│ │ │ │ │ - """Add shims for older Python versions"""
│ │ │ │ │ -
│ │ │ │ │ - # introduced in 3.9
│ │ │ │ │ - if not hasattr(socket, 'recv_fds'):
│ │ │ │ │ - import array
│ │ │ │ │ -
│ │ │ │ │ - import _socket
│ │ │ │ │ -
│ │ │ │ │ - def recv_fds(sock, bufsize, maxfds, flags=0):
│ │ │ │ │ - fds = array.array("i")
│ │ │ │ │ - msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize))
│ │ │ │ │ - for cmsg_level, cmsg_type, cmsg_data in ancdata:
│ │ │ │ │ - if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS):
│ │ │ │ │ - fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
│ │ │ │ │ - return msg, list(fds), flags, addr
│ │ │ │ │ -
│ │ │ │ │ - socket.recv_fds = recv_fds
│ │ │ │ │ -
│ │ │ │ │ - # introduced in 3.7
│ │ │ │ │ - if not hasattr(contextlib, 'AsyncExitStack'):
│ │ │ │ │ - class AsyncExitStack:
│ │ │ │ │ - async def __aenter__(self):
│ │ │ │ │ - self.cms = []
│ │ │ │ │ - return self
│ │ │ │ │ -
│ │ │ │ │ - async def enter_async_context(self, cm):
│ │ │ │ │ - result = await cm.__aenter__()
│ │ │ │ │ - self.cms.append(cm)
│ │ │ │ │ - return result
│ │ │ │ │ -
│ │ │ │ │ - async def __aexit__(self, exc_type, exc_value, traceback):
│ │ │ │ │ - for cm in self.cms:
│ │ │ │ │ - cm.__aexit__(exc_type, exc_value, traceback)
│ │ │ │ │ -
│ │ │ │ │ - contextlib.AsyncExitStack = AsyncExitStack
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/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 configparser
│ │ │ │ │ import logging
│ │ │ │ │ -from typing import Dict, List, Optional
│ │ │ │ │ +import os
│ │ │ │ │ +from pathlib import Path
│ │ │ │ │
│ │ │ │ │ -from .jsonutil import JsonObject, JsonValue
│ │ │ │ │ -from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer
│ │ │ │ │ +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'
│ │ │ │ │
│ │ │ │ │ -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
│ │ │ │ │
│ │ │ │ │ +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
│ │ │ │ │
│ │ │ │ │ -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
│ │ │ │ │ + # 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
│ │ │ │ │
│ │ │ │ │ - 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')
│ │ │ │ │ +class Config(bus.Object, interface='cockpit.Config'):
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + self.reload()
│ │ │ │ │
│ │ │ │ │ - def drop_channel(self, channel: str) -> None:
│ │ │ │ │ + @bus.Interface.Method(out_types='s', in_types='ss')
│ │ │ │ │ + def get_string(self, section, key):
│ │ │ │ │ try:
│ │ │ │ │ - self.open_channels.pop(channel)
│ │ │ │ │ - logger.debug('router dropped channel %s', channel)
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels)
│ │ │ │ │ -
│ │ │ │ │ - def add_endpoint(self, endpoint: Endpoint) -> None:
│ │ │ │ │ - self.endpoints[endpoint] = set()
│ │ │ │ │ - self.no_endpoints.clear()
│ │ │ │ │ -
│ │ │ │ │ - def shutdown_endpoint(self, endpoint: Endpoint, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ - channels = self.endpoints.pop(endpoint)
│ │ │ │ │ - logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels)
│ │ │ │ │ - for channel in channels:
│ │ │ │ │ - self.write_control(_msg, command='close', channel=channel, **kwargs)
│ │ │ │ │ - self.drop_channel(channel)
│ │ │ │ │ -
│ │ │ │ │ - if not self.endpoints:
│ │ │ │ │ - self.no_endpoints.set()
│ │ │ │ │ -
│ │ │ │ │ - # were we waiting to exit?
│ │ │ │ │ - if self._eof:
│ │ │ │ │ - logger.debug(' endpoints remaining: %r', self.endpoints)
│ │ │ │ │ - if not self.endpoints and self.transport:
│ │ │ │ │ - logger.debug(' close transport')
│ │ │ │ │ - self.transport.close()
│ │ │ │ │ -
│ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ - endpoints = set(self.endpoints)
│ │ │ │ │ - logger.debug('do_kill(%s, %s). Considering %d endpoints.', host, group, len(endpoints))
│ │ │ │ │ - for endpoint in endpoints:
│ │ │ │ │ - endpoint.do_kill(host, group, message)
│ │ │ │ │ -
│ │ │ │ │ - def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ - # If this is an open message then we need to apply the routing rules to
│ │ │ │ │ - # figure out the correct endpoint to connect. If it's not an open
│ │ │ │ │ - # message, then we expect the endpoint to already exist.
│ │ │ │ │ - if command == 'open':
│ │ │ │ │ - if channel in self.open_channels:
│ │ │ │ │ - raise CockpitProtocolError('channel is already open')
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload'))
│ │ │ │ │ - endpoint = self.check_rules(message)
│ │ │ │ │ - except RoutingError as exc:
│ │ │ │ │ - self.write_control(exc.get_attrs(), command='close', channel=channel)
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - self.open_channels[channel] = endpoint
│ │ │ │ │ - self.endpoints[endpoint].add(channel)
│ │ │ │ │ - else:
│ │ │ │ │ - try:
│ │ │ │ │ - endpoint = self.open_channels[channel]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - # sending to a non-existent channel can happen due to races and is not an error
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - # At this point, we have the endpoint. Route the message.
│ │ │ │ │ - endpoint.do_channel_control(channel, command, message)
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + @bus.Interface.Method(out_types='u', in_types='ssuuu')
│ │ │ │ │ + def get_u_int(self, section, key, default, maximum, minimum):
│ │ │ │ │ try:
│ │ │ │ │ - endpoint = self.open_channels[channel]
│ │ │ │ │ + value = self.config[section][key]
│ │ │ │ │ except KeyError:
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - endpoint.do_channel_data(channel, data)
│ │ │ │ │ -
│ │ │ │ │ - def eof_received(self) -> bool:
│ │ │ │ │ - logger.debug('eof_received(%r)', self)
│ │ │ │ │ + return default
│ │ │ │ │
│ │ │ │ │ - endpoints = set(self.endpoints)
│ │ │ │ │ - for endpoint in endpoints:
│ │ │ │ │ - endpoint.do_close()
│ │ │ │ │ + try:
│ │ │ │ │ + int_val = int(value)
│ │ │ │ │ + except ValueError:
│ │ │ │ │ + logger.warning('cockpit.conf: [%s] %s is not an integer', section, key)
│ │ │ │ │ + return default
│ │ │ │ │
│ │ │ │ │ - self._eof = True
│ │ │ │ │ - logger.debug(' endpoints remaining: %r', self.endpoints)
│ │ │ │ │ - return bool(self.endpoints)
│ │ │ │ │ + return min(max(int_val, minimum), maximum)
│ │ │ │ │
│ │ │ │ │ - _communication_done: Optional[asyncio.Future] = None
│ │ │ │ │ + @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 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)
│ │ │ │ │ +class Environment(bus.Object, interface='cockpit.Environment'):
│ │ │ │ │ + variables = bus.Interface.Property('a{ss}')
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + @variables.getter
│ │ │ │ │ + def get_variables(self):
│ │ │ │ │ + return os.environ.copy()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/__init__.py': br'''from ._version import __version__
│ │ │ │ │
│ │ │ │ │ - # 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()
│ │ │ │ │ +__all__ = (
│ │ │ │ │ + '__version__',
│ │ │ │ │ +)
│ │ │ │ │ ''',
│ │ │ │ │ '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
│ │ │ │ │ @@ -1739,15 +3824,17 @@
│ │ │ │ │
│ │ │ │ │ asyncio.run(run(args), debug=args.debug)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ if __name__ == '__main__':
│ │ │ │ │ main()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/samples.py': br'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/_version.py': br'''__version__ = '319'
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/channel.py': r'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -1756,1250 +3843,599 @@
│ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ # GNU General Public License for more details.
│ │ │ │ │ #
│ │ │ │ │ # You should have received a copy of the GNU General Public License
│ │ │ │ │ # along with this program. If not, see .
│ │ │ │ │
│ │ │ │ │ -import errno
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import codecs
│ │ │ │ │ +import json
│ │ │ │ │ import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import re
│ │ │ │ │ -from typing import Any, DefaultDict, Iterable, List, NamedTuple, Optional, Tuple
│ │ │ │ │ -
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Handle
│ │ │ │ │ -
│ │ │ │ │ -USER_HZ = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
│ │ │ │ │ -MS_PER_JIFFY = 1000 / (USER_HZ if (USER_HZ > 0) else 100)
│ │ │ │ │ -HWMON_PATH = '/sys/class/hwmon'
│ │ │ │ │ +import traceback
│ │ │ │ │ +import typing
│ │ │ │ │ +from typing import BinaryIO, Callable, ClassVar, Collection, Generator, Mapping, Sequence, Type
│ │ │ │ │
│ │ │ │ │ -# we would like to do this, but mypy complains; https://github.com/python/mypy/issues/2900
│ │ │ │ │ -# Samples = collections.defaultdict[str, Union[float, Dict[str, Union[float, None]]]]
│ │ │ │ │ -Samples = DefaultDict[str, Any]
│ │ │ │ │ +from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_bool, get_enum, get_str
│ │ │ │ │ +from .protocol import CockpitProblem
│ │ │ │ │ +from .router import Endpoint, Router, RoutingRule
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def read_int_file(rootfd: int, statfile: str, default: Optional[int] = None, key: bytes = b'') -> Optional[int]:
│ │ │ │ │ - # Not every stat is available, such as cpu.weight
│ │ │ │ │ - try:
│ │ │ │ │ - fd = os.open(statfile, os.O_RDONLY, dir_fd=rootfd)
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - data = os.read(fd, 1024)
│ │ │ │ │ - except OSError as e:
│ │ │ │ │ - # cgroups can disappear between the open and read
│ │ │ │ │ - if e.errno != errno.ENODEV:
│ │ │ │ │ - logger.warning('Failed to read %s: %s', statfile, e)
│ │ │ │ │ - return None
│ │ │ │ │ - finally:
│ │ │ │ │ - os.close(fd)
│ │ │ │ │ -
│ │ │ │ │ - if key:
│ │ │ │ │ - start = data.index(key) + len(key)
│ │ │ │ │ - end = data.index(b'\n', start)
│ │ │ │ │ - data = data[start:end]
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - # 0 often means "none", so replace it with default value
│ │ │ │ │ - return int(data) or default
│ │ │ │ │ - except ValueError:
│ │ │ │ │ - # Some samples such as "memory.max" contains "max" when there is a no limit
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SampleDescription(NamedTuple):
│ │ │ │ │ - name: str
│ │ │ │ │ - units: str
│ │ │ │ │ - semantics: str
│ │ │ │ │ - instanced: bool
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Sampler:
│ │ │ │ │ - descriptions: List[SampleDescription]
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class CPUSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('cpu.basic.nice', 'millisec', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('cpu.basic.user', 'millisec', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('cpu.basic.system', 'millisec', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('cpu.basic.iowait', 'millisec', 'counter', instanced=False),
│ │ │ │ │ -
│ │ │ │ │ - SampleDescription('cpu.core.nice', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cpu.core.user', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cpu.core.system', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cpu.core.iowait', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/stat') as stat:
│ │ │ │ │ - for line in stat:
│ │ │ │ │ - if not line.startswith('cpu'):
│ │ │ │ │ - continue
│ │ │ │ │ - cpu, user, nice, system, _idle, iowait = line.split()[:6]
│ │ │ │ │ - core = cpu[3:] or None
│ │ │ │ │ - if core:
│ │ │ │ │ - prefix = 'cpu.core'
│ │ │ │ │ - samples[f'{prefix}.nice'][core] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.user'][core] = int(user) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.system'][core] = int(system) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.iowait'][core] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │ - else:
│ │ │ │ │ - prefix = 'cpu.basic'
│ │ │ │ │ - samples[f'{prefix}.nice'] = int(nice) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.user'] = int(user) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.system'] = int(system) * MS_PER_JIFFY
│ │ │ │ │ - samples[f'{prefix}.iowait'] = int(iowait) * MS_PER_JIFFY
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class MemorySampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('memory.free', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - SampleDescription('memory.used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - SampleDescription('memory.cached', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - SampleDescription('memory.swap-used', 'bytes', 'instant', instanced=False),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/meminfo') as meminfo:
│ │ │ │ │ - items = {k: int(v.strip(' kB\n')) for line in meminfo for k, v in [line.split(':', 1)]}
│ │ │ │ │ -
│ │ │ │ │ - samples['memory.free'] = 1024 * items['MemFree']
│ │ │ │ │ - samples['memory.used'] = 1024 * (items['MemTotal'] - items['MemAvailable'])
│ │ │ │ │ - samples['memory.cached'] = 1024 * (items['Buffers'] + items['Cached'])
│ │ │ │ │ - samples['memory.swap-used'] = 1024 * (items['SwapTotal'] - items['SwapFree'])
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class CPUTemperatureSampler(Sampler):
│ │ │ │ │ - # Cache found sensors, as they can't be hotplugged.
│ │ │ │ │ - sensors: Optional[List[str]] = None
│ │ │ │ │ -
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('cpu.temperature', 'celsius', 'instant', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def detect_cpu_sensors(dir_fd: int) -> Iterable[str]:
│ │ │ │ │ - # Read the name file to decide what to do with this directory
│ │ │ │ │ - try:
│ │ │ │ │ - with Handle.open('name', os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ - name = os.read(fd, 1024).decode().strip()
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - if name == 'atk0110':
│ │ │ │ │ - # only sample 'CPU Temperature' in atk0110
│ │ │ │ │ - predicate = (lambda label: label == 'CPU Temperature')
│ │ │ │ │ - elif name == 'cpu_thermal':
│ │ │ │ │ - # labels are not used on ARM
│ │ │ │ │ - predicate = None
│ │ │ │ │ - elif name == 'coretemp':
│ │ │ │ │ - # accept all labels on Intel
│ │ │ │ │ - predicate = None
│ │ │ │ │ - elif name in ['k8temp', 'k10temp']:
│ │ │ │ │ - predicate = None
│ │ │ │ │ - else:
│ │ │ │ │ - # Not a CPU sensor
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - # Now scan the directory for inputs
│ │ │ │ │ - for input_filename in os.listdir(dir_fd):
│ │ │ │ │ - if not input_filename.endswith('_input'):
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - if predicate:
│ │ │ │ │ - # We need to check the label
│ │ │ │ │ - try:
│ │ │ │ │ - label_filename = input_filename.replace('_input', '_label')
│ │ │ │ │ - with Handle.open(label_filename, os.O_RDONLY, dir_fd=dir_fd) as fd:
│ │ │ │ │ - label = os.read(fd, 1024).decode().strip()
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - if not predicate(label):
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - yield input_filename
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def scan_sensors() -> Iterable[str]:
│ │ │ │ │ - try:
│ │ │ │ │ - top_fd = Handle.open(HWMON_PATH, os.O_RDONLY | os.O_DIRECTORY)
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - with top_fd:
│ │ │ │ │ - for hwmon_name in os.listdir(top_fd):
│ │ │ │ │ - with Handle.open(hwmon_name, os.O_RDONLY | os.O_DIRECTORY, dir_fd=top_fd) as subdir_fd:
│ │ │ │ │ - for sensor in CPUTemperatureSampler.detect_cpu_sensors(subdir_fd):
│ │ │ │ │ - yield f'{HWMON_PATH}/{hwmon_name}/{sensor}'
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - if self.sensors is None:
│ │ │ │ │ - self.sensors = list(CPUTemperatureSampler.scan_sensors())
│ │ │ │ │ -
│ │ │ │ │ - for sensor_path in self.sensors:
│ │ │ │ │ - with open(sensor_path) as sensor:
│ │ │ │ │ - temperature = int(sensor.read().strip())
│ │ │ │ │ - if temperature == 0:
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - samples['cpu.temperature'][sensor_path] = temperature / 1000
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class DiskSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('disk.all.read', 'bytes', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('disk.all.written', 'bytes', 'counter', instanced=False),
│ │ │ │ │ - SampleDescription('disk.dev.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('disk.dev.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/diskstats') as diskstats:
│ │ │ │ │ - all_read_bytes = 0
│ │ │ │ │ - all_written_bytes = 0
│ │ │ │ │ -
│ │ │ │ │ - for line in diskstats:
│ │ │ │ │ - # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ - fields = line.strip().split()
│ │ │ │ │ - dev_major = fields[0]
│ │ │ │ │ - dev_name = fields[2]
│ │ │ │ │ - num_sectors_read = fields[5]
│ │ │ │ │ - num_sectors_written = fields[9]
│ │ │ │ │ -
│ │ │ │ │ - # ignore mdraid
│ │ │ │ │ - if dev_major == '9':
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - # ignore device-mapper
│ │ │ │ │ - if dev_name.startswith('dm-'):
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - # Skip partitions
│ │ │ │ │ - if dev_name[:2] in ['sd', 'hd', 'vd'] and dev_name[-1].isdigit():
│ │ │ │ │ - continue
│ │ │ │ │ +if typing.TYPE_CHECKING:
│ │ │ │ │ + _T = typing.TypeVar('_T')
│ │ │ │ │ + _P = typing.ParamSpec("_P")
│ │ │ │ │
│ │ │ │ │ - # Ignore nvme partitions
│ │ │ │ │ - if dev_name.startswith('nvme') and 'p' in dev_name:
│ │ │ │ │ - continue
│ │ │ │ │
│ │ │ │ │ - read_bytes = int(num_sectors_read) * 512
│ │ │ │ │ - written_bytes = int(num_sectors_written) * 512
│ │ │ │ │ +class ChannelRoutingRule(RoutingRule):
│ │ │ │ │ + table: 'dict[str, list[Type[Channel]]]'
│ │ │ │ │
│ │ │ │ │ - all_read_bytes += read_bytes
│ │ │ │ │ - all_written_bytes += written_bytes
│ │ │ │ │ + def __init__(self, router: Router, channel_types: 'Collection[Type[Channel]]'):
│ │ │ │ │ + super().__init__(router)
│ │ │ │ │ + self.table = {}
│ │ │ │ │
│ │ │ │ │ - samples['disk.dev.read'][dev_name] = read_bytes
│ │ │ │ │ - samples['disk.dev.written'][dev_name] = written_bytes
│ │ │ │ │ + # Sort the channels into buckets by payload type
│ │ │ │ │ + for cls in channel_types:
│ │ │ │ │ + entry = self.table.setdefault(cls.payload, [])
│ │ │ │ │ + entry.append(cls)
│ │ │ │ │
│ │ │ │ │ - samples['disk.all.read'] = all_read_bytes
│ │ │ │ │ - samples['disk.all.written'] = all_written_bytes
│ │ │ │ │ + # 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: 'Collection[tuple[str, object]]', options: JsonObject) -> bool:
│ │ │ │ │ + for key, expected_value in restrictions:
│ │ │ │ │ + our_value = options.get(key)
│ │ │ │ │
│ │ │ │ │ -class CGroupSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('cgroup.memory.usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.memory.limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.memory.sw-usage', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.memory.sw-limit', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.cpu.usage', 'millisec', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('cgroup.cpu.shares', 'count', 'instant', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ + # If 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
│ │ │ │ │
│ │ │ │ │ - cgroups_v2: Optional[bool] = None
│ │ │ │ │ + # 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
│ │ │ │ │
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - if self.cgroups_v2 is None:
│ │ │ │ │ - self.cgroups_v2 = os.path.exists('/sys/fs/cgroup/cgroup.controllers')
│ │ │ │ │ + # Everything checked out
│ │ │ │ │ + return True
│ │ │ │ │
│ │ │ │ │ - if self.cgroups_v2:
│ │ │ │ │ - cgroups_v2_path = '/sys/fs/cgroup/'
│ │ │ │ │ - for path, _, _, rootfd in os.fwalk(cgroups_v2_path):
│ │ │ │ │ - cgroup = path.replace(cgroups_v2_path, '')
│ │ │ │ │ + def apply_rule(self, options: JsonObject) -> 'Channel | None':
│ │ │ │ │ + assert self.router is not None
│ │ │ │ │
│ │ │ │ │ - if not cgroup:
│ │ │ │ │ - continue
│ │ │ │ │ + payload = options.get('payload')
│ │ │ │ │ + if not isinstance(payload, str):
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.current', 0)
│ │ │ │ │ - samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.max')
│ │ │ │ │ - samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.swap.current', 0)
│ │ │ │ │ - samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.swap.max')
│ │ │ │ │ - samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.weight')
│ │ │ │ │ - usage_usec = read_int_file(rootfd, 'cpu.stat', 0, key=b'usage_usec')
│ │ │ │ │ - if usage_usec:
│ │ │ │ │ - samples['cgroup.cpu.usage'][cgroup] = usage_usec / 1000
│ │ │ │ │ + for cls in self.table.get(payload, []):
│ │ │ │ │ + if self.check_restrictions(cls.restrictions, options):
│ │ │ │ │ + return cls(self.router)
│ │ │ │ │ else:
│ │ │ │ │ - memory_path = '/sys/fs/cgroup/memory/'
│ │ │ │ │ - for path, _, _, rootfd in os.fwalk(memory_path):
│ │ │ │ │ - cgroup = path.replace(memory_path, '')
│ │ │ │ │ -
│ │ │ │ │ - if not cgroup:
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.usage_in_bytes', 0)
│ │ │ │ │ - samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.limit_in_bytes')
│ │ │ │ │ - samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.memsw.usage_in_bytes', 0)
│ │ │ │ │ - samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.memsw.limit_in_bytes')
│ │ │ │ │ -
│ │ │ │ │ - cpu_path = '/sys/fs/cgroup/cpu/'
│ │ │ │ │ - for path, _, _, rootfd in os.fwalk(cpu_path):
│ │ │ │ │ - cgroup = path.replace(cpu_path, '')
│ │ │ │ │ -
│ │ │ │ │ - if not cgroup:
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.shares')
│ │ │ │ │ - usage_nsec = read_int_file(rootfd, 'cpuacct.usage')
│ │ │ │ │ - if usage_nsec:
│ │ │ │ │ - samples['cgroup.cpu.usage'][cgroup] = usage_nsec / 1000000
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class CGroupDiskIO(Sampler):
│ │ │ │ │ - IO_RE = re.compile(rb'\bread_bytes: (?P\d+).*\nwrite_bytes: (?P\d+)', flags=re.S)
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('disk.cgroup.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('disk.cgroup.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_cgroup_name(fd: int) -> str:
│ │ │ │ │ - with Handle.open('cgroup', os.O_RDONLY, dir_fd=fd) as cgroup_fd:
│ │ │ │ │ - cgroup_name = os.read(cgroup_fd, 2048).decode().strip()
│ │ │ │ │ -
│ │ │ │ │ - # Skip leading ::0/
│ │ │ │ │ - return cgroup_name[4:]
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_proc_io(fd: int) -> Tuple[int, int]:
│ │ │ │ │ - with Handle.open('io', os.O_RDONLY, dir_fd=fd) as io_fd:
│ │ │ │ │ - data = os.read(io_fd, 4096)
│ │ │ │ │ -
│ │ │ │ │ - match = re.search(CGroupDiskIO.IO_RE, data)
│ │ │ │ │ - if match:
│ │ │ │ │ - proc_read = int(match.group('read'))
│ │ │ │ │ - proc_write = int(match.group('write'))
│ │ │ │ │ -
│ │ │ │ │ - return proc_read, proc_write
│ │ │ │ │ -
│ │ │ │ │ - return 0, 0
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples):
│ │ │ │ │ - with Handle.open('/proc', os.O_RDONLY | os.O_DIRECTORY) as proc_fd:
│ │ │ │ │ - reads = samples['disk.cgroup.read']
│ │ │ │ │ - writes = samples['disk.cgroup.written']
│ │ │ │ │ -
│ │ │ │ │ - for path in os.listdir(proc_fd):
│ │ │ │ │ - # non-pid entries in proc are guaranteed to start with a character a-z
│ │ │ │ │ - if path[0] < '0' or path[0] > '9':
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - with Handle.open(path, os.O_PATH, dir_fd=proc_fd) as pid_fd:
│ │ │ │ │ - cgroup_name = self.get_cgroup_name(pid_fd)
│ │ │ │ │ - proc_read, proc_write = self.get_proc_io(pid_fd)
│ │ │ │ │ - except (FileNotFoundError, PermissionError, ProcessLookupError):
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - reads[cgroup_name] = reads.get(cgroup_name, 0) + proc_read
│ │ │ │ │ - writes[cgroup_name] = writes.get(cgroup_name, 0) + proc_write
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class NetworkSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('network.interface.tx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('network.interface.rx', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open("/proc/net/dev") as network_samples:
│ │ │ │ │ - for line in network_samples:
│ │ │ │ │ - fields = line.split()
│ │ │ │ │ -
│ │ │ │ │ - # Skip header line
│ │ │ │ │ - if fields[0][-1] != ':':
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - iface = fields[0][:-1]
│ │ │ │ │ - samples['network.interface.rx'][iface] = int(fields[1])
│ │ │ │ │ - samples['network.interface.tx'][iface] = int(fields[9])
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class MountSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('mount.total', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - SampleDescription('mount.used', 'bytes', 'instant', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/mounts') as mounts:
│ │ │ │ │ - for line in mounts:
│ │ │ │ │ - # Only look at real devices
│ │ │ │ │ - if line[0] != '/':
│ │ │ │ │ - continue
│ │ │ │ │ -
│ │ │ │ │ - path = line.split()[1]
│ │ │ │ │ - try:
│ │ │ │ │ - res = os.statvfs(path)
│ │ │ │ │ - except OSError:
│ │ │ │ │ - continue
│ │ │ │ │ - frsize = res.f_frsize
│ │ │ │ │ - total = frsize * res.f_blocks
│ │ │ │ │ - samples['mount.total'][path] = total
│ │ │ │ │ - samples['mount.used'][path] = total - frsize * res.f_bfree
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class BlockSampler(Sampler):
│ │ │ │ │ - descriptions = [
│ │ │ │ │ - SampleDescription('block.device.read', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - SampleDescription('block.device.written', 'bytes', 'counter', instanced=True),
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - def sample(self, samples: Samples) -> None:
│ │ │ │ │ - with open('/proc/diskstats') as diskstats:
│ │ │ │ │ - for line in diskstats:
│ │ │ │ │ - # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
│ │ │ │ │ - [_, _, dev_name, _, _, sectors_read, _, _, _, sectors_written, *_] = line.strip().split()
│ │ │ │ │ -
│ │ │ │ │ - samples['block.device.read'][dev_name] = int(sectors_read) * 512
│ │ │ │ │ - samples['block.device.written'][dev_name] = int(sectors_written) * 512
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -SAMPLERS = [
│ │ │ │ │ - BlockSampler,
│ │ │ │ │ - CGroupSampler,
│ │ │ │ │ - CGroupDiskIO,
│ │ │ │ │ - CPUSampler,
│ │ │ │ │ - CPUTemperatureSampler,
│ │ │ │ │ - DiskSampler,
│ │ │ │ │ - MemorySampler,
│ │ │ │ │ - MountSampler,
│ │ │ │ │ - NetworkSampler,
│ │ │ │ │ -]
│ │ │ │ │ -''',
│ │ │ │ │ - '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__)
│ │ │ │ │ -
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ -class InternalBus:
│ │ │ │ │ - exportees: List[bus.Slot]
│ │ │ │ │ + def shutdown(self):
│ │ │ │ │ + pass # we don't hold any state
│ │ │ │ │
│ │ │ │ │ - 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 ChannelError(CockpitProblem):
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class Bridge(Router, PackagesListener):
│ │ │ │ │ - internal_bus: InternalBus
│ │ │ │ │ - packages: Optional[Packages]
│ │ │ │ │ - bridge_configs: Sequence[BridgeConfig]
│ │ │ │ │ - args: argparse.Namespace
│ │ │ │ │ +class Channel(Endpoint):
│ │ │ │ │ + # Values borrowed from C implementation
│ │ │ │ │ + BLOCK_SIZE = 16 * 1024
│ │ │ │ │ + SEND_WINDOW = 2 * 1024 * 1024
│ │ │ │ │
│ │ │ │ │ - def __init__(self, args: argparse.Namespace):
│ │ │ │ │ - self.internal_bus = InternalBus(EXPORTS)
│ │ │ │ │ - self.bridge_configs = []
│ │ │ │ │ - self.args = args
│ │ │ │ │ + # Flow control book-keeping
│ │ │ │ │ + _send_pings: bool = False
│ │ │ │ │ + _out_sequence: int = 0
│ │ │ │ │ + _out_window: int = SEND_WINDOW
│ │ │ │ │ + _ack_bytes: bool
│ │ │ │ │
│ │ │ │ │ - self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged)
│ │ │ │ │ - self.internal_bus.export('/superuser', self.superuser_rule)
│ │ │ │ │ + # Task management
│ │ │ │ │ + _tasks: 'set[asyncio.Task]'
│ │ │ │ │ + _close_args: 'JsonObject | None' = None
│ │ │ │ │
│ │ │ │ │ - self.internal_bus.export('/config', Config())
│ │ │ │ │ - self.internal_bus.export('/environment', Environment())
│ │ │ │ │ + # Must be filled in by the channel implementation
│ │ │ │ │ + payload: 'ClassVar[str]'
│ │ │ │ │ + restrictions: 'ClassVar[Sequence[tuple[str, object]]]' = ()
│ │ │ │ │
│ │ │ │ │ - self.peers_rule = PeersRoutingRule(self)
│ │ │ │ │ + # These get filled in from .do_open()
│ │ │ │ │ + channel = ''
│ │ │ │ │ + group = ''
│ │ │ │ │ + is_binary: bool
│ │ │ │ │ + decoder: 'codecs.IncrementalDecoder | None'
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + # input
│ │ │ │ │ + def do_control(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ + # 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 = get_str(message, 'channel')
│ │ │ │ │ + if get_bool(message, 'flow-control', default=False):
│ │ │ │ │ + self._send_pings = True
│ │ │ │ │ + self._ack_bytes = get_enum(message, 'send-acks', ['bytes'], None) is not None
│ │ │ │ │ + self.group = get_str(message, 'group', 'default')
│ │ │ │ │ + self.is_binary = get_enum(message, 'binary', ['raw'], None) is not None
│ │ │ │ │ + self.decoder = None
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - super().__init__([
│ │ │ │ │ - HostRoutingRule(self),
│ │ │ │ │ - self.superuser_rule,
│ │ │ │ │ - ChannelRoutingRule(self, CHANNEL_TYPES),
│ │ │ │ │ - self.peers_rule,
│ │ │ │ │ - ])
│ │ │ │ │ + def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ + # Already closing? Ignore.
│ │ │ │ │ + if self._close_args is not None:
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_os_release():
│ │ │ │ │ + # Catch errors and turn them into close messages
│ │ │ │ │ try:
│ │ │ │ │ - file = open('/etc/os-release', encoding='utf-8')
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - try:
│ │ │ │ │ - file = open('/usr/lib/os-release', encoding='utf-8')
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - logger.warning("Neither /etc/os-release nor /usr/lib/os-release exists")
│ │ │ │ │ - return {}
│ │ │ │ │ -
│ │ │ │ │ - os_release = {}
│ │ │ │ │ - for line in file.readlines():
│ │ │ │ │ - line = line.strip()
│ │ │ │ │ - if not line or line.startswith('#'):
│ │ │ │ │ - continue
│ │ │ │ │ try:
│ │ │ │ │ - k, v = line.split('=')
│ │ │ │ │ - (v_parsed, ) = shlex.split(v) # expect exactly one token
│ │ │ │ │ - except ValueError:
│ │ │ │ │ - logger.warning('Ignoring invalid line in os-release: %r', line)
│ │ │ │ │ - continue
│ │ │ │ │ - os_release[k] = v_parsed
│ │ │ │ │ - return os_release
│ │ │ │ │ -
│ │ │ │ │ - def do_init(self, message: JsonObject) -> None:
│ │ │ │ │ - # we're only interested in the case where this is a dict, but
│ │ │ │ │ - # 'superuser' may well be `False` and that's not an error
│ │ │ │ │ - with contextlib.suppress(JsonError):
│ │ │ │ │ - superuser = get_dict(message, 'superuser')
│ │ │ │ │ - self.superuser_rule.init(superuser)
│ │ │ │ │ -
│ │ │ │ │ - def do_send_init(self) -> None:
│ │ │ │ │ - init_args = {
│ │ │ │ │ - 'capabilities': {'explicit-superuser': True},
│ │ │ │ │ - 'command': 'init',
│ │ │ │ │ - 'os-release': self.get_os_release(),
│ │ │ │ │ - 'version': 1,
│ │ │ │ │ - }
│ │ │ │ │ -
│ │ │ │ │ - if self.packages is not None:
│ │ │ │ │ - init_args['packages'] = dict.fromkeys(self.packages.packages)
│ │ │ │ │ -
│ │ │ │ │ - self.write_control(init_args)
│ │ │ │ │ -
│ │ │ │ │ - # PackagesListener interface
│ │ │ │ │ - def packages_loaded(self) -> None:
│ │ │ │ │ - assert self.packages
│ │ │ │ │ - bridge_configs = self.packages.get_bridge_configs()
│ │ │ │ │ - if self.bridge_configs != bridge_configs:
│ │ │ │ │ - self.superuser_rule.set_configs(bridge_configs)
│ │ │ │ │ - self.peers_rule.set_configs(bridge_configs)
│ │ │ │ │ - self.bridge_configs = bridge_configs
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -async def run(args) -> None:
│ │ │ │ │ - logger.debug("Hi. How are you today?")
│ │ │ │ │ + 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.get_attrs())
│ │ │ │ │
│ │ │ │ │ - # 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
│ │ │ │ │ + def do_kill(self, host: 'str | None', group: 'str | None', _message: JsonObject) -> None:
│ │ │ │ │ + # Already closing? Ignore.
│ │ │ │ │ + if self._close_args is not None:
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - logger.debug('Starting the router.')
│ │ │ │ │ - router = Bridge(args)
│ │ │ │ │ - StdioTransport(asyncio.get_running_loop(), router)
│ │ │ │ │ + if host is not None:
│ │ │ │ │ + return
│ │ │ │ │ + if group is not None and self.group != group:
│ │ │ │ │ + return
│ │ │ │ │ + self.do_close()
│ │ │ │ │
│ │ │ │ │ - logger.debug('Startup done. Looping until connection closes.')
│ │ │ │ │ + # At least this one really ought to be implemented...
│ │ │ │ │ + def do_open(self, options: JsonObject) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - await router.communicate()
│ │ │ │ │ - except (BrokenPipeError, ConnectionResetError):
│ │ │ │ │ - # not unexpected if the peer doesn't hang up cleanly
│ │ │ │ │ + # ... but many subclasses may reasonably want to ignore some of these.
│ │ │ │ │ + def do_ready(self) -> None:
│ │ │ │ │ pass
│ │ │ │ │
│ │ │ │ │ + def do_done(self) -> None:
│ │ │ │ │ + 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
│ │ │ │ │ + def do_close(self) -> None:
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - 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 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 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
│ │ │ │ │ + def send_ack(self, data: bytes) -> None:
│ │ │ │ │ + if self._ack_bytes:
│ │ │ │ │ + self.send_control('ack', bytes=len(data))
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - import systemd.journal # type: ignore[import]
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - # No python3-systemd? Don't redirect.
│ │ │ │ │ - return False
│ │ │ │ │ + def do_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ + # Already closing? Ignore.
│ │ │ │ │ + if self._close_args is not None:
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - logging.root.addHandler(systemd.journal.JournalHandler())
│ │ │ │ │ - return True
│ │ │ │ │ + # Catch errors and turn them into close messages
│ │ │ │ │ + try:
│ │ │ │ │ + if not self.do_data(data):
│ │ │ │ │ + self.send_ack(data)
│ │ │ │ │ + except ChannelError as exc:
│ │ │ │ │ + self.close(exc.get_attrs())
│ │ │ │ │
│ │ │ │ │ + def do_data(self, data: bytes) -> 'bool | None':
│ │ │ │ │ + """Handles incoming data to the channel.
│ │ │ │ │
│ │ │ │ │ -def setup_logging(*, debug: bool) -> None:
│ │ │ │ │ - """Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set"""
│ │ │ │ │ + Return value is True if the channel takes care of send acks on its own,
│ │ │ │ │ + in which case it should call self.send_ack() on `data` at some point.
│ │ │ │ │ + None or False means that the acknowledgement is sent automatically."""
│ │ │ │ │ + # By default, channels can't receive data.
│ │ │ │ │ + del data
│ │ │ │ │ + self.close()
│ │ │ │ │ + return True
│ │ │ │ │
│ │ │ │ │ - modules = os.getenv('COCKPIT_DEBUG', '')
│ │ │ │ │ + # output
│ │ │ │ │ + def ready(self, **kwargs: JsonValue) -> None:
│ │ │ │ │ + self.thaw_endpoint()
│ │ │ │ │ + self.send_control(command='ready', **kwargs)
│ │ │ │ │
│ │ │ │ │ - # Either setup logging via journal or via formatted messages to stderr
│ │ │ │ │ - if not setup_journald():
│ │ │ │ │ - logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s')
│ │ │ │ │ + def __decode_frame(self, data: bytes, *, final: bool = False) -> str:
│ │ │ │ │ + assert self.decoder is not None
│ │ │ │ │ + try:
│ │ │ │ │ + return self.decoder.decode(data, final=final)
│ │ │ │ │ + except UnicodeDecodeError as exc:
│ │ │ │ │ + raise ChannelError('protocol-error', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + def done(self) -> None:
│ │ │ │ │ + # any residue from partial send_data() frames? this is invalid, fail the channel
│ │ │ │ │ + if self.decoder is not None:
│ │ │ │ │ + self.__decode_frame(b'', final=True)
│ │ │ │ │ + self.send_control(command='done')
│ │ │ │ │
│ │ │ │ │ - logging.getLogger(module).setLevel(logging.DEBUG)
│ │ │ │ │ + # 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 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
│ │ │ │ │ + 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()
│ │ │ │ │
│ │ │ │ │ - # 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()
│ │ │ │ │ + def create_task(self, coroutine, name=None):
│ │ │ │ │ + """Create a task associated with the channel.
│ │ │ │ │
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - logger.debug("Couldn't start ssh-agent (FileNotFoundError)")
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - logger.warning("Could not start ssh-agent: %s", exc)
│ │ │ │ │ + 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.
│ │ │ │ │
│ │ │ │ │ -def main(*, beipack: bool = False) -> None:
│ │ │ │ │ - polyfills.install()
│ │ │ │ │ + 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()
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + def send_bytes(self, data: bytes) -> bool:
│ │ │ │ │ + """Send binary data and handle book-keeping for flow control.
│ │ │ │ │
│ │ │ │ │ - # This is determined by who calls us
│ │ │ │ │ - args.beipack = beipack
│ │ │ │ │ + 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.
│ │ │ │ │
│ │ │ │ │ - # 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()
│ │ │ │ │ + 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.
│ │ │ │ │
│ │ │ │ │ - setup_logging(debug=args.debug)
│ │ │ │ │ + Be careful with text channels (i.e. without binary="raw"): you are responsible
│ │ │ │ │ + for ensuring that @data is valid UTF-8. This isn't validated here for
│ │ │ │ │ + efficiency reasons.
│ │ │ │ │ + """
│ │ │ │ │ + self.send_channel_data(self.channel, data)
│ │ │ │ │
│ │ │ │ │ - # 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
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - # 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()
│ │ │ │ │ + return self._out_sequence < self._out_window
│ │ │ │ │
│ │ │ │ │ - # asyncio.run() shim for Python 3.6 support
│ │ │ │ │ - run_async(run(args), debug=args.debug)
│ │ │ │ │ + def send_data(self, data: bytes) -> bool:
│ │ │ │ │ + """Send data and transparently handle UTF-8 for text channels
│ │ │ │ │
│ │ │ │ │ + Use this for channels which can be text, but are not guaranteed to get
│ │ │ │ │ + valid UTF-8 frames -- i.e. multi-byte characters may be split across
│ │ │ │ │ + frames. This is expensive, so prefer send_text() or send_bytes() wherever
│ │ │ │ │ + possible.
│ │ │ │ │ + """
│ │ │ │ │ + if self.is_binary:
│ │ │ │ │ + return self.send_bytes(data)
│ │ │ │ │
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ - '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 .
│ │ │ │ │ + # for text channels we must avoid splitting UTF-8 multi-byte characters,
│ │ │ │ │ + # as these can't be sent to a WebSocket (and are often confusing for text streams as well)
│ │ │ │ │ + if self.decoder is None:
│ │ │ │ │ + self.decoder = codecs.getincrementaldecoder('utf-8')(errors='strict')
│ │ │ │ │ + return self.send_text(self.__decode_frame(data))
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -from typing import Callable, List, Optional, Sequence
│ │ │ │ │ + def send_text(self, data: str) -> bool:
│ │ │ │ │ + """Send UTF-8 string data and handle book-keeping for flow control.
│ │ │ │ │
│ │ │ │ │ -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
│ │ │ │ │ + Similar to `send_bytes`, but for text data. The data is sent as UTF-8 encoded bytes.
│ │ │ │ │ + """
│ │ │ │ │ + return self.send_bytes(data.encode())
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ + def send_json(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> bool:
│ │ │ │ │ + pretty = self.json_encoder.encode(create_object(_msg, kwargs)) + '\n'
│ │ │ │ │ + return self.send_text(pretty)
│ │ │ │ │
│ │ │ │ │ + def do_pong(self, message):
│ │ │ │ │ + if not self._send_pings: # huh?
│ │ │ │ │ + logger.warning("Got wild pong on channel %s", self.channel)
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ -class PeerError(CockpitProblem):
│ │ │ │ │ - pass
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ -class PeerExited(Exception):
│ │ │ │ │ - def __init__(self, exit_code: int):
│ │ │ │ │ - self.exit_code = exit_code
│ │ │ │ │ + json_encoder: 'ClassVar[json.JSONEncoder]' = json.JSONEncoder(indent=2)
│ │ │ │ │
│ │ │ │ │ + def send_control(self, command: str, **kwargs: JsonValue) -> None:
│ │ │ │ │ + self.send_channel_control(self.channel, command, None, **kwargs)
│ │ │ │ │
│ │ │ │ │ -class Peer(CockpitProtocol, SubprocessProtocol, Endpoint):
│ │ │ │ │ - done_callbacks: List[Callable[[], None]]
│ │ │ │ │ - init_future: Optional[asyncio.Future]
│ │ │ │ │ + def send_pong(self, message: JsonObject) -> None:
│ │ │ │ │ + self.send_channel_control(self.channel, 'pong', message)
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ +class ProtocolChannel(Channel, asyncio.Protocol):
│ │ │ │ │ + """A channel subclass that implements the asyncio Protocol interface.
│ │ │ │ │
│ │ │ │ │ - self.init_future = asyncio.get_running_loop().create_future()
│ │ │ │ │ - self.done_callbacks = []
│ │ │ │ │ + In effect, data sent to this channel will be written to the connected
│ │ │ │ │ + transport, and vice-versa. Flow control is supported.
│ │ │ │ │
│ │ │ │ │ - # Initialization
│ │ │ │ │ - async def do_connect_transport(self) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + 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.
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + Otherwise, if the subclass implements .do_open() itself, it is responsible
│ │ │ │ │ + for setting up the connection and ensuring that .connection_made() is called.
│ │ │ │ │ + """
│ │ │ │ │ + _transport: 'asyncio.Transport | None'
│ │ │ │ │ + _send_pongs: bool = True
│ │ │ │ │ + _last_ping: 'JsonObject | None' = None
│ │ │ │ │ + _create_transport_task: 'asyncio.Task[asyncio.Transport] | None' = None
│ │ │ │ │
│ │ │ │ │ - async def start(self, init_host: Optional[str] = None, **kwargs: JsonValue) -> JsonObject:
│ │ │ │ │ - """Request that the Peer is started and connected to the router.
│ │ │ │ │ + # read-side EOF handling
│ │ │ │ │ + _close_on_eof: bool = False
│ │ │ │ │ + _eof: bool = False
│ │ │ │ │
│ │ │ │ │ - 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.
│ │ │ │ │ + async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport:
│ │ │ │ │ + """Creates the transport for this channel, according to options.
│ │ │ │ │
│ │ │ │ │ - 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.
│ │ │ │ │ + The event loop for the transport is passed to the function. The
│ │ │ │ │ + protocol for the transport is the channel object, itself (self).
│ │ │ │ │
│ │ │ │ │ - In any case, the return value is the init message from the peer.
│ │ │ │ │ + This needs to be implemented by the subclass.
│ │ │ │ │ """
│ │ │ │ │ - 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)
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ - connect_task = asyncio.create_task(self.do_connect_transport())
│ │ │ │ │ - connect_task.add_done_callback(_connect_task_done)
│ │ │ │ │ + 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:
│ │ │ │ │ - # 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
│ │ │ │ │ + transport = task.result()
│ │ │ │ │ + except ChannelError as exc:
│ │ │ │ │ + self.close(exc.get_attrs())
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + self.connection_made(transport)
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - finally:
│ │ │ │ │ - self.init_future = None
│ │ │ │ │ + def connection_made(self, transport: asyncio.BaseTransport) -> None:
│ │ │ │ │ + assert isinstance(transport, asyncio.Transport)
│ │ │ │ │ + self._transport = transport
│ │ │ │ │
│ │ │ │ │ - # In any case (failure or success) make sure this is done.
│ │ │ │ │ - if not connect_task.done():
│ │ │ │ │ - connect_task.cancel()
│ │ │ │ │ + def _get_close_args(self) -> JsonObject:
│ │ │ │ │ + return {}
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + def connection_lost(self, exc: 'Exception | None') -> None:
│ │ │ │ │ + self.close(self._get_close_args())
│ │ │ │ │
│ │ │ │ │ - # Thaw the queued messages
│ │ │ │ │ - self.thaw_endpoint()
│ │ │ │ │ + def do_data(self, data: bytes) -> None:
│ │ │ │ │ + assert self._transport is not None
│ │ │ │ │ + self._transport.write(data)
│ │ │ │ │
│ │ │ │ │ - return init_message
│ │ │ │ │ + def do_done(self) -> None:
│ │ │ │ │ + assert self._transport is not None
│ │ │ │ │ + if self._transport.can_write_eof():
│ │ │ │ │ + self._transport.write_eof()
│ │ │ │ │
│ │ │ │ │ - # 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
│ │ │ │ │ + def do_close(self) -> None:
│ │ │ │ │ + if self._transport is not None:
│ │ │ │ │ + self._transport.close()
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - task.result()
│ │ │ │ │ - except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError):
│ │ │ │ │ - pass # Those are expected. Others will throw.
│ │ │ │ │ + def data_received(self, data: bytes) -> None:
│ │ │ │ │ + assert self._transport is not None
│ │ │ │ │ + if not self.send_data(data):
│ │ │ │ │ + self._transport.pause_reading()
│ │ │ │ │
│ │ │ │ │ - start_task = asyncio.create_task(self.start(init_host, **kwargs))
│ │ │ │ │ - start_task.add_done_callback(_start_task_done)
│ │ │ │ │ + def do_resume_send(self) -> None:
│ │ │ │ │ + assert self._transport is not None
│ │ │ │ │ + self._transport.resume_reading()
│ │ │ │ │
│ │ │ │ │ - # Shutdown
│ │ │ │ │ - def add_done_callback(self, callback: Callable[[], None]) -> None:
│ │ │ │ │ - self.done_callbacks.append(callback)
│ │ │ │ │ + def close_on_eof(self) -> None:
│ │ │ │ │ + """Mark the channel to be closed on EOF.
│ │ │ │ │
│ │ │ │ │ - # Handling of interesting events
│ │ │ │ │ - def do_superuser_init_done(self) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + 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.
│ │ │ │ │
│ │ │ │ │ - def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + If EOF was already received, then calling this function will close the
│ │ │ │ │ + channel immediately.
│ │ │ │ │
│ │ │ │ │ - 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}')
│ │ │ │ │ + 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:
│ │ │ │ │ - # 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)
│ │ │ │ │ + self._eof = True
│ │ │ │ │ + self.done()
│ │ │ │ │ + return not self._close_on_eof
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + # Channel receive-side flow control
│ │ │ │ │ + def do_ping(self, message):
│ │ │ │ │ + if self._send_pongs:
│ │ │ │ │ + self.send_pong(message)
│ │ │ │ │ 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
│ │ │ │ │ + # we'll have to pong later
│ │ │ │ │ + self._last_ping = message
│ │ │ │ │
│ │ │ │ │ - # 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)
│ │ │ │ │ + def pause_writing(self) -> None:
│ │ │ │ │ + # We can't actually stop writing, but we can stop replying to pings
│ │ │ │ │ + self._send_pongs = False
│ │ │ │ │
│ │ │ │ │ - self.rules.append(rule)
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - # 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/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 .
│ │ │ │ │ +class AsyncChannel(Channel):
│ │ │ │ │ + """A subclass for async/await-style implementation of channels, with flow control
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import glob
│ │ │ │ │ -import grp
│ │ │ │ │ -import json
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import pwd
│ │ │ │ │ -from pathlib import Path
│ │ │ │ │ -from typing import Dict, Optional
│ │ │ │ │ + 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.
│ │ │ │ │
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch
│ │ │ │ │ + The subclass must provide an async `run()` function, which will be spawned
│ │ │ │ │ + as a task. The task is cancelled when the channel is closed.
│ │ │ │ │
│ │ │ │ │ -from . import config
│ │ │ │ │ + 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()`.
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ + 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. EOF is None. This is a buffer: since we
│ │ │ │ │ + # need to handle do_data() without blocking, we have no choice.
│ │ │ │ │ + receive_queue: 'asyncio.Queue[bytes | JsonObject | None]'
│ │ │ │ │ + loop: asyncio.AbstractEventLoop
│ │ │ │ │
│ │ │ │ │ -class cockpit_LoginMessages(bus.Object):
│ │ │ │ │ - messages: Optional[str] = None
│ │ │ │ │ + # Send-side flow control
│ │ │ │ │ + write_waiter = 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
│ │ │ │ │ + async def run(self, options: JsonObject) -> 'JsonObject | None':
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ - logger.debug("Trying to read login messages from fd %s", fdstr)
│ │ │ │ │ + async def run_wrapper(self, options: JsonObject) -> None:
│ │ │ │ │ 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
│ │ │ │ │ + self.loop = asyncio.get_running_loop()
│ │ │ │ │ + self.close(await self.run(options))
│ │ │ │ │ + except asyncio.CancelledError: # user requested close
│ │ │ │ │ + self.close()
│ │ │ │ │ + except ChannelError as exc:
│ │ │ │ │ + self.close(exc.get_attrs())
│ │ │ │ │ + except BaseException:
│ │ │ │ │ + self.close({'problem': 'internal-error', 'cause': traceback.format_exc()})
│ │ │ │ │ + raise
│ │ │ │ │
│ │ │ │ │ - @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 = {}
│ │ │ │ │ + async def read(self) -> 'bytes | None':
│ │ │ │ │ + # Three possibilities for what we'll find:
│ │ │ │ │ + # - None (EOF) → return None
│ │ │ │ │ + # - a ping → send a pong
│ │ │ │ │ + # - bytes (possibly empty) → ack the receipt, and return it
│ │ │ │ │ + while True:
│ │ │ │ │ + item = await self.receive_queue.get()
│ │ │ │ │ + if item is None:
│ │ │ │ │ + return None
│ │ │ │ │ + if isinstance(item, Mapping):
│ │ │ │ │ + self.send_pong(item)
│ │ │ │ │ + else:
│ │ │ │ │ + self.send_ack(item)
│ │ │ │ │ + return item
│ │ │ │ │
│ │ │ │ │ - contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()})
│ │ │ │ │ + async def write(self, data: bytes) -> None:
│ │ │ │ │ + if not self.send_data(data):
│ │ │ │ │ + self.write_waiter = self.loop.create_future()
│ │ │ │ │ + await self.write_waiter
│ │ │ │ │
│ │ │ │ │ - self.path.mkdir(parents=True, exist_ok=True)
│ │ │ │ │ - with open(self.path.joinpath(filename), 'w') as fp:
│ │ │ │ │ - json.dump(contents, fp, indent=2)
│ │ │ │ │ + async def in_thread(self, fn: 'Callable[_P, _T]', *args: '_P.args', **kwargs: '_P.kwargs') -> '_T':
│ │ │ │ │ + return await self.loop.run_in_executor(None, fn, *args, **kwargs)
│ │ │ │ │
│ │ │ │ │ - def notify(self):
│ │ │ │ │ - def _notify_now():
│ │ │ │ │ - self.properties_changed('cockpit.Machines', {}, ['Machines'])
│ │ │ │ │ - self.pending_notify = None
│ │ │ │ │ + async def sendfile(self, stream: BinaryIO) -> None:
│ │ │ │ │ + with stream:
│ │ │ │ │ + while True:
│ │ │ │ │ + data = await self.loop.run_in_executor(None, stream.read, Channel.BLOCK_SIZE)
│ │ │ │ │ + if data == b'':
│ │ │ │ │ + break
│ │ │ │ │ + await self.write(data)
│ │ │ │ │
│ │ │ │ │ - # 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)
│ │ │ │ │ + self.done()
│ │ │ │ │
│ │ │ │ │ - # inotify events
│ │ │ │ │ - def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None:
│ │ │ │ │ - self.notify()
│ │ │ │ │ + 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_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None:
│ │ │ │ │ - self.notify()
│ │ │ │ │ + def do_open(self, options: JsonObject) -> None:
│ │ │ │ │ + self.receive_queue = asyncio.Queue()
│ │ │ │ │ + self._run_task = self.create_task(self.run_wrapper(options),
│ │ │ │ │ + name=f'{self.__class__.__name__}.run_wrapper({options})')
│ │ │ │ │
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - self.path = config.lookup_config('machines.d')
│ │ │ │ │ + def do_done(self) -> None:
│ │ │ │ │ + self.receive_queue.put_nowait(None)
│ │ │ │ │
│ │ │ │ │ - # ignore the first callback
│ │ │ │ │ - self.pending_notify = ...
│ │ │ │ │ - self.watch = pathwatch.PathWatch(str(self.path), self)
│ │ │ │ │ - self.pending_notify = None
│ │ │ │ │ + def do_close(self) -> None:
│ │ │ │ │ + self._run_task.cancel()
│ │ │ │ │
│ │ │ │ │ + def do_ping(self, message: JsonObject) -> None:
│ │ │ │ │ + self.receive_queue.put_nowait(message)
│ │ │ │ │
│ │ │ │ │ -class cockpit_User(bus.Object):
│ │ │ │ │ - name = bus.Interface.Property('s', value='')
│ │ │ │ │ - full = bus.Interface.Property('s', value='')
│ │ │ │ │ - id = bus.Interface.Property('i', value=0)
│ │ │ │ │ - gid = bus.Interface.Property('i', value=0)
│ │ │ │ │ - home = bus.Interface.Property('s', value='')
│ │ │ │ │ - shell = bus.Interface.Property('s', value='')
│ │ │ │ │ - groups = bus.Interface.Property('as', value=[])
│ │ │ │ │ + def do_data(self, data: bytes) -> bool:
│ │ │ │ │ + self.receive_queue.put_nowait(data)
│ │ │ │ │ + return True # we will send the 'ack' later (from read())
│ │ │ │ │
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - user = pwd.getpwuid(os.getuid())
│ │ │ │ │ - self.name = user.pw_name
│ │ │ │ │ - self.full = user.pw_gecos
│ │ │ │ │ - self.id = user.pw_uid
│ │ │ │ │ - self.gid = user.pw_gid
│ │ │ │ │ - self.home = user.pw_dir
│ │ │ │ │ - self.shell = user.pw_shell
│ │ │ │ │
│ │ │ │ │ - # We want the primary group first in the list, without duplicates.
│ │ │ │ │ - # This is a bit awkward because `set()` is unordered...
│ │ │ │ │ - groups = [grp.getgrgid(user.pw_gid).gr_name]
│ │ │ │ │ - for gr in grp.getgrall():
│ │ │ │ │ - if user.pw_name in gr.gr_mem and gr.gr_name not in groups:
│ │ │ │ │ - groups.append(gr.gr_name)
│ │ │ │ │ - self.groups = groups
│ │ │ │ │ +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.
│ │ │ │ │ + """
│ │ │ │ │ + __generator: 'Generator[bytes, None, JsonObject]'
│ │ │ │ │
│ │ │ │ │ -EXPORTS = [
│ │ │ │ │ - ('/LoginMessages', cockpit_LoginMessages),
│ │ │ │ │ - ('/machines', cockpit_Machines),
│ │ │ │ │ - ('/user', cockpit_User),
│ │ │ │ │ -]
│ │ │ │ │ -''',
│ │ │ │ │ + def do_yield_data(self, options: JsonObject) -> 'Generator[bytes, None, JsonObject]':
│ │ │ │ │ + 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)
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ 'cockpit/packages.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -3573,15 +5009,79 @@
│ │ │ │ │ elif filename == 'manifests.js':
│ │ │ │ │ return self.load_manifests_js(headers)
│ │ │ │ │ elif filename == 'manifests.json':
│ │ │ │ │ return self.load_manifests_json()
│ │ │ │ │ else:
│ │ │ │ │ raise KeyError
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/channel.py': r'''# This file is part of Cockpit.
│ │ │ │ │ + '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@@
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAYAAAA6/NlyAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAN1wAADdcBQiibeAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAWGSURBVGiBzZtNaJxFGMd/z2R3YxHbtZa2pqdcPInS9CCUov3QiuhF2hIUjOZrd5NA9VoPuiJSTx6S0s27SVxJ8aOpPSgqpbZVlIIIJirqtRdNpBU3FUqhm53HQzZpPjfvm51J8oeFZd5n5v/8eWbemXfmGcETBgcHt1prm1S1CWgSkUZV3QIkgS0Vs5vAZOV3DRhV1VFr7Wh3d3fRh1/isrFcLrerrq7uKHBMVffW2P4fqjocj8fPtLe3jzty0Y3gfD7/pLX2hIjsB4yLNufAAleste90dXV9W2tjNQkOgmCfqr4lIgdrdSQMROSqtfbNTCZzedVtrKbSwMDAjnK5nBOR51dLXAtU9Yt4PJ5eTVePLDgIgmPAaWBb1LqOURSRV1Op1JkolUILHhkZSRSLxUHgpciu+cX7QCadTpfCGIcSPDQ0dF+pVDonIk/X5Jo/XAaOpNPpmysZrig4l8vtMsZcAB524ZlH/AI8k06nJ6oZVZ1Cent7NxtjvmTjiwV4FLhYKBSS1YyWjXBlzH4FHKrRkY82bdqUamlpuVXNaHh4+N7bt2/ngRdr5LsIPLfcmF42wsVisZ/axaKqV1YSC9DS0nJLVa/UygccZnoWWRJLCu7v728GWh2QIyKJCLb3uOAEOirT5yIsEhwEwTYR6XVEDFAfwdaVYIDTAwMDOxYWLhIsIjlgu0Pi0CKi9IYQ2Gat7VtYOE9wZW181CFpJBHWWpcRBjiWz+efmFswT7CIvO2YEFUN3aWNMVG6fyhYa9+YxzHzJwiCA6q63zUhEcawhwgjIgfnRnluhF93TVZBaBE+IgxgrT0xywEQBMGDwAEfZKq6nmMYABF5amhoqAHuRrgZqPNBFiVqviIMmFKpdAQqglW12RNRpJeWrwgDGGOaAczg4OBWEXnMF1GU1ZPHCKOqewuFQtJYa5twvHu5gCj0GI7SG1YBuXPnzm5jrd3jkSSSCIdr6eXa32NEZLdnktAiPEcYVd1tRKTRJwnr9/GwFBqNqlbdIXCAKIK9Rhi43zB91uMTGynCScPdgy1f2HARVs8kG0mwNUwfVfrERurSk4bpM1qfCCVYVQVwueOxFCY3TIT7+voSeFzxVVA0TJ+8+4TJZrOxEHa+xy+qes0Ao76Jdu7cueLiJpFIPOTbDxEZi6nqqIjfniQiP+bz+Z9VdWoZkzjQ5NWJaYzGYrHYWLlcVvyOn6Sn/bIo0EQiMWY6Ojr+BX5YZ2fWAldbW1snDYCInF1vb9YAZwFiAOVy+VNjzHu4z8ABQETax8fHP8hms3ap56oq+Xz+BeBDH/xAGTgPFYFdXV1/Ad94IqOaWAAR0YmJiU988QOXZw7KZyMqIu/6YqsmNorNamGtPTnzf1ZwKpW6BHzni3S9ICJX5ya0eT9bWm+IyNJnSzAb5c/X1COPEJHPOjs752UVLHorx+PxDuD6mnnlD/+USqXMwsJFgtva2m6o6mtr45M/iEhPT0/P3wvLl5x3M5nMxyIy7Io8m82uOL+HsYmAQiqVGlnqwbIkyWSy01FWDQ0NDa9UE6Sq0tDQ4Crz4BKQXu5h1Q+G3t7ezfX19d8Djzhyxjd+TyQS+1pbW5fd1KjajY4fP/6fMeZZ4DfnrrnHr7FY7HA1sRBi7dzZ2flnLBbbC1xw5pp7XAIeD5M/HTV9OA+8XItnHjAIdDtNH56LDZQgfl1EulOp1PkolVa1y5HL5baLSP96XQEAzsXj8Z62trYbUSvWfMkDyOIgCTUM1u2Sx0IEQXBARE6o6iHcbyKUga9V9WQmk6n5a87pxt2pU6ceiMfjR0SkxcFFrZ9E5IyqjqyU5R4F3nYqC4VCcmpqavYqnrW2UUSSTB/PzhzRzlzDK1K5igeMJhKJsZXm09XifyIP5eSF+BKpAAAAAElFTkSuQmC)
│ │ │ │ │ +
@@message@@
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -3591,605 +5091,542 @@
│ │ │ │ │ # 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 codecs
│ │ │ │ │ import json
│ │ │ │ │ import logging
│ │ │ │ │ -import traceback
│ │ │ │ │ -import typing
│ │ │ │ │ -from typing import BinaryIO, Callable, ClassVar, Collection, Generator, Mapping, Sequence, Type
│ │ │ │ │ +import sys
│ │ │ │ │ +import time
│ │ │ │ │ +from collections import defaultdict
│ │ │ │ │ +from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union
│ │ │ │ │
│ │ │ │ │ -from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_bool, get_enum, get_str
│ │ │ │ │ -from .protocol import CockpitProblem
│ │ │ │ │ -from .router import Endpoint, Router, RoutingRule
│ │ │ │ │ +from ..channel import AsyncChannel, ChannelError
│ │ │ │ │ +from ..jsonutil import JsonList
│ │ │ │ │ +from ..samples import SAMPLERS, SampleDescription, Sampler, Samples
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -if typing.TYPE_CHECKING:
│ │ │ │ │ - _T = typing.TypeVar('_T')
│ │ │ │ │ - _P = typing.ParamSpec("_P")
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class ChannelRoutingRule(RoutingRule):
│ │ │ │ │ - table: 'dict[str, list[Type[Channel]]]'
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, router: Router, channel_types: 'Collection[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: 'Collection[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) -> 'Channel | None':
│ │ │ │ │ - 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 MetricInfo(NamedTuple):
│ │ │ │ │ + derive: Optional[str]
│ │ │ │ │ + desc: SampleDescription
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class ChannelError(CockpitProblem):
│ │ │ │ │ - pass
│ │ │ │ │ +class InternalMetricsChannel(AsyncChannel):
│ │ │ │ │ + payload = 'metrics1'
│ │ │ │ │ + restrictions = [('source', 'internal')]
│ │ │ │ │
│ │ │ │ │ + metrics: List[MetricInfo]
│ │ │ │ │ + samplers: Set
│ │ │ │ │ + samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None
│ │ │ │ │
│ │ │ │ │ -class Channel(Endpoint):
│ │ │ │ │ - # Values borrowed from C implementation
│ │ │ │ │ - BLOCK_SIZE = 16 * 1024
│ │ │ │ │ - SEND_WINDOW = 2 * 1024 * 1024
│ │ │ │ │ + interval: int = 1000
│ │ │ │ │ + need_meta: bool = True
│ │ │ │ │ + last_timestamp: float = 0
│ │ │ │ │ + next_timestamp: float = 0
│ │ │ │ │
│ │ │ │ │ - # Flow control book-keeping
│ │ │ │ │ - _send_pings: bool = False
│ │ │ │ │ - _out_sequence: int = 0
│ │ │ │ │ - _out_window: int = SEND_WINDOW
│ │ │ │ │ - _ack_bytes: bool
│ │ │ │ │ + @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}
│ │ │ │ │
│ │ │ │ │ - # Task management
│ │ │ │ │ - _tasks: 'set[asyncio.Task]'
│ │ │ │ │ - _close_args: 'JsonObject | None' = None
│ │ │ │ │ + def parse_options(self, options):
│ │ │ │ │ + logger.debug('metrics internal open: %s, channel: %s', options, self.channel)
│ │ │ │ │
│ │ │ │ │ - # Must be filled in by the channel implementation
│ │ │ │ │ - payload: 'ClassVar[str]'
│ │ │ │ │ - restrictions: 'ClassVar[Sequence[tuple[str, object]]]' = ()
│ │ │ │ │ + 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}')
│ │ │ │ │
│ │ │ │ │ - # These get filled in from .do_open()
│ │ │ │ │ - channel = ''
│ │ │ │ │ - group = ''
│ │ │ │ │ - is_binary: bool
│ │ │ │ │ - decoder: 'codecs.IncrementalDecoder | None'
│ │ │ │ │ + self.interval = interval
│ │ │ │ │
│ │ │ │ │ - # input
│ │ │ │ │ - def do_control(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ - # 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 = get_str(message, 'channel')
│ │ │ │ │ - if get_bool(message, 'flow-control', default=False):
│ │ │ │ │ - self._send_pings = True
│ │ │ │ │ - self._ack_bytes = get_enum(message, 'send-acks', ['bytes'], None) is not None
│ │ │ │ │ - self.group = get_str(message, 'group', 'default')
│ │ │ │ │ - self.is_binary = get_enum(message, 'binary', ['raw'], None) is not None
│ │ │ │ │ - self.decoder = None
│ │ │ │ │ - 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)
│ │ │ │ │ + metrics = options.get('metrics')
│ │ │ │ │ + if not isinstance(metrics, list) or len(metrics) == 0:
│ │ │ │ │ + logger.error('invalid "metrics" value: %s', metrics)
│ │ │ │ │ + raise ChannelError('protocol-error', message='invalid "metrics" option was specified (not an array)')
│ │ │ │ │
│ │ │ │ │ - def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ - # Already closing? Ignore.
│ │ │ │ │ - if self._close_args is not None:
│ │ │ │ │ - return
│ │ │ │ │ + sampler_classes = set()
│ │ │ │ │ + for metric in metrics:
│ │ │ │ │ + # validate it's an object
│ │ │ │ │ + name = metric.get('name')
│ │ │ │ │ + units = metric.get('units')
│ │ │ │ │ + derive = metric.get('derive')
│ │ │ │ │
│ │ │ │ │ - # 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.get_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
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - # ... but many subclasses may reasonably want to ignore some of these.
│ │ │ │ │ - def do_ready(self) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + if units and units != desc.units:
│ │ │ │ │ + raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}')
│ │ │ │ │
│ │ │ │ │ - def do_done(self) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + sampler_classes.add(sampler)
│ │ │ │ │ + self.metrics.append(MetricInfo(derive=derive, desc=desc))
│ │ │ │ │
│ │ │ │ │ - def do_close(self) -> None:
│ │ │ │ │ - self.close()
│ │ │ │ │ + self.samplers = {cls() for cls in sampler_classes}
│ │ │ │ │
│ │ │ │ │ - def do_options(self, message: JsonObject) -> None:
│ │ │ │ │ - raise ChannelError('not-supported', message='This channel does not implement "options"')
│ │ │ │ │ + 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
│ │ │ │ │ + })
│ │ │ │ │
│ │ │ │ │ - # 'reasonable' default, overridden in other channels for receive-side flow control
│ │ │ │ │ - def do_ping(self, message: JsonObject) -> None:
│ │ │ │ │ - self.send_pong(message)
│ │ │ │ │ + self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics)
│ │ │ │ │ + self.need_meta = False
│ │ │ │ │
│ │ │ │ │ - def send_ack(self, data: bytes) -> None:
│ │ │ │ │ - if self._ack_bytes:
│ │ │ │ │ - self.send_control('ack', bytes=len(data))
│ │ │ │ │ + def sample(self):
│ │ │ │ │ + samples = defaultdict(dict)
│ │ │ │ │ + for sampler in self.samplers:
│ │ │ │ │ + sampler.sample(samples)
│ │ │ │ │ + return samples
│ │ │ │ │
│ │ │ │ │ - def do_channel_data(self, channel: str, data: bytes) -> None:
│ │ │ │ │ - # Already closing? Ignore.
│ │ │ │ │ - if self._close_args is not None:
│ │ │ │ │ - return
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - # Catch errors and turn them into close messages
│ │ │ │ │ - try:
│ │ │ │ │ - if not self.do_data(data):
│ │ │ │ │ - self.send_ack(data)
│ │ │ │ │ - except ChannelError as exc:
│ │ │ │ │ - self.close(exc.get_attrs())
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - def do_data(self, data: bytes) -> 'bool | None':
│ │ │ │ │ - """Handles incoming data to the channel.
│ │ │ │ │ + for metricinfo in self.metrics:
│ │ │ │ │ + value = samples[metricinfo.desc.name]
│ │ │ │ │
│ │ │ │ │ - Return value is True if the channel takes care of send acks on its own,
│ │ │ │ │ - in which case it should call self.send_ack() on `data` at some point.
│ │ │ │ │ - None or False means that the acknowledgement is sent automatically."""
│ │ │ │ │ - # By default, channels can't receive data.
│ │ │ │ │ - del data
│ │ │ │ │ - self.close()
│ │ │ │ │ - return True
│ │ │ │ │ + if metricinfo.desc.instanced:
│ │ │ │ │ + old_value = last_samples[metricinfo.desc.name]
│ │ │ │ │ + assert isinstance(value, dict)
│ │ │ │ │ + assert isinstance(old_value, dict)
│ │ │ │ │
│ │ │ │ │ - # output
│ │ │ │ │ - def ready(self, **kwargs: JsonValue) -> None:
│ │ │ │ │ - self.thaw_endpoint()
│ │ │ │ │ - self.send_control(command='ready', **kwargs)
│ │ │ │ │ + # If we have less or more keys the data changed, send a meta message.
│ │ │ │ │ + if value.keys() != old_value.keys():
│ │ │ │ │ + self.need_meta = True
│ │ │ │ │
│ │ │ │ │ - def __decode_frame(self, data: bytes, *, final: bool = False) -> str:
│ │ │ │ │ - assert self.decoder is not None
│ │ │ │ │ - try:
│ │ │ │ │ - return self.decoder.decode(data, final=final)
│ │ │ │ │ - except UnicodeDecodeError as exc:
│ │ │ │ │ - raise ChannelError('protocol-error', message=str(exc)) from exc
│ │ │ │ │ + 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 done(self) -> None:
│ │ │ │ │ - # any residue from partial send_data() frames? this is invalid, fail the channel
│ │ │ │ │ - if self.decoder is not None:
│ │ │ │ │ - self.__decode_frame(b'', final=True)
│ │ │ │ │ - self.send_control(command='done')
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - # tasks and close management
│ │ │ │ │ - def is_closing(self) -> bool:
│ │ │ │ │ - return self._close_args is not None
│ │ │ │ │ + if metricinfo.derive == 'rate':
│ │ │ │ │ + data.append(self.calculate_sample_rate(value, old_value))
│ │ │ │ │ + else:
│ │ │ │ │ + data.append(value)
│ │ │ │ │
│ │ │ │ │ - def _close_now(self) -> None:
│ │ │ │ │ - self.shutdown_endpoint(self._close_args)
│ │ │ │ │ + if self.need_meta:
│ │ │ │ │ + self.send_meta(samples, timestamp)
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + self.last_timestamp = self.next_timestamp
│ │ │ │ │ + self.send_text(json.dumps([data]))
│ │ │ │ │
│ │ │ │ │ - def create_task(self, coroutine, name=None):
│ │ │ │ │ - """Create a task associated with the channel.
│ │ │ │ │ + async def run(self, options):
│ │ │ │ │ + self.metrics = []
│ │ │ │ │ + self.samplers = set()
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + InternalMetricsChannel.ensure_samplers()
│ │ │ │ │
│ │ │ │ │ - def close(self, close_args: 'JsonObject | None' = None) -> None:
│ │ │ │ │ - """Requests the channel to be closed.
│ │ │ │ │ + self.parse_options(options)
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - After you call this method, you won't get anymore `.do_*()` calls.
│ │ │ │ │ + last_samples = defaultdict(dict)
│ │ │ │ │ + while True:
│ │ │ │ │ + samples = self.sample()
│ │ │ │ │ + self.send_updates(samples, last_samples)
│ │ │ │ │ + last_samples = samples
│ │ │ │ │ + await asyncio.sleep(self.interval / 1000)
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/channels/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 .
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ +import logging
│ │ │ │ │
│ │ │ │ │ - def send_bytes(self, data: bytes) -> bool:
│ │ │ │ │ - """Send binary data and handle book-keeping for flow control.
│ │ │ │ │ +from ..channel import Channel
│ │ │ │ │
│ │ │ │ │ - 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.
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ - 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.
│ │ │ │ │
│ │ │ │ │ - Be careful with text channels (i.e. without binary="raw"): you are responsible
│ │ │ │ │ - for ensuring that @data is valid UTF-8. This isn't validated here for
│ │ │ │ │ - efficiency reasons.
│ │ │ │ │ - """
│ │ │ │ │ - self.send_channel_data(self.channel, data)
│ │ │ │ │ +class EchoChannel(Channel):
│ │ │ │ │ + payload = 'echo'
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + def do_open(self, options):
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - return self._out_sequence < self._out_window
│ │ │ │ │ + def do_data(self, data: bytes) -> None:
│ │ │ │ │ + self.send_bytes(data)
│ │ │ │ │
│ │ │ │ │ - def send_data(self, data: bytes) -> bool:
│ │ │ │ │ - """Send data and transparently handle UTF-8 for text channels
│ │ │ │ │ + def do_done(self):
│ │ │ │ │ + self.done()
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - Use this for channels which can be text, but are not guaranteed to get
│ │ │ │ │ - valid UTF-8 frames -- i.e. multi-byte characters may be split across
│ │ │ │ │ - frames. This is expensive, so prefer send_text() or send_bytes() wherever
│ │ │ │ │ - possible.
│ │ │ │ │ - """
│ │ │ │ │ - if self.is_binary:
│ │ │ │ │ - return self.send_bytes(data)
│ │ │ │ │
│ │ │ │ │ - # for text channels we must avoid splitting UTF-8 multi-byte characters,
│ │ │ │ │ - # as these can't be sent to a WebSocket (and are often confusing for text streams as well)
│ │ │ │ │ - if self.decoder is None:
│ │ │ │ │ - self.decoder = codecs.getincrementaldecoder('utf-8')(errors='strict')
│ │ │ │ │ - return self.send_text(self.__decode_frame(data))
│ │ │ │ │ +class NullChannel(Channel):
│ │ │ │ │ + payload = 'null'
│ │ │ │ │
│ │ │ │ │ - def send_text(self, data: str) -> bool:
│ │ │ │ │ - """Send UTF-8 string data and handle book-keeping for flow control.
│ │ │ │ │ + def do_open(self, options):
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - Similar to `send_bytes`, but for text data. The data is sent as UTF-8 encoded bytes.
│ │ │ │ │ - """
│ │ │ │ │ - return self.send_bytes(data.encode())
│ │ │ │ │ + def do_close(self):
│ │ │ │ │ + self.close()
│ │ │ │ │ +''',
│ │ │ │ │ + '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 .
│ │ │ │ │
│ │ │ │ │ - def send_json(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> bool:
│ │ │ │ │ - pretty = self.json_encoder.encode(create_object(_msg, kwargs)) + '\n'
│ │ │ │ │ - return self.send_text(pretty)
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import subprocess
│ │ │ │ │ +from typing import Dict
│ │ │ │ │
│ │ │ │ │ - def do_pong(self, message):
│ │ │ │ │ - if not self._send_pings: # huh?
│ │ │ │ │ - logger.warning("Got wild pong on channel %s", self.channel)
│ │ │ │ │ - return
│ │ │ │ │ +from ..channel import ChannelError, ProtocolChannel
│ │ │ │ │ +from ..jsonutil import JsonDict, JsonObject, get_bool, get_enum, get_int, get_object, get_str, get_strv
│ │ │ │ │ +from ..transports import SubprocessProtocol, SubprocessTransport, WindowSize
│ │ │ │ │
│ │ │ │ │ - self._out_window = message['sequence'] + Channel.SEND_WINDOW
│ │ │ │ │ - if self._out_sequence < self._out_window:
│ │ │ │ │ - self.do_resume_send()
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ +class SocketStreamChannel(ProtocolChannel):
│ │ │ │ │ + payload = 'stream'
│ │ │ │ │
│ │ │ │ │ - def send_control(self, command: str, **kwargs: JsonValue) -> None:
│ │ │ │ │ - self.send_channel_control(self.channel, command, None, **kwargs)
│ │ │ │ │ + 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')
│ │ │ │ │
│ │ │ │ │ - def send_pong(self, message: JsonObject) -> None:
│ │ │ │ │ - self.send_channel_control(self.channel, 'pong', message)
│ │ │ │ │ + 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 ProtocolChannel(Channel, asyncio.Protocol):
│ │ │ │ │ - """A channel subclass that implements the asyncio Protocol interface.
│ │ │ │ │ + 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')
│ │ │ │ │
│ │ │ │ │ - In effect, data sent to this channel will be written to the connected
│ │ │ │ │ - transport, and vice-versa. Flow control is supported.
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - 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: 'asyncio.Transport | None'
│ │ │ │ │ - _send_pongs: bool = True
│ │ │ │ │ - _last_ping: 'JsonObject | None' = None
│ │ │ │ │ - _create_transport_task: 'asyncio.Task[asyncio.Transport] | None' = None
│ │ │ │ │ +class SubprocessStreamChannel(ProtocolChannel, SubprocessProtocol):
│ │ │ │ │ + payload = 'stream'
│ │ │ │ │ + restrictions = (('spawn', None),)
│ │ │ │ │
│ │ │ │ │ - # read-side EOF handling
│ │ │ │ │ - _close_on_eof: bool = False
│ │ │ │ │ - _eof: bool = False
│ │ │ │ │ + def process_exited(self) -> None:
│ │ │ │ │ + self.close_on_eof()
│ │ │ │ │
│ │ │ │ │ - async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport:
│ │ │ │ │ - """Creates the transport for this channel, according to options.
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - The event loop for the transport is passed to the function. The
│ │ │ │ │ - protocol for the transport is the channel object, itself (self).
│ │ │ │ │ + def do_options(self, options):
│ │ │ │ │ + window = get_object(options, 'window', WindowSize, None)
│ │ │ │ │ + if window is not None:
│ │ │ │ │ + self._transport.set_window_size(window)
│ │ │ │ │
│ │ │ │ │ - This needs to be implemented by the subclass.
│ │ │ │ │ - """
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> SubprocessTransport:
│ │ │ │ │ + args = get_strv(options, 'spawn')
│ │ │ │ │ + err = get_enum(options, 'err', ['out', 'ignore', 'message'], 'message')
│ │ │ │ │ + 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 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)
│ │ │ │ │ + if err == 'out':
│ │ │ │ │ + stderr = subprocess.STDOUT
│ │ │ │ │ + elif err == 'ignore':
│ │ │ │ │ + stderr = subprocess.DEVNULL
│ │ │ │ │ + else:
│ │ │ │ │ + stderr = subprocess.PIPE
│ │ │ │ │
│ │ │ │ │ - def create_transport_done(self, task: 'asyncio.Task[asyncio.Transport]') -> None:
│ │ │ │ │ - assert task is self._create_transport_task
│ │ │ │ │ - self._create_transport_task = None
│ │ │ │ │ + env: Dict[str, str] = dict(os.environ)
│ │ │ │ │ try:
│ │ │ │ │ - transport = task.result()
│ │ │ │ │ - except ChannelError as exc:
│ │ │ │ │ - self.close(exc.get_attrs())
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - self.connection_made(transport)
│ │ │ │ │ - self.ready()
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - def connection_made(self, transport: asyncio.BaseTransport) -> None:
│ │ │ │ │ - assert isinstance(transport, asyncio.Transport)
│ │ │ │ │ - self._transport = transport
│ │ │ │ │ + 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 .
│ │ │ │ │
│ │ │ │ │ - def _get_close_args(self) -> JsonObject:
│ │ │ │ │ - return {}
│ │ │ │ │ +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
│ │ │ │ │
│ │ │ │ │ - def connection_lost(self, exc: 'Exception | None') -> None:
│ │ │ │ │ - self.close(self._get_close_args())
│ │ │ │ │ +CHANNEL_TYPES = [
│ │ │ │ │ + DBusChannel,
│ │ │ │ │ + EchoChannel,
│ │ │ │ │ + FsInfoChannel,
│ │ │ │ │ + FsListChannel,
│ │ │ │ │ + FsReadChannel,
│ │ │ │ │ + FsReplaceChannel,
│ │ │ │ │ + FsWatchChannel,
│ │ │ │ │ + HttpChannel,
│ │ │ │ │ + InternalMetricsChannel,
│ │ │ │ │ + NullChannel,
│ │ │ │ │ + PackagesChannel,
│ │ │ │ │ + SubprocessStreamChannel,
│ │ │ │ │ + SocketStreamChannel,
│ │ │ │ │ +]
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/channels/http.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (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_data(self, data: bytes) -> None:
│ │ │ │ │ - assert self._transport is not None
│ │ │ │ │ - self._transport.write(data)
│ │ │ │ │ +import http.client
│ │ │ │ │ +import logging
│ │ │ │ │ +import socket
│ │ │ │ │ +import ssl
│ │ │ │ │
│ │ │ │ │ - def do_done(self) -> None:
│ │ │ │ │ - assert self._transport is not None
│ │ │ │ │ - if self._transport.can_write_eof():
│ │ │ │ │ - self._transport.write_eof()
│ │ │ │ │ +from ..channel import AsyncChannel, ChannelError
│ │ │ │ │ +from ..jsonutil import JsonObject, get_dict, get_int, get_object, get_str, typechecked
│ │ │ │ │
│ │ │ │ │ - def do_close(self) -> None:
│ │ │ │ │ - if self._transport is not None:
│ │ │ │ │ - self._transport.close()
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ +class HttpChannel(AsyncChannel):
│ │ │ │ │ + payload = 'http-stream2'
│ │ │ │ │
│ │ │ │ │ - def close_on_eof(self) -> None:
│ │ │ │ │ - """Mark the channel to be closed on EOF.
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_headers(response: http.client.HTTPResponse, *, binary: bool) -> JsonObject:
│ │ │ │ │ + # Never send these headers
│ │ │ │ │ + remove = {'Connection', 'Transfer-Encoding'}
│ │ │ │ │
│ │ │ │ │ - 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 not binary:
│ │ │ │ │ + # Only send these headers for raw binary streams
│ │ │ │ │ + remove.update({'Content-Length', 'Range'})
│ │ │ │ │
│ │ │ │ │ - If EOF was already received, then calling this function will close the
│ │ │ │ │ - channel immediately.
│ │ │ │ │ + return {key: value for key, value in response.getheaders() if key not in remove}
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + @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 eof_received(self) -> bool:
│ │ │ │ │ - self._eof = True
│ │ │ │ │ - self.done()
│ │ │ │ │ - return not self._close_on_eof
│ │ │ │ │ + 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')
│ │ │ │ │
│ │ │ │ │ - # 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
│ │ │ │ │ + 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 pause_writing(self) -> None:
│ │ │ │ │ - # We can't actually stop writing, but we can stop replying to pings
│ │ │ │ │ - self._send_pongs = False
│ │ │ │ │ + if 'validate' in opt_tls and not opt_tls['validate']:
│ │ │ │ │ + context.check_hostname = False
│ │ │ │ │ + context.verify_mode = ssl.VerifyMode.CERT_NONE
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + # See https://github.com/python/typeshed/issues/11057
│ │ │ │ │ + return http.client.HTTPSConnection(opt_address, port=opt_port, context=context) # type: ignore[arg-type]
│ │ │ │ │
│ │ │ │ │ + else:
│ │ │ │ │ + return http.client.HTTPConnection(opt_address, port=opt_port)
│ │ │ │ │
│ │ │ │ │ -class AsyncChannel(Channel):
│ │ │ │ │ - """A subclass for async/await-style implementation of channels, with flow control
│ │ │ │ │ + @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()
│ │ │ │ │
│ │ │ │ │ - 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.
│ │ │ │ │ + @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()
│ │ │ │ │
│ │ │ │ │ - The subclass must provide an async `run()` function, which will be spawned
│ │ │ │ │ - as a task. The task is cancelled when the channel is closed.
│ │ │ │ │ + async def run(self, options: JsonObject) -> None:
│ │ │ │ │ + logger.debug('open %s', options)
│ │ │ │ │
│ │ │ │ │ - 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()`.
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - On the sending side, write() will block if the channel backs up.
│ │ │ │ │ - """
│ │ │ │ │ + if 'connection' in options:
│ │ │ │ │ + raise ChannelError('protocol-error', message='connection sharing is not implemented on this bridge')
│ │ │ │ │
│ │ │ │ │ - # Receive-side flow control: intermix pings and data in the queue and reply
│ │ │ │ │ - # to pings as we dequeue them. EOF is None. This is a buffer: since we
│ │ │ │ │ - # need to handle do_data() without blocking, we have no choice.
│ │ │ │ │ - receive_queue: 'asyncio.Queue[bytes | JsonObject | None]'
│ │ │ │ │ - loop: asyncio.AbstractEventLoop
│ │ │ │ │ + connection = self.create_client(options)
│ │ │ │ │
│ │ │ │ │ - # Send-side flow control
│ │ │ │ │ - write_waiter = None
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - async def run(self, options: JsonObject) -> 'JsonObject | None':
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + body = b''
│ │ │ │ │ + while True:
│ │ │ │ │ + data = await self.read()
│ │ │ │ │ + if data is None:
│ │ │ │ │ + break
│ │ │ │ │ + body += data
│ │ │ │ │
│ │ │ │ │ - async def run_wrapper(self, options: JsonObject) -> None:
│ │ │ │ │ + # Connect in a thread and handle errors
│ │ │ │ │ try:
│ │ │ │ │ - self.loop = asyncio.get_running_loop()
│ │ │ │ │ - self.close(await self.run(options))
│ │ │ │ │ - except asyncio.CancelledError: # user requested close
│ │ │ │ │ - self.close()
│ │ │ │ │ - except ChannelError as exc:
│ │ │ │ │ - self.close(exc.get_attrs())
│ │ │ │ │ - except BaseException:
│ │ │ │ │ - self.close({'problem': 'internal-error', 'cause': traceback.format_exc()})
│ │ │ │ │ - raise
│ │ │ │ │ -
│ │ │ │ │ - async def read(self) -> 'bytes | None':
│ │ │ │ │ - # Three possibilities for what we'll find:
│ │ │ │ │ - # - None (EOF) → return None
│ │ │ │ │ - # - a ping → send a pong
│ │ │ │ │ - # - bytes (possibly empty) → ack the receipt, and return it
│ │ │ │ │ - while True:
│ │ │ │ │ - item = await self.receive_queue.get()
│ │ │ │ │ - if item is None:
│ │ │ │ │ - return None
│ │ │ │ │ - if isinstance(item, Mapping):
│ │ │ │ │ - self.send_pong(item)
│ │ │ │ │ - else:
│ │ │ │ │ - self.send_ack(item)
│ │ │ │ │ - return item
│ │ │ │ │ + await self.in_thread(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
│ │ │ │ │
│ │ │ │ │ - async def write(self, data: bytes) -> None:
│ │ │ │ │ - if not self.send_data(data):
│ │ │ │ │ - self.write_waiter = self.loop.create_future()
│ │ │ │ │ - await self.write_waiter
│ │ │ │ │ + # Submit request in a thread and handle errors
│ │ │ │ │ + try:
│ │ │ │ │ + response = await self.in_thread(self.request, connection, method, path, headers or {}, body)
│ │ │ │ │ + except (http.client.HTTPException, OSError) as exc:
│ │ │ │ │ + raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ - async def in_thread(self, fn: 'Callable[_P, _T]', *args: '_P.args', **kwargs: '_P.kwargs') -> '_T':
│ │ │ │ │ - return await self.loop.run_in_executor(None, fn, *args, **kwargs)
│ │ │ │ │ + self.send_control(command='response',
│ │ │ │ │ + status=response.status,
│ │ │ │ │ + reason=response.reason,
│ │ │ │ │ + headers=self.get_headers(response, binary=self.is_binary))
│ │ │ │ │
│ │ │ │ │ - async def sendfile(self, stream: BinaryIO) -> None:
│ │ │ │ │ - with stream:
│ │ │ │ │ + # Receive the body and finish up
│ │ │ │ │ + try:
│ │ │ │ │ while True:
│ │ │ │ │ - data = await self.loop.run_in_executor(None, stream.read, Channel.BLOCK_SIZE)
│ │ │ │ │ - if data == b'':
│ │ │ │ │ + block = await self.in_thread(response.read1, self.BLOCK_SIZE)
│ │ │ │ │ + if not block:
│ │ │ │ │ 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: JsonObject) -> None:
│ │ │ │ │ - self.receive_queue = asyncio.Queue()
│ │ │ │ │ - self._run_task = self.create_task(self.run_wrapper(options),
│ │ │ │ │ - name=f'{self.__class__.__name__}.run_wrapper({options})')
│ │ │ │ │ -
│ │ │ │ │ - def do_done(self) -> None:
│ │ │ │ │ - self.receive_queue.put_nowait(None)
│ │ │ │ │ -
│ │ │ │ │ - def do_close(self) -> None:
│ │ │ │ │ - self._run_task.cancel()
│ │ │ │ │ -
│ │ │ │ │ - def do_ping(self, message: JsonObject) -> None:
│ │ │ │ │ - self.receive_queue.put_nowait(message)
│ │ │ │ │ -
│ │ │ │ │ - def do_data(self, data: bytes) -> bool:
│ │ │ │ │ - self.receive_queue.put_nowait(data)
│ │ │ │ │ - return True # we will send the 'ack' later (from read())
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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.
│ │ │ │ │ - """
│ │ │ │ │ - __generator: 'Generator[bytes, None, JsonObject]'
│ │ │ │ │ -
│ │ │ │ │ - def do_yield_data(self, options: JsonObject) -> 'Generator[bytes, None, JsonObject]':
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + await self.write(block)
│ │ │ │ │
│ │ │ │ │ - def do_open(self, options: JsonObject) -> None:
│ │ │ │ │ - self.__generator = self.do_yield_data(options)
│ │ │ │ │ - self.do_resume_send()
│ │ │ │ │ + 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''
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ - 'cockpit/__init__.py': br'''from ._version import __version__
│ │ │ │ │ + await self.in_thread(connection.close)
│ │ │ │ │ + except (http.client.HTTPException, OSError) as exc:
│ │ │ │ │ + raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │ -__all__ = (
│ │ │ │ │ - '__version__',
│ │ │ │ │ -)
│ │ │ │ │ + self.done()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/protocol.py': br'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit.
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -4198,256 +5635,519 @@
│ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ # GNU General Public License for more details.
│ │ │ │ │ #
│ │ │ │ │ # You should have received a copy of the GNU General Public License
│ │ │ │ │ # along with this program. If not, see .
│ │ │ │ │
│ │ │ │ │ +# Missing stuff compared to the C bridge that we should probably add:
│ │ │ │ │ +#
│ │ │ │ │ +# - removing matches
│ │ │ │ │ +# - removing watches
│ │ │ │ │ +# - emitting of signals
│ │ │ │ │ +# - publishing of objects
│ │ │ │ │ +# - failing more gracefully in some cases (during open, etc)
│ │ │ │ │ +#
│ │ │ │ │ +# Stuff we might or might not do:
│ │ │ │ │ +#
│ │ │ │ │ +# - using non-default service names
│ │ │ │ │ +#
│ │ │ │ │ +# Stuff we should probably not do:
│ │ │ │ │ +#
│ │ │ │ │ +# - emulation of ObjectManager via recursive introspection
│ │ │ │ │ +# - automatic detection of ObjectManager below the given path_namespace
│ │ │ │ │ +# - recursive scraping of properties for new object paths
│ │ │ │ │ +# (for path_namespace watches that don't hit an ObjectManager)
│ │ │ │ │ +
│ │ │ │ │ import asyncio
│ │ │ │ │ +import errno
│ │ │ │ │ import json
│ │ │ │ │ import logging
│ │ │ │ │ import traceback
│ │ │ │ │ -import uuid
│ │ │ │ │ +import xml.etree.ElementTree as ET
│ │ │ │ │
│ │ │ │ │ -from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_int, get_str, get_str_or_none, typechecked
│ │ │ │ │ +from cockpit._vendor import systemd_ctypes
│ │ │ │ │ +from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection
│ │ │ │ │ +
│ │ │ │ │ +from ..channel import Channel, ChannelError
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ +# The dbusjson3 payload
│ │ │ │ │ +#
│ │ │ │ │ +# This channel payload type translates JSON encoded messages on a
│ │ │ │ │ +# Cockpit channel to D-Bus messages, in a mostly straightforward way.
│ │ │ │ │ +# See doc/protocol.md for a description of the basics.
│ │ │ │ │ +#
│ │ │ │ │ +# However, dbusjson3 offers some advanced features as well that are
│ │ │ │ │ +# meant to support the "magic" DBusProxy objects implemented by
│ │ │ │ │ +# cockpit.js. Those proxy objects "magically" expose all the methods
│ │ │ │ │ +# and properties of a D-Bus interface without requiring any explicit
│ │ │ │ │ +# binding code to be generated for a JavaScript client. A dbusjson3
│ │ │ │ │ +# channel does this by doing automatic introspection and property
│ │ │ │ │ +# retrieval without much direction from the JavaScript client.
│ │ │ │ │ +#
│ │ │ │ │ +# The details of what exactly is done is not specified very strictly,
│ │ │ │ │ +# and the Python bridge will likely differ from the C bridge
│ │ │ │ │ +# significantly. This will be informed by what existing code actually
│ │ │ │ │ +# needs, and we might end up with a more concrete description of what
│ │ │ │ │ +# a client can actually expect.
│ │ │ │ │ +#
│ │ │ │ │ +# Here is an example of a more complex scenario:
│ │ │ │ │ +#
│ │ │ │ │ +# - The client adds a "watch" for a path namespace. There is a
│ │ │ │ │ +# ObjectManager at the given path and the bridge emits "meta" and
│ │ │ │ │ +# "notify" messages to describe all interfaces and objects reported
│ │ │ │ │ +# by that ObjectManager.
│ │ │ │ │ +#
│ │ │ │ │ +# - The client makes a method call that causes a new object with a new
│ │ │ │ │ +# interface to appear at the ObjectManager. The bridge will send a
│ │ │ │ │ +# "meta" and "notify" message to describe this new object.
│ │ │ │ │ +#
│ │ │ │ │ +# - Since the InterfacesAdded signal was emitted before the method
│ │ │ │ │ +# reply, the bridge must send the "meta" and "notify" messages
│ │ │ │ │ +# before the method reply message.
│ │ │ │ │ +#
│ │ │ │ │ +# - However, in order to construct the "meta" message, the bridge must
│ │ │ │ │ +# perform a Introspect call, and consequently must delay sending the
│ │ │ │ │ +# method reply until that call has finished.
│ │ │ │ │ +#
│ │ │ │ │ +# The Python bridge implements this delaying of messages with
│ │ │ │ │ +# coroutines and a fair mutex. Every message coming from D-Bus will
│ │ │ │ │ +# wait on the mutex for its turn to send its message on the Cockpit
│ │ │ │ │ +# channel, and will keep that mutex locked until it is done with
│ │ │ │ │ +# sending. Since the mutex is fair, everyone will nicely wait in line
│ │ │ │ │ +# without messages getting re-ordered.
│ │ │ │ │ +#
│ │ │ │ │ +# The scenario above will play out like this:
│ │ │ │ │ +#
│ │ │ │ │ +# - While adding the initial "watch", the lock is held until the
│ │ │ │ │ +# "meta" and "notify" messages have been sent.
│ │ │ │ │ +#
│ │ │ │ │ +# - Later, when the InterfacesAdded signal comes in that has been
│ │ │ │ │ +# triggered by the method call, the mutex will be locked while the
│ │ │ │ │ +# necessary introspection is going on.
│ │ │ │ │ +#
│ │ │ │ │ +# - The method reply will likely come while the mutex is locked, and
│ │ │ │ │ +# the task for sending that reply on the Cockpit channel will enter
│ │ │ │ │ +# the wait queue of the mutex.
│ │ │ │ │ +#
│ │ │ │ │ +# - Once the introspection is done and the new "meta" and "notify"
│ │ │ │ │ +# messages have been sent, the mutex is unlocked, the method reply
│ │ │ │ │ +# task acquires it, and sends its message.
│ │ │ │ │
│ │ │ │ │ -class CockpitProblem(Exception):
│ │ │ │ │ - """A type of exception that carries a problem code and a message.
│ │ │ │ │
│ │ │ │ │ - Depending on the scope, this is used to handle shutting down:
│ │ │ │ │ +class InterfaceCache:
│ │ │ │ │ + def __init__(self):
│ │ │ │ │ + self.cache = {}
│ │ │ │ │ + self.old = set() # Interfaces already returned by get_interface_if_new
│ │ │ │ │
│ │ │ │ │ - - an individual channel (sends problem code in the close message)
│ │ │ │ │ - - peer connections (sends problem code in close message for each open channel)
│ │ │ │ │ - - the main stdio interaction with the bridge
│ │ │ │ │ + def inject(self, interfaces):
│ │ │ │ │ + self.cache.update(interfaces)
│ │ │ │ │
│ │ │ │ │ - It is usually thrown in response to some violation of expected protocol
│ │ │ │ │ - when parsing messages, connecting to a peer, or opening a channel.
│ │ │ │ │ - """
│ │ │ │ │ - attrs: JsonObject
│ │ │ │ │ + async def introspect_path(self, bus, destination, object_path):
│ │ │ │ │ + xml, = await bus.call_method_async(destination, object_path,
│ │ │ │ │ + 'org.freedesktop.DBus.Introspectable',
│ │ │ │ │ + 'Introspect')
│ │ │ │ │
│ │ │ │ │ - def __init__(self, problem: str, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ - kwargs['problem'] = problem
│ │ │ │ │ - self.attrs = create_object(_msg, kwargs)
│ │ │ │ │ - super().__init__(get_str(self.attrs, 'message', problem))
│ │ │ │ │ + et = ET.fromstring(xml)
│ │ │ │ │
│ │ │ │ │ - def get_attrs(self) -> JsonObject:
│ │ │ │ │ - if self.attrs['problem'] == 'internal-error' and self.__cause__ is not None:
│ │ │ │ │ - return dict(self.attrs, cause=traceback.format_exception(
│ │ │ │ │ - self.__cause__.__class__, self.__cause__, self.__cause__.__traceback__
│ │ │ │ │ - ))
│ │ │ │ │ - else:
│ │ │ │ │ - return self.attrs
│ │ │ │ │ + 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 CockpitProtocolError(CockpitProblem):
│ │ │ │ │ - def __init__(self, message: str, problem: str = 'protocol-error'):
│ │ │ │ │ - super().__init__(problem, message=message)
│ │ │ │ │ + return interfaces
│ │ │ │ │
│ │ │ │ │ + async def get_interface(self, interface_name, bus=None, destination=None, object_path=None):
│ │ │ │ │ + try:
│ │ │ │ │ + return self.cache[interface_name]
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ -class CockpitProtocol(asyncio.Protocol):
│ │ │ │ │ - """A naive implementation of the Cockpit frame protocol
│ │ │ │ │ + if bus and object_path:
│ │ │ │ │ + try:
│ │ │ │ │ + await self.introspect_path(bus, destination, object_path)
│ │ │ │ │ + except BusError:
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - We need to use this because Python's SelectorEventLoop doesn't supported
│ │ │ │ │ - buffered protocols.
│ │ │ │ │ - """
│ │ │ │ │ - transport: 'asyncio.Transport | None' = None
│ │ │ │ │ - buffer = b''
│ │ │ │ │ - _closed: bool = False
│ │ │ │ │ - _communication_done: 'asyncio.Future[None] | None' = None
│ │ │ │ │ + return self.cache.get(interface_name)
│ │ │ │ │
│ │ │ │ │ - def do_ready(self) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - def do_closed(self, exc: 'Exception | None') -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + 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 transport_control_received(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + return ''.join(interface['methods'][method]['in'])
│ │ │ │ │
│ │ │ │ │ - def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ - def channel_data_received(self, channel: str, data: bytes) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ +def notify_update(notify, path, interface_name, props):
│ │ │ │ │ + notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()}
│ │ │ │ │
│ │ │ │ │ - def frame_received(self, frame: bytes) -> None:
│ │ │ │ │ - header, _, data = frame.partition(b'\n')
│ │ │ │ │
│ │ │ │ │ - if header != b'':
│ │ │ │ │ - channel = header.decode('ascii')
│ │ │ │ │ - logger.debug('data received: %d bytes of data for channel %s', len(data), channel)
│ │ │ │ │ - self.channel_data_received(channel, data)
│ │ │ │ │ +class DBusChannel(Channel):
│ │ │ │ │ + json_encoder = systemd_ctypes.JSONEncoder(indent=2)
│ │ │ │ │ + payload = 'dbus-json3'
│ │ │ │ │
│ │ │ │ │ - else:
│ │ │ │ │ - self.control_received(data)
│ │ │ │ │ + matches = None
│ │ │ │ │ + name = None
│ │ │ │ │ + bus = None
│ │ │ │ │ + owner = None
│ │ │ │ │
│ │ │ │ │ - def control_received(self, data: bytes) -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - message = typechecked(json.loads(data), dict)
│ │ │ │ │ - command = get_str(message, 'command')
│ │ │ │ │ - channel = get_str(message, 'channel', None)
│ │ │ │ │ + async def setup_name_owner_tracking(self):
│ │ │ │ │ + def send_owner(owner):
│ │ │ │ │ + # We must be careful not to send duplicate owner
│ │ │ │ │ + # notifications. cockpit.js relies on that.
│ │ │ │ │ + if self.owner != owner:
│ │ │ │ │ + self.owner = owner
│ │ │ │ │ + self.send_json(owner=owner)
│ │ │ │ │
│ │ │ │ │ - if channel is not None:
│ │ │ │ │ - logger.debug('channel control received %s', message)
│ │ │ │ │ - self.channel_control_received(channel, command, message)
│ │ │ │ │ + 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('transport control received %s', message)
│ │ │ │ │ - self.transport_control_received(command, message)
│ │ │ │ │ -
│ │ │ │ │ - except (json.JSONDecodeError, JsonError) as exc:
│ │ │ │ │ - raise CockpitProtocolError(f'control message: {exc!s}') from exc
│ │ │ │ │ + logger.debug("Failed to get owner of service '%s': %s", self.name, error.message)
│ │ │ │ │ + else:
│ │ │ │ │ + send_owner(unique_name)
│ │ │ │ │
│ │ │ │ │ - def consume_one_frame(self, data: bytes) -> int:
│ │ │ │ │ - """Consumes a single frame from view.
│ │ │ │ │ + def do_open(self, options):
│ │ │ │ │ + self.cache = InterfaceCache()
│ │ │ │ │ + self.name = options.get('name')
│ │ │ │ │ + self.matches = []
│ │ │ │ │
│ │ │ │ │ - Returns positive if a number of bytes were consumed, or negative if no
│ │ │ │ │ - work can be done because of a given number of bytes missing.
│ │ │ │ │ - """
│ │ │ │ │ + bus = options.get('bus')
│ │ │ │ │ + address = options.get('address')
│ │ │ │ │
│ │ │ │ │ try:
│ │ │ │ │ - newline = data.index(b'\n')
│ │ │ │ │ - except ValueError as exc:
│ │ │ │ │ - if len(data) < 10:
│ │ │ │ │ - # Let's try reading more
│ │ │ │ │ - return len(data) - 10
│ │ │ │ │ - raise CockpitProtocolError("size line is too long") from exc
│ │ │ │ │ + if address is not None:
│ │ │ │ │ + if bus is not None and bus != 'none':
│ │ │ │ │ + raise ChannelError('protocol-error', message='only one of "bus" and "address" can be specified')
│ │ │ │ │ + logger.debug('get bus with address %s for %s', address, self.name)
│ │ │ │ │ + self.bus = Bus.new(address=address, bus_client=self.name is not None)
│ │ │ │ │ + elif bus == 'internal':
│ │ │ │ │ + logger.debug('get internal bus for %s', self.name)
│ │ │ │ │ + self.bus = self.router.internal_bus.client
│ │ │ │ │ + else:
│ │ │ │ │ + if bus == 'session':
│ │ │ │ │ + logger.debug('get session bus for %s', self.name)
│ │ │ │ │ + self.bus = Bus.default_user()
│ │ │ │ │ + elif bus == 'system' or bus is None:
│ │ │ │ │ + logger.debug('get system bus for %s', self.name)
│ │ │ │ │ + self.bus = Bus.default_system()
│ │ │ │ │ + else:
│ │ │ │ │ + raise ChannelError('protocol-error', message=f'invalid bus "{bus}"')
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc
│ │ │ │ │
│ │ │ │ │ try:
│ │ │ │ │ - length = int(data[:newline])
│ │ │ │ │ - except ValueError as exc:
│ │ │ │ │ - raise CockpitProtocolError("frame size is not an integer") from exc
│ │ │ │ │ -
│ │ │ │ │ - start = newline + 1
│ │ │ │ │ - end = start + length
│ │ │ │ │ + self.bus.attach_event(None, 0)
│ │ │ │ │ + except OSError as err:
│ │ │ │ │ + if err.errno != errno.EBUSY:
│ │ │ │ │ + raise
│ │ │ │ │
│ │ │ │ │ - if end > len(data):
│ │ │ │ │ - # We need to read more
│ │ │ │ │ - return len(data) - end
│ │ │ │ │ + # 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()
│ │ │ │ │
│ │ │ │ │ - # We can consume a full frame
│ │ │ │ │ - self.frame_received(data[start:end])
│ │ │ │ │ - return end
│ │ │ │ │ + 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 connection_made(self, transport: asyncio.BaseTransport) -> None:
│ │ │ │ │ - logger.debug('connection_made(%s)', transport)
│ │ │ │ │ - assert isinstance(transport, asyncio.Transport)
│ │ │ │ │ - self.transport = transport
│ │ │ │ │ - self.do_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']
│ │ │ │ │
│ │ │ │ │ - if self._closed:
│ │ │ │ │ - logger.debug(' but the protocol already was closed, so closing transport')
│ │ │ │ │ - transport.close()
│ │ │ │ │ + def filter_owner(message):
│ │ │ │ │ + if self.owner is not None and self.owner == message.get_sender():
│ │ │ │ │ + handler(message)
│ │ │ │ │
│ │ │ │ │ - def connection_lost(self, exc: 'Exception | None') -> None:
│ │ │ │ │ - logger.debug('connection_lost')
│ │ │ │ │ - assert self.transport is not None
│ │ │ │ │ - self.transport = None
│ │ │ │ │ - self.close(exc)
│ │ │ │ │ + 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 close(self, exc: 'Exception | None' = None) -> None:
│ │ │ │ │ - if self._closed:
│ │ │ │ │ - return
│ │ │ │ │ - self._closed = True
│ │ │ │ │ + self.matches.append(match)
│ │ │ │ │
│ │ │ │ │ - if self.transport:
│ │ │ │ │ - self.transport.close()
│ │ │ │ │ + def add_async_signal_handler(self, handler, **kwargs):
│ │ │ │ │ + def sync_handler(message):
│ │ │ │ │ + self.create_task(handler(message))
│ │ │ │ │ + self.add_signal_handler(sync_handler, **kwargs)
│ │ │ │ │
│ │ │ │ │ - self.do_closed(exc)
│ │ │ │ │ + async def do_call(self, message):
│ │ │ │ │ + path, iface, method, args = message['call']
│ │ │ │ │ + cookie = message.get('id')
│ │ │ │ │ + flags = message.get('flags')
│ │ │ │ │
│ │ │ │ │ - def write_channel_data(self, channel: str, payload: bytes) -> None:
│ │ │ │ │ - """Send a given payload (bytes) on channel (string)"""
│ │ │ │ │ - # Channel is certainly ascii (as enforced by .encode() below)
│ │ │ │ │ - frame_length = len(channel + '\n') + len(payload)
│ │ │ │ │ - header = f'{frame_length}\n{channel}\n'.encode('ascii')
│ │ │ │ │ - if self.transport is not None:
│ │ │ │ │ - logger.debug('writing to transport %s', self.transport)
│ │ │ │ │ - self.transport.write(header + payload)
│ │ │ │ │ + timeout = message.get('timeout')
│ │ │ │ │ + if timeout is not None:
│ │ │ │ │ + # sd_bus timeout is μs, cockpit API timeout is ms
│ │ │ │ │ + timeout *= 1000
│ │ │ │ │ else:
│ │ │ │ │ - logger.debug('cannot write to closed transport')
│ │ │ │ │ + # sd_bus has no "indefinite" timeout, so use MAX_UINT64
│ │ │ │ │ + timeout = 2 ** 64 - 1
│ │ │ │ │
│ │ │ │ │ - def write_control(self, _msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
│ │ │ │ │ - """Write a control message. See jsonutil.create_object() for details."""
│ │ │ │ │ - logger.debug('sending control message %r %r', _msg, kwargs)
│ │ │ │ │ - pretty = json.dumps(create_object(_msg, kwargs), indent=2) + '\n'
│ │ │ │ │ - self.write_channel_data('', pretty.encode())
│ │ │ │ │ + # We have to figure out the signature of the call. Either we got told it:
│ │ │ │ │ + signature = message.get('type')
│ │ │ │ │ +
│ │ │ │ │ + # ... or there aren't any arguments
│ │ │ │ │ + if signature is None and len(args) == 0:
│ │ │ │ │ + signature = ''
│ │ │ │ │ +
│ │ │ │ │ + # ... or we need to introspect
│ │ │ │ │ + if signature is None:
│ │ │ │ │ + try:
│ │ │ │ │ + logger.debug('Doing introspection request for %s %s', iface, method)
│ │ │ │ │ + signature = await self.cache.get_signature(iface, method, self.bus, self.name, path)
│ │ │ │ │ + except BusError as error:
│ │ │ │ │ + self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie)
│ │ │ │ │ + return
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + self.send_json(
│ │ │ │ │ + error=[
│ │ │ │ │ + "org.freedesktop.DBus.Error.UnknownMethod",
│ │ │ │ │ + [f"Introspection data for method {iface} {method} not available"]],
│ │ │ │ │ + id=cookie)
│ │ │ │ │ + return
│ │ │ │ │ + except Exception as exc:
│ │ │ │ │ + self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie)
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - def data_received(self, data: bytes) -> None:
│ │ │ │ │ try:
│ │ │ │ │ - self.buffer += data
│ │ │ │ │ - while self.buffer:
│ │ │ │ │ - result = self.consume_one_frame(self.buffer)
│ │ │ │ │ - if result <= 0:
│ │ │ │ │ - return
│ │ │ │ │ - self.buffer = self.buffer[result:]
│ │ │ │ │ - except CockpitProtocolError as exc:
│ │ │ │ │ - self.close(exc)
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - def eof_received(self) -> bool:
│ │ │ │ │ - return False
│ │ │ │ │ + async def do_add_match(self, message):
│ │ │ │ │ + add_match = message['add-match']
│ │ │ │ │ + logger.debug('adding match %s', add_match)
│ │ │ │ │
│ │ │ │ │ + async def match_hit(message):
│ │ │ │ │ + logger.debug('got match')
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + self.send_json(signal=[
│ │ │ │ │ + message.get_path(),
│ │ │ │ │ + message.get_interface(),
│ │ │ │ │ + message.get_member(),
│ │ │ │ │ + list(message.get_body())
│ │ │ │ │ + ])
│ │ │ │ │
│ │ │ │ │ -# Helpful functionality for "server"-side protocol implementations
│ │ │ │ │ -class CockpitProtocolServer(CockpitProtocol):
│ │ │ │ │ - init_host: 'str | None' = None
│ │ │ │ │ - authorizations: 'dict[str, asyncio.Future[str]] | None' = None
│ │ │ │ │ + self.add_async_signal_handler(match_hit, **add_match)
│ │ │ │ │
│ │ │ │ │ - def do_send_init(self) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + 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.
│ │ │ │ │
│ │ │ │ │ - def do_init(self, message: JsonObject) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + async def handler(message):
│ │ │ │ │ + member = message.get_member()
│ │ │ │ │ + if member == "InterfacesAdded":
│ │ │ │ │ + (path, interface_props) = message.get_body()
│ │ │ │ │ + logger.debug('interfaces added %s %s', path, interface_props)
│ │ │ │ │ + meta = {}
│ │ │ │ │ + notify = {}
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + for name, props in interface_props.items():
│ │ │ │ │ + if interface_name is None or name == interface_name:
│ │ │ │ │ + mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path)
│ │ │ │ │ + if mm:
│ │ │ │ │ + meta.update({name: mm})
│ │ │ │ │ + notify_update(notify, path, name, props)
│ │ │ │ │ + self.send_json(meta=meta)
│ │ │ │ │ + self.send_json(notify=notify)
│ │ │ │ │ + elif member == "InterfacesRemoved":
│ │ │ │ │ + (path, interfaces) = message.get_body()
│ │ │ │ │ + logger.debug('interfaces removed %s %s', path, interfaces)
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + notify = {path: dict.fromkeys(interfaces)}
│ │ │ │ │ + self.send_json(notify=notify)
│ │ │ │ │
│ │ │ │ │ - def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + self.add_async_signal_handler(handler,
│ │ │ │ │ + path=path,
│ │ │ │ │ + interface="org.freedesktop.DBus.ObjectManager")
│ │ │ │ │ + objects, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ + 'org.freedesktop.DBus.ObjectManager',
│ │ │ │ │ + 'GetManagedObjects')
│ │ │ │ │ + for p, ifaces in objects.items():
│ │ │ │ │ + for iface, props in ifaces.items():
│ │ │ │ │ + if interface_name is None or iface == interface_name:
│ │ │ │ │ + mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p)
│ │ │ │ │ + if mm:
│ │ │ │ │ + meta.update({iface: mm})
│ │ │ │ │ + notify_update(notify, p, iface, props)
│ │ │ │ │
│ │ │ │ │ - def transport_control_received(self, command: str, message: JsonObject) -> None:
│ │ │ │ │ - if command == 'init':
│ │ │ │ │ - if get_int(message, 'version') != 1:
│ │ │ │ │ - raise CockpitProtocolError('incorrect version number')
│ │ │ │ │ - self.init_host = get_str(message, 'host')
│ │ │ │ │ - self.do_init(message)
│ │ │ │ │ - elif command == 'kill':
│ │ │ │ │ - self.do_kill(get_str_or_none(message, 'host', None), get_str_or_none(message, 'group', None), message)
│ │ │ │ │ - elif command == 'authorize':
│ │ │ │ │ - self.do_authorize(message)
│ │ │ │ │ + async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify):
│ │ │ │ │ + # Watch a single object at "path", but maybe also watch for
│ │ │ │ │ + # property changes for all objects below "path".
│ │ │ │ │ +
│ │ │ │ │ + async def handler(message):
│ │ │ │ │ + async with self.watch_processing_lock:
│ │ │ │ │ + path = message.get_path()
│ │ │ │ │ + name, props, invalids = message.get_body()
│ │ │ │ │ + logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids)
│ │ │ │ │ + for inv in invalids:
│ │ │ │ │ + try:
│ │ │ │ │ + reply, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ + 'org.freedesktop.DBus.Properties', 'Get',
│ │ │ │ │ + 'ss', name, inv)
│ │ │ │ │ + except BusError as exc:
│ │ │ │ │ + logger.debug('failed to fetch property %s.%s on %s %s: %s',
│ │ │ │ │ + name, inv, self.name, path, str(exc))
│ │ │ │ │ + continue
│ │ │ │ │ + props[inv] = reply
│ │ │ │ │ + notify = {}
│ │ │ │ │ + notify_update(notify, path, name, props)
│ │ │ │ │ + self.send_json(notify=notify)
│ │ │ │ │ +
│ │ │ │ │ + this_meta = await self.cache.introspect_path(self.bus, self.name, path)
│ │ │ │ │ + if interface_name is not None:
│ │ │ │ │ + interface = this_meta.get(interface_name)
│ │ │ │ │ + this_meta = {interface_name: interface}
│ │ │ │ │ + meta.update(this_meta)
│ │ │ │ │ + if recursive_props:
│ │ │ │ │ + self.add_async_signal_handler(handler,
│ │ │ │ │ + interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ + path_namespace=path)
│ │ │ │ │ else:
│ │ │ │ │ - raise CockpitProtocolError(f'unexpected control message {command} received')
│ │ │ │ │ + self.add_async_signal_handler(handler,
│ │ │ │ │ + interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ + path=path)
│ │ │ │ │
│ │ │ │ │ - def do_ready(self) -> None:
│ │ │ │ │ - self.do_send_init()
│ │ │ │ │ + for name in meta:
│ │ │ │ │ + if name.startswith("org.freedesktop.DBus."):
│ │ │ │ │ + continue
│ │ │ │ │ + try:
│ │ │ │ │ + props, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ + 'org.freedesktop.DBus.Properties',
│ │ │ │ │ + 'GetAll', 's', name)
│ │ │ │ │ + notify_update(notify, path, name, props)
│ │ │ │ │ + except BusError:
│ │ │ │ │ + pass
│ │ │ │ │ +
│ │ │ │ │ + async def do_watch(self, message):
│ │ │ │ │ + watch = message['watch']
│ │ │ │ │ + path = watch.get('path')
│ │ │ │ │ + path_namespace = watch.get('path_namespace')
│ │ │ │ │ + interface_name = watch.get('interface')
│ │ │ │ │ + cookie = message.get('id')
│ │ │ │ │ +
│ │ │ │ │ + path = path or path_namespace
│ │ │ │ │ + recursive = path == path_namespace
│ │ │ │ │ +
│ │ │ │ │ + if path is None or cookie is None:
│ │ │ │ │ + logger.debug('ignored incomplete watch request %s', message)
│ │ │ │ │ + self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie)
│ │ │ │ │ + self.send_json(reply=[], id=cookie)
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - # authorize request/response API
│ │ │ │ │ - async def request_authorization(
│ │ │ │ │ - self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue
│ │ │ │ │ - ) -> str:
│ │ │ │ │ - if self.authorizations is None:
│ │ │ │ │ - self.authorizations = {}
│ │ │ │ │ - cookie = str(uuid.uuid4())
│ │ │ │ │ - future = asyncio.get_running_loop().create_future()
│ │ │ │ │ try:
│ │ │ │ │ - self.authorizations[cookie] = future
│ │ │ │ │ - self.write_control(None, command='authorize', challenge=challenge, cookie=cookie, **kwargs)
│ │ │ │ │ - return await asyncio.wait_for(future, timeout)
│ │ │ │ │ - finally:
│ │ │ │ │ - self.authorizations.pop(cookie)
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - def do_authorize(self, message: JsonObject) -> None:
│ │ │ │ │ - cookie = get_str(message, 'cookie')
│ │ │ │ │ - response = get_str(message, 'response')
│ │ │ │ │ + async def do_meta(self, message):
│ │ │ │ │ + self.cache.inject(message['meta'])
│ │ │ │ │
│ │ │ │ │ - if self.authorizations is None or cookie not in self.authorizations:
│ │ │ │ │ - logger.warning('no matching authorize request')
│ │ │ │ │ + def do_data(self, data):
│ │ │ │ │ + message = json.loads(data)
│ │ │ │ │ + logger.debug('receive dbus request %s %s', self.name, message)
│ │ │ │ │ +
│ │ │ │ │ + if 'call' in message:
│ │ │ │ │ + self.create_task(self.do_call(message))
│ │ │ │ │ + elif 'add-match' in message:
│ │ │ │ │ + self.create_task(self.do_add_match(message))
│ │ │ │ │ + elif 'watch' in message:
│ │ │ │ │ + self.create_task(self.do_watch(message))
│ │ │ │ │ + elif 'meta' in message:
│ │ │ │ │ + self.create_task(self.do_meta(message))
│ │ │ │ │ + else:
│ │ │ │ │ + logger.debug('ignored dbus request %s', message)
│ │ │ │ │ return
│ │ │ │ │
│ │ │ │ │ - self.authorizations[cookie].set_result(response)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/superuser.py': br'''# This file is part of Cockpit.
│ │ │ │ │ + def do_close(self):
│ │ │ │ │ + for slot in self.matches:
│ │ │ │ │ + slot.cancel()
│ │ │ │ │ + self.matches = []
│ │ │ │ │ + self.close()
│ │ │ │ │ +'''.encode('utf-8'),
│ │ │ │ │ + '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.
│ │ │ │ │ @@ -4456,566 +6156,671 @@
│ │ │ │ │ # 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 Optional
│ │ │ │ │
│ │ │ │ │ -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
│ │ │ │ │ +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__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class SuperuserPeer(ConfiguredPeer):
│ │ │ │ │ - responder: ferny.AskpassHandler
│ │ │ │ │ +class PackagesChannel(AsyncChannel):
│ │ │ │ │ + payload = 'http-stream1'
│ │ │ │ │ + restrictions = [("internal", "packages")]
│ │ │ │ │
│ │ │ │ │ - def __init__(self, router: Router, config: BridgeConfig, responder: ferny.AskpassHandler):
│ │ │ │ │ - super().__init__(router, config)
│ │ │ │ │ - self.responder = responder
│ │ │ │ │ + # used to carry data forward from open to done
│ │ │ │ │ + options: Optional[JsonObject] = None
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + 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()))
│ │ │ │ │ + self.done()
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - responders: 'list[ferny.InteractionHandler]' = [self.responder]
│ │ │ │ │ + async def run(self, options: JsonObject) -> None:
│ │ │ │ │ + packages: Packages = self.router.packages # type: ignore[attr-defined] # yes, this is evil
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + try:
│ │ │ │ │ + if get_str(options, 'method') != 'GET':
│ │ │ │ │ + raise ValueError(f'Unsupported HTTP method {options["method"]}')
│ │ │ │ │
│ │ │ │ │ - agent = ferny.InteractionAgent(responders)
│ │ │ │ │ + self.ready()
│ │ │ │ │ + if await self.read() is not None:
│ │ │ │ │ + raise ValueError('Received unexpected data')
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + path = get_str(options, 'path')
│ │ │ │ │ + headers = get_dict(options, 'headers')
│ │ │ │ │ + document = packages.load_path(path, headers)
│ │ │ │ │
│ │ │ │ │ - transport = await self.spawn(self.args, env, stderr=agent, start_new_session=True)
│ │ │ │ │ + # 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,
│ │ │ │ │ + }
│ │ │ │ │
│ │ │ │ │ - if stage1 is not None:
│ │ │ │ │ - transport.write(stage1)
│ │ │ │ │ + if document.content_encoding is not None:
│ │ │ │ │ + out_headers['Content-Encoding'] = document.content_encoding
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - await agent.communicate()
│ │ │ │ │ - except ferny.InteractionError as exc:
│ │ │ │ │ - raise PeerError('authentication-failed', message=str(exc)) from exc
│ │ │ │ │ + if document.content_security_policy is not None:
│ │ │ │ │ + policy = document.content_security_policy
│ │ │ │ │ +
│ │ │ │ │ + # 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')
│ │ │ │ │
│ │ │ │ │ + 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';")
│ │ │ │ │
│ │ │ │ │ -class CockpitResponder(ferny.AskpassHandler):
│ │ │ │ │ - commands = ('ferny.askpass', 'cockpit.send-stderr')
│ │ │ │ │ + out_headers['Content-Security-Policy'] = policy
│ │ │ │ │
│ │ │ │ │ - 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 ValueError as exc:
│ │ │ │ │ + self.http_error(400, str(exc))
│ │ │ │ │
│ │ │ │ │ + except KeyError:
│ │ │ │ │ + self.http_error(404, 'Not found')
│ │ │ │ │
│ │ │ │ │ -class AuthorizeResponder(CockpitResponder):
│ │ │ │ │ - def __init__(self, router: Router):
│ │ │ │ │ - self.router = router
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + self.http_error(500, f'Internal error: {exc!s}')
│ │ │ │ │
│ │ │ │ │ - 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}')
│ │ │ │ │ + else:
│ │ │ │ │ + self.send_json(status=200, reason='OK', headers=out_headers)
│ │ │ │ │ + await self.sendfile(document.data)
│ │ │ │ │ +''',
│ │ │ │ │ + '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 .
│ │ │ │ │
│ │ │ │ │ +import asyncio
│ │ │ │ │ +import contextlib
│ │ │ │ │ +import enum
│ │ │ │ │ +import errno
│ │ │ │ │ +import fnmatch
│ │ │ │ │ +import functools
│ │ │ │ │ +import grp
│ │ │ │ │ +import logging
│ │ │ │ │ +import os
│ │ │ │ │ +import pwd
│ │ │ │ │ +import re
│ │ │ │ │ +import stat
│ │ │ │ │ +import tempfile
│ │ │ │ │ +from pathlib import Path
│ │ │ │ │ +from typing import Callable, Generator, Iterable
│ │ │ │ │
│ │ │ │ │ -class SuperuserRoutingRule(RoutingRule, CockpitResponder, bus.Object, interface='cockpit.Superuser'):
│ │ │ │ │ - superuser_configs: Sequence[BridgeConfig] = ()
│ │ │ │ │ - pending_prompt: Optional[asyncio.Future]
│ │ │ │ │ - peer: Optional[SuperuserPeer]
│ │ │ │ │ +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
│ │ │ │ │
│ │ │ │ │ - # D-Bus signals
│ │ │ │ │ - prompt = bus.Interface.Signal('s', 's', 's', 'b', 's') # message, prompt, default, echo, error
│ │ │ │ │ +from ..channel import AsyncChannel, Channel, ChannelError, GeneratorChannel
│ │ │ │ │ +from ..jsonutil import (
│ │ │ │ │ + JsonDict,
│ │ │ │ │ + JsonDocument,
│ │ │ │ │ + JsonError,
│ │ │ │ │ + JsonObject,
│ │ │ │ │ + get_bool,
│ │ │ │ │ + get_int,
│ │ │ │ │ + get_str,
│ │ │ │ │ + get_strv,
│ │ │ │ │ + json_merge_and_filter_patch,
│ │ │ │ │ +)
│ │ │ │ │
│ │ │ │ │ - # D-Bus properties
│ │ │ │ │ - bridges = bus.Interface.Property('as', value=[])
│ │ │ │ │ - current = bus.Interface.Property('s', value='none')
│ │ │ │ │ - methods = bus.Interface.Property('a{sv}', value={})
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ - # 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')
│ │ │ │ │ +@functools.lru_cache()
│ │ │ │ │ +def my_umask() -> int:
│ │ │ │ │ + match = re.search(r'^Umask:\s*0([0-7]*)$', Path('/proc/self/status').read_text(), re.M)
│ │ │ │ │ + return (match and int(match.group(1), 8)) or 0o077
│ │ │ │ │
│ │ │ │ │ - # 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)
│ │ │ │ │ +def tag_from_stat(buf):
│ │ │ │ │ + return f'1:{buf.st_ino}-{buf.st_mtime}-{buf.st_mode:o}-{buf.st_uid}-{buf.st_gid}'
│ │ │ │ │
│ │ │ │ │ - self.pending_prompt = None
│ │ │ │ │ - self.peer = None
│ │ │ │ │ - self.startup = None
│ │ │ │ │
│ │ │ │ │ - if privileged or os.getuid() == 0:
│ │ │ │ │ - self.current = 'root'
│ │ │ │ │ +def tag_from_path(path):
│ │ │ │ │ + try:
│ │ │ │ │ + return tag_from_stat(os.stat(path))
│ │ │ │ │ + except FileNotFoundError:
│ │ │ │ │ + return '-'
│ │ │ │ │ + except OSError:
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - 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')
│ │ │ │ │ +def tag_from_fd(fd):
│ │ │ │ │ + try:
│ │ │ │ │ + return tag_from_stat(os.fstat(fd))
│ │ │ │ │ + except OSError:
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - assert self.peer is None
│ │ │ │ │ - assert self.startup is None
│ │ │ │ │
│ │ │ │ │ - for config in self.superuser_configs:
│ │ │ │ │ - if name in (config.name, 'any'):
│ │ │ │ │ - break
│ │ │ │ │ +class FsListChannel(Channel):
│ │ │ │ │ + payload = 'fslist1'
│ │ │ │ │ +
│ │ │ │ │ + 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:
│ │ │ │ │ - raise bus.BusError('cockpit.Superuser.Error', f'Unknown superuser bridge type "{name}"')
│ │ │ │ │ + mode = 'special'
│ │ │ │ │
│ │ │ │ │ - self.current = 'init'
│ │ │ │ │ - self.peer = SuperuserPeer(self.router, config, responder)
│ │ │ │ │ - self.peer.add_done_callback(self.peer_done)
│ │ │ │ │ + self.send_json(event=event, path=entry.name, type=mode)
│ │ │ │ │ +
│ │ │ │ │ + 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')
│ │ │ │ │
│ │ │ │ │ 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
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - self.current = self.peer.config.name
│ │ │ │ │ + self.ready()
│ │ │ │ │ + for entry in scan_dir:
│ │ │ │ │ + self.send_entry("present", entry)
│ │ │ │ │
│ │ │ │ │ - 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}
│ │ │ │ │ + if not watch:
│ │ │ │ │ + self.done()
│ │ │ │ │ + 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 FsReadChannel(GeneratorChannel):
│ │ │ │ │ + payload = 'fsread1'
│ │ │ │ │
│ │ │ │ │ - def cancel_prompt(self):
│ │ │ │ │ - if self.pending_prompt is not None:
│ │ │ │ │ - self.pending_prompt.cancel()
│ │ │ │ │ - self.pending_prompt = None
│ │ │ │ │ + def do_yield_data(self, options: JsonObject) -> Generator[bytes, None, JsonObject]:
│ │ │ │ │ + path = get_str(options, 'path')
│ │ │ │ │ + max_read_size = get_int(options, 'max_read_size', None)
│ │ │ │ │
│ │ │ │ │ - def shutdown(self):
│ │ │ │ │ - self.cancel_prompt()
│ │ │ │ │ + logger.debug('Opening file "%s" for reading', path)
│ │ │ │ │
│ │ │ │ │ - if self.peer is not None:
│ │ │ │ │ - self.peer.close()
│ │ │ │ │ + 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')
│ │ │ │ │
│ │ │ │ │ - # close() should have disconnected the peer immediately
│ │ │ │ │ - assert self.peer is None
│ │ │ │ │ + if self.is_binary and stat.S_ISREG(buf.st_mode):
│ │ │ │ │ + self.ready(size_hint=buf.st_size)
│ │ │ │ │ + else:
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - # 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)
│ │ │ │ │ + while True:
│ │ │ │ │ + data = filep.read1(Channel.BLOCK_SIZE)
│ │ │ │ │ + if data == b'':
│ │ │ │ │ + break
│ │ │ │ │ + logger.debug(' ...sending %d bytes', len(data))
│ │ │ │ │ + if not self.is_binary:
│ │ │ │ │ + data = data.replace(b'\0', b'').decode(errors='ignore').encode()
│ │ │ │ │ + yield data
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + return {'tag': tag_from_stat(buf)}
│ │ │ │ │
│ │ │ │ │ - # D-Bus methods
│ │ │ │ │ - @bus.Interface.Method(in_types=['s'])
│ │ │ │ │ - async def start(self, name: str) -> None:
│ │ │ │ │ - await self.go(name, self)
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - @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/config.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2023 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is distributed in the hope that it will be useful,
│ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ -# GNU General Public License for more details.
│ │ │ │ │ -#
│ │ │ │ │ -# You should have received a copy of the GNU General Public License
│ │ │ │ │ -# along with this program. If not, see .
│ │ │ │ │ +class FsReplaceChannel(AsyncChannel):
│ │ │ │ │ + payload = 'fsreplace1'
│ │ │ │ │
│ │ │ │ │ -import configparser
│ │ │ │ │ -import logging
│ │ │ │ │ -import os
│ │ │ │ │ -from pathlib import Path
│ │ │ │ │ + def delete(self, path: str, tag: 'str | None') -> str:
│ │ │ │ │ + if tag is not None and tag != tag_from_path(path):
│ │ │ │ │ + raise ChannelError('change-conflict')
│ │ │ │ │ + with contextlib.suppress(FileNotFoundError): # delete is idempotent
│ │ │ │ │ + os.unlink(path)
│ │ │ │ │ + return '-'
│ │ │ │ │
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import bus
│ │ │ │ │ + async def set_contents(self, path: str, tag: 'str | None', data: 'bytes | None', size: 'int | None') -> str:
│ │ │ │ │ + dirname, basename = os.path.split(path)
│ │ │ │ │ + tmpname: str | None
│ │ │ │ │ + fd, tmpname = tempfile.mkstemp(dir=dirname, prefix=f'.{basename}-')
│ │ │ │ │ + try:
│ │ │ │ │ + if size is not None:
│ │ │ │ │ + logger.debug('fallocate(%s.tmp, %d)', path, size)
│ │ │ │ │ + if size: # posix_fallocate() of 0 bytes is EINVAL
│ │ │ │ │ + await self.in_thread(os.posix_fallocate, fd, 0, size)
│ │ │ │ │ + self.ready() # ...only after that worked
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ + written = 0
│ │ │ │ │ + while data is not None:
│ │ │ │ │ + await self.in_thread(os.write, fd, data)
│ │ │ │ │ + written += len(data)
│ │ │ │ │ + data = await self.read()
│ │ │ │ │
│ │ │ │ │ -XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'))
│ │ │ │ │ -DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit'
│ │ │ │ │ + if size is not None and written < size:
│ │ │ │ │ + logger.debug('ftruncate(%s.tmp, %d)', path, written)
│ │ │ │ │ + await self.in_thread(os.ftruncate, fd, written)
│ │ │ │ │
│ │ │ │ │ + await self.in_thread(os.fdatasync, fd)
│ │ │ │ │
│ │ │ │ │ -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
│ │ │ │ │ + if tag is None:
│ │ │ │ │ + # no preconditions about what currently exists or not
│ │ │ │ │ + # calculate the file mode from the umask
│ │ │ │ │ + os.fchmod(fd, 0o666 & ~my_umask())
│ │ │ │ │ + os.rename(tmpname, path)
│ │ │ │ │ + tmpname = None
│ │ │ │ │
│ │ │ │ │ - # 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
│ │ │ │ │ + elif tag == '-':
│ │ │ │ │ + # the file must not exist. file mode from umask.
│ │ │ │ │ + os.fchmod(fd, 0o666 & ~my_umask())
│ │ │ │ │ + os.link(tmpname, path) # will fail if file exists
│ │ │ │ │
│ │ │ │ │ + else:
│ │ │ │ │ + # the file must exist with the given tag
│ │ │ │ │ + buf = os.stat(path)
│ │ │ │ │ + if tag != tag_from_stat(buf):
│ │ │ │ │ + raise ChannelError('change-conflict')
│ │ │ │ │ + # chown/chmod from the existing file permissions
│ │ │ │ │ + os.fchmod(fd, stat.S_IMODE(buf.st_mode))
│ │ │ │ │ + os.fchown(fd, buf.st_uid, buf.st_gid)
│ │ │ │ │ + os.rename(tmpname, path)
│ │ │ │ │ + tmpname = None
│ │ │ │ │
│ │ │ │ │ -class Config(bus.Object, interface='cockpit.Config'):
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - self.reload()
│ │ │ │ │ + finally:
│ │ │ │ │ + os.close(fd)
│ │ │ │ │ + if tmpname is not None:
│ │ │ │ │ + os.unlink(tmpname)
│ │ │ │ │
│ │ │ │ │ - @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
│ │ │ │ │ + return tag_from_path(path)
│ │ │ │ │
│ │ │ │ │ - @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(self, options: JsonObject) -> JsonObject:
│ │ │ │ │ + path = get_str(options, 'path')
│ │ │ │ │ + size = get_int(options, 'size', None)
│ │ │ │ │ + tag = get_str(options, 'tag', None)
│ │ │ │ │
│ │ │ │ │ try:
│ │ │ │ │ - int_val = int(value)
│ │ │ │ │ - except ValueError:
│ │ │ │ │ - logger.warning('cockpit.conf: [%s] %s is not an integer', section, key)
│ │ │ │ │ - return default
│ │ │ │ │ + # In the `size` case, .set_contents() sends the ready only after
│ │ │ │ │ + # it knows that the allocate was successful. In the case without
│ │ │ │ │ + # `size`, we need to send the ready() up front in order to
│ │ │ │ │ + # receive the first frame and decide if we're creating or deleting.
│ │ │ │ │ + if size is not None:
│ │ │ │ │ + tag = await self.set_contents(path, tag, b'', size)
│ │ │ │ │ + else:
│ │ │ │ │ + self.ready()
│ │ │ │ │ + data = await self.read()
│ │ │ │ │ + # if we get EOF right away, that's a request to delete
│ │ │ │ │ + if data is None:
│ │ │ │ │ + tag = self.delete(path, tag)
│ │ │ │ │ + else:
│ │ │ │ │ + tag = await self.set_contents(path, tag, data, None)
│ │ │ │ │
│ │ │ │ │ - return min(max(int_val, minimum), maximum)
│ │ │ │ │ + self.done()
│ │ │ │ │ + return {'tag': tag}
│ │ │ │ │
│ │ │ │ │ - @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)
│ │ │ │ │ + except FileNotFoundError as exc:
│ │ │ │ │ + raise ChannelError('not-found') from exc
│ │ │ │ │ + except FileExistsError as exc:
│ │ │ │ │ + # that's from link() noticing that the target file already exists
│ │ │ │ │ + raise ChannelError('change-conflict') from exc
│ │ │ │ │ + except PermissionError as exc:
│ │ │ │ │ + raise ChannelError('access-denied') from exc
│ │ │ │ │ + except IsADirectoryError as exc:
│ │ │ │ │ + # not ideal, but the closest code we have
│ │ │ │ │ + raise ChannelError('access-denied', message=str(exc)) from exc
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + raise ChannelError('internal-error', message=str(exc)) from exc
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class Environment(bus.Object, interface='cockpit.Environment'):
│ │ │ │ │ - variables = bus.Interface.Property('a{ss}')
│ │ │ │ │ +class FsWatchChannel(Channel, PathWatchListener):
│ │ │ │ │ + payload = 'fswatch1'
│ │ │ │ │ + _tag = None
│ │ │ │ │ + _watch = None
│ │ │ │ │
│ │ │ │ │ - @variables.getter
│ │ │ │ │ - def get_variables(self):
│ │ │ │ │ - return os.environ.copy()
│ │ │ │ │ -''',
│ │ │ │ │ - '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 .
│ │ │ │ │ + # 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
│ │ │ │ │
│ │ │ │ │ -import getpass
│ │ │ │ │ -import logging
│ │ │ │ │ -import re
│ │ │ │ │ -import socket
│ │ │ │ │ -from typing import Dict, List, Optional, Tuple
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def mask_to_event_and_type(mask: InotifyEvent) -> 'tuple[str, str | None]':
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ -from cockpit._vendor import ferny
│ │ │ │ │ + def do_inotify_event(self, mask: InotifyEvent, _cookie: int, name: 'bytes | None') -> None:
│ │ │ │ │ + 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_)
│ │ │ │ │
│ │ │ │ │ -from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none
│ │ │ │ │ -from .peer import Peer, PeerError
│ │ │ │ │ -from .router import Router, RoutingRule
│ │ │ │ │ + def do_identity_changed(self, fd: 'int | None', err: 'int | None') -> None:
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ + def do_open(self, options: JsonObject) -> None:
│ │ │ │ │ + self._path = get_str(options, 'path')
│ │ │ │ │ + self._tag = None
│ │ │ │ │
│ │ │ │ │ + self._active = False
│ │ │ │ │ + self._watch = PathWatch(self._path, self)
│ │ │ │ │ + self._active = True
│ │ │ │ │
│ │ │ │ │ -class PasswordResponder(ferny.AskpassHandler):
│ │ │ │ │ - PASSPHRASE_RE = re.compile(r"Enter passphrase for key '(.*)': ")
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - password: Optional[str]
│ │ │ │ │ + def do_close(self) -> None:
│ │ │ │ │ + if self._watch is not None:
│ │ │ │ │ + self._watch.close()
│ │ │ │ │ + self._watch = None
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ +class Follow(enum.Enum):
│ │ │ │ │ + NO = False
│ │ │ │ │ + YES = True
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ +class FsInfoChannel(Channel, PathWatchListener):
│ │ │ │ │ + payload = 'fsinfo'
│ │ │ │ │
│ │ │ │ │ - async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:
│ │ │ │ │ - logger.debug('Got askpass(%s): %s', hint, prompt)
│ │ │ │ │ + # Options (all get set in `do_open()`)
│ │ │ │ │ + path: str
│ │ │ │ │ + attrs: 'set[str]'
│ │ │ │ │ + fnmatch: str
│ │ │ │ │ + targets: bool
│ │ │ │ │ + follow: bool
│ │ │ │ │ + watch: bool
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + # 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]'
│ │ │ │ │
│ │ │ │ │ - assert self.password is not None
│ │ │ │ │ - assert self.password_attempts == 0
│ │ │ │ │ - self.password_attempts += 1
│ │ │ │ │ - return self.password
│ │ │ │ │ + @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
│ │ │ │ │
│ │ │ │ │ -class SshPeer(Peer):
│ │ │ │ │ - session: Optional[ferny.Session] = None
│ │ │ │ │ - host: str
│ │ │ │ │ - user: Optional[str]
│ │ │ │ │ - password: Optional[str]
│ │ │ │ │ - private: bool
│ │ │ │ │ + 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 do_connect_transport(self) -> None:
│ │ │ │ │ - assert self.session is not None
│ │ │ │ │ - logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private)
│ │ │ │ │ + def 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}
│ │ │ │ │
│ │ │ │ │ - basename, colon, portstr = self.host.rpartition(':')
│ │ │ │ │ - if colon and portstr.isdigit():
│ │ │ │ │ - host = basename
│ │ │ │ │ - port = int(portstr)
│ │ │ │ │ - else:
│ │ │ │ │ - host = self.host
│ │ │ │ │ - port = None
│ │ │ │ │ + result = {key: func(buf) for key, func in stat_getters}
│ │ │ │ │
│ │ │ │ │ - responder = PasswordResponder(self.password)
│ │ │ │ │ - options = {"StrictHostKeyChecking": 'yes'}
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - if self.password is not None:
│ │ │ │ │ - options.update(NumberOfPasswordPrompts='1')
│ │ │ │ │ - else:
│ │ │ │ │ - options.update(PasswordAuthentication="no", KbdInteractiveAuthentication="no")
│ │ │ │ │ + return result
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - await self.session.connect(host, login_name=self.user, port=port,
│ │ │ │ │ - handle_host_key=self.private, options=options,
│ │ │ │ │ - interaction_responder=responder)
│ │ │ │ │ - except (OSError, socket.gaierror) as exc:
│ │ │ │ │ - logger.debug('connecting to host %s failed: %s', host, exc)
│ │ │ │ │ - raise PeerError('no-host', error='no-host', message=str(exc)) from exc
│ │ │ │ │ + return get_attrs
│ │ │ │ │
│ │ │ │ │ - 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}
│ │ │ │ │ + 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(dict.fromkeys(self.current_value), partial=True)
│ │ │ │ │ + self.current_value = {'partial': True}
│ │ │ │ │ + updates.update(partial=None)
│ │ │ │ │ else:
│ │ │ │ │ - error_args = {}
│ │ │ │ │ + # otherwise there's no overlap: we can just remove the old keys
│ │ │ │ │ + updates.update(dict.fromkeys(self.current_value))
│ │ │ │ │
│ │ │ │ │ - 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'
│ │ │ │ │ + json_merge_and_filter_patch(self.current_value, updates)
│ │ │ │ │ + if updates:
│ │ │ │ │ + self.send_json(updates)
│ │ │ │ │
│ │ │ │ │ - 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 process_update(self, updates: 'set[str]', *, reset: bool = False) -> None:
│ │ │ │ │ + assert self.fd is not None
│ │ │ │ │
│ │ │ │ │ - except ferny.SshAuthenticationError as exc:
│ │ │ │ │ - logger.debug('authentication to host %s failed: %s', host, exc)
│ │ │ │ │ + entries: JsonDict = {name: self.getattrs(self.fd, name, Follow.NO) for name in updates}
│ │ │ │ │
│ │ │ │ │ - results = dict.fromkeys(exc.methods, "not-provided")
│ │ │ │ │ - if 'password' in results and self.password is not None:
│ │ │ │ │ - if responder.password_attempts == 0:
│ │ │ │ │ - results['password'] = 'not-tried'
│ │ │ │ │ - else:
│ │ │ │ │ - results['password'] = 'denied'
│ │ │ │ │ + info = entries.pop('', {})
│ │ │ │ │ + assert isinstance(info, dict) # fstat() will never fail with FileNotFoundError
│ │ │ │ │
│ │ │ │ │ - raise PeerError('authentication-failed',
│ │ │ │ │ - error=responder.error_message or 'authentication-failed',
│ │ │ │ │ - auth_method_results=results) from exc
│ │ │ │ │ + if self.effective_fnmatch:
│ │ │ │ │ + info['entries'] = entries
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + if self.targets:
│ │ │ │ │ + info['targets'] = targets = {}
│ │ │ │ │ + # 'targets' is used to report attributes about the ultimate target
│ │ │ │ │ + # of symlinks, but only if this information would not already be
│ │ │ │ │ + # reported. As such, we exclude '.' and any path which would end
│ │ │ │ │ + # up in 'entries' (if it existed). '..' needs special treatment:
│ │ │ │ │ + # it might be `.interesting()` but it won't be in 'entries', so
│ │ │ │ │ + # it's always treated as a target.
│ │ │ │ │ + for name in {e.get('target') for e in entries.values() if isinstance(e, dict)}:
│ │ │ │ │ + if isinstance(name, str) and name != '.':
│ │ │ │ │ + # exclude anything that would end up in 'entries'
│ │ │ │ │ + if (name == '..' or '/' in name or not self.interesting(name)):
│ │ │ │ │ + targets[name] = self.getattrs(self.fd, name, Follow.YES)
│ │ │ │ │
│ │ │ │ │ - args = self.session.wrap_subprocess_args(['cockpit-bridge'])
│ │ │ │ │ - await self.spawn(args, [])
│ │ │ │ │ + self.send_update({'info': info}, reset=reset)
│ │ │ │ │
│ │ │ │ │ - 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 process_pending_updates(self) -> None:
│ │ │ │ │ + assert self.pending is not None
│ │ │ │ │ + if self.pending:
│ │ │ │ │ + self.process_update(self.pending)
│ │ │ │ │ + self.pending = None
│ │ │ │ │
│ │ │ │ │ - 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 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)
│ │ │ │ │
│ │ │ │ │ - def do_superuser_init_done(self) -> None:
│ │ │ │ │ - self.password = None
│ │ │ │ │ + def schedule_update(self, name: str) -> None:
│ │ │ │ │ + if not self.interesting(name):
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + if self.pending is None:
│ │ │ │ │ + asyncio.get_running_loop().call_later(0.1, self.process_pending_updates)
│ │ │ │ │ + self.pending = set()
│ │ │ │ │
│ │ │ │ │ - self.session = ferny.Session()
│ │ │ │ │ + self.pending.add(name)
│ │ │ │ │
│ │ │ │ │ - superuser: JsonValue
│ │ │ │ │ - init_superuser = get_str_or_none(options, 'init-superuser', None)
│ │ │ │ │ - if init_superuser in (None, 'none'):
│ │ │ │ │ - superuser = False
│ │ │ │ │ + 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:
│ │ │ │ │ - superuser = {'id': init_superuser}
│ │ │ │ │ + problem = 'internal-error'
│ │ │ │ │
│ │ │ │ │ - self.start_in_background(init_host=host, superuser=superuser)
│ │ │ │ │ + self.send_update({'error': {
│ │ │ │ │ + 'problem': problem, 'message': os.strerror(err), 'errno': errno.errorcode[err]
│ │ │ │ │ + }}, reset=True)
│ │ │ │ │
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ -class HostRoutingRule(RoutingRule):
│ │ │ │ │ - remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer]
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - def __init__(self, router):
│ │ │ │ │ - super().__init__(router)
│ │ │ │ │ - self.remotes = {}
│ │ │ │ │ + return False
│ │ │ │ │
│ │ │ │ │ - def apply_rule(self, options: JsonObject) -> Optional[Peer]:
│ │ │ │ │ - assert self.router is not None
│ │ │ │ │ - assert self.router.init_host is not None
│ │ │ │ │ + def report_initial_state(self, fd: Handle) -> None:
│ │ │ │ │ + if self.flag_onlydir_error(fd):
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - host = get_str(options, 'host', self.router.init_host)
│ │ │ │ │ - if host == self.router.init_host:
│ │ │ │ │ - return None
│ │ │ │ │ + self.fd = fd
│ │ │ │ │
│ │ │ │ │ - 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 ''
│ │ │ │ │ + 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 = ''
│ │ │ │ │
│ │ │ │ │ - if get_str(options, 'session', None) == 'private':
│ │ │ │ │ - nonce = get_str(options, 'channel')
│ │ │ │ │ + self.process_update({e for e in entries if self.interesting(e)}, reset=True)
│ │ │ │ │ +
│ │ │ │ │ + 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')
│ │ │ │ │ +
│ │ │ │ │ + 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('')
│ │ │ │ │ +
│ │ │ │ │ + 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:
│ │ │ │ │ - nonce = None
│ │ │ │ │ + self.report_error(err)
│ │ │ │ │
│ │ │ │ │ - assert isinstance(host, str)
│ │ │ │ │ - assert user is None or isinstance(user, str)
│ │ │ │ │ - assert nonce is None or isinstance(nonce, str)
│ │ │ │ │ + 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()
│ │ │ │ │
│ │ │ │ │ - key = host, user, nonce
│ │ │ │ │ + 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')
│ │ │ │ │
│ │ │ │ │ - logger.debug('Request for channel %s is remote.', options)
│ │ │ │ │ - logger.debug('key=%s', key)
│ │ │ │ │ + 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')
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + self.current_value = {}
│ │ │ │ │ + self.ready()
│ │ │ │ │
│ │ │ │ │ - return self.remotes[key]
│ │ │ │ │ + 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()
│ │ │ │ │
│ │ │ │ │ - def shutdown(self):
│ │ │ │ │ - for peer in set(self.remotes.values()):
│ │ │ │ │ - peer.close()
│ │ │ │ │ + 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/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
│ │ │ │ │ @@ -5209,262 +7014,14 @@
│ │ │ │ │ # 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/askpass.py': br'''from .interaction_client import main
│ │ │ │ │ -
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/py.typed': br'''''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging
│ │ │ │ │ -import re
│ │ │ │ │ -from typing import ClassVar, Match, Sequence
│ │ │ │ │ -
│ │ │ │ │ -from .interaction_agent import AskpassHandler
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class AskpassPrompt:
│ │ │ │ │ - """An askpass prompt resulting from a call to ferny-askpass.
│ │ │ │ │ -
│ │ │ │ │ - stderr: the contents of stderr from before ferny-askpass was called.
│ │ │ │ │ - Likely related to previous failed operations.
│ │ │ │ │ - messages: all but the last line of the prompt as handed to ferny-askpass.
│ │ │ │ │ - Usually contains context about the question.
│ │ │ │ │ - prompt: the last line handed to ferny-askpass. The prompt itself.
│ │ │ │ │ - """
│ │ │ │ │ - stderr: str
│ │ │ │ │ - messages: str
│ │ │ │ │ - prompt: str
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, prompt: str, messages: str, stderr: str) -> None:
│ │ │ │ │ - self.stderr = stderr
│ │ │ │ │ - self.messages = messages
│ │ │ │ │ - self.prompt = prompt
│ │ │ │ │ -
│ │ │ │ │ - def reply(self, response: str) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - async def handle_via(self, responder: 'SshAskpassResponder') -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - response = await self.dispatch(responder)
│ │ │ │ │ - if response is not None:
│ │ │ │ │ - self.reply(response)
│ │ │ │ │ - finally:
│ │ │ │ │ - self.close()
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SSHAskpassPrompt(AskpassPrompt):
│ │ │ │ │ - # The valid answers to prompts of this type. If this is None then any
│ │ │ │ │ - # answer is permitted. If it's a sequence then only answers from the
│ │ │ │ │ - # sequence are permitted. If it's an empty sequence, then no answer is
│ │ │ │ │ - # permitted (ie: the askpass callback should never return).
│ │ │ │ │ - answers: 'ClassVar[Sequence[str] | None]' = None
│ │ │ │ │ -
│ │ │ │ │ - # Patterns to capture. `_pattern` *must* match.
│ │ │ │ │ - _pattern: ClassVar[str]
│ │ │ │ │ - # `_extra_patterns` can fill in extra class attributes if they match.
│ │ │ │ │ - _extra_patterns: ClassVar[Sequence[str]] = ()
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None:
│ │ │ │ │ - super().__init__(prompt, messages, stderr)
│ │ │ │ │ - self.__dict__.update(match.groupdict())
│ │ │ │ │ -
│ │ │ │ │ - for pattern in self._extra_patterns:
│ │ │ │ │ - extra_match = re.search(with_helpers(pattern), messages, re.M)
│ │ │ │ │ - if extra_match is not None:
│ │ │ │ │ - self.__dict__.update(extra_match.groupdict())
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -# Specific prompts
│ │ │ │ │ -HELPERS = {
│ │ │ │ │ - "%{algorithm}": r"(?P\b[-\w]+\b)",
│ │ │ │ │ - "%{filename}": r"(?P.+)",
│ │ │ │ │ - "%{fingerprint}": r"(?PSHA256:[0-9A-Za-z+/]{43})",
│ │ │ │ │ - "%{hostname}": r"(?P[^ @']+)",
│ │ │ │ │ - "%{pkcs11_id}": r"(?P.+)",
│ │ │ │ │ - "%{username}": r"(?P[^ @']+)",
│ │ │ │ │ -}
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshPasswordPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"%{username}@%{hostname}'s password: "
│ │ │ │ │ - username: 'str | None' = None
│ │ │ │ │ - hostname: 'str | None' = None
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_password_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshPassphrasePrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Enter passphrase for key '%{filename}': "
│ │ │ │ │ - filename: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_passphrase_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshFIDOPINPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Enter PIN for %{algorithm} key %{filename}: "
│ │ │ │ │ - algorithm: str
│ │ │ │ │ - filename: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_fido_pin_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshFIDOUserPresencePrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Confirm user presence for key %{algorithm} %{fingerprint}"
│ │ │ │ │ - answers = ()
│ │ │ │ │ - algorithm: str
│ │ │ │ │ - fingerprint: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_fido_user_presence_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshPKCS11PINPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Enter PIN for '%{pkcs11_id}': "
│ │ │ │ │ - pkcs11_id: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_pkcs11_pin_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshHostKeyPrompt(SSHAskpassPrompt):
│ │ │ │ │ - _pattern = r"Are you sure you want to continue connecting \(yes/no(/\[fingerprint\])?\)\? "
│ │ │ │ │ - _extra_patterns = [
│ │ │ │ │ - r"%{fingerprint}[.]$",
│ │ │ │ │ - r"^%{algorithm} key fingerprint is",
│ │ │ │ │ - r"^The fingerprint for the %{algorithm} key sent by the remote host is$"
│ │ │ │ │ - ]
│ │ │ │ │ - answers = ('yes', 'no')
│ │ │ │ │ - algorithm: str
│ │ │ │ │ - fingerprint: str
│ │ │ │ │ -
│ │ │ │ │ - async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ - return await responder.do_host_key_prompt(self)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def with_helpers(pattern: str) -> str:
│ │ │ │ │ - for name, helper in HELPERS.items():
│ │ │ │ │ - pattern = pattern.replace(name, helper)
│ │ │ │ │ -
│ │ │ │ │ - assert '%{' not in pattern
│ │ │ │ │ - return pattern
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt:
│ │ │ │ │ - classes = [
│ │ │ │ │ - SshFIDOPINPrompt,
│ │ │ │ │ - SshFIDOUserPresencePrompt,
│ │ │ │ │ - SshHostKeyPrompt,
│ │ │ │ │ - SshPKCS11PINPrompt,
│ │ │ │ │ - SshPassphrasePrompt,
│ │ │ │ │ - SshPasswordPrompt,
│ │ │ │ │ - ]
│ │ │ │ │ -
│ │ │ │ │ - # The last line is the line after the last newline character, excluding the
│ │ │ │ │ - # optional final newline character. eg: "x\ny\nLAST\n" or "x\ny\nLAST"
│ │ │ │ │ - second_last_newline = string.rfind('\n', 0, -1)
│ │ │ │ │ - if second_last_newline >= 0:
│ │ │ │ │ - last_line = string[second_last_newline + 1:]
│ │ │ │ │ - extras = string[:second_last_newline + 1]
│ │ │ │ │ - else:
│ │ │ │ │ - last_line = string
│ │ │ │ │ - extras = ''
│ │ │ │ │ -
│ │ │ │ │ - for cls in classes:
│ │ │ │ │ - pattern = with_helpers(cls._pattern)
│ │ │ │ │ - match = re.fullmatch(pattern, last_line)
│ │ │ │ │ - if match is not None:
│ │ │ │ │ - return cls(last_line, extras, stderr, match)
│ │ │ │ │ -
│ │ │ │ │ - return AskpassPrompt(last_line, extras, stderr)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class SshAskpassResponder(AskpassHandler):
│ │ │ │ │ - async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None':
│ │ │ │ │ - return await categorize_ssh_prompt(prompt, stderr).dispatch(self)
│ │ │ │ │ -
│ │ │ │ │ - async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None':
│ │ │ │ │ - # Default fallback for unrecognised message types: unimplemented
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -
│ │ │ │ │ - async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None':
│ │ │ │ │ - return await self.do_prompt(prompt)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3
│ │ │ │ │ -
│ │ │ │ │ -import array
│ │ │ │ │ -import io
│ │ │ │ │ -import os
│ │ │ │ │ -import socket
│ │ │ │ │ -import sys
│ │ │ │ │ -from typing import Sequence
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None:
│ │ │ │ │ - cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')]
│ │ │ │ │ -
│ │ │ │ │ - with cmd_write:
│ │ │ │ │ - with cmd_read:
│ │ │ │ │ - with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock:
│ │ │ │ │ - fd_array = array.array('i', (cmd_read.fileno(), *fds))
│ │ │ │ │ - sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)])
│ │ │ │ │ -
│ │ │ │ │ - cmd_write.write(repr((command, args)))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int:
│ │ │ │ │ - ours, theirs = socket.socketpair()
│ │ │ │ │ -
│ │ │ │ │ - with theirs:
│ │ │ │ │ - command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd))
│ │ │ │ │ -
│ │ │ │ │ - with ours:
│ │ │ │ │ - return int(ours.recv(16) or b'1')
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def main() -> None:
│ │ │ │ │ - if len(sys.argv) == 1:
│ │ │ │ │ - command(2, 'ferny.end', [])
│ │ │ │ │ - else:
│ │ │ │ │ - sys.exit(askpass(2, 1, sys.argv, dict(os.environ)))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -''',
│ │ │ │ │ 'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -5854,76 +7411,14 @@
│ │ │ │ │ assert self._subprocess_transport is not None
│ │ │ │ │ self._subprocess_transport.send_signal(number)
│ │ │ │ │
│ │ │ │ │ def terminate(self) -> None:
│ │ │ │ │ assert self._subprocess_transport is not None
│ │ │ │ │ self._subprocess_transport.terminate()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import (
│ │ │ │ │ - BEIBOOT_GADGETS,
│ │ │ │ │ - COMMAND_TEMPLATE,
│ │ │ │ │ - AskpassHandler,
│ │ │ │ │ - InteractionAgent,
│ │ │ │ │ - InteractionError,
│ │ │ │ │ - InteractionHandler,
│ │ │ │ │ - temporary_askpass,
│ │ │ │ │ - write_askpass_to_tmpdir,
│ │ │ │ │ -)
│ │ │ │ │ -from .session import Session
│ │ │ │ │ -from .ssh_askpass import (
│ │ │ │ │ - AskpassPrompt,
│ │ │ │ │ - SshAskpassResponder,
│ │ │ │ │ - SshFIDOPINPrompt,
│ │ │ │ │ - SshFIDOUserPresencePrompt,
│ │ │ │ │ - SshHostKeyPrompt,
│ │ │ │ │ - SshPassphrasePrompt,
│ │ │ │ │ - SshPasswordPrompt,
│ │ │ │ │ - SshPKCS11PINPrompt,
│ │ │ │ │ -)
│ │ │ │ │ -from .ssh_errors import (
│ │ │ │ │ - SshAuthenticationError,
│ │ │ │ │ - SshChangedHostKeyError,
│ │ │ │ │ - SshError,
│ │ │ │ │ - SshHostKeyError,
│ │ │ │ │ - SshUnknownHostKeyError,
│ │ │ │ │ -)
│ │ │ │ │ -from .transport import FernyTransport, SubprocessError
│ │ │ │ │ -
│ │ │ │ │ -__all__ = [
│ │ │ │ │ - 'AskpassHandler',
│ │ │ │ │ - 'AskpassPrompt',
│ │ │ │ │ - 'AuthenticationError',
│ │ │ │ │ - 'BEIBOOT_GADGETS',
│ │ │ │ │ - 'COMMAND_TEMPLATE',
│ │ │ │ │ - 'ChangedHostKeyError',
│ │ │ │ │ - 'FernyTransport',
│ │ │ │ │ - 'HostKeyError',
│ │ │ │ │ - 'InteractionAgent',
│ │ │ │ │ - 'InteractionError',
│ │ │ │ │ - 'InteractionHandler',
│ │ │ │ │ - 'Session',
│ │ │ │ │ - 'SshAskpassResponder',
│ │ │ │ │ - 'SshAuthenticationError',
│ │ │ │ │ - 'SshChangedHostKeyError',
│ │ │ │ │ - 'SshError',
│ │ │ │ │ - 'SshFIDOPINPrompt',
│ │ │ │ │ - 'SshFIDOUserPresencePrompt',
│ │ │ │ │ - 'SshHostKeyError',
│ │ │ │ │ - 'SshHostKeyPrompt',
│ │ │ │ │ - 'SshPKCS11PINPrompt',
│ │ │ │ │ - 'SshPassphrasePrompt',
│ │ │ │ │ - 'SshPasswordPrompt',
│ │ │ │ │ - 'SshUnknownHostKeyError',
│ │ │ │ │ - 'SubprocessError',
│ │ │ │ │ - 'temporary_askpass',
│ │ │ │ │ - 'write_askpass_to_tmpdir',
│ │ │ │ │ -]
│ │ │ │ │ -
│ │ │ │ │ -__version__ = '0'
│ │ │ │ │ -''',
│ │ │ │ │ 'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -6338,14 +7833,77 @@
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ @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/py.typed': br'''''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import (
│ │ │ │ │ + BEIBOOT_GADGETS,
│ │ │ │ │ + COMMAND_TEMPLATE,
│ │ │ │ │ + AskpassHandler,
│ │ │ │ │ + InteractionAgent,
│ │ │ │ │ + InteractionError,
│ │ │ │ │ + InteractionHandler,
│ │ │ │ │ + temporary_askpass,
│ │ │ │ │ + write_askpass_to_tmpdir,
│ │ │ │ │ +)
│ │ │ │ │ +from .session import Session
│ │ │ │ │ +from .ssh_askpass import (
│ │ │ │ │ + AskpassPrompt,
│ │ │ │ │ + SshAskpassResponder,
│ │ │ │ │ + SshFIDOPINPrompt,
│ │ │ │ │ + SshFIDOUserPresencePrompt,
│ │ │ │ │ + SshHostKeyPrompt,
│ │ │ │ │ + SshPassphrasePrompt,
│ │ │ │ │ + SshPasswordPrompt,
│ │ │ │ │ + SshPKCS11PINPrompt,
│ │ │ │ │ +)
│ │ │ │ │ +from .ssh_errors import (
│ │ │ │ │ + SshAuthenticationError,
│ │ │ │ │ + SshChangedHostKeyError,
│ │ │ │ │ + SshError,
│ │ │ │ │ + SshHostKeyError,
│ │ │ │ │ + SshUnknownHostKeyError,
│ │ │ │ │ +)
│ │ │ │ │ +from .transport import FernyTransport, SubprocessError
│ │ │ │ │ +
│ │ │ │ │ +__all__ = [
│ │ │ │ │ + 'AskpassHandler',
│ │ │ │ │ + 'AskpassPrompt',
│ │ │ │ │ + 'AuthenticationError',
│ │ │ │ │ + 'BEIBOOT_GADGETS',
│ │ │ │ │ + 'COMMAND_TEMPLATE',
│ │ │ │ │ + 'ChangedHostKeyError',
│ │ │ │ │ + 'FernyTransport',
│ │ │ │ │ + 'HostKeyError',
│ │ │ │ │ + 'InteractionAgent',
│ │ │ │ │ + 'InteractionError',
│ │ │ │ │ + 'InteractionHandler',
│ │ │ │ │ + 'Session',
│ │ │ │ │ + 'SshAskpassResponder',
│ │ │ │ │ + 'SshAuthenticationError',
│ │ │ │ │ + 'SshChangedHostKeyError',
│ │ │ │ │ + 'SshError',
│ │ │ │ │ + 'SshFIDOPINPrompt',
│ │ │ │ │ + 'SshFIDOUserPresencePrompt',
│ │ │ │ │ + 'SshHostKeyError',
│ │ │ │ │ + 'SshHostKeyPrompt',
│ │ │ │ │ + 'SshPKCS11PINPrompt',
│ │ │ │ │ + 'SshPassphrasePrompt',
│ │ │ │ │ + 'SshPasswordPrompt',
│ │ │ │ │ + 'SshUnknownHostKeyError',
│ │ │ │ │ + 'SubprocessError',
│ │ │ │ │ + 'temporary_askpass',
│ │ │ │ │ + 'write_askpass_to_tmpdir',
│ │ │ │ │ +]
│ │ │ │ │ +
│ │ │ │ │ +__version__ = '0'
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1)
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2023 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ @@ -6466,1612 +8024,541 @@
│ │ │ │ │ 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/bei/tmpfs.py': br'''import os
│ │ │ │ │ -import subprocess
│ │ │ │ │ -import sys
│ │ │ │ │ -import tempfile
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def main(*command: str) -> None:
│ │ │ │ │ - with tempfile.TemporaryDirectory() as tmpdir:
│ │ │ │ │ - os.chdir(tmpdir)
│ │ │ │ │ -
│ │ │ │ │ - for key, value in __loader__.get_contents().items():
│ │ │ │ │ - if key.startswith('tmpfs/'):
│ │ │ │ │ - subdir = os.path.dirname(key)
│ │ │ │ │ - os.makedirs(subdir, exist_ok=True)
│ │ │ │ │ - with open(key, 'wb') as fp:
│ │ │ │ │ - fp.write(value)
│ │ │ │ │ -
│ │ │ │ │ - os.chdir('tmpfs')
│ │ │ │ │ -
│ │ │ │ │ - result = subprocess.run(command, check=False)
│ │ │ │ │ - sys.exit(result.returncode)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (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 binascii
│ │ │ │ │ -import lzma
│ │ │ │ │ -import os
│ │ │ │ │ -import sys
│ │ │ │ │ -import tempfile
│ │ │ │ │ -import zipfile
│ │ │ │ │ -from typing import Dict, Iterable, List, Optional, Set, Tuple
│ │ │ │ │ -
│ │ │ │ │ -from .data import read_data_file
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def ascii_bytes_repr(data: bytes) -> str:
│ │ │ │ │ - return 'b' + escape_string(data.decode('ascii'))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def utf8_bytes_repr(data: bytes) -> str:
│ │ │ │ │ - return escape_string(data.decode('utf-8')) + ".encode('utf-8')"
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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}")'
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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
│ │ │ │ │ -
│ │ │ │ │ - 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
│ │ │ │ │ -
│ │ │ │ │ - return base64_bytes_repr(data, imports)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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()) +
│ │ │ │ │ - '}')
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def pack(contents: Dict[str, bytes],
│ │ │ │ │ - entrypoint: Optional[str] = None,
│ │ │ │ │ - args: str = '') -> str:
│ │ │ │ │ - """Creates a beipack with the given `contents`.
│ │ │ │ │ -
│ │ │ │ │ - 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()
│ │ │ │ │ -
│ │ │ │ │ - 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('')
│ │ │ │ │ -
│ │ │ │ │ - imports = {'import sys'}
│ │ │ │ │ - contents_txt = dict_repr(contents, imports)
│ │ │ │ │ - lines.extend(imports)
│ │ │ │ │ - lines.append(f'sys.meta_path.insert(0, BeipackLoader({contents_txt}))')
│ │ │ │ │ -
│ │ │ │ │ - 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)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def collect_contents(filenames: List[str],
│ │ │ │ │ - relative_to: Optional[str] = None) -> Dict[str, bytes]:
│ │ │ │ │ - contents: Dict[str, bytes] = {}
│ │ │ │ │ -
│ │ │ │ │ - for filename in filenames:
│ │ │ │ │ - with open(filename, 'rb') as file:
│ │ │ │ │ - contents[os.path.relpath(filename, start=relative_to)] = file.read()
│ │ │ │ │ -
│ │ │ │ │ - return contents
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def collect_module(name: str, *, recursive: bool) -> Dict[str, bytes]:
│ │ │ │ │ - import importlib.resources
│ │ │ │ │ - from importlib.resources.abc import Traversable
│ │ │ │ │ -
│ │ │ │ │ - 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)
│ │ │ │ │ -
│ │ │ │ │ - return dict(walk(name.replace('.', '/'), importlib.resources.files(name)))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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:
│ │ │ │ │ - continue
│ │ │ │ │ - contents[entry.filename] = file.read(entry)
│ │ │ │ │ -
│ │ │ │ │ - return contents
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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()
│ │ │ │ │ -
│ │ │ │ │ - contents = collect_contents(args.files, relative_to=args.topdir)
│ │ │ │ │ -
│ │ │ │ │ - for file in args.zip:
│ │ │ │ │ - contents.update(collect_zip(file))
│ │ │ │ │ -
│ │ │ │ │ - for name in args.module:
│ │ │ │ │ - contents.update(collect_module(name, recursive=True))
│ │ │ │ │ -
│ │ │ │ │ - for path in args.build:
│ │ │ │ │ - contents.update(collect_pep517(path))
│ │ │ │ │ -
│ │ │ │ │ - result = pack(contents, args.main, args.main_args).encode('utf-8')
│ │ │ │ │ -
│ │ │ │ │ - if args.python:
│ │ │ │ │ - result = b'#!' + args.python.encode('ascii') + b'\n' + result
│ │ │ │ │ -
│ │ │ │ │ - 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)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -''',
│ │ │ │ │ - '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 .
│ │ │ │ │ -
│ │ │ │ │ -import textwrap
│ │ │ │ │ -from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
│ │ │ │ │ -
│ │ │ │ │ -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 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 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/_vendor/bei/spawn.py': br'''"""Helper to create a beipack to spawn a command with files in a tmpdir"""
│ │ │ │ │ -
│ │ │ │ │ -import argparse
│ │ │ │ │ -import os
│ │ │ │ │ -import sys
│ │ │ │ │ -
│ │ │ │ │ -from . import pack, tmpfs
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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()
│ │ │ │ │ -
│ │ │ │ │ - contents = {
│ │ │ │ │ - '_beitmpfs.py': tmpfs.__spec__.loader.get_data(tmpfs.__spec__.origin)
│ │ │ │ │ - }
│ │ │ │ │ -
│ │ │ │ │ - if args.file is not None:
│ │ │ │ │ - files = args.file
│ │ │ │ │ - else:
│ │ │ │ │ - file = args.command[-1]
│ │ │ │ │ - files = [file]
│ │ │ │ │ - args.command[-1] = './' + os.path.basename(file)
│ │ │ │ │ -
│ │ │ │ │ - for filename in files:
│ │ │ │ │ - with open(filename, 'rb') as file:
│ │ │ │ │ - basename = os.path.basename(filename)
│ │ │ │ │ - contents[f'tmpfs/{basename}'] = file.read()
│ │ │ │ │ -
│ │ │ │ │ - script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command))
│ │ │ │ │ - sys.stdout.write(script)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if __name__ == '__main__':
│ │ │ │ │ - main()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/bei/beiboot.py': br"""# beiboot - Remote bootloader for Python
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (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 .
│ │ │ │ │ + 'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3
│ │ │ │ │
│ │ │ │ │ -import argparse
│ │ │ │ │ -import asyncio
│ │ │ │ │ +import array
│ │ │ │ │ +import io
│ │ │ │ │ import os
│ │ │ │ │ -import shlex
│ │ │ │ │ -import subprocess
│ │ │ │ │ +import socket
│ │ │ │ │ import sys
│ │ │ │ │ -import threading
│ │ │ │ │ -from typing import IO, List, Sequence, Tuple
│ │ │ │ │ -
│ │ │ │ │ -from .bootloader import make_bootloader
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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]
│ │ │ │ │ -
│ │ │ │ │ - if tty:
│ │ │ │ │ - command = (interpreter, '-iq')
│ │ │ │ │ - else:
│ │ │ │ │ - command = (
│ │ │ │ │ - interpreter, '-ic',
│ │ │ │ │ - # https://github.com/python/cpython/issues/93139
│ │ │ │ │ - '''" - beiboot - "; import sys; sys.ps1 = ''; sys.ps2 = '';'''
│ │ │ │ │ - )
│ │ │ │ │ -
│ │ │ │ │ - if sh:
│ │ │ │ │ - command = (' '.join(shlex.quote(arg) for arg in command),)
│ │ │ │ │ -
│ │ │ │ │ - return command
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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 get_command(*args: str, tty: bool = False, sh: bool = False) -> Sequence[str]:
│ │ │ │ │ - return (*args, *get_python_command(local=True, tty=tty, sh=sh))
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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()
│ │ │ │ │ -
│ │ │ │ │ - threading.Thread(target=_thread, daemon=True).start()
│ │ │ │ │ +from typing import Sequence
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -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)
│ │ │ │ │ +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')]
│ │ │ │ │
│ │ │ │ │ - splice_in_thread(0, proc.stdin)
│ │ │ │ │ - sys.exit(proc.wait())
│ │ │ │ │ + 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 send_xz_and_splice(command: Sequence[str], script: bytes) -> None:
│ │ │ │ │ - import ferny
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ +def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int:
│ │ │ │ │ + ours, theirs = socket.socketpair()
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + with theirs:
│ │ │ │ │ + command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd))
│ │ │ │ │
│ │ │ │ │ - asyncio.run(agent.communicate())
│ │ │ │ │ - splice_in_thread(0, proc.stdin)
│ │ │ │ │ - sys.exit(proc.wait())
│ │ │ │ │ + with ours:
│ │ │ │ │ + return int(ours.recv(16) or b'1')
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ 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='*')
│ │ │ │ │ -
│ │ │ │ │ - args = parser.parse_args()
│ │ │ │ │ - tty = not args.script and os.isatty(0)
│ │ │ │ │ -
│ │ │ │ │ - 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)
│ │ │ │ │ -
│ │ │ │ │ - if args.script:
│ │ │ │ │ - with open(args.script, 'rb') as file:
│ │ │ │ │ - script = file.read()
│ │ │ │ │ -
│ │ │ │ │ - send_and_splice(command, script)
│ │ │ │ │ -
│ │ │ │ │ - elif args.xz:
│ │ │ │ │ - with open(args.xz, 'rb') as file:
│ │ │ │ │ - script = file.read()
│ │ │ │ │ -
│ │ │ │ │ - send_xz_and_splice(command, script)
│ │ │ │ │ -
│ │ │ │ │ + if len(sys.argv) == 1:
│ │ │ │ │ + command(2, 'ferny.end', [])
│ │ │ │ │ else:
│ │ │ │ │ - # If we're streaming from stdin then this is a lot easier...
│ │ │ │ │ - os.execlp(command[0], *command)
│ │ │ │ │ + sys.exit(askpass(2, 1, sys.argv, dict(os.environ)))
│ │ │ │ │
│ │ │ │ │ - # Otherwise, "full strength"
│ │ │ │ │
│ │ │ │ │ if __name__ == '__main__':
│ │ │ │ │ main()
│ │ │ │ │ -""",
│ │ │ │ │ - 'cockpit/_vendor/bei/__init__.py': br'''''',
│ │ │ │ │ - 'cockpit/_vendor/bei/data/__init__.py': br'''import sys
│ │ │ │ │ -
│ │ │ │ │ -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')
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack
│ │ │ │ │ -
│ │ │ │ │ -import importlib.abc
│ │ │ │ │ -import importlib.util
│ │ │ │ │ -import io
│ │ │ │ │ -import sys
│ │ │ │ │ -from types import ModuleType
│ │ │ │ │ -from typing import BinaryIO, Dict, Iterator, Optional, Sequence
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -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 ResourceReader(AbstractResourceReader):
│ │ │ │ │ - def __init__(self, contents: Dict[str, bytes], filename: str) -> None:
│ │ │ │ │ - self._contents = contents
│ │ │ │ │ - self._dir = f'{filename}/'
│ │ │ │ │ -
│ │ │ │ │ - def is_resource(self, resource: str) -> bool:
│ │ │ │ │ - return f'{self._dir}{resource}' in self._contents
│ │ │ │ │ -
│ │ │ │ │ - def open_resource(self, resource: str) -> BinaryIO:
│ │ │ │ │ - return io.BytesIO(self._contents[f'{self._dir}{resource}'])
│ │ │ │ │ -
│ │ │ │ │ - def resource_path(self, resource: str) -> str:
│ │ │ │ │ - raise FileNotFoundError
│ │ │ │ │ -
│ │ │ │ │ - def contents(self) -> Iterator[str]:
│ │ │ │ │ - dir_length = len(self._dir)
│ │ │ │ │ - result = set()
│ │ │ │ │ -
│ │ │ │ │ - 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)
│ │ │ │ │ -
│ │ │ │ │ - contents: Dict[str, bytes]
│ │ │ │ │ - modules: Dict[str, str]
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self, contents: Dict[str, bytes]) -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - contents[__file__] = __self_source__ # type: ignore[name-defined]
│ │ │ │ │ - except NameError:
│ │ │ │ │ - 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_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/_vendor/systemd_ctypes/bus.py': br'''# systemd_ctypes
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (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 enum
│ │ │ │ │ -import logging
│ │ │ │ │ -import typing
│ │ │ │ │ -from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union
│ │ │ │ │ -
│ │ │ │ │ -from . import bustypes, introspection, libsystemd
│ │ │ │ │ -from .librarywrapper import WeakReference, byref
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class BusError(Exception):
│ │ │ │ │ - """An exception corresponding to a D-Bus error message
│ │ │ │ │ -
│ │ │ │ │ - This exception is raised by the method call methods. You can also raise it
│ │ │ │ │ - from your own method handlers. It can also be passed directly to functions
│ │ │ │ │ - such as Message.reply_method_error().
│ │ │ │ │ -
│ │ │ │ │ - :name: the 'code' of the error, like org.freedesktop.DBus.Error.UnknownMethod
│ │ │ │ │ - :message: a human-readable description of the error
│ │ │ │ │ - """
│ │ │ │ │ - def __init__(self, name: str, message: str):
│ │ │ │ │ - super().__init__(f'{name}: {message}')
│ │ │ │ │ - self.name = name
│ │ │ │ │ - self.message = message
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class BusMessage(libsystemd.sd_bus_message):
│ │ │ │ │ - """A message, received from or to be sent over D-Bus
│ │ │ │ │ -
│ │ │ │ │ - This is the low-level interface to receiving and sending individual
│ │ │ │ │ - messages over D-Bus. You won't normally need to use it.
│ │ │ │ │ -
│ │ │ │ │ - A message is associated with a particular bus. You can create messages for
│ │ │ │ │ - a bus with Bus.message_new_method_call() or Bus.message_new_signal(). You
│ │ │ │ │ - can create replies to method calls with Message.new_method_return() or
│ │ │ │ │ - Message.new_method_error(). You can append arguments with Message.append()
│ │ │ │ │ - and send the message with Message.send().
│ │ │ │ │ - """
│ │ │ │ │ - def get_bus(self) -> 'Bus':
│ │ │ │ │ - """Get the bus that a message is associated with.
│ │ │ │ │ -
│ │ │ │ │ - This is the bus that a message came from or will be sent on. Every
│ │ │ │ │ - message has an associated bus, and it cannot be changed.
│ │ │ │ │ -
│ │ │ │ │ - :returns: the Bus
│ │ │ │ │ - """
│ │ │ │ │ - return Bus.ref(self._get_bus())
│ │ │ │ │ -
│ │ │ │ │ - def get_error(self) -> Optional[BusError]:
│ │ │ │ │ - """Get the BusError from a message.
│ │ │ │ │ -
│ │ │ │ │ - :returns: a BusError for an error message, or None for a non-error message
│ │ │ │ │ - """
│ │ │ │ │ - error = self._get_error()
│ │ │ │ │ - if error:
│ │ │ │ │ - return BusError(*error.contents.get())
│ │ │ │ │ - else:
│ │ │ │ │ - return None
│ │ │ │ │ -
│ │ │ │ │ - def new_method_return(self, signature: str = '', *args: Any) -> 'BusMessage':
│ │ │ │ │ - """Create a new (successful) return message as a reply to this message.
│ │ │ │ │ -
│ │ │ │ │ - This only makes sense when performed on a method call message.
│ │ │ │ │ -
│ │ │ │ │ - :signature: The signature of the result, as a string.
│ │ │ │ │ - :args: The values to send, conforming to the signature string.
│ │ │ │ │ -
│ │ │ │ │ - :returns: the reply message
│ │ │ │ │ - """
│ │ │ │ │ - reply = BusMessage()
│ │ │ │ │ - self._new_method_return(byref(reply))
│ │ │ │ │ - reply.append(signature, *args)
│ │ │ │ │ - return reply
│ │ │ │ │ -
│ │ │ │ │ - def new_method_error(self, error: Union[BusError, OSError]) -> 'BusMessage':
│ │ │ │ │ - """Create a new error message as a reply to this message.
│ │ │ │ │ -
│ │ │ │ │ - This only makes sense when performed on a method call message.
│ │ │ │ │ -
│ │ │ │ │ - :error: BusError or OSError of the error to send
│ │ │ │ │ -
│ │ │ │ │ - :returns: the reply message
│ │ │ │ │ - """
│ │ │ │ │ - reply = BusMessage()
│ │ │ │ │ - if isinstance(error, BusError):
│ │ │ │ │ - self._new_method_errorf(byref(reply), error.name, "%s", error.message)
│ │ │ │ │ - else:
│ │ │ │ │ - assert isinstance(error, OSError)
│ │ │ │ │ - self._new_method_errnof(byref(reply), error.errno, "%s", str(error))
│ │ │ │ │ - return reply
│ │ │ │ │ -
│ │ │ │ │ - def append_arg(self, typestring: str, arg: Any) -> None:
│ │ │ │ │ - """Append a single argument to the message.
│ │ │ │ │ -
│ │ │ │ │ - :typestring: a single typestring, such as 's', or 'a{sv}'
│ │ │ │ │ - :arg: the argument to append, matching the typestring
│ │ │ │ │ - """
│ │ │ │ │ - type_, = bustypes.from_signature(typestring)
│ │ │ │ │ - type_.writer(self, arg)
│ │ │ │ │ -
│ │ │ │ │ - def append(self, signature: str, *args: Any) -> None:
│ │ │ │ │ - """Append zero or more arguments to the message.
│ │ │ │ │ -
│ │ │ │ │ - :signature: concatenated typestrings, such 'a{sv}' (one arg), or 'ss' (two args)
│ │ │ │ │ - :args: one argument for each type string in the signature
│ │ │ │ │ - """
│ │ │ │ │ - types = bustypes.from_signature(signature)
│ │ │ │ │ - assert len(types) == len(args), f'call args {args} have different length than signature {signature}'
│ │ │ │ │ - for type_, arg in zip(types, args):
│ │ │ │ │ - type_.writer(self, arg)
│ │ │ │ │ -
│ │ │ │ │ - def get_body(self) -> Tuple[object, ...]:
│ │ │ │ │ - """Gets the body of a message.
│ │ │ │ │ -
│ │ │ │ │ - Possible return values are (), ('single',), or ('x', 'y'). If you
│ │ │ │ │ - check the signature of the message using Message.has_signature() then
│ │ │ │ │ - you can use tuple unpacking.
│ │ │ │ │ -
│ │ │ │ │ - single, = message.get_body()
│ │ │ │ │ -
│ │ │ │ │ - x, y = other_message.get_body()
│ │ │ │ │ -
│ │ │ │ │ - :returns: an n-tuple containing one value per argument in the message
│ │ │ │ │ - """
│ │ │ │ │ - self.rewind(True)
│ │ │ │ │ - types = bustypes.from_signature(self.get_signature(True))
│ │ │ │ │ - return tuple(type_.reader(self) for type_ in types)
│ │ │ │ │ -
│ │ │ │ │ - def send(self) -> bool: # Literal[True]
│ │ │ │ │ - """Sends a message on the bus that it was created for.
│ │ │ │ │ -
│ │ │ │ │ - :returns: True
│ │ │ │ │ - """
│ │ │ │ │ - self.get_bus().send(self, None)
│ │ │ │ │ - return True
│ │ │ │ │ -
│ │ │ │ │ - def reply_method_error(self, error: Union[BusError, OSError]) -> bool: # Literal[True]
│ │ │ │ │ - """Sends an error as a reply to a method call message.
│ │ │ │ │ -
│ │ │ │ │ - :error: A BusError or OSError
│ │ │ │ │ -
│ │ │ │ │ - :returns: True
│ │ │ │ │ - """
│ │ │ │ │ - return self.new_method_error(error).send()
│ │ │ │ │ -
│ │ │ │ │ - def reply_method_return(self, signature: str = '', *args: Any) -> bool: # Literal[True]
│ │ │ │ │ - """Sends a return value as a reply to a method call message.
│ │ │ │ │ -
│ │ │ │ │ - :signature: The signature of the result, as a string.
│ │ │ │ │ - :args: The values to send, conforming to the signature string.
│ │ │ │ │ -
│ │ │ │ │ - :returns: True
│ │ │ │ │ - """
│ │ │ │ │ - return self.new_method_return(signature, *args).send()
│ │ │ │ │ -
│ │ │ │ │ - def _coroutine_task_complete(self, out_type: bustypes.MessageType, task: asyncio.Task) -> None:
│ │ │ │ │ - try:
│ │ │ │ │ - self.reply_method_function_return_value(out_type, task.result())
│ │ │ │ │ - except (BusError, OSError) as exc:
│ │ │ │ │ - self.reply_method_error(exc)
│ │ │ │ │ -
│ │ │ │ │ - def reply_method_function_return_value(self,
│ │ │ │ │ - out_type: bustypes.MessageType,
│ │ │ │ │ - return_value: Any) -> bool: # Literal[True]:
│ │ │ │ │ - """Sends the result of a function call as a reply to a method call message.
│ │ │ │ │ -
│ │ │ │ │ - This call does a bit of magic: it adapts from the usual Python return
│ │ │ │ │ - value conventions (where the return value is ``None``, a single value,
│ │ │ │ │ - or a tuple) to the normal D-Bus return value conventions (where the
│ │ │ │ │ - result is always a tuple).
│ │ │ │ │ -
│ │ │ │ │ - Additionally, if the value is found to be a coroutine, a task is
│ │ │ │ │ - created to run the coroutine to completion and return the result
│ │ │ │ │ - (including exception handling).
│ │ │ │ │ -
│ │ │ │ │ - :out_types: The types of the return values, as an iterable.
│ │ │ │ │ - :return_value: The return value of a Python function call.
│ │ │ │ │ -
│ │ │ │ │ - :returns: True
│ │ │ │ │ - """
│ │ │ │ │ - if asyncio.coroutines.iscoroutine(return_value):
│ │ │ │ │ - task = asyncio.create_task(return_value)
│ │ │ │ │ - task.add_done_callback(lambda task: self._coroutine_task_complete(out_type, task))
│ │ │ │ │ - return True
│ │ │ │ │ -
│ │ │ │ │ - reply = self.new_method_return()
│ │ │ │ │ - # In the general case, a function returns an n-tuple, but...
│ │ │ │ │ - if len(out_type) == 0:
│ │ │ │ │ - # Functions with no return value return None.
│ │ │ │ │ - assert return_value is None
│ │ │ │ │ - elif len(out_type) == 1:
│ │ │ │ │ - # Functions with a single return value return that value.
│ │ │ │ │ - out_type.write(reply, return_value)
│ │ │ │ │ - else:
│ │ │ │ │ - # (general case) n return values are handled as an n-tuple.
│ │ │ │ │ - assert len(out_type) == len(return_value)
│ │ │ │ │ - out_type.write(reply, *return_value)
│ │ │ │ │ - return reply.send()
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Slot(libsystemd.sd_bus_slot):
│ │ │ │ │ - def __init__(self, callback: Callable[[BusMessage], bool]):
│ │ │ │ │ - def handler(message: WeakReference, _data: object, _err: object) -> int:
│ │ │ │ │ - return 1 if callback(BusMessage.ref(message)) else 0
│ │ │ │ │ - self.trampoline = libsystemd.sd_bus_message_handler_t(handler)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if typing.TYPE_CHECKING:
│ │ │ │ │ - FutureMessage = asyncio.Future[BusMessage]
│ │ │ │ │ -else:
│ │ │ │ │ - # Python 3.6 can't subscript asyncio.Future
│ │ │ │ │ - FutureMessage = asyncio.Future
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class PendingCall(Slot):
│ │ │ │ │ - future: FutureMessage
│ │ │ │ │ -
│ │ │ │ │ - def __init__(self) -> None:
│ │ │ │ │ - future = asyncio.get_running_loop().create_future()
│ │ │ │ │ -
│ │ │ │ │ - def done(message: BusMessage) -> bool:
│ │ │ │ │ - error = message.get_error()
│ │ │ │ │ - if future.cancelled():
│ │ │ │ │ - return True
│ │ │ │ │ - if error is not None:
│ │ │ │ │ - future.set_exception(error)
│ │ │ │ │ - else:
│ │ │ │ │ - future.set_result(message)
│ │ │ │ │ - return True
│ │ │ │ │ -
│ │ │ │ │ - super().__init__(done)
│ │ │ │ │ - self.future = future
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Bus(libsystemd.sd_bus):
│ │ │ │ │ - _default_system_instance = None
│ │ │ │ │ - _default_user_instance = None
│ │ │ │ │ -
│ │ │ │ │ - class NameFlags(enum.IntFlag):
│ │ │ │ │ - DEFAULT = 0
│ │ │ │ │ - REPLACE_EXISTING = 1 << 0
│ │ │ │ │ - ALLOW_REPLACEMENT = 1 << 1
│ │ │ │ │ - QUEUE = 1 << 2
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def new(
│ │ │ │ │ - fd: Optional[int] = None,
│ │ │ │ │ - address: Optional[str] = None,
│ │ │ │ │ - bus_client: bool = False,
│ │ │ │ │ - server: bool = False,
│ │ │ │ │ - start: bool = True,
│ │ │ │ │ - attach_event: bool = True
│ │ │ │ │ - ) -> 'Bus':
│ │ │ │ │ - bus = Bus()
│ │ │ │ │ - Bus._new(byref(bus))
│ │ │ │ │ - if address is not None:
│ │ │ │ │ - bus.set_address(address)
│ │ │ │ │ - if fd is not None:
│ │ │ │ │ - bus.set_fd(fd, fd)
│ │ │ │ │ - if bus_client:
│ │ │ │ │ - bus.set_bus_client(True)
│ │ │ │ │ - if server:
│ │ │ │ │ - bus.set_server(True, libsystemd.sd_id128())
│ │ │ │ │ - if address is not None or fd is not None:
│ │ │ │ │ - if start:
│ │ │ │ │ - bus.start()
│ │ │ │ │ - if attach_event:
│ │ │ │ │ - bus.attach_event(None, 0)
│ │ │ │ │ - return bus
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def default_system(attach_event: bool = True) -> 'Bus':
│ │ │ │ │ - if Bus._default_system_instance is None:
│ │ │ │ │ - Bus._default_system_instance = Bus()
│ │ │ │ │ - Bus._default_system(byref(Bus._default_system_instance))
│ │ │ │ │ - if attach_event:
│ │ │ │ │ - Bus._default_system_instance.attach_event(None, 0)
│ │ │ │ │ - return Bus._default_system_instance
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def default_user(attach_event: bool = True) -> 'Bus':
│ │ │ │ │ - if Bus._default_user_instance is None:
│ │ │ │ │ - Bus._default_user_instance = Bus()
│ │ │ │ │ - Bus._default_user(byref(Bus._default_user_instance))
│ │ │ │ │ - if attach_event:
│ │ │ │ │ - Bus._default_user_instance.attach_event(None, 0)
│ │ │ │ │ - return Bus._default_user_instance
│ │ │ │ │ -
│ │ │ │ │ - def message_new_method_call(
│ │ │ │ │ - self,
│ │ │ │ │ - destination: Optional[str],
│ │ │ │ │ - path: str,
│ │ │ │ │ - interface: str,
│ │ │ │ │ - member: str,
│ │ │ │ │ - types: str = '',
│ │ │ │ │ - *args: object
│ │ │ │ │ - ) -> BusMessage:
│ │ │ │ │ - message = BusMessage()
│ │ │ │ │ - self._message_new_method_call(byref(message), destination, path, interface, member)
│ │ │ │ │ - message.append(types, *args)
│ │ │ │ │ - return message
│ │ │ │ │ -
│ │ │ │ │ - def message_new_signal(
│ │ │ │ │ - self, path: str, interface: str, member: str, types: str = '', *args: object
│ │ │ │ │ - ) -> BusMessage:
│ │ │ │ │ - message = BusMessage()
│ │ │ │ │ - self._message_new_signal(byref(message), path, interface, member)
│ │ │ │ │ - message.append(types, *args)
│ │ │ │ │ - return message
│ │ │ │ │ -
│ │ │ │ │ - def call(self, message: BusMessage, timeout: Optional[int] = None) -> BusMessage:
│ │ │ │ │ - reply = BusMessage()
│ │ │ │ │ - error = libsystemd.sd_bus_error()
│ │ │ │ │ - try:
│ │ │ │ │ - self._call(message, timeout or 0, byref(error), byref(reply))
│ │ │ │ │ - return reply
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - raise BusError(*error.get()) from exc
│ │ │ │ │ -
│ │ │ │ │ - def call_method(
│ │ │ │ │ - self,
│ │ │ │ │ - destination: str,
│ │ │ │ │ - path: str,
│ │ │ │ │ - interface: str,
│ │ │ │ │ - member: str,
│ │ │ │ │ - types: str = '',
│ │ │ │ │ - *args: object,
│ │ │ │ │ - timeout: Optional[int] = None
│ │ │ │ │ - ) -> Tuple[object, ...]:
│ │ │ │ │ - logger.debug('Doing sync method call %s %s %s %s %s %s',
│ │ │ │ │ - destination, path, interface, member, types, args)
│ │ │ │ │ - message = self.message_new_method_call(destination, path, interface, member, types, *args)
│ │ │ │ │ - message = self.call(message, timeout)
│ │ │ │ │ - return message.get_body()
│ │ │ │ │ -
│ │ │ │ │ - async def call_async(
│ │ │ │ │ - self,
│ │ │ │ │ - message: BusMessage,
│ │ │ │ │ - timeout: Optional[int] = None
│ │ │ │ │ - ) -> BusMessage:
│ │ │ │ │ - pending = PendingCall()
│ │ │ │ │ - self._call_async(byref(pending), message, pending.trampoline, pending.userdata, timeout or 0)
│ │ │ │ │ - return await pending.future
│ │ │ │ │ -
│ │ │ │ │ - async def call_method_async(
│ │ │ │ │ - self,
│ │ │ │ │ - destination: Optional[str],
│ │ │ │ │ - path: str,
│ │ │ │ │ - interface: str,
│ │ │ │ │ - member: str,
│ │ │ │ │ - types: str = '',
│ │ │ │ │ - *args: object,
│ │ │ │ │ - timeout: Optional[int] = None
│ │ │ │ │ - ) -> Tuple[object, ...]:
│ │ │ │ │ - logger.debug('Doing async method call %s %s %s %s %s %s',
│ │ │ │ │ - destination, path, interface, member, types, args)
│ │ │ │ │ - message = self.message_new_method_call(destination, path, interface, member, types, *args)
│ │ │ │ │ - message = await self.call_async(message, timeout)
│ │ │ │ │ - return message.get_body()
│ │ │ │ │ -
│ │ │ │ │ - def add_match(self, rule: str, handler: Callable[[BusMessage], bool]) -> Slot:
│ │ │ │ │ - slot = Slot(handler)
│ │ │ │ │ - self._add_match(byref(slot), rule, slot.trampoline, slot.userdata)
│ │ │ │ │ - return slot
│ │ │ │ │ -
│ │ │ │ │ - def add_object(self, path: str, obj: 'BaseObject') -> Slot:
│ │ │ │ │ - slot = Slot(obj.message_received)
│ │ │ │ │ - self._add_object(byref(slot), path, slot.trampoline, slot.userdata)
│ │ │ │ │ - obj.registered_on_bus(self, path)
│ │ │ │ │ - return slot
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class BaseObject:
│ │ │ │ │ - """Base object type for exporting objects on the bus
│ │ │ │ │ -
│ │ │ │ │ - This is the lowest-level class that can be passed to Bus.add_object().
│ │ │ │ │ -
│ │ │ │ │ - If you want to directly subclass this, you'll need to implement
│ │ │ │ │ - `message_received()`.
│ │ │ │ │ -
│ │ │ │ │ - Subclassing from `bus.Object` is probably a better choice.
│ │ │ │ │ - """
│ │ │ │ │ - _dbus_bus: Optional[Bus] = None
│ │ │ │ │ - _dbus_path: Optional[str] = None
│ │ │ │ │ -
│ │ │ │ │ - def registered_on_bus(self, bus: Bus, path: str) -> None:
│ │ │ │ │ - """Report that an instance was exported on a given bus and path.
│ │ │ │ │ -
│ │ │ │ │ - This is used so that the instance knows where to send signals.
│ │ │ │ │ - Bus.add_object() calls this: you probably shouldn't call this on your
│ │ │ │ │ - own.
│ │ │ │ │ - """
│ │ │ │ │ - self._dbus_bus = bus
│ │ │ │ │ - self._dbus_path = path
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging
│ │ │ │ │ +import re
│ │ │ │ │ +from typing import ClassVar, Match, Sequence
│ │ │ │ │
│ │ │ │ │ - self.registered()
│ │ │ │ │ +from .interaction_agent import AskpassHandler
│ │ │ │ │
│ │ │ │ │ - def registered(self) -> None:
│ │ │ │ │ - """Called after an object has been registered on the bus
│ │ │ │ │ +logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ - This is the correct method to implement to do some initial work that
│ │ │ │ │ - needs to be done after registration. The default implementation does
│ │ │ │ │ - nothing.
│ │ │ │ │ - """
│ │ │ │ │ - pass
│ │ │ │ │
│ │ │ │ │ - def emit_signal(
│ │ │ │ │ - self, interface: str, name: str, signature: str, *args: Any
│ │ │ │ │ - ) -> bool:
│ │ │ │ │ - """Emit a D-Bus signal on this object
│ │ │ │ │ +class AskpassPrompt:
│ │ │ │ │ + """An askpass prompt resulting from a call to ferny-askpass.
│ │ │ │ │
│ │ │ │ │ - The object must have been exported on the bus with Bus.add_object().
│ │ │ │ │ + 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
│ │ │ │ │
│ │ │ │ │ - :interface: the interface of the signal
│ │ │ │ │ - :name: the 'member' name of the signal to emit
│ │ │ │ │ - :signature: the type signature, as a string
│ │ │ │ │ - :args: the arguments, according to the signature
│ │ │ │ │ - :returns: True
│ │ │ │ │ - """
│ │ │ │ │ - assert self._dbus_bus is not None
│ │ │ │ │ - assert self._dbus_path is not None
│ │ │ │ │ - return self._dbus_bus.message_new_signal(self._dbus_path, interface, name, signature, *args).send()
│ │ │ │ │ + def __init__(self, prompt: str, messages: str, stderr: str) -> None:
│ │ │ │ │ + self.stderr = stderr
│ │ │ │ │ + self.messages = messages
│ │ │ │ │ + self.prompt = prompt
│ │ │ │ │
│ │ │ │ │ - def message_received(self, message: BusMessage) -> bool:
│ │ │ │ │ - """Called when a message is received for this object
│ │ │ │ │ + def reply(self, response: str) -> None:
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - This is the lowest level interface to the BaseObject. You need to
│ │ │ │ │ - handle method calls, properties, and introspection.
│ │ │ │ │ + def close(self) -> None:
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - You are expected to handle the message and return True. Normally this
│ │ │ │ │ - means that you send a reply. If you don't want to handle the message,
│ │ │ │ │ - return False and other handlers will have a chance to run. If no
│ │ │ │ │ - handler handles the message, systemd will generate a suitable error
│ │ │ │ │ - message and send that, instead.
│ │ │ │ │ + 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()
│ │ │ │ │
│ │ │ │ │ - :message: the message that was received
│ │ │ │ │ - :returns: True if the message was handled
│ │ │ │ │ - """
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_prompt(self)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class Interface:
│ │ │ │ │ - """The high-level base class for defining D-Bus interfaces
│ │ │ │ │ +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
│ │ │ │ │
│ │ │ │ │ - This class provides high-level APIs for defining methods, properties, and
│ │ │ │ │ - signals, as well as implementing introspection.
│ │ │ │ │ + # 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]] = ()
│ │ │ │ │
│ │ │ │ │ - On its own, this class doesn't provide a mechanism for exporting anything
│ │ │ │ │ - on the bus. The Object class does that, and you'll generally want to
│ │ │ │ │ - subclass from it, as it contains several built-in standard interfaces
│ │ │ │ │ - (introspection, properties, etc.).
│ │ │ │ │ + def __init__(self, prompt: str, messages: str, stderr: str, match: Match) -> None:
│ │ │ │ │ + super().__init__(prompt, messages, stderr)
│ │ │ │ │ + self.__dict__.update(match.groupdict())
│ │ │ │ │
│ │ │ │ │ - The name of your class will be interpreted as a D-Bus interface name.
│ │ │ │ │ - Underscores are converted to dots. No case conversion is performed. If
│ │ │ │ │ - the interface name can't be represented using this scheme, or if you'd like
│ │ │ │ │ - to name your class differently, you can provide an interface= kwarg to the
│ │ │ │ │ - class definition.
│ │ │ │ │ + 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())
│ │ │ │ │
│ │ │ │ │ - class com_example_Interface(bus.Object):
│ │ │ │ │ - pass
│ │ │ │ │
│ │ │ │ │ - class MyInterface(bus.Object, interface='org.cockpit_project.Interface'):
│ │ │ │ │ - pass
│ │ │ │ │ +# 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[^ @']+)",
│ │ │ │ │ +}
│ │ │ │ │
│ │ │ │ │ - The methods, properties, and signals which are visible from D-Bus are
│ │ │ │ │ - defined using helper classes with the corresponding names (Method,
│ │ │ │ │ - Property, Signal). You should use normal Python snake_case conventions for
│ │ │ │ │ - the member names: they will automatically be converted to CamelCase by
│ │ │ │ │ - splitting on underscore and converting the first letter of each resulting
│ │ │ │ │ - word to uppercase. For example, `method_name` becomes `MethodName`.
│ │ │ │ │
│ │ │ │ │ - Each Method, Property, or Signal constructor takes an optional name= kwargs
│ │ │ │ │ - to override the automatic name conversion convention above.
│ │ │ │ │ +class SshPasswordPrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"%{username}@%{hostname}'s password: "
│ │ │ │ │ + username: 'str | None' = None
│ │ │ │ │ + hostname: 'str | None' = None
│ │ │ │ │
│ │ │ │ │ - An example class might look like:
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_password_prompt(self)
│ │ │ │ │
│ │ │ │ │ - class com_example_MyObject(bus.Object):
│ │ │ │ │ - created = bus.Interface.Signal('s', 'i')
│ │ │ │ │ - renames = bus.Interface.Property('u', value=0)
│ │ │ │ │ - name = bus.Interface.Property('s', 'undefined')
│ │ │ │ │
│ │ │ │ │ - @bus.Interface.Method(out_types=(), in_types='s')
│ │ │ │ │ - def rename(self, name):
│ │ │ │ │ - self.renames += 1
│ │ │ │ │ - self.name = name
│ │ │ │ │ +class SshPassphrasePrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Enter passphrase for key '%{filename}': "
│ │ │ │ │ + filename: str
│ │ │ │ │
│ │ │ │ │ - def registered(self):
│ │ │ │ │ - self.created('Hello', 42)
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_passphrase_prompt(self)
│ │ │ │ │
│ │ │ │ │ - See the documentation for the Method, Property, and Signal classes for
│ │ │ │ │ - more information and examples.
│ │ │ │ │ - """
│ │ │ │ │
│ │ │ │ │ - # Class variables
│ │ │ │ │ - _dbus_interfaces: Dict[str, Dict[str, Dict[str, Any]]]
│ │ │ │ │ - _dbus_members: Optional[Tuple[str, Dict[str, Dict[str, Any]]]]
│ │ │ │ │ +class SshFIDOPINPrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Enter PIN for %{algorithm} key %{filename}: "
│ │ │ │ │ + algorithm: str
│ │ │ │ │ + filename: str
│ │ │ │ │
│ │ │ │ │ - # Instance variables: stored in Python form
│ │ │ │ │ - _dbus_property_values: Optional[Dict[str, Any]] = None
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_fido_pin_prompt(self)
│ │ │ │ │
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def __init_subclass__(cls, interface: Optional[str] = None) -> None:
│ │ │ │ │ - if interface is None:
│ │ │ │ │ - assert '__' not in cls.__name__, 'Class name cannot contain sequential underscores'
│ │ │ │ │ - interface = cls.__name__.replace('_', '.')
│ │ │ │ │
│ │ │ │ │ - # This is the information for this subclass directly
│ │ │ │ │ - members: Dict[str, Dict[str, Interface._Member]] = {'methods': {}, 'properties': {}, 'signals': {}}
│ │ │ │ │ - for name, member in cls.__dict__.items():
│ │ │ │ │ - if isinstance(member, Interface._Member):
│ │ │ │ │ - member.setup(interface, name, members)
│ │ │ │ │ +class SshFIDOUserPresencePrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Confirm user presence for key %{algorithm} %{fingerprint}"
│ │ │ │ │ + answers = ()
│ │ │ │ │ + algorithm: str
│ │ │ │ │ + fingerprint: str
│ │ │ │ │
│ │ │ │ │ - # We only store the information if something was actually defined
│ │ │ │ │ - if sum(len(category) for category in members.values()) > 0:
│ │ │ │ │ - cls._dbus_members = (interface, members)
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_fido_user_presence_prompt(self)
│ │ │ │ │
│ │ │ │ │ - # This is the information for this subclass, with all its ancestors
│ │ │ │ │ - cls._dbus_interfaces = dict(ancestor.__dict__['_dbus_members']
│ │ │ │ │ - for ancestor in cls.mro()
│ │ │ │ │ - if '_dbus_members' in ancestor.__dict__)
│ │ │ │ │
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def _find_interface(cls, interface: str) -> Dict[str, Dict[str, '_Member']]:
│ │ │ │ │ - try:
│ │ │ │ │ - return cls._dbus_interfaces[interface]
│ │ │ │ │ - except KeyError as exc:
│ │ │ │ │ - raise Object.Method.Unhandled from exc
│ │ │ │ │ +class SshPKCS11PINPrompt(SSHAskpassPrompt):
│ │ │ │ │ + _pattern = r"Enter PIN for '%{pkcs11_id}': "
│ │ │ │ │ + pkcs11_id: str
│ │ │ │ │
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def _find_category(cls, interface: str, category: str) -> Dict[str, '_Member']:
│ │ │ │ │ - return cls._find_interface(interface)[category]
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_pkcs11_pin_prompt(self)
│ │ │ │ │
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def _find_member(cls, interface: str, category: str, member: str) -> '_Member':
│ │ │ │ │ - members = cls._find_category(interface, category)
│ │ │ │ │ - try:
│ │ │ │ │ - return members[member]
│ │ │ │ │ - except KeyError as exc:
│ │ │ │ │ - raise Object.Method.Unhandled from exc
│ │ │ │ │
│ │ │ │ │ - class _Member:
│ │ │ │ │ - _category: str # filled in from subclasses
│ │ │ │ │ +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
│ │ │ │ │
│ │ │ │ │ - _python_name: Optional[str] = None
│ │ │ │ │ - _name: Optional[str] = None
│ │ │ │ │ - _interface: Optional[str] = None
│ │ │ │ │ - _description: Optional[Dict[str, Any]]
│ │ │ │ │ + async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
│ │ │ │ │ + return await responder.do_host_key_prompt(self)
│ │ │ │ │
│ │ │ │ │ - def __init__(self, name: Optional[str] = None) -> None:
│ │ │ │ │ - self._python_name = None
│ │ │ │ │ - self._interface = None
│ │ │ │ │ - self._name = name
│ │ │ │ │
│ │ │ │ │ - def setup(self, interface: str, name: str, members: Dict[str, Dict[str, 'Interface._Member']]) -> None:
│ │ │ │ │ - self._python_name = name # for error messages
│ │ │ │ │ - if self._name is None:
│ │ │ │ │ - self._name = ''.join(word.title() for word in name.split('_'))
│ │ │ │ │ - self._interface = interface
│ │ │ │ │ - self._description = self._describe()
│ │ │ │ │ - members[self._category][self._name] = self
│ │ │ │ │ +def with_helpers(pattern: str) -> str:
│ │ │ │ │ + for name, helper in HELPERS.items():
│ │ │ │ │ + pattern = pattern.replace(name, helper)
│ │ │ │ │
│ │ │ │ │ - def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + assert '%{' not in pattern
│ │ │ │ │ + return pattern
│ │ │ │ │
│ │ │ │ │ - def __getitem__(self, key: str) -> Any:
│ │ │ │ │ - # Acts as an adaptor for dict accesses from introspection.to_xml()
│ │ │ │ │ - assert self._description is not None
│ │ │ │ │ - return self._description[key]
│ │ │ │ │
│ │ │ │ │ - class Property(_Member):
│ │ │ │ │ - """Defines a D-Bus property on an interface
│ │ │ │ │ +def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt:
│ │ │ │ │ + classes = [
│ │ │ │ │ + SshFIDOPINPrompt,
│ │ │ │ │ + SshFIDOUserPresencePrompt,
│ │ │ │ │ + SshHostKeyPrompt,
│ │ │ │ │ + SshPKCS11PINPrompt,
│ │ │ │ │ + SshPassphrasePrompt,
│ │ │ │ │ + SshPasswordPrompt,
│ │ │ │ │ + ]
│ │ │ │ │
│ │ │ │ │ - There are two main ways to define properties: with and without getters.
│ │ │ │ │ - If you define a property without a getter, then you must provide a
│ │ │ │ │ - value (via the value= kwarg). In this case, the property value is
│ │ │ │ │ - maintained internally and can be accessed from Python in the usual way.
│ │ │ │ │ - Change signals are sent automatically.
│ │ │ │ │ + # 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 = ''
│ │ │ │ │
│ │ │ │ │ - class MyObject(bus.Object):
│ │ │ │ │ - counter = bus.Interface.Property('i', value=0)
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - a = MyObject()
│ │ │ │ │ - a.counter = 5
│ │ │ │ │ - a.counter += 1
│ │ │ │ │ - print(a.counter)
│ │ │ │ │ + return AskpassPrompt(last_line, extras, stderr)
│ │ │ │ │
│ │ │ │ │ - The other way to define properties is with a getter function. In this
│ │ │ │ │ - case, you can read from the property in the normal way, but not write
│ │ │ │ │ - to it. You are responsible for emitting change signals for yourself.
│ │ │ │ │ - You must not provide the value= kwarg.
│ │ │ │ │
│ │ │ │ │ - class MyObject(bus.Object):
│ │ │ │ │ - _counter = 0
│ │ │ │ │ +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)
│ │ │ │ │
│ │ │ │ │ - counter = bus.Interface.Property('i')
│ │ │ │ │ - @counter.getter
│ │ │ │ │ - def get_counter(self):
│ │ │ │ │ - return self._counter
│ │ │ │ │ + async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None':
│ │ │ │ │ + # Default fallback for unrecognised message types: unimplemented
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - @counter.setter
│ │ │ │ │ - def set_counter(self, value):
│ │ │ │ │ - self._counter = value
│ │ │ │ │ - self.property_changed('Counter')
│ │ │ │ │ + async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │
│ │ │ │ │ - In either case, you can provide a setter function. This function has
│ │ │ │ │ - no impact on Python code, but makes the property writable from the view
│ │ │ │ │ - of D-Bus. Your setter will be called when a Properties.Set() call is
│ │ │ │ │ - made, and no other action will be performed. A trivial implementation
│ │ │ │ │ - might look like:
│ │ │ │ │ + async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │
│ │ │ │ │ - class MyObject(bus.Object):
│ │ │ │ │ - counter = bus.Interface.Property('i', value=0)
│ │ │ │ │ - @counter.setter
│ │ │ │ │ - def set_counter(self, value):
│ │ │ │ │ - # we got a request to set the counter from D-Bus
│ │ │ │ │ - self.counter = value
│ │ │ │ │ + async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │
│ │ │ │ │ - In all cases, the first (and only mandatory) argument to the
│ │ │ │ │ - constructor is the D-Bus type of the property.
│ │ │ │ │ + async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │
│ │ │ │ │ - Your getter and setter functions can be provided by kwarg to the
│ │ │ │ │ - constructor. You can also give a name= kwarg to override the default
│ │ │ │ │ - name conversion scheme.
│ │ │ │ │ - """
│ │ │ │ │ - _category = 'properties'
│ │ │ │ │ + async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │
│ │ │ │ │ - _getter: Optional[Callable[[Any], Any]]
│ │ │ │ │ - _setter: Optional[Callable[[Any, Any], None]]
│ │ │ │ │ - _type: bustypes.Type
│ │ │ │ │ - _value: Any
│ │ │ │ │ + async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None':
│ │ │ │ │ + return await self.do_prompt(prompt)
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main
│ │ │ │ │
│ │ │ │ │ - def __init__(self, type_string: str,
│ │ │ │ │ - value: Any = None,
│ │ │ │ │ - name: Optional[str] = None,
│ │ │ │ │ - getter: Optional[Callable[[Any], Any]] = None,
│ │ │ │ │ - setter: Optional[Callable[[Any, Any], None]] = None):
│ │ │ │ │ - assert value is None or getter is None, 'A property cannot have both a value and a getter'
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/bei/tmpfs.py': br'''import os
│ │ │ │ │ +import subprocess
│ │ │ │ │ +import sys
│ │ │ │ │ +import tempfile
│ │ │ │ │
│ │ │ │ │ - super().__init__(name=name)
│ │ │ │ │ - self._getter = getter
│ │ │ │ │ - self._setter = setter
│ │ │ │ │ - self._type, = bustypes.from_signature(type_string)
│ │ │ │ │ - self._value = value
│ │ │ │ │
│ │ │ │ │ - def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ - return {'type': self._type.typestring, 'flags': 'r' if self._setter is None else 'w'}
│ │ │ │ │ +def main(*command: str) -> None:
│ │ │ │ │ + with tempfile.TemporaryDirectory() as tmpdir:
│ │ │ │ │ + os.chdir(tmpdir)
│ │ │ │ │
│ │ │ │ │ - def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Any:
│ │ │ │ │ - assert self._name is not None
│ │ │ │ │ - if obj is None:
│ │ │ │ │ - return self
│ │ │ │ │ - if self._getter is not None:
│ │ │ │ │ - return self._getter.__get__(obj, cls)()
│ │ │ │ │ - elif self._value is not None:
│ │ │ │ │ - if obj._dbus_property_values is not None:
│ │ │ │ │ - return obj._dbus_property_values.get(self._name, self._value)
│ │ │ │ │ - else:
│ │ │ │ │ - return self._value
│ │ │ │ │ - else:
│ │ │ │ │ - raise AttributeError(f"'{obj.__class__.__name__}' property '{self._python_name}' "
│ │ │ │ │ - f"was not properly initialised: use either the 'value=' kwarg or "
│ │ │ │ │ - f"the @'{self._python_name}.getter' decorator")
│ │ │ │ │ + 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 __set__(self, obj: 'Object', value: Any) -> None:
│ │ │ │ │ - assert self._name is not None
│ │ │ │ │ - if self._getter is not None:
│ │ │ │ │ - raise AttributeError(f"Cannot directly assign '{obj.__class__.__name__}' "
│ │ │ │ │ - "property '{self._python_name}' because it has a getter")
│ │ │ │ │ - if obj._dbus_property_values is None:
│ │ │ │ │ - obj._dbus_property_values = {}
│ │ │ │ │ - obj._dbus_property_values[self._name] = value
│ │ │ │ │ - if obj._dbus_bus is not None:
│ │ │ │ │ - obj.properties_changed(self._interface, {self._name: bustypes.Variant(value, self._type)}, [])
│ │ │ │ │ + os.chdir('tmpfs')
│ │ │ │ │
│ │ │ │ │ - def to_dbus(self, obj: 'Object') -> bustypes.Variant:
│ │ │ │ │ - return bustypes.Variant(self.__get__(obj), self._type)
│ │ │ │ │ + result = subprocess.run(command, check=False)
│ │ │ │ │ + sys.exit(result.returncode)
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python
│ │ │ │ │ +#
│ │ │ │ │ +# Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ +#
│ │ │ │ │ +# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ +# it under the terms of the GNU General Public License as published by
│ │ │ │ │ +# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ +# (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 from_dbus(self, obj: 'Object', value: bustypes.Variant) -> None:
│ │ │ │ │ - if self._setter is None or self._type != value.type:
│ │ │ │ │ - raise Object.Method.Unhandled
│ │ │ │ │ - self._setter.__get__(obj)(value.value)
│ │ │ │ │ +import argparse
│ │ │ │ │ +import binascii
│ │ │ │ │ +import lzma
│ │ │ │ │ +import os
│ │ │ │ │ +import sys
│ │ │ │ │ +import tempfile
│ │ │ │ │ +import zipfile
│ │ │ │ │ +from typing import Dict, Iterable, List, Optional, Set, Tuple
│ │ │ │ │
│ │ │ │ │ - def getter(self, getter: Callable[[Any], Any]) -> Callable[[Any], Any]:
│ │ │ │ │ - if self._value is not None:
│ │ │ │ │ - raise ValueError('A property cannot have both a value and a getter')
│ │ │ │ │ - if self._getter is not None:
│ │ │ │ │ - raise ValueError('This property already has a getter')
│ │ │ │ │ - self._getter = getter
│ │ │ │ │ - return getter
│ │ │ │ │ +from .data import read_data_file
│ │ │ │ │
│ │ │ │ │ - def setter(self, setter: Callable[[Any, Any], None]) -> Callable[[Any, Any], None]:
│ │ │ │ │ - self._setter = setter
│ │ │ │ │ - return setter
│ │ │ │ │
│ │ │ │ │ - class Signal(_Member):
│ │ │ │ │ - """Defines a D-Bus signal on an interface
│ │ │ │ │ +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)
│ │ │ │ │
│ │ │ │ │ - This is a callable which will result in the signal being emitted.
│ │ │ │ │
│ │ │ │ │ - The constructor takes the types of the arguments, each one as a
│ │ │ │ │ - separate parameter. For example:
│ │ │ │ │ +def ascii_bytes_repr(data: bytes) -> str:
│ │ │ │ │ + return 'b' + escape_string(data.decode('ascii'))
│ │ │ │ │
│ │ │ │ │ - properties_changed = Interface.Signal('s', 'a{sv}', 'as')
│ │ │ │ │
│ │ │ │ │ - You can give a name= kwarg to override the default name conversion
│ │ │ │ │ - scheme.
│ │ │ │ │ - """
│ │ │ │ │ - _category = 'signals'
│ │ │ │ │ - _type: bustypes.MessageType
│ │ │ │ │ +def utf8_bytes_repr(data: bytes) -> str:
│ │ │ │ │ + return escape_string(data.decode('utf-8')) + ".encode('utf-8')"
│ │ │ │ │
│ │ │ │ │ - def __init__(self, *out_types: str, name: Optional[str] = None) -> None:
│ │ │ │ │ - super().__init__(name=name)
│ │ │ │ │ - self._type = bustypes.MessageType(out_types)
│ │ │ │ │
│ │ │ │ │ - def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ - return {'in': self._type.typestrings}
│ │ │ │ │ +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}")'
│ │ │ │ │
│ │ │ │ │ - def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Callable[..., None]:
│ │ │ │ │ - def emitter(obj: Object, *args: Any) -> None:
│ │ │ │ │ - assert self._interface is not None
│ │ │ │ │ - assert self._name is not None
│ │ │ │ │ - assert obj._dbus_bus is not None
│ │ │ │ │ - assert obj._dbus_path is not None
│ │ │ │ │ - message = obj._dbus_bus.message_new_signal(obj._dbus_path, self._interface, self._name)
│ │ │ │ │ - self._type.write(message, *args)
│ │ │ │ │ - message.send()
│ │ │ │ │ - return emitter.__get__(obj, cls)
│ │ │ │ │
│ │ │ │ │ - class Method(_Member):
│ │ │ │ │ - """Defines a D-Bus method on an 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
│ │ │ │ │
│ │ │ │ │ - This is a function decorator which marks a given method for export.
│ │ │ │ │ + try:
│ │ │ │ │ + return ascii_bytes_repr(data)
│ │ │ │ │ + except UnicodeDecodeError:
│ │ │ │ │ + # it's not ascii
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - The constructor takes two arguments: the type of the output arguments,
│ │ │ │ │ - and the type of the input arguments. Both should be given as a
│ │ │ │ │ - sequence.
│ │ │ │ │ + # utf-8
│ │ │ │ │ + try:
│ │ │ │ │ + return utf8_bytes_repr(data)
│ │ │ │ │ + except UnicodeDecodeError:
│ │ │ │ │ + # it's not utf-8
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - @Interface.Method(['a{sv}'], ['s'])
│ │ │ │ │ - def get_all(self, interface):
│ │ │ │ │ - ...
│ │ │ │ │ + return base64_bytes_repr(data, imports)
│ │ │ │ │
│ │ │ │ │ - You can give a name= kwarg to override the default name conversion
│ │ │ │ │ - scheme.
│ │ │ │ │ - """
│ │ │ │ │ - _category = 'methods'
│ │ │ │ │
│ │ │ │ │ - class Unhandled(Exception):
│ │ │ │ │ - """Raised by a method to indicate that the message triggering that
│ │ │ │ │ - method call remains unhandled."""
│ │ │ │ │ - pass
│ │ │ │ │ +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()) +
│ │ │ │ │ + '}')
│ │ │ │ │
│ │ │ │ │ - def __init__(self, out_types: Sequence[str] = (), in_types: Sequence[str] = (), name: Optional[str] = None):
│ │ │ │ │ - super().__init__(name=name)
│ │ │ │ │ - self._out_type = bustypes.MessageType(out_types)
│ │ │ │ │ - self._in_type = bustypes.MessageType(in_types)
│ │ │ │ │ - self._func = None
│ │ │ │ │
│ │ │ │ │ - def __get__(self, obj, cls=None):
│ │ │ │ │ - return self._func.__get__(obj, cls)
│ │ │ │ │ +def pack(contents: Dict[str, bytes],
│ │ │ │ │ + entrypoint: Optional[str] = None,
│ │ │ │ │ + args: str = '') -> str:
│ │ │ │ │ + """Creates a beipack with the given `contents`.
│ │ │ │ │
│ │ │ │ │ - def __call__(self, *args, **kwargs):
│ │ │ │ │ - # decorator
│ │ │ │ │ - self._func, = args
│ │ │ │ │ - return self
│ │ │ │ │ + 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:
│ │ │ │ │
│ │ │ │ │ - def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ - return {'in': [item.typestring for item in self._in_type.item_types],
│ │ │ │ │ - 'out': [item.typestring for item in self._out_type.item_types]}
│ │ │ │ │ + from package.module import func as main
│ │ │ │ │ + main()
│ │ │ │ │
│ │ │ │ │ - def _invoke(self, obj, message):
│ │ │ │ │ - args = self._in_type.read(message)
│ │ │ │ │ - if args is None:
│ │ │ │ │ - return False
│ │ │ │ │ - try:
│ │ │ │ │ - result = self._func.__get__(obj)(*args)
│ │ │ │ │ - except (BusError, OSError) as error:
│ │ │ │ │ - return message.reply_method_error(error)
│ │ │ │ │ + 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).
│ │ │ │ │ + """
│ │ │ │ │
│ │ │ │ │ - return message.reply_method_function_return_value(self._out_type, result)
│ │ │ │ │ + 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}))')
│ │ │ │ │
│ │ │ │ │ -class org_freedesktop_DBus_Peer(Interface):
│ │ │ │ │ - @Interface.Method()
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def ping() -> None:
│ │ │ │ │ - pass
│ │ │ │ │ + if entrypoint:
│ │ │ │ │ + package, main = entrypoint.split(':')
│ │ │ │ │ + lines.append(f'from {package} import {main} as main')
│ │ │ │ │ + lines.append(f'main({args})')
│ │ │ │ │
│ │ │ │ │ - @Interface.Method('s')
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_machine_id() -> str:
│ │ │ │ │ - with open('/etc/machine-id', encoding='ascii') as file:
│ │ │ │ │ - return file.read().strip()
│ │ │ │ │ + return ''.join(f'{line}\n' for line in lines)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class org_freedesktop_DBus_Introspectable(Interface):
│ │ │ │ │ - @Interface.Method('s')
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def introspect(cls) -> str:
│ │ │ │ │ - return introspection.to_xml(cls._dbus_interfaces)
│ │ │ │ │ +def collect_contents(filenames: List[str],
│ │ │ │ │ + relative_to: Optional[str] = None) -> Dict[str, bytes]:
│ │ │ │ │ + contents: Dict[str, bytes] = {}
│ │ │ │ │
│ │ │ │ │ + for filename in filenames:
│ │ │ │ │ + with open(filename, 'rb') as file:
│ │ │ │ │ + contents[os.path.relpath(filename, start=relative_to)] = file.read()
│ │ │ │ │
│ │ │ │ │ -class org_freedesktop_DBus_Properties(Interface):
│ │ │ │ │ - properties_changed = Interface.Signal('s', 'a{sv}', 'as')
│ │ │ │ │ + return contents
│ │ │ │ │
│ │ │ │ │ - @Interface.Method('v', 'ss')
│ │ │ │ │ - def get(self, interface, name):
│ │ │ │ │ - return self._find_member(interface, 'properties', name).to_dbus(self)
│ │ │ │ │
│ │ │ │ │ - @Interface.Method(['a{sv}'], 's')
│ │ │ │ │ - def get_all(self, interface):
│ │ │ │ │ - properties = self._find_category(interface, 'properties')
│ │ │ │ │ - return {name: prop.to_dbus(self) for name, prop in properties.items()}
│ │ │ │ │ +def collect_module(name: str, *, recursive: bool) -> Dict[str, bytes]:
│ │ │ │ │ + import importlib.resources
│ │ │ │ │ + from importlib.resources.abc import Traversable
│ │ │ │ │
│ │ │ │ │ - @Interface.Method('', 'ssv')
│ │ │ │ │ - def set(self, interface, name, value):
│ │ │ │ │ - self._find_member(interface, 'properties', name).from_dbus(self, value)
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ + return dict(walk(name.replace('.', '/'), importlib.resources.files(name)))
│ │ │ │ │
│ │ │ │ │ -class Object(org_freedesktop_DBus_Introspectable,
│ │ │ │ │ - org_freedesktop_DBus_Peer,
│ │ │ │ │ - org_freedesktop_DBus_Properties,
│ │ │ │ │ - BaseObject,
│ │ │ │ │ - Interface):
│ │ │ │ │ - """High-level base class for exporting objects on D-Bus
│ │ │ │ │
│ │ │ │ │ - This is usually where you should start.
│ │ │ │ │ +def collect_zip(filename: str) -> Dict[str, bytes]:
│ │ │ │ │ + contents = {}
│ │ │ │ │
│ │ │ │ │ - This provides a base for exporting objects on the bus, implements the
│ │ │ │ │ - standard D-Bus interfaces, and allows you to add your own interfaces to the
│ │ │ │ │ - mix. See the documentation for Interface to find out how to define and
│ │ │ │ │ - implement your D-Bus interface.
│ │ │ │ │ - """
│ │ │ │ │ - def message_received(self, message: BusMessage) -> bool:
│ │ │ │ │ - interface = message.get_interface()
│ │ │ │ │ - name = message.get_member()
│ │ │ │ │ + with zipfile.ZipFile(filename) as file:
│ │ │ │ │ + for entry in file.filelist:
│ │ │ │ │ + if '.dist-info/' in entry.filename:
│ │ │ │ │ + continue
│ │ │ │ │ + contents[entry.filename] = file.read(entry)
│ │ │ │ │
│ │ │ │ │ - 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/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 .
│ │ │ │ │ + return contents
│ │ │ │ │
│ │ │ │ │ -import xml.etree.ElementTree as ET
│ │ │ │ │
│ │ │ │ │ +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)
│ │ │ │ │
│ │ │ │ │ -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 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()
│ │ │ │ │
│ │ │ │ │ -def parse_property(prop):
│ │ │ │ │ - return {
│ │ │ │ │ - "flags": 'w' if prop.attrib.get('access') == 'write' else 'r',
│ │ │ │ │ - "type": prop.attrib['type']
│ │ │ │ │ - }
│ │ │ │ │ + contents = collect_contents(args.files, relative_to=args.topdir)
│ │ │ │ │
│ │ │ │ │ + for file in args.zip:
│ │ │ │ │ + contents.update(collect_zip(file))
│ │ │ │ │
│ │ │ │ │ -def parse_signal(signal):
│ │ │ │ │ - return {"in": [tag.attrib['type'] for tag in signal.findall("arg")]}
│ │ │ │ │ + for name in args.module:
│ │ │ │ │ + contents.update(collect_module(name, recursive=True))
│ │ │ │ │
│ │ │ │ │ + for path in args.build:
│ │ │ │ │ + contents.update(collect_pep517(path))
│ │ │ │ │
│ │ │ │ │ -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')}
│ │ │ │ │ - }
│ │ │ │ │ + result = pack(contents, args.main, args.main_args).encode('utf-8')
│ │ │ │ │
│ │ │ │ │ + if args.python:
│ │ │ │ │ + result = b'#!' + args.python.encode('ascii') + b'\n' + result
│ │ │ │ │
│ │ │ │ │ -def parse_xml(xml):
│ │ │ │ │ - et = ET.fromstring(xml)
│ │ │ │ │ - return {tag.attrib['name']: parse_interface(tag) for tag in et.findall('interface')}
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ -# Pretend like this is a little bit functional
│ │ │ │ │ -def element(tag, children=(), **kwargs):
│ │ │ │ │ - tag = ET.Element(tag, kwargs)
│ │ │ │ │ - tag.extend(children)
│ │ │ │ │ - return tag
│ │ │ │ │
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/bei/spawn.py': br'''"""Helper to create a beipack to spawn a command with files in a tmpdir"""
│ │ │ │ │
│ │ │ │ │ -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]
│ │ │ │ │ - ])
│ │ │ │ │ +import argparse
│ │ │ │ │ +import os
│ │ │ │ │ +import sys
│ │ │ │ │
│ │ │ │ │ +from . import pack, tmpfs
│ │ │ │ │
│ │ │ │ │ -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 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()
│ │ │ │ │
│ │ │ │ │ -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']
│ │ │ │ │ - ])
│ │ │ │ │ + contents = {
│ │ │ │ │ + '_beitmpfs.py': tmpfs.__spec__.loader.get_data(tmpfs.__spec__.origin)
│ │ │ │ │ + }
│ │ │ │ │
│ │ │ │ │ + if args.file is not None:
│ │ │ │ │ + files = args.file
│ │ │ │ │ + else:
│ │ │ │ │ + file = args.command[-1]
│ │ │ │ │ + files = [file]
│ │ │ │ │ + args.command[-1] = './' + os.path.basename(file)
│ │ │ │ │
│ │ │ │ │ -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()),
│ │ │ │ │ - ])
│ │ │ │ │ + for filename in files:
│ │ │ │ │ + with open(filename, 'rb') as file:
│ │ │ │ │ + basename = os.path.basename(filename)
│ │ │ │ │ + contents[f'tmpfs/{basename}'] = file.read()
│ │ │ │ │ +
│ │ │ │ │ + script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command))
│ │ │ │ │ + sys.stdout.write(script)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def to_xml(interfaces):
│ │ │ │ │ - node = element('node', children=(interface_to_xml(name, members) for name, members in interfaces.items()))
│ │ │ │ │ - return ET.tostring(node, encoding='unicode')
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes
│ │ │ │ │ + 'cockpit/_vendor/bei/__init__.py': br'''''',
│ │ │ │ │ + 'cockpit/_vendor/bei/beiboot.py': br"""# beiboot - Remote bootloader for Python
│ │ │ │ │ #
│ │ │ │ │ # Copyright (C) 2022 Allison Karlitskaya
│ │ │ │ │ #
│ │ │ │ │ # This program is free software: you can redistribute it and/or modify
│ │ │ │ │ # it under the terms of the GNU General Public License as published by
│ │ │ │ │ # the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ # (at your option) any later version.
│ │ │ │ │ @@ -8080,281 +8567,360 @@
│ │ │ │ │ # but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ # GNU General Public License for more details.
│ │ │ │ │ #
│ │ │ │ │ # You should have received a copy of the GNU General Public License
│ │ │ │ │ # along with this program. If not, see .
│ │ │ │ │
│ │ │ │ │ -import errno
│ │ │ │ │ -import logging
│ │ │ │ │ +import argparse
│ │ │ │ │ +import asyncio
│ │ │ │ │ import os
│ │ │ │ │ -import stat
│ │ │ │ │ -from typing import Any, List, Optional
│ │ │ │ │ +import shlex
│ │ │ │ │ +import subprocess
│ │ │ │ │ +import sys
│ │ │ │ │ +import threading
│ │ │ │ │ +from typing import IO, List, Sequence, Tuple
│ │ │ │ │
│ │ │ │ │ -from .event import Event
│ │ │ │ │ -from .inotify import Event as IN
│ │ │ │ │ +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]
│ │ │ │ │
│ │ │ │ │ -# inotify hard facts:
│ │ │ │ │ -#
│ │ │ │ │ -# DELETE_SELF doesn't get called until all references to an inode are gone
│ │ │ │ │ -# - including open fds
│ │ │ │ │ -# - including on directories
│ │ │ │ │ -#
│ │ │ │ │ -# ATTRIB gets called when unlinking files (because the link count changes) but
│ │ │ │ │ -# not on directories. When unlinking an open directory, no events at all
│ │ │ │ │ -# happen on the directory. ATTRIB also collects child events, which means we
│ │ │ │ │ -# get a lot of unwanted noise.
│ │ │ │ │ -#
│ │ │ │ │ -# There's nothing like UNLINK_SELF, unfortunately.
│ │ │ │ │ -#
│ │ │ │ │ -# Even if it was possible to take this approach, it might not work: after
│ │ │ │ │ -# you've opened the fd, it might get deleted before you can establish the watch
│ │ │ │ │ -# on it.
│ │ │ │ │ -#
│ │ │ │ │ -# Additionally, systemd makes it impossible to register those events on
│ │ │ │ │ -# symlinks (because it removes IN_DONT_FOLLOW in order to watch via
│ │ │ │ │ -# /proc/self/fd).
│ │ │ │ │ -#
│ │ │ │ │ -# For all of these reasons, unfortunately, the best way seems to be to watch
│ │ │ │ │ -# for CREATE|DELETE|MOVE events on each intermediate directory.
│ │ │ │ │ -#
│ │ │ │ │ -# Unfortunately there is no way to filter to only the name we're interested in,
│ │ │ │ │ -# so we're gonna get a lot of unnecessary wakeups.
│ │ │ │ │ -#
│ │ │ │ │ -# Also: due to the above-mentioned race about watching after opening the fd,
│ │ │ │ │ -# let's just always watch for both create and delete events *before* trying to
│ │ │ │ │ -# open the fd. We could try to reduce the mask after the fact, but meh...
│ │ │ │ │ -#
│ │ │ │ │ -# We use a WatchInvalidator utility class to fill the role of "Tell me when an
│ │ │ │ │ -# event happened on this (directory) fd which impacted the name file". We
│ │ │ │ │ -# build a series of these when setting up a watch in order to find out if any
│ │ │ │ │ -# part of the path leading to the monitored file changed.
│ │ │ │ │ + if tty:
│ │ │ │ │ + command = (interpreter, '-iq')
│ │ │ │ │ + else:
│ │ │ │ │ + command = (
│ │ │ │ │ + interpreter, '-ic',
│ │ │ │ │ + # https://github.com/python/cpython/issues/93139
│ │ │ │ │ + '''" - beiboot - "; import sys; sys.ps1 = ''; sys.ps2 = '';'''
│ │ │ │ │ + )
│ │ │ │ │
│ │ │ │ │ + if sh:
│ │ │ │ │ + command = (' '.join(shlex.quote(arg) for arg in command),)
│ │ │ │ │
│ │ │ │ │ -class Handle(int):
│ │ │ │ │ - """An integer subclass that makes it easier to work with file descriptors"""
│ │ │ │ │ + return command
│ │ │ │ │
│ │ │ │ │ - def __new__(cls, fd: int = -1) -> 'Handle':
│ │ │ │ │ - return super(Handle, cls).__new__(cls, fd)
│ │ │ │ │
│ │ │ │ │ - # separate __init__() to set _needs_close mostly to keep pylint quiet
│ │ │ │ │ - def __init__(self, fd: int = -1):
│ │ │ │ │ - super().__init__()
│ │ │ │ │ - self._needs_close = fd != -1
│ │ │ │ │ +def get_ssh_command(*args: str, tty: bool = False) -> Sequence[str]:
│ │ │ │ │ + return ('ssh',
│ │ │ │ │ + *(['-t'] if tty else ()),
│ │ │ │ │ + *args,
│ │ │ │ │ + *get_python_command(tty=tty, sh=True))
│ │ │ │ │
│ │ │ │ │ - def __bool__(self) -> bool:
│ │ │ │ │ - return self != -1
│ │ │ │ │
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - if self._needs_close:
│ │ │ │ │ - self._needs_close = False
│ │ │ │ │ - os.close(self)
│ │ │ │ │ +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 __eq__(self, value: object) -> bool:
│ │ │ │ │ - if int.__eq__(self, value): # also handles both == -1
│ │ │ │ │ - return True
│ │ │ │ │
│ │ │ │ │ - if not isinstance(value, int): # other object is not an int
│ │ │ │ │ - return False
│ │ │ │ │ +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 not self or not value: # when only one == -1
│ │ │ │ │ - return False
│ │ │ │ │
│ │ │ │ │ - return os.path.sameopenfile(self, value)
│ │ │ │ │ +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()
│ │ │ │ │
│ │ │ │ │ - def __del__(self) -> None:
│ │ │ │ │ - if self._needs_close:
│ │ │ │ │ - self.close()
│ │ │ │ │ + threading.Thread(target=_thread, daemon=True).start()
│ │ │ │ │
│ │ │ │ │ - def __enter__(self) -> 'Handle':
│ │ │ │ │ - return self
│ │ │ │ │
│ │ │ │ │ - def __exit__(self, _type: type, _value: object, _traceback: object) -> None:
│ │ │ │ │ - self.close()
│ │ │ │ │ +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)
│ │ │ │ │
│ │ │ │ │ - @classmethod
│ │ │ │ │ - def open(cls, *args: Any, **kwargs: Any) -> 'Handle':
│ │ │ │ │ - return cls(os.open(*args, **kwargs))
│ │ │ │ │ + splice_in_thread(0, proc.stdin)
│ │ │ │ │ + sys.exit(proc.wait())
│ │ │ │ │
│ │ │ │ │ - def steal(self) -> 'Handle':
│ │ │ │ │ - self._needs_close = False
│ │ │ │ │ - return self.__class__(int(self))
│ │ │ │ │
│ │ │ │ │ +def send_xz_and_splice(command: Sequence[str], script: bytes) -> None:
│ │ │ │ │ + import ferny
│ │ │ │ │
│ │ │ │ │ -class WatchInvalidator:
│ │ │ │ │ - _name: bytes
│ │ │ │ │ - _source: Optional[Event.Source]
│ │ │ │ │ - _watch: Optional['PathWatch']
│ │ │ │ │ + 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()
│ │ │ │ │
│ │ │ │ │ - def event(self, mask: IN, _cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ - logger.debug('invalidator event %s %s', mask, name)
│ │ │ │ │ - if self._watch is not None:
│ │ │ │ │ - # If this node itself disappeared, that's definitely an
│ │ │ │ │ - # invalidation. Otherwise, the name needs to match.
│ │ │ │ │ - if IN.IGNORED in mask or self._name == name:
│ │ │ │ │ - logger.debug('Invalidating!')
│ │ │ │ │ - self._watch.invalidate()
│ │ │ │ │ + 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()
│ │ │ │ │
│ │ │ │ │ - def __init__(self, watch: 'PathWatch', event: Event, dirfd: int, name: str):
│ │ │ │ │ - self._watch = watch
│ │ │ │ │ - self._name = name.encode('utf-8')
│ │ │ │ │ + asyncio.run(agent.communicate())
│ │ │ │ │ + splice_in_thread(0, proc.stdin)
│ │ │ │ │ + sys.exit(proc.wait())
│ │ │ │ │
│ │ │ │ │ - # establishing invalidation watches is best-effort and can fail for a
│ │ │ │ │ - # number of reasons, including search (+x) but not read (+r) permission
│ │ │ │ │ - # on a particular path component, or exceeding limits on watches
│ │ │ │ │ - try:
│ │ │ │ │ - mask = IN.CREATE | IN.DELETE | IN.MOVE | IN.DELETE_SELF | IN.IGNORED
│ │ │ │ │ - self._source = event.add_inotify_fd(dirfd, mask, self.event)
│ │ │ │ │ - except OSError:
│ │ │ │ │ - self._source = None
│ │ │ │ │
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - # This is a little bit tricky: systemd doesn't have a specific close
│ │ │ │ │ - # API outside of unref, so let's make it as explicit as possible.
│ │ │ │ │ - self._watch = None
│ │ │ │ │ - self._source = None
│ │ │ │ │ +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='*')
│ │ │ │ │
│ │ │ │ │ + args = parser.parse_args()
│ │ │ │ │ + tty = not args.script and os.isatty(0)
│ │ │ │ │
│ │ │ │ │ -class PathStack(List[str]):
│ │ │ │ │ - def add_path(self, pathname: str) -> None:
│ │ │ │ │ - # TO DO: consider doing something reasonable with trailing slashes
│ │ │ │ │ - # this is a stack, popped from the end: push components in reverse
│ │ │ │ │ - self.extend(item for item in reversed(pathname.split('/')) if item)
│ │ │ │ │ - if pathname.startswith('/'):
│ │ │ │ │ - self.append('/')
│ │ │ │ │ + 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)
│ │ │ │ │
│ │ │ │ │ - def __init__(self, path: str):
│ │ │ │ │ - super().__init__()
│ │ │ │ │ - self.add_path(path)
│ │ │ │ │ + if args.script:
│ │ │ │ │ + with open(args.script, 'rb') as file:
│ │ │ │ │ + script = file.read()
│ │ │ │ │
│ │ │ │ │ + send_and_splice(command, script)
│ │ │ │ │
│ │ │ │ │ -class Listener:
│ │ │ │ │ - def do_inotify_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + elif args.xz:
│ │ │ │ │ + with open(args.xz, 'rb') as file:
│ │ │ │ │ + script = file.read()
│ │ │ │ │
│ │ │ │ │ - def do_identity_changed(self, fd: Optional[Handle], errno: Optional[int]) -> None:
│ │ │ │ │ - raise NotImplementedError
│ │ │ │ │ + send_xz_and_splice(command, script)
│ │ │ │ │
│ │ │ │ │ + else:
│ │ │ │ │ + # If we're streaming from stdin then this is a lot easier...
│ │ │ │ │ + os.execlp(command[0], *command)
│ │ │ │ │
│ │ │ │ │ -class PathWatch:
│ │ │ │ │ - _event: Event
│ │ │ │ │ - _listener: Listener
│ │ │ │ │ - _path: str
│ │ │ │ │ - _invalidators: List[WatchInvalidator]
│ │ │ │ │ - _errno: Optional[int]
│ │ │ │ │ - _source: Optional[Event.Source]
│ │ │ │ │ - _tag: Optional[None]
│ │ │ │ │ - _fd: Handle
│ │ │ │ │ + # Otherwise, "full strength"
│ │ │ │ │
│ │ │ │ │ - def __init__(self, path: str, listener: Listener, event: Optional[Event] = None):
│ │ │ │ │ - self._event = event or Event.default()
│ │ │ │ │ - self._path = path
│ │ │ │ │ - self._listener = listener
│ │ │ │ │ +if __name__ == '__main__':
│ │ │ │ │ + main()
│ │ │ │ │ +""",
│ │ │ │ │ + '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 .
│ │ │ │ │
│ │ │ │ │ - self._invalidators = []
│ │ │ │ │ - self._errno = None
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._tag = None
│ │ │ │ │ - self._fd = Handle()
│ │ │ │ │ +import textwrap
│ │ │ │ │ +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
│ │ │ │ │
│ │ │ │ │ - self.invalidate()
│ │ │ │ │ +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 got_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ - logger.debug('target event %s: %s %s %s', self._path, mask, cookie, name)
│ │ │ │ │ - self._listener.do_inotify_event(mask, cookie, name)
│ │ │ │ │
│ │ │ │ │ - def invalidate(self) -> None:
│ │ │ │ │ - for invalidator in self._invalidators:
│ │ │ │ │ - invalidator.close()
│ │ │ │ │ - self._invalidators = []
│ │ │ │ │ +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
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - fd = self.walk()
│ │ │ │ │ - except OSError as error:
│ │ │ │ │ - logger.debug('walk ended in error %d', error.errno)
│ │ │ │ │
│ │ │ │ │ - if self._source or self._fd or self._errno != error.errno:
│ │ │ │ │ - logger.debug('Ending existing watches.')
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._fd.close()
│ │ │ │ │ - self._fd = Handle()
│ │ │ │ │ - self._errno = error.errno
│ │ │ │ │ +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)
│ │ │ │ │
│ │ │ │ │ - logger.debug('Notifying of new error state %d', self._errno)
│ │ │ │ │ - self._listener.do_identity_changed(None, self._errno)
│ │ │ │ │ + # 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)
│ │ │ │ │
│ │ │ │ │ - return
│ │ │ │ │ + # Yield functions mentioned in steps from the caller
│ │ │ │ │ + for name, args in steps:
│ │ │ │ │ + yield '', name + repr(tuple(args))
│ │ │ │ │
│ │ │ │ │ - with fd:
│ │ │ │ │ - logger.debug('walk successful. Got fd %d', fd)
│ │ │ │ │ - if fd == self._fd:
│ │ │ │ │ - logger.debug('fd seems to refer to same file. Doing nothing.')
│ │ │ │ │ - return
│ │ │ │ │
│ │ │ │ │ - logger.debug('This file is new for us. Removing old watch.')
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._fd.close()
│ │ │ │ │ - self._fd = fd.steal()
│ │ │ │ │ +def make_bootloader(steps: Sequence[Tuple[str, Sequence[object]]],
│ │ │ │ │ + gadgets: Optional[Dict[str, str]] = None) -> str:
│ │ │ │ │ + imports: Set[str] = set()
│ │ │ │ │ + lines: List[str] = []
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - logger.debug('Establishing a new watch.')
│ │ │ │ │ - self._source = self._event.add_inotify_fd(self._fd, IN.CHANGED, self.got_event)
│ │ │ │ │ - logger.debug('Watching successfully. Notifying of new identity.')
│ │ │ │ │ - self._listener.do_identity_changed(self._fd, None)
│ │ │ │ │ - except OSError as error:
│ │ │ │ │ - logger.debug('Watching failed (%d). Notifying of new identity.', error.errno)
│ │ │ │ │ - self._listener.do_identity_changed(self._fd, error.errno)
│ │ │ │ │ + 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 walk(self) -> Handle:
│ │ │ │ │ - remaining_symlink_lookups = 40
│ │ │ │ │ - remaining_components = PathStack(self._path)
│ │ │ │ │ - dirfd = Handle()
│ │ │ │ │ + return "".join(f"{line}\n" for line in [*imports, *lines]) + "\n"
│ │ │ │ │ +''',
│ │ │ │ │ + 'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - logger.debug('Starting path walk')
│ │ │ │ │ +import importlib.abc
│ │ │ │ │ +import importlib.util
│ │ │ │ │ +import io
│ │ │ │ │ +import sys
│ │ │ │ │ +from types import ModuleType
│ │ │ │ │ +from typing import BinaryIO, Dict, Iterator, Optional, Sequence
│ │ │ │ │
│ │ │ │ │ - while remaining_components:
│ │ │ │ │ - logger.debug('r=%s dfd=%s', remaining_components, dirfd)
│ │ │ │ │
│ │ │ │ │ - name = remaining_components.pop()
│ │ │ │ │ +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
│ │ │ │ │
│ │ │ │ │ - if dirfd and name != '/':
│ │ │ │ │ - self._invalidators.append(WatchInvalidator(self, self._event, dirfd, name))
│ │ │ │ │ + class ResourceReader(AbstractResourceReader):
│ │ │ │ │ + def __init__(self, contents: Dict[str, bytes], filename: str) -> None:
│ │ │ │ │ + self._contents = contents
│ │ │ │ │ + self._dir = f'{filename}/'
│ │ │ │ │
│ │ │ │ │ - with Handle.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=dirfd) as fd:
│ │ │ │ │ - mode = os.fstat(fd).st_mode
│ │ │ │ │ + def is_resource(self, resource: str) -> bool:
│ │ │ │ │ + return f'{self._dir}{resource}' in self._contents
│ │ │ │ │
│ │ │ │ │ - if stat.S_ISLNK(mode):
│ │ │ │ │ - if remaining_symlink_lookups == 0:
│ │ │ │ │ - raise OSError(errno.ELOOP, os.strerror(errno.ELOOP))
│ │ │ │ │ - remaining_symlink_lookups -= 1
│ │ │ │ │ - linkpath = os.readlink('', dir_fd=fd)
│ │ │ │ │ - logger.debug('%s is a symlink. adding %s to components', name, linkpath)
│ │ │ │ │ - remaining_components.add_path(linkpath)
│ │ │ │ │ + def open_resource(self, resource: str) -> BinaryIO:
│ │ │ │ │ + return io.BytesIO(self._contents[f'{self._dir}{resource}'])
│ │ │ │ │
│ │ │ │ │ - else:
│ │ │ │ │ - dirfd.close()
│ │ │ │ │ - dirfd = fd.steal()
│ │ │ │ │ + def resource_path(self, resource: str) -> str:
│ │ │ │ │ + raise FileNotFoundError
│ │ │ │ │
│ │ │ │ │ - return dirfd.steal()
│ │ │ │ │ + def contents(self) -> Iterator[str]:
│ │ │ │ │ + dir_length = len(self._dir)
│ │ │ │ │ + result = set()
│ │ │ │ │
│ │ │ │ │ - finally:
│ │ │ │ │ - dirfd.close()
│ │ │ │ │ + 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])
│ │ │ │ │
│ │ │ │ │ - def close(self) -> None:
│ │ │ │ │ - for invalidator in self._invalidators:
│ │ │ │ │ - invalidator.close()
│ │ │ │ │ - self._invalidators = []
│ │ │ │ │ - self._source = None
│ │ │ │ │ - self._fd.close()
│ │ │ │ │ + return iter(result)
│ │ │ │ │ +
│ │ │ │ │ + contents: Dict[str, bytes]
│ │ │ │ │ + modules: Dict[str, str]
│ │ │ │ │ +
│ │ │ │ │ + def __init__(self, contents: Dict[str, bytes]) -> None:
│ │ │ │ │ + try:
│ │ │ │ │ + contents[__file__] = __self_source__ # type: ignore[name-defined]
│ │ │ │ │ + except NameError:
│ │ │ │ │ + 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_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/_vendor/bei/data/__init__.py': br'''import sys
│ │ │ │ │ +
│ │ │ │ │ +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')
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/py.typed': br'''''',
│ │ │ │ │ '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
│ │ │ │ │ @@ -8682,14 +9248,153 @@
│ │ │ │ │ sd_bus_message,
│ │ │ │ │ sd_bus_slot,
│ │ │ │ │ sd_event,
│ │ │ │ │ sd_event_source,
│ │ │ │ │ }:
│ │ │ │ │ cls._install_cfuncs(libsystemd)
│ │ │ │ │ '''.encode('utf-8'),
│ │ │ │ │ + '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
│ │ │ │ │ +# (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 selectors
│ │ │ │ │ +import sys
│ │ │ │ │ +from typing import Callable, ClassVar, Coroutine, List, Optional, Tuple
│ │ │ │ │ +
│ │ │ │ │ +from . import inotify, libsystemd
│ │ │ │ │ +from .librarywrapper import Reference, UserData, byref
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class Event(libsystemd.sd_event):
│ │ │ │ │ + class Source(libsystemd.sd_event_source):
│ │ │ │ │ + def cancel(self) -> None:
│ │ │ │ │ + self._unref()
│ │ │ │ │ + self.value = None
│ │ │ │ │ +
│ │ │ │ │ + _default_instance: ClassVar[Optional['Event']] = None
│ │ │ │ │ +
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def default() -> 'Event':
│ │ │ │ │ + if Event._default_instance is None:
│ │ │ │ │ + Event._default_instance = Event()
│ │ │ │ │ + Event._default(byref(Event._default_instance))
│ │ │ │ │ + return Event._default_instance
│ │ │ │ │ +
│ │ │ │ │ + InotifyHandler = Callable[[inotify.Event, int, Optional[bytes]], None]
│ │ │ │ │ +
│ │ │ │ │ + class InotifySource(Source):
│ │ │ │ │ + def __init__(self, handler: 'Event.InotifyHandler') -> None:
│ │ │ │ │ + def callback(source: libsystemd.sd_event_source,
│ │ │ │ │ + _event: Reference[inotify.inotify_event],
│ │ │ │ │ + userdata: UserData) -> int:
│ │ │ │ │ + event = _event.contents
│ │ │ │ │ + handler(inotify.Event(event.mask), event.cookie, event.name)
│ │ │ │ │ + return 0
│ │ │ │ │ + self.trampoline = libsystemd.sd_event_inotify_handler_t(callback)
│ │ │ │ │ +
│ │ │ │ │ + def add_inotify(self, path: str, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:
│ │ │ │ │ + source = Event.InotifySource(handler)
│ │ │ │ │ + self._add_inotify(byref(source), path, mask, source.trampoline, source.userdata)
│ │ │ │ │ + return source
│ │ │ │ │ +
│ │ │ │ │ + def add_inotify_fd(self, fd: int, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:
│ │ │ │ │ + # HACK: sd_event_add_inotify_fd() got added in 250, which is too new. Fake it.
│ │ │ │ │ + return self.add_inotify(f'/proc/self/fd/{fd}', mask, handler)
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +# This is all a bit more awkward than it should have to be: systemd's event
│ │ │ │ │ +# loop chaining model is designed for glib's prepare/check/dispatch paradigm;
│ │ │ │ │ +# failing to call prepare() can lead to deadlocks, for example.
│ │ │ │ │ +#
│ │ │ │ │ +# Hack a selector subclass which calls prepare() before sleeping and this for us.
│ │ │ │ │ +class Selector(selectors.DefaultSelector):
│ │ │ │ │ + def __init__(self, event: Optional[Event] = None) -> None:
│ │ │ │ │ + super().__init__()
│ │ │ │ │ + self.sd_event = event or Event.default()
│ │ │ │ │ + self.key = self.register(self.sd_event.get_fd(), selectors.EVENT_READ)
│ │ │ │ │ +
│ │ │ │ │ + def select(
│ │ │ │ │ + self, timeout: Optional[float] = None
│ │ │ │ │ + ) -> List[Tuple[selectors.SelectorKey, int]]:
│ │ │ │ │ + # It's common to drop the last reference to a Source or Slot object on
│ │ │ │ │ + # a dispatch of that same source/slot from the main loop. If we happen
│ │ │ │ │ + # to garbage collect before returning, the trampoline could be
│ │ │ │ │ + # destroyed before we're done using it. Provide a mechanism to defer
│ │ │ │ │ + # the destruction of trampolines for as long as we might be
│ │ │ │ │ + # dispatching. This gets cleared again at the bottom, before return.
│ │ │ │ │ + libsystemd.Trampoline.deferred = []
│ │ │ │ │ +
│ │ │ │ │ + while self.sd_event.prepare():
│ │ │ │ │ + self.sd_event.dispatch()
│ │ │ │ │ + ready = super().select(timeout)
│ │ │ │ │ + # workaround https://github.com/systemd/systemd/issues/23826
│ │ │ │ │ + # keep calling wait() until there's nothing left
│ │ │ │ │ + while self.sd_event.wait(0):
│ │ │ │ │ + self.sd_event.dispatch()
│ │ │ │ │ + while self.sd_event.prepare():
│ │ │ │ │ + self.sd_event.dispatch()
│ │ │ │ │ +
│ │ │ │ │ + # We can be sure we're not dispatching callbacks anymore
│ │ │ │ │ + libsystemd.Trampoline.deferred = None
│ │ │ │ │ +
│ │ │ │ │ + # This could return zero events with infinite timeout, but nobody seems to mind.
│ │ │ │ │ + return [(key, events) for (key, events) in ready if key != self.key]
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +class EventLoopPolicy(asyncio.DefaultEventLoopPolicy):
│ │ │ │ │ + def new_event_loop(self) -> asyncio.AbstractEventLoop:
│ │ │ │ │ + return asyncio.SelectorEventLoop(Selector())
│ │ │ │ │ +
│ │ │ │ │ +
│ │ │ │ │ +def run_async(main: Coroutine[None, None, None], debug: Optional[bool] = None) -> None:
│ │ │ │ │ + asyncio.set_event_loop_policy(EventLoopPolicy())
│ │ │ │ │ +
│ │ │ │ │ + polyfill = sys.version_info < (3, 7, 0) and not hasattr(asyncio, 'run')
│ │ │ │ │ + if polyfill:
│ │ │ │ │ + # Polyfills for Python 3.6:
│ │ │ │ │ + loop = asyncio.get_event_loop()
│ │ │ │ │ +
│ │ │ │ │ + assert not hasattr(asyncio, 'get_running_loop')
│ │ │ │ │ + asyncio.get_running_loop = lambda: loop
│ │ │ │ │ +
│ │ │ │ │ + assert not hasattr(asyncio, 'create_task')
│ │ │ │ │ + asyncio.create_task = loop.create_task
│ │ │ │ │ +
│ │ │ │ │ + assert not hasattr(asyncio, 'run')
│ │ │ │ │ +
│ │ │ │ │ + def run(
│ │ │ │ │ + main: Coroutine[None, None, None], debug: Optional[bool] = None
│ │ │ │ │ + ) -> None:
│ │ │ │ │ + if debug is not None:
│ │ │ │ │ + loop.set_debug(debug)
│ │ │ │ │ + loop.run_until_complete(main)
│ │ │ │ │ +
│ │ │ │ │ + asyncio.run = run # type: ignore[assignment]
│ │ │ │ │ +
│ │ │ │ │ + asyncio._systemd_ctypes_polyfills = True # type: ignore[attr-defined]
│ │ │ │ │ +
│ │ │ │ │ + asyncio.run(main, debug=debug)
│ │ │ │ │ +
│ │ │ │ │ + if polyfill:
│ │ │ │ │ + del asyncio.create_task, asyncio.get_running_loop, asyncio.run
│ │ │ │ │ +''',
│ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes
│ │ │ │ │ #
│ │ │ │ │ # 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
│ │ │ │ │ @@ -8757,14 +9462,74 @@
│ │ │ │ │ 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/py.typed': br'''''',
│ │ │ │ │ '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
│ │ │ │ │ @@ -8972,73 +9737,14 @@
│ │ │ │ │ self._ref()
│ │ │ │ │ return self
│ │ │ │ │
│ │ │ │ │ def __del__(self) -> None:
│ │ │ │ │ if self.value is not None:
│ │ │ │ │ self._unref()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing
│ │ │ │ │ -from typing import TYPE_CHECKING
│ │ │ │ │ -
│ │ │ │ │ -# The goal here is to continue to work on Python 3.6 while pretending to have
│ │ │ │ │ -# access to some modern typing features. The shims provided here are only
│ │ │ │ │ -# enough for what we need for systemd_ctypes to work at runtime.
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -if TYPE_CHECKING:
│ │ │ │ │ - # See https://github.com/python/mypy/issues/1153 for why we do this separately
│ │ │ │ │ - from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin
│ │ │ │ │ -
│ │ │ │ │ -else:
│ │ │ │ │ - # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated
│ │ │ │ │ - # arrived in 3.9. Unfortunately, it's difficult to implement a mocked up
│ │ │ │ │ - # version of Annotated which works with the real typing.get_args() and
│ │ │ │ │ - # .get_origin() in Python 3.8, so we use our own versions there as well.
│ │ │ │ │ - try:
│ │ │ │ │ - from typing import Annotated, get_args, get_origin
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - class AnnotatedMeta(type):
│ │ │ │ │ - def __getitem__(cls, params):
│ │ │ │ │ - class AnnotatedType:
│ │ │ │ │ - __origin__ = Annotated
│ │ │ │ │ - __args__ = params
│ │ │ │ │ - return AnnotatedType
│ │ │ │ │ -
│ │ │ │ │ - class Annotated(metaclass=AnnotatedMeta):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ - def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]:
│ │ │ │ │ - return getattr(annotation, '__args__', ())
│ │ │ │ │ -
│ │ │ │ │ - def get_origin(annotation: typing.Any) -> typing.Any:
│ │ │ │ │ - return getattr(annotation, '__origin__', None)
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - from typing import ForwardRef
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - from typing import _ForwardRef as ForwardRef
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - from typing import TypeGuard
│ │ │ │ │ - except ImportError:
│ │ │ │ │ - T = typing.TypeVar('T')
│ │ │ │ │ -
│ │ │ │ │ - class TypeGuard(typing.Generic[T]):
│ │ │ │ │ - pass
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -__all__ = (
│ │ │ │ │ - 'Annotated',
│ │ │ │ │ - 'ForwardRef',
│ │ │ │ │ - 'TypeGuard',
│ │ │ │ │ - 'get_args',
│ │ │ │ │ - 'get_origin',
│ │ │ │ │ - 'TYPE_CHECKING',
│ │ │ │ │ -)
│ │ │ │ │ -''',
│ │ │ │ │ 'cockpit/_vendor/systemd_ctypes/__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
│ │ │ │ │ @@ -9071,15 +9777,15 @@
│ │ │ │ │ "Handle",
│ │ │ │ │ "JSONEncoder",
│ │ │ │ │ "PathWatch",
│ │ │ │ │ "Variant",
│ │ │ │ │ "run_async",
│ │ │ │ │ ]
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/_vendor/systemd_ctypes/event.py': br'''# systemd_ctypes
│ │ │ │ │ + '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.
│ │ │ │ │ @@ -9088,135 +9794,89 @@
│ │ │ │ │ # 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 selectors
│ │ │ │ │ -import sys
│ │ │ │ │ -from typing import Callable, ClassVar, Coroutine, List, Optional, Tuple
│ │ │ │ │ -
│ │ │ │ │ -from . import inotify, libsystemd
│ │ │ │ │ -from .librarywrapper import Reference, UserData, byref
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class Event(libsystemd.sd_event):
│ │ │ │ │ - class Source(libsystemd.sd_event_source):
│ │ │ │ │ - def cancel(self) -> None:
│ │ │ │ │ - self._unref()
│ │ │ │ │ - self.value = None
│ │ │ │ │ -
│ │ │ │ │ - _default_instance: ClassVar[Optional['Event']] = None
│ │ │ │ │ -
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def default() -> 'Event':
│ │ │ │ │ - if Event._default_instance is None:
│ │ │ │ │ - Event._default_instance = Event()
│ │ │ │ │ - Event._default(byref(Event._default_instance))
│ │ │ │ │ - return Event._default_instance
│ │ │ │ │ -
│ │ │ │ │ - InotifyHandler = Callable[[inotify.Event, int, Optional[bytes]], None]
│ │ │ │ │ +import xml.etree.ElementTree as ET
│ │ │ │ │
│ │ │ │ │ - class InotifySource(Source):
│ │ │ │ │ - def __init__(self, handler: 'Event.InotifyHandler') -> None:
│ │ │ │ │ - def callback(source: libsystemd.sd_event_source,
│ │ │ │ │ - _event: Reference[inotify.inotify_event],
│ │ │ │ │ - userdata: UserData) -> int:
│ │ │ │ │ - event = _event.contents
│ │ │ │ │ - handler(inotify.Event(event.mask), event.cookie, event.name)
│ │ │ │ │ - return 0
│ │ │ │ │ - self.trampoline = libsystemd.sd_event_inotify_handler_t(callback)
│ │ │ │ │
│ │ │ │ │ - def add_inotify(self, path: str, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:
│ │ │ │ │ - source = Event.InotifySource(handler)
│ │ │ │ │ - self._add_inotify(byref(source), path, mask, source.trampoline, source.userdata)
│ │ │ │ │ - return source
│ │ │ │ │ +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 add_inotify_fd(self, fd: int, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:
│ │ │ │ │ - # HACK: sd_event_add_inotify_fd() got added in 250, which is too new. Fake it.
│ │ │ │ │ - return self.add_inotify(f'/proc/self/fd/{fd}', mask, handler)
│ │ │ │ │
│ │ │ │ │ +def parse_property(prop):
│ │ │ │ │ + return {
│ │ │ │ │ + "flags": 'w' if prop.attrib.get('access') == 'write' else 'r',
│ │ │ │ │ + "type": prop.attrib['type']
│ │ │ │ │ + }
│ │ │ │ │
│ │ │ │ │ -# This is all a bit more awkward than it should have to be: systemd's event
│ │ │ │ │ -# loop chaining model is designed for glib's prepare/check/dispatch paradigm;
│ │ │ │ │ -# failing to call prepare() can lead to deadlocks, for example.
│ │ │ │ │ -#
│ │ │ │ │ -# Hack a selector subclass which calls prepare() before sleeping and this for us.
│ │ │ │ │ -class Selector(selectors.DefaultSelector):
│ │ │ │ │ - def __init__(self, event: Optional[Event] = None) -> None:
│ │ │ │ │ - super().__init__()
│ │ │ │ │ - self.sd_event = event or Event.default()
│ │ │ │ │ - self.key = self.register(self.sd_event.get_fd(), selectors.EVENT_READ)
│ │ │ │ │
│ │ │ │ │ - def select(
│ │ │ │ │ - self, timeout: Optional[float] = None
│ │ │ │ │ - ) -> List[Tuple[selectors.SelectorKey, int]]:
│ │ │ │ │ - # It's common to drop the last reference to a Source or Slot object on
│ │ │ │ │ - # a dispatch of that same source/slot from the main loop. If we happen
│ │ │ │ │ - # to garbage collect before returning, the trampoline could be
│ │ │ │ │ - # destroyed before we're done using it. Provide a mechanism to defer
│ │ │ │ │ - # the destruction of trampolines for as long as we might be
│ │ │ │ │ - # dispatching. This gets cleared again at the bottom, before return.
│ │ │ │ │ - libsystemd.Trampoline.deferred = []
│ │ │ │ │ +def parse_signal(signal):
│ │ │ │ │ + return {"in": [tag.attrib['type'] for tag in signal.findall("arg")]}
│ │ │ │ │
│ │ │ │ │ - while self.sd_event.prepare():
│ │ │ │ │ - self.sd_event.dispatch()
│ │ │ │ │ - ready = super().select(timeout)
│ │ │ │ │ - # workaround https://github.com/systemd/systemd/issues/23826
│ │ │ │ │ - # keep calling wait() until there's nothing left
│ │ │ │ │ - while self.sd_event.wait(0):
│ │ │ │ │ - self.sd_event.dispatch()
│ │ │ │ │ - while self.sd_event.prepare():
│ │ │ │ │ - self.sd_event.dispatch()
│ │ │ │ │
│ │ │ │ │ - # We can be sure we're not dispatching callbacks anymore
│ │ │ │ │ - libsystemd.Trampoline.deferred = None
│ │ │ │ │ +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')}
│ │ │ │ │ + }
│ │ │ │ │
│ │ │ │ │ - # This could return zero events with infinite timeout, but nobody seems to mind.
│ │ │ │ │ - return [(key, events) for (key, events) in ready if key != self.key]
│ │ │ │ │
│ │ │ │ │ +def parse_xml(xml):
│ │ │ │ │ + et = ET.fromstring(xml)
│ │ │ │ │ + return {tag.attrib['name']: parse_interface(tag) for tag in et.findall('interface')}
│ │ │ │ │
│ │ │ │ │ -class EventLoopPolicy(asyncio.DefaultEventLoopPolicy):
│ │ │ │ │ - def new_event_loop(self) -> asyncio.AbstractEventLoop:
│ │ │ │ │ - return asyncio.SelectorEventLoop(Selector())
│ │ │ │ │
│ │ │ │ │ +# Pretend like this is a little bit functional
│ │ │ │ │ +def element(tag, children=(), **kwargs):
│ │ │ │ │ + tag = ET.Element(tag, kwargs)
│ │ │ │ │ + tag.extend(children)
│ │ │ │ │ + return tag
│ │ │ │ │
│ │ │ │ │ -def run_async(main: Coroutine[None, None, None], debug: Optional[bool] = None) -> None:
│ │ │ │ │ - asyncio.set_event_loop_policy(EventLoopPolicy())
│ │ │ │ │
│ │ │ │ │ - polyfill = sys.version_info < (3, 7, 0) and not hasattr(asyncio, 'run')
│ │ │ │ │ - if polyfill:
│ │ │ │ │ - # Polyfills for Python 3.6:
│ │ │ │ │ - loop = asyncio.get_event_loop()
│ │ │ │ │ +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]
│ │ │ │ │ + ])
│ │ │ │ │
│ │ │ │ │ - assert not hasattr(asyncio, 'get_running_loop')
│ │ │ │ │ - asyncio.get_running_loop = lambda: loop
│ │ │ │ │
│ │ │ │ │ - assert not hasattr(asyncio, 'create_task')
│ │ │ │ │ - asyncio.create_task = loop.create_task
│ │ │ │ │ +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'])
│ │ │ │ │
│ │ │ │ │ - assert not hasattr(asyncio, 'run')
│ │ │ │ │
│ │ │ │ │ - def run(
│ │ │ │ │ - main: Coroutine[None, None, None], debug: Optional[bool] = None
│ │ │ │ │ - ) -> None:
│ │ │ │ │ - if debug is not None:
│ │ │ │ │ - loop.set_debug(debug)
│ │ │ │ │ - loop.run_until_complete(main)
│ │ │ │ │ +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']
│ │ │ │ │ + ])
│ │ │ │ │
│ │ │ │ │ - asyncio.run = run # type: ignore[assignment]
│ │ │ │ │
│ │ │ │ │ - asyncio._systemd_ctypes_polyfills = True # type: ignore[attr-defined]
│ │ │ │ │ +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()),
│ │ │ │ │ + ])
│ │ │ │ │
│ │ │ │ │ - asyncio.run(main, debug=debug)
│ │ │ │ │
│ │ │ │ │ - if polyfill:
│ │ │ │ │ - del asyncio.create_task, asyncio.get_running_loop, asyncio.run
│ │ │ │ │ +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/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
│ │ │ │ │ @@ -9762,1815 +10422,1155 @@
│ │ │ │ │ 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/data/fail.html': br'''
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ - @@message@@
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAYAAAA6/NlyAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAN1wAADdcBQiibeAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAWGSURBVGiBzZtNaJxFGMd/z2R3YxHbtZa2pqdcPInS9CCUov3QiuhF2hIUjOZrd5NA9VoPuiJSTx6S0s27SVxJ8aOpPSgqpbZVlIIIJirqtRdNpBU3FUqhm53HQzZpPjfvm51J8oeFZd5n5v/8eWbemXfmGcETBgcHt1prm1S1CWgSkUZV3QIkgS0Vs5vAZOV3DRhV1VFr7Wh3d3fRh1/isrFcLrerrq7uKHBMVffW2P4fqjocj8fPtLe3jzty0Y3gfD7/pLX2hIjsB4yLNufAAleste90dXV9W2tjNQkOgmCfqr4lIgdrdSQMROSqtfbNTCZzedVtrKbSwMDAjnK5nBOR51dLXAtU9Yt4PJ5eTVePLDgIgmPAaWBb1LqOURSRV1Op1JkolUILHhkZSRSLxUHgpciu+cX7QCadTpfCGIcSPDQ0dF+pVDonIk/X5Jo/XAaOpNPpmysZrig4l8vtMsZcAB524ZlH/AI8k06nJ6oZVZ1Cent7NxtjvmTjiwV4FLhYKBSS1YyWjXBlzH4FHKrRkY82bdqUamlpuVXNaHh4+N7bt2/ngRdr5LsIPLfcmF42wsVisZ/axaKqV1YSC9DS0nJLVa/UygccZnoWWRJLCu7v728GWh2QIyKJCLb3uOAEOirT5yIsEhwEwTYR6XVEDFAfwdaVYIDTAwMDOxYWLhIsIjlgu0Pi0CKi9IYQ2Gat7VtYOE9wZW181CFpJBHWWpcRBjiWz+efmFswT7CIvO2YEFUN3aWNMVG6fyhYa9+YxzHzJwiCA6q63zUhEcawhwgjIgfnRnluhF93TVZBaBE+IgxgrT0xywEQBMGDwAEfZKq6nmMYABF5amhoqAHuRrgZqPNBFiVqviIMmFKpdAQqglW12RNRpJeWrwgDGGOaAczg4OBWEXnMF1GU1ZPHCKOqewuFQtJYa5twvHu5gCj0GI7SG1YBuXPnzm5jrd3jkSSSCIdr6eXa32NEZLdnktAiPEcYVd1tRKTRJwnr9/GwFBqNqlbdIXCAKIK9Rhi43zB91uMTGynCScPdgy1f2HARVs8kG0mwNUwfVfrERurSk4bpM1qfCCVYVQVwueOxFCY3TIT7+voSeFzxVVA0TJ+8+4TJZrOxEHa+xy+qes0Ao76Jdu7cueLiJpFIPOTbDxEZi6nqqIjfniQiP+bz+Z9VdWoZkzjQ5NWJaYzGYrHYWLlcVvyOn6Sn/bIo0EQiMWY6Ojr+BX5YZ2fWAldbW1snDYCInF1vb9YAZwFiAOVy+VNjzHu4z8ABQETax8fHP8hms3ap56oq+Xz+BeBDH/xAGTgPFYFdXV1/Ad94IqOaWAAR0YmJiU988QOXZw7KZyMqIu/6YqsmNorNamGtPTnzf1ZwKpW6BHzni3S9ICJX5ya0eT9bWm+IyNJnSzAb5c/X1COPEJHPOjs752UVLHorx+PxDuD6mnnlD/+USqXMwsJFgtva2m6o6mtr45M/iEhPT0/P3wvLl5x3M5nMxyIy7Io8m82uOL+HsYmAQiqVGlnqwbIkyWSy01FWDQ0NDa9UE6Sq0tDQ4Crz4BKQXu5h1Q+G3t7ezfX19d8Djzhyxjd+TyQS+1pbW5fd1KjajY4fP/6fMeZZ4DfnrrnHr7FY7HA1sRBi7dzZ2flnLBbbC1xw5pp7XAIeD5M/HTV9OA+8XItnHjAIdDtNH56LDZQgfl1EulOp1PkolVa1y5HL5baLSP96XQEAzsXj8Z62trYbUSvWfMkDyOIgCTUM1u2Sx0IEQXBARE6o6iHcbyKUga9V9WQmk6n5a87pxt2pU6ceiMfjR0SkxcFFrZ9E5IyqjqyU5R4F3nYqC4VCcmpqavYqnrW2UUSSTB/PzhzRzlzDK1K5igeMJhKJsZXm09XifyIP5eSF+BKpAAAAAElFTkSuQmC)
│ │ │ │ │ -
@@message@@
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/data/__init__.py': br'''import sys
│ │ │ │ │ -
│ │ │ │ │ -if sys.version_info >= (3, 11):
│ │ │ │ │ - import importlib.resources
│ │ │ │ │ -
│ │ │ │ │ - def read_cockpit_data_file(filename: str) -> bytes:
│ │ │ │ │ - return (importlib.resources.files('cockpit.data') / filename).read_bytes()
│ │ │ │ │ -
│ │ │ │ │ -else:
│ │ │ │ │ - import importlib.abc
│ │ │ │ │ -
│ │ │ │ │ - def read_cockpit_data_file(filename: str) -> bytes:
│ │ │ │ │ - # https://github.com/python/mypy/issues/4182
│ │ │ │ │ - loader = __loader__ # type: ignore[name-defined]
│ │ │ │ │ - assert isinstance(loader, importlib.abc.ResourceLoader)
│ │ │ │ │ -
│ │ │ │ │ - path = __file__.replace('__init__.py', filename)
│ │ │ │ │ - return loader.get_data(path)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/channels/stream.py': br'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes
│ │ │ │ │ #
│ │ │ │ │ -# 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 asyncio
│ │ │ │ │ +import errno
│ │ │ │ │ import logging
│ │ │ │ │ import os
│ │ │ │ │ -import subprocess
│ │ │ │ │ -from typing import Dict
│ │ │ │ │ +import stat
│ │ │ │ │ +from typing import Any, List, Optional
│ │ │ │ │
│ │ │ │ │ -from ..channel import ChannelError, ProtocolChannel
│ │ │ │ │ -from ..jsonutil import JsonDict, JsonObject, get_bool, get_enum, get_int, get_object, get_str, get_strv
│ │ │ │ │ -from ..transports import SubprocessProtocol, SubprocessTransport, WindowSize
│ │ │ │ │ +from .event import Event
│ │ │ │ │ +from .inotify import Event as IN
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -class SocketStreamChannel(ProtocolChannel):
│ │ │ │ │ - payload = 'stream'
│ │ │ │ │ -
│ │ │ │ │ - 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')
│ │ │ │ │ -
│ │ │ │ │ - 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}'
│ │ │ │ │ -
│ │ │ │ │ - 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')
│ │ │ │ │ -
│ │ │ │ │ - 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 SubprocessStreamChannel(ProtocolChannel, SubprocessProtocol):
│ │ │ │ │ - payload = 'stream'
│ │ │ │ │ - restrictions = (('spawn', None),)
│ │ │ │ │ -
│ │ │ │ │ - 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 do_options(self, options):
│ │ │ │ │ - window = get_object(options, 'window', WindowSize, None)
│ │ │ │ │ - if window is not None:
│ │ │ │ │ - self._transport.set_window_size(window)
│ │ │ │ │ -
│ │ │ │ │ - async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> SubprocessTransport:
│ │ │ │ │ - args = get_strv(options, 'spawn')
│ │ │ │ │ - err = get_enum(options, 'err', ['out', 'ignore', 'message'], 'message')
│ │ │ │ │ - cwd = get_str(options, 'directory', '.')
│ │ │ │ │ - pty = get_bool(options, 'pty', default=False)
│ │ │ │ │ - window = get_object(options, 'window', WindowSize, None)
│ │ │ │ │ - environ = get_strv(options, 'environ', [])
│ │ │ │ │ -
│ │ │ │ │ - if err == 'out':
│ │ │ │ │ - stderr = subprocess.STDOUT
│ │ │ │ │ - elif err == 'ignore':
│ │ │ │ │ - stderr = subprocess.DEVNULL
│ │ │ │ │ - else:
│ │ │ │ │ - stderr = subprocess.PIPE
│ │ │ │ │ -
│ │ │ │ │ - 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/http.py': br'''# This file is part of Cockpit.
│ │ │ │ │ +# inotify hard facts:
│ │ │ │ │ #
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ +# DELETE_SELF doesn't get called until all references to an inode are gone
│ │ │ │ │ +# - including open fds
│ │ │ │ │ +# - including on directories
│ │ │ │ │ #
│ │ │ │ │ -# 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.
│ │ │ │ │ +# ATTRIB gets called when unlinking files (because the link count changes) but
│ │ │ │ │ +# not on directories. When unlinking an open directory, no events at all
│ │ │ │ │ +# happen on the directory. ATTRIB also collects child events, which means we
│ │ │ │ │ +# get a lot of unwanted noise.
│ │ │ │ │ #
│ │ │ │ │ -# 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.
│ │ │ │ │ +# There's nothing like UNLINK_SELF, unfortunately.
│ │ │ │ │ #
│ │ │ │ │ -# You should have received a copy of the GNU General Public License
│ │ │ │ │ -# along with this program. If not, see .
│ │ │ │ │ -
│ │ │ │ │ -import http.client
│ │ │ │ │ -import logging
│ │ │ │ │ -import socket
│ │ │ │ │ -import ssl
│ │ │ │ │ -
│ │ │ │ │ -from ..channel import AsyncChannel, ChannelError
│ │ │ │ │ -from ..jsonutil import JsonObject, get_dict, get_int, get_object, get_str, typechecked
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ +# Even if it was possible to take this approach, it might not work: after
│ │ │ │ │ +# you've opened the fd, it might get deleted before you can establish the watch
│ │ │ │ │ +# on it.
│ │ │ │ │ +#
│ │ │ │ │ +# Additionally, systemd makes it impossible to register those events on
│ │ │ │ │ +# symlinks (because it removes IN_DONT_FOLLOW in order to watch via
│ │ │ │ │ +# /proc/self/fd).
│ │ │ │ │ +#
│ │ │ │ │ +# For all of these reasons, unfortunately, the best way seems to be to watch
│ │ │ │ │ +# for CREATE|DELETE|MOVE events on each intermediate directory.
│ │ │ │ │ +#
│ │ │ │ │ +# Unfortunately there is no way to filter to only the name we're interested in,
│ │ │ │ │ +# so we're gonna get a lot of unnecessary wakeups.
│ │ │ │ │ +#
│ │ │ │ │ +# Also: due to the above-mentioned race about watching after opening the fd,
│ │ │ │ │ +# let's just always watch for both create and delete events *before* trying to
│ │ │ │ │ +# open the fd. We could try to reduce the mask after the fact, but meh...
│ │ │ │ │ +#
│ │ │ │ │ +# We use a WatchInvalidator utility class to fill the role of "Tell me when an
│ │ │ │ │ +# event happened on this (directory) fd which impacted the name file". We
│ │ │ │ │ +# build a series of these when setting up a watch in order to find out if any
│ │ │ │ │ +# part of the path leading to the monitored file changed.
│ │ │ │ │
│ │ │ │ │ -class HttpChannel(AsyncChannel):
│ │ │ │ │ - payload = 'http-stream2'
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def get_headers(response: http.client.HTTPResponse, *, binary: bool) -> JsonObject:
│ │ │ │ │ - # Never send these headers
│ │ │ │ │ - remove = {'Connection', 'Transfer-Encoding'}
│ │ │ │ │ +class Handle(int):
│ │ │ │ │ + """An integer subclass that makes it easier to work with file descriptors"""
│ │ │ │ │
│ │ │ │ │ - if not binary:
│ │ │ │ │ - # Only send these headers for raw binary streams
│ │ │ │ │ - remove.update({'Content-Length', 'Range'})
│ │ │ │ │ + def __new__(cls, fd: int = -1) -> 'Handle':
│ │ │ │ │ + return super(Handle, cls).__new__(cls, fd)
│ │ │ │ │
│ │ │ │ │ - return {key: value for key, value in response.getheaders() if key not in remove}
│ │ │ │ │ + # separate __init__() to set _needs_close mostly to keep pylint quiet
│ │ │ │ │ + def __init__(self, fd: int = -1):
│ │ │ │ │ + super().__init__()
│ │ │ │ │ + self._needs_close = fd != -1
│ │ │ │ │
│ │ │ │ │ - @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 __bool__(self) -> bool:
│ │ │ │ │ + return self != -1
│ │ │ │ │
│ │ │ │ │ - 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 close(self) -> None:
│ │ │ │ │ + if self._needs_close:
│ │ │ │ │ + self._needs_close = False
│ │ │ │ │ + os.close(self)
│ │ │ │ │
│ │ │ │ │ - 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 __eq__(self, value: object) -> bool:
│ │ │ │ │ + if int.__eq__(self, value): # also handles both == -1
│ │ │ │ │ + return True
│ │ │ │ │
│ │ │ │ │ - if 'validate' in opt_tls and not opt_tls['validate']:
│ │ │ │ │ - context.check_hostname = False
│ │ │ │ │ - context.verify_mode = ssl.VerifyMode.CERT_NONE
│ │ │ │ │ + if not isinstance(value, int): # other object is not an int
│ │ │ │ │ + return False
│ │ │ │ │
│ │ │ │ │ - # See https://github.com/python/typeshed/issues/11057
│ │ │ │ │ - return http.client.HTTPSConnection(opt_address, port=opt_port, context=context) # type: ignore[arg-type]
│ │ │ │ │ + if not self or not value: # when only one == -1
│ │ │ │ │ + return False
│ │ │ │ │
│ │ │ │ │ - else:
│ │ │ │ │ - return http.client.HTTPConnection(opt_address, port=opt_port)
│ │ │ │ │ + return os.path.sameopenfile(self, value)
│ │ │ │ │
│ │ │ │ │ - @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 __del__(self) -> None:
│ │ │ │ │ + if self._needs_close:
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - @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 __enter__(self) -> 'Handle':
│ │ │ │ │ + return self
│ │ │ │ │
│ │ │ │ │ - async def run(self, options: JsonObject) -> None:
│ │ │ │ │ - logger.debug('open %s', options)
│ │ │ │ │ + def __exit__(self, _type: type, _value: object, _traceback: object) -> None:
│ │ │ │ │ + self.close()
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def open(cls, *args: Any, **kwargs: Any) -> 'Handle':
│ │ │ │ │ + return cls(os.open(*args, **kwargs))
│ │ │ │ │
│ │ │ │ │ - if 'connection' in options:
│ │ │ │ │ - raise ChannelError('protocol-error', message='connection sharing is not implemented on this bridge')
│ │ │ │ │ + def steal(self) -> 'Handle':
│ │ │ │ │ + self._needs_close = False
│ │ │ │ │ + return self.__class__(int(self))
│ │ │ │ │
│ │ │ │ │ - connection = self.create_client(options)
│ │ │ │ │
│ │ │ │ │ - self.ready()
│ │ │ │ │ +class WatchInvalidator:
│ │ │ │ │ + _name: bytes
│ │ │ │ │ + _source: Optional[Event.Source]
│ │ │ │ │ + _watch: Optional['PathWatch']
│ │ │ │ │
│ │ │ │ │ - body = b''
│ │ │ │ │ - while True:
│ │ │ │ │ - data = await self.read()
│ │ │ │ │ - if data is None:
│ │ │ │ │ - break
│ │ │ │ │ - body += data
│ │ │ │ │ + def event(self, mask: IN, _cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ + logger.debug('invalidator event %s %s', mask, name)
│ │ │ │ │ + if self._watch is not None:
│ │ │ │ │ + # If this node itself disappeared, that's definitely an
│ │ │ │ │ + # invalidation. Otherwise, the name needs to match.
│ │ │ │ │ + if IN.IGNORED in mask or self._name == name:
│ │ │ │ │ + logger.debug('Invalidating!')
│ │ │ │ │ + self._watch.invalidate()
│ │ │ │ │
│ │ │ │ │ - # Connect in a thread and handle errors
│ │ │ │ │ - try:
│ │ │ │ │ - await self.in_thread(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 __init__(self, watch: 'PathWatch', event: Event, dirfd: int, name: str):
│ │ │ │ │ + self._watch = watch
│ │ │ │ │ + self._name = name.encode('utf-8')
│ │ │ │ │
│ │ │ │ │ - # Submit request in a thread and handle errors
│ │ │ │ │ + # establishing invalidation watches is best-effort and can fail for a
│ │ │ │ │ + # number of reasons, including search (+x) but not read (+r) permission
│ │ │ │ │ + # on a particular path component, or exceeding limits on watches
│ │ │ │ │ try:
│ │ │ │ │ - response = await self.in_thread(self.request, connection, method, path, headers or {}, body)
│ │ │ │ │ - except (http.client.HTTPException, OSError) as exc:
│ │ │ │ │ - raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │ + mask = IN.CREATE | IN.DELETE | IN.MOVE | IN.DELETE_SELF | IN.IGNORED
│ │ │ │ │ + self._source = event.add_inotify_fd(dirfd, mask, self.event)
│ │ │ │ │ + except OSError:
│ │ │ │ │ + self._source = None
│ │ │ │ │
│ │ │ │ │ - self.send_control(command='response',
│ │ │ │ │ - status=response.status,
│ │ │ │ │ - reason=response.reason,
│ │ │ │ │ - headers=self.get_headers(response, binary=self.is_binary))
│ │ │ │ │ + def close(self) -> None:
│ │ │ │ │ + # This is a little bit tricky: systemd doesn't have a specific close
│ │ │ │ │ + # API outside of unref, so let's make it as explicit as possible.
│ │ │ │ │ + self._watch = None
│ │ │ │ │ + self._source = None
│ │ │ │ │
│ │ │ │ │ - # Receive the body and finish up
│ │ │ │ │ - try:
│ │ │ │ │ - while True:
│ │ │ │ │ - block = await self.in_thread(response.read1, self.BLOCK_SIZE)
│ │ │ │ │ - if not block:
│ │ │ │ │ - break
│ │ │ │ │ - await self.write(block)
│ │ │ │ │
│ │ │ │ │ - logger.debug('reading response done')
│ │ │ │ │ - # this returns immediately and does not read anything more, but updates the http.client's
│ │ │ │ │ - # internal state machine to "response done"
│ │ │ │ │ - block = response.read()
│ │ │ │ │ - assert block == b''
│ │ │ │ │ +class PathStack(List[str]):
│ │ │ │ │ + def add_path(self, pathname: str) -> None:
│ │ │ │ │ + # TO DO: consider doing something reasonable with trailing slashes
│ │ │ │ │ + # this is a stack, popped from the end: push components in reverse
│ │ │ │ │ + self.extend(item for item in reversed(pathname.split('/')) if item)
│ │ │ │ │ + if pathname.startswith('/'):
│ │ │ │ │ + self.append('/')
│ │ │ │ │
│ │ │ │ │ - await self.in_thread(connection.close)
│ │ │ │ │ - except (http.client.HTTPException, OSError) as exc:
│ │ │ │ │ - raise ChannelError('terminated', message=str(exc)) from exc
│ │ │ │ │ + def __init__(self, path: str):
│ │ │ │ │ + super().__init__()
│ │ │ │ │ + self.add_path(path)
│ │ │ │ │
│ │ │ │ │ - self.done()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/channels/metrics.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is distributed in the hope that it will be useful,
│ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ -# GNU General Public License for more details.
│ │ │ │ │ -#
│ │ │ │ │ -# You should have received a copy of the GNU General Public License
│ │ │ │ │ -# along with this program. If not, see .
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import json
│ │ │ │ │ -import logging
│ │ │ │ │ -import sys
│ │ │ │ │ -import time
│ │ │ │ │ -from collections import defaultdict
│ │ │ │ │ -from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union
│ │ │ │ │ +class Listener:
│ │ │ │ │ + def do_inotify_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ -from ..channel import AsyncChannel, ChannelError
│ │ │ │ │ -from ..jsonutil import JsonList
│ │ │ │ │ -from ..samples import SAMPLERS, SampleDescription, Sampler, Samples
│ │ │ │ │ + def do_identity_changed(self, fd: Optional[Handle], errno: Optional[int]) -> None:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │ +class PathWatch:
│ │ │ │ │ + _event: Event
│ │ │ │ │ + _listener: Listener
│ │ │ │ │ + _path: str
│ │ │ │ │ + _invalidators: List[WatchInvalidator]
│ │ │ │ │ + _errno: Optional[int]
│ │ │ │ │ + _source: Optional[Event.Source]
│ │ │ │ │ + _tag: Optional[None]
│ │ │ │ │ + _fd: Handle
│ │ │ │ │
│ │ │ │ │ -class MetricInfo(NamedTuple):
│ │ │ │ │ - derive: Optional[str]
│ │ │ │ │ - desc: SampleDescription
│ │ │ │ │ + def __init__(self, path: str, listener: Listener, event: Optional[Event] = None):
│ │ │ │ │ + self._event = event or Event.default()
│ │ │ │ │ + self._path = path
│ │ │ │ │ + self._listener = listener
│ │ │ │ │
│ │ │ │ │ + self._invalidators = []
│ │ │ │ │ + self._errno = None
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._tag = None
│ │ │ │ │ + self._fd = Handle()
│ │ │ │ │
│ │ │ │ │ -class InternalMetricsChannel(AsyncChannel):
│ │ │ │ │ - payload = 'metrics1'
│ │ │ │ │ - restrictions = [('source', 'internal')]
│ │ │ │ │ + self.invalidate()
│ │ │ │ │
│ │ │ │ │ - metrics: List[MetricInfo]
│ │ │ │ │ - samplers: Set
│ │ │ │ │ - samplers_cache: Optional[Dict[str, Tuple[Sampler, SampleDescription]]] = None
│ │ │ │ │ + def got_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
│ │ │ │ │ + logger.debug('target event %s: %s %s %s', self._path, mask, cookie, name)
│ │ │ │ │ + self._listener.do_inotify_event(mask, cookie, name)
│ │ │ │ │
│ │ │ │ │ - interval: int = 1000
│ │ │ │ │ - need_meta: bool = True
│ │ │ │ │ - last_timestamp: float = 0
│ │ │ │ │ - next_timestamp: float = 0
│ │ │ │ │ + def invalidate(self) -> None:
│ │ │ │ │ + for invalidator in self._invalidators:
│ │ │ │ │ + invalidator.close()
│ │ │ │ │ + self._invalidators = []
│ │ │ │ │
│ │ │ │ │ - @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:
│ │ │ │ │ + fd = self.walk()
│ │ │ │ │ + except OSError as error:
│ │ │ │ │ + logger.debug('walk ended in error %d', error.errno)
│ │ │ │ │
│ │ │ │ │ - def parse_options(self, options):
│ │ │ │ │ - logger.debug('metrics internal open: %s, channel: %s', options, self.channel)
│ │ │ │ │ + if self._source or self._fd or self._errno != error.errno:
│ │ │ │ │ + logger.debug('Ending existing watches.')
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._fd.close()
│ │ │ │ │ + self._fd = Handle()
│ │ │ │ │ + self._errno = error.errno
│ │ │ │ │
│ │ │ │ │ - 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}')
│ │ │ │ │ + logger.debug('Notifying of new error state %d', self._errno)
│ │ │ │ │ + self._listener.do_identity_changed(None, self._errno)
│ │ │ │ │
│ │ │ │ │ - self.interval = interval
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - 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)')
│ │ │ │ │ + with fd:
│ │ │ │ │ + logger.debug('walk successful. Got fd %d', fd)
│ │ │ │ │ + if fd == self._fd:
│ │ │ │ │ + logger.debug('fd seems to refer to same file. Doing nothing.')
│ │ │ │ │ + return
│ │ │ │ │
│ │ │ │ │ - sampler_classes = set()
│ │ │ │ │ - for metric in metrics:
│ │ │ │ │ - # validate it's an object
│ │ │ │ │ - name = metric.get('name')
│ │ │ │ │ - units = metric.get('units')
│ │ │ │ │ - derive = metric.get('derive')
│ │ │ │ │ + logger.debug('This file is new for us. Removing old watch.')
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._fd.close()
│ │ │ │ │ + self._fd = fd.steal()
│ │ │ │ │
│ │ │ │ │ try:
│ │ │ │ │ - sampler, desc = self.samplers_cache[name]
│ │ │ │ │ - except KeyError as exc:
│ │ │ │ │ - logger.error('unsupported metric: %s', name)
│ │ │ │ │ - raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc
│ │ │ │ │ -
│ │ │ │ │ - if units and units != desc.units:
│ │ │ │ │ - raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}')
│ │ │ │ │ -
│ │ │ │ │ - sampler_classes.add(sampler)
│ │ │ │ │ - self.metrics.append(MetricInfo(derive=derive, desc=desc))
│ │ │ │ │ -
│ │ │ │ │ - self.samplers = {cls() for cls in sampler_classes}
│ │ │ │ │ -
│ │ │ │ │ - def send_meta(self, samples: Samples, timestamp: float):
│ │ │ │ │ - metrics: JsonList = []
│ │ │ │ │ - for metricinfo in self.metrics:
│ │ │ │ │ - if metricinfo.desc.instanced:
│ │ │ │ │ - metrics.append({
│ │ │ │ │ - 'name': metricinfo.desc.name,
│ │ │ │ │ - 'units': metricinfo.desc.units,
│ │ │ │ │ - 'instances': list(samples[metricinfo.desc.name].keys()),
│ │ │ │ │ - 'semantics': metricinfo.desc.semantics
│ │ │ │ │ - })
│ │ │ │ │ - else:
│ │ │ │ │ - metrics.append({
│ │ │ │ │ - 'name': metricinfo.desc.name,
│ │ │ │ │ - 'derive': metricinfo.derive, # type: ignore[dict-item]
│ │ │ │ │ - 'units': metricinfo.desc.units,
│ │ │ │ │ - 'semantics': metricinfo.desc.semantics
│ │ │ │ │ - })
│ │ │ │ │ -
│ │ │ │ │ - self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000, metrics=metrics)
│ │ │ │ │ - self.need_meta = False
│ │ │ │ │ -
│ │ │ │ │ - def sample(self):
│ │ │ │ │ - samples = defaultdict(dict)
│ │ │ │ │ - for sampler in self.samplers:
│ │ │ │ │ - sampler.sample(samples)
│ │ │ │ │ - return samples
│ │ │ │ │ -
│ │ │ │ │ - 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 send_updates(self, samples: Samples, last_samples: Samples):
│ │ │ │ │ - data: List[Union[float, List[Optional[Union[float, bool]]]]] = []
│ │ │ │ │ - timestamp = time.time()
│ │ │ │ │ - self.next_timestamp = timestamp
│ │ │ │ │ -
│ │ │ │ │ - for metricinfo in self.metrics:
│ │ │ │ │ - value = samples[metricinfo.desc.name]
│ │ │ │ │ + logger.debug('Establishing a new watch.')
│ │ │ │ │ + self._source = self._event.add_inotify_fd(self._fd, IN.CHANGED, self.got_event)
│ │ │ │ │ + logger.debug('Watching successfully. Notifying of new identity.')
│ │ │ │ │ + self._listener.do_identity_changed(self._fd, None)
│ │ │ │ │ + except OSError as error:
│ │ │ │ │ + logger.debug('Watching failed (%d). Notifying of new identity.', error.errno)
│ │ │ │ │ + self._listener.do_identity_changed(self._fd, error.errno)
│ │ │ │ │
│ │ │ │ │ - if metricinfo.desc.instanced:
│ │ │ │ │ - old_value = last_samples[metricinfo.desc.name]
│ │ │ │ │ - assert isinstance(value, dict)
│ │ │ │ │ - assert isinstance(old_value, dict)
│ │ │ │ │ + def walk(self) -> Handle:
│ │ │ │ │ + remaining_symlink_lookups = 40
│ │ │ │ │ + remaining_components = PathStack(self._path)
│ │ │ │ │ + dirfd = Handle()
│ │ │ │ │
│ │ │ │ │ - # If we have less or more keys the data changed, send a meta message.
│ │ │ │ │ - if value.keys() != old_value.keys():
│ │ │ │ │ - self.need_meta = True
│ │ │ │ │ + try:
│ │ │ │ │ + logger.debug('Starting path walk')
│ │ │ │ │
│ │ │ │ │ - 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)))
│ │ │ │ │ + while remaining_components:
│ │ │ │ │ + logger.debug('r=%s dfd=%s', remaining_components, dirfd)
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + name = remaining_components.pop()
│ │ │ │ │
│ │ │ │ │ - if metricinfo.derive == 'rate':
│ │ │ │ │ - data.append(self.calculate_sample_rate(value, old_value))
│ │ │ │ │ - else:
│ │ │ │ │ - data.append(value)
│ │ │ │ │ + if dirfd and name != '/':
│ │ │ │ │ + self._invalidators.append(WatchInvalidator(self, self._event, dirfd, name))
│ │ │ │ │
│ │ │ │ │ - if self.need_meta:
│ │ │ │ │ - self.send_meta(samples, timestamp)
│ │ │ │ │ + with Handle.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=dirfd) as fd:
│ │ │ │ │ + mode = os.fstat(fd).st_mode
│ │ │ │ │
│ │ │ │ │ - self.last_timestamp = self.next_timestamp
│ │ │ │ │ - self.send_text(json.dumps([data]))
│ │ │ │ │ + if stat.S_ISLNK(mode):
│ │ │ │ │ + if remaining_symlink_lookups == 0:
│ │ │ │ │ + raise OSError(errno.ELOOP, os.strerror(errno.ELOOP))
│ │ │ │ │ + remaining_symlink_lookups -= 1
│ │ │ │ │ + linkpath = os.readlink('', dir_fd=fd)
│ │ │ │ │ + logger.debug('%s is a symlink. adding %s to components', name, linkpath)
│ │ │ │ │ + remaining_components.add_path(linkpath)
│ │ │ │ │
│ │ │ │ │ - async def run(self, options):
│ │ │ │ │ - self.metrics = []
│ │ │ │ │ - self.samplers = set()
│ │ │ │ │ + else:
│ │ │ │ │ + dirfd.close()
│ │ │ │ │ + dirfd = fd.steal()
│ │ │ │ │
│ │ │ │ │ - InternalMetricsChannel.ensure_samplers()
│ │ │ │ │ + return dirfd.steal()
│ │ │ │ │
│ │ │ │ │ - self.parse_options(options)
│ │ │ │ │ - self.ready()
│ │ │ │ │ + finally:
│ │ │ │ │ + dirfd.close()
│ │ │ │ │
│ │ │ │ │ - last_samples = defaultdict(dict)
│ │ │ │ │ - while True:
│ │ │ │ │ - samples = self.sample()
│ │ │ │ │ - self.send_updates(samples, last_samples)
│ │ │ │ │ - last_samples = samples
│ │ │ │ │ - await asyncio.sleep(self.interval / 1000)
│ │ │ │ │ + def close(self) -> None:
│ │ │ │ │ + for invalidator in self._invalidators:
│ │ │ │ │ + invalidator.close()
│ │ │ │ │ + self._invalidators = []
│ │ │ │ │ + self._source = None
│ │ │ │ │ + self._fd.close()
│ │ │ │ │ ''',
│ │ │ │ │ - 'cockpit/channels/filesystem.py': r'''# This file is part of Cockpit.
│ │ │ │ │ + 'cockpit/_vendor/systemd_ctypes/bus.py': br'''# systemd_ctypes
│ │ │ │ │ #
│ │ │ │ │ -# 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 asyncio
│ │ │ │ │ -import contextlib
│ │ │ │ │ import enum
│ │ │ │ │ -import errno
│ │ │ │ │ -import fnmatch
│ │ │ │ │ -import functools
│ │ │ │ │ -import grp
│ │ │ │ │ import logging
│ │ │ │ │ -import os
│ │ │ │ │ -import pwd
│ │ │ │ │ -import re
│ │ │ │ │ -import stat
│ │ │ │ │ -import tempfile
│ │ │ │ │ -from pathlib import Path
│ │ │ │ │ -from typing import Callable, Generator, 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 typing
│ │ │ │ │ +from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union
│ │ │ │ │
│ │ │ │ │ -from ..channel import AsyncChannel, Channel, ChannelError, GeneratorChannel
│ │ │ │ │ -from ..jsonutil import (
│ │ │ │ │ - JsonDict,
│ │ │ │ │ - JsonDocument,
│ │ │ │ │ - JsonError,
│ │ │ │ │ - JsonObject,
│ │ │ │ │ - get_bool,
│ │ │ │ │ - get_int,
│ │ │ │ │ - get_str,
│ │ │ │ │ - get_strv,
│ │ │ │ │ - json_merge_and_filter_patch,
│ │ │ │ │ -)
│ │ │ │ │ +from . import bustypes, introspection, libsystemd
│ │ │ │ │ +from .librarywrapper import WeakReference, byref
│ │ │ │ │
│ │ │ │ │ logger = logging.getLogger(__name__)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -@functools.lru_cache()
│ │ │ │ │ -def my_umask() -> int:
│ │ │ │ │ - match = re.search(r'^Umask:\s*0([0-7]*)$', Path('/proc/self/status').read_text(), re.M)
│ │ │ │ │ - return (match and int(match.group(1), 8)) or 0o077
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -def tag_from_stat(buf):
│ │ │ │ │ - return f'1:{buf.st_ino}-{buf.st_mtime}-{buf.st_mode:o}-{buf.st_uid}-{buf.st_gid}'
│ │ │ │ │ +class BusError(Exception):
│ │ │ │ │ + """An exception corresponding to a D-Bus error message
│ │ │ │ │
│ │ │ │ │ + This exception is raised by the method call methods. You can also raise it
│ │ │ │ │ + from your own method handlers. It can also be passed directly to functions
│ │ │ │ │ + such as Message.reply_method_error().
│ │ │ │ │
│ │ │ │ │ -def tag_from_path(path):
│ │ │ │ │ - try:
│ │ │ │ │ - return tag_from_stat(os.stat(path))
│ │ │ │ │ - except FileNotFoundError:
│ │ │ │ │ - return '-'
│ │ │ │ │ - except OSError:
│ │ │ │ │ - return None
│ │ │ │ │ + :name: the 'code' of the error, like org.freedesktop.DBus.Error.UnknownMethod
│ │ │ │ │ + :message: a human-readable description of the error
│ │ │ │ │ + """
│ │ │ │ │ + def __init__(self, name: str, message: str):
│ │ │ │ │ + super().__init__(f'{name}: {message}')
│ │ │ │ │ + self.name = name
│ │ │ │ │ + self.message = message
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │ -def tag_from_fd(fd):
│ │ │ │ │ - try:
│ │ │ │ │ - return tag_from_stat(os.fstat(fd))
│ │ │ │ │ - except OSError:
│ │ │ │ │ - return None
│ │ │ │ │ +class BusMessage(libsystemd.sd_bus_message):
│ │ │ │ │ + """A message, received from or to be sent over D-Bus
│ │ │ │ │
│ │ │ │ │ + This is the low-level interface to receiving and sending individual
│ │ │ │ │ + messages over D-Bus. You won't normally need to use it.
│ │ │ │ │
│ │ │ │ │ -class FsListChannel(Channel):
│ │ │ │ │ - payload = 'fslist1'
│ │ │ │ │ + A message is associated with a particular bus. You can create messages for
│ │ │ │ │ + a bus with Bus.message_new_method_call() or Bus.message_new_signal(). You
│ │ │ │ │ + can create replies to method calls with Message.new_method_return() or
│ │ │ │ │ + Message.new_method_error(). You can append arguments with Message.append()
│ │ │ │ │ + and send the message with Message.send().
│ │ │ │ │ + """
│ │ │ │ │ + def get_bus(self) -> 'Bus':
│ │ │ │ │ + """Get the bus that a message is associated with.
│ │ │ │ │
│ │ │ │ │ - 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'
│ │ │ │ │ + This is the bus that a message came from or will be sent on. Every
│ │ │ │ │ + message has an associated bus, and it cannot be changed.
│ │ │ │ │
│ │ │ │ │ - self.send_json(event=event, path=entry.name, type=mode)
│ │ │ │ │ + :returns: the Bus
│ │ │ │ │ + """
│ │ │ │ │ + return Bus.ref(self._get_bus())
│ │ │ │ │
│ │ │ │ │ - def do_open(self, options):
│ │ │ │ │ - path = options.get('path')
│ │ │ │ │ - watch = options.get('watch', True)
│ │ │ │ │ + def get_error(self) -> Optional[BusError]:
│ │ │ │ │ + """Get the BusError from a message.
│ │ │ │ │
│ │ │ │ │ - if watch:
│ │ │ │ │ - raise ChannelError('not-supported', message='watching is not implemented, use fswatch1')
│ │ │ │ │ + :returns: a BusError for an error message, or None for a non-error message
│ │ │ │ │ + """
│ │ │ │ │ + error = self._get_error()
│ │ │ │ │ + if error:
│ │ │ │ │ + return BusError(*error.contents.get())
│ │ │ │ │ + else:
│ │ │ │ │ + return None
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + def new_method_return(self, signature: str = '', *args: Any) -> 'BusMessage':
│ │ │ │ │ + """Create a new (successful) return message as a reply to this message.
│ │ │ │ │
│ │ │ │ │ - self.ready()
│ │ │ │ │ - for entry in scan_dir:
│ │ │ │ │ - self.send_entry("present", entry)
│ │ │ │ │ + This only makes sense when performed on a method call message.
│ │ │ │ │
│ │ │ │ │ - if not watch:
│ │ │ │ │ - self.done()
│ │ │ │ │ - self.close()
│ │ │ │ │ + :signature: The signature of the result, as a string.
│ │ │ │ │ + :args: The values to send, conforming to the signature string.
│ │ │ │ │
│ │ │ │ │ + :returns: the reply message
│ │ │ │ │ + """
│ │ │ │ │ + reply = BusMessage()
│ │ │ │ │ + self._new_method_return(byref(reply))
│ │ │ │ │ + reply.append(signature, *args)
│ │ │ │ │ + return reply
│ │ │ │ │
│ │ │ │ │ -class FsReadChannel(GeneratorChannel):
│ │ │ │ │ - payload = 'fsread1'
│ │ │ │ │ + def new_method_error(self, error: Union[BusError, OSError]) -> 'BusMessage':
│ │ │ │ │ + """Create a new error message as a reply to this message.
│ │ │ │ │
│ │ │ │ │ - def do_yield_data(self, options: JsonObject) -> Generator[bytes, None, JsonObject]:
│ │ │ │ │ - path = get_str(options, 'path')
│ │ │ │ │ - max_read_size = get_int(options, 'max_read_size', None)
│ │ │ │ │ + This only makes sense when performed on a method call message.
│ │ │ │ │
│ │ │ │ │ - logger.debug('Opening file "%s" for reading', path)
│ │ │ │ │ + :error: BusError or OSError of the error to send
│ │ │ │ │
│ │ │ │ │ - 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')
│ │ │ │ │ + :returns: the reply message
│ │ │ │ │ + """
│ │ │ │ │ + reply = BusMessage()
│ │ │ │ │ + if isinstance(error, BusError):
│ │ │ │ │ + self._new_method_errorf(byref(reply), error.name, "%s", error.message)
│ │ │ │ │ + else:
│ │ │ │ │ + assert isinstance(error, OSError)
│ │ │ │ │ + self._new_method_errnof(byref(reply), error.errno, "%s", str(error))
│ │ │ │ │ + return reply
│ │ │ │ │
│ │ │ │ │ - if self.is_binary and stat.S_ISREG(buf.st_mode):
│ │ │ │ │ - self.ready(size_hint=buf.st_size)
│ │ │ │ │ - else:
│ │ │ │ │ - self.ready()
│ │ │ │ │ + def append_arg(self, typestring: str, arg: Any) -> None:
│ │ │ │ │ + """Append a single argument to the message.
│ │ │ │ │
│ │ │ │ │ - while True:
│ │ │ │ │ - data = filep.read1(Channel.BLOCK_SIZE)
│ │ │ │ │ - if data == b'':
│ │ │ │ │ - break
│ │ │ │ │ - logger.debug(' ...sending %d bytes', len(data))
│ │ │ │ │ - if not self.is_binary:
│ │ │ │ │ - data = data.replace(b'\0', b'').decode(errors='ignore').encode()
│ │ │ │ │ - yield data
│ │ │ │ │ + :typestring: a single typestring, such as 's', or 'a{sv}'
│ │ │ │ │ + :arg: the argument to append, matching the typestring
│ │ │ │ │ + """
│ │ │ │ │ + type_, = bustypes.from_signature(typestring)
│ │ │ │ │ + type_.writer(self, arg)
│ │ │ │ │
│ │ │ │ │ - return {'tag': tag_from_stat(buf)}
│ │ │ │ │ + def append(self, signature: str, *args: Any) -> None:
│ │ │ │ │ + """Append zero or more arguments to the message.
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + :signature: concatenated typestrings, such 'a{sv}' (one arg), or 'ss' (two args)
│ │ │ │ │ + :args: one argument for each type string in the signature
│ │ │ │ │ + """
│ │ │ │ │ + types = bustypes.from_signature(signature)
│ │ │ │ │ + assert len(types) == len(args), f'call args {args} have different length than signature {signature}'
│ │ │ │ │ + for type_, arg in zip(types, args):
│ │ │ │ │ + type_.writer(self, arg)
│ │ │ │ │
│ │ │ │ │ + def get_body(self) -> Tuple[object, ...]:
│ │ │ │ │ + """Gets the body of a message.
│ │ │ │ │
│ │ │ │ │ -class FsReplaceChannel(AsyncChannel):
│ │ │ │ │ - payload = 'fsreplace1'
│ │ │ │ │ + Possible return values are (), ('single',), or ('x', 'y'). If you
│ │ │ │ │ + check the signature of the message using Message.has_signature() then
│ │ │ │ │ + you can use tuple unpacking.
│ │ │ │ │
│ │ │ │ │ - def delete(self, path: str, tag: 'str | None') -> str:
│ │ │ │ │ - if tag is not None and tag != tag_from_path(path):
│ │ │ │ │ - raise ChannelError('change-conflict')
│ │ │ │ │ - with contextlib.suppress(FileNotFoundError): # delete is idempotent
│ │ │ │ │ - os.unlink(path)
│ │ │ │ │ - return '-'
│ │ │ │ │ + single, = message.get_body()
│ │ │ │ │
│ │ │ │ │ - async def set_contents(self, path: str, tag: 'str | None', data: 'bytes | None', size: 'int | None') -> str:
│ │ │ │ │ - dirname, basename = os.path.split(path)
│ │ │ │ │ - tmpname: str | None
│ │ │ │ │ - fd, tmpname = tempfile.mkstemp(dir=dirname, prefix=f'.{basename}-')
│ │ │ │ │ - try:
│ │ │ │ │ - if size is not None:
│ │ │ │ │ - logger.debug('fallocate(%s.tmp, %d)', path, size)
│ │ │ │ │ - if size: # posix_fallocate() of 0 bytes is EINVAL
│ │ │ │ │ - await self.in_thread(os.posix_fallocate, fd, 0, size)
│ │ │ │ │ - self.ready() # ...only after that worked
│ │ │ │ │ + x, y = other_message.get_body()
│ │ │ │ │
│ │ │ │ │ - written = 0
│ │ │ │ │ - while data is not None:
│ │ │ │ │ - await self.in_thread(os.write, fd, data)
│ │ │ │ │ - written += len(data)
│ │ │ │ │ - data = await self.read()
│ │ │ │ │ + :returns: an n-tuple containing one value per argument in the message
│ │ │ │ │ + """
│ │ │ │ │ + self.rewind(True)
│ │ │ │ │ + types = bustypes.from_signature(self.get_signature(True))
│ │ │ │ │ + return tuple(type_.reader(self) for type_ in types)
│ │ │ │ │
│ │ │ │ │ - if size is not None and written < size:
│ │ │ │ │ - logger.debug('ftruncate(%s.tmp, %d)', path, written)
│ │ │ │ │ - await self.in_thread(os.ftruncate, fd, written)
│ │ │ │ │ + def send(self) -> bool: # Literal[True]
│ │ │ │ │ + """Sends a message on the bus that it was created for.
│ │ │ │ │
│ │ │ │ │ - await self.in_thread(os.fdatasync, fd)
│ │ │ │ │ + :returns: True
│ │ │ │ │ + """
│ │ │ │ │ + self.get_bus().send(self, None)
│ │ │ │ │ + return True
│ │ │ │ │
│ │ │ │ │ - if tag is None:
│ │ │ │ │ - # no preconditions about what currently exists or not
│ │ │ │ │ - # calculate the file mode from the umask
│ │ │ │ │ - os.fchmod(fd, 0o666 & ~my_umask())
│ │ │ │ │ - os.rename(tmpname, path)
│ │ │ │ │ - tmpname = None
│ │ │ │ │ + def reply_method_error(self, error: Union[BusError, OSError]) -> bool: # Literal[True]
│ │ │ │ │ + """Sends an error as a reply to a method call message.
│ │ │ │ │
│ │ │ │ │ - elif tag == '-':
│ │ │ │ │ - # the file must not exist. file mode from umask.
│ │ │ │ │ - os.fchmod(fd, 0o666 & ~my_umask())
│ │ │ │ │ - os.link(tmpname, path) # will fail if file exists
│ │ │ │ │ + :error: A BusError or OSError
│ │ │ │ │
│ │ │ │ │ - else:
│ │ │ │ │ - # the file must exist with the given tag
│ │ │ │ │ - buf = os.stat(path)
│ │ │ │ │ - if tag != tag_from_stat(buf):
│ │ │ │ │ - raise ChannelError('change-conflict')
│ │ │ │ │ - # chown/chmod from the existing file permissions
│ │ │ │ │ - os.fchmod(fd, stat.S_IMODE(buf.st_mode))
│ │ │ │ │ - os.fchown(fd, buf.st_uid, buf.st_gid)
│ │ │ │ │ - os.rename(tmpname, path)
│ │ │ │ │ - tmpname = None
│ │ │ │ │ + :returns: True
│ │ │ │ │ + """
│ │ │ │ │ + return self.new_method_error(error).send()
│ │ │ │ │
│ │ │ │ │ - finally:
│ │ │ │ │ - os.close(fd)
│ │ │ │ │ - if tmpname is not None:
│ │ │ │ │ - os.unlink(tmpname)
│ │ │ │ │ + def reply_method_return(self, signature: str = '', *args: Any) -> bool: # Literal[True]
│ │ │ │ │ + """Sends a return value as a reply to a method call message.
│ │ │ │ │
│ │ │ │ │ - return tag_from_path(path)
│ │ │ │ │ + :signature: The signature of the result, as a string.
│ │ │ │ │ + :args: The values to send, conforming to the signature string.
│ │ │ │ │
│ │ │ │ │ - async def run(self, options: JsonObject) -> JsonObject:
│ │ │ │ │ - path = get_str(options, 'path')
│ │ │ │ │ - size = get_int(options, 'size', None)
│ │ │ │ │ - tag = get_str(options, 'tag', None)
│ │ │ │ │ + :returns: True
│ │ │ │ │ + """
│ │ │ │ │ + return self.new_method_return(signature, *args).send()
│ │ │ │ │
│ │ │ │ │ + def _coroutine_task_complete(self, out_type: bustypes.MessageType, task: asyncio.Task) -> None:
│ │ │ │ │ try:
│ │ │ │ │ - # In the `size` case, .set_contents() sends the ready only after
│ │ │ │ │ - # it knows that the allocate was successful. In the case without
│ │ │ │ │ - # `size`, we need to send the ready() up front in order to
│ │ │ │ │ - # receive the first frame and decide if we're creating or deleting.
│ │ │ │ │ - if size is not None:
│ │ │ │ │ - tag = await self.set_contents(path, tag, b'', size)
│ │ │ │ │ - else:
│ │ │ │ │ - self.ready()
│ │ │ │ │ - data = await self.read()
│ │ │ │ │ - # if we get EOF right away, that's a request to delete
│ │ │ │ │ - if data is None:
│ │ │ │ │ - tag = self.delete(path, tag)
│ │ │ │ │ - else:
│ │ │ │ │ - tag = await self.set_contents(path, tag, data, None)
│ │ │ │ │ + self.reply_method_function_return_value(out_type, task.result())
│ │ │ │ │ + except (BusError, OSError) as exc:
│ │ │ │ │ + self.reply_method_error(exc)
│ │ │ │ │
│ │ │ │ │ - self.done()
│ │ │ │ │ - return {'tag': tag}
│ │ │ │ │ + def reply_method_function_return_value(self,
│ │ │ │ │ + out_type: bustypes.MessageType,
│ │ │ │ │ + return_value: Any) -> bool: # Literal[True]:
│ │ │ │ │ + """Sends the result of a function call as a reply to a method call message.
│ │ │ │ │
│ │ │ │ │ - except FileNotFoundError as exc:
│ │ │ │ │ - raise ChannelError('not-found') from exc
│ │ │ │ │ - except FileExistsError as exc:
│ │ │ │ │ - # that's from link() noticing that the target file already exists
│ │ │ │ │ - raise ChannelError('change-conflict') from exc
│ │ │ │ │ - except PermissionError as exc:
│ │ │ │ │ - raise ChannelError('access-denied') from exc
│ │ │ │ │ - except IsADirectoryError as exc:
│ │ │ │ │ - # not ideal, but the closest code we have
│ │ │ │ │ - raise ChannelError('access-denied', message=str(exc)) from exc
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - raise ChannelError('internal-error', message=str(exc)) from exc
│ │ │ │ │ + This call does a bit of magic: it adapts from the usual Python return
│ │ │ │ │ + value conventions (where the return value is ``None``, a single value,
│ │ │ │ │ + or a tuple) to the normal D-Bus return value conventions (where the
│ │ │ │ │ + result is always a tuple).
│ │ │ │ │
│ │ │ │ │ + Additionally, if the value is found to be a coroutine, a task is
│ │ │ │ │ + created to run the coroutine to completion and return the result
│ │ │ │ │ + (including exception handling).
│ │ │ │ │
│ │ │ │ │ -class FsWatchChannel(Channel, PathWatchListener):
│ │ │ │ │ - payload = 'fswatch1'
│ │ │ │ │ - _tag = None
│ │ │ │ │ - _watch = None
│ │ │ │ │ + :out_types: The types of the return values, as an iterable.
│ │ │ │ │ + :return_value: The return value of a Python function call.
│ │ │ │ │
│ │ │ │ │ - # 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
│ │ │ │ │ + :returns: True
│ │ │ │ │ + """
│ │ │ │ │ + if asyncio.coroutines.iscoroutine(return_value):
│ │ │ │ │ + task = asyncio.create_task(return_value)
│ │ │ │ │ + task.add_done_callback(lambda task: self._coroutine_task_complete(out_type, task))
│ │ │ │ │ + return True
│ │ │ │ │
│ │ │ │ │ - @staticmethod
│ │ │ │ │ - def mask_to_event_and_type(mask: InotifyEvent) -> 'tuple[str, str | None]':
│ │ │ │ │ - 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
│ │ │ │ │ + reply = self.new_method_return()
│ │ │ │ │ + # In the general case, a function returns an n-tuple, but...
│ │ │ │ │ + if len(out_type) == 0:
│ │ │ │ │ + # Functions with no return value return None.
│ │ │ │ │ + assert return_value is None
│ │ │ │ │ + elif len(out_type) == 1:
│ │ │ │ │ + # Functions with a single return value return that value.
│ │ │ │ │ + out_type.write(reply, return_value)
│ │ │ │ │ else:
│ │ │ │ │ - return 'changed', None
│ │ │ │ │ + # (general case) n return values are handled as an n-tuple.
│ │ │ │ │ + assert len(out_type) == len(return_value)
│ │ │ │ │ + out_type.write(reply, *return_value)
│ │ │ │ │ + return reply.send()
│ │ │ │ │
│ │ │ │ │ - def do_inotify_event(self, mask: InotifyEvent, _cookie: int, name: 'bytes | None') -> None:
│ │ │ │ │ - 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_)
│ │ │ │ │
│ │ │ │ │ - def do_identity_changed(self, fd: 'int | None', err: 'int | None') -> None:
│ │ │ │ │ - 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)
│ │ │ │ │ +class Slot(libsystemd.sd_bus_slot):
│ │ │ │ │ + def __init__(self, callback: Callable[[BusMessage], bool]):
│ │ │ │ │ + def handler(message: WeakReference, _data: object, _err: object) -> int:
│ │ │ │ │ + return 1 if callback(BusMessage.ref(message)) else 0
│ │ │ │ │ + self.trampoline = libsystemd.sd_bus_message_handler_t(handler)
│ │ │ │ │
│ │ │ │ │ - def do_open(self, options: JsonObject) -> None:
│ │ │ │ │ - self._path = get_str(options, 'path')
│ │ │ │ │ - self._tag = None
│ │ │ │ │
│ │ │ │ │ - self._active = False
│ │ │ │ │ - self._watch = PathWatch(self._path, self)
│ │ │ │ │ - self._active = True
│ │ │ │ │ +if typing.TYPE_CHECKING:
│ │ │ │ │ + FutureMessage = asyncio.Future[BusMessage]
│ │ │ │ │ +else:
│ │ │ │ │ + # Python 3.6 can't subscript asyncio.Future
│ │ │ │ │ + FutureMessage = asyncio.Future
│ │ │ │ │
│ │ │ │ │ - self.ready()
│ │ │ │ │
│ │ │ │ │ - def do_close(self) -> None:
│ │ │ │ │ - if self._watch is not None:
│ │ │ │ │ - self._watch.close()
│ │ │ │ │ - self._watch = None
│ │ │ │ │ - self.close()
│ │ │ │ │ +class PendingCall(Slot):
│ │ │ │ │ + future: FutureMessage
│ │ │ │ │
│ │ │ │ │ + def __init__(self) -> None:
│ │ │ │ │ + future = asyncio.get_running_loop().create_future()
│ │ │ │ │
│ │ │ │ │ -class Follow(enum.Enum):
│ │ │ │ │ - NO = False
│ │ │ │ │ - YES = True
│ │ │ │ │ + def done(message: BusMessage) -> bool:
│ │ │ │ │ + error = message.get_error()
│ │ │ │ │ + if future.cancelled():
│ │ │ │ │ + return True
│ │ │ │ │ + if error is not None:
│ │ │ │ │ + future.set_exception(error)
│ │ │ │ │ + else:
│ │ │ │ │ + future.set_result(message)
│ │ │ │ │ + return True
│ │ │ │ │
│ │ │ │ │ + super().__init__(done)
│ │ │ │ │ + self.future = future
│ │ │ │ │
│ │ │ │ │ -class FsInfoChannel(Channel, PathWatchListener):
│ │ │ │ │ - payload = 'fsinfo'
│ │ │ │ │
│ │ │ │ │ - # Options (all get set in `do_open()`)
│ │ │ │ │ - path: str
│ │ │ │ │ - attrs: 'set[str]'
│ │ │ │ │ - fnmatch: str
│ │ │ │ │ - targets: bool
│ │ │ │ │ - follow: bool
│ │ │ │ │ - watch: bool
│ │ │ │ │ +class Bus(libsystemd.sd_bus):
│ │ │ │ │ + _default_system_instance = None
│ │ │ │ │ + _default_user_instance = None
│ │ │ │ │
│ │ │ │ │ - # 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]'
│ │ │ │ │ + class NameFlags(enum.IntFlag):
│ │ │ │ │ + DEFAULT = 0
│ │ │ │ │ + REPLACE_EXISTING = 1 << 0
│ │ │ │ │ + ALLOW_REPLACEMENT = 1 << 1
│ │ │ │ │ + QUEUE = 1 << 2
│ │ │ │ │
│ │ │ │ │ @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
│ │ │ │ │ -
│ │ │ │ │ - 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)
│ │ │ │ │ + def new(
│ │ │ │ │ + fd: Optional[int] = None,
│ │ │ │ │ + address: Optional[str] = None,
│ │ │ │ │ + bus_client: bool = False,
│ │ │ │ │ + server: bool = False,
│ │ │ │ │ + start: bool = True,
│ │ │ │ │ + attach_event: bool = True
│ │ │ │ │ + ) -> 'Bus':
│ │ │ │ │ + bus = Bus()
│ │ │ │ │ + Bus._new(byref(bus))
│ │ │ │ │ + if address is not None:
│ │ │ │ │ + bus.set_address(address)
│ │ │ │ │ + if fd is not None:
│ │ │ │ │ + bus.set_fd(fd, fd)
│ │ │ │ │ + if bus_client:
│ │ │ │ │ + bus.set_bus_client(True)
│ │ │ │ │ + if server:
│ │ │ │ │ + bus.set_server(True, libsystemd.sd_id128())
│ │ │ │ │ + if address is not None or fd is not None:
│ │ │ │ │ + if start:
│ │ │ │ │ + bus.start()
│ │ │ │ │ + if attach_event:
│ │ │ │ │ + bus.attach_event(None, 0)
│ │ │ │ │ + return bus
│ │ │ │ │
│ │ │ │ │ - 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}
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def default_system(attach_event: bool = True) -> 'Bus':
│ │ │ │ │ + if Bus._default_system_instance is None:
│ │ │ │ │ + Bus._default_system_instance = Bus()
│ │ │ │ │ + Bus._default_system(byref(Bus._default_system_instance))
│ │ │ │ │ + if attach_event:
│ │ │ │ │ + Bus._default_system_instance.attach_event(None, 0)
│ │ │ │ │ + return Bus._default_system_instance
│ │ │ │ │
│ │ │ │ │ - result = {key: func(buf) for key, func in stat_getters}
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def default_user(attach_event: bool = True) -> 'Bus':
│ │ │ │ │ + if Bus._default_user_instance is None:
│ │ │ │ │ + Bus._default_user_instance = Bus()
│ │ │ │ │ + Bus._default_user(byref(Bus._default_user_instance))
│ │ │ │ │ + if attach_event:
│ │ │ │ │ + Bus._default_user_instance.attach_event(None, 0)
│ │ │ │ │ + return Bus._default_user_instance
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + def message_new_method_call(
│ │ │ │ │ + self,
│ │ │ │ │ + destination: Optional[str],
│ │ │ │ │ + path: str,
│ │ │ │ │ + interface: str,
│ │ │ │ │ + member: str,
│ │ │ │ │ + types: str = '',
│ │ │ │ │ + *args: object
│ │ │ │ │ + ) -> BusMessage:
│ │ │ │ │ + message = BusMessage()
│ │ │ │ │ + self._message_new_method_call(byref(message), destination, path, interface, member)
│ │ │ │ │ + message.append(types, *args)
│ │ │ │ │ + return message
│ │ │ │ │
│ │ │ │ │ - return result
│ │ │ │ │ + def message_new_signal(
│ │ │ │ │ + self, path: str, interface: str, member: str, types: str = '', *args: object
│ │ │ │ │ + ) -> BusMessage:
│ │ │ │ │ + message = BusMessage()
│ │ │ │ │ + self._message_new_signal(byref(message), path, interface, member)
│ │ │ │ │ + message.append(types, *args)
│ │ │ │ │ + return message
│ │ │ │ │
│ │ │ │ │ - return get_attrs
│ │ │ │ │ + def call(self, message: BusMessage, timeout: Optional[int] = None) -> BusMessage:
│ │ │ │ │ + reply = BusMessage()
│ │ │ │ │ + error = libsystemd.sd_bus_error()
│ │ │ │ │ + try:
│ │ │ │ │ + self._call(message, timeout or 0, byref(error), byref(reply))
│ │ │ │ │ + return reply
│ │ │ │ │ + except OSError as exc:
│ │ │ │ │ + raise BusError(*error.get()) from exc
│ │ │ │ │
│ │ │ │ │ - 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(dict.fromkeys(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(dict.fromkeys(self.current_value))
│ │ │ │ │ + def call_method(
│ │ │ │ │ + self,
│ │ │ │ │ + destination: str,
│ │ │ │ │ + path: str,
│ │ │ │ │ + interface: str,
│ │ │ │ │ + member: str,
│ │ │ │ │ + types: str = '',
│ │ │ │ │ + *args: object,
│ │ │ │ │ + timeout: Optional[int] = None
│ │ │ │ │ + ) -> Tuple[object, ...]:
│ │ │ │ │ + logger.debug('Doing sync method call %s %s %s %s %s %s',
│ │ │ │ │ + destination, path, interface, member, types, args)
│ │ │ │ │ + message = self.message_new_method_call(destination, path, interface, member, types, *args)
│ │ │ │ │ + message = self.call(message, timeout)
│ │ │ │ │ + return message.get_body()
│ │ │ │ │
│ │ │ │ │ - json_merge_and_filter_patch(self.current_value, updates)
│ │ │ │ │ - if updates:
│ │ │ │ │ - self.send_json(updates)
│ │ │ │ │ + async def call_async(
│ │ │ │ │ + self,
│ │ │ │ │ + message: BusMessage,
│ │ │ │ │ + timeout: Optional[int] = None
│ │ │ │ │ + ) -> BusMessage:
│ │ │ │ │ + pending = PendingCall()
│ │ │ │ │ + self._call_async(byref(pending), message, pending.trampoline, pending.userdata, timeout or 0)
│ │ │ │ │ + return await pending.future
│ │ │ │ │
│ │ │ │ │ - def process_update(self, updates: 'set[str]', *, reset: bool = False) -> None:
│ │ │ │ │ - assert self.fd is not None
│ │ │ │ │ + async def call_method_async(
│ │ │ │ │ + self,
│ │ │ │ │ + destination: Optional[str],
│ │ │ │ │ + path: str,
│ │ │ │ │ + interface: str,
│ │ │ │ │ + member: str,
│ │ │ │ │ + types: str = '',
│ │ │ │ │ + *args: object,
│ │ │ │ │ + timeout: Optional[int] = None
│ │ │ │ │ + ) -> Tuple[object, ...]:
│ │ │ │ │ + logger.debug('Doing async method call %s %s %s %s %s %s',
│ │ │ │ │ + destination, path, interface, member, types, args)
│ │ │ │ │ + message = self.message_new_method_call(destination, path, interface, member, types, *args)
│ │ │ │ │ + message = await self.call_async(message, timeout)
│ │ │ │ │ + return message.get_body()
│ │ │ │ │
│ │ │ │ │ - entries: JsonDict = {name: self.getattrs(self.fd, name, Follow.NO) for name in updates}
│ │ │ │ │ + def add_match(self, rule: str, handler: Callable[[BusMessage], bool]) -> Slot:
│ │ │ │ │ + slot = Slot(handler)
│ │ │ │ │ + self._add_match(byref(slot), rule, slot.trampoline, slot.userdata)
│ │ │ │ │ + return slot
│ │ │ │ │
│ │ │ │ │ - info = entries.pop('', {})
│ │ │ │ │ - assert isinstance(info, dict) # fstat() will never fail with FileNotFoundError
│ │ │ │ │ + def add_object(self, path: str, obj: 'BaseObject') -> Slot:
│ │ │ │ │ + slot = Slot(obj.message_received)
│ │ │ │ │ + self._add_object(byref(slot), path, slot.trampoline, slot.userdata)
│ │ │ │ │ + obj.registered_on_bus(self, path)
│ │ │ │ │ + return slot
│ │ │ │ │
│ │ │ │ │ - if self.effective_fnmatch:
│ │ │ │ │ - info['entries'] = entries
│ │ │ │ │
│ │ │ │ │ - if self.targets:
│ │ │ │ │ - info['targets'] = targets = {}
│ │ │ │ │ - # 'targets' is used to report attributes about the ultimate target
│ │ │ │ │ - # of symlinks, but only if this information would not already be
│ │ │ │ │ - # reported. As such, we exclude '.' and any path which would end
│ │ │ │ │ - # up in 'entries' (if it existed). '..' needs special treatment:
│ │ │ │ │ - # it might be `.interesting()` but it won't be in 'entries', so
│ │ │ │ │ - # it's always treated as a target.
│ │ │ │ │ - for name in {e.get('target') for e in entries.values() if isinstance(e, dict)}:
│ │ │ │ │ - if isinstance(name, str) and name != '.':
│ │ │ │ │ - # exclude anything that would end up in 'entries'
│ │ │ │ │ - if (name == '..' or '/' in name or not self.interesting(name)):
│ │ │ │ │ - targets[name] = self.getattrs(self.fd, name, Follow.YES)
│ │ │ │ │ +class BaseObject:
│ │ │ │ │ + """Base object type for exporting objects on the bus
│ │ │ │ │
│ │ │ │ │ - self.send_update({'info': info}, reset=reset)
│ │ │ │ │ + This is the lowest-level class that can be passed to Bus.add_object().
│ │ │ │ │
│ │ │ │ │ - def process_pending_updates(self) -> None:
│ │ │ │ │ - assert self.pending is not None
│ │ │ │ │ - if self.pending:
│ │ │ │ │ - self.process_update(self.pending)
│ │ │ │ │ - self.pending = None
│ │ │ │ │ + If you want to directly subclass this, you'll need to implement
│ │ │ │ │ + `message_received()`.
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + Subclassing from `bus.Object` is probably a better choice.
│ │ │ │ │ + """
│ │ │ │ │ + _dbus_bus: Optional[Bus] = None
│ │ │ │ │ + _dbus_path: Optional[str] = None
│ │ │ │ │
│ │ │ │ │ - def schedule_update(self, name: str) -> None:
│ │ │ │ │ - if not self.interesting(name):
│ │ │ │ │ - return
│ │ │ │ │ + def registered_on_bus(self, bus: Bus, path: str) -> None:
│ │ │ │ │ + """Report that an instance was exported on a given bus and path.
│ │ │ │ │
│ │ │ │ │ - if self.pending is None:
│ │ │ │ │ - asyncio.get_running_loop().call_later(0.1, self.process_pending_updates)
│ │ │ │ │ - self.pending = set()
│ │ │ │ │ + This is used so that the instance knows where to send signals.
│ │ │ │ │ + Bus.add_object() calls this: you probably shouldn't call this on your
│ │ │ │ │ + own.
│ │ │ │ │ + """
│ │ │ │ │ + self._dbus_bus = bus
│ │ │ │ │ + self._dbus_path = path
│ │ │ │ │
│ │ │ │ │ - self.pending.add(name)
│ │ │ │ │ + self.registered()
│ │ │ │ │
│ │ │ │ │ - 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 registered(self) -> None:
│ │ │ │ │ + """Called after an object has been registered on the bus
│ │ │ │ │
│ │ │ │ │ - self.send_update({'error': {
│ │ │ │ │ - 'problem': problem, 'message': os.strerror(err), 'errno': errno.errorcode[err]
│ │ │ │ │ - }}, reset=True)
│ │ │ │ │ + This is the correct method to implement to do some initial work that
│ │ │ │ │ + needs to be done after registration. The default implementation does
│ │ │ │ │ + nothing.
│ │ │ │ │ + """
│ │ │ │ │ + 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 emit_signal(
│ │ │ │ │ + self, interface: str, name: str, signature: str, *args: Any
│ │ │ │ │ + ) -> bool:
│ │ │ │ │ + """Emit a D-Bus signal on this object
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + The object must have been exported on the bus with Bus.add_object().
│ │ │ │ │
│ │ │ │ │ - return False
│ │ │ │ │ + :interface: the interface of the signal
│ │ │ │ │ + :name: the 'member' name of the signal to emit
│ │ │ │ │ + :signature: the type signature, as a string
│ │ │ │ │ + :args: the arguments, according to the signature
│ │ │ │ │ + :returns: True
│ │ │ │ │ + """
│ │ │ │ │ + assert self._dbus_bus is not None
│ │ │ │ │ + assert self._dbus_path is not None
│ │ │ │ │ + return self._dbus_bus.message_new_signal(self._dbus_path, interface, name, signature, *args).send()
│ │ │ │ │
│ │ │ │ │ - def report_initial_state(self, fd: Handle) -> None:
│ │ │ │ │ - if self.flag_onlydir_error(fd):
│ │ │ │ │ - return
│ │ │ │ │ + def message_received(self, message: BusMessage) -> bool:
│ │ │ │ │ + """Called when a message is received for this object
│ │ │ │ │
│ │ │ │ │ - self.fd = fd
│ │ │ │ │ + This is the lowest level interface to the BaseObject. You need to
│ │ │ │ │ + handle method calls, properties, and introspection.
│ │ │ │ │
│ │ │ │ │ - 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 = ''
│ │ │ │ │ + You are expected to handle the message and return True. Normally this
│ │ │ │ │ + means that you send a reply. If you don't want to handle the message,
│ │ │ │ │ + return False and other handlers will have a chance to run. If no
│ │ │ │ │ + handler handles the message, systemd will generate a suitable error
│ │ │ │ │ + message and send that, instead.
│ │ │ │ │
│ │ │ │ │ - self.process_update({e for e in entries if self.interesting(e)}, reset=True)
│ │ │ │ │ + :message: the message that was received
│ │ │ │ │ + :returns: True if the message was handled
│ │ │ │ │ + """
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ - 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')
│ │ │ │ │
│ │ │ │ │ - self.schedule_update(name)
│ │ │ │ │ +class Interface:
│ │ │ │ │ + """The high-level base class for defining D-Bus interfaces
│ │ │ │ │
│ │ │ │ │ - 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('')
│ │ │ │ │ + This class provides high-level APIs for defining methods, properties, and
│ │ │ │ │ + signals, as well as implementing introspection.
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + On its own, this class doesn't provide a mechanism for exporting anything
│ │ │ │ │ + on the bus. The Object class does that, and you'll generally want to
│ │ │ │ │ + subclass from it, as it contains several built-in standard interfaces
│ │ │ │ │ + (introspection, properties, etc.).
│ │ │ │ │
│ │ │ │ │ - if err is None:
│ │ │ │ │ - assert fd is not None
│ │ │ │ │ - self.report_initial_state(fd)
│ │ │ │ │ - else:
│ │ │ │ │ - self.report_error(err)
│ │ │ │ │ + The name of your class will be interpreted as a D-Bus interface name.
│ │ │ │ │ + Underscores are converted to dots. No case conversion is performed. If
│ │ │ │ │ + the interface name can't be represented using this scheme, or if you'd like
│ │ │ │ │ + to name your class differently, you can provide an interface= kwarg to the
│ │ │ │ │ + class definition.
│ │ │ │ │
│ │ │ │ │ - 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 com_example_Interface(bus.Object):
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - 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')
│ │ │ │ │ + class MyInterface(bus.Object, interface='org.cockpit_project.Interface'):
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - 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')
│ │ │ │ │ + The methods, properties, and signals which are visible from D-Bus are
│ │ │ │ │ + defined using helper classes with the corresponding names (Method,
│ │ │ │ │ + Property, Signal). You should use normal Python snake_case conventions for
│ │ │ │ │ + the member names: they will automatically be converted to CamelCase by
│ │ │ │ │ + splitting on underscore and converting the first letter of each resulting
│ │ │ │ │ + word to uppercase. For example, `method_name` becomes `MethodName`.
│ │ │ │ │
│ │ │ │ │ - self.current_value = {}
│ │ │ │ │ - self.ready()
│ │ │ │ │ + Each Method, Property, or Signal constructor takes an optional name= kwargs
│ │ │ │ │ + to override the automatic name conversion convention above.
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + An example class might look like:
│ │ │ │ │
│ │ │ │ │ - self.done()
│ │ │ │ │ - self.close()
│ │ │ │ │ + class com_example_MyObject(bus.Object):
│ │ │ │ │ + created = bus.Interface.Signal('s', 'i')
│ │ │ │ │ + renames = bus.Interface.Property('u', value=0)
│ │ │ │ │ + name = bus.Interface.Property('s', 'undefined')
│ │ │ │ │
│ │ │ │ │ - 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/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 .
│ │ │ │ │ + @bus.Interface.Method(out_types=(), in_types='s')
│ │ │ │ │ + def rename(self, name):
│ │ │ │ │ + self.renames += 1
│ │ │ │ │ + self.name = name
│ │ │ │ │
│ │ │ │ │ -import logging
│ │ │ │ │ -from typing import Optional
│ │ │ │ │ + def registered(self):
│ │ │ │ │ + self.created('Hello', 42)
│ │ │ │ │
│ │ │ │ │ -from ..channel import AsyncChannel
│ │ │ │ │ -from ..data import read_cockpit_data_file
│ │ │ │ │ -from ..jsonutil import JsonObject, get_dict, get_str
│ │ │ │ │ -from ..packages import Packages
│ │ │ │ │ + See the documentation for the Method, Property, and Signal classes for
│ │ │ │ │ + more information and examples.
│ │ │ │ │ + """
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ + # Class variables
│ │ │ │ │ + _dbus_interfaces: Dict[str, Dict[str, Dict[str, Any]]]
│ │ │ │ │ + _dbus_members: Optional[Tuple[str, Dict[str, Dict[str, Any]]]]
│ │ │ │ │
│ │ │ │ │ + # Instance variables: stored in Python form
│ │ │ │ │ + _dbus_property_values: Optional[Dict[str, Any]] = None
│ │ │ │ │
│ │ │ │ │ -class PackagesChannel(AsyncChannel):
│ │ │ │ │ - payload = 'http-stream1'
│ │ │ │ │ - restrictions = [("internal", "packages")]
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def __init_subclass__(cls, interface: Optional[str] = None) -> None:
│ │ │ │ │ + if interface is None:
│ │ │ │ │ + assert '__' not in cls.__name__, 'Class name cannot contain sequential underscores'
│ │ │ │ │ + interface = cls.__name__.replace('_', '.')
│ │ │ │ │
│ │ │ │ │ - # used to carry data forward from open to done
│ │ │ │ │ - options: Optional[JsonObject] = None
│ │ │ │ │ + # This is the information for this subclass directly
│ │ │ │ │ + members: Dict[str, Dict[str, Interface._Member]] = {'methods': {}, 'properties': {}, 'signals': {}}
│ │ │ │ │ + for name, member in cls.__dict__.items():
│ │ │ │ │ + if isinstance(member, Interface._Member):
│ │ │ │ │ + member.setup(interface, name, members)
│ │ │ │ │
│ │ │ │ │ - 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()))
│ │ │ │ │ - self.done()
│ │ │ │ │ - self.close()
│ │ │ │ │ + # We only store the information if something was actually defined
│ │ │ │ │ + if sum(len(category) for category in members.values()) > 0:
│ │ │ │ │ + cls._dbus_members = (interface, members)
│ │ │ │ │
│ │ │ │ │ - async def run(self, options: JsonObject) -> None:
│ │ │ │ │ - packages: Packages = self.router.packages # type: ignore[attr-defined] # yes, this is evil
│ │ │ │ │ + # This is the information for this subclass, with all its ancestors
│ │ │ │ │ + cls._dbus_interfaces = dict(ancestor.__dict__['_dbus_members']
│ │ │ │ │ + for ancestor in cls.mro()
│ │ │ │ │ + if '_dbus_members' in ancestor.__dict__)
│ │ │ │ │
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def _find_interface(cls, interface: str) -> Dict[str, Dict[str, '_Member']]:
│ │ │ │ │ try:
│ │ │ │ │ - if get_str(options, 'method') != 'GET':
│ │ │ │ │ - raise ValueError(f'Unsupported HTTP method {options["method"]}')
│ │ │ │ │ -
│ │ │ │ │ - self.ready()
│ │ │ │ │ - if await self.read() is not None:
│ │ │ │ │ - raise ValueError('Received unexpected data')
│ │ │ │ │ -
│ │ │ │ │ - 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,
│ │ │ │ │ - }
│ │ │ │ │ -
│ │ │ │ │ - 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
│ │ │ │ │ -
│ │ │ │ │ - # 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')
│ │ │ │ │ -
│ │ │ │ │ - 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
│ │ │ │ │ -
│ │ │ │ │ - except ValueError as exc:
│ │ │ │ │ - self.http_error(400, str(exc))
│ │ │ │ │ -
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - self.http_error(404, 'Not found')
│ │ │ │ │ -
│ │ │ │ │ - except OSError as exc:
│ │ │ │ │ - self.http_error(500, f'Internal error: {exc!s}')
│ │ │ │ │ -
│ │ │ │ │ - else:
│ │ │ │ │ - self.send_json(status=200, reason='OK', headers=out_headers)
│ │ │ │ │ - await self.sendfile(document.data)
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/channels/trivial.py': br'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is distributed in the hope that it will be useful,
│ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ -# GNU General Public License for more details.
│ │ │ │ │ -#
│ │ │ │ │ -# You should have received a copy of the GNU General Public License
│ │ │ │ │ -# along with this program. If not, see .
│ │ │ │ │ -
│ │ │ │ │ -import logging
│ │ │ │ │ -
│ │ │ │ │ -from ..channel import Channel
│ │ │ │ │ -
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ -
│ │ │ │ │ -
│ │ │ │ │ -class EchoChannel(Channel):
│ │ │ │ │ - payload = 'echo'
│ │ │ │ │ -
│ │ │ │ │ - def do_open(self, options):
│ │ │ │ │ - self.ready()
│ │ │ │ │ + return cls._dbus_interfaces[interface]
│ │ │ │ │ + except KeyError as exc:
│ │ │ │ │ + raise Object.Method.Unhandled from exc
│ │ │ │ │
│ │ │ │ │ - def do_data(self, data: bytes) -> None:
│ │ │ │ │ - self.send_bytes(data)
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def _find_category(cls, interface: str, category: str) -> Dict[str, '_Member']:
│ │ │ │ │ + return cls._find_interface(interface)[category]
│ │ │ │ │
│ │ │ │ │ - def do_done(self):
│ │ │ │ │ - self.done()
│ │ │ │ │ - self.close()
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def _find_member(cls, interface: str, category: str, member: str) -> '_Member':
│ │ │ │ │ + members = cls._find_category(interface, category)
│ │ │ │ │ + try:
│ │ │ │ │ + return members[member]
│ │ │ │ │ + except KeyError as exc:
│ │ │ │ │ + raise Object.Method.Unhandled from exc
│ │ │ │ │
│ │ │ │ │ + class _Member:
│ │ │ │ │ + _category: str # filled in from subclasses
│ │ │ │ │
│ │ │ │ │ -class NullChannel(Channel):
│ │ │ │ │ - payload = 'null'
│ │ │ │ │ + _python_name: Optional[str] = None
│ │ │ │ │ + _name: Optional[str] = None
│ │ │ │ │ + _interface: Optional[str] = None
│ │ │ │ │ + _description: Optional[Dict[str, Any]]
│ │ │ │ │
│ │ │ │ │ - def do_open(self, options):
│ │ │ │ │ - self.ready()
│ │ │ │ │ + def __init__(self, name: Optional[str] = None) -> None:
│ │ │ │ │ + self._python_name = None
│ │ │ │ │ + self._interface = None
│ │ │ │ │ + self._name = name
│ │ │ │ │
│ │ │ │ │ - def do_close(self):
│ │ │ │ │ - self.close()
│ │ │ │ │ -''',
│ │ │ │ │ - 'cockpit/channels/dbus.py': r'''# This file is part of Cockpit.
│ │ │ │ │ -#
│ │ │ │ │ -# Copyright (C) 2022 Red Hat, Inc.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is free software: you can redistribute it and/or modify
│ │ │ │ │ -# it under the terms of the GNU General Public License as published by
│ │ │ │ │ -# the Free Software Foundation, either version 3 of the License, or
│ │ │ │ │ -# (at your option) any later version.
│ │ │ │ │ -#
│ │ │ │ │ -# This program is distributed in the hope that it will be useful,
│ │ │ │ │ -# but WITHOUT ANY WARRANTY; without even the implied warranty of
│ │ │ │ │ -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
│ │ │ │ │ -# GNU General Public License for more details.
│ │ │ │ │ -#
│ │ │ │ │ -# You should have received a copy of the GNU General Public License
│ │ │ │ │ -# along with this program. If not, see .
│ │ │ │ │ + def setup(self, interface: str, name: str, members: Dict[str, Dict[str, 'Interface._Member']]) -> None:
│ │ │ │ │ + self._python_name = name # for error messages
│ │ │ │ │ + if self._name is None:
│ │ │ │ │ + self._name = ''.join(word.title() for word in name.split('_'))
│ │ │ │ │ + self._interface = interface
│ │ │ │ │ + self._description = self._describe()
│ │ │ │ │ + members[self._category][self._name] = self
│ │ │ │ │
│ │ │ │ │ -# 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)
│ │ │ │ │ + def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ + raise NotImplementedError
│ │ │ │ │
│ │ │ │ │ -import asyncio
│ │ │ │ │ -import errno
│ │ │ │ │ -import json
│ │ │ │ │ -import logging
│ │ │ │ │ -import traceback
│ │ │ │ │ -import xml.etree.ElementTree as ET
│ │ │ │ │ + def __getitem__(self, key: str) -> Any:
│ │ │ │ │ + # Acts as an adaptor for dict accesses from introspection.to_xml()
│ │ │ │ │ + assert self._description is not None
│ │ │ │ │ + return self._description[key]
│ │ │ │ │
│ │ │ │ │ -from cockpit._vendor import systemd_ctypes
│ │ │ │ │ -from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection
│ │ │ │ │ + class Property(_Member):
│ │ │ │ │ + """Defines a D-Bus property on an interface
│ │ │ │ │
│ │ │ │ │ -from ..channel import Channel, ChannelError
│ │ │ │ │ + There are two main ways to define properties: with and without getters.
│ │ │ │ │ + If you define a property without a getter, then you must provide a
│ │ │ │ │ + value (via the value= kwarg). In this case, the property value is
│ │ │ │ │ + maintained internally and can be accessed from Python in the usual way.
│ │ │ │ │ + Change signals are sent automatically.
│ │ │ │ │
│ │ │ │ │ -logger = logging.getLogger(__name__)
│ │ │ │ │ + class MyObject(bus.Object):
│ │ │ │ │ + counter = bus.Interface.Property('i', value=0)
│ │ │ │ │
│ │ │ │ │ -# 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.
│ │ │ │ │ + a = MyObject()
│ │ │ │ │ + a.counter = 5
│ │ │ │ │ + a.counter += 1
│ │ │ │ │ + print(a.counter)
│ │ │ │ │
│ │ │ │ │ + The other way to define properties is with a getter function. In this
│ │ │ │ │ + case, you can read from the property in the normal way, but not write
│ │ │ │ │ + to it. You are responsible for emitting change signals for yourself.
│ │ │ │ │ + You must not provide the value= kwarg.
│ │ │ │ │
│ │ │ │ │ -class InterfaceCache:
│ │ │ │ │ - def __init__(self):
│ │ │ │ │ - self.cache = {}
│ │ │ │ │ - self.old = set() # Interfaces already returned by get_interface_if_new
│ │ │ │ │ + class MyObject(bus.Object):
│ │ │ │ │ + _counter = 0
│ │ │ │ │
│ │ │ │ │ - def inject(self, interfaces):
│ │ │ │ │ - self.cache.update(interfaces)
│ │ │ │ │ + counter = bus.Interface.Property('i')
│ │ │ │ │ + @counter.getter
│ │ │ │ │ + def get_counter(self):
│ │ │ │ │ + return self._counter
│ │ │ │ │
│ │ │ │ │ - async def introspect_path(self, bus, destination, object_path):
│ │ │ │ │ - xml, = await bus.call_method_async(destination, object_path,
│ │ │ │ │ - 'org.freedesktop.DBus.Introspectable',
│ │ │ │ │ - 'Introspect')
│ │ │ │ │ + @counter.setter
│ │ │ │ │ + def set_counter(self, value):
│ │ │ │ │ + self._counter = value
│ │ │ │ │ + self.property_changed('Counter')
│ │ │ │ │
│ │ │ │ │ - et = ET.fromstring(xml)
│ │ │ │ │ + In either case, you can provide a setter function. This function has
│ │ │ │ │ + no impact on Python code, but makes the property writable from the view
│ │ │ │ │ + of D-Bus. Your setter will be called when a Properties.Set() call is
│ │ │ │ │ + made, and no other action will be performed. A trivial implementation
│ │ │ │ │ + might look like:
│ │ │ │ │
│ │ │ │ │ - interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')}
│ │ │ │ │ + class MyObject(bus.Object):
│ │ │ │ │ + counter = bus.Interface.Property('i', value=0)
│ │ │ │ │ + @counter.setter
│ │ │ │ │ + def set_counter(self, value):
│ │ │ │ │ + # we got a request to set the counter from D-Bus
│ │ │ │ │ + self.counter = value
│ │ │ │ │
│ │ │ │ │ - # Add all interfaces we found: we might use them later
│ │ │ │ │ - self.inject(interfaces)
│ │ │ │ │ + In all cases, the first (and only mandatory) argument to the
│ │ │ │ │ + constructor is the D-Bus type of the property.
│ │ │ │ │
│ │ │ │ │ - return interfaces
│ │ │ │ │ + Your getter and setter functions can be provided by kwarg to the
│ │ │ │ │ + constructor. You can also give a name= kwarg to override the default
│ │ │ │ │ + name conversion scheme.
│ │ │ │ │ + """
│ │ │ │ │ + _category = 'properties'
│ │ │ │ │
│ │ │ │ │ - async def get_interface(self, interface_name, bus=None, destination=None, object_path=None):
│ │ │ │ │ - try:
│ │ │ │ │ - return self.cache[interface_name]
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - pass
│ │ │ │ │ + _getter: Optional[Callable[[Any], Any]]
│ │ │ │ │ + _setter: Optional[Callable[[Any, Any], None]]
│ │ │ │ │ + _type: bustypes.Type
│ │ │ │ │ + _value: Any
│ │ │ │ │
│ │ │ │ │ - if bus and object_path:
│ │ │ │ │ - try:
│ │ │ │ │ - await self.introspect_path(bus, destination, object_path)
│ │ │ │ │ - except BusError:
│ │ │ │ │ - pass
│ │ │ │ │ + def __init__(self, type_string: str,
│ │ │ │ │ + value: Any = None,
│ │ │ │ │ + name: Optional[str] = None,
│ │ │ │ │ + getter: Optional[Callable[[Any], Any]] = None,
│ │ │ │ │ + setter: Optional[Callable[[Any, Any], None]] = None):
│ │ │ │ │ + assert value is None or getter is None, 'A property cannot have both a value and a getter'
│ │ │ │ │
│ │ │ │ │ - return self.cache.get(interface_name)
│ │ │ │ │ + super().__init__(name=name)
│ │ │ │ │ + self._getter = getter
│ │ │ │ │ + self._setter = setter
│ │ │ │ │ + self._type, = bustypes.from_signature(type_string)
│ │ │ │ │ + self._value = value
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ + return {'type': self._type.typestring, 'flags': 'r' if self._setter is None else 'w'}
│ │ │ │ │
│ │ │ │ │ - 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 __get__(self, obj: 'Object', cls: Optional[type] = None) -> Any:
│ │ │ │ │ + assert self._name is not None
│ │ │ │ │ + if obj is None:
│ │ │ │ │ + return self
│ │ │ │ │ + if self._getter is not None:
│ │ │ │ │ + return self._getter.__get__(obj, cls)()
│ │ │ │ │ + elif self._value is not None:
│ │ │ │ │ + if obj._dbus_property_values is not None:
│ │ │ │ │ + return obj._dbus_property_values.get(self._name, self._value)
│ │ │ │ │ + else:
│ │ │ │ │ + return self._value
│ │ │ │ │ + else:
│ │ │ │ │ + raise AttributeError(f"'{obj.__class__.__name__}' property '{self._python_name}' "
│ │ │ │ │ + f"was not properly initialised: use either the 'value=' kwarg or "
│ │ │ │ │ + f"the @'{self._python_name}.getter' decorator")
│ │ │ │ │
│ │ │ │ │ - return ''.join(interface['methods'][method]['in'])
│ │ │ │ │ + def __set__(self, obj: 'Object', value: Any) -> None:
│ │ │ │ │ + assert self._name is not None
│ │ │ │ │ + if self._getter is not None:
│ │ │ │ │ + raise AttributeError(f"Cannot directly assign '{obj.__class__.__name__}' "
│ │ │ │ │ + "property '{self._python_name}' because it has a getter")
│ │ │ │ │ + if obj._dbus_property_values is None:
│ │ │ │ │ + obj._dbus_property_values = {}
│ │ │ │ │ + obj._dbus_property_values[self._name] = value
│ │ │ │ │ + if obj._dbus_bus is not None:
│ │ │ │ │ + obj.properties_changed(self._interface, {self._name: bustypes.Variant(value, self._type)}, [])
│ │ │ │ │
│ │ │ │ │ + def to_dbus(self, obj: 'Object') -> bustypes.Variant:
│ │ │ │ │ + return bustypes.Variant(self.__get__(obj), self._type)
│ │ │ │ │
│ │ │ │ │ -def notify_update(notify, path, interface_name, props):
│ │ │ │ │ - notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()}
│ │ │ │ │ + def from_dbus(self, obj: 'Object', value: bustypes.Variant) -> None:
│ │ │ │ │ + if self._setter is None or self._type != value.type:
│ │ │ │ │ + raise Object.Method.Unhandled
│ │ │ │ │ + self._setter.__get__(obj)(value.value)
│ │ │ │ │
│ │ │ │ │ + def getter(self, getter: Callable[[Any], Any]) -> Callable[[Any], Any]:
│ │ │ │ │ + if self._value is not None:
│ │ │ │ │ + raise ValueError('A property cannot have both a value and a getter')
│ │ │ │ │ + if self._getter is not None:
│ │ │ │ │ + raise ValueError('This property already has a getter')
│ │ │ │ │ + self._getter = getter
│ │ │ │ │ + return getter
│ │ │ │ │
│ │ │ │ │ -class DBusChannel(Channel):
│ │ │ │ │ - json_encoder = systemd_ctypes.JSONEncoder(indent=2)
│ │ │ │ │ - payload = 'dbus-json3'
│ │ │ │ │ + def setter(self, setter: Callable[[Any, Any], None]) -> Callable[[Any, Any], None]:
│ │ │ │ │ + self._setter = setter
│ │ │ │ │ + return setter
│ │ │ │ │
│ │ │ │ │ - matches = None
│ │ │ │ │ - name = None
│ │ │ │ │ - bus = None
│ │ │ │ │ - owner = None
│ │ │ │ │ + class Signal(_Member):
│ │ │ │ │ + """Defines a D-Bus signal on an interface
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + This is a callable which will result in the signal being emitted.
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + The constructor takes the types of the arguments, each one as a
│ │ │ │ │ + separate parameter. For example:
│ │ │ │ │
│ │ │ │ │ - def do_open(self, options):
│ │ │ │ │ - self.cache = InterfaceCache()
│ │ │ │ │ - self.name = options.get('name')
│ │ │ │ │ - self.matches = []
│ │ │ │ │ + properties_changed = Interface.Signal('s', 'a{sv}', 'as')
│ │ │ │ │
│ │ │ │ │ - bus = options.get('bus')
│ │ │ │ │ - address = options.get('address')
│ │ │ │ │ + You can give a name= kwarg to override the default name conversion
│ │ │ │ │ + scheme.
│ │ │ │ │ + """
│ │ │ │ │ + _category = 'signals'
│ │ │ │ │ + _type: bustypes.MessageType
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + def __init__(self, *out_types: str, name: Optional[str] = None) -> None:
│ │ │ │ │ + super().__init__(name=name)
│ │ │ │ │ + self._type = bustypes.MessageType(out_types)
│ │ │ │ │
│ │ │ │ │ - try:
│ │ │ │ │ - self.bus.attach_event(None, 0)
│ │ │ │ │ - except OSError as err:
│ │ │ │ │ - if err.errno != errno.EBUSY:
│ │ │ │ │ - raise
│ │ │ │ │ + def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ + return {'in': self._type.typestrings}
│ │ │ │ │
│ │ │ │ │ - # 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 __get__(self, obj: 'Object', cls: Optional[type] = None) -> Callable[..., None]:
│ │ │ │ │ + def emitter(obj: Object, *args: Any) -> None:
│ │ │ │ │ + assert self._interface is not None
│ │ │ │ │ + assert self._name is not None
│ │ │ │ │ + assert obj._dbus_bus is not None
│ │ │ │ │ + assert obj._dbus_path is not None
│ │ │ │ │ + message = obj._dbus_bus.message_new_signal(obj._dbus_path, self._interface, self._name)
│ │ │ │ │ + self._type.write(message, *args)
│ │ │ │ │ + message.send()
│ │ │ │ │ + return emitter.__get__(obj, cls)
│ │ │ │ │
│ │ │ │ │ - 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()
│ │ │ │ │ + class Method(_Member):
│ │ │ │ │ + """Defines a D-Bus method on an interface
│ │ │ │ │
│ │ │ │ │ - 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']
│ │ │ │ │ + This is a function decorator which marks a given method for export.
│ │ │ │ │
│ │ │ │ │ - def filter_owner(message):
│ │ │ │ │ - if self.owner is not None and self.owner == message.get_sender():
│ │ │ │ │ - handler(message)
│ │ │ │ │ + The constructor takes two arguments: the type of the output arguments,
│ │ │ │ │ + and the type of the input arguments. Both should be given as a
│ │ │ │ │ + sequence.
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + @Interface.Method(['a{sv}'], ['s'])
│ │ │ │ │ + def get_all(self, interface):
│ │ │ │ │ + ...
│ │ │ │ │
│ │ │ │ │ - self.matches.append(match)
│ │ │ │ │ + You can give a name= kwarg to override the default name conversion
│ │ │ │ │ + scheme.
│ │ │ │ │ + """
│ │ │ │ │ + _category = 'methods'
│ │ │ │ │
│ │ │ │ │ - def add_async_signal_handler(self, handler, **kwargs):
│ │ │ │ │ - def sync_handler(message):
│ │ │ │ │ - self.create_task(handler(message))
│ │ │ │ │ - self.add_signal_handler(sync_handler, **kwargs)
│ │ │ │ │ + class Unhandled(Exception):
│ │ │ │ │ + """Raised by a method to indicate that the message triggering that
│ │ │ │ │ + method call remains unhandled."""
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - async def do_call(self, message):
│ │ │ │ │ - path, iface, method, args = message['call']
│ │ │ │ │ - cookie = message.get('id')
│ │ │ │ │ - flags = message.get('flags')
│ │ │ │ │ + def __init__(self, out_types: Sequence[str] = (), in_types: Sequence[str] = (), name: Optional[str] = None):
│ │ │ │ │ + super().__init__(name=name)
│ │ │ │ │ + self._out_type = bustypes.MessageType(out_types)
│ │ │ │ │ + self._in_type = bustypes.MessageType(in_types)
│ │ │ │ │ + self._func = None
│ │ │ │ │
│ │ │ │ │ - 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 __get__(self, obj, cls=None):
│ │ │ │ │ + return self._func.__get__(obj, cls)
│ │ │ │ │
│ │ │ │ │ - # We have to figure out the signature of the call. Either we got told it:
│ │ │ │ │ - signature = message.get('type')
│ │ │ │ │ + def __call__(self, *args, **kwargs):
│ │ │ │ │ + # decorator
│ │ │ │ │ + self._func, = args
│ │ │ │ │ + return self
│ │ │ │ │
│ │ │ │ │ - # ... or there aren't any arguments
│ │ │ │ │ - if signature is None and len(args) == 0:
│ │ │ │ │ - signature = ''
│ │ │ │ │ + def _describe(self) -> Dict[str, Any]:
│ │ │ │ │ + return {'in': [item.typestring for item in self._in_type.item_types],
│ │ │ │ │ + 'out': [item.typestring for item in self._out_type.item_types]}
│ │ │ │ │
│ │ │ │ │ - # ... or we need to introspect
│ │ │ │ │ - if signature is None:
│ │ │ │ │ + def _invoke(self, obj, message):
│ │ │ │ │ + args = self._in_type.read(message)
│ │ │ │ │ + if args is None:
│ │ │ │ │ + return False
│ │ │ │ │ try:
│ │ │ │ │ - logger.debug('Doing introspection request for %s %s', iface, method)
│ │ │ │ │ - signature = await self.cache.get_signature(iface, method, self.bus, self.name, path)
│ │ │ │ │ - except BusError as error:
│ │ │ │ │ - self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie)
│ │ │ │ │ - return
│ │ │ │ │ - except KeyError:
│ │ │ │ │ - self.send_json(
│ │ │ │ │ - error=[
│ │ │ │ │ - "org.freedesktop.DBus.Error.UnknownMethod",
│ │ │ │ │ - [f"Introspection data for method {iface} {method} not available"]],
│ │ │ │ │ - id=cookie)
│ │ │ │ │ - return
│ │ │ │ │ - except Exception as exc:
│ │ │ │ │ - self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie)
│ │ │ │ │ - return
│ │ │ │ │ -
│ │ │ │ │ - try:
│ │ │ │ │ - method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args)
│ │ │ │ │ - reply = await self.bus.call_async(method_call, timeout=timeout)
│ │ │ │ │ - # If the method call has kicked off any signals related to
│ │ │ │ │ - # watch processing, wait for that to be done.
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - # TODO: stop hard-coding the endian flag here.
│ │ │ │ │ - self.send_json(
│ │ │ │ │ - reply=[reply.get_body()], id=cookie,
│ │ │ │ │ - flags="<" if flags is not None else None,
│ │ │ │ │ - type=reply.get_signature(True)) # noqa: FBT003
│ │ │ │ │ - except BusError as error:
│ │ │ │ │ - # actually, should send the fields from the message body
│ │ │ │ │ - self.send_json(error=[error.name, [error.message]], id=cookie)
│ │ │ │ │ - except Exception:
│ │ │ │ │ - logger.exception("do_call(%s): generic exception", message)
│ │ │ │ │ - self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie)
│ │ │ │ │ -
│ │ │ │ │ - async def do_add_match(self, message):
│ │ │ │ │ - add_match = message['add-match']
│ │ │ │ │ - logger.debug('adding match %s', add_match)
│ │ │ │ │ -
│ │ │ │ │ - async def match_hit(message):
│ │ │ │ │ - logger.debug('got match')
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - self.send_json(signal=[
│ │ │ │ │ - message.get_path(),
│ │ │ │ │ - message.get_interface(),
│ │ │ │ │ - message.get_member(),
│ │ │ │ │ - list(message.get_body())
│ │ │ │ │ - ])
│ │ │ │ │ -
│ │ │ │ │ - self.add_async_signal_handler(match_hit, **add_match)
│ │ │ │ │ -
│ │ │ │ │ - async def setup_objectmanager_watch(self, path, interface_name, meta, notify):
│ │ │ │ │ - # Watch the objects managed by the ObjectManager at "path".
│ │ │ │ │ - # Properties are not watched, that is done by setup_path_watch
│ │ │ │ │ - # below via recursive_props == True.
│ │ │ │ │ + result = self._func.__get__(obj)(*args)
│ │ │ │ │ + except (BusError, OSError) as error:
│ │ │ │ │ + return message.reply_method_error(error)
│ │ │ │ │
│ │ │ │ │ - async def handler(message):
│ │ │ │ │ - member = message.get_member()
│ │ │ │ │ - if member == "InterfacesAdded":
│ │ │ │ │ - (path, interface_props) = message.get_body()
│ │ │ │ │ - logger.debug('interfaces added %s %s', path, interface_props)
│ │ │ │ │ - meta = {}
│ │ │ │ │ - notify = {}
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - for name, props in interface_props.items():
│ │ │ │ │ - if interface_name is None or name == interface_name:
│ │ │ │ │ - mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path)
│ │ │ │ │ - if mm:
│ │ │ │ │ - meta.update({name: mm})
│ │ │ │ │ - notify_update(notify, path, name, props)
│ │ │ │ │ - self.send_json(meta=meta)
│ │ │ │ │ - self.send_json(notify=notify)
│ │ │ │ │ - elif member == "InterfacesRemoved":
│ │ │ │ │ - (path, interfaces) = message.get_body()
│ │ │ │ │ - logger.debug('interfaces removed %s %s', path, interfaces)
│ │ │ │ │ - async with self.watch_processing_lock:
│ │ │ │ │ - notify = {path: dict.fromkeys(interfaces)}
│ │ │ │ │ - self.send_json(notify=notify)
│ │ │ │ │ + return message.reply_method_function_return_value(self._out_type, result)
│ │ │ │ │
│ │ │ │ │ - self.add_async_signal_handler(handler,
│ │ │ │ │ - path=path,
│ │ │ │ │ - interface="org.freedesktop.DBus.ObjectManager")
│ │ │ │ │ - objects, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ - 'org.freedesktop.DBus.ObjectManager',
│ │ │ │ │ - 'GetManagedObjects')
│ │ │ │ │ - for p, ifaces in objects.items():
│ │ │ │ │ - for iface, props in ifaces.items():
│ │ │ │ │ - if interface_name is None or iface == interface_name:
│ │ │ │ │ - mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p)
│ │ │ │ │ - if mm:
│ │ │ │ │ - meta.update({iface: mm})
│ │ │ │ │ - notify_update(notify, p, iface, props)
│ │ │ │ │
│ │ │ │ │ - async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify):
│ │ │ │ │ - # Watch a single object at "path", but maybe also watch for
│ │ │ │ │ - # property changes for all objects below "path".
│ │ │ │ │ +class org_freedesktop_DBus_Peer(Interface):
│ │ │ │ │ + @Interface.Method()
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def ping() -> None:
│ │ │ │ │ + pass
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + @Interface.Method('s')
│ │ │ │ │ + @staticmethod
│ │ │ │ │ + def get_machine_id() -> str:
│ │ │ │ │ + with open('/etc/machine-id', encoding='ascii') as file:
│ │ │ │ │ + return file.read().strip()
│ │ │ │ │
│ │ │ │ │ - this_meta = await self.cache.introspect_path(self.bus, self.name, path)
│ │ │ │ │ - if interface_name is not None:
│ │ │ │ │ - interface = this_meta.get(interface_name)
│ │ │ │ │ - this_meta = {interface_name: interface}
│ │ │ │ │ - meta.update(this_meta)
│ │ │ │ │ - if recursive_props:
│ │ │ │ │ - self.add_async_signal_handler(handler,
│ │ │ │ │ - interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ - path_namespace=path)
│ │ │ │ │ - else:
│ │ │ │ │ - self.add_async_signal_handler(handler,
│ │ │ │ │ - interface="org.freedesktop.DBus.Properties",
│ │ │ │ │ - path=path)
│ │ │ │ │
│ │ │ │ │ - for name in meta:
│ │ │ │ │ - if name.startswith("org.freedesktop.DBus."):
│ │ │ │ │ - continue
│ │ │ │ │ - try:
│ │ │ │ │ - props, = await self.bus.call_method_async(self.name, path,
│ │ │ │ │ - 'org.freedesktop.DBus.Properties',
│ │ │ │ │ - 'GetAll', 's', name)
│ │ │ │ │ - notify_update(notify, path, name, props)
│ │ │ │ │ - except BusError:
│ │ │ │ │ - pass
│ │ │ │ │ +class org_freedesktop_DBus_Introspectable(Interface):
│ │ │ │ │ + @Interface.Method('s')
│ │ │ │ │ + @classmethod
│ │ │ │ │ + def introspect(cls) -> str:
│ │ │ │ │ + return introspection.to_xml(cls._dbus_interfaces)
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ +class org_freedesktop_DBus_Properties(Interface):
│ │ │ │ │ + properties_changed = Interface.Signal('s', 'a{sv}', 'as')
│ │ │ │ │
│ │ │ │ │ - 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
│ │ │ │ │ + @Interface.Method('v', 'ss')
│ │ │ │ │ + def get(self, interface, name):
│ │ │ │ │ + return self._find_member(interface, 'properties', name).to_dbus(self)
│ │ │ │ │
│ │ │ │ │ - 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)
│ │ │ │ │ + @Interface.Method(['a{sv}'], 's')
│ │ │ │ │ + def get_all(self, interface):
│ │ │ │ │ + properties = self._find_category(interface, 'properties')
│ │ │ │ │ + return {name: prop.to_dbus(self) for name, prop in properties.items()}
│ │ │ │ │
│ │ │ │ │ - async def do_meta(self, message):
│ │ │ │ │ - self.cache.inject(message['meta'])
│ │ │ │ │ + @Interface.Method('', 'ssv')
│ │ │ │ │ + def set(self, interface, name, value):
│ │ │ │ │ + self._find_member(interface, 'properties', name).from_dbus(self, value)
│ │ │ │ │
│ │ │ │ │ - def do_data(self, data):
│ │ │ │ │ - message = json.loads(data)
│ │ │ │ │ - logger.debug('receive dbus request %s %s', self.name, message)
│ │ │ │ │
│ │ │ │ │ - if 'call' in message:
│ │ │ │ │ - self.create_task(self.do_call(message))
│ │ │ │ │ - elif 'add-match' in message:
│ │ │ │ │ - self.create_task(self.do_add_match(message))
│ │ │ │ │ - elif 'watch' in message:
│ │ │ │ │ - self.create_task(self.do_watch(message))
│ │ │ │ │ - elif 'meta' in message:
│ │ │ │ │ - self.create_task(self.do_meta(message))
│ │ │ │ │ - else:
│ │ │ │ │ - logger.debug('ignored dbus request %s', message)
│ │ │ │ │ - return
│ │ │ │ │ +class Object(org_freedesktop_DBus_Introspectable,
│ │ │ │ │ + org_freedesktop_DBus_Peer,
│ │ │ │ │ + org_freedesktop_DBus_Properties,
│ │ │ │ │ + BaseObject,
│ │ │ │ │ + Interface):
│ │ │ │ │ + """High-level base class for exporting objects on D-Bus
│ │ │ │ │
│ │ │ │ │ - def do_close(self):
│ │ │ │ │ - for slot in self.matches:
│ │ │ │ │ - slot.cancel()
│ │ │ │ │ - self.matches = []
│ │ │ │ │ - self.close()
│ │ │ │ │ -'''.encode('utf-8'),
│ │ │ │ │ - '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 .
│ │ │ │ │ + This is usually where you should start.
│ │ │ │ │
│ │ │ │ │ -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
│ │ │ │ │ + This provides a base for exporting objects on the bus, implements the
│ │ │ │ │ + standard D-Bus interfaces, and allows you to add your own interfaces to the
│ │ │ │ │ + mix. See the documentation for Interface to find out how to define and
│ │ │ │ │ + implement your D-Bus interface.
│ │ │ │ │ + """
│ │ │ │ │ + def message_received(self, message: BusMessage) -> bool:
│ │ │ │ │ + interface = message.get_interface()
│ │ │ │ │ + name = message.get_member()
│ │ │ │ │
│ │ │ │ │ -CHANNEL_TYPES = [
│ │ │ │ │ - DBusChannel,
│ │ │ │ │ - EchoChannel,
│ │ │ │ │ - FsInfoChannel,
│ │ │ │ │ - FsListChannel,
│ │ │ │ │ - FsReadChannel,
│ │ │ │ │ - FsReplaceChannel,
│ │ │ │ │ - FsWatchChannel,
│ │ │ │ │ - HttpChannel,
│ │ │ │ │ - InternalMetricsChannel,
│ │ │ │ │ - NullChannel,
│ │ │ │ │ - PackagesChannel,
│ │ │ │ │ - SubprocessStreamChannel,
│ │ │ │ │ - SocketStreamChannel,
│ │ │ │ │ -]
│ │ │ │ │ + try:
│ │ │ │ │ + method = self._find_member(interface, 'methods', name)
│ │ │ │ │ + assert isinstance(method, Interface.Method)
│ │ │ │ │ + return method._invoke(self, message)
│ │ │ │ │ + except Object.Method.Unhandled:
│ │ │ │ │ + return False
│ │ │ │ │ ''',
│ │ │ │ │ }))
│ │ │ │ │ from cockpit.bridge import main as main
│ │ │ │ │ main(beipack=True)
│ │ │ ├── ./usr/lib/python3/dist-packages/cockpit-319.dist-info/direct_url.json
│ │ │ │ ├── Pretty-printed
│ │ │ │ │┄ Similarity: 0.90625%
│ │ │ │ │┄ Differences: {"'archive_info'": "{'hash': "
│ │ │ │ │┄ "'sha256=92d1f75f948ae696e4269b7d96657197e1e9058d2c0d9de0eb41d9fe23fe6a02', "
│ │ │ │ │┄ "'hashes': {'sha256': "
│ │ │ │ │┄ "'92d1f75f948ae696e4269b7d96657197e1e9058d2c0d9de0eb41d9fe23fe6a02'}}"}
│ │ │ │ │ @@ -1,9 +1,9 @@
│ │ │ │ │ {
│ │ │ │ │ "archive_info": {
│ │ │ │ │ - "hash": "sha256=3bcbb7fc44a69fd66f13e0a6ed7046c98553f345bf2748ed4ccf82088ac567ee",
│ │ │ │ │ + "hash": "sha256=92d1f75f948ae696e4269b7d96657197e1e9058d2c0d9de0eb41d9fe23fe6a02",
│ │ │ │ │ "hashes": {
│ │ │ │ │ - "sha256": "3bcbb7fc44a69fd66f13e0a6ed7046c98553f345bf2748ed4ccf82088ac567ee"
│ │ │ │ │ + "sha256": "92d1f75f948ae696e4269b7d96657197e1e9058d2c0d9de0eb41d9fe23fe6a02"
│ │ │ │ │ }
│ │ │ │ │ },
│ │ │ │ │ "url": "file:///build/reproducible-path/cockpit-319/tmp/wheel/cockpit-319-py3-none-any.whl"
│ │ │ │ │ }