From d98d65eee5961799e6409dda1dc2b6aa3cae760f Mon Sep 17 00:00:00 2001 From: Nilay Shah <58663029+njshah301@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:59:05 +0530 Subject: [PATCH] Add mosapi client to intract with ICANN's monitoring system (#2892) * Add mosapi client to intract with ICANN's monitoring system This change introduces a comprehensive client to interact with ICANN's Monitoring System API (MoSAPI). This provides direct, automated access to critical registry health and compliance data, moving Nomulus towards a more proactive monitoring posture. A core, stateless MosApiClient that manages communication and authentication with the MoSAPI service using TLS client certificates. * Resolve review feedback & upgrade to OkHttp3 client This commit addresses and resolves all outstanding review comments, primarily encompassing a shift to OkHttp3, security configuration cleanup, and general code improvements. * **Review:** Addressed and resolved all pending review comments. * **Feature:** Switched the underlying HTTP client implementation to [OkHttp3](https://square.github.io/okhttp/). * **Configuration:** Consolidated TLS Certificates-related configuration into the dedicated configuration area. * **Cleanup:** Removed unused components (`HttpUtils` and `HttpModule`) and performed general code cleanup. * **Quality:** Improved exception handling logic for better robustness. * Refactor and fix Mosapi exception handling Addresses code review feedback and resulting test failures. - Flattens package structure by moving MosApiException and its test. - Corrects exception handling to ensure MosApiAuthorizationException propagates correctly, before the general exception handler. - Adds a default case to the MosApiException factory for robustness. - Uses lowercase for placeholder TLDs in default-config.yaml. * Refactor and improve Mosapi client implementation Simplifying URL validation with Guava Preconditions and refining exception handling to use `Throwables`. * Refactor precondition checks using project specific utility --- .../registry/config/RegistryConfig.java | 47 ++++ .../config/RegistryConfigSettings.java | 11 + .../registry/config/files/default-config.yaml | 27 +++ .../registry/module/RegistryComponent.java | 2 + .../google/registry/mosapi/MosApiClient.java | 150 +++++++++++++ .../registry/mosapi/MosApiException.java | 110 ++++++++++ .../registry/mosapi/MosApiResponse.java | 62 ++++++ .../mosapi/model/MosApiErrorResponse.java | 23 ++ .../registry/mosapi/module/MosApiModule.java | 187 ++++++++++++++++ .../google/registry/mosapi/package-info.java | 16 ++ .../module/TestRegistryComponent.java | 2 + .../registry/mosapi/MosApiClientTest.java | 203 ++++++++++++++++++ .../registry/mosapi/MosApiExceptionTest.java | 100 +++++++++ .../mosapi/model/MosApiErrorResponseTest.java | 41 ++++ .../mosapi/module/MosApiModuleTest.java | 190 ++++++++++++++++ .../gateway/nomulus-route-backend.yaml | 9 + 16 files changed, 1180 insertions(+) create mode 100644 core/src/main/java/google/registry/mosapi/MosApiClient.java create mode 100644 core/src/main/java/google/registry/mosapi/MosApiException.java create mode 100644 core/src/main/java/google/registry/mosapi/MosApiResponse.java create mode 100644 core/src/main/java/google/registry/mosapi/model/MosApiErrorResponse.java create mode 100644 core/src/main/java/google/registry/mosapi/module/MosApiModule.java create mode 100644 core/src/main/java/google/registry/mosapi/package-info.java create mode 100644 core/src/test/java/google/registry/mosapi/MosApiClientTest.java create mode 100644 core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java create mode 100644 core/src/test/java/google/registry/mosapi/model/MosApiErrorResponseTest.java create mode 100644 core/src/test/java/google/registry/mosapi/module/MosApiModuleTest.java diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 3f97dbe9d..131966003 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -36,6 +36,7 @@ import dagger.Provides; import google.registry.bsa.UploadBsaUnavailableDomainsAction; import google.registry.dns.ReadDnsRefreshRequestsAction; import google.registry.model.common.DnsRefreshRequest; +import google.registry.mosapi.MosApiClient; import google.registry.persistence.transaction.JpaTransactionManager; import google.registry.request.Action.Service; import google.registry.util.RegistryEnvironment; @@ -1415,6 +1416,52 @@ public final class RegistryConfig { return config.bsa.uploadUnavailableDomainsUrl; } + /** + * Returns the URL we send HTTP requests for MoSAPI. + * + * @see MosApiClient + */ + @Provides + @Config("mosapiServiceUrl") + public static String provideMosapiServiceUrl(RegistryConfigSettings config) { + return config.mosapi.serviceUrl; + } + + /** + * Returns the entityType we send HTTP requests for MoSAPI. + * + * @see MosApiClient + */ + @Provides + @Config("mosapiEntityType") + public static String provideMosapiEntityType(RegistryConfigSettings config) { + return config.mosapi.entityType; + } + + @Provides + @Config("mosapiTlsCertSecretName") + public static String provideMosapiTlsCertSecretName(RegistryConfigSettings config) { + return config.mosapi.tlsCertSecretName; + } + + @Provides + @Config("mosapiTlsCertKeyName") + public static String provideMosapiTlsKeySecretName(RegistryConfigSettings config) { + return config.mosapi.tlsKeySecretName; + } + + @Provides + @Config("mosapiTlds") + public static ImmutableSet provideMosapiTlds(RegistryConfigSettings config) { + return ImmutableSet.copyOf(config.mosapi.tlds); + } + + @Provides + @Config("mosapiServices") + public static ImmutableSet provideMosapiServices(RegistryConfigSettings config) { + return ImmutableSet.copyOf(config.mosapi.services); + } + private static String formatComments(String text) { return Splitter.on('\n').omitEmptyStrings().trimResults().splitToList(text).stream() .map(s -> "# " + s) diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 1f0a6cace..1d94be171 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -43,6 +43,7 @@ public class RegistryConfigSettings { public DnsUpdate dnsUpdate; public BulkPricingPackageMonitoring bulkPricingPackageMonitoring; public Bsa bsa; + public MosApi mosapi; /** Configuration options that apply to the entire GCP project. */ public static class GcpProject { @@ -262,4 +263,14 @@ public class RegistryConfigSettings { public String unblockableDomainsUrl; public String uploadUnavailableDomainsUrl; } + + /** Configuration for Mosapi. */ + public static class MosApi { + public String serviceUrl; + public String tlsCertSecretName; + public String tlsKeySecretName; + public String entityType; + public List tlds; + public List services; + } } diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 923e3d57f..060665e9f 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -616,3 +616,30 @@ bsa: unblockableDomainsUrl: "https://" # API endpoint for uploading the list of unavailable domain names. uploadUnavailableDomainsUrl: "https://" + +mosapi: + # URL for the MosAPI + serviceUrl: https://mosapi.icann.org + # The type of entity being monitored. + # For registries, this is 'ry' + # For registrars, this is 'rr' + entityType: ry + # Add your List of TLDs to be monitored + tlds: + - your_tld1 + - your_tld2 + # Add tls cert secret name + # you configured in secret manager + tlsCertSecretName: YOUR_TLS_CERT_SECRET_NAME + # Add tls key secret name + # you configured in secret manager + tlsKeySecretName: YOUR_TLS_KEY_SECRET_NAME + # List of services to check for each TLD. + services: + - "dns" + - "rdap" + - "rdds" + - "epp" + - "dnssec" + + diff --git a/core/src/main/java/google/registry/module/RegistryComponent.java b/core/src/main/java/google/registry/module/RegistryComponent.java index f8b00fe86..7f7170340 100644 --- a/core/src/main/java/google/registry/module/RegistryComponent.java +++ b/core/src/main/java/google/registry/module/RegistryComponent.java @@ -40,6 +40,7 @@ import google.registry.keyring.api.KeyModule; import google.registry.module.RegistryComponent.RegistryModule; import google.registry.module.RequestComponent.RequestComponentModule; import google.registry.monitoring.whitebox.StackdriverModule; +import google.registry.mosapi.module.MosApiModule; import google.registry.persistence.PersistenceModule; import google.registry.privileges.secretmanager.SecretManagerModule; import google.registry.rde.JSchModule; @@ -71,6 +72,7 @@ import jakarta.inject.Singleton; GroupsModule.class, GroupssettingsModule.class, GsonModule.class, + MosApiModule.class, JSchModule.class, KeyModule.class, KeyringModule.class, diff --git a/core/src/main/java/google/registry/mosapi/MosApiClient.java b/core/src/main/java/google/registry/mosapi/MosApiClient.java new file mode 100644 index 000000000..af0022d88 --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/MosApiClient.java @@ -0,0 +1,150 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +package google.registry.mosapi; + +import static org.apache.beam.sdk.util.Preconditions.checkArgumentNotNull; + +import com.google.common.base.Throwables; +import google.registry.config.RegistryConfig.Config; +import google.registry.mosapi.MosApiException.MosApiAuthorizationException; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.Map; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +@Singleton +public class MosApiClient { + + private final OkHttpClient httpClient; + private final String baseUrl; + + @Inject + public MosApiClient( + @Named("mosapiHttpClient") OkHttpClient httpClient, + @Config("mosapiServiceUrl") String mosapiUrl, + @Config("mosapiEntityType") String entityType) { + this.httpClient = httpClient; + // Pre-calculate base URL and validate it to fail fast on bad config + String fullUrl = String.format("%s/%s", mosapiUrl, entityType); + checkArgumentNotNull( + HttpUrl.parse(fullUrl), "Invalid MoSAPI Service URL configuration: %s", fullUrl); + + this.baseUrl = fullUrl; + } + + /** + * Sends a GET request to the specified MoSAPI endpoint. + * + * @param entityId The TLD or registrar ID the request is for. + * @param endpoint The specific API endpoint path (e.g., "v2/monitoring/state"). + * @param params A map of query parameters to be URL-encoded and appended to the request. + * @param headers A map of HTTP headers to be included in the request. + * @return The {@link Response} from the server if the request is successful. The caller is + * responsible for closing this response. + * @throws MosApiException if the request fails due to a network error or an unhandled HTTP + * status. + * @throws MosApiAuthorizationException if the server returns a 401 Unauthorized status. + */ + public Response sendGetRequest( + String entityId, String endpoint, Map params, Map headers) + throws MosApiException { + HttpUrl url = buildUri(entityId, endpoint, params); + Request.Builder requestBuilder = new Request.Builder().url(url).get(); + headers.forEach(requestBuilder::addHeader); + try { + Response response = httpClient.newCall(requestBuilder.build()).execute(); + return checkResponseForAuthError(response); + } catch (RuntimeException | IOException e) { + // Check if it's the specific authorization exception (re-thrown or caught here) + Throwables.throwIfInstanceOf(e, MosApiAuthorizationException.class); + // Otherwise, treat as a generic connection/API error + throw new MosApiException("Error during GET request to " + url, e); + } + } + + /** + * Sends a POST request to the specified MoSAPI endpoint. + * + *

Note: This method is for future use. There are currently no MoSAPI endpoints in the + * project scope that require a POST request. + * + * @param entityId The TLD or registrar ID the request is for. + * @param endpoint The specific API endpoint path. + * @param params A map of query parameters to be URL-encoded. + * @param headers A map of HTTP headers to be included in the request. + * @param body The request body to be sent with the POST request. + * @return The {@link Response} from the server. The caller is responsible for closing this + * response. + * @throws MosApiException if the request fails. + * @throws MosApiAuthorizationException if the server returns a 401 Unauthorized status. + */ + public Response sendPostRequest( + String entityId, + String endpoint, + Map params, + Map headers, + String body) + throws MosApiException { + HttpUrl url = buildUri(entityId, endpoint, params); + RequestBody requestBody = RequestBody.create(body, MediaType.parse("application/json")); + + Request.Builder requestBuilder = new Request.Builder().url(url).post(requestBody); + headers.forEach(requestBuilder::addHeader); + try { + Response response = httpClient.newCall(requestBuilder.build()).execute(); + return checkResponseForAuthError(response); + } catch (RuntimeException | IOException e) { + // Check if it's the specific authorization exception (re-thrown or caught here) + Throwables.throwIfInstanceOf(e, MosApiAuthorizationException.class); + // Otherwise, treat as a generic connection/API error + throw new MosApiException("Error during POST request to " + url, e); + } + } + + private Response checkResponseForAuthError(Response response) + throws MosApiAuthorizationException { + if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) { + response.close(); + throw new MosApiAuthorizationException( + "Authorization failed for the requested resource. The client certificate may not be" + + " authorized for the specified TLD or Registrar."); + } + return response; + } + + /** + * Builds the full URL for a request, including the base URL, entityId, path, and query params. + */ + private HttpUrl buildUri(String entityId, String path, Map queryParams) { + String sanitizedPath = path.startsWith("/") ? path.substring(1) : path; + + // We can safely use get() here because we validated baseUrl in the constructor + HttpUrl.Builder urlBuilder = + HttpUrl.get(baseUrl).newBuilder().addPathSegment(entityId).addPathSegments(sanitizedPath); + + if (queryParams != null) { + queryParams.forEach(urlBuilder::addQueryParameter); + } + return urlBuilder.build(); + } +} diff --git a/core/src/main/java/google/registry/mosapi/MosApiException.java b/core/src/main/java/google/registry/mosapi/MosApiException.java new file mode 100644 index 000000000..e38e0236e --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/MosApiException.java @@ -0,0 +1,110 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +package google.registry.mosapi; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import google.registry.mosapi.model.MosApiErrorResponse; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.Optional; + +/** Custom exception for MoSAPI client errors. */ +public class MosApiException extends IOException { + + private final MosApiErrorResponse errorResponse; + + public MosApiException(MosApiErrorResponse errorResponse) { + super( + String.format( + "MoSAPI returned an error (code: %s): %s", + errorResponse.resultCode(), errorResponse.message())); + this.errorResponse = errorResponse; + } + + public MosApiException(String message, Throwable cause) { + super(message, cause); + this.errorResponse = null; + } + + public Optional getErrorResponse() { + return Optional.ofNullable(errorResponse); + } + + /** Annotation for associating a MoSAPI result code with an exception subclass. */ + @Documented + @Retention(RUNTIME) + @Target(TYPE) + public @interface MosApiResultCode { + String value(); + } + + /** Thrown when MoSAPI returns a 401 Unauthorized error. */ + public static class MosApiAuthorizationException extends MosApiException { + public MosApiAuthorizationException(String message) { + super(message, null); + } + } + + /** Creates a specific exception based on the MoSAPI error response. */ + public static MosApiException create(MosApiErrorResponse errorResponse) { + Optional responseEnum = MosApiResponse.fromCode(errorResponse.resultCode()); + if (responseEnum.isPresent()) { + return switch (responseEnum.get()) { + case DATE_DURATION_INVALID -> new DateDurationInvalidException(errorResponse); + case DATE_ORDER_INVALID -> new DateOrderInvalidException(errorResponse); + case START_DATE_SYNTAX_INVALID -> new StartDateSyntaxInvalidException(errorResponse); + case END_DATE_SYNTAX_INVALID -> new EndDateSyntaxInvalidException(errorResponse); + default -> new MosApiException(errorResponse); + }; + } + return new MosApiException(errorResponse); + } + + /** Thrown when the date duration in a MoSAPI request is invalid. */ + @MosApiResultCode("2011") + public static class DateDurationInvalidException extends MosApiException { + public DateDurationInvalidException(MosApiErrorResponse errorResponse) { + super(errorResponse); + } + } + + /** Thrown when the date order in a MoSAPI request is invalid. */ + @MosApiResultCode("2012") + public static class DateOrderInvalidException extends MosApiException { + public DateOrderInvalidException(MosApiErrorResponse errorResponse) { + super(errorResponse); + } + } + + /** Thrown when the startDate syntax in a MoSAPI request is invalid. */ + @MosApiResultCode("2013") + public static class StartDateSyntaxInvalidException extends MosApiException { + public StartDateSyntaxInvalidException(MosApiErrorResponse errorResponse) { + super(errorResponse); + } + } + + /** Thrown when the endDate syntax in a MoSAPI request is invalid. */ + @MosApiResultCode("2014") + public static class EndDateSyntaxInvalidException extends MosApiException { + public EndDateSyntaxInvalidException(MosApiErrorResponse errorResponse) { + super(errorResponse); + } + } +} diff --git a/core/src/main/java/google/registry/mosapi/MosApiResponse.java b/core/src/main/java/google/registry/mosapi/MosApiResponse.java new file mode 100644 index 000000000..63519585e --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/MosApiResponse.java @@ -0,0 +1,62 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +package google.registry.mosapi; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Represents known MoSAPI API result codes and their default messages. + * + *

The definitions for these codes can be found in the official ICANN MoSAPI Specification, + * specifically in the 'Result Codes' section. + * + * @see ICANN MoSAPI Specification + */ +public enum MosApiResponse { + DATE_DURATION_INVALID( + "2011", "The difference between endDate and startDate is " + "more than 31 days"), + DATE_ORDER_INVALID("2012", "The EndDate is before startDate"), + START_DATE_SYNTAX_INVALID("2013", "StartDate syntax is invalid"), + END_DATE_SYNTAX_INVALID("2014", "EndDate syntax is invalid"); + + private final String code; + private final String defaultMessage; + + private static final Map CODE_MAP = + Arrays.stream(values()).collect(Collectors.toMap(e -> e.code, Function.identity())); + + MosApiResponse(String code, String defaultMessage) { + this.code = code; + this.defaultMessage = defaultMessage; + } + + public String getCode() { + return code; + } + + public String getDefaultMessage() { + return defaultMessage; + } + + // Returns the enum constant associated with the given result code string + public static Optional fromCode(String code) { + + return Optional.ofNullable(CODE_MAP.get(code)); + } +} diff --git a/core/src/main/java/google/registry/mosapi/model/MosApiErrorResponse.java b/core/src/main/java/google/registry/mosapi/model/MosApiErrorResponse.java new file mode 100644 index 000000000..afd41e69e --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/model/MosApiErrorResponse.java @@ -0,0 +1,23 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +package google.registry.mosapi.model; + +/** + * Represents the generic JSON error response from the MoSAPI service for a 400 Bad Request. + * + * @see ICANN MoSAPI Specification, Section + * 8 + */ +public record MosApiErrorResponse(String resultCode, String message, String description) {} diff --git a/core/src/main/java/google/registry/mosapi/module/MosApiModule.java b/core/src/main/java/google/registry/mosapi/module/MosApiModule.java new file mode 100644 index 000000000..69f331f17 --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/module/MosApiModule.java @@ -0,0 +1,187 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +package google.registry.mosapi.module; + +import dagger.Module; +import dagger.Provides; +import google.registry.config.RegistryConfig.Config; +import google.registry.privileges.secretmanager.SecretManagerClient; +import jakarta.inject.Named; +import jakarta.inject.Provider; +import jakarta.inject.Singleton; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.Optional; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import okhttp3.OkHttpClient; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; + +@Module +public final class MosApiModule { + + // Secret Manager constants + private static final String LATEST_SECRET_VERSION = "latest"; + + // @Named annotations for Dagger + private static final String MOSAPI_TLS_CERT = "mosapiTlsCert"; + private static final String MOSAPI_TLS_KEY = "mosapiTlsKey"; + private static final String MOSAPI_SSL_CONTEXT = "mosapiSslContext"; + private static final String MOSAPI_HTTP_CLIENT = "mosapiHttpClient"; + + // Cryptography-related constants + private static final String CERTIFICATE_TYPE = "X.509"; + private static final String KEY_STORE_TYPE = "PKCS12"; + private static final String KEY_STORE_ALIAS = "client"; + private static final String SSL_CONTEXT_PROTOCOL = "TLS"; + + /** + * Provides a Provider for the MoSAPI TLS Cert. + * + *

This method returns a Dagger {@link Provider} that can be used to fetch the TLS Certs for a + * MosAPI. + * + * @param secretManagerClient The injected Secret Manager client. + * @param tlsCertSecretName The name of the secret in Secret Manager (from config). + * @return A Provider for the MoSAPI TLS Certs. + */ + @Provides + @Named(MOSAPI_TLS_CERT) + public static String provideMosapiTlsCert( + SecretManagerClient secretManagerClient, + @Config("mosapiTlsCertSecretName") String tlsCertSecretName) { + return secretManagerClient.getSecretData(tlsCertSecretName, Optional.of(LATEST_SECRET_VERSION)); + } + + /** + * Provides a Provider for the MoSAPI TLS Key. + * + *

This method returns a Dagger {@link Provider} that can be used to fetch the TLS Key for a + * MosAPI. + * + * @param secretManagerClient The injected Secret Manager client. + * @param tlsKeySecretName The name of the secret in Secret Manager (from config). + * @return A Provider for the MoSAPI TLS Key. + */ + @Provides + @Named(MOSAPI_TLS_KEY) + public static String provideMosapiTlsKey( + SecretManagerClient secretManagerClient, + @Config("mosapiTlsKeySecretName") String tlsKeySecretName) { + return secretManagerClient.getSecretData(tlsKeySecretName, Optional.of(LATEST_SECRET_VERSION)); + } + + @Provides + static Certificate provideCertificate(@Named(MOSAPI_TLS_CERT) String tlsCert) { + try { + CertificateFactory cf = CertificateFactory.getInstance(CERTIFICATE_TYPE); + return cf.generateCertificate( + new ByteArrayInputStream(tlsCert.getBytes(StandardCharsets.UTF_8))); + } catch (CertificateException e) { + throw new RuntimeException("Could not create X.509 certificate from provided PEM", e); + } + } + + @Provides + static PrivateKey providePrivateKey(@Named(MOSAPI_TLS_KEY) String tlsKey) { + try (PEMParser pemParser = new PEMParser(new StringReader(tlsKey))) { + Object parsedObj = pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + if (parsedObj instanceof PEMKeyPair) { + return converter.getPrivateKey(((PEMKeyPair) parsedObj).getPrivateKeyInfo()); + } else if (parsedObj instanceof PrivateKeyInfo) { + return converter.getPrivateKey((PrivateKeyInfo) parsedObj); + } + throw new IllegalArgumentException( + String.format( + "Could not parse TLS private key; unexpected format %s", + parsedObj != null ? parsedObj.getClass().getName() : "null")); + } catch (IOException e) { + throw new RuntimeException("Could not parse TLS private key from PEM string", e); + } + } + + @Provides + static KeyStore provideKeyStore(PrivateKey privateKey, Certificate certificate) { + try { + KeyStore keyStore = KeyStore.getInstance(KEY_STORE_TYPE); + keyStore.load(null, null); + keyStore.setKeyEntry( + KEY_STORE_ALIAS, privateKey, new char[0], new Certificate[] {certificate}); + return keyStore; + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException("Could not create KeyStore for mTLS", e); + } + } + + @Provides + static KeyManagerFactory provideKeyManagerFactory(KeyStore keyStore) { + try { + KeyManagerFactory kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, new char[0]); + return kmf; + } catch (GeneralSecurityException e) { + throw new RuntimeException("Could not initialize KeyManagerFactory", e); + } + } + + @Provides + @Named(MOSAPI_SSL_CONTEXT) + static SSLContext provideSslContext(KeyManagerFactory keyManagerFactory) { + try { + SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL); + sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + return sslContext; + } catch (GeneralSecurityException e) { + throw new RuntimeException("Could not initialize SSLContext", e); + } + } + + @Provides + static X509TrustManager provideTrustManager() { + try { + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + return (X509TrustManager) trustManagerFactory.getTrustManagers()[0]; + } catch (GeneralSecurityException e) { + throw new RuntimeException("Could not initialize TrustManager", e); + } + } + + @Provides + @Singleton + @Named(MOSAPI_HTTP_CLIENT) + static OkHttpClient provideMosapiHttpClient( + @Named(MOSAPI_SSL_CONTEXT) SSLContext sslContext, X509TrustManager trustManager) { + return new OkHttpClient.Builder() + .sslSocketFactory(sslContext.getSocketFactory(), trustManager) + .build(); + } +} diff --git a/core/src/main/java/google/registry/mosapi/package-info.java b/core/src/main/java/google/registry/mosapi/package-info.java new file mode 100644 index 000000000..5c5056dcc --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/package-info.java @@ -0,0 +1,16 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +@javax.annotation.ParametersAreNonnullByDefault +package google.registry.mosapi; diff --git a/core/src/test/java/google/registry/module/TestRegistryComponent.java b/core/src/test/java/google/registry/module/TestRegistryComponent.java index 94129fcbe..4e1446f29 100644 --- a/core/src/test/java/google/registry/module/TestRegistryComponent.java +++ b/core/src/test/java/google/registry/module/TestRegistryComponent.java @@ -34,6 +34,7 @@ import google.registry.keyring.KeyringModule; import google.registry.keyring.api.KeyModule; import google.registry.module.TestRequestComponent.TestRequestComponentModule; import google.registry.monitoring.whitebox.StackdriverModule; +import google.registry.mosapi.module.MosApiModule; import google.registry.persistence.PersistenceModule; import google.registry.privileges.secretmanager.SecretManagerModule; import google.registry.rde.JSchModule; @@ -61,6 +62,7 @@ import jakarta.inject.Singleton; GroupsModule.class, GroupssettingsModule.class, GsonModule.class, + MosApiModule.class, JSchModule.class, KeyModule.class, KeyringModule.class, diff --git a/core/src/test/java/google/registry/mosapi/MosApiClientTest.java b/core/src/test/java/google/registry/mosapi/MosApiClientTest.java new file mode 100644 index 000000000..5603076aa --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/MosApiClientTest.java @@ -0,0 +1,203 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +package google.registry.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import google.registry.mosapi.MosApiException.MosApiAuthorizationException; +import java.io.IOException; +import java.util.Map; +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +public class MosApiClientTest { + private static final String SERVICE_URL = "https://mosapi.example.com/v1"; + private static final String ENTITY_TYPE = "registries"; + + // Mocks + private OkHttpClient mockHttpClient; + private Call mockCall; + + private MosApiClient mosApiClient; + + @BeforeEach + void setUp() { + mockHttpClient = mock(OkHttpClient.class); + mockCall = mock(Call.class); + when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall); + mosApiClient = new MosApiClient(mockHttpClient, SERVICE_URL, ENTITY_TYPE); + } + + @Test + void testConstructor_throwsOnInvalidUrl() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> new MosApiClient(mockHttpClient, "ht tp://bad-url", ENTITY_TYPE)); + assertThat(thrown).hasMessageThat().contains("Invalid MoSAPI Service URL"); + } + + // --- GET Request Tests --- + + @Test + void testSendGetRequest_success() throws Exception { + // 1. Prepare Success Response + Response successResponse = createResponse(200, "{\"status\":\"ok\"}"); + when(mockCall.execute()).thenReturn(successResponse); + + Map params = ImmutableMap.of("since", "2024-01-01"); + Map headers = ImmutableMap.of("Authorization", "Bearer token123"); + + // 2. Execute + try (Response response = + mosApiClient.sendGetRequest("tld-1", "monitoring/state", params, headers)) { + + // Verify Response + assertThat(response.isSuccessful()).isTrue(); + assertThat(response.body().string()).isEqualTo("{\"status\":\"ok\"}"); + + // 3. Verify Request Construction + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(mockHttpClient).newCall(requestCaptor.capture()); + Request capturedRequest = requestCaptor.getValue(); + + // Check URL: + assertThat(capturedRequest.method()).isEqualTo("GET"); + assertThat(capturedRequest.url().encodedPath()) + .isEqualTo("/v1/registries/tld-1/monitoring/state"); + assertThat(capturedRequest.url().queryParameter("since")).isEqualTo("2024-01-01"); + + // Check Headers + assertThat(capturedRequest.header("Authorization")).isEqualTo("Bearer token123"); + } + } + + @Test + void testSendGetRequest_throwsOn401() throws IOException { + // Prepare 401 Response + Response unauthorizedResponse = createResponse(401, "Unauthorized"); + when(mockCall.execute()).thenReturn(unauthorizedResponse); + + MosApiAuthorizationException thrown = + assertThrows( + MosApiAuthorizationException.class, + () -> + mosApiClient.sendGetRequest("tld-1", "path", ImmutableMap.of(), ImmutableMap.of())); + + assertThat(thrown).hasMessageThat().contains("Authorization failed"); + } + + @Test + void testSendGetRequest_wrapsIoException() throws IOException { + // Simulate Network Failure + when(mockCall.execute()).thenThrow(new IOException("Network error")); + + // Execute & Assert + MosApiException thrown = + assertThrows( + MosApiException.class, + () -> + mosApiClient.sendGetRequest("tld-1", "path", ImmutableMap.of(), ImmutableMap.of())); + + assertThat(thrown).hasMessageThat().contains("Error during GET request to"); + assertThat(thrown).hasCauseThat().isInstanceOf(IOException.class); + } + + // --- POST Request Tests --- + + @Test + void testSendPostRequest_success() throws Exception { + // 1. Prepare Response + Response successResponse = createResponse(200, "{\"updated\":true}"); + when(mockCall.execute()).thenReturn(successResponse); + + String requestBody = "{\"data\":\"update\"}"; + Map headers = ImmutableMap.of("Content-Type", "application/json"); + + try (Response response = + mosApiClient.sendPostRequest("tld-1", "update", null, headers, requestBody)) { + + assertThat(response.isSuccessful()).isTrue(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(mockHttpClient).newCall(requestCaptor.capture()); + Request capturedRequest = requestCaptor.getValue(); + + assertThat(capturedRequest.method()).isEqualTo("POST"); + assertThat(capturedRequest.url().encodedPath()).isEqualTo("/v1/registries/tld-1/update"); + + // Verify Body content + Buffer buffer = new Buffer(); + capturedRequest.body().writeTo(buffer); + assertThat(buffer.readUtf8()).isEqualTo(requestBody); + } + } + + @Test + void testSendPostRequest_throwsOn401() throws IOException { + // Prepare 401 Response + Response unauthorizedResponse = createResponse(401, "Unauthorized"); + when(mockCall.execute()).thenReturn(unauthorizedResponse); + + MosApiAuthorizationException thrown = + assertThrows( + MosApiAuthorizationException.class, + () -> + mosApiClient.sendPostRequest( + "tld-1", "path", ImmutableMap.of(), ImmutableMap.of(), "{}")); + + assertThat(thrown).hasMessageThat().contains("Authorization failed"); + } + + @Test + void testSendPostRequest_wrapsIoException() throws IOException { + // Simulate Network Failure + when(mockCall.execute()).thenThrow(new IOException("Network error")); + MosApiException thrown = + assertThrows( + MosApiException.class, + () -> + mosApiClient.sendPostRequest( + "tld-1", "path", ImmutableMap.of(), ImmutableMap.of(), "{}")); + assertThat(thrown).hasMessageThat().contains("Error during POST request to"); + assertThat(thrown).hasCauseThat().isInstanceOf(IOException.class); + } + + /** Helper to build a real OkHttp Response object manually. */ + private Response createResponse(int code, String bodyContent) { + return new Response.Builder() + .request(new Request.Builder().url("http://localhost/").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("Msg") + .body(ResponseBody.create(bodyContent, MediaType.parse("application/json"))) + .build(); + } +} diff --git a/core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java b/core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java new file mode 100644 index 000000000..38fb9d0ed --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java @@ -0,0 +1,100 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. +package google.registry.mosapi; + +import static com.google.common.truth.Truth.assertThat; + +import google.registry.mosapi.MosApiException.DateDurationInvalidException; +import google.registry.mosapi.MosApiException.DateOrderInvalidException; +import google.registry.mosapi.MosApiException.EndDateSyntaxInvalidException; +import google.registry.mosapi.MosApiException.MosApiAuthorizationException; +import google.registry.mosapi.MosApiException.StartDateSyntaxInvalidException; +import google.registry.mosapi.model.MosApiErrorResponse; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link MosApiException}. */ +public class MosApiExceptionTest { + + @Test + void testConstructor_withErrorResponse() { + MosApiErrorResponse errorResponse = + new MosApiErrorResponse("1234", "Test Message", "Test Description"); + MosApiException exception = new MosApiException(errorResponse); + assertThat(exception) + .hasMessageThat() + .isEqualTo("MoSAPI returned an error (code: 1234): Test Message"); + assertThat(exception.getErrorResponse()).hasValue(errorResponse); + } + + @Test + void testConstructor_withMessageAndCause() { + RuntimeException cause = new RuntimeException("Root Cause"); + MosApiException exception = new MosApiException("Wrapper Message", cause); + assertThat(exception).hasMessageThat().isEqualTo("Wrapper Message"); + assertThat(exception).hasCauseThat().isEqualTo(cause); + assertThat(exception.getErrorResponse()).isEmpty(); + } + + @Test + void testAuthorizationException() { + MosApiAuthorizationException exception = new MosApiAuthorizationException("Unauthorized"); + assertThat(exception).isInstanceOf(MosApiException.class); + assertThat(exception).hasMessageThat().isEqualTo("Unauthorized"); + assertThat(exception.getErrorResponse()).isEmpty(); + } + + @Test + void testCreate_forDateDurationInvalid() { + MosApiErrorResponse errorResponse = + new MosApiErrorResponse("2011", "Duration invalid", "Description"); + MosApiException exception = MosApiException.create(errorResponse); + assertThat(exception).isInstanceOf(DateDurationInvalidException.class); + assertThat(exception.getErrorResponse()).hasValue(errorResponse); + } + + @Test + void testCreate_forDateOrderInvalid() { + MosApiErrorResponse errorResponse = + new MosApiErrorResponse("2012", "End date before start date", "Description"); + MosApiException exception = MosApiException.create(errorResponse); + assertThat(exception).isInstanceOf(DateOrderInvalidException.class); + assertThat(exception.getErrorResponse()).hasValue(errorResponse); + } + + @Test + void testCreate_forStartDateSyntaxInvalid() { + MosApiErrorResponse errorResponse = + new MosApiErrorResponse("2013", "Invalid start date format", "Description"); + MosApiException exception = MosApiException.create(errorResponse); + assertThat(exception).isInstanceOf(StartDateSyntaxInvalidException.class); + assertThat(exception.getErrorResponse()).hasValue(errorResponse); + } + + @Test + void testCreate_forEndDateSyntaxInvalid() { + MosApiErrorResponse errorResponse = + new MosApiErrorResponse("2014", "Invalid end date format", "Description"); + MosApiException exception = MosApiException.create(errorResponse); + assertThat(exception).isInstanceOf(EndDateSyntaxInvalidException.class); + assertThat(exception.getErrorResponse()).hasValue(errorResponse); + } + + @Test + void testCreate_forUnknownCode() { + MosApiErrorResponse errorResponse = new MosApiErrorResponse("9999", "Unknown", "Description"); + MosApiException exception = MosApiException.create(errorResponse); + assertThat(exception.getClass()).isEqualTo(MosApiException.class); + assertThat(exception.getErrorResponse()).hasValue(errorResponse); + } +} diff --git a/core/src/test/java/google/registry/mosapi/model/MosApiErrorResponseTest.java b/core/src/test/java/google/registry/mosapi/model/MosApiErrorResponseTest.java new file mode 100644 index 000000000..1c0078ed0 --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/model/MosApiErrorResponseTest.java @@ -0,0 +1,41 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. +package google.registry.mosapi.model; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link MosApiErrorResponse}. */ +public class MosApiErrorResponseTest { + + @Test + void testJsonDeserialization() { + String json = + """ + { + "resultCode": "2012", + "message": "The endDate is before the startDate.", + "description": "Validation failed" + } + """; + + MosApiErrorResponse response = new Gson().fromJson(json, MosApiErrorResponse.class); + + assertThat(response.resultCode()).isEqualTo("2012"); + assertThat(response.message()).isEqualTo("The endDate is before the startDate."); + assertThat(response.description()).isEqualTo("Validation failed"); + } +} diff --git a/core/src/test/java/google/registry/mosapi/module/MosApiModuleTest.java b/core/src/test/java/google/registry/mosapi/module/MosApiModuleTest.java new file mode 100644 index 000000000..f413164d5 --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/module/MosApiModuleTest.java @@ -0,0 +1,190 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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. + +package google.registry.mosapi.module; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import google.registry.privileges.secretmanager.SecretManagerClient; +import java.io.StringWriter; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; +import okhttp3.OkHttpClient; +import org.bouncycastle.asn1.ASN1GeneralizedTime; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.asn1.x509.Time; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class MosApiModuleTest { + + private static final String TEST_CERT_SECRET_NAME = "testCert"; + private static final String TEST_KEY_SECRET_NAME = "testKey"; + + private SecretManagerClient secretManagerClient; + private String validCertPem; + private String validKeyPem; + private PrivateKey generatedPrivateKey; + private X509Certificate generatedCertificate; + + @BeforeAll + static void setupStatics() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @BeforeEach + void setUp() throws Exception { + secretManagerClient = mock(SecretManagerClient.class); + generateTestCredentials(); + } + + @Test + void testProvideMosapiTlsCert_fetchesFromConfiguredSecretName() { + when(secretManagerClient.getSecretData(any(), any())).thenReturn(validCertPem); + String result = MosApiModule.provideMosapiTlsCert(secretManagerClient, TEST_CERT_SECRET_NAME); + assertThat(result).isEqualTo(validCertPem); + verify(secretManagerClient).getSecretData(eq(TEST_CERT_SECRET_NAME), eq(Optional.of("latest"))); + } + + @Test + void testProvideMosapiTlsKey_fetchesFromConfiguredSecretName() { + when(secretManagerClient.getSecretData(any(), any())).thenReturn(validKeyPem); + String result = MosApiModule.provideMosapiTlsKey(secretManagerClient, TEST_KEY_SECRET_NAME); + assertThat(result).isEqualTo(validKeyPem); + verify(secretManagerClient).getSecretData(eq(TEST_KEY_SECRET_NAME), eq(Optional.of("latest"))); + } + + @Test + void testProvideCertificate_parsesValidPem() { + Certificate cert = MosApiModule.provideCertificate(validCertPem); + assertThat(cert).isInstanceOf(X509Certificate.class); + // Verify the public key matches to ensure we parsed the correct cert + assertThat(cert.getPublicKey()).isEqualTo(generatedCertificate.getPublicKey()); + } + + @Test + void testProvideCertificate_throwsOnInvalidPem() { + RuntimeException thrown = + assertThrows( + RuntimeException.class, () -> MosApiModule.provideCertificate("NOT A REAL CERT")); + assertThat(thrown).hasMessageThat().contains("Could not create X.509 certificate"); + } + + @Test + void testProvidePrivateKey_parsesValidPem() { + PrivateKey key = MosApiModule.providePrivateKey(validKeyPem); + assertThat(key).isNotNull(); + assertThat(key.getAlgorithm()).isEqualTo("RSA"); + assertThat(key.getEncoded()).isEqualTo(generatedPrivateKey.getEncoded()); + } + + @Test + void testProvidePrivateKey_throwsOnInvalidPem() { + RuntimeException thrown = + assertThrows( + RuntimeException.class, () -> MosApiModule.providePrivateKey("NOT A REAL KEY")); + assertThat(thrown).hasMessageThat().contains("Could not parse TLS private key"); + } + + @Test + void testProvideKeyStore_createsWithCorrectAlias() throws Exception { + KeyStore keyStore = MosApiModule.provideKeyStore(generatedPrivateKey, generatedCertificate); + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualTo("PKCS12"); + assertThat(keyStore.containsAlias("client")).isTrue(); + assertThat(keyStore.getCertificate("client")).isEqualTo(generatedCertificate); + } + + @Test + void testProvideMosapiHttpClient_usesConfiguredSslContext() { + SSLContext mockSslContext = mock(SSLContext.class); + SSLSocketFactory mockSocketFactory = mock(SSLSocketFactory.class); + X509TrustManager mockTrustManager = mock(X509TrustManager.class); + when(mockTrustManager.getAcceptedIssuers()).thenReturn(new X509Certificate[0]); + when(mockSslContext.getSocketFactory()).thenReturn(mockSocketFactory); + OkHttpClient client = MosApiModule.provideMosapiHttpClient(mockSslContext, mockTrustManager); + assertThat(client).isNotNull(); + assertThat(client.sslSocketFactory()).isEqualTo(mockSocketFactory); + } + + private void generateTestCredentials() throws Exception { + // 1. Generate KeyPair + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + this.generatedPrivateKey = keyPair.getPrivate(); + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'").withZone(ZoneId.of("UTC")); + Instant now = Instant.now(); + Instant end = now.plus(Duration.ofDays(365)); + // Convert string to Bouncy Castle Time objects + Time notBefore = new Time(new ASN1GeneralizedTime(formatter.format(now))); + Time notAfter = new Time(new ASN1GeneralizedTime(formatter.format(end))); + X509v3CertificateBuilder certBuilder = + new X509v3CertificateBuilder( + new X500Name("CN=Test"), + BigInteger.valueOf(now.toEpochMilli()), + notBefore, + notAfter, + new X500Name("CN=Test"), + SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded())); + ContentSigner contentSigner = + new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keyPair.getPrivate()); + this.generatedCertificate = + new JcaX509CertificateConverter() + .setProvider("BC") + .getCertificate(certBuilder.build(contentSigner)); + // 4. Convert to PEM Strings + this.validCertPem = toPem(generatedCertificate); + this.validKeyPem = toPem(generatedPrivateKey); + } + + private String toPem(Object object) throws Exception { + StringWriter stringWriter = new StringWriter(); + try (JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter)) { + pemWriter.writeObject(object); + } + return stringWriter.toString(); + } +} diff --git a/jetty/kubernetes/gateway/nomulus-route-backend.yaml b/jetty/kubernetes/gateway/nomulus-route-backend.yaml index ef8e5564f..edb6a6099 100644 --- a/jetty/kubernetes/gateway/nomulus-route-backend.yaml +++ b/jetty/kubernetes/gateway/nomulus-route-backend.yaml @@ -26,6 +26,9 @@ spec: - path: type: PathPrefix value: /_dr/loadtest + - path: + type: PathPrefix + value: /_dr/mosapi backendRefs: - group: net.gke.io kind: ServiceImport @@ -62,6 +65,12 @@ spec: headers: - name: "canary" value: "true" + - path: + type: PathPrefix + value: /_dr/mosapi + headers: + - name: "canary" + value: "true" backendRefs: - group: net.gke.io kind: ServiceImport