diff --git a/tests/test_the_test/test_mock_backend_v2.py b/tests/test_the_test/test_mock_backend_v2.py index d8fb76e10fd..8410dc854e7 100644 --- a/tests/test_the_test/test_mock_backend_v2.py +++ b/tests/test_the_test/test_mock_backend_v2.py @@ -1,7 +1,11 @@ """Unit coverage for MockedBackendServer (utils.mocked_backend).""" +from datetime import UTC, datetime, timedelta import gzip import signal +import socket +import threading +import ssl import json from collections.abc import Generator from pathlib import Path @@ -11,8 +15,11 @@ from utils import pytest import requests import zstandard +from cryptography import x509 from utils import scenarios +from utils.interfaces._backend_v2 import _BackendV2InterfaceValidator +from utils._context.containers import MockedBackendContainer from utils.mocked_backend import MockedBackendClient, MockedBackendServer from utils.mocked_backend.ffe import UFC_FIXTURE_RELATIVE_PATH @@ -231,3 +238,172 @@ def test_health_endpoint(server: MockedBackendServer, tmp_path: Path): assert response.json() == {"status": "ok"} # a control endpoint: not recorded assert list(tmp_path.glob("*.json")) == [] + + +def _backend_v2_with_requests(tmp_path: Path, requests_: list[tuple[str, str | None]]) -> _BackendV2InterfaceValidator: + """A backend_v2 interface that received a series request per (filename, api_key)""" + interface = _BackendV2InterfaceValidator() + interface.configure(str(tmp_path), replay=False) + for filename, api_key in requests_: + headers = [["Content-Type", "application/json"]] + ([["DD-API-KEY", api_key]] if api_key else []) + file = Path(interface.log_folder) / filename + data = { + "log_filename": str(file), + "path": "/api/v2/series", + "request": {"headers": headers, "content": {"series": [{"metric": filename}]}}, + "response": {}, + } + file.write_text(json.dumps(data)) + interface.ingest_file(str(file)) + return interface + + +@scenarios.test_the_test +def test_backend_v2_with_api_key_only_returns_requests_sent_with_this_key(tmp_path: Path): + interface = _backend_v2_with_requests( + tmp_path, [("000__agent.json", "agent-key"), ("001__sidecar.json", "sidecar-key"), ("002__nokey.json", None)] + ) + + assert [d["log_filename"].split("/")[-1] for d in interface.get_data(with_api_key="sidecar-key")] == [ + "001__sidecar.json" + ] + assert [point["metric"] for _, point in interface.get_metrics(with_api_key="agent-key")] == ["000__agent.json"] + assert list(interface.get_data(with_api_key="unknown-key")) == [] + # without it, every request + assert len(list(interface.get_data())) == 3 + + +@scenarios.test_the_test +def test_backend_v2_with_api_key_ignores_the_header_name_case(tmp_path: Path): + interface = _backend_v2_with_requests(tmp_path, [("000__agent.json", "agent-key")]) + data = next(interface.get_data()) + data["request"]["headers"] = [["dd-api-key", "agent-key"]] + + assert len(list(interface.get_data(with_api_key="agent-key"))) == 1 + + +_MITMPROXY_CA = "utils/proxy/.mitmproxy/mitmproxy-ca-cert.pem" +_TLS_CERT_FILE = MockedBackendContainer.tls_cert_file +_INTAKE_HOSTNAME = "event-platform-intake.mock-intake.invalid" + + +def _https_post(port: int, hostname: str, path: str, body: bytes) -> str: + """POST over HTTPS to 127.0.0.1, verifying the certificate is valid for hostname: the status line""" + context = ssl.create_default_context(cafile=_MITMPROXY_CA) + with ( + socket.create_connection(("127.0.0.1", port), timeout=5) as raw, + context.wrap_socket(raw, server_hostname=hostname) as tls, + ): + request = f"POST {path} HTTP/1.1\r\nHost: {hostname}\r\nContent-Type: application/json\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n" + tls.sendall(request.encode() + body) + return tls.recv(4096).decode().splitlines()[0] + + +@scenarios.test_the_test +def test_https_mode_serves_and_records_https_requests(tmp_path: Path): + server = MockedBackendServer(str(tmp_path), port=0, tls_cert_file=_TLS_CERT_FILE) + try: + status_line = _https_post(server.port, _INTAKE_HOSTNAME, "/api/v2/exposures", b'{"over": "https"}') + # the control endpoints, as used from the host + health = requests.get(f"{server.base_url}/system-tests/health", timeout=5, verify=_MITMPROXY_CA) + finally: + server.close() + + assert status_line == "HTTP/1.0 200 OK" + assert health.json() == {"status": "ok"} + recorded = json.loads((tmp_path / "000__api_v2_exposures.json").read_text()) + assert recorded["request"]["content"] == {"over": "https"} + + +@scenarios.test_the_test +def test_https_mode_does_not_serve_http(tmp_path: Path): + server = MockedBackendServer(str(tmp_path), port=0, tls_cert_file=_TLS_CERT_FILE) + try: + with pytest.raises(requests.exceptions.ConnectionError): + requests.post(f"http://127.0.0.1:{server.port}/api/v2/series", json={}, timeout=5) + finally: + server.close() + + +@scenarios.test_the_test +def test_https_mode_releases_the_port_if_the_certificate_cannot_be_loaded(tmp_path: Path): + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + + with pytest.raises(FileNotFoundError) as error: + MockedBackendServer(str(tmp_path), port=port, tls_cert_file=str(tmp_path / "missing.pem")) + + # the port is free again, even while the error (holding the half-built server) is still referenced + assert error.traceback + MockedBackendServer(str(tmp_path), port=port).close() + + +@scenarios.test_the_test +def test_https_mode_is_not_blocked_by_an_idle_connection(tmp_path: Path): + server = MockedBackendServer(str(tmp_path), port=0, tls_cert_file=_TLS_CERT_FILE) + closed = threading.Event() + + def close() -> None: + server.close() + closed.set() + + try: + # connects, and never starts the TLS handshake + with socket.create_connection(("127.0.0.1", server.port), timeout=5): + status_line = _https_post(server.port, _INTAKE_HOSTNAME, "/api/v2/exposures", b"{}") + threading.Thread(target=close, daemon=True).start() + assert closed.wait(timeout=10), "close() hangs while a connection is idle" + finally: + if not closed.is_set(): + server.close() + + assert status_line == "HTTP/1.0 200 OK" + + +@scenarios.test_the_test +def test_https_mode_advertises_https_urls(tmp_path: Path): + server = MockedBackendServer(str(tmp_path), port=0, tls_cert_file=_TLS_CERT_FILE) + try: + assert server.container_url.startswith("https://") + assert server.ffe_library_config_url.startswith("https://") + finally: + server.close() + + +@scenarios.test_the_test +def test_https_certificate_is_only_valid_for_the_served_hostnames(tmp_path: Path): + server = MockedBackendServer(str(tmp_path), port=0, tls_cert_file=_TLS_CERT_FILE) + try: + with pytest.raises(ssl.SSLCertVerificationError): + _https_post(server.port, "other.mock-intake.invalid", "/api/v2/exposures", b"{}") + finally: + server.close() + + +@scenarios.test_the_test +def test_https_certificate_covers_the_container_hostnames_for_a_while(): + """The committed certificate must be regenerated when a hostname is added, or before it expires""" + cert = x509.load_pem_x509_certificate(Path(_TLS_CERT_FILE).read_bytes()) + names = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + container = MockedBackendContainer(tls=True) + + # reached by its name, its intake hostnames, and by localhost from the healthcheck and the host + assert sorted(names.get_values_for_type(x509.DNSName)) == sorted( + [container.name, *container.network_aliases, "localhost"] + ) + assert [str(ip) for ip in names.get_values_for_type(x509.IPAddress)] == ["127.0.0.1"] + # naive, in UTC + assert cert.not_valid_after.replace(tzinfo=UTC) - datetime.now(UTC) > timedelta(days=180) + + +@scenarios.test_the_test +def test_mocked_backend_container_serves_either_http_or_https(): + http = MockedBackendContainer() + assert http.container_url == "http://mocked-backend:4900" + assert http.network_aliases == [] + + https = MockedBackendContainer(tls=True) + # senders build the intake URLs from DD_SITE: the default HTTPS port, and the intake hostnames + assert https.container_url == "https://mocked-backend:443" + assert https.network_aliases == list(MockedBackendContainer.tls_hostnames) diff --git a/utils/_context/containers.py b/utils/_context/containers.py index 1a4f5e88c18..54705b58487 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -53,6 +53,10 @@ # fake key of length 32 FAKE_DD_API_KEY = "0123456789abcdef0123456789abcdef" +# DD_SITE of agentless senders: the reserved .invalid domain fails closed if a request is not sent to the mocked backend +MOCK_INTAKE_SITE = "mock-intake.invalid" +# its own key tells, in the mocked backend, what serverless-init sent (see interfaces.backend_v2 with_api_key) +SERVERLESS_INIT_FAKE_DD_API_KEY = "fedcba9876543210fedcba9876543210" _DEFAULT_NETWORK_NAME = "system-tests_default" _NETWORK_NAME = "bridge" if "GITLAB_CI" in os.environ else _DEFAULT_NETWORK_NAME @@ -103,6 +107,7 @@ def __init__( pid_mode: str | None = None, cgroupns: str | None = None, mounts: list[docker.types.Mount] | None = None, + network_aliases: list[str] | None = None, ) -> None: assert name assert image_name @@ -141,6 +146,8 @@ def __init__( # Unlike volumes, a bind mount listed here fails container creation when its source is # missing, instead of being created as an empty directory by the docker daemon. self.mounts = mounts + # other names other containers of the network can reach it with + self.network_aliases = network_aliases or [] def _get_image_name(self, binary_file_name: str | None, default_name: str) -> str: # if the container provide binary_file_name, then a file named binaries/{binary_file_name} @@ -254,6 +261,11 @@ def start(self, network: Network) -> Container: # auto_remove=True, detach=True, network=network.name, + networking_config=( + {network.name: get_docker_client().api.create_endpoint_config(aliases=self.network_aliases)} + if self.network_aliases + else None + ), volumes=self.volumes, ports=self.ports, working_dir=self.working_dir, @@ -779,14 +791,31 @@ class MockedBackendContainer(TestedContainer): What it receives is recorded in the logs folder, and read by interfaces.backend_v2. """ - port = 4900 - - def __init__(self) -> None: + # With tls, it serves HTTPS instead of HTTP: for senders that only speak HTTPS to the intake hostnames derived + # from DD_SITE (e.g. an SDK sending exposures directly). Its certificate is signed by the mitmproxy CA: these + # senders must trust it. See utils/mocked_backend/tls/README.md + tls_hostnames = ( + f"event-platform-intake.{MOCK_INTAKE_SITE}", + f"instrumentation-telemetry-intake.{MOCK_INTAKE_SITE}", + ) + tls_cert_file = "utils/mocked_backend/tls/mocked-backend.pem" + tls_ca_file = "utils/proxy/.mitmproxy/mitmproxy-ca-cert.pem" + + def __init__(self, *, tls: bool = False) -> None: + self.tls = tls + self.scheme = "https" if tls else "http" + # the default HTTPS port: senders build the intake URLs from DD_SITE, without port + self.port = 443 if tls else 4900 + # in the healthcheck, the certificate is checked against localhost + health_context = f"ssl.create_default_context(cafile='/app/{self.tls_ca_file}')" if tls else "None" super().__init__( # it has every dependency of utils/mocked_backend image_name="datadog/system-tests:proxy-v1", # a valid hostname: some HTTP clients reject an underscore name="mocked-backend", + # Docker rejects aliases on the default bridge network, used on GitLab (see _NETWORK_NAME). If a tls + # scenario runs there, map tls_hostnames to this container with extra_hosts on the senders instead + network_aliases=list(self.tls_hostnames) if tls else None, working_dir="/app", volumes={ # without utils/__init__.py: utils is a namespace package, and the rest of utils is not needed @@ -798,7 +827,7 @@ def __init__(self) -> None: # for the control endpoints, used from the host. Docker picks a free host port ports={f"{self.port}/tcp": ("127.0.0.1", None)}, healthcheck={ - "test": f"python -c \"import urllib.request; print(urllib.request.urlopen('http://localhost:{self.port}/system-tests/health', timeout=2).read().decode())\"", # noqa: E501 + "test": f"python -c \"import ssl, urllib.request; print(urllib.request.urlopen('{self.scheme}://localhost:{self.port}/system-tests/health', timeout=2, context={health_context}).read().decode())\"", # noqa: E501 "retries": 30, }, ) @@ -809,18 +838,30 @@ def configure(self, *, host_log_folder: str, replay: bool): # Same relative path in the container and on the host, so recorded log filenames are the same log_folder = f"{host_log_folder}/interfaces/backend_v2" self.volumes[f"./{log_folder}"] = {"bind": f"/app/{log_folder}", "mode": "rw"} - self.command = ["python", "-m", "utils.mocked_backend", "--port", str(self.port), "--log-folder", log_folder] + self.command = [ + "python", + "-m", + "utils.mocked_backend", + "--port", + str(self.port), + "--log-folder", + log_folder, + ] + if self.tls: + self.command += ["--tls-cert-file", self.tls_cert_file] @property def container_url(self) -> str: """The URL other containers use to reach the mocked backend""" - return f"http://{self.name}:{self.port}" + return f"{self.scheme}://{self.name}:{self.port}" @property def client(self) -> MockedBackendClient: """Client of the control endpoints, from the host""" host_port = self.runtime_container.attrs["NetworkSettings"]["Ports"][f"{self.port}/tcp"][0]["HostPort"] - return MockedBackendClient(f"http://127.0.0.1:{host_port}") + return MockedBackendClient( + f"{self.scheme}://127.0.0.1:{host_port}", verify=self.tls_ca_file if self.tls else True + ) class LambdaProxyContainer(TestedContainer): @@ -956,8 +997,8 @@ def __init__(self) -> None: name="ffe-serverless-init", image_name="datadog/serverless-init:1.10.4", environment={ - "DD_API_KEY": FAKE_DD_API_KEY, - "DD_SITE": "mock-intake.invalid", + "DD_API_KEY": SERVERLESS_INIT_FAKE_DD_API_KEY, + "DD_SITE": MOCK_INTAKE_SITE, "DD_SERVICE": "ffe-system-tests-serverless-init", "DD_ENV": "system-tests", "DD_APM_ENABLED": "true", @@ -1810,7 +1851,6 @@ def __init__(self) -> None: command="uvicorn app:app --host 0.0.0.0 --port 8089", volumes={ "./utils/build/docker/internal_server/app.py": {"bind": "/app/app.py", "mode": "ro"}, - "./utils/build/docker/internal_server/app.sh": {"bind": "/app/app.sh", "mode": "ro"}, }, ) diff --git a/utils/interfaces/_backend_v2.py b/utils/interfaces/_backend_v2.py index 1cb8193d627..89214fadd9a 100644 --- a/utils/interfaces/_backend_v2.py +++ b/utils/interfaces/_backend_v2.py @@ -33,7 +33,17 @@ def ingest_all_files(self) -> None: # same path as given by the watchdog, so files are not ingested twice self.ingest_file(str(file)) - def get_traces(self, request: HttpResponse | None = None) -> Generator[tuple[dict, DataDogAgentTrace]]: + def get_data(self, path_filters: Iterable[str] | str | None = None, *, with_api_key: str | None = None): + """Same as other interfaces. with_api_key: only requests sent with this API key. Each sender (agent, library, + serverless-init...) uses its own key, it tells where a request comes from. + """ + for data in super().get_data(path_filters=path_filters): + if with_api_key is None or _get_api_key(data) == with_api_key: + yield data + + def get_traces( + self, request: HttpResponse | None = None, *, with_api_key: str | None = None + ) -> Generator[tuple[dict, DataDogAgentTrace]]: """Attempts to fetch the traces the agent submitted to the mocked backend. When a valid request is given, then we filter the spans to the ones sampled @@ -46,7 +56,7 @@ def get_traces(self, request: HttpResponse | None = None) -> Generator[tuple[dic if rid: logger.debug(f"Will try to find backend_v2 spans related to request {rid}") - for data in self.get_data(path_filters="/api/v0.2/traces"): + for data in self.get_data(path_filters="/api/v0.2/traces", with_api_key=with_api_key): logger.debug(f"Looking at backend_v2 data {data['log_filename']}") if "content" not in data["request"]: @@ -78,7 +88,9 @@ def get_traces(self, request: HttpResponse | None = None) -> Generator[tuple[dic yield data, trace break - def get_spans(self, request: HttpResponse | None = None) -> Generator[tuple[dict, DataDogAgentSpan], None, None]: + def get_spans( + self, request: HttpResponse | None = None, *, with_api_key: str | None = None + ) -> Generator[tuple[dict, DataDogAgentSpan], None, None]: """Attempts to fetch the spans the agent submitted to the mocked backend. When a valid request is given, then we filter the spans to the ones sampled @@ -91,18 +103,20 @@ def get_spans(self, request: HttpResponse | None = None) -> Generator[tuple[dict if rid: logger.debug(f"Will try to find backend_v2 spans related to request {rid}") - for data, trace in self.get_traces(request=request): + for data, trace in self.get_traces(request=request, with_api_key=with_api_key): for span in trace.spans: if rid is None or span.get_rid() == rid: yield data, span - def get_spans_list(self, request: HttpResponse | None = None) -> list[DataDogAgentSpan]: - return [span for _, span in self.get_spans(request)] + def get_spans_list( + self, request: HttpResponse | None = None, *, with_api_key: str | None = None + ) -> list[DataDogAgentSpan]: + return [span for _, span in self.get_spans(request, with_api_key=with_api_key)] def get_appsec_data( - self, request: HttpResponse + self, request: HttpResponse, *, with_api_key: str | None = None ) -> Generator[tuple[dict[str, Any], DataDogAgentSpan, dict[str, Any]], Any, None]: - for data, span in self.get_spans(request): + for data, span in self.get_spans(request, with_api_key=with_api_key): json_data = span.meta.get("_dd.appsec.json") if json_data is not None: yield data, span, json_data @@ -111,11 +125,11 @@ def get_appsec_data( elif (v1_metastruct_data := span.meta.get("appsec")) is not None: yield data, span, v1_metastruct_data - def get_profiling_data(self): - yield from self.get_data(path_filters="/api/v2/profile") + def get_profiling_data(self, *, with_api_key: str | None = None): + yield from self.get_data(path_filters="/api/v2/profile", with_api_key=with_api_key) - def get_telemetry_data(self, *, flatten_message_batches: bool = True): - all_data = self.get_data(path_filters="/api/v2/apmtelemetry") + def get_telemetry_data(self, *, flatten_message_batches: bool = True, with_api_key: str | None = None): + all_data = self.get_data(path_filters="/api/v2/apmtelemetry", with_api_key=with_api_key) if flatten_message_batches: yield from all_data else: @@ -131,8 +145,8 @@ def get_telemetry_data(self, *, flatten_message_batches: bool = True): else: yield data - def assert_trace_exists(self, request: HttpResponse): - for _, _ in self.get_traces(request=request): + def assert_trace_exists(self, request: HttpResponse, *, with_api_key: str | None = None): + for _, _ in self.get_traces(request=request, with_api_key=with_api_key): return raise ValueError(f"No trace has been found for request {request.get_rid()}") @@ -160,10 +174,12 @@ def get_span_metrics(span: DataDogAgentSpan) -> dict[str, str]: raise ValueError(f"Unknown span format: {span.trace.format}") - def get_metrics(self): + def get_metrics(self, *, with_api_key: str | None = None): """Attempts to fetch the metrics the agent submitted to the mocked backend.""" - for data in self.get_data(path_filters=("/api/v2/series", "/api/intake/metrics/v3/series")): + for data in self.get_data( + path_filters=("/api/v2/series", "/api/intake/metrics/v3/series"), with_api_key=with_api_key + ): content = data["request"]["content"] assert isinstance(content, dict), f"content is not a dict in {data['log_filename']}" @@ -178,10 +194,10 @@ def get_metrics(self): for point in series: yield data, point - def get_sketches(self): + def get_sketches(self, *, with_api_key: str | None = None): """Attempts to fetch the sketches the agent submitted to the mocked backend.""" - all_data = self.get_data(path_filters="/api/beta/sketches") + all_data = self.get_data(path_filters="/api/beta/sketches", with_api_key=with_api_key) for data in all_data: if "sketches" in data["request"]["content"]: @@ -190,10 +206,10 @@ def get_sketches(self): for point in content: yield data, point - def get_dsm_data(self): - return self.get_data(path_filters="/api/v0.1/pipeline_stats") + def get_dsm_data(self, *, with_api_key: str | None = None): + return self.get_data(path_filters="/api/v0.1/pipeline_stats", with_api_key=with_api_key) - def get_stats(self, resource: str | list[str] = ""): + def get_stats(self, resource: str | list[str] = "", *, with_api_key: str | None = None): """Attempts to fetch the stats the agent submitted to the mocked backend. When a valid request is given, then we filter the stats to the ones sampled @@ -201,7 +217,7 @@ def get_stats(self, resource: str | list[str] = ""): """ resources = [resource] if isinstance(resource, str) else resource - for data in self.get_data(path_filters="/api/v0.2/stats"): + for data in self.get_data(path_filters="/api/v0.2/stats", with_api_key=with_api_key): client_stats_payloads = data["request"]["content"]["Stats"] for client_stats_payload in client_stats_payloads: @@ -209,3 +225,12 @@ def get_stats(self, resource: str | list[str] = ""): for client_grouped_stat in client_stats_buckets["Stats"]: if not resources or "" in resources or client_grouped_stat["Resource"] in resources: yield client_grouped_stat + + +def _get_api_key(data: dict) -> str | None: + """The API key a request was sent with""" + for name, value in data["request"]["headers"]: + if name.lower() == "dd-api-key": + return value + + return None diff --git a/utils/mocked_backend/__main__.py b/utils/mocked_backend/__main__.py index 83b81b95502..973ada3c908 100644 --- a/utils/mocked_backend/__main__.py +++ b/utils/mocked_backend/__main__.py @@ -20,6 +20,7 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="python -m utils.mocked_backend", description=__doc__.splitlines()[0]) parser.add_argument("--port", type=int, default=4900, help="port to listen on, 0 picks a free one (default: 4900)") parser.add_argument("--log-folder", help="folder where each received request is recorded (default: not recorded)") + parser.add_argument("--tls-cert-file", help="serve HTTPS with this certificate and its key (default: HTTP)") args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s") @@ -35,7 +36,11 @@ def stop(_signum: int, _frame: FrameType | None) -> None: signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) - server = MockedBackendServer(args.log_folder, port=args.port) + server = MockedBackendServer( + args.log_folder, + port=args.port, + tls_cert_file=args.tls_cert_file, + ) # read by whoever started the process when the port is picked by the OS print(f"Mocked backend listening on port {server.port}", flush=True) # noqa: T201 try: diff --git a/utils/mocked_backend/_backend.py b/utils/mocked_backend/_backend.py index e90b8ab5c50..56e3e34d7fc 100644 --- a/utils/mocked_backend/_backend.py +++ b/utils/mocked_backend/_backend.py @@ -9,6 +9,7 @@ from __future__ import annotations from http import HTTPStatus +import ssl import threading from typing import TYPE_CHECKING @@ -42,8 +43,26 @@ def __init__( on_message: Callable[[dict], None] | None = None, *, port: int = _DEFAULT_PORT, + tls_cert_file: str | None = None, ) -> None: + """With tls_cert_file (a certificate and its key), it serves HTTPS instead of HTTP: for senders that only speak + HTTPS to the intake hostnames derived from DD_SITE. + + HTTPS is meant for the mocked backend container (MockedBackendContainer(tls=True)): the certificate covers its + hostnames, not host.docker.internal, so the URLs this server advertises to containers can't be verified. + """ super().__init__(("0.0.0.0", port), MockBackendV2RequestHandler) # noqa: S104 + self.scheme = "https" if tls_cert_file else "http" + if tls_cert_file: + try: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls_cert_file) + # the handshake runs in each request thread, not in the accepting one: an idle client blocks nothing + self.socket = context.wrap_socket(self.socket, server_side=True, do_handshake_on_connect=False) + except Exception: + # the caller never gets a server to close: release the port now + self.server_close() + raise self.log_folder = log_folder self.on_message = on_message if on_message else lambda _: None self.message_count = 0 @@ -61,17 +80,18 @@ def __init__( @property def base_url(self) -> str: - return f"http://127.0.0.1:{self.port}" + return f"{self.scheme}://127.0.0.1:{self.port}" @property def client(self) -> MockedBackendClient: """Client of the control endpoints of this server""" - return MockedBackendClient(self.base_url) + # over HTTPS, it only talks to itself on the loopback: nothing to verify + return MockedBackendClient(self.base_url, verify=self.scheme == "http") @property def container_url(self) -> str: """The URL a container can use to reach the mock server on the docker host.""" - return f"http://{HOST_DOCKER_INTERNAL}:{self.port}" + return f"{self.scheme}://{HOST_DOCKER_INTERNAL}:{self.port}" def add_handler(self, method: HttpMethod, path: str, handler: RouteHandler) -> None: """Register a handler for an exact (method, path) pair. ``path`` is matched without its diff --git a/utils/mocked_backend/_backend_ffe.py b/utils/mocked_backend/_backend_ffe.py index 10c509972ea..69e7dfbbfa7 100644 --- a/utils/mocked_backend/_backend_ffe.py +++ b/utils/mocked_backend/_backend_ffe.py @@ -305,7 +305,7 @@ def ffe_library_base_url(self) -> str: host = os.environ.get("SYSTEM_TESTS_MOCK_FFE_AGENTLESS_BACKEND_HOST") or os.environ.get( "SYSTEM_TESTS_MOCK_AGENTLESS_BACKEND_HOST", "host.docker.internal" ) - return f"http://{host}:{self.port}" + return f"{self.scheme}://{host}:{self.port}" @property def ffe_library_config_url(self) -> str: diff --git a/utils/mocked_backend/_client.py b/utils/mocked_backend/_client.py index ae180e0358b..aa23539a016 100644 --- a/utils/mocked_backend/_client.py +++ b/utils/mocked_backend/_client.py @@ -15,11 +15,13 @@ class MockedBackendClient: - def __init__(self, base_url: str) -> None: + def __init__(self, base_url: str, *, verify: bool | str = True) -> None: + """verify: as in requests, e.g. the CA file trusting the certificate of an HTTPS server""" self.base_url = base_url.rstrip("/") + self.verify = verify def ffe_reset(self) -> None: - response = requests.post(f"{self.base_url}{_FFE_CONTROL_PATH}/reset", timeout=5) + response = requests.post(f"{self.base_url}{_FFE_CONTROL_PATH}/reset", timeout=5, verify=self.verify) response.raise_for_status() def ffe_set_response(self, response_id: str, *, response_format: ResponseFormat = "json") -> None: @@ -31,10 +33,11 @@ def ffe_set_responses(self, responses: object, *, response_format: ResponseForma f"{self.base_url}{_FFE_CONTROL_PATH}/responses", json={"responses": validated_responses, "response_format": response_format}, timeout=5, + verify=self.verify, ) response.raise_for_status() def ffe_get_status(self) -> MockedBackendFFEStatus: - response = requests.get(f"{self.base_url}{_FFE_CONTROL_PATH}/status", timeout=5) + response = requests.get(f"{self.base_url}{_FFE_CONTROL_PATH}/status", timeout=5, verify=self.verify) response.raise_for_status() return cast("MockedBackendFFEStatus", response.json()) diff --git a/utils/mocked_backend/_request_handler.py b/utils/mocked_backend/_request_handler.py index b84650be8a1..156afa7a8a9 100644 --- a/utils/mocked_backend/_request_handler.py +++ b/utils/mocked_backend/_request_handler.py @@ -57,6 +57,7 @@ def _decode_content(raw_body: bytes, content_encoding: str) -> bytes: class MockedBackendServerCore(ABC, ThreadingHTTPServer): on_message: Callable[[dict], None] port: int + scheme: str @abstractmethod def get_handler(self, method: HttpMethod, path: str) -> tuple[RouteHandler, str | None, str | None]: ... diff --git a/utils/mocked_backend/tls/README.md b/utils/mocked_backend/tls/README.md new file mode 100644 index 00000000000..0ec22a45255 --- /dev/null +++ b/utils/mocked_backend/tls/README.md @@ -0,0 +1,13 @@ +`mocked-backend.pem` holds the certificate, and its key, the mocked backend uses when it serves HTTPS +(`--tls-cert-file`). It is signed by the mitmproxy CA (`utils/proxy/.mitmproxy`), trusted by the senders of HTTPS +scenarios. It covers the container name, the intake hostnames derived from the mock `DD_SITE`, and `localhost`. + +To add a hostname, or before it expires (in 2036), regenerate it: + +```bash +openssl req -new -newkey rsa:2048 -nodes -keyout key.pem -subj "/CN=mocked-backend" -out req.csr +echo "subjectAltName=DNS:mocked-backend,DNS:event-platform-intake.mock-intake.invalid,DNS:instrumentation-telemetry-intake.mock-intake.invalid,DNS:localhost,IP:127.0.0.1" > ext +openssl x509 -req -in req.csr -CA utils/proxy/.mitmproxy/mitmproxy-ca.pem -CAkey utils/proxy/.mitmproxy/mitmproxy-ca.pem \ + -set_serial 0x$(openssl rand -hex 8) -days 3650 -sha256 -extfile ext -out cert.pem +cat cert.pem key.pem > utils/mocked_backend/tls/mocked-backend.pem +``` diff --git a/utils/mocked_backend/tls/mocked-backend.pem b/utils/mocked_backend/tls/mocked-backend.pem new file mode 100644 index 00000000000..bcfa6d06eb8 --- /dev/null +++ b/utils/mocked_backend/tls/mocked-backend.pem @@ -0,0 +1,48 @@ +-----BEGIN CERTIFICATE----- +MIIDVzCCAj+gAwIBAgIISpCwLXrcV+EwDQYJKoZIhvcNAQELBQAwKDESMBAGA1UE +AwwJbWl0bXByb3h5MRIwEAYDVQQKDAltaXRtcHJveHkwHhcNMjYxMDI2MTgyNTM2 +WhcNMzYxMDIzMTgyNTM2WjAZMRcwFQYDVQQDDA5tb2NrZWQtYmFja2VuZDCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALX9Yo6kd1HjWiMHS3Ym3AJkXzBw +BzmpR+1fdXbRsl+SonAnH9Iy4dAkMqxfbRqEk+hhDfbapRGMxmpl0JGo0WG2YnGg +jXhMqPIubA82mwIItQPKy2uICJar4t0eciLXoA2mEjgrbCsLfu6zhJLIOnbyVhHx +lnJwxxUPrEaT47rq0aGV3QpMRzq+yEj00xlHG386KsQIrzq9no4kTu5vPSrWfBR9 +XpiQWzMMv1MBJmqZymdFWn3oyy9iDqN2YoK6rf3XdfhHTS5dR5nPKkkzpUiXl2zy +0DC28pST1ikUTLRSLAt3Wb7SDVXBm292LjC/BD2+s3VXciD2eb/wDMAss48CAwEA +AaOBkzCBkDCBjQYDVR0RBIGFMIGCgg5tb2NrZWQtYmFja2VuZIIpZXZlbnQtcGxh +dGZvcm0taW50YWtlLm1vY2staW50YWtlLmludmFsaWSCNGluc3RydW1lbnRhdGlv +bi10ZWxlbWV0cnktaW50YWtlLm1vY2staW50YWtlLmludmFsaWSCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAFHlXGhYckM6+v1Fmk8exFO0ebNWl +UURZ7dXBBOY0HB3vyPbD8Lp90iKLq9uj4YATsCoCReIMtYe1KvmH0pp1OHNnjYn7 +DQruvaZgN8et6qQKT0NGl687RuvuVSWk6jf4Uo5f5D+M5IRymcFoVTxkcCpL0VV9 +lS1mM3FwwveivzM4GtHfpsAAESBhppLCGU1Y9mn4m1mZVNR6c2iru281zR174bdG +aNTsMZ8+iH1vM32plUU3zAxPela60vcLEaRfQw+THqsLSS7KVJk8CPurAPufEGV0 +s1ToBDPOc88Z1a6DfLs1mHc+FJCHanpn4nQ6rmPucX1cZGeLDY5dl0mBXA== +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC1/WKOpHdR41oj +B0t2JtwCZF8wcAc5qUftX3V20bJfkqJwJx/SMuHQJDKsX20ahJPoYQ322qURjMZq +ZdCRqNFhtmJxoI14TKjyLmwPNpsCCLUDystriAiWq+LdHnIi16ANphI4K2wrC37u +s4SSyDp28lYR8ZZycMcVD6xGk+O66tGhld0KTEc6vshI9NMZRxt/OirECK86vZ6O +JE7ubz0q1nwUfV6YkFszDL9TASZqmcpnRVp96MsvYg6jdmKCuq3913X4R00uXUeZ +zypJM6VIl5ds8tAwtvKUk9YpFEy0UiwLd1m+0g1VwZtvdi4wvwQ9vrN1V3Ig9nm/ +8AzALLOPAgMBAAECggEAPxdY05qwwvLOjNY6kvtlym6BkYlE9/h3txMg5dBMxlVP +exIVNgntvvrNlnR+nnLX0/fRn4KtNyhWoQwrAlREQlu4RSwj31C0QUFWtO95TXs3 +RdlILD88uv9b5sxnRSz7j7/DETX8ie+yUY5qp3hCzHm23QGj5oiye9qzHsSmbmXj +xUNkmkSokVlQpLslDGcd2qt5sDBx3Zbs9yDUJkY4PH8FqIretr25ikj98gzWBEKk +MqnzLF6POIdl0XZc2qGw5mBTqUm0ddJ5OfnrImmPLj1gKM/GsyFnR+cb0tFl2CqB +25dkkoLgIyp9V1WhmGMMKn+HjcDIb4YgIs0UEEqCAQKBgQDnEVTU4FzkVfwhQFik +bZRQhYsNHqxJcBL5rCP+9S2u+rdFgQ1ClMWCD+tpDILIVLEIlEVgj9+q3iBGnqfM +kYfMPox4Bi+czKTYGtpddhAeieDxsYSn4uNT3qpjuaEma+/E2KrPXXOtS8BljezS +PgvbpMk/dHdlDn9+P3twiwG9XQKBgQDJoGYPCnY0flbSUsDZfiFeyUNwQ4i5SMPD +u6BGhNuIY2ug211HQ6b4DjBjT78tk9QcFefAtZaESzurnV5KCEqMkDnAiqP5OInA +18y4UGG0oQF7ef0gVr/aGUPoDCVZvE0yfMpgFTSzQtqf3beXSHYsDO2tizzTjw4+ +Gv0Bjj452wKBgH/jztEX8TIMCUWaKLWxVMKQd1rmnDL1TftUdIPbzdIoQCG/AMjW +MM8A4qm82/HnNijqb8+3MfOPh8uuam9GKSQ5hmO0AMxHVAGsMypBe0B7zb3YI/UM +FghImIQjrPboDMglp7yoG6tR3wYWXHotD0EdXqm5W4/aS2Rj3H144pidAoGAKTtN +46GGDrIPWy9lFJUAifuqeNi2qv1AW1SRMBqXMkryhZCWgK5LBMsSBh0WwWFLrPql +y/jWcPNZnnyt/YG4jQopZUB0y9BOC0uWJ9L4SBHs1a1aoDRdQ2KLSnbBZJpnRv3R +/NgZUNdJEgwrMRq2X8e9CxJAPBszFGw8dQb7nqUCgYByr+C1nsIos59/ZsQorO8k +0qddrTqdrEZwYA9zvmci2aHPBXYnD5eGl3xPVyE5Wu8fxXlZRFGmpmIZcWYHH+TT +IclLv8UpOYd81NANb4Dtt+4Y9gIkR0e9b7TT50RTEykoZv4rCvslkaFaNklEe+dU +DcM+GP3FlyA8uxVuIg/4Ng== +-----END PRIVATE KEY-----