blob: 779ea71aaddf69ae99a7059b9983903bd400e939 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Collector TLS / mTLS helpers for gRPC and HTTP reporters.
Rules:
- TLS when FORCE_TLS is set or the trusted CA file exists.
- Custom CA is used only when that file exists *and* parses as a CA PEM
(otherwise system trust with FORCE_TLS, or plaintext without it).
- mTLS (client cert + key) is enabled only when a usable custom CA was loaded
*and* both cert-chain and key files exist and parse. Missing / invalid
cert/key logs a warning and stays one-way TLS — it does not fail agent start.
- A non-empty CA / cert / key path that cannot be resolved logs a warning
(misconfig must not look like "TLS off by design").
- Invalid PEM content, SSLContext build failures, and temp-file I/O errors
never raise into the host process: warn and degrade (see degrade table in
``ssl_context_for_collector`` / ``requests_tls_settings``).
- HTTP mTLS temp PEMs are process-local: after ``os.fork()`` the child rebinds
its temp-file list so exiting the child cannot unlink the parent's files.
"""
from __future__ import annotations
import atexit
import base64
import os
import ssl
import tempfile
from pathlib import Path
from typing import List, Optional, Tuple
from skywalking.loggings import logger
# Align with Node TlsMaterialCache: refuse oversized PEMs (DoS / misconfig).
_MAX_PEM_BYTES = 256 * 1024
# Avoid repeating the same misconfig warning on every reporter call.
_warned_keys: set = set()
def _warn_once(key: str, message: str, *args) -> None:
if key in _warned_keys:
return
_warned_keys.add(key)
logger.warning(message, *args)
def _configured_path(value: str) -> str:
return (value or '').strip()
def ssl_file_path(value: str) -> Optional[Path]:
"""
Return a readable regular file path, or None.
Paths are cwd-relative or absolute. Symlinks are followed (Kubernetes
secret mounts are typically ``ca.crt -> ..data/ca.crt``); the resolved
target must be a regular file.
Never raises: ``expanduser`` / ``resolve`` failures (including symlink
loops and unresolvable ``~user`` → ``RuntimeError``) degrade to None so
TLS misconfig cannot abort agent start.
"""
text = _configured_path(value)
if not text:
return None
try:
path = Path(text).expanduser()
resolved = path.resolve(strict=True)
if resolved.is_file():
return resolved
except (OSError, ValueError, RuntimeError):
# OSError: missing path / I/O. ValueError: embedded NUL. RuntimeError:
# expanduser ~user miss or symlink-loop resolve on some platforms.
return None
return None
def collector_uses_tls() -> bool:
from skywalking import config
force = bool(config.agent_force_tls)
ca_cfg = _configured_path(config.agent_ssl_trusted_ca_path)
ca_path = ssl_file_path(ca_cfg) if ca_cfg else None
if ca_cfg and ca_path is None:
if force:
_warn_once(
f'ca-missing:{ca_cfg}',
'SW_AGENT_SSL_TRUSTED_CA_PATH is set (%r) but is not a readable regular '
'file; custom CA is ignored. FORCE_TLS remains on with the process trust store.',
ca_cfg,
)
else:
_warn_once(
f'ca-missing:{ca_cfg}',
'SW_AGENT_SSL_TRUSTED_CA_PATH is set (%r) but is not a readable regular '
'file; TLS via custom CA is disabled and the collector stays plaintext '
'(set SW_AGENT_FORCE_TLS to use the process trust store).',
ca_cfg,
)
return force or ca_path is not None
def _read_bytes(path: Path) -> bytes:
size = os.path.getsize(path)
if size > _MAX_PEM_BYTES:
raise ValueError(
f'SSL PEM file exceeds {_MAX_PEM_BYTES} bytes: {path} ({size} bytes)'
)
return path.read_bytes()
def _load_trusted_ca(path: Path) -> bytes:
"""
Read and parse a trusted CA PEM.
Raises OSError / ValueError when the file is unreadable, oversized, or not
a CA bundle OpenSSL can load — callers degrade instead of handing garbage
to gRPC / requests (which may only fail at connect time).
Returns ASCII PEM with ``CERTIFICATE`` and/or ``TRUSTED CERTIFICATE`` blocks
extracted (labels and trust attributes preserved). UTF-8 BOM / preamble
accepted by ``cafile`` stay usable for ``cadata`` and temp verify files.
"""
data = _read_bytes(path)
try:
ssl.create_default_context(cafile=str(path))
except OSError as exc:
# ssl.SSLError subclasses OSError on CPython.
raise ValueError(f'Invalid trusted CA PEM {path}: {exc}') from exc
return _extract_ca_pem_blocks(data)
def _validate_client_cert_key(cert_pem: bytes, key_pem: bytes) -> None:
"""Raise OSError / ValueError when cert+key cannot load as a client identity."""
cert_path = key_path = None
try:
fd, cert_path = tempfile.mkstemp(suffix='.crt')
try:
os.write(fd, cert_pem)
finally:
os.close(fd)
fd, key_path = tempfile.mkstemp(suffix='.pem')
try:
os.write(fd, key_pem)
finally:
os.close(fd)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_cert_chain(cert_path, key_path)
finally:
for path in (cert_path, key_path):
if path is None:
continue
try:
os.unlink(path)
except OSError:
pass
# OpenSSL PKCS#1 PEM → PKCS#8 PEM for stacks that only accept PKCS#8.
# grpcio/BoringSSL and CPython ssl both accept PKCS#1 RSA PEMs directly; this
# wrap exists for HTTP stacks / older OpenSSL bindings that reject
# ``BEGIN RSA PRIVATE KEY`` and only load PKCS#8 ``BEGIN PRIVATE KEY``.
_PKCS1_PEM_HEADER = '-----BEGIN RSA PRIVATE KEY-----'
_PKCS1_PEM_FOOTER = '-----END RSA PRIVATE KEY-----'
_PKCS8_PEM_HEADER = '-----BEGIN PRIVATE KEY-----'
_PKCS8_PEM_FOOTER = '-----END PRIVATE KEY-----'
_ENCRYPTED_PEM_HEADER = '-----BEGIN ENCRYPTED PRIVATE KEY-----'
def normalize_private_key_pem(key_pem: bytes) -> bytes:
"""
Return PKCS#8 PEM bytes. PKCS#1 (`BEGIN RSA PRIVATE KEY`) is wrapped into
an unencrypted PKCS#8 PEM; other PEM/DER forms pass through.
Passphrase-encrypted PEMs (PKCS#8 encrypted or legacy OpenSSL Proc-Type)
are rejected with a clear error (not supported).
Only the bytes between the matching PKCS#1 BEGIN/END delimiters are
decoded — comments or ``openssl rsa -text`` preamble outside the block
must not corrupt the key (OpenSSL accepts such files).
"""
text = key_pem.decode('utf-8', errors='ignore')
if _ENCRYPTED_PEM_HEADER in text:
raise ValueError(
'Passphrase-encrypted private keys are not supported; '
'use an unencrypted PEM (PKCS#8 or PKCS#1 RSA)'
)
# Legacy OpenSSL ``openssl genrsa -aes256`` / ``rsa -des3`` output.
if 'Proc-Type:' in text and 'ENCRYPTED' in text.upper():
raise ValueError(
'Passphrase-encrypted private keys are not supported; '
'use an unencrypted PEM (PKCS#8 or PKCS#1 RSA)'
)
if _PKCS1_PEM_HEADER not in text:
return key_pem
start = text.find(_PKCS1_PEM_HEADER)
end = text.find(_PKCS1_PEM_FOOTER, start)
if start < 0 or end < 0:
raise ValueError('Invalid PKCS#1 private key PEM: missing BEGIN/END delimiters')
body = text[start + len(_PKCS1_PEM_HEADER):end]
body = body.replace('\r', '').replace('\n', '').replace(' ', '')
try:
pkcs1 = base64.b64decode(body, validate=False)
except ValueError as exc:
raise ValueError(f'Invalid PKCS#1 private key PEM: {exc}') from exc
pkcs1_length = len(pkcs1)
total_length = pkcs1_length + 22
# Fixed 2-byte length form (keys up to ~64KiB DER).
if total_length > 0xFFFF or pkcs1_length > 0xFFFF:
raise ValueError('PKCS#1 private key too large for PKCS#8 wrap')
# Hand-rolled PKCS#8 PrivateKeyInfo wrapping RSA PKCS#1 (AlgorithmIdentifier
# rsaEncryption). Verified byte-identical to cryptography for 2048/4096 RSA.
pkcs8_header = bytes([
0x30, 0x82, (total_length >> 8) & 0xFF, total_length & 0xFF,
0x02, 0x01, 0x00,
0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00,
0x04, 0x82, (pkcs1_length >> 8) & 0xFF, pkcs1_length & 0xFF,
])
encoded = base64.b64encode(pkcs8_header + pkcs1).decode('ascii')
# 64-char lines, matching typical PEM layout.
lines = [encoded[i:i + 64] for i in range(0, len(encoded), 64)]
pem = _PKCS8_PEM_HEADER + '\n' + '\n'.join(lines) + '\n' + _PKCS8_PEM_FOOTER + '\n'
return pem.encode('ascii')
# OpenSSL ``openssl x509 -trustout`` emits TRUSTED CERTIFICATE (with trust
# auxiliary). Keep that label — do not relabel as CERTIFICATE.
_CA_PEM_LABELS = ('TRUSTED CERTIFICATE', 'CERTIFICATE')
# Sentinel: optional ``material`` args mean "load via tls_pem_material()".
_MATERIAL_UNSET = object()
def _extract_ca_pem_blocks(data: bytes) -> bytes:
"""
Return ASCII PEM with ``CERTIFICATE`` / ``TRUSTED CERTIFICATE`` blocks only.
Preserves each block's PEM label and trust attributes. Strips UTF-8 BOM and
ignores preamble outside delimiters. ``TRUSTED CERTIFICATE`` is searched
first so its header is not confused with plain ``CERTIFICATE``.
"""
text = data.decode('utf-8', errors='ignore').lstrip('\ufeff')
found: List[Tuple[int, str]] = []
for label in _CA_PEM_LABELS:
header = f'-----BEGIN {label}-----'
footer = f'-----END {label}-----'
pos = 0
while True:
start = text.find(header, pos)
if start < 0:
break
end = text.find(footer, start)
if end < 0:
break
end += len(footer)
found.append((start, text[start:end].strip() + '\n'))
pos = end
if not found:
raise ValueError('No CERTIFICATE or TRUSTED CERTIFICATE PEM block found')
found.sort(key=lambda item: item[0])
return ''.join(block for _, block in found).encode('ascii')
def _mtls_material(*, ca_usable: bool) -> Tuple[Optional[bytes], Optional[bytes]]:
"""
Client certificate_chain and private_key PEM bytes, or (None, None).
mTLS is considered only when a custom CA was successfully loaded and parsed
(``ca_usable``). A CA path that exists but failed to load must not unlock
client certs for FORCE_TLS / system-trust fallback.
Private keys are normalized to PKCS#8 when the file is PKCS#1.
"""
from skywalking import config
cert_cfg = _configured_path(config.agent_ssl_cert_chain_path)
key_cfg = _configured_path(config.agent_ssl_key_path)
if not ca_usable:
# Client material without a usable CA cannot enable mTLS (by design).
if cert_cfg or key_cfg:
_warn_once(
f'mtls-without-ca:{cert_cfg}|{key_cfg}',
'Client SSL cert/key is configured but SW_AGENT_SSL_TRUSTED_CA_PATH '
'is missing, unreadable, or not a valid CA PEM; mTLS is disabled. '
'Provide a readable CA PEM to enable mTLS (FORCE_TLS alone uses '
'system trust for one-way TLS).',
)
return None, None
if bool(cert_cfg) ^ bool(key_cfg):
_warn_once(
f'mtls-partial:{cert_cfg}|{key_cfg}',
'Only one of SW_AGENT_SSL_CERT_CHAIN_PATH / SW_AGENT_SSL_KEY_PATH is set; '
'mTLS requires both. Staying on one-way TLS.',
)
return None, None
if not cert_cfg or not key_cfg:
return None, None
cert_path = ssl_file_path(cert_cfg)
key_path = ssl_file_path(key_cfg)
if cert_path is None or key_path is None:
logger.warning('Failed to enable mTLS caused by cert or key cannot be found.')
return None, None
try:
cert_pem = _read_bytes(cert_path)
key_pem = normalize_private_key_pem(_read_bytes(key_path))
_validate_client_cert_key(cert_pem, key_pem)
return cert_pem, key_pem
except (OSError, ValueError) as exc:
logger.warning('Failed to enable mTLS caused by cert or key read error: %s', exc)
return None, None
# Keep mTLS / CA temp PEM paths alive for the process (requests/ssl need file paths).
_mtls_temp_files: List[str] = []
_mtls_file_cache_key: Optional[Tuple[bytes, bytes]] = None
_mtls_file_cache: Optional[Tuple[str, str]] = None
_ca_file_cache_key: Optional[bytes] = None
_ca_file_cache: Optional[str] = None
_atexit_registered = False
def _cleanup_mtls_temp_files() -> None:
global _ca_file_cache, _ca_file_cache_key, _mtls_file_cache, _mtls_file_cache_key
for path in list(_mtls_temp_files):
try:
os.unlink(path)
except OSError:
pass
_mtls_temp_files.clear()
_mtls_file_cache = None
_mtls_file_cache_key = None
_ca_file_cache = None
_ca_file_cache_key = None
def _after_fork_in_child() -> None:
"""
Drop inherited temp-path bookkeeping without unlinking.
Parent-created PEM temps must stay on disk for the parent reporter. Clearing
the list in-place would also empty the parent's list (same object); rebind
instead so the child's atexit handler cannot delete the parent's files.
"""
global _mtls_temp_files, _mtls_file_cache, _mtls_file_cache_key
global _ca_file_cache, _ca_file_cache_key
_mtls_temp_files = []
_mtls_file_cache = None
_mtls_file_cache_key = None
_ca_file_cache = None
_ca_file_cache_key = None
if hasattr(os, 'register_at_fork'):
os.register_at_fork(after_in_child=_after_fork_in_child)
def _ensure_atexit() -> None:
global _atexit_registered
if not _atexit_registered:
atexit.register(_cleanup_mtls_temp_files)
_atexit_registered = True
def _pem_bytes_to_temp_file(data: bytes, suffix: str) -> str:
_ensure_atexit()
handle, path = tempfile.mkstemp(suffix=suffix)
try:
view = memoryview(data)
offset = 0
while offset < len(view):
written = os.write(handle, view[offset:])
if written <= 0:
raise OSError(f'Failed to write SSL PEM temp file {path}')
offset += written
except Exception:
os.close(handle)
try:
os.unlink(path)
except OSError:
pass
raise
else:
os.close(handle)
_mtls_temp_files.append(path)
return path
def _discard_mtls_temp_path(path: Optional[str]) -> None:
"""Unlink a temp PEM and drop it from the process cleanup list."""
if not path:
return
try:
os.unlink(path)
except OSError:
pass
try:
_mtls_temp_files.remove(path)
except ValueError:
pass
def _mtls_cert_key_files(
certificate_chain: bytes,
private_key: bytes,
) -> Optional[Tuple[str, str]]:
"""
Write normalized PEM bytes to temp files (cached per material).
Returns None when temp files cannot be created (caller stays one-way TLS).
If the key temp write fails after the cert was written, the orphaned cert
file is unlinked immediately (not left until process exit).
"""
global _mtls_file_cache_key, _mtls_file_cache
cache_key = (certificate_chain, private_key)
if _mtls_file_cache is not None and _mtls_file_cache_key == cache_key:
return _mtls_file_cache
cert_path = None
try:
cert_path = _pem_bytes_to_temp_file(certificate_chain, '.crt')
key_path = _pem_bytes_to_temp_file(private_key, '.pem')
paths = (cert_path, key_path)
except OSError as exc:
_discard_mtls_temp_path(cert_path)
_warn_once(
f'mtls-temp:{exc}',
'Failed to write mTLS cert/key temp files (%s); staying on one-way TLS.',
exc,
)
return None
_mtls_file_cache_key = cache_key
_mtls_file_cache = paths
return paths
def tls_pem_material() -> Optional[Tuple[Optional[bytes], Optional[bytes], Optional[bytes]]]:
"""
PEM bytes for (root_certificates, private_key, certificate_chain).
None means plaintext (no TLS). Tuple members may still be None when using
FORCE_TLS with the process trust store and/or one-way TLS.
CA / client PEMs are parsed before return so invalid content degrades here
(plaintext or system-trust one-way TLS) instead of only failing at connect.
Enable decision uses the *current* CA load result (not a prior existence
check alone): if the CA path races away between resolve and read without
FORCE_TLS, returns None (plaintext) rather than a system-trust HTTPS tuple.
"""
from skywalking import config
# Emit configured-but-unusable CA path warnings (warn_once dedupes).
collector_uses_tls()
force = bool(config.agent_force_tls)
ca_path = ssl_file_path(config.agent_ssl_trusted_ca_path)
root_certificates = None
ca_usable = False
if ca_path is not None:
try:
root_certificates = _load_trusted_ca(ca_path)
ca_usable = True
except (OSError, ValueError) as exc:
if force:
logger.warning(
'Failed to load trusted CA file (%s); continuing with FORCE_TLS '
'and the process trust store (custom CA ignored, mTLS disabled).',
exc,
)
else:
logger.warning(
'Failed to load trusted CA file (%s); collector stays plaintext '
'(set SW_AGENT_FORCE_TLS to use the process trust store instead).',
exc,
)
# Still surface mTLS-without-CA diagnostics when client PEMs are set.
_mtls_material(ca_usable=False)
return None
if not force and not ca_usable:
# No TLS without FORCE or a usable CA — including CA that vanished after
# collector_uses_tls() saw it (TOCTOU). Warn if client cert/key are set.
_mtls_material(ca_usable=False)
return None
certificate_chain, private_key = _mtls_material(ca_usable=ca_usable)
return root_certificates, private_key, certificate_chain
def safe_tls_pem_material() -> Optional[Tuple[Optional[bytes], Optional[bytes], Optional[bytes]]]:
"""
``tls_pem_material()`` that never raises into HTTP reporter ``__init__``.
On unexpected failure: FORCE_TLS → system-trust tuple; else plaintext None.
"""
try:
return tls_pem_material()
except Exception as exc: # noqa: BLE001 - never fail host process start
from skywalking import config
_warn_once(
f'tls-material:{exc}',
'Failed to load collector TLS material (%s); falling back.',
exc,
)
if config.agent_force_tls:
return None, None, None
return None
def grpc_ssl_credentials():
"""
grpc.ChannelCredentials for TLS/mTLS, or None for plaintext.
FORCE_TLS without a CA file uses the process trust store
(grpc.ssl_channel_credentials() with no roots).
If credential construction ultimately fails (including process-trust
FORCE_TLS fallback), returns None so the channel stays plaintext with a
warning — never raises into agent start.
"""
import grpc
material = tls_pem_material()
if material is None:
return None
root_certificates, private_key, certificate_chain = material
try:
return grpc.ssl_channel_credentials(
root_certificates=root_certificates,
private_key=private_key,
certificate_chain=certificate_chain,
)
except Exception as exc: # noqa: BLE001 - never fail host process start
from skywalking import config
if private_key is not None or certificate_chain is not None:
_warn_once(
f'grpc-creds-mtls:{exc}',
'Failed to build gRPC mTLS credentials (%s); retrying one-way TLS.',
exc,
)
try:
return grpc.ssl_channel_credentials(root_certificates=root_certificates)
except Exception as one_way_exc: # noqa: BLE001
_warn_once(
f'grpc-creds-oneway:{one_way_exc}',
'Failed to build gRPC TLS credentials (%s); collector stays plaintext.',
one_way_exc,
)
return None
if config.agent_force_tls:
_warn_once(
f'grpc-creds-force:{exc}',
'Failed to build gRPC TLS credentials with custom CA (%s); '
'continuing with FORCE_TLS and the process trust store.',
exc,
)
try:
return grpc.ssl_channel_credentials()
except Exception as force_exc: # noqa: BLE001
_warn_once(
f'grpc-creds-force-fail:{force_exc}',
'Failed to build gRPC TLS credentials (%s); collector stays plaintext.',
force_exc,
)
return None
_warn_once(
f'grpc-creds:{exc}',
'Failed to build gRPC TLS credentials (%s); collector stays plaintext.',
exc,
)
return None
def collector_http_scheme(material=_MATERIAL_UNSET) -> str:
"""
``https://`` when TLS material is enabled, else ``http://``.
Pass ``material`` from a shared ``tls_pem_material()`` call so HTTP URL
scheme and session TLS settings cannot disagree across a TOCTOU window.
Never raises into reporter ``__init__`` (HTTP clients call this before
other TLS helpers that may wrap failures).
"""
try:
if material is _MATERIAL_UNSET:
material = tls_pem_material()
return 'https://' if material is not None else 'http://'
except Exception as exc: # noqa: BLE001 - never fail host process start
from skywalking import config
_warn_once(
f'http-scheme:{exc}',
'Failed to decide collector HTTP scheme (%s); falling back.',
exc,
)
return 'https://' if config.agent_force_tls else 'http://'
def _ca_verify_temp_file(root_certificates: bytes) -> str:
"""Process-lifetime temp path for trusted CA bytes (survives K8s secret rotation)."""
global _ca_file_cache_key, _ca_file_cache
if (
_ca_file_cache is not None
and _ca_file_cache_key == root_certificates
and os.path.exists(_ca_file_cache)
):
return _ca_file_cache
path = _pem_bytes_to_temp_file(root_certificates, '.crt')
_ca_file_cache_key = root_certificates
_ca_file_cache = path
return path
def _configured_ca_path_for_verify() -> Optional[str]:
"""
Absolute, expanduser'd configured CA path **without** resolving symlinks.
Validates that a regular file is reachable (via ``ssl_file_path``), but
returns the configured path so Kubernetes ``ca.crt -> ..data/ca.crt`` stays
stable across secret rotation when a temp CA snapshot cannot be written.
``ssl_file_path`` / ``Path.resolve`` would pin ``session.verify`` to a
versioned ``v1/`` target that rotation deletes.
"""
from skywalking import config
text = _configured_path(config.agent_ssl_trusted_ca_path)
if not text or ssl_file_path(text) is None:
return None
try:
path = Path(text).expanduser()
# absolute() does not follow symlinks (unlike resolve()).
return str(path.absolute())
except (OSError, ValueError, RuntimeError):
return None
def _ca_pem_has_trusted_certificate(root_certificates: bytes) -> bool:
"""True when extracted CA bytes include an OpenSSL TRUSTED CERTIFICATE block."""
return b'BEGIN TRUSTED CERTIFICATE' in root_certificates
def requests_tls_settings(material=_MATERIAL_UNSET) -> Tuple[object, Optional[Tuple[str, str]]]:
"""
(verify, cert) for requests.Session.
verify is True (system CAs), a CA file path, or unused for plaintext callers.
cert is (cert_path, key_path) when mTLS files are present.
Pass ``material`` from a shared ``tls_pem_material()`` call (with
``collector_http_scheme``) so scheme and verify cannot disagree.
Keeps the same enable/disable decision as grpc_ssl_credentials / tls_pem_material
so an unreadable or oversized CA cannot leave HTTP on https:// with a bad verify path.
Custom CA prefers a process-lifetime temp snapshot of the validated PEM bytes
(never the resolved symlink target), so Kubernetes secret rotation that
removes the old ``..data`` version cannot invalidate an already-configured
session. If the snapshot cannot be written (e.g. read-only temp dir), fall
back to the configured CA path **without resolving symlinks** (still
validated as readable) rather than discarding the private CA for Requests'
default trust store or pinning a deleted ``v1/`` target after rotation.
Client-cert temp-file failures drop mTLS only (one-way TLS), with a warning.
"""
if material is _MATERIAL_UNSET:
material = tls_pem_material()
if material is None:
return True, None
root_certificates, private_key, certificate_chain = material
if root_certificates is not None:
try:
verify: object = _ca_verify_temp_file(root_certificates)
except OSError as exc:
ca_verify = _configured_ca_path_for_verify()
if ca_verify is not None:
_warn_once(
f'requests-ca-temp:{exc}',
'Failed to persist trusted CA temp file (%s); '
'falling back to configured CA path for HTTP verify.',
exc,
)
verify = ca_verify
else:
_warn_once(
f'requests-ca-temp:{exc}',
'Failed to persist trusted CA temp file (%s); '
'using process trust store for HTTP verify.',
exc,
)
verify = True
else:
verify = True
if private_key is None or certificate_chain is None:
return verify, None
pair = _mtls_cert_key_files(certificate_chain, private_key)
return verify, pair
def configure_requests_session(session, material=_MATERIAL_UNSET) -> None:
try:
verify, cert = requests_tls_settings(material)
except Exception as exc: # noqa: BLE001 - never fail host process start
from skywalking import config
_warn_once(
f'requests-tls:{exc}',
'Failed to configure HTTP reporter TLS (%s); falling back.',
exc,
)
if config.agent_force_tls or ssl_file_path(config.agent_ssl_trusted_ca_path):
# Prefer the configured private CA over discarding it for system trust.
ca_verify = _configured_ca_path_for_verify()
session.verify = ca_verify if ca_verify is not None else True
session.cert = None
return
session.verify = verify
if cert is not None:
session.cert = cert
def ssl_context_for_collector(material=_MATERIAL_UNSET) -> Optional[ssl.SSLContext]:
"""
stdlib SSLContext for aiohttp, or None when the collector stays plaintext.
Pass ``material`` from a shared ``tls_pem_material()`` call (with
``collector_http_scheme``) so scheme and SSLContext cannot disagree.
Degrade on SSLError / OSError (never raise into agent bootstrap):
- bad / unparsable custom CA + FORCE_TLS → process trust store, no client cert
- bad / unparsable custom CA without FORCE_TLS → plaintext (None)
- bad client cert/key or temp-file failure → one-way TLS (CA or system trust)
Custom CA is loaded from normalized in-memory PEM bytes (``cadata``) when the
bundle is plain ``CERTIFICATE`` only, so path races and UTF-8 BOM/preamble
cannot drop the custom trust store after ``tls_pem_material`` already accepted
the CA. Bundles that include ``TRUSTED CERTIFICATE`` (``openssl x509 -trustout``)
always use ``cafile``: Python's ``cadata`` loader can silently skip trusted
blocks while still succeeding on ordinary certificates in a mixed bundle.
"""
from skywalking import config
if material is _MATERIAL_UNSET:
material = tls_pem_material()
if material is None:
return None
root_certificates, private_key, certificate_chain = material
def _system_trust_context() -> ssl.SSLContext:
return ssl.create_default_context()
def _load_ca_via_cafile() -> Tuple[Optional[ssl.SSLContext], Optional[BaseException]]:
"""Load custom CA through cafile (snapshot, else configured path)."""
try:
cafile = _ca_verify_temp_file(root_certificates)
return ssl.create_default_context(cafile=cafile), None
except OSError as temp_exc:
ca_verify = _configured_ca_path_for_verify()
if ca_verify is not None:
try:
return ssl.create_default_context(cafile=ca_verify), None
except OSError as path_exc:
return None, path_exc
return None, temp_exc
ctx: Optional[ssl.SSLContext]
if root_certificates is not None:
load_exc: Optional[BaseException] = None
ctx = None
# TRUSTED CERTIFICATE must not go through cadata: mixed bundles can
# load only the ordinary CERTIFICATE entries and never raise.
if _ca_pem_has_trusted_certificate(root_certificates):
ctx, load_exc = _load_ca_via_cafile()
else:
try:
# Normalized ASCII PEM from _load_trusted_ca (BOM/preamble stripped).
ctx = ssl.create_default_context(
cadata=root_certificates.decode('ascii'),
)
except (OSError, ValueError) as exc:
load_exc = exc
ctx = None
ctx, load_exc = _load_ca_via_cafile()
if load_exc is not None:
# ssl.SSLError subclasses OSError; ValueError: non-ASCII PEM bytes.
if config.agent_force_tls:
_warn_once(
f'ssl-ctx-ca:{load_exc}',
'Failed to load trusted CA into SSLContext (%s); continuing with '
'FORCE_TLS and the process trust store (mTLS disabled).',
load_exc,
)
try:
ctx = _system_trust_context()
except OSError as sys_exc:
_warn_once(
f'ssl-ctx-system:{sys_exc}',
'Failed to create SSLContext from process trust store (%s); '
'collector stays plaintext.',
sys_exc,
)
return None
private_key = None
certificate_chain = None
else:
_warn_once(
f'ssl-ctx-ca-plain:{load_exc}',
'Failed to load trusted CA into SSLContext (%s); collector stays '
'plaintext (set SW_AGENT_FORCE_TLS to use the process trust store).',
load_exc,
)
return None
else:
# FORCE_TLS without usable CA → process trust store.
try:
ctx = _system_trust_context()
except OSError as exc:
_warn_once(
f'ssl-ctx-system:{exc}',
'Failed to create SSLContext from process trust store (%s); '
'collector stays plaintext.',
exc,
)
return None
if private_key is not None and certificate_chain is not None:
pair = _mtls_cert_key_files(certificate_chain, private_key)
if pair is None:
return ctx
cert_file, key_file = pair
try:
ctx.load_cert_chain(cert_file, key_file)
except OSError as exc:
_warn_once(
f'ssl-ctx-mtls:{exc}',
'Failed to load client cert/key into SSLContext (%s); staying on one-way TLS.',
exc,
)
return ctx