Skip to content

Commit 44f78c3

Browse files
authored
Add support for mTLS authentication with OpAMP (#419)
* opamp: extend client for mTLS * distro: get mTLS configuration from env vars * Add documentation * Aplly feedback from review
1 parent e28bbdd commit 44f78c3

File tree

9 files changed

+248
-10
lines changed

9 files changed

+248
-10
lines changed

docs/reference/edot-python/configuration.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,31 @@ product:
6767

6868
If the OpAMP server is configured to require authentication set the `ELASTIC_OTEL_OPAMP_HEADERS` environment variable.
6969

70-
```
70+
```sh
7171
export ELASTIC_OTEL_OPAMP_HEADERS="Authorization=ApiKey an_api_key"
7272
```
7373

74+
### Configure mTLS for Central configuration
75+
76+
```{applies_to}
77+
serverless: unavailable
78+
stack: preview 9.1
79+
product:
80+
edot_python: preview 1.10.0
81+
```
82+
83+
If the OpAMP Central configuration server requires mutual TLS to encrypt data in transit you need to set the following environment variables:
84+
85+
- `ELASTIC_OTEL_OPAMP_CERTIFICATE`: The path of the trusted certificate to use when verifying a server’s TLS credentials, this may also be used if the server is using a self-signed certificate.
86+
- `ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE`: Client certificate/chain trust for clients private key path to use in mTLS communication in PEM format.
87+
- `ELASTIC_OTEL_OPAMP_CLIENT_KEY`: Client private key path to use in mTLS communication in PEM format.
88+
89+
```sh
90+
export ELASTIC_OTEL_OPAMP_CERTIFICATE=/path/to/rootCA.pem
91+
export ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE=/path/to/client.pem
92+
export ELASTIC_OTEL_OPAMP_CLIENT_KEY=/path/to/client-key.pem
93+
```
94+
7495
### Central configuration settings
7596

7697
You can modify the following settings for EDOT Python through APM Agent Central Configuration:

src/elasticotel/distro/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@
6060
ELASTIC_OTEL_OPAMP_ENDPOINT,
6161
ELASTIC_OTEL_OPAMP_HEADERS,
6262
ELASTIC_OTEL_SYSTEM_METRICS_ENABLED,
63+
ELASTIC_OTEL_OPAMP_CERTIFICATE,
64+
ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE,
65+
ELASTIC_OTEL_OPAMP_CLIENT_KEY,
6366
)
6467
from elasticotel.distro.resource_detectors import get_cloud_resource_detectors
6568
from elasticotel.distro.config import opamp_handler, _initialize_config, DEFAULT_SAMPLING_RATE
@@ -129,10 +132,17 @@ def _configure(self, **kwargs):
129132
else:
130133
headers = None
131134

135+
# If string is a path to the certificate, if bool means to check the server certificate. Behaviour inherited from requests
136+
tls_certificate: str | bool = os.environ.get(ELASTIC_OTEL_OPAMP_CERTIFICATE, True)
137+
tls_client_certificate: str | None = os.environ.get(ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE)
138+
tls_client_key: str | None = os.environ.get(ELASTIC_OTEL_OPAMP_CLIENT_KEY)
132139
opamp_client = OpAMPClient(
133140
endpoint=endpoint_url,
134141
agent_identifying_attributes=agent_identifying_attributes,
135142
headers=headers,
143+
tls_certificate=tls_certificate,
144+
tls_client_certificate=tls_client_certificate,
145+
tls_client_key=tls_client_key,
136146
)
137147
opamp_agent = OpAMPAgent(
138148
interval=30,

src/elasticotel/distro/environment_variables.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,30 @@
4040
4141
**Default value:** ``not set``
4242
"""
43+
44+
ELASTIC_OTEL_OPAMP_CERTIFICATE = "ELASTIC_OTEL_OPAMP_CERTIFICATE"
45+
"""
46+
.. envvar:: ELASTIC_OTEL_OPAMP_CERTIFICATE
47+
48+
The path of the trusted certificate to use when verifying a server’s TLS credentials, this is needed for mTLS or when the server is using a self-signed certificate.
49+
50+
**Default value:** ``not set``
51+
"""
52+
53+
ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE = "ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE"
54+
"""
55+
.. envvar:: ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE
56+
57+
Client certificate/chain trust for clients private key path to use in mTLS communication in PEM format.
58+
59+
**Default value:** ``not set``
60+
"""
61+
62+
ELASTIC_OTEL_OPAMP_CLIENT_KEY = "ELASTIC_OTEL_OPAMP_CLIENT_KEY"
63+
"""
64+
.. envvar:: ELASTIC_OTEL_OPAMP_CLIENT_KEY
65+
66+
Client private key path to use in mTLS communication in PEM format.
67+
68+
**Default value:** ``not set``
69+
"""

src/opentelemetry/_opamp/client.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,20 @@ def __init__(
6060
timeout_millis: int = _DEFAULT_OPAMP_TIMEOUT_MS,
6161
agent_identifying_attributes: Mapping[str, AnyValue],
6262
agent_non_identifying_attributes: Mapping[str, AnyValue] | None = None,
63+
# this matches requests but can be mapped to other http libraries APIs
64+
tls_certificate: str | bool = True,
65+
tls_client_certificate: str | None = None,
66+
tls_client_key: str | None = None,
6367
):
6468
self._timeout_millis = timeout_millis
6569
self._transport = RequestsTransport()
6670

6771
self._endpoint = endpoint
6872
headers = headers or {}
6973
self._headers = {**_OPAMP_HTTP_HEADERS, **headers}
74+
self._tls_certificate = tls_certificate
75+
self._tls_client_certificate = tls_client_certificate
76+
self._tls_client_key = tls_client_key
7077

7178
self._agent_description = messages._build_agent_description(
7279
identifying_attributes=agent_identifying_attributes,
@@ -154,7 +161,13 @@ def _send(self, data: bytes):
154161
token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True))
155162
try:
156163
response = self._transport.send(
157-
url=self._endpoint, headers=self._headers, data=data, timeout_millis=self._timeout_millis
164+
url=self._endpoint,
165+
headers=self._headers,
166+
data=data,
167+
timeout_millis=self._timeout_millis,
168+
tls_certificate=self._tls_certificate,
169+
tls_client_certificate=self._tls_client_certificate,
170+
tls_client_key=self._tls_client_key,
158171
)
159172
return response
160173
finally:

src/opentelemetry/_opamp/transport/base.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17+
from __future__ import annotations
18+
1719
import abc
1820
from typing import Mapping
1921

@@ -27,5 +29,15 @@
2729

2830
class HttpTransport(abc.ABC):
2931
@abc.abstractmethod
30-
def send(self, url: str, headers: Mapping[str, str], data: bytes, timeout_millis: int) -> opamp_pb2.ServerToAgent:
32+
def send(
33+
self,
34+
*,
35+
url: str,
36+
headers: Mapping[str, str],
37+
data: bytes,
38+
timeout_millis: int,
39+
tls_certificate: str | bool,
40+
tls_client_certificate: str | None = None,
41+
tls_client_key: str | None = None,
42+
) -> opamp_pb2.ServerToAgent:
3143
pass

src/opentelemetry/_opamp/transport/requests.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17+
from __future__ import annotations
18+
1719
import logging
1820
from typing import Mapping
1921

@@ -32,11 +34,28 @@ def __init__(self):
3234
self.session = requests.Session()
3335

3436
# TODO: support basic-auth?
35-
def send(self, url: str, headers: Mapping[str, str], data: bytes, timeout_millis: int):
37+
def send(
38+
self,
39+
*,
40+
url: str,
41+
headers: Mapping[str, str],
42+
data: bytes,
43+
timeout_millis: int,
44+
tls_certificate: str | bool,
45+
tls_client_certificate: str | None = None,
46+
tls_client_key: str | None = None,
47+
):
3648
headers = {**base_headers, **headers}
3749
timeout: float = timeout_millis / 1e3
50+
client_cert = (
51+
(tls_client_certificate, tls_client_key)
52+
if tls_client_certificate and tls_client_key
53+
else tls_client_certificate
54+
)
3855
try:
39-
response = self.session.post(url, headers=headers, data=data, timeout=timeout)
56+
response = self.session.post(
57+
url, headers=headers, data=data, timeout=timeout, verify=tls_certificate, cert=client_cert
58+
)
4059
response.raise_for_status()
4160
except Exception as exc:
4261
logger.error(str(exc))

tests/distro/test_distro.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121

2222
from elasticotel.distro import ElasticOpenTelemetryConfigurator, ElasticOpenTelemetryDistro, logger as distro_logger
2323
from elasticotel.distro.config import opamp_handler, logger as config_logger, Config
24-
from elasticotel.distro.environment_variables import ELASTIC_OTEL_OPAMP_ENDPOINT, ELASTIC_OTEL_SYSTEM_METRICS_ENABLED
24+
from elasticotel.distro.environment_variables import (
25+
ELASTIC_OTEL_OPAMP_ENDPOINT,
26+
ELASTIC_OTEL_OPAMP_CERTIFICATE,
27+
ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE,
28+
ELASTIC_OTEL_OPAMP_CLIENT_KEY,
29+
ELASTIC_OTEL_SYSTEM_METRICS_ENABLED,
30+
)
2531
from elasticotel.sdk.sampler import DefaultSampler
2632
from opentelemetry.environment_variables import (
2733
OTEL_LOGS_EXPORTER,
@@ -162,6 +168,9 @@ def test_configurator_sets_up_opamp_with_http_endpoint(self, client_mock, agent_
162168
endpoint="http://localhost:4320/v1/opamp",
163169
agent_identifying_attributes={"service.name": "service", "deployment.environment.name": "dev"},
164170
headers=None,
171+
tls_certificate=True,
172+
tls_client_certificate=None,
173+
tls_client_key=None,
165174
)
166175
agent_mock.assert_called_once_with(interval=30, message_handler=opamp_handler, client=client_mock)
167176
agent_mock.start.assert_called_once_with()
@@ -186,6 +195,9 @@ def test_configurator_sets_up_opamp_with_https_endpoint(self, client_mock, agent
186195
endpoint="https://localhost:4320/v1/opamp",
187196
agent_identifying_attributes={"service.name": "service", "deployment.environment.name": "dev"},
188197
headers=None,
198+
tls_certificate=True,
199+
tls_client_certificate=None,
200+
tls_client_key=None,
189201
)
190202
agent_mock.assert_called_once_with(interval=30, message_handler=opamp_handler, client=client_mock)
191203
agent_mock.start.assert_called_once_with()
@@ -211,6 +223,9 @@ def test_configurator_sets_up_opamp_with_headers_from_environment_variable(self,
211223
endpoint="http://localhost:4320/v1/opamp",
212224
agent_identifying_attributes={"service.name": "service", "deployment.environment.name": "dev"},
213225
headers={"authorization": "ApiKey foobar==="},
226+
tls_certificate=True,
227+
tls_client_certificate=None,
228+
tls_client_key=None,
214229
)
215230
agent_mock.assert_called_once_with(interval=30, message_handler=opamp_handler, client=client_mock)
216231
agent_mock.start.assert_called_once_with()
@@ -235,6 +250,9 @@ def test_configurator_adds_path_to_opamp_endpoint_if_missing(self, client_mock,
235250
endpoint="https://localhost:4320/v1/opamp",
236251
agent_identifying_attributes={"service.name": "service", "deployment.environment.name": "dev"},
237252
headers=None,
253+
tls_certificate=True,
254+
tls_client_certificate=None,
255+
tls_client_key=None,
238256
)
239257
agent_mock.assert_called_once_with(interval=30, message_handler=opamp_handler, client=client_mock)
240258
agent_mock.start.assert_called_once_with()
@@ -259,6 +277,39 @@ def test_configurator_sets_up_opamp_without_deployment_environment_name(self, cl
259277
endpoint="https://localhost:4320/v1/opamp",
260278
agent_identifying_attributes={"service.name": "service"},
261279
headers=None,
280+
tls_certificate=True,
281+
tls_client_certificate=None,
282+
tls_client_key=None,
283+
)
284+
agent_mock.assert_called_once_with(interval=30, message_handler=opamp_handler, client=client_mock)
285+
agent_mock.start.assert_called_once_with()
286+
287+
@mock.patch.dict(
288+
"os.environ",
289+
{
290+
ELASTIC_OTEL_OPAMP_ENDPOINT: "https://localhost:4320/v1/opamp",
291+
ELASTIC_OTEL_OPAMP_CERTIFICATE: "server.pem",
292+
ELASTIC_OTEL_OPAMP_CLIENT_CERTIFICATE: "client.pem",
293+
ELASTIC_OTEL_OPAMP_CLIENT_KEY: "client.key",
294+
"OTEL_RESOURCE_ATTRIBUTES": "service.name=service",
295+
},
296+
clear=True,
297+
)
298+
@mock.patch("elasticotel.distro.OpAMPAgent")
299+
@mock.patch("elasticotel.distro.OpAMPClient")
300+
def test_configurator_sets_up_opamp_with_mTLS_variables(self, client_mock, agent_mock):
301+
client_mock.return_value = client_mock
302+
agent_mock.return_value = agent_mock
303+
304+
ElasticOpenTelemetryConfigurator()._configure()
305+
306+
client_mock.assert_called_once_with(
307+
endpoint="https://localhost:4320/v1/opamp",
308+
agent_identifying_attributes={"service.name": "service"},
309+
headers=None,
310+
tls_certificate="server.pem",
311+
tls_client_certificate="client.pem",
312+
tls_client_key="client.key",
262313
)
263314
agent_mock.assert_called_once_with(interval=30, message_handler=opamp_handler, client=client_mock)
264315
agent_mock.start.assert_called_once_with()

tests/opamp/test_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ def test_client_headers_override_defaults():
8888
headers={"Content-Type": "application/x-protobuf", "User-Agent": "Custom"},
8989
data=b"",
9090
timeout_millis=1000,
91+
tls_certificate=True,
92+
tls_client_certificate=None,
93+
tls_client_key=None,
9194
)
9295

9396

@@ -347,6 +350,9 @@ def test_send(client):
347350
headers={"Content-Type": "application/x-protobuf", "User-Agent": "OTel-OpAMP-Python/" + __version__},
348351
data=b"foo",
349352
timeout_millis=1000,
353+
tls_certificate=True,
354+
tls_client_certificate=None,
355+
tls_client_key=None,
350356
)
351357

352358

0 commit comments

Comments
 (0)