diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java index cbb67f45..54ef0d05 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java @@ -56,9 +56,8 @@ public boolean validate() { } if (isNullOrEmpty(serverCert)) { - LOG.warning( - "Credential \"" + CRED_OTLP_SERVER_CERT + "\" not found. Skipping cloud-logging exporter configuration"); - return false; + LOG.info("Credential \"" + CRED_OTLP_SERVER_CERT + + "\" not present in service binding. Falling back to the JVM default trust store for the ingest endpoint."); } return true; } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProvider.java index f5eecc1f..34754a13 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProvider.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProvider.java @@ -3,6 +3,9 @@ import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudLoggingServicesProvider; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXPORTER; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.BindingServerCertificateSource; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.SystemTrustAnchorSource; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.TrustedCertificatesJoiner; import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporterBuilder; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; @@ -23,15 +26,21 @@ public class CloudLoggingLogsExporterProvider implements ConfigurableLogRecordEx private final Function> servicesProvider; private final CloudLoggingCredentials.Parser credentialParser; + private final Function trustedCertificatesProvider; public CloudLoggingLogsExporterProvider() { - this(config -> new CloudLoggingServicesProvider(config).get(), CloudLoggingCredentials.parser()); + this(config -> new CloudLoggingServicesProvider(config).get(), + CloudLoggingCredentials.parser(), + credentials -> TrustedCertificatesJoiner.toPemBytes(new SystemTrustAnchorSource(), + new BindingServerCertificateSource(credentials.getServerCert()))); } CloudLoggingLogsExporterProvider(Function> serviceProvider, - CloudLoggingCredentials.Parser credentialParser) { + CloudLoggingCredentials.Parser credentialParser, + Function trustedCertificatesProvider) { this.servicesProvider = serviceProvider; this.credentialParser = credentialParser; + this.trustedCertificatesProvider = trustedCertificatesProvider; } private static String getCompression(ConfigProperties config) { @@ -65,7 +74,8 @@ private LogRecordExporter createExporter(ConfigProperties config, CloudFoundrySe OtlpGrpcLogRecordExporterBuilder builder = OtlpGrpcLogRecordExporter.builder(); builder.setEndpoint(credentials.getEndpoint()).setCompression(getCompression(config)) .setClientTls(credentials.getClientKey(), credentials.getClientCert()) - .setTrustedCertificates(credentials.getServerCert()).setRetryPolicy(RetryPolicy.getDefault()); + .setTrustedCertificates(trustedCertificatesProvider.apply(credentials)) + .setRetryPolicy(RetryPolicy.getDefault()); Duration timeOut = getTimeOut(config); if (timeOut != null) { diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java index 414c0328..c204358f 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java @@ -3,6 +3,9 @@ import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudLoggingServicesProvider; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXPORTER; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.BindingServerCertificateSource; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.SystemTrustAnchorSource; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.TrustedCertificatesJoiner; import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporterBuilder; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; @@ -32,15 +35,21 @@ public class CloudLoggingMetricsExporterProvider implements ConfigurableMetricEx private final Function> servicesProvider; private final CloudLoggingCredentials.Parser credentialParser; + private final Function trustedCertificatesProvider; public CloudLoggingMetricsExporterProvider() { - this(config -> new CloudLoggingServicesProvider(config).get(), CloudLoggingCredentials.parser()); + this(config -> new CloudLoggingServicesProvider(config).get(), + CloudLoggingCredentials.parser(), + credentials -> TrustedCertificatesJoiner.toPemBytes(new SystemTrustAnchorSource(), + new BindingServerCertificateSource(credentials.getServerCert()))); } CloudLoggingMetricsExporterProvider(Function> serviceProvider, - CloudLoggingCredentials.Parser credentialParser) { + CloudLoggingCredentials.Parser credentialParser, + Function trustedCertificatesProvider) { this.servicesProvider = serviceProvider; this.credentialParser = credentialParser; + this.trustedCertificatesProvider = trustedCertificatesProvider; } private static String getCompression(ConfigProperties config) { @@ -114,7 +123,8 @@ private MetricExporter createExporter(ConfigProperties config, CloudFoundryServi OtlpGrpcMetricExporterBuilder builder = OtlpGrpcMetricExporter.builder(); builder.setEndpoint(credentials.getEndpoint()).setCompression(getCompression(config)) .setClientTls(credentials.getClientKey(), credentials.getClientCert()) - .setTrustedCertificates(credentials.getServerCert()).setRetryPolicy(RetryPolicy.getDefault()) + .setTrustedCertificates(trustedCertificatesProvider.apply(credentials)) + .setRetryPolicy(RetryPolicy.getDefault()) .setAggregationTemporalitySelector(getAggregationTemporalitySelector(config)) .setDefaultAggregationSelector(getDefaultAggregationSelector(config)); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProvider.java index c6b22897..e2962d81 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProvider.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProvider.java @@ -3,6 +3,9 @@ import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudLoggingServicesProvider; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXPORTER; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.BindingServerCertificateSource; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.SystemTrustAnchorSource; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.TrustedCertificatesJoiner; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporterBuilder; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; @@ -23,15 +26,21 @@ public class CloudLoggingSpanExporterProvider implements ConfigurableSpanExporte private final Function> servicesProvider; private final CloudLoggingCredentials.Parser credentialParser; + private final Function trustedCertificatesProvider; public CloudLoggingSpanExporterProvider() { - this(config -> new CloudLoggingServicesProvider(config).get(), CloudLoggingCredentials.parser()); + this(config -> new CloudLoggingServicesProvider(config).get(), + CloudLoggingCredentials.parser(), + credentials -> TrustedCertificatesJoiner.toPemBytes(new SystemTrustAnchorSource(), + new BindingServerCertificateSource(credentials.getServerCert()))); } CloudLoggingSpanExporterProvider(Function> serviceProvider, - CloudLoggingCredentials.Parser credentialParser) { + CloudLoggingCredentials.Parser credentialParser, + Function trustedCertificatesProvider) { this.servicesProvider = serviceProvider; this.credentialParser = credentialParser; + this.trustedCertificatesProvider = trustedCertificatesProvider; } private static String getCompression(ConfigProperties config) { @@ -65,7 +74,8 @@ private SpanExporter createExporter(ConfigProperties config, CloudFoundryService OtlpGrpcSpanExporterBuilder builder = OtlpGrpcSpanExporter.builder(); builder.setEndpoint(credentials.getEndpoint()).setCompression(getCompression(config)) .setClientTls(credentials.getClientKey(), credentials.getClientCert()) - .setTrustedCertificates(credentials.getServerCert()).setRetryPolicy(RetryPolicy.getDefault()); + .setTrustedCertificates(trustedCertificatesProvider.apply(credentials)) + .setRetryPolicy(RetryPolicy.getDefault()); Duration timeOut = getTimeOut(config); if (timeOut != null) { diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/BindingServerCertificateSource.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/BindingServerCertificateSource.java new file mode 100644 index 00000000..e56efe44 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/BindingServerCertificateSource.java @@ -0,0 +1,43 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Supplies the X.509 certificate carried by a Cloud Foundry service binding's + * {@code server-ca} field (or an equivalent field name for other bindings). + * + *

The input is expected to be the raw PEM bytes as they arrive in the binding. + * A {@code null} or empty input yields an empty stream, so the source can be used + * unconditionally with {@link TrustedCertificatesJoiner}.

+ */ +public class BindingServerCertificateSource implements X509CertificateSource { + + private static final Logger LOG = Logger.getLogger(BindingServerCertificateSource.class.getName()); + + private final byte[] pemBytes; + + public BindingServerCertificateSource(byte[] pemBytes) { + this.pemBytes = pemBytes; + } + + @Override + public Stream stream() { + if (pemBytes == null || pemBytes.length == 0) { + return Stream.empty(); + } + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(pemBytes)); + return Stream.of(certificate); + } catch (CertificateException e) { + LOG.log(Level.WARNING, e, () -> "Failed to parse server-ca from service binding; it will be omitted from the trust anchors."); + return Stream.empty(); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/DownloadedServerCertificateSource.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/DownloadedServerCertificateSource.java new file mode 100644 index 00000000..b9ba079d --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/DownloadedServerCertificateSource.java @@ -0,0 +1,49 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Supplies the X.509 leaf certificate returned by {@link ServerCertificateDownloader} + * for a fixed OTLP endpoint URL. + * + *

This is the fallback path used when a service binding does not carry a + * {@code server-ca} field. It connects to the endpoint with TLS (validation disabled) + * and reads back the leaf certificate the server presents.

+ */ +public class DownloadedServerCertificateSource implements X509CertificateSource { + + private static final Logger LOG = Logger.getLogger(DownloadedServerCertificateSource.class.getName()); + + private final ServerCertificateDownloader downloader; + private final String endpointUrl; + + public DownloadedServerCertificateSource(ServerCertificateDownloader downloader, String endpointUrl) { + this.downloader = downloader; + this.endpointUrl = endpointUrl; + } + + @Override + public Stream stream() { + String pem = downloader.download(endpointUrl); + if (pem == null || pem.isEmpty()) { + return Stream.empty(); + } + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = (X509Certificate) factory + .generateCertificate(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8))); + return Stream.of(certificate); + } catch (CertificateException e) { + LOG.log(Level.WARNING, e, () -> "Failed to parse server certificate downloaded from " + endpointUrl + + "; it will be omitted from the trust anchors."); + return Stream.empty(); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemEncoder.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemEncoder.java new file mode 100644 index 00000000..74fa7d25 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemEncoder.java @@ -0,0 +1,32 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Base64; + +/** + * Encodes X.509 certificates as PEM strings. + * + *

Line separator is {@code "\n"} and Base64 body is wrapped at 64 characters, + * matching the format expected by OpenTelemetry's {@code setTrustedCertificates(byte[])}.

+ */ +final class PemEncoder { + + private static final String LINE_SEPARATOR = "\n"; + private static final Base64.Encoder BASE64_ENCODER = + Base64.getMimeEncoder(64, LINE_SEPARATOR.getBytes(StandardCharsets.UTF_8)); + + private PemEncoder() { + } + + /** + * Returns the PEM encoding of the given certificate, including the + * {@code BEGIN CERTIFICATE} / {@code END CERTIFICATE} armor and a trailing newline. + */ + static String encode(X509Certificate certificate) throws CertificateEncodingException { + return "-----BEGIN CERTIFICATE-----" + LINE_SEPARATOR + + BASE64_ENCODER.encodeToString(certificate.getEncoded()) + LINE_SEPARATOR + + "-----END CERTIFICATE-----" + LINE_SEPARATOR; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloader.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloader.java index be8880a5..acf99e8b 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloader.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloader.java @@ -3,22 +3,18 @@ import javax.net.ssl.*; import java.io.IOException; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; -import java.util.Base64; import java.util.logging.Level; import java.util.logging.Logger; public class ServerCertificateDownloader { private static final Logger LOG = Logger.getLogger(ServerCertificateDownloader.class.getName()); - private static final byte[] LINE_SEPARATOR = "\n".getBytes(StandardCharsets.UTF_8); - private static final Base64.Encoder BASE64_ENCODER = Base64.getMimeEncoder(64, LINE_SEPARATOR); private final SSLSocketFactory sslSocketFactory; @@ -78,12 +74,7 @@ public String download(String endpointUrl) { return null; } - X509Certificate x509Cert = (X509Certificate) serverCertificates[0]; - byte[] encoded = x509Cert.getEncoded(); - return "-----BEGIN CERTIFICATE-----\n" // - + BASE64_ENCODER.encodeToString(encoded) // - + "\n-----END CERTIFICATE-----\n"; - + return PemEncoder.encode((X509Certificate) serverCertificates[0]); } } catch (CertificateEncodingException | IOException e) { LOG.log(Level.WARNING, e, () -> "Failed to download server certificate from " + endpointUrl); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/SystemTrustAnchorSource.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/SystemTrustAnchorSource.java new file mode 100644 index 00000000..6fea9453 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/SystemTrustAnchorSource.java @@ -0,0 +1,44 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Supplies the X.509 trust anchors known to the JVM's default trust store + * (typically {@code $JAVA_HOME/lib/security/cacerts}). + * + *

This is the same set the platform uses to validate ordinary HTTPS connections, + * so any endpoint whose server certificate chains to a public root is trusted without + * additional configuration.

+ * + *

The stream aggregates the accepted issuers of every + * {@link X509TrustManager} returned by the default {@link TrustManagerFactory}, so a + * runtime with multiple configured trust managers contributes all of them.

+ */ +public class SystemTrustAnchorSource implements X509CertificateSource { + + private static final Logger LOG = Logger.getLogger(SystemTrustAnchorSource.class.getName()); + + @Override + public Stream stream() { + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((KeyStore) null); + return Arrays.stream(tmf.getTrustManagers()) + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .flatMap(tm -> Arrays.stream(tm.getAcceptedIssuers())); + } catch (NoSuchAlgorithmException | KeyStoreException e) { + LOG.log(Level.WARNING, e, () -> "Failed to enumerate JVM default trust anchors; system trust anchors will be omitted."); + return Stream.empty(); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/TrustedCertificatesJoiner.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/TrustedCertificatesJoiner.java new file mode 100644 index 00000000..cd563e3a --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/TrustedCertificatesJoiner.java @@ -0,0 +1,44 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Concatenates the PEM encodings of the certificates from one or more + * {@link X509CertificateSource} instances into a single byte array suitable for + * {@code OtlpGrpc*ExporterBuilder.setTrustedCertificates(byte[])}. + * + *

Sources are consumed in the order given; certificates that fail to encode + * are logged and skipped so a single bad certificate does not break the exporter.

+ */ +public final class TrustedCertificatesJoiner { + + private static final Logger LOG = Logger.getLogger(TrustedCertificatesJoiner.class.getName()); + + private TrustedCertificatesJoiner() { + } + + /** + * Encodes every certificate produced by the given sources as PEM and returns the + * concatenated bytes (UTF-8). Empty sources contribute nothing; the returned array + * is empty if no source yields a certificate. + */ + public static byte[] toPemBytes(X509CertificateSource... sources) { + StringBuilder pem = new StringBuilder(); + for (X509CertificateSource source : sources) { + source.stream().forEach(cert -> appendPem(pem, cert)); + } + return pem.toString().getBytes(StandardCharsets.UTF_8); + } + + private static void appendPem(StringBuilder pem, X509Certificate cert) { + try { + pem.append(PemEncoder.encode(cert)); + } catch (CertificateEncodingException e) { + LOG.log(Level.WARNING, e, () -> "Failed to PEM-encode a trust anchor; it will be omitted."); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/X509CertificateSource.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/X509CertificateSource.java new file mode 100644 index 00000000..2392c218 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/X509CertificateSource.java @@ -0,0 +1,24 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import java.security.cert.X509Certificate; +import java.util.stream.Stream; + +/** + * Supplies zero or more X.509 certificates that should be added to the OTLP exporter's + * trust anchors. Implementations may source the certificates from a service binding, + * a network download, the JVM's default trust store, or any other origin. + * + *

The returned stream must be finite and may be empty if the source cannot deliver + * a certificate (e.g. a binding field is missing). Implementations should not throw; + * they should log and return an empty stream so callers can safely aggregate multiple + * sources with {@link TrustedCertificatesJoiner}.

+ */ +@FunctionalInterface +public interface X509CertificateSource { + + /** + * Returns the certificates supplied by this source. Callers must consume the + * stream fully; sources are not required to be repeatable. + */ + Stream stream(); +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentialsTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentialsTest.java index 934e8b36..05979b25 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentialsTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentialsTest.java @@ -58,12 +58,13 @@ void missingClientCert() { } @Test - void missingServerCert() { + void missingServerCertIsOptional() { CloudFoundryCredentials.Builder builder = builder().add("ingest-otlp-endpoint", "test-endpoint").add("ingest-otlp-cert", VALID_CLIENT_CERT) .add("ingest-otlp-key", VALID_CLIENT_KEY); CloudLoggingCredentials credentials = PARSER.parse(builder.build()); - assertThat(credentials.validate()).isFalse(); + assertThat(credentials.validate()).isTrue(); + assertThat(credentials.getServerCert()).isNull(); } @Test diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProviderTest.java index 8c556039..08055f70 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProviderTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingLogsExporterProviderTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; @@ -32,21 +31,24 @@ public class CloudLoggingLogsExporterProviderTest { @Mock private CloudLoggingCredentials.Parser credentialParser; + @Mock(strictness = LENIENT) + private Function trustedCertificatesProvider; + @Mock(strictness = LENIENT) private ConfigProperties config; - @InjectMocks private CloudLoggingLogsExporterProvider exporterProvider; @BeforeEach - void setUp() { + void setUp() throws IOException { when(config.getString(any(), any())).thenAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { return invocation.getArguments()[1]; } }); - + when(trustedCertificatesProvider.apply(any())).thenReturn(PEMUtil.read("certificate.pem")); + exporterProvider = new CloudLoggingLogsExporterProvider(servicesProvider, credentialParser, trustedCertificatesProvider); } @Test @@ -89,7 +91,6 @@ void registersExportersWithValidBindings() throws IOException { when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); - when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); when(credentialParser.parse(any())).thenReturn(invalidCredentials).thenReturn(validCredentials); LogRecordExporter exporter = exporterProvider.createExporter(config); assertThat(exporter).isNotNull(); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProviderTest.java index 38770416..d4960bb1 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProviderTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProviderTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; @@ -34,21 +33,24 @@ public class CloudLoggingMetricsExporterProviderTest { @Mock private CloudLoggingCredentials.Parser credentialParser; + @Mock(strictness = LENIENT) + private Function trustedCertificatesProvider; + @Mock(strictness = LENIENT) private ConfigProperties config; - @InjectMocks private CloudLoggingMetricsExporterProvider exporterProvider; @BeforeEach - void setUp() { + void setUp() throws IOException { when(config.getString(any(), any())).thenAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { return invocation.getArguments()[1]; } }); - + when(trustedCertificatesProvider.apply(any())).thenReturn(PEMUtil.read("certificate.pem")); + exporterProvider = new CloudLoggingMetricsExporterProvider(servicesProvider, credentialParser, trustedCertificatesProvider); } @Test @@ -91,7 +93,6 @@ void registersExportersWithValidBindings() throws IOException { when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); - when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); when(credentialParser.parse(any())).thenReturn(invalidCredentials).thenReturn(validCredentials); MetricExporter exporter = exporterProvider.createExporter(config); assertThat(exporter).isNotNull(); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProviderTest.java index 788790f6..1f70bafa 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProviderTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingSpanExporterProviderTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; @@ -34,21 +33,24 @@ public class CloudLoggingSpanExporterProviderTest { @Mock private CloudLoggingCredentials.Parser credentialParser; + @Mock(strictness = LENIENT) + private Function trustedCertificatesProvider; + @Mock(strictness = LENIENT) private ConfigProperties config; - @InjectMocks private CloudLoggingSpanExporterProvider exporterProvider; @BeforeEach - void setUp() { + void setUp() throws IOException { when(config.getString(any(), any())).thenAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { return invocation.getArguments()[1]; } }); - + when(trustedCertificatesProvider.apply(any())).thenReturn(PEMUtil.read("certificate.pem")); + exporterProvider = new CloudLoggingSpanExporterProvider(servicesProvider, credentialParser, trustedCertificatesProvider); } @Test @@ -91,7 +93,6 @@ void registersExportersWithValidBindings() throws IOException { when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); - when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); when(credentialParser.parse(any())).thenReturn(invalidCredentials).thenReturn(validCredentials); SpanExporter exporter = exporterProvider.createExporter(config); assertThat(exporter).isNotNull(); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/BindingServerCertificateSourceTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/BindingServerCertificateSourceTest.java new file mode 100644 index 00000000..b0042756 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/BindingServerCertificateSourceTest.java @@ -0,0 +1,49 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class BindingServerCertificateSourceTest { + + private static byte[] validPemBytes() throws Exception { + try (InputStream is = BindingServerCertificateSourceTest.class.getClassLoader() + .getResourceAsStream("certificate.pem")) { + assumeThat(is).as("test resource certificate.pem must be present on the classpath").isNotNull(); + return is.readAllBytes(); + } + } + + @Test + void parsesValidPemIntoASingleCertificate() throws Exception { + BindingServerCertificateSource source = new BindingServerCertificateSource(validPemBytes()); + + assertThat(source.stream()).hasSize(1); + } + + @Test + void yieldsEmptyStreamForNullInput() { + BindingServerCertificateSource source = new BindingServerCertificateSource(null); + + assertThat(source.stream()).isEmpty(); + } + + @Test + void yieldsEmptyStreamForEmptyInput() { + BindingServerCertificateSource source = new BindingServerCertificateSource(new byte[0]); + + assertThat(source.stream()).isEmpty(); + } + + @Test + void yieldsEmptyStreamForMalformedInput() { + BindingServerCertificateSource source = + new BindingServerCertificateSource("not a pem".getBytes(StandardCharsets.UTF_8)); + + assertThat(source.stream()).isEmpty(); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/DownloadedServerCertificateSourceTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/DownloadedServerCertificateSourceTest.java new file mode 100644 index 00000000..2f69342a --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/DownloadedServerCertificateSourceTest.java @@ -0,0 +1,54 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DownloadedServerCertificateSourceTest { + + private static final String ENDPOINT = "https://example.com:443"; + + @Mock + private ServerCertificateDownloader downloader; + + private static String validPem() throws Exception { + try (InputStream is = DownloadedServerCertificateSourceTest.class.getClassLoader() + .getResourceAsStream("certificate.pem")) { + assumeThat(is).as("test resource certificate.pem must be present on the classpath").isNotNull(); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Test + void parsesDownloadedPemIntoASingleCertificate() throws Exception { + when(downloader.download(ENDPOINT)).thenReturn(validPem()); + DownloadedServerCertificateSource source = new DownloadedServerCertificateSource(downloader, ENDPOINT); + + assertThat(source.stream()).hasSize(1); + } + + @Test + void yieldsEmptyStreamWhenDownloaderReturnsNull() { + when(downloader.download(ENDPOINT)).thenReturn(null); + DownloadedServerCertificateSource source = new DownloadedServerCertificateSource(downloader, ENDPOINT); + + assertThat(source.stream()).isEmpty(); + } + + @Test + void yieldsEmptyStreamWhenDownloaderReturnsMalformedPem() { + when(downloader.download(ENDPOINT)).thenReturn("not a pem"); + DownloadedServerCertificateSource source = new DownloadedServerCertificateSource(downloader, ENDPOINT); + + assertThat(source.stream()).isEmpty(); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemEncoderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemEncoderTest.java new file mode 100644 index 00000000..aa89c691 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemEncoderTest.java @@ -0,0 +1,62 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class PemEncoderTest { + + private static final X509Certificate CERTIFICATE = loadTestCertificate(); + + private static X509Certificate loadTestCertificate() { + try (InputStream is = PemEncoderTest.class.getClassLoader().getResourceAsStream("certificate.pem")) { + assumeThat(is).as("test resource certificate.pem must be present on the classpath").isNotNull(); + return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(is); + } catch (Exception e) { + throw new IllegalStateException("Failed to load test certificate", e); + } + } + + @Test + void encodesCertificateWithArmorAndTrailingNewline() throws Exception { + String pem = PemEncoder.encode(CERTIFICATE); + + assertThat(pem).startsWith("-----BEGIN CERTIFICATE-----\n").endsWith("-----END CERTIFICATE-----\n"); + } + + @Test + void encodedOutputRoundtripsBackToTheSameCertificate() throws Exception { + String pem = PemEncoder.encode(CERTIFICATE); + + X509Certificate parsed = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8))); + assertThat(parsed).isEqualTo(CERTIFICATE); + } + + @Test + void wrapsBase64BodyAt64Characters() throws Exception { + String pem = PemEncoder.encode(CERTIFICATE); + String body = pem.replace("-----BEGIN CERTIFICATE-----\n", "").replace("\n-----END CERTIFICATE-----\n", ""); + + for (String line : body.split("\n")) { + // The last line may be shorter; every other line must be exactly 64 chars. + assertThat(line.length()).isLessThanOrEqualTo(64); + } + } + + @Test + void isCompatibleWithPreviousServerCertificateDownloaderFormat() throws Exception { + // Guard against accidental drift: the format must remain interchangeable with the + // one previously produced by ServerCertificateDownloader#download. + String pem = PemEncoder.encode(CERTIFICATE); + + assertThat(pem).matches("(?s)^-----BEGIN CERTIFICATE-----\\n([A-Za-z0-9+/=]{1,64}\\n)+-----END CERTIFICATE-----\\n$"); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/SystemTrustAnchorSourceTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/SystemTrustAnchorSourceTest.java new file mode 100644 index 00000000..d57ed1c8 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/SystemTrustAnchorSourceTest.java @@ -0,0 +1,28 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; + +class SystemTrustAnchorSourceTest { + + @Test + void yieldsTheJvmDefaultTrustAnchors() { + SystemTrustAnchorSource source = new SystemTrustAnchorSource(); + + assertThat(source.stream()).isNotEmpty() + .allSatisfy(cert -> assertThat(cert).isInstanceOf(X509Certificate.class)); + } + + @Test + void repeatedInvocationsProduceIndependentStreams() { + SystemTrustAnchorSource source = new SystemTrustAnchorSource(); + + long first = source.stream().count(); + long second = source.stream().count(); + + assertThat(first).isPositive().isEqualTo(second); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/TrustedCertificatesJoinerTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/TrustedCertificatesJoinerTest.java new file mode 100644 index 00000000..11764c0a --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/TrustedCertificatesJoinerTest.java @@ -0,0 +1,48 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class TrustedCertificatesJoinerTest { + + private static byte[] validPemBytes() throws Exception { + try (InputStream is = TrustedCertificatesJoinerTest.class.getClassLoader() + .getResourceAsStream("certificate.pem")) { + assumeThat(is).as("test resource certificate.pem must be present on the classpath").isNotNull(); + return is.readAllBytes(); + } + } + + @Test + void returnsEmptyBytesWhenNoSourcesProvided() { + assertThat(TrustedCertificatesJoiner.toPemBytes()).isEmpty(); + } + + @Test + void returnsEmptyBytesWhenAllSourcesAreEmpty() { + byte[] result = TrustedCertificatesJoiner.toPemBytes(new BindingServerCertificateSource(null), + new BindingServerCertificateSource(new byte[0])); + + assertThat(result).isEmpty(); + } + + @Test + void concatenatesCertificatesFromMultipleSourcesInOrder() throws Exception { + byte[] result = TrustedCertificatesJoiner.toPemBytes(new SystemTrustAnchorSource(), + new BindingServerCertificateSource(validPemBytes())); + + String pem = new String(result, StandardCharsets.UTF_8); + long beginMarkers = pem.lines().filter(l -> l.equals("-----BEGIN CERTIFICATE-----")).count(); + long endMarkers = pem.lines().filter(l -> l.equals("-----END CERTIFICATE-----")).count(); + assertThat(beginMarkers).isEqualTo(endMarkers).isGreaterThan(1); + + // The binding source is passed last, so its PEM must appear at the end of the joined output. + byte[] bindingOnly = TrustedCertificatesJoiner.toPemBytes(new BindingServerCertificateSource(validPemBytes())); + assertThat(pem).endsWith(new String(bindingOnly, StandardCharsets.UTF_8)); + } +}