qubes-doc/developer/services/qrexec-socket-services.rst

302 lines
8.4 KiB
ReStructuredText
Raw Normal View History

2024-05-21 14:59:46 -04:00
=============================
Qrexec: socket-based services
=============================
*This page describes how to implement and use new socket-backed services for qrexec. See* :doc:`qrexec </developer/services/qrexec>` *for general overview of the qrexec framework.*
As of Qubes 4.1, qrexec allows implementing services not only as
executable files, but also as Unix sockets. This allows Qubes RPC
requests to be handled by a server running in a VM and listening for
connections.
How it works
------------
When a Qubes RPC service is invoked, qrexec searches for a file that
handles it in the qubes-rpc directories (``/etc/qubes-rpc`` or
``/usr/local/etc/qubes-rpc``). If the file is a Unix socket, qrexec will
try to connect to it.
Before passing user input, the socket service will receive a
null-terminated service descriptor, i.e. the part after ``QUBESRPC``.
When running in a VM, this is:
.. code:: bash
<service_name> <source>\0
When running in dom0, it is:
.. code:: bash
<service_name> <source> <target_type> <target>\0
(The target type can be ``name``, in which case target is a domain name,
or ``keyword``, in which the target is a keyword like ``@dispvm``).
Afterwards, data provided by the services user (as stdin) is sent into
the socket, and data received from the socket is sent back to the user
(as stdout). When the service closes the socket, an exit code of 0 is
sent back to the user.
Differences from executable-based services
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
From the user point of view, the socket-based service behaves almost
like an executable-based one. Here are the differences:
- There is no stderr (the socket provides only one output stream).
Currently, that means stderr will also never be closed on users end.
- There is no exit code. When the socket connection is closed, exit
code 0 is sent to the user.
Recommended use
---------------
Create a program that binds to path *outside* ``/etc/qubes-rpc``, such
as ``/var/run/my-daemon.sock``. Put a symlink in ``/etc/qubes-rpc``,
e.g. ``ln -s /var/run/my-daemon.sock /etc/qubes-rpc/qubes.Service``.
If your program handles multiple services, create multiple symlinks. You
can dispatch based on the service descriptor.
Do not run the program as root.
You can use systemd and socket activation so that the program is started
only when the service is invoked. See the below example.
Example: ``qrexec-policy-agent``
--------------------------------
``qrexec-policy-agent`` is the program that handles “ask” prompts for
Qubes RPC calls. It is a good example of an application that: * Uses
Python and asyncio. * Runs as a daemon, to save some overhead on
starting process. * Runs as a normal user. This is achieved using
users instance of systemd. * Uses systemd socket activation. This way
it can be installed in all VMs, but started only if its ever needed.
See the
`qubes-core-qrexec <https://github.com/QubesOS/qubes-core-qrexec/>`__
repository for details.
Systemd unit files
^^^^^^^^^^^^^^^^^^
**/lib/systemd/user/qubes-qrexec-policy-agent.service**: This is the
service configuration.
.. code:: bash
[Unit]
Description=Qubes remote exec policy agent
ConditionUser=!root
ConditionGroup=qubes
Requires=qubes-qrexec-policy-agent.socket
[Service]
Type=simple
ExecStart=/usr/bin/qrexec-policy-agent
[Install]
WantedBy=default.target
**/lib/systemd/user/qubes-qrexec-policy-agent.socket**: This is the
socket file that will activate the service.
.. code:: bash
[Unit]
Description=Qubes remote exec policy agent socket
ConditionUser=!root
ConditionGroup=qubes
PartOf=qubes-qrexec-policy-agent.service
[Socket]
ListenStream=/var/run/qubes/policy-agent.sock
[Install]
WantedBy=sockets.target
Note the ``ConditionUser`` and ``ConditionGroup`` that ensure that the
socket and service is started only as the right user
Start the socket using ``systemctl --user start``. Enable it using
``systemctl --user enable``, so that it starts automatically.
.. code:: bash
systemctl --user start qubes-qrexec-policy-agent.socket
systemctl --user enable qubes-qrexec-policy-agent.socket
Alternatively, you can enable the service by creating a symlink:
.. code:: bash
sudo ln -s /lib/systemd/user/qubes-qrexec-policy-agent.socket /lib/systemd/user/sockets.target.wants/
Link in qubes-rpc
^^^^^^^^^^^^^^^^^
``qrexec-policy-agent`` will handle a Qubes RPC service called
``policy.Ask``, so we add a link:
.. code:: bash
sudo ln -s /var/run/qubes/policy-agent.sock /etc/qubes-rpc/policy.Ask
Python server with socket activation
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Socket activation in systemd works by starting our program with the
socket file already bound at a specific file descriptor. Its a simple
mechanism based on a few environment variables, but the canonical way is
to use the ``sd_listen_fds()`` function from systemd library (or, in our
case, its Python version).
Install the Python systemd library:
.. code:: bash
sudo dnf install python3-systemd
Here is the server code:
.. code:: python
import os
import asyncio
import socket
from systemd.daemon import listen_fds
class SocketService:
def __init__(self, socket_path, socket_activated=False):
self._socket_path = socket_path
self._socket_activated = socket_activated
async def run(self):
server = await self.start()
async with server:
await server.serve_forever()
async def start(self):
if self._socket_activated:
fds = listen_fds()
if fds:
assert len(fds) == 1, 'too many listen_fds: {}'.format(
listen_fds)
sock = socket.socket(fileno=fds[0])
return await asyncio.start_unix_server(self._client_connected,
sock=sock)
if os.path.exists(self._socket_path):
os.unlink(self._socket_path)
return await asyncio.start_unix_server(self._client_connected,
path=self._socket_path)
async def _client_connected(self, reader, writer):
try:
data = await reader.read()
assert b'\0' in data, data
service_descriptor, data = data.split(b'\0', 1)
response = await self.handle_request(service_descriptor, data)
writer.write(response)
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
async def handle_request(self, service_descriptor, data):
# process params, return response
return response
def main():
socket_path = '/var/run/qubes/policy-agent.sock'
service = SocketService(socket_path)
loop = asyncio.get_event_loop()
loop.run_until_complete(service.run())
if __name__ == '__main__':
main()
You can also use ``qrexec/server.py`` from
`qubes-core-qrexec <https://github.com/QubesOS/qubes-core-qrexec/>`__
repository, which is a variant of the above code - but note that
currently its somewhat more specific (JSON requests and ASCII
responses; no target handling in service descriptors).
Using the service
^^^^^^^^^^^^^^^^^
The service is invoked in the same way as a standard Qubes RPC service:
.. code:: bash
echo <input_data> | qrexec-client -d domX 'DEFAULT:QUBESRPC policy.Ask'
You can also connect to it locally, but remember to include the service
descriptor:
.. code:: bash
echo -e 'policy.Ask dom0\0<input data>' | nc -U /etc/qubes-rpc/policy.Ask
Further reading
---------------
- :doc:`Qrexec overview </developer/services/qrexec>`
- :doc:`Qrexec internals </developer/services/qrexec-internals>`
- `qubes-core-qrexec <https://github.com/QubesOS/qubes-core-qrexec/>`__
repository - contains the above example
- `systemd.socket <https://www.freedesktop.org/software/systemd/man/systemd.socket.html>`__
- socket unit configuration
- `Streams in Python asyncio <https://docs.python.org/3/library/asyncio-stream.html>`__