1
0
mirror of https://github.com/google/nomulus synced 2025-12-23 06:15:42 +00:00

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
This commit is contained in:
Nilay Shah
2025-12-09 21:59:05 +05:30
committed by GitHub
parent 28e72bd0d0
commit d98d65eee5
16 changed files with 1180 additions and 0 deletions

View File

@@ -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<String> provideMosapiTlds(RegistryConfigSettings config) {
return ImmutableSet.copyOf(config.mosapi.tlds);
}
@Provides
@Config("mosapiServices")
public static ImmutableSet<String> 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)

View File

@@ -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<String> tlds;
public List<String> services;
}
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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. <b>The caller is
* responsible for closing this response.</b>
* @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<String, String> params, Map<String, String> 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.
*
* <p><b>Note:</b> 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. <b>The caller is responsible for closing this
* response.</b>
* @throws MosApiException if the request fails.
* @throws MosApiAuthorizationException if the server returns a 401 Unauthorized status.
*/
public Response sendPostRequest(
String entityId,
String endpoint,
Map<String, String> params,
Map<String, String> 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<String, String> 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();
}
}

View File

@@ -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<MosApiErrorResponse> 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<MosApiResponse> 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);
}
}
}

View File

@@ -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.
*
* <p>The definitions for these codes can be found in the official ICANN MoSAPI Specification,
* specifically in the 'Result Codes' section.
*
* @see <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification</a>
*/
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<String, MosApiResponse> 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<MosApiResponse> fromCode(String code) {
return Optional.ofNullable(CODE_MAP.get(code));
}
}

View File

@@ -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 <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification, Section
* 8</a>
*/
public record MosApiErrorResponse(String resultCode, String message, String description) {}

View File

@@ -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.
*
* <p>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.
*
* <p>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();
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<String, String> params = ImmutableMap.of("since", "2024-01-01");
Map<String, String> 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<Request> 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<String, String> headers = ImmutableMap.of("Content-Type", "application/json");
try (Response response =
mosApiClient.sendPostRequest("tld-1", "update", null, headers, requestBody)) {
assertThat(response.isSuccessful()).isTrue();
ArgumentCaptor<Request> 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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