diff --git a/doc/getting_started.rst b/doc/getting_started.rst index 842a5aa1f..8c9dc00a1 100644 --- a/doc/getting_started.rst +++ b/doc/getting_started.rst @@ -435,6 +435,26 @@ Follow these instructions to install the systemd files on your machine(s): # usermod -a -G labgrid +Enabling gRPC connection security +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is encouraged to use TLS for gRPC channels in a production environment. + +This can be enabled on the ``labgrid-coordinator`` by adding the ``--tls``, +``--cert`` and ``--key`` options. +Refer to the ``labgrid-coordinator`` man page for details. + +When you are connecting with ``labgrid-client`` or ``labgrid-exporter`` to a +``labgrid-coordinator`` that has TLS gRPC channels enabled you need to pass +the ``--tls`` option. If ``--cert`` is not set, labgrid uses the host CA +certificates to verify the coordinator certificate. Use ``--cert`` to provide +a specific CA certificate instead. +Refer to the ``labgrid-client`` and ``labgrid-exporter`` man pages for details. +For ``RemotePlace`` connections from an environment config, set the +``coordinator_tls`` option or ``LG_COORDINATOR_TLS``. If ``coordinator_cert`` +is not set, labgrid uses the host CA certificates. Set ``coordinator_cert`` +to provide a specific CA certificate instead. + Using a Strategy ---------------- diff --git a/doc/man/client.rst b/doc/man/client.rst index 309e36365..ba9ae76d1 100644 --- a/doc/man/client.rst +++ b/doc/man/client.rst @@ -54,6 +54,11 @@ LG_COORDINATOR This variable can be used to set the default coordinator in the format ``HOST[:PORT]`` (instead of using the ``-x`` option). +LG_COORDINATOR_TLS +~~~~~~~~~~~~~~~~~~ +This variable can be set to enable TLS gRPC channels without using the +``--tls`` option. + LG_PROXY ~~~~~~~~ This variable can be used to specify a SSH proxy hostname which should be used @@ -128,6 +133,12 @@ Add all resources with the group "example-group" to the place example-place: $ labgrid-client -p example-place add-match */example-group/*/* +Retrieve a list of places on a ``labgrid-coordinator`` that uses TLS gRPC channels: + +.. code-block:: bash + + $ labgrid-client --tls [--cert PATH] places + See Also -------- diff --git a/doc/man/coordinator.rst b/doc/man/coordinator.rst index c89eccad4..670d67ac9 100644 --- a/doc/man/coordinator.rst +++ b/doc/man/coordinator.rst @@ -25,9 +25,41 @@ OPTIONS display command line help -l ADDRESS, --listen ADDRESS make coordinator listen on host and port +--tls + enable TLS gRPC channel +--cert + path to TLS certificate (in PEM format) +--key + path to TLS key (in PEM format) -d, --debug enable debug mode +TLS WITH A REVERSE PROXY +------------------------ + +Instead of enabling TLS in ``labgrid-coordinator`` directly, a reverse proxy can +terminate TLS and forward cleartext gRPC to the coordinator. For example, with +``nginx``: + +.. code-block:: nginx + + server { + listen 20407 ssl http2; + server_name labgrid.example.com; + + ssl_certificate /etc/ssl/labgrid-coordinator.crt; + ssl_certificate_key /etc/ssl/labgrid-coordinator.key; + + location / { + grpc_pass grpc://127.0.0.1:20408; + } + } + +In this setup, start ``labgrid-coordinator`` without ``--tls`` and point +``labgrid-client`` and ``labgrid-exporter`` at the reverse proxy using +``--tls``. + + SEE ALSO -------- diff --git a/doc/man/device-config.rst b/doc/man/device-config.rst index d0e5ff914..5fd5dab98 100644 --- a/doc/man/device-config.rst +++ b/doc/man/device-config.rst @@ -43,6 +43,15 @@ OPTIONS KEYS takes as parameter the coordinator ``HOST[:PORT]`` to connect to. Defaults to ``127.0.0.1:20408``. +``coordinator_tls`` + enables a TLS gRPC channel for ``RemotePlace`` connections. + Defaults to whether ``LG_COORDINATOR_TLS`` is set. + +``coordinator_cert`` + path to the coordinator TLS certificate in PEM format for ``RemotePlace`` + connections. If unset, the host CA certificates are used. Relative paths are + resolved relative to the configuration file. + .. _labgrid-device-config-images: IMAGES diff --git a/doc/man/exporter.rst b/doc/man/exporter.rst index c30ca3180..8b94960f6 100644 --- a/doc/man/exporter.rst +++ b/doc/man/exporter.rst @@ -27,6 +27,10 @@ OPTIONS display command line help -x, --coordinator coordinator ``HOST[:PORT]`` to connect to, defaults to ``127.0.0.1:20408`` +--tls + enable TLS gRPC channel +--cert + path to TLS certificate (in PEM format) -i, --isolated enable isolated mode (always request SSH forwards) -n, --name @@ -99,6 +103,12 @@ Same as above, but with name ``myname``: $ labgrid-exporter -n myname my-config.yaml +Same as above, but connecting to a ``labgrid-coordinator`` that uses TLS gRPC channels: + +.. code-block:: bash + + $ labgrid-exporter --tls [--cert PATH] -n myname my-config.yaml + SEE ALSO -------- diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 4d2eb0bfa..0b078dbbe 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -41,6 +41,7 @@ TAG_KEY, TAG_VAL, queue_as_aiter, + get_client_credentials, ) from .. import Environment, Target, target_factory from ..exceptions import NoDriverFoundError, NoResourceFoundError, InvalidConfigError @@ -92,6 +93,7 @@ class ClientSession: the coordinator.""" address = attr.ib(validator=attr.validators.instance_of(str)) + credentials = attr.ib(validator=attr.validators.optional(attr.validators.instance_of(grpc.ChannelCredentials))) loop = attr.ib(validator=attr.validators.instance_of(asyncio.BaseEventLoop)) env = attr.ib(default=None, validator=attr.validators.optional(attr.validators.instance_of(Environment))) role = attr.ib(default=None, validator=attr.validators.optional(attr.validators.instance_of(str))) @@ -120,10 +122,18 @@ def __attrs_post_init__(self): ("grpc.http2.max_pings_without_data", 0), # no limit ] - self.channel = grpc.aio.insecure_channel( - target=self.address, - options=channel_options, - ) + if self.credentials: + self.channel = grpc.aio.secure_channel( + target=self.address, + credentials=self.credentials, + options=channel_options, + ) + else: + self.channel = grpc.aio.insecure_channel( + target=self.address, + options=channel_options, + ) + self.stub = labgrid_coordinator_pb2_grpc.CoordinatorStub(self.channel) self.out_queue = asyncio.Queue() @@ -1698,7 +1708,12 @@ def ensure_event_loop(external_loop=None): def start_session( - address: str, *, extra: Dict[str, Any] = None, debug: bool = False, loop: "asyncio.AbstractEventLoop | None" = None + address: str, + *, + extra: Dict[str, Any] = None, + credentials: grpc.ChannelCredentials = None, + debug: bool = False, + loop: "asyncio.AbstractEventLoop | None" = None, ): """ Starts a ClientSession. @@ -1720,7 +1735,7 @@ def start_session( address = proxymanager.get_grpc_address(address, default_port=20408) - session = ClientSession(address, loop, **extra) + session = ClientSession(address, credentials, loop, **extra) loop.run_until_complete(session.start()) return session @@ -1848,6 +1863,13 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg type=str, help="coordinator HOST[:PORT] (default: value from env variable LG_COORDINATOR, otherwise 127.0.0.1:20408)", ) + parser.add_argument( + "--tls", + action="store_true", + default=os.environ.get("LG_COORDINATOR_TLS") is not None, + help="enable TLS gRPC channel", + ) + parser.add_argument("--cert", type=pathlib.PurePath, help="path to the server's TLS certificate (in PEM format)") parser.add_argument( "-c", "--config", @@ -2346,7 +2368,9 @@ def main(): logging.debug('Starting session with "%s"', coordinator_address) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - session = start_session(coordinator_address, extra=extra, debug=args.debug, loop=loop) + session = start_session( + coordinator_address, extra=extra, credentials=get_client_credentials(args), debug=args.debug, loop=loop + ) logging.debug("Started session") try: diff --git a/labgrid/remote/common.py b/labgrid/remote/common.py index 14c8a2d74..e9e0c4e81 100644 --- a/labgrid/remote/common.py +++ b/labgrid/remote/common.py @@ -1,14 +1,18 @@ import asyncio +import platform +import subprocess import time import enum import random import re import string import logging +from argparse import Namespace from datetime import datetime from fnmatch import fnmatchcase - +from typing import Optional import attr +import grpc from .generated import labgrid_coordinator_pb2 @@ -58,6 +62,59 @@ def build_dict_from_map(m): return d +def _fetch_root_certificates_darwin(): + try: + p = subprocess.run( + ["security", "find-certificate", "-a", "-p"], + capture_output=True, + timeout=10, + ) + if p.returncode != 0 or not p.stdout: + return None + return p.stdout + except Exception: + logging.exception("unexpected error when fetching certificates from macOS Keychain") + + return None + + +def _fetch_root_certificates_linux(): + ca_bundle_path = "/etc/ssl/certs/ca-certificates.crt" + try: + # TODO: Current supports Debian/Ubuntu. Extend to support other distributions. + with open(ca_bundle_path, "rb") as f: + certs = f.read() + if certs: + return certs + except OSError as e: + logging.warning("failed to read CA bundle at %s: %s", ca_bundle_path, e) + except Exception: + logging.exception("unexpected error while reading ca certificates") + + return None + + +def _fetch_root_certificates(): + if platform.system() == "Darwin": + return _fetch_root_certificates_darwin() + + if platform.system() == "Linux": + return _fetch_root_certificates_linux() + + return None + + +def get_client_credentials(args: Namespace) -> Optional[grpc.ChannelCredentials]: + if not args.tls: + return None + + if not args.cert: + return grpc.ssl_channel_credentials(root_certificates=_fetch_root_certificates()) + + with open(args.cert, "rb") as fc: + return grpc.ssl_channel_credentials(root_certificates=fc.read()) + + @attr.s(eq=False) class ResourceEntry: data = attr.ib() # cls, params diff --git a/labgrid/remote/coordinator.py b/labgrid/remote/coordinator.py index ea60f4933..4a5f789b3 100644 --- a/labgrid/remote/coordinator.py +++ b/labgrid/remote/coordinator.py @@ -10,7 +10,8 @@ import copy import random import signal - +import pathlib +from typing import Optional import attr import grpc from grpc_reflection.v1alpha import reflection @@ -1109,7 +1110,7 @@ async def GetReservations(self, request: labgrid_coordinator_pb2.GetReservations return labgrid_coordinator_pb2.GetReservationsResponse(reservations=reservations) -async def serve(listen, cleanup) -> None: +async def serve(listen, cleanup, server_credentials=None) -> None: asyncio.current_task().set_name("coordinator-serve") # It seems since https://github.com/grpc/grpc/pull/34647, the # ping_timeout_ms default of 60 seconds overrides keepalive_timeout_ms, @@ -1146,7 +1147,11 @@ async def serve(listen, cleanup) -> None: except ImportError: logging.info("Module grpcio-channelz not available") - bound = server.add_insecure_port(listen) + if server_credentials: + bound = server.add_secure_port(listen, server_credentials) + else: + bound = server.add_insecure_port(listen) + logging.debug("Starting server") await server.start() @@ -1175,6 +1180,18 @@ def callback(): await server.wait_for_termination() +def get_server_credentials(args: argparse.Namespace) -> Optional[grpc.ServerCredentials]: + if not args.tls: + return None + + if not args.cert or not args.key: + raise RuntimeError("--cert and --key must be provided when --tls is provided") + + with open(args.key, "rb") as fk: + with open(args.cert, "rb") as fc: + return grpc.ssl_server_credentials([(fk.read(), fc.read())]) + + def main(): parser = argparse.ArgumentParser() parser.add_argument( @@ -1185,6 +1202,9 @@ def main(): default="[::]:20408", help="coordinator listening host and port", ) + parser.add_argument("--tls", action="store_true", default=False, help="enable TLS gRPC channel") + parser.add_argument("--cert", type=pathlib.PurePath, help="path to TLS certificate (in PEM format)") + parser.add_argument("--key", type=pathlib.PurePath, help="path to TLS key (in PEM format)") parser.add_argument("-d", "--debug", action="store_true", default=False, help="enable debug mode") parser.add_argument("--pystuck", action="store_true", help="enable pystuck") parser.add_argument( @@ -1214,7 +1234,8 @@ def main(): cleanup = [] loop.set_debug(True) try: - loop.run_until_complete(serve(args.listen, cleanup)) + server_credentials = get_server_credentials(args) + loop.run_until_complete(serve(args.listen, cleanup, server_credentials)) finally: if cleanup: loop.run_until_complete(*cleanup) diff --git a/labgrid/remote/exporter.py b/labgrid/remote/exporter.py index 68c4fa708..51650845b 100755 --- a/labgrid/remote/exporter.py +++ b/labgrid/remote/exporter.py @@ -16,12 +16,13 @@ from pathlib import Path from typing import Dict, Type from socket import gethostname, getfqdn +import pathlib import attr import grpc from .config import ResourceConfig -from .common import ResourceEntry, queue_as_aiter +from .common import ResourceEntry, get_client_credentials, queue_as_aiter from .generated import labgrid_coordinator_pb2, labgrid_coordinator_pb2_grpc from ..util import get_free_port, labgrid_version @@ -831,10 +832,17 @@ def __init__(self, config) -> None: if urlsplit(f"//{config['coordinator']}").port is None: config["coordinator"] += ":20408" - self.channel = grpc.aio.insecure_channel( - target=config["coordinator"], - options=channel_options, - ) + if config["credentials"]: + self.channel = grpc.aio.secure_channel( + target=config["coordinator"], + credentials=config["credentials"], + options=channel_options, + ) + else: + self.channel = grpc.aio.insecure_channel( + target=config["coordinator"], + options=channel_options, + ) self.stub = labgrid_coordinator_pb2_grpc.CoordinatorStub(self.channel) self.out_queue = asyncio.Queue() self.pump_task = None @@ -1081,6 +1089,8 @@ def main(): default=os.environ.get("LG_COORDINATOR", "127.0.0.1:20408"), help="coordinator host and port", ) + parser.add_argument("--tls", action="store_true", default=False, help="enable TLS gRPC channel") + parser.add_argument("--cert", type=pathlib.PurePath, help="path to TLS certificate (in PEM format)") parser.add_argument( "-n", "--name", @@ -1122,6 +1132,7 @@ def main(): "hostname": args.hostname or (getfqdn() if args.fqdn else gethostname()), "resources": args.resources, "coordinator": args.coordinator, + "credentials": get_client_credentials(args), "isolated": args.isolated, } diff --git a/labgrid/resource/remote.py b/labgrid/resource/remote.py index d2a63d5e9..460f96116 100644 --- a/labgrid/resource/remote.py +++ b/labgrid/resource/remote.py @@ -1,3 +1,4 @@ +from argparse import Namespace import copy import os import attr @@ -16,13 +17,47 @@ def __attrs_post_init__(self): self.ready = None self.unmanaged_resources = [] + @staticmethod + def _is_tls_option_enabled(value): + if isinstance(value, str): + return value.strip().lower() == "true" + + return value is True + + def _get_credentials(self): + from ..remote.common import get_client_credentials + + tls = os.environ.get("LG_COORDINATOR_TLS") is not None + cert = None + + if self.env: + config = self.env.config + tls = config.get_option("coordinator_tls", tls) + + cert = config.get_option("coordinator_cert", "") + if cert: + cert = config.resolve_path(str(cert)) + else: + cert = None + + args = Namespace( + tls=self._is_tls_option_enabled(tls), + cert=cert, + ) + + return get_client_credentials(args) + def _start(self): if self.session: return from ..remote.client import start_session try: - self.session = start_session(self.url, extra={'env': self.env}) + self.session = start_session( + self.url, + extra={'env': self.env}, + credentials=self._get_credentials(), + ) except ConnectionRefusedError as e: raise ConnectionRefusedError(f"Could not connect to coordinator {self.url}") \ from e diff --git a/man/labgrid-client.1 b/man/labgrid-client.1 index dfc95b2e8..6f03f6033 100644 --- a/man/labgrid-client.1 +++ b/man/labgrid-client.1 @@ -39,8 +39,9 @@ This is the client to control a boards status and interface with it on remote ma .INDENT 3.5 .sp .EX -usage: labgrid\-client [\-x ADDRESS] [\-c CONFIG] [\-p PLACE] [\-s STATE] - [\-i INITIAL_STATE] [\-d] [\-v] [\-P PROXY] +usage: labgrid\-client [\-x ADDRESS] [\-\-tls] [\-\-cert CERT] [\-c CONFIG] + [\-p PLACE] [\-s STATE] [\-i INITIAL_STATE] [\-d] [\-v] + [\-P PROXY] COMMAND ... .EE .UNINDENT @@ -52,6 +53,16 @@ coordinator HOST[:PORT] (default: value from env variable LG_COORDINATOR, otherw .UNINDENT .INDENT 0.0 .TP +.B \-\-tls +enable TLS gRPC channel +.UNINDENT +.INDENT 0.0 +.TP +.B \-\-cert +path to the server\(aqs TLS certificate (in PEM format) +.UNINDENT +.INDENT 0.0 +.TP .B \-c , \-\-config env config file (default: value from env variable LG_ENV) .UNINDENT @@ -1149,6 +1160,10 @@ using the \fB\-\-config\fP option, the \fB\-\-config\fP option overrides it. .sp This variable can be used to set the default coordinator in the format \fBHOST[:PORT]\fP (instead of using the \fB\-x\fP option). +.SS LG_COORDINATOR_TLS +.sp +This variable can be set to enable TLS gRPC channels without using the +\fB\-\-tls\fP option. .SS LG_PROXY .sp This variable can be used to specify a SSH proxy hostname which should be used @@ -1230,6 +1245,16 @@ $ labgrid\-client \-p example\-place add\-match */example\-group/*/* .EE .UNINDENT .UNINDENT +.sp +Retrieve a list of places on a \fBlabgrid\-coordinator\fP that uses TLS gRPC channels: +.INDENT 0.0 +.INDENT 3.5 +.sp +.EX +$ labgrid\-client \-\-tls [\-\-cert PATH] places +.EE +.UNINDENT +.UNINDENT .SH SEE ALSO .sp \fBlabgrid\-exporter\fP(1) diff --git a/man/labgrid-coordinator.1 b/man/labgrid-coordinator.1 index e99c30f93..d7359fffc 100644 --- a/man/labgrid-coordinator.1 +++ b/man/labgrid-coordinator.1 @@ -51,9 +51,45 @@ display command line help .BI \-l \ ADDRESS\fR,\fB \ \-\-listen \ ADDRESS make coordinator listen on host and port .TP +.B \-\-tls +enable TLS gRPC channel +.TP +.B \-\-cert +path to TLS certificate (in PEM format) +.TP +.B \-\-key +path to TLS key (in PEM format) +.TP .B \-d\fP,\fB \-\-debug enable debug mode .UNINDENT +.SS TLS WITH A REVERSE PROXY +.sp +Instead of enabling TLS in \fBlabgrid\-coordinator\fP directly, a reverse proxy can +terminate TLS and forward cleartext gRPC to the coordinator. For example, with +\fBnginx\fP: +.INDENT 0.0 +.INDENT 3.5 +.sp +.EX +server { + listen 20407 ssl http2; + server_name labgrid.example.com; + + ssl_certificate /etc/ssl/labgrid\-coordinator.crt; + ssl_certificate_key /etc/ssl/labgrid\-coordinator.key; + + location / { + grpc_pass grpc://127.0.0.1:20408; + } +} +.EE +.UNINDENT +.UNINDENT +.sp +In this setup, start \fBlabgrid\-coordinator\fP without \fB\-\-tls\fP and point +\fBlabgrid\-client\fP and \fBlabgrid\-exporter\fP at the reverse proxy using +\fB\-\-tls\fP\&. .SS SEE ALSO .sp \fBlabgrid\-client\fP(1), \fBlabgrid\-exporter\fP(1) diff --git a/man/labgrid-device-config.5 b/man/labgrid-device-config.5 index 0755927de..2d74bfdc1 100644 --- a/man/labgrid-device-config.5 +++ b/man/labgrid-device-config.5 @@ -60,6 +60,15 @@ The \fBoptions:\fP top key configures various options such as the coordinator_ad .B \fBcoordinator_address\fP takes as parameter the coordinator \fBHOST[:PORT]\fP to connect to. Defaults to \fB127.0.0.1:20408\fP\&. +.TP +.B \fBcoordinator_tls\fP +enables a TLS gRPC channel for \fBRemotePlace\fP connections. +Defaults to whether \fBLG_COORDINATOR_TLS\fP is set. +.TP +.B \fBcoordinator_cert\fP +path to the coordinator TLS certificate in PEM format for \fBRemotePlace\fP +connections. If unset, the host CA certificates are used. Relative paths are +resolved relative to the configuration file. .UNINDENT .SS IMAGES .sp diff --git a/man/labgrid-exporter.1 b/man/labgrid-exporter.1 index 92619f4d1..1954dda48 100644 --- a/man/labgrid-exporter.1 +++ b/man/labgrid-exporter.1 @@ -51,6 +51,12 @@ display command line help .B \-x\fP,\fB \-\-coordinator coordinator \fBHOST[:PORT]\fP to connect to, defaults to \fB127.0.0.1:20408\fP .TP +.B \-\-tls +enable TLS gRPC channel +.TP +.B \-\-cert +path to TLS certificate (in PEM format) +.TP .B \-i\fP,\fB \-\-isolated enable isolated mode (always request SSH forwards) .TP @@ -126,6 +132,16 @@ $ labgrid\-exporter \-n myname my\-config.yaml .EE .UNINDENT .UNINDENT +.sp +Same as above, but connecting to a \fBlabgrid\-coordinator\fP that uses TLS gRPC channels: +.INDENT 0.0 +.INDENT 3.5 +.sp +.EX +$ labgrid\-exporter \-\-tls [\-\-cert PATH] \-n myname my\-config.yaml +.EE +.UNINDENT +.UNINDENT .SS SEE ALSO .sp \fBlabgrid\-client\fP(1), \fBlabgrid\-device\-config\fP(5) diff --git a/tests/test_remote.py b/tests/test_remote.py index 76a1da434..5866f885c 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -1,4 +1,12 @@ +import argparse import pexpect +import pytest +from labgrid import Environment, Target +from labgrid.remote.coordinator import get_server_credentials +from labgrid.remote.common import get_client_credentials +from labgrid.resource import RemotePlace +from labgrid.resource.common import ResourceManager +from labgrid.util.yaml import dump def test_client_help(): @@ -48,3 +56,316 @@ def test_exporter_coordinator_becomes_unreachable(coordinator, exporter): assert exporter.exitstatus == 100 coordinator.resume_tree() + + +def setup_tmp_cert_key(tmpdir): + cert = "cert.pem" + cert_file = tmpdir.join(cert) + cert_file.write( + """-----BEGIN CERTIFICATE----- +MIIGXjCCBEagAwIBAgIUbB+eM8qxEoxQX/KptIJArd7oyhMwDQYJKoZIhvcNAQEL +BQAwgaExCzAJBgNVBAYTAkdCMRIwEAYDVQQIDAlTb21lU3RhdGUxETAPBgNVBAcM +CFNvbWVDaXR5MRQwEgYDVQQKDAtTb21lQ29tcGFueTEXMBUGA1UECwwOU29tZURl +cGFydG1lbnQxEjAQBgNVBAMMCWxvY2FsaG9zdDEoMCYGCSqGSIb3DQEJARYZZW1h +aWwuYWRkcmVzc0Bjb21wYW55LmNvbTAeFw0yNTEyMDUxNDE5MjdaFw0zNTEyMDMx +NDE5MjdaMIGhMQswCQYDVQQGEwJHQjESMBAGA1UECAwJU29tZVN0YXRlMREwDwYD +VQQHDAhTb21lQ2l0eTEUMBIGA1UECgwLU29tZUNvbXBhbnkxFzAVBgNVBAsMDlNv +bWVEZXBhcnRtZW50MRIwEAYDVQQDDAlsb2NhbGhvc3QxKDAmBgkqhkiG9w0BCQEW +GWVtYWlsLmFkZHJlc3NAY29tcGFueS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCwISW6WyB2jw6NT4m14BXHjrQE/grXd8TDRTlVNasMGf2TVWmr +y+HTqy0xI6mmcyvh7rPw+kY57qLGJdhISB44ayXbMbWYdSkhb/qBmG9NgH9J4OBX +1yajmi6elgqYTm+9o7CvhyFcF2lcMPy9a8JRADrwucK5L5wuXXVEvqCjWToq2xP4 +Qiujdh/CRB0kV/nvwGKsn1rX9SU3JxvP6oMdb0mq9Xp90Wx/5CMzZq8dNrHFCxda +IVhdZcPqRY9yft/K6V9ARJARcl9c6+1gCfZc2uqMLuLVjkrn0UFq06q28n2JvkRP +NC2TmIH82+bhsVolXeO62m8V/VekXT16NCB/cl0HXTVTa37r7qFadFhBGP7uoZy8 +TyUhEiumt55N4MCyQMbqXXCT8ypserVL325v4/THyr/vFtLvpLSCayDB2diOe4Ii +s4I0M6dhmF2/ThtwNbWurC25RKYGjBV0gaCe96nG8LxKOb2nYdEI+LOOajJyrd+o +SwPvTgpHcJF4+NP+E8t+QJ5jO1s/geelPqxCEdk6GHXnwWBKiIeY8Lm/wj8O5PH4 +SfIMxhapFUIdf9Y5thtLkmcW73k7mEQy7Vqy4RTHvgH0wwAdAO93luci4YVDpmml +G9n1Xg1u5H6hNsn8/D72LBcZvHMfxmjXQPzYaLJb6J5WGADgJzh+yr6EPwIDAQAB +o4GLMIGIMA4GA1UdDwEB/wQEAwIDiDAMBgNVHRMEBTADAQH/MEkGA1UdEQRCMECC +EHNvbWUtY29tcGFueS5jb22CCWxvY2FsaG9zdIIJMTI3LjAuMC4xhwR/AAABhxAA +AAAAAAAAAAAAAAAAAAABMB0GA1UdDgQWBBSV9hF1IhJg+XzkNlMuUsWvaWqhmjAN +BgkqhkiG9w0BAQsFAAOCAgEAcXVn1pCkMDf++z9IQAQOZHdsDklJY0yHSKpnZwd5 +XF+lZZlW4OiY6Ednj5nJsXYImhqEJiCWyVYUu/CDzHvFNUVLa1Eav9lHqPhpAZtg +FEjYenEmdcklYDuooGn1Mp7zM0QO442rVAluy65GZSBZw2XF07bvHBqCLLhiYjXi +t8fBe2r4UJ/pv4mpVLQ1tuDYJ3ObH/DQ/nxHRpwFuDobU7Xm0nIBhvtlbvPgddFF +kam3zLwgOQkxVbu5i4k893KwQ9HHLmHath5go/iNx8A5Je3cQqUusY35KWN4sa+K +tGzhuL4xTUKjCvTygSu3KCEvFTafCgCQSA/dM3h1gAnz5STUGUUSS4lxDXWswSI7 +S914+8kLkTQ5eKbYWc6LAfNFaUFSPaUqYveWvHZ9K0pQZ/4RPH+JJJ7BCC9J6h+a +bAW6BniHsn4SNtT8AqQN3MVwgHcA4EvoBP2frIO1SWrL8/dRC5hz1yDcMtCemFSB +X/LMdSgjsS5Re4sqeTyYI7u24HxDc/+k8/MZsnI5l2ZezE11KpI30TxWgoSWGmjM +WXNpODmLLad5CatK4Yh6G0Aza+QiUc13R8vbftjWWT6kcf07+AxN193Z06ZMNIoj +igUfKt9THGeZvEUW4G9G/nKD1uT0p4re3NVTibUEs86WOKIJExGXZyAEvovkAS8D +ez4= +-----END CERTIFICATE----- +""" + ) + + key = "key.pem" + key_file = tmpdir.join(key) + key_file.write( + """-----BEGIN PRIVATE KEY----- +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 +yov2s6hjFo07mz1KLoarpgWSV1BTrjA= +-----END PRIVATE KEY----- +""" + ) + + return (cert_file, key_file) + + +def test_tls_coordinator_with_client(tmpdir): + cert, key = setup_tmp_cert_key(tmpdir) + + with pexpect.spawn( + f"python -m labgrid.remote.coordinator --tls --cert {cert} --key {key}", cwd=tmpdir + ) as coord_spawn: + coord_spawn.expect("INFO:root:Coordinator ready", timeout=1) + + with pexpect.spawn( + f"python -m labgrid.remote.client --tls --cert {cert} reserve abc=123", cwd=tmpdir + ) as client_spawn: + client_spawn.expect(" state: waiting", timeout=1) + client_spawn.close() + + coord_spawn.close() + + +def test_tls_coordinator_with_exporter(tmpdir): + config = "exports.yaml" + p = tmpdir.join(config) + p.write( + """ + Testport: + NetworkSerialPort: + host: 'localhost' + port: 4000 + """ + ) + + cert, key = setup_tmp_cert_key(tmpdir) + + with pexpect.spawn( + f"python -m labgrid.remote.coordinator --tls --cert {cert} --key {key}", cwd=tmpdir + ) as coord_spawn: + coord_spawn.expect("INFO:root:Coordinator ready", timeout=1) + + with pexpect.spawn( + f"python -m labgrid.remote.exporter --tls --cert {cert} {config}", cwd=tmpdir + ) as exporter_spawn: + exporter_spawn.expect( + "add resource Testport/NetworkSerialPort: NetworkSerialPort/OrderedDict.*", timeout=1 + ) + exporter_spawn.expect("INFO:root:connected to coordinator version .*", timeout=1) + exporter_spawn.close() + + coord_spawn.close() + + +def test_get_server_credentials_not_tls(): + args = { + "tls": False, + "cert": None, + "key": None, + } + + ns = argparse.Namespace(**args) + credentials = get_server_credentials(ns) + + assert credentials is None + + +def test_get_server_credentials_only_key(): + args = { + "tls": True, + "cert": None, + "key": "akey", + } + + ns = argparse.Namespace(**args) + + with pytest.raises(RuntimeError): + get_server_credentials(ns) + + +def test_get_server_credentials_only_cert(): + args = { + "tls": True, + "cert": "acert", + "key": None, + } + + ns = argparse.Namespace(**args) + + with pytest.raises(RuntimeError): + get_server_credentials(ns) + + +def test_get_server_credentials_valid(tmpdir): + cert_path, key_path = setup_tmp_cert_key(tmpdir) + + args = { + "tls": True, + "cert": cert_path, + "key": key_path, + } + + ns = argparse.Namespace(**args) + + creds = get_server_credentials(ns) + + assert creds is not None + + +def test_get_client_credentials_not_tls(): + args = { + "tls": False, + "cert": None, + } + + ns = argparse.Namespace(**args) + credentials = get_client_credentials(ns) + + assert credentials is None + + +def test_get_client_credentials_valid(tmpdir): + cert_path, _ = setup_tmp_cert_key(tmpdir) + + args = { + "tls": True, + "cert": cert_path, + } + + ns = argparse.Namespace(**args) + + creds = get_client_credentials(ns) + + assert creds is not None + + +def _make_remote_place_config(tmpdir, options=None): + config = tmpdir.join("config.yaml") + data = {"targets": {}} + if options: + data["options"] = options + + config.write(dump(data)) + + return config + + +def _capture_remote_place_credentials(monkeypatch, tmpdir, *, options=None, tls_env=False): + monkeypatch.setattr(ResourceManager, "instances", {}) + monkeypatch.delenv("LG_COORDINATOR_TLS", raising=False) + + if tls_env: + monkeypatch.setenv("LG_COORDINATOR_TLS", "true") + + class DummySession: + loop = None + + def get_place(self, name): + return argparse.Namespace(tags={}, acquired_resources=[]) + + def get_target_resources(self, place): + return {} + + captured = {} + + def get_client_credentials(args): + captured["args"] = args + return "credentials" if args.tls else None + + def start_session(address, *, extra=None, credentials=None, **kwargs): + captured["credentials"] = credentials + return DummySession() + + monkeypatch.setattr("labgrid.remote.common.get_client_credentials", get_client_credentials) + monkeypatch.setattr("labgrid.remote.client.start_session", start_session) + + config = _make_remote_place_config(tmpdir, options=options) + env = Environment(str(config)) + target = Target("test", env=env) + RemotePlace(target, name="test") + + return captured + + +@pytest.mark.parametrize( + ("options", "tls_env", "expected_tls", "expected_cert"), + [ + pytest.param({}, False, False, None, id="default-no-tls"), + pytest.param({}, True, True, None, id="tls-env"), + pytest.param( + {"coordinator_tls": True, "coordinator_cert": "cert.pem"}, + False, + True, + "cert.pem", + id="tls-config", + ), + pytest.param({"coordinator_tls": "TRUE"}, False, True, None, id="tls-config-string"), + pytest.param({"coordinator_tls": False}, True, False, None, id="config-overrides-env"), + pytest.param({"coordinator_tls": "false"}, True, False, None, id="config-string-overrides-env"), + ], +) +def test_remote_place_client_credentials(monkeypatch, tmpdir, options, tls_env, expected_tls, expected_cert): + cert_path, _ = setup_tmp_cert_key(tmpdir) + assert cert_path.basename == "cert.pem" + if expected_cert: + expected_cert = str(tmpdir.join(expected_cert)) + + captured = _capture_remote_place_credentials(monkeypatch, tmpdir, options=options, tls_env=tls_env) + + assert captured["args"].tls is expected_tls + assert captured["args"].cert == expected_cert + assert captured["credentials"] == ("credentials" if expected_tls else None)