blob: d6623d51be83d7b72e87b772bcfe8a69b7da4831 [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.
#
import base64
import os
import ssl
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from skywalking import config
from skywalking.utils import tls as tls_mod
from skywalking.utils.tls import (
collector_http_scheme,
collector_uses_tls,
normalize_private_key_pem,
requests_tls_settings,
ssl_context_for_collector,
tls_pem_material,
)
# Self-signed PEMs for unit tests (loadable by OpenSSL; not for production).
_TEST_CA_CERT = b"""-----BEGIN CERTIFICATE-----
MIICxjCCAa6gAwIBAgIUF4Oln8syl8F84oLaGDfp2Y34WNIwDQYJKoZIhvcNAQEL
BQAwHTEbMBkGA1UEAwwSc2t5d2Fsa2luZy10ZXN0LWNhMB4XDTI2MDkxMDIzNTkx
NVoXDTM2MDkwODIzNTkxNVowHTEbMBkGA1UEAwwSc2t5d2Fsa2luZy10ZXN0LWNh
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3kWpwJ4D/b7osNRM6khH
22cU4/HMvVZsMqEMl966iDxhOjBsTiY9kUFHdQqqxfyEcSf5cZ9bGgOq+qFE+0ES
O7/xm7mPehMtBfrOIryOLwGru7a09Nt4EzsyNr8Pfa3uUMRGqMjg5BwWL/1/Fl+N
yWNcoe+DzQ1nMu1mOXDgRk+T/Kv4sVrUMUAxbEVr0pYIOBwmksKd1/vgDoZirKD/
I4natV4RJUkiaDbovE/iUb7D9RqTvKj+xUe9IOY/zRhwZAS9mX3+xSYCS5eik/uP
J0sRt8TBbehtGlbqtfKDqlL75dJxR9noOX69Q4s/xw1bupFvrtkAjfhTTiABC/tJ
4QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBuLlOFq8oN6aymQ6mlaeXIXc+Hs87V
jKxeMkQuEIz5oTqykfz4od7CJHTqaSsJC5o/M7F8KuYHjT9CFxM+bbkCyUkQXPRC
ZEibp8qiqJeUiWnU7bTtoBcY/so5V1DNrer56jajJ3iltnOnw3reiH3BKIjr20hq
jzECpayEsoEFyXUViVsJtd+0A0owJy2h2z9dB6c+C/H5BAuo/M7dbQAIOyQiBXeY
gLcAZ/zj9tMNTxOAcFHppHZS1TVF/Wt5c/shLDxC0XVvutVG8s3q+cqZ/xQyMs9y
wlTT55CMucIIw0KTLNyNCB12fALoaf0AzZbHBCEvTsi4Qf34NW2C7V2s
-----END CERTIFICATE-----
"""
_TEST_CLIENT_CERT = b"""-----BEGIN CERTIFICATE-----
MIICzjCCAbagAwIBAgIUCipZSsMD9Tl7eGdHHlEujKB/mF8wDQYJKoZIhvcNAQEL
BQAwITEfMB0GA1UEAwwWc2t5d2Fsa2luZy10ZXN0LWNsaWVudDAeFw0yNjA5MTAy
MzU5MTVaFw0zNjA5MDgyMzU5MTVaMCExHzAdBgNVBAMMFnNreXdhbGtpbmctdGVz
dC1jbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDNyXYhjEpM
5H+gOWuEzLQh5cwJtjbLDpCo6756AtffkGxGZI96HFzpBshl8e1AcXWSIdia9P1M
xckVeTgreYHe5YxfYRuhXsOfAYVxM8nb3v1iwieHhetmIHlzNzpGLXluqkwNyBuY
xo3y+AApv6Otxf+v/60Uj6kEjMhJM8O/BEoNwagvl1KA7FknTyJWnQmpHwZpovmP
TNogsjHn1a79MioUq8n1QOCQlfXqssoLllCjQXblv8TiBuaijAXfa66stdoxsQ8+
GKv+WIC5zQyjhcoKhur37hGlSwSbOKng4MI9zfHMNvPvhRdDachGZ9vl0f+61OLf
aphVI1xn0u1/AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEptwm4EAQuVUdqTNrPF
47e3E5iZtkBjF2TDFiLJfvjvGZJFj7PbtrFR9UVRxP5l6Y/8GHmLtIvni1C+7HjV
tuW2JpId+miVvM0vHm3FPtjil5JapSDVlNrtkOxGxizOtuf4vx0s1bUlxbDstarx
ASVBFRxdG+XBhAyCC3Jgui2li/mw2oJEk50b37cD9vE8jSuA5qHNLRRqMhRPs+Y4
CPh4r/MnvhJChhEPzzbUoxejbrccpVBBA2hlwM363tRNr7E3i6tsDFfnChhXlKIH
ypuoPrg+L1WWdF4gkCWhCYdZN7dM0ph1Z+2XZjGZYmk6/US9HOvVImOw39AAr9Ev
bAs=
-----END CERTIFICATE-----
"""
_TEST_CLIENT_KEY_PKCS1 = b"""-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAzcl2IYxKTOR/oDlrhMy0IeXMCbY2yw6QqOu+egLX35BsRmSP
ehxc6QbIZfHtQHF1kiHYmvT9TMXJFXk4K3mB3uWMX2EboV7DnwGFcTPJ2979YsIn
h4XrZiB5czc6Ri15bqpMDcgbmMaN8vgAKb+jrcX/r/+tFI+pBIzISTPDvwRKDcGo
L5dSgOxZJ08iVp0JqR8GaaL5j0zaILIx59Wu/TIqFKvJ9UDgkJX16rLKC5ZQo0F2
5b/E4gbmoowF32uurLXaMbEPPhir/liAuc0Mo4XKCobq9+4RpUsEmzip4ODCPc3x
zDbz74UXQ2nIRmfb5dH/utTi32qYVSNcZ9LtfwIDAQABAoIBADGuZ5Sl1/JEYAOA
mVKQURS189KMaSIQvB/r+ipesVtJ9Lnx4Smr20pu1sa1539dZPMItNCEQPrd7TP/
9e2ZAh+b742/VfpZdITYyyyPQjaQ2T+UfBKd5DzdrjSAqtLye5SaDI5vNyplFTQJ
Z5CssYlsedQ1t8V1AWsVyezSUHm20iAPFzldYwyhsS9jz0H8EBtMuMU2uo7I6e4a
4WlQAQtzcBNzplcwdAQ9qvba3UFlA62gIsH39Va+QckXROgnISaJMsq1H/RSx12Y
rht8SOxF7GXvjfnesZ6Gfc7gek/8E65ny+CS0xv8xj2HV8W+IrlQFE4pqX8VQ/Qg
OJWdCpECgYEA+rURpz+IAP7aYcGgogKhATJOOJGHqg2grXWUZr6J/NrMI4/ohJg3
rMoiEJBDb0q/tUxsvuT8aH+UxuZ3gWwcMci5PvldKlrFC+sVpJTlLB+MWbNPWBs/
dr7riyy/0ZJ5OTt3h/ZsSy2RczFOKWXXNS/r/WvXFtfvV40+McGQ4S0CgYEA0iGf
m9Z0BBpHlwL3sGsZvPI5KflhgMx3D2YDwGonk4fW31k+NcCYR1d+0Ghq0oNyb2qS
09z+ZZA94uiO3t+mtZc5IWLZoJW/dDBiam2tVManluqFRX+i3d2JcJuDNpiI76Wz
Be8NrpG8uOfaO3tovmgwaS1WZ3mBe1R21wBi/NsCgYBRq0x95BdE48B2GeJfBGY4
go+yo83C2r+d4fCe67D9urTHXOjM0N1KH2qrZKNjDMGYqLXAFc4XqH/pr0f81B/3
I8Ecv5TW6EzKTiF1xL9G+Vv6GIxfUjkBUL5gTwqJlaKBv1p34xFyB/0avlQM7k0F
2X+RxWCC44LnTW6WPM0aXQKBgQCaGrySLlmRNLCyCCQchr8ueboAlXqzWcArU9aG
g5OYt7OWwz1DcIZ9M6a2Mw28a1g+a7tYkyci1wD76y/0NbNuU4Q7fuI5yfjJvj4+
7UaD+Nipbj7k9DE+Yx1Lr1EwdfdfQXckb+fp0cnFFYxPuTbdBU4TpINMiaizCQPK
s+bkpwKBgQC0WurqUUJ6EZc6ahUwxeGKVgJcBt4+9Nz+61t0j9mwrga7iIHmOBp/
hnGpvSFjYbsX7zIPELCZs4GTkpAvyrDLSD1x/ZTLf+cXyX/c/7vF3KtOoKAQT9N0
VunQSXwz7Gln1ZemLrcAn9XQwovDZGAkT+NrCRda9sFfD8GN3AedUg==
-----END RSA PRIVATE KEY-----
"""
class TestCollectorTls(unittest.TestCase):
def setUp(self):
self._saved = (
config.agent_force_tls,
config.agent_ssl_trusted_ca_path,
config.agent_ssl_cert_chain_path,
config.agent_ssl_key_path,
)
config.agent_force_tls = False
config.agent_ssl_trusted_ca_path = ''
config.agent_ssl_cert_chain_path = ''
config.agent_ssl_key_path = ''
tls_mod._warned_keys.clear()
tls_mod._cleanup_mtls_temp_files()
tls_mod._mtls_file_cache_key = None
tls_mod._mtls_file_cache = None
def tearDown(self):
(
config.agent_force_tls,
config.agent_ssl_trusted_ca_path,
config.agent_ssl_cert_chain_path,
config.agent_ssl_key_path,
) = self._saved
tls_mod._cleanup_mtls_temp_files()
tls_mod._mtls_file_cache_key = None
tls_mod._mtls_file_cache = None
tls_mod._warned_keys.clear()
def _write_pem(self, directory: str, name: str, data: bytes) -> Path:
path = Path(directory) / name
path.write_bytes(data)
return path
def test_plaintext_when_tls_off_and_no_ca(self):
self.assertFalse(collector_uses_tls())
self.assertIsNone(tls_pem_material())
self.assertEqual(collector_http_scheme(), 'http://')
def test_force_tls_uses_system_trust_without_ca(self):
config.agent_force_tls = True
self.assertTrue(collector_uses_tls())
self.assertEqual(collector_http_scheme(), 'https://')
self.assertEqual(tls_pem_material(), (None, None, None))
verify, cert = requests_tls_settings()
self.assertTrue(verify)
self.assertIsNone(cert)
def test_ca_file_enables_tls_without_force_flag(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
self.assertTrue(collector_uses_tls())
self.assertEqual(tls_pem_material(), (_TEST_CA_CERT, None, None))
verify, cert = requests_tls_settings()
self.assertIsInstance(verify, str)
self.assertTrue(Path(verify).is_file())
self.assertFalse(Path(verify).samefile(ca))
self.assertEqual(
Path(verify).read_bytes(),
tls_mod._extract_ca_pem_blocks(_TEST_CA_CERT),
)
self.assertIsNone(cert)
def test_missing_ca_path_warns_and_stays_plaintext(self):
config.agent_ssl_trusted_ca_path = '/nonexistent/skywalking-ca.crt'
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertFalse(collector_uses_tls())
self.assertIsNone(tls_pem_material())
self.assertEqual(collector_http_scheme(), 'http://')
self.assertTrue(any('SW_AGENT_SSL_TRUSTED_CA_PATH' in line for line in logs.output))
self.assertTrue(any('plaintext' in line for line in logs.output))
def test_mtls_when_ca_cert_and_key_exist(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
roots, private_key, chain = tls_pem_material()
self.assertEqual(roots, _TEST_CA_CERT)
self.assertEqual(chain, _TEST_CLIENT_CERT)
self.assertIn(b'BEGIN PRIVATE KEY', private_key)
verify, pair = requests_tls_settings()
self.assertIsInstance(verify, str)
self.assertTrue(Path(verify).is_file())
self.assertFalse(Path(verify).samefile(ca))
self.assertIsNotNone(pair)
cert_file, key_file = pair
self.assertEqual(Path(cert_file).read_bytes(), _TEST_CLIENT_CERT)
self.assertEqual(Path(key_file).read_bytes(), private_key)
self.assertTrue(all(os.path.exists(p) for p in tls_mod._mtls_temp_files))
tls_mod._cleanup_mtls_temp_files()
self.assertEqual(tls_mod._mtls_temp_files, [])
def test_normalize_pkcs1_to_pkcs8(self):
der = b'\x30' + b'\x00' * 31
body = base64.b64encode(der).decode('ascii')
pkcs1 = (
'-----BEGIN RSA PRIVATE KEY-----\n'
f'{body}\n'
'-----END RSA PRIVATE KEY-----\n'
).encode('ascii')
out = normalize_private_key_pem(pkcs1)
text = out.decode('ascii')
self.assertIn('-----BEGIN PRIVATE KEY-----', text)
self.assertIn('-----END PRIVATE KEY-----', text)
self.assertNotIn('BEGIN RSA PRIVATE KEY', text)
pkcs8 = b'-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n'
self.assertEqual(normalize_private_key_pem(pkcs8), pkcs8)
def test_normalize_pkcs1_ignores_preamble_outside_pem_block(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = Path(tmp) / 'key.pem'
key.write_bytes(b'# This is a private key\n' + _TEST_CLIENT_KEY_PKCS1)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_cert_chain(crt, key)
normalized = normalize_private_key_pem(key.read_bytes())
normalized_path = Path(tmp) / 'normalized.pem'
normalized_path.write_bytes(normalized)
ctx.load_cert_chain(crt, normalized_path) # must not raise
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
roots, private_key, chain = tls_pem_material()
self.assertIsNotNone(roots)
self.assertIsNotNone(private_key)
self.assertIsNotNone(chain)
def test_normalize_rejects_encrypted_private_key(self):
encrypted = (
b'-----BEGIN ENCRYPTED PRIVATE KEY-----\n'
b'abc\n'
b'-----END ENCRYPTED PRIVATE KEY-----\n'
)
with self.assertRaises(ValueError) as ctx:
normalize_private_key_pem(encrypted)
self.assertIn('Passphrase-encrypted', str(ctx.exception))
def test_normalize_rejects_legacy_proc_type_encrypted(self):
legacy = (
b'-----BEGIN RSA PRIVATE KEY-----\n'
b'Proc-Type: 4,ENCRYPTED\n'
b'DEK-Info: AES-256-CBC,0123456789ABCDEF0123456789ABCDEF\n'
b'\n'
b'AAAA\n'
b'-----END RSA PRIVATE KEY-----\n'
)
with self.assertRaises(ValueError) as ctx:
normalize_private_key_pem(legacy)
self.assertIn('Passphrase-encrypted', str(ctx.exception))
def test_mtls_converts_pkcs1_key_for_grpc_and_http(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
roots, private_key, chain = tls_pem_material()
self.assertEqual(roots, _TEST_CA_CERT)
self.assertEqual(chain, _TEST_CLIENT_CERT)
self.assertIn(b'BEGIN PRIVATE KEY', private_key)
self.assertNotIn(b'BEGIN RSA PRIVATE KEY', private_key)
verify, pair = requests_tls_settings()
self.assertIsInstance(verify, str)
self.assertTrue(Path(verify).is_file())
self.assertFalse(Path(verify).samefile(ca))
self.assertEqual(Path(pair[1]).read_bytes(), private_key)
def test_missing_key_stays_one_way_tls(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(Path(tmp) / 'missing.pem')
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertEqual(tls_pem_material(), (_TEST_CA_CERT, None, None))
self.assertTrue(any('mTLS' in line for line in logs.output))
def test_only_cert_configured_warns(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = ''
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertEqual(tls_pem_material(), (_TEST_CA_CERT, None, None))
self.assertTrue(any('Only one of' in line for line in logs.output))
def test_client_certs_ignored_without_ca_file_warns(self):
with tempfile.TemporaryDirectory() as tmp:
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_force_tls = True
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertEqual(tls_pem_material(), (None, None, None))
self.assertTrue(any('mTLS is disabled' in line for line in logs.output))
def test_symlink_ca_to_file_is_accepted(self):
with tempfile.TemporaryDirectory() as tmp:
real = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
link = Path(tmp) / 'ca-link.crt'
try:
link.symlink_to(real)
except OSError:
self.skipTest('symlinks not available')
config.agent_ssl_trusted_ca_path = str(link)
self.assertTrue(collector_uses_tls())
self.assertEqual(tls_pem_material(), (_TEST_CA_CERT, None, None))
def test_oversized_pem_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.crt'
ca.write_bytes(b'X' * (tls_mod._MAX_PEM_BYTES + 1))
config.agent_ssl_trusted_ca_path = str(ca)
self.assertTrue(collector_uses_tls())
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertIsNone(tls_pem_material())
self.assertTrue(any('plaintext' in line for line in logs.output))
self.assertFalse(any('system trust' in line for line in logs.output))
self.assertEqual(collector_http_scheme(), 'http://')
verify, cert = requests_tls_settings()
self.assertTrue(verify)
self.assertIsNone(cert)
self.assertIsNone(ssl_context_for_collector())
def test_oversized_pem_with_force_tls_uses_system_trust(self):
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.crt'
ca.write_bytes(b'X' * (tls_mod._MAX_PEM_BYTES + 1))
config.agent_force_tls = True
config.agent_ssl_trusted_ca_path = str(ca)
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertEqual(tls_pem_material(), (None, None, None))
self.assertTrue(any('process trust store' in line for line in logs.output))
self.assertEqual(collector_http_scheme(), 'https://')
def test_bad_ca_content_degrades_to_plaintext(self):
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.crt'
ca.write_text('not-a-pem')
config.agent_ssl_trusted_ca_path = str(ca)
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertIsNone(tls_pem_material())
self.assertEqual(collector_http_scheme(), 'http://')
self.assertIsNone(ssl_context_for_collector())
self.assertTrue(any('plaintext' in line for line in logs.output))
verify, cert = requests_tls_settings()
# Material is None → callers that still ask for settings get defaults,
# but HTTP reporter uses scheme from tls_pem_material() → http://.
self.assertTrue(verify)
self.assertIsNone(cert)
def test_bad_ca_content_with_force_tls_degrades_to_system_trust(self):
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.crt'
ca.write_text('not-a-pem')
config.agent_force_tls = True
config.agent_ssl_trusted_ca_path = str(ca)
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertEqual(tls_pem_material(), (None, None, None))
ctx = ssl_context_for_collector()
self.assertIsInstance(ctx, ssl.SSLContext)
self.assertTrue(any('process trust store' in line for line in logs.output))
verify, cert = requests_tls_settings()
self.assertTrue(verify)
self.assertIsNone(cert)
def test_bad_ca_with_force_and_client_certs_drops_mtls(self):
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.crt'
ca.write_bytes(b'bad')
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_force_tls = True
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertEqual(tls_pem_material(), (None, None, None))
self.assertTrue(any('mTLS is disabled' in line for line in logs.output))
verify, pair = requests_tls_settings()
self.assertTrue(verify)
self.assertIsNone(pair)
def test_oversized_ca_with_force_and_client_certs_drops_mtls(self):
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.crt'
ca.write_bytes(b'X' * (tls_mod._MAX_PEM_BYTES + 1))
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_force_tls = True
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
with self.assertLogs('skywalking', level='WARNING'):
self.assertEqual(tls_pem_material(), (None, None, None))
verify, pair = requests_tls_settings()
self.assertTrue(verify)
self.assertIsNone(pair)
def test_invalid_client_material_stays_one_way_tls(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = Path(tmp) / 'client.crt'
key = Path(tmp) / 'client.pem'
crt.write_bytes(b'not-a-cert')
key.write_bytes(b'not-a-key')
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertEqual(tls_pem_material(), (_TEST_CA_CERT, None, None))
self.assertTrue(any('mTLS' in line for line in logs.output))
def test_temp_file_failure_stays_one_way_for_requests(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
def boom(*_a, **_k):
raise OSError(30, 'Read-only file system')
with patch('tempfile.mkstemp', side_effect=boom), \
self.assertLogs('skywalking', level='WARNING') as logs:
# Validation also uses mkstemp; either path must stay one-way TLS.
material = tls_pem_material()
if material is not None and material[1] is not None:
verify, pair = requests_tls_settings()
# CA snapshot may fall back to the configured path; client temps fail.
self.assertTrue(verify is True or (isinstance(verify, str) and Path(verify).is_file()))
self.assertIsNone(pair)
else:
self.assertEqual(material, (_TEST_CA_CERT, None, None))
self.assertTrue(any('mTLS' in line or 'temp files' in line or 'configured CA path' in line
for line in logs.output))
def test_configure_requests_session_never_raises(self):
config.agent_force_tls = True
session = MagicMock()
with patch('skywalking.utils.tls.requests_tls_settings', side_effect=RuntimeError('boom')), \
self.assertLogs('skywalking', level='WARNING'):
tls_mod.configure_requests_session(session)
self.assertIs(session.verify, True)
self.assertIsNone(session.cert)
def test_ssl_file_path_rejects_invalid_path(self):
self.assertIsNone(tls_mod.ssl_file_path('a\x00b'))
self.assertIsNone(tls_mod.ssl_file_path('/nonexistent/ca.crt'))
def test_ssl_file_path_swallows_expanduser_and_resolve_errors(self):
with patch.object(Path, 'expanduser', side_effect=RuntimeError('no home')):
self.assertIsNone(tls_mod.ssl_file_path('~/missing-user-ca.crt'))
with patch.object(Path, 'resolve', side_effect=RuntimeError('symlink loop')):
self.assertIsNone(tls_mod.ssl_file_path('ca.crt'))
# HTTP scheme is chosen before configure_requests_session's try/except.
config.agent_ssl_trusted_ca_path = '~/missing-user-ca.crt'
with patch.object(Path, 'expanduser', side_effect=RuntimeError('no home')):
self.assertEqual(collector_http_scheme(), 'http://')
def test_ca_vanishes_after_exists_check_stays_plaintext_without_force(self):
# Simulate TOCTOU: existence probe said TLS-on, but the CA path is gone
# before material load — without FORCE_TLS must stay plaintext.
config.agent_ssl_trusted_ca_path = '/tmp/vanished-ca.crt'
with patch.object(tls_mod, 'collector_uses_tls', return_value=True), \
patch.object(tls_mod, 'ssl_file_path', return_value=None):
self.assertIsNone(tls_pem_material())
self.assertEqual(collector_http_scheme(), 'http://')
with tempfile.TemporaryDirectory() as tmp:
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
tls_mod._warned_keys.clear()
with patch.object(tls_mod, 'collector_uses_tls', return_value=True), \
patch.object(tls_mod, 'ssl_file_path', return_value=None), \
self.assertLogs('skywalking', level='WARNING') as logs:
self.assertIsNone(tls_pem_material())
self.assertTrue(any('mTLS is disabled' in line for line in logs.output))
self.assertEqual(collector_http_scheme(), 'http://')
def test_client_certs_without_ca_warn_when_force_tls_off(self):
with tempfile.TemporaryDirectory() as tmp:
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_force_tls = False
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
with self.assertLogs('skywalking', level='WARNING') as logs:
self.assertIsNone(tls_pem_material())
self.assertTrue(any('mTLS is disabled' in line for line in logs.output))
self.assertEqual(collector_http_scheme(), 'http://')
def test_requests_verify_survives_k8s_style_ca_rotation(self):
import shutil
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for version in ('v1', 'v2'):
(root / version).mkdir()
(root / version / 'ca.crt').write_bytes(_TEST_CA_CERT)
try:
(root / '..data').symlink_to('v1')
ca = root / 'ca.crt'
ca.symlink_to(Path('..data') / 'ca.crt')
except OSError:
self.skipTest('symlinks not available')
if tls_mod.ssl_file_path(str(ca)) is None:
self.skipTest('symlink CA path not readable as regular file')
config.agent_ssl_trusted_ca_path = str(ca)
verify, _cert = requests_tls_settings()
self.assertIsInstance(verify, str)
self.assertTrue(Path(verify).is_file())
stored = Path(verify)
try:
(root / '..data-next').symlink_to('v2')
os.replace(root / '..data-next', root / '..data')
shutil.rmtree(root / 'v1')
except OSError:
self.skipTest('symlink rotation not available')
self.assertEqual(ca.read_bytes(), _TEST_CA_CERT)
self.assertTrue(stored.is_file())
self.assertEqual(
stored.read_bytes(),
tls_mod._extract_ca_pem_blocks(_TEST_CA_CERT),
)
def test_extract_ca_pem_blocks_preserves_trusted_label(self):
mixed = (
b'\xef\xbb\xbf# preamble \xe6\xb3\xa8\xe9\x87\x8a\n'
b'-----BEGIN TRUSTED CERTIFICATE-----\n'
b'THJ1c3RlZA==\n'
b'-----END TRUSTED CERTIFICATE-----\n'
b'-----BEGIN CERTIFICATE-----\n'
b'Y2VydA==\n'
b'-----END CERTIFICATE-----\n'
)
out = tls_mod._extract_ca_pem_blocks(mixed)
self.assertTrue(out.startswith(b'-----BEGIN TRUSTED CERTIFICATE-----'))
self.assertIn(b'-----BEGIN CERTIFICATE-----', out)
self.assertIn(b'THJ1c3RlZA==', out)
self.assertNotIn(b'preamble', out)
self.assertNotIn('\ufeff'.encode('utf-8'), out)
def test_requests_ca_temp_failure_falls_back_to_configured_path(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
def boom(*_a, **_k):
raise OSError(30, 'Read-only file system')
with patch('tempfile.mkstemp', side_effect=boom), \
self.assertLogs('skywalking', level='WARNING') as logs:
verify, cert = requests_tls_settings()
self.assertIsInstance(verify, str)
# Unresolved configured path (absolute), not a deleted resolve() target.
self.assertEqual(Path(verify), Path(ca).expanduser().absolute())
self.assertTrue(Path(verify).is_file())
self.assertIsNone(cert)
self.assertTrue(any('configured CA path' in line for line in logs.output))
def test_requests_ca_temp_failure_keeps_symlink_across_k8s_rotation(self):
"""No-temp fallback + K8s secret rotation must keep the configured symlink."""
import shutil
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for version in ('v1', 'v2'):
(root / version).mkdir()
(root / version / 'ca.crt').write_bytes(_TEST_CA_CERT)
try:
(root / '..data').symlink_to('v1')
ca = root / 'ca.crt'
ca.symlink_to(Path('..data') / 'ca.crt')
except OSError:
self.skipTest('symlinks not available')
if tls_mod.ssl_file_path(str(ca)) is None:
self.skipTest('symlink CA path not readable as regular file')
config.agent_ssl_trusted_ca_path = str(ca)
def boom(*_a, **_k):
raise OSError(30, 'Read-only file system')
with patch('tempfile.mkstemp', side_effect=boom), \
self.assertLogs('skywalking', level='WARNING'):
verify, _cert = requests_tls_settings()
self.assertIsInstance(verify, str)
stored = Path(verify)
# Must be the configured symlink, not the resolved v1/ target.
self.assertEqual(stored, ca.expanduser().absolute())
self.assertNotEqual(stored.parent.name, 'v1')
try:
(root / '..data-next').symlink_to('v2')
os.replace(root / '..data-next', root / '..data')
shutil.rmtree(root / 'v1')
except OSError:
self.skipTest('symlink rotation not available')
self.assertEqual(ca.read_bytes(), _TEST_CA_CERT)
self.assertTrue(stored.is_file())
self.assertEqual(stored.read_bytes(), _TEST_CA_CERT)
def test_trusted_certificate_pem_enables_sync_http_tls(self):
import shutil
import subprocess
if shutil.which('openssl') is None:
self.skipTest('openssl not available')
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.pem'
trusted = Path(tmp) / 'trusted.pem'
ca.write_bytes(_TEST_CA_CERT)
try:
subprocess.run(
[
'openssl', 'x509', '-in', str(ca), '-addtrust', 'serverAuth',
'-trustout', '-out', str(trusted),
],
check=True,
capture_output=True,
)
except (OSError, subprocess.CalledProcessError) as exc:
self.skipTest(f'openssl trustout failed: {exc}')
text = trusted.read_text(encoding='ascii', errors='ignore')
self.assertIn('BEGIN TRUSTED CERTIFICATE', text)
ssl.create_default_context(cafile=str(trusted))
config.agent_ssl_trusted_ca_path = str(trusted)
material = tls_pem_material()
self.assertIsNotNone(material)
roots, private_key, chain = material
self.assertIsNotNone(roots)
self.assertIn(b'BEGIN TRUSTED CERTIFICATE', roots)
self.assertNotIn(b'-----BEGIN CERTIFICATE-----', roots)
self.assertIsNone(private_key)
self.assertIsNone(chain)
self.assertEqual(collector_http_scheme(), 'https://')
verify, cert = requests_tls_settings()
self.assertIsInstance(verify, str)
self.assertTrue(Path(verify).is_file())
self.assertIn(b'BEGIN TRUSTED CERTIFICATE', Path(verify).read_bytes())
self.assertIsNone(cert)
def test_aio_mixed_trusted_and_certificate_bundle_uses_cafile(self):
"""cadata must not silently drop TRUSTED blocks from a mixed CA bundle."""
import shutil
import subprocess
if shutil.which('openssl') is None:
self.skipTest('openssl not available')
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ca = root / 'ca.pem'
cert = root / 'server.pem'
trusted = root / 'trusted.pem'
bundle = root / 'bundle.pem'
ca.write_bytes(_TEST_CA_CERT)
cert.write_bytes(_TEST_CLIENT_CERT)
try:
subprocess.run(
[
'openssl', 'x509', '-in', str(cert), '-addtrust', 'serverAuth',
'-trustout', '-out', str(trusted),
],
check=True,
capture_output=True,
)
except (OSError, subprocess.CalledProcessError) as exc:
self.skipTest(f'openssl trustout failed: {exc}')
bundle.write_bytes(trusted.read_bytes() + ca.read_bytes())
direct = ssl.create_default_context(cafile=str(bundle))
self.assertGreaterEqual(direct.cert_store_stats()['x509'], 2)
config.agent_ssl_trusted_ca_path = str(bundle)
material = tls_pem_material()
self.assertIsNotNone(material)
roots = material[0]
self.assertIn(b'BEGIN TRUSTED CERTIFICATE', roots)
self.assertIn(b'BEGIN CERTIFICATE', roots)
self.assertTrue(tls_mod._ca_pem_has_trusted_certificate(roots))
# Prove cadata alone would under-load the mixed bundle.
cadata_only = ssl.create_default_context(cadata=roots.decode('ascii'))
self.assertLess(
cadata_only.cert_store_stats()['x509'],
direct.cert_store_stats()['x509'],
)
ctx = ssl_context_for_collector()
self.assertIsInstance(ctx, ssl.SSLContext)
self.assertEqual(
ctx.cert_store_stats()['x509'],
direct.cert_store_stats()['x509'],
)
def test_ssl_context_skips_cadata_when_trusted_label_present(self):
"""Control-flow guard: TRUSTED marker forces cafile (no openssl required)."""
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
mixed = (
b'-----BEGIN TRUSTED CERTIFICATE-----\n'
b'MIIB\n'
b'-----END TRUSTED CERTIFICATE-----\n'
) + _TEST_CA_CERT
self.assertTrue(tls_mod._ca_pem_has_trusted_certificate(mixed))
calls = []
real_cdc = ssl.create_default_context
def spy(*_a, **kwargs):
calls.append(dict(kwargs))
if 'cadata' in kwargs:
raise AssertionError(
'cadata must not be used when TRUSTED CERTIFICATE is present'
)
# Load a known-good CA file regardless of the snapshot path.
return real_cdc(cafile=str(ca))
with patch.object(tls_mod, 'tls_pem_material', return_value=(mixed, None, None)), \
patch.object(tls_mod, '_ca_verify_temp_file', return_value=str(ca)), \
patch('ssl.create_default_context', side_effect=spy):
ctx = ssl_context_for_collector()
self.assertIsInstance(ctx, ssl.SSLContext)
self.assertTrue(any('cafile' in c for c in calls))
self.assertFalse(any('cadata' in c for c in calls))
def test_aio_context_accepts_bom_and_utf8_preamble_ca(self):
with tempfile.TemporaryDirectory() as tmp:
ca = Path(tmp) / 'ca.crt'
config.agent_ssl_trusted_ca_path = str(ca)
for label, prefix in [
('plain', b''),
('bom', b'\xef\xbb\xbf'),
('utf8-comment', '# 注释\n'.encode('utf-8')),
]:
ca.write_bytes(prefix + _TEST_CA_CERT)
ssl.create_default_context(cafile=str(ca))
tls_mod._warned_keys.clear()
self.assertEqual(collector_http_scheme(), 'https://', label)
ctx = ssl_context_for_collector()
self.assertIsInstance(ctx, ssl.SSLContext, label)
def test_create_default_context_oserror_degrades(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
with patch('ssl.create_default_context', side_effect=PermissionError(13, 'Permission denied')), \
self.assertLogs('skywalking', level='WARNING') as logs:
# Validation and SSLContext build both call create_default_context.
self.assertIsNone(tls_pem_material())
self.assertTrue(any('plaintext' in line for line in logs.output))
config.agent_force_tls = True
tls_mod._warned_keys.clear()
call_count = {'n': 0}
def _ctx(*_a, **kwargs):
call_count['n'] += 1
if kwargs.get('cadata') or kwargs.get('cafile'):
raise PermissionError(13, 'Permission denied')
return ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
with patch('ssl.create_default_context', side_effect=_ctx), \
self.assertLogs('skywalking', level='WARNING') as logs:
material = tls_pem_material()
self.assertEqual(material, (None, None, None))
ctx = ssl_context_for_collector()
self.assertIsInstance(ctx, ssl.SSLContext)
self.assertTrue(any('process trust store' in line for line in logs.output))
def test_system_trust_failure_after_bad_cadata_does_not_raise(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
config.agent_force_tls = True
config.agent_ssl_trusted_ca_path = str(ca)
def _ctx(*_a, **kwargs):
if kwargs.get('cadata') or kwargs.get('cafile'):
raise PermissionError(13, 'Permission denied')
raise PermissionError(13, 'system trust boom')
# Bypass early CA validation so we exercise ssl_context_for_collector's
# FORCE + cadata-fail → system-trust path.
with patch.object(tls_mod, 'tls_pem_material', return_value=(_TEST_CA_CERT, None, None)), \
patch('ssl.create_default_context', side_effect=_ctx), \
self.assertLogs('skywalking', level='WARNING') as logs:
self.assertIsNone(ssl_context_for_collector())
self.assertTrue(any('plaintext' in line for line in logs.output))
def test_after_fork_rebind_clears_child_bookkeeping_without_unlink(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
_verify, pair = requests_tls_settings()
self.assertIsNotNone(pair)
parent_list = tls_mod._mtls_temp_files
cert_file, key_file = pair
self.assertTrue(parent_list)
self.assertIn(cert_file, parent_list)
self.assertIn(key_file, parent_list)
self.assertTrue(os.path.exists(cert_file))
tls_mod._after_fork_in_child()
self.assertIsNot(tls_mod._mtls_temp_files, parent_list)
self.assertEqual(tls_mod._mtls_temp_files, [])
self.assertIsNone(tls_mod._mtls_file_cache)
self.assertIsNone(tls_mod._ca_file_cache)
# Simulate child atexit against the rebound empty list.
tls_mod._cleanup_mtls_temp_files()
self.assertTrue(os.path.exists(cert_file))
self.assertTrue(os.path.exists(key_file))
# Parent still holds the original list object with live paths.
self.assertIn(cert_file, parent_list)
self.assertIn(key_file, parent_list)
# Restore parent bookkeeping so tearDown can unlink temps.
tls_mod._mtls_temp_files = parent_list
tls_mod._mtls_file_cache = pair
tls_mod._mtls_file_cache_key = (
Path(cert_file).read_bytes(),
Path(key_file).read_bytes(),
)
if _verify is not True and isinstance(_verify, str):
tls_mod._ca_file_cache = _verify
tls_mod._ca_file_cache_key = Path(_verify).read_bytes()
@unittest.skipUnless(hasattr(os, 'fork'), 'os.fork required')
def test_fork_child_exit_does_not_delete_parent_mtls_temps(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
crt = self._write_pem(tmp, 'client.crt', _TEST_CLIENT_CERT)
key = self._write_pem(tmp, 'client.pem', _TEST_CLIENT_KEY_PKCS1)
config.agent_ssl_trusted_ca_path = str(ca)
config.agent_ssl_cert_chain_path = str(crt)
config.agent_ssl_key_path = str(key)
verify, pair = requests_tls_settings()
self.assertIsNotNone(pair)
cert_file, key_file = pair
self.assertTrue(os.path.exists(cert_file))
self.assertTrue(os.path.exists(key_file))
pid = os.fork()
if pid == 0:
# Run atexit like a normal shutdown, then hard-exit. Raising
# SystemExit under pytest in a forked child is unreliable
# (non-zero wait status / parent sees the exception).
import atexit
atexit._run_exitfuncs()
os._exit(0)
_pid, status = os.waitpid(pid, 0)
self.assertTrue(os.WIFEXITED(status), status)
self.assertEqual(os.WEXITSTATUS(status), 0)
self.assertTrue(os.path.exists(cert_file), 'parent cert temp deleted by child')
self.assertTrue(os.path.exists(key_file), 'parent key temp deleted by child')
self.assertEqual(tls_mod._mtls_file_cache, pair)
def test_mtls_partial_temp_failure_does_not_orphan_cert(self):
# Second mkstemp (key) fails after cert was written → cert must be unlinked.
calls = {'n': 0}
real_mkstemp = tempfile.mkstemp
def mkstemp_fail_on_key(*args, **kwargs):
calls['n'] += 1
if calls['n'] == 1:
return real_mkstemp(*args, **kwargs)
raise OSError(30, 'Read-only file system')
with patch('tempfile.mkstemp', side_effect=mkstemp_fail_on_key), \
self.assertLogs('skywalking', level='WARNING') as logs:
pair = tls_mod._mtls_cert_key_files(_TEST_CLIENT_CERT, _TEST_CLIENT_KEY_PKCS1)
self.assertIsNone(pair)
self.assertEqual(tls_mod._mtls_temp_files, [])
self.assertTrue(any('temp files' in line for line in logs.output))
def test_configure_requests_session_fallback_prefers_configured_ca(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
session = MagicMock()
with patch('skywalking.utils.tls.requests_tls_settings', side_effect=RuntimeError('boom')), \
self.assertLogs('skywalking', level='WARNING'):
tls_mod.configure_requests_session(session)
self.assertEqual(session.verify, str(Path(ca).expanduser().absolute()))
self.assertIsNone(session.cert)
def test_http_scheme_and_settings_share_one_material(self):
with tempfile.TemporaryDirectory() as tmp:
ca = self._write_pem(tmp, 'ca.crt', _TEST_CA_CERT)
config.agent_ssl_trusted_ca_path = str(ca)
material = tls_pem_material()
self.assertIsNotNone(material)
calls = {'n': 0}
real = tls_mod.tls_pem_material
def counting():
calls['n'] += 1
return real()
with patch.object(tls_mod, 'tls_pem_material', side_effect=counting):
# Shared material: helpers must not reload.
self.assertEqual(collector_http_scheme(material), 'https://')
verify, cert = requests_tls_settings(material)
self.assertIsInstance(verify, str)
self.assertIsNone(cert)
self.assertIsInstance(ssl_context_for_collector(material), ssl.SSLContext)
self.assertEqual(calls['n'], 0)
def test_extract_pem_blocks_removed(self):
self.assertFalse(hasattr(tls_mod, '_extract_pem_blocks'))
if __name__ == '__main__':
unittest.main()