diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index a24017343..f8c3c4a7d 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -97,3 +97,6 @@ turn.cloudflare.apiToken: ABCDEFGHIJKLM linkDevice.secret: AAAAAAAAAAA= tlsKeyStore.password: unset + +hlrLookup.apiKey: AAAAAAAAAAA +hlrLookup.apiSecret: AAAAAAAAAAA diff --git a/service/config/sample.yml b/service/config/sample.yml index b1bc57c0b..ae2710595 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -528,3 +528,7 @@ callQualitySurvey: { "credential": "configuration" } + +hlrLookup: + apiKey: secret://hlrLookup.apiKey + apiSecret: secret://hlrLookup.apiSecret diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 47cd88476..b122ee0c5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -39,6 +39,7 @@ import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguratio import org.whispersystems.textsecuregcm.configuration.GenericZkConfig; import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration; import org.whispersystems.textsecuregcm.configuration.GrpcConfiguration; +import org.whispersystems.textsecuregcm.configuration.HlrLookupConfiguration; import org.whispersystems.textsecuregcm.configuration.IdlePrimaryDeviceReminderConfiguration; import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration; @@ -332,6 +333,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private Map retries = Collections.emptyMap(); + @Valid + @NotNull + @JsonProperty + private HlrLookupConfiguration hlrLookup; + @JsonProperty @Valid @NotNull @@ -590,4 +596,8 @@ public class WhisperServerConfiguration extends Configuration { public CallQualitySurveyConfiguration getCallQualitySurveyConfiguration() { return callQualitySurvey; } + + public HlrLookupConfiguration getHlrLookupConfiguration() { + return hlrLookup; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 59ff3ee49..1fdc0f8e3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -257,6 +257,8 @@ import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; +import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider; +import org.whispersystems.textsecuregcm.telephony.hlrlookup.HlrLookupCarrierDataProvider; import org.whispersystems.textsecuregcm.util.BufferingInterceptor; import org.whispersystems.textsecuregcm.util.ManagedAwsCrt; import org.whispersystems.textsecuregcm.util.ManagedExecutors; @@ -580,6 +582,10 @@ public class WhisperServerService extends Application CompletableFuture> sendAsync(HttpRequest request, + public HttpResponse send(final HttpRequest request, final HttpResponse.BodyHandler bodyHandler) + throws IOException { + final Callable> requestCallable = + () -> httpClient().send(requestWithTimeout(request, defaultRequestTimeout), bodyHandler); + + try { + return retry != null + ? breaker.executeCallable(retry.decorateCallable(requestCallable)) + : breaker.executeCallable(requestCallable); + } catch (final IOException e) { + throw e; + } catch (final Exception e) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + + throw new RuntimeException(e); + } + } + + public CompletableFuture> sendAsync(final HttpRequest request, final HttpResponse.BodyHandler bodyHandler) { - if (request.timeout().isEmpty()) { - request = HttpRequest.newBuilder(request, (_, _) -> true) - .timeout(defaultRequestTimeout) - .build(); - } - final Supplier>> asyncRequestSupplier = - sendAsync(httpClient(), request, bodyHandler); + () -> httpClient().sendAsync(requestWithTimeout(request, defaultRequestTimeout), bodyHandler); if (retry != null) { assert retryExecutor != null; @@ -87,10 +103,12 @@ public class FaultTolerantHttpClient { } } - private Supplier>> sendAsync(HttpClient client, HttpRequest request, - HttpResponse.BodyHandler bodyHandler) { - - return () -> client.sendAsync(request, bodyHandler); + private static HttpRequest requestWithTimeout(final HttpRequest request, final Duration defaultRequestTimeout) { + return request.timeout().isPresent() + ? request + : HttpRequest.newBuilder(request, (_, _) -> true) + .timeout(defaultRequestTimeout) + .build(); } public static class Builder { @@ -102,7 +120,7 @@ public class FaultTolerantHttpClient { private int numClients = 1; private final String name; - private Executor executor; + private final Executor executor; private KeyStore trustStore; private String securityProtocol = SECURITY_PROTOCOL_TLS_1_2; private String retryConfigurationName; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierData.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierData.java new file mode 100644 index 000000000..499bb59a3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierData.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony; + +import java.util.Optional; + +/// Line type and home network information for a specific phone number. +/// +/// @param carrierName the name of the network operator for the specified phone number +/// @param lineType the line type for the specified phone number +/// @param mcc the mobile country code (MCC) of the phone number's home network if known; may be empty if the phone +/// number is not a mobile number +/// @param mnc the mobile network code (MNC) of the phone number's home network if known; may be empty if the phone +/// number is not a mobile number +public record CarrierData(String carrierName, LineType lineType, Optional mcc, Optional mnc) { + + public enum LineType { + MOBILE, + LANDLINE, + FIXED_VOIP, + NON_FIXED_VOIP, + OTHER, + UNKNOWN + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataException.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataException.java new file mode 100644 index 000000000..ca76433a9 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataException.java @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony; + +/// Indicates that a request for carrier data failed permanently (e.g. it was affirmatively rejected by the provider) +/// and should not be retried without modification. +public class CarrierDataException extends Exception { + + public CarrierDataException(final String message) { + super(message); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataProvider.java new file mode 100644 index 000000000..74c377352 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony; + +import com.google.i18n.phonenumbers.Phonenumber; +import java.io.IOException; +import java.time.Duration; +import java.util.Optional; + +/// A carrier data provider returns line type and home network information about a specific phone number. Carrier data +/// providers may cache results and return cached results if they are newer than a given maximum age. +public interface CarrierDataProvider { + + /// Retrieves carrier data for a given phone number. + /// + /// @param phoneNumber the phone number for which to retrieve line type and home network information + /// @param maxCachedAge the maximum age of a cached response to return; providers must attempt to fetch fresh data if + /// cached data is older than the given maximum age, and may choose to fetch fresh data under any circumstances + /// + /// @return line type and home network information for the given phone number if available or empty if this provider + /// could not find information for the given phone number + /// + /// @throws IOException if the provider could not be reached due to a network problem of any kind + /// @throws CarrierDataException if the request failed and should not be retried without modification + Optional lookupCarrierData(Phonenumber.PhoneNumber phoneNumber, Duration maxCachedAge) + throws IOException, CarrierDataException; +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProvider.java new file mode 100644 index 000000000..555eed38a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProvider.java @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony.hlrlookup; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.i18n.phonenumbers.Phonenumber; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import javax.annotation.Nullable; +import io.micrometer.core.instrument.Metrics; +import org.apache.commons.lang3.StringUtils; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; +import org.whispersystems.textsecuregcm.metrics.MetricsUtil; +import org.whispersystems.textsecuregcm.telephony.CarrierData; +import org.whispersystems.textsecuregcm.telephony.CarrierDataException; +import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +/// A carrier data provider that uses [HLR Lookup](https://www.hlrlookup.com/) as its data source. +public class HlrLookupCarrierDataProvider implements CarrierDataProvider { + + private final String apiKey; + private final String apiSecret; + + private final FaultTolerantHttpClient httpClient; + private final URI lookupUri; + + private static final URI HLR_LOOKUP_URI = URI.create("https://api.hlrlookup.com/apiv2/hlr"); + + private static final ObjectMapper OBJECT_MAPPER = SystemMapper.jsonMapper(); + + private static final String REQUEST_COUNTER_NAME = MetricsUtil.name(HlrLookupCarrierDataProvider.class, "request"); + + public HlrLookupCarrierDataProvider(final String apiKey, + final String apiSecret, + final Executor httpRequestExecutor, + @Nullable final String circuitBreakerConfigurationName, + @Nullable final String retryConfigurationName, + final ScheduledExecutorService retryExecutor) { + + this(apiKey, + apiSecret, + FaultTolerantHttpClient.newBuilder("hlr-lookup", httpRequestExecutor) + .withCircuitBreaker(circuitBreakerConfigurationName) + .withRetry(retryConfigurationName, retryExecutor) + .build(), + HLR_LOOKUP_URI); + } + + @VisibleForTesting + HlrLookupCarrierDataProvider(final String apiKey, + final String apiSecret, + final FaultTolerantHttpClient httpClient, + final URI lookupUri) { + + this.apiKey = apiKey; + this.apiSecret = apiSecret; + this.httpClient = httpClient; + this.lookupUri = lookupUri; + } + + @Override + public Optional lookupCarrierData(final Phonenumber.PhoneNumber phoneNumber, + final Duration maxCachedAge) throws IOException, CarrierDataException { + + final HlrLookupResponse response; + + try { + final String requestJson = OBJECT_MAPPER.writeValueAsString( + new HlrLookupRequest(apiKey, apiSecret, + List.of(TelephoneNumberRequest.forPhoneNumber(phoneNumber, maxCachedAge)))); + + final HttpRequest request = HttpRequest + .newBuilder(lookupUri) + .POST(HttpRequest.BodyPublishers.ofString(requestJson)) + .header("Content-Type", "application/json") + .build(); + + final HttpResponse httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (httpResponse.statusCode() != 200) { + // We may or may not have helpful data in the response body + try { + final HlrLookupResponse hlrLookupResponse = parseResponse(httpResponse.body()); + + if (StringUtils.isNotBlank(hlrLookupResponse.error()) || StringUtils.isNotBlank( + hlrLookupResponse.message())) { + throw new CarrierDataException( + "Received a non-success status code (%d): error = %s; message = %s".formatted( + httpResponse.statusCode(), hlrLookupResponse.error(), hlrLookupResponse.message())); + } + } catch (final JsonProcessingException _) { + // We couldn't parse the body, so just move on with the default error message + } + + throw new CarrierDataException("Received a non-success status code (%d)".formatted(httpResponse.statusCode())); + } + + response = parseResponse(httpResponse.body()); + + Metrics.counter(REQUEST_COUNTER_NAME, "status", String.valueOf(httpResponse.statusCode())) + .increment(); + } catch (final IOException | CarrierDataException e) { + Metrics.counter(REQUEST_COUNTER_NAME, "exception", e.getClass().getSimpleName()) + .increment(); + + throw e; + } + + if (response.results() == null || response.results().isEmpty()) { + throw new CarrierDataException("No error reported, but no results provided"); + } + + final HlrLookupResult result = response.results().getFirst(); + + if (!result.error().equals("NONE")) { + throw new CarrierDataException("Received a per-number error: " + result.error()); + } + + return getNetworkDetails(result) + .map(networkDetails -> new CarrierData( + networkDetails.name(), + lineType(result.telephoneNumberType()), + mccFromMccMnc(networkDetails.mccmnc()), + mncFromMccMnc(networkDetails.mccmnc()))); + } + + @VisibleForTesting + static Optional mccFromMccMnc(@Nullable final String mccMnc) { + // MCCs are always 3 digits + return Optional.ofNullable(StringUtils.stripToNull(mccMnc)) + .map(trimmedMccMnc -> trimmedMccMnc.substring(0, 3)); + } + + @VisibleForTesting + static Optional mncFromMccMnc(@Nullable final String mccMnc) { + // MNCs may be 2 or 3 digits, but always come after a 3-digit MCC + return Optional.ofNullable(StringUtils.stripToNull(mccMnc)) + .map(trimmedMccMnc -> trimmedMccMnc.substring(3)); + } + + @VisibleForTesting + static CarrierData.LineType lineType(@Nullable final String telephoneNumberType) { + if (telephoneNumberType == null) { + return CarrierData.LineType.UNKNOWN; + } + + return switch (telephoneNumberType) { + case "MOBILE" -> CarrierData.LineType.MOBILE; + case "LANDLINE", "MOBILE_OR_LANDLINE" -> CarrierData.LineType.LANDLINE; + case "VOIP" -> CarrierData.LineType.NON_FIXED_VOIP; + case "UNKNOWN" -> CarrierData.LineType.UNKNOWN; + default -> CarrierData.LineType.OTHER; + }; + } + + @VisibleForTesting + static Optional getNetworkDetails(final HlrLookupResult hlrLookupResult) { + if (hlrLookupResult.currentNetwork().equals("AVAILABLE")) { + return Optional.of(hlrLookupResult.currentNetworkDetails()); + } else if (hlrLookupResult.originalNetwork().equals("AVAILABLE")) { + return Optional.of(hlrLookupResult.originalNetworkDetails()); + } + + return Optional.empty(); + } + + @VisibleForTesting + static HlrLookupResponse parseResponse(final String responseJson) throws JsonProcessingException { + return OBJECT_MAPPER.readValue(responseJson, HlrLookupResponse.class); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupRequest.java new file mode 100644 index 000000000..72ba0512c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupRequest.java @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony.hlrlookup; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +record HlrLookupRequest(String apiKey, String apiSecret, List requests) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResponse.java new file mode 100644 index 000000000..fad7bad40 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResponse.java @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony.hlrlookup; + +import javax.annotation.Nullable; +import java.util.List; + +record HlrLookupResponse(@Nullable List results, + @Nullable String error, + @Nullable String message) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResult.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResult.java new file mode 100644 index 000000000..ab4c4fae7 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResult.java @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony.hlrlookup; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +record HlrLookupResult(String error, + String originalNetwork, + NetworkDetails originalNetworkDetails, + String currentNetwork, + NetworkDetails currentNetworkDetails, + String telephoneNumberType) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/NetworkDetails.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/NetworkDetails.java new file mode 100644 index 000000000..ba5bbd418 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/NetworkDetails.java @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony.hlrlookup; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +record NetworkDetails(String name, + String mccmnc, + String countryName, + String countryIso3, + String area, + String countryPrefix) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/TelephoneNumberRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/TelephoneNumberRequest.java new file mode 100644 index 000000000..fb44646fc --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/TelephoneNumberRequest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony.hlrlookup; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; +import org.apache.commons.lang3.StringUtils; +import javax.annotation.Nullable; +import java.time.Duration; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +record TelephoneNumberRequest(String telephoneNumber, + @Nullable Integer cacheDaysGlobal, + @Nullable Integer cacheDaysPrivate, + @JsonProperty("save_to_cache") String saveToGlobalCache) { + + static TelephoneNumberRequest forPhoneNumber(final Phonenumber.PhoneNumber phoneNumber, final Duration maxCachedAge) { + + return new TelephoneNumberRequest( + StringUtils.stripStart(PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164), "+"), + (int) maxCachedAge.toDays(), + (int) maxCachedAge.toDays(), + "NO"); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClientTest.java index 738194fb1..97e991a1a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClientTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClientTest.java @@ -47,7 +47,7 @@ import org.whispersystems.textsecuregcm.util.ResilienceUtil; class FaultTolerantHttpClientTest { @RegisterExtension - private final WireMockExtension wireMock = WireMockExtension.newInstance() + private static final WireMockExtension wireMock = WireMockExtension.newInstance() .options(wireMockConfig().dynamicPort().dynamicHttpsPort()) .build(); @@ -60,32 +60,34 @@ class FaultTolerantHttpClientTest { retryExecutor = Executors.newSingleThreadScheduledExecutor(); } + @SuppressWarnings("ResultOfMethodCallIgnored") @AfterEach void tearDown() throws InterruptedException { httpExecutor.shutdown(); httpExecutor.awaitTermination(1, TimeUnit.SECONDS); + retryExecutor.shutdown(); retryExecutor.awaitTermination(1, TimeUnit.SECONDS); } @Test - void testSimpleGet() { + void testSimpleGetAsync() { wireMock.stubFor(get(urlEqualTo("/ping")) .willReturn(aResponse() .withHeader("Content-Type", "text/plain") .withBody("Pong!"))); - FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testSimpleGet", httpExecutor) + final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testSimpleGet", httpExecutor) .withRetry(null, retryExecutor) .withVersion(HttpClient.Version.HTTP_2) .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping")) - .GET() - .build(); + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping")) + .GET() + .build(); - HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); + final HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); assertThat(response.statusCode()).isEqualTo(200); assertThat(response.body()).isEqualTo("Pong!"); @@ -94,24 +96,49 @@ class FaultTolerantHttpClientTest { } @Test - void testRetryGet() { - wireMock.stubFor(get(urlEqualTo("/failure")) - .willReturn(aResponse() - .withStatus(500) - .withHeader("Content-Type", "text/plain") - .withBody("Pong!"))); + void testSimpleGetSync() throws IOException, InterruptedException { + wireMock.stubFor(get(urlEqualTo("/ping")) + .willReturn(aResponse() + .withHeader("Content-Type", "text/plain") + .withBody("Pong!"))); - FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testRetryGet", httpExecutor) + final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testSimpleGet", httpExecutor) .withRetry(null, retryExecutor) .withVersion(HttpClient.Version.HTTP_2) .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + wireMock.getPort() + "/failure")) - .GET() - .build(); + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping")) + .GET() + .build(); - HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).isEqualTo("Pong!"); + + wireMock.verify(1, getRequestedFor(urlEqualTo("/ping"))); + } + + @Test + void testRetryGetAsync() { + wireMock.stubFor(get(urlEqualTo("/failure")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "text/plain") + .withBody("Pong!"))); + + final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testRetryGet", httpExecutor) + .withRetry(null, retryExecutor) + .withVersion(HttpClient.Version.HTTP_2) + .build(); + + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + wireMock.getPort() + "/failure")) + .GET() + .build(); + + final HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); assertThat(response.statusCode()).isEqualTo(500); assertThat(response.body()).isEqualTo("Pong!"); @@ -120,7 +147,33 @@ class FaultTolerantHttpClientTest { } @Test - void testRetryGetOnException() { + void testRetryGetSync() throws IOException, InterruptedException { + wireMock.stubFor(get(urlEqualTo("/failure")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "text/plain") + .withBody("Pong!"))); + + final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testRetryGet", httpExecutor) + .withRetry(null, retryExecutor) + .withVersion(HttpClient.Version.HTTP_2) + .build(); + + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + wireMock.getPort() + "/failure")) + .GET() + .build(); + + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(500); + assertThat(response.body()).isEqualTo("Pong!"); + + wireMock.verify(3, getRequestedFor(urlEqualTo("/failure"))); + } + + @Test + void testRetryGetAsyncOnException() { final HttpClient mockHttpClient = mock(HttpClient.class); final Retry retry = Retry.of("test", new RetryConfiguration().toRetryConfigBuilder() @@ -137,20 +190,47 @@ class FaultTolerantHttpClientTest { when(mockHttpClient.sendAsync(any(), any())) .thenReturn(CompletableFuture.failedFuture(new IOException("test exception"))); - HttpRequest request = HttpRequest.newBuilder() + final HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:1234/failure")) .GET() .build(); - try { - client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - throw new AssertionError("Should have failed!"); - } catch (CompletionException e) { - assertThat(e.getCause()).isInstanceOf(IOException.class); - } + assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(IOException.class); + verify(mockHttpClient, times(3)).sendAsync(any(), any()); } + @Test + void testRetryGetSyncOnException() throws IOException, InterruptedException { + final HttpClient mockHttpClient = mock(HttpClient.class); + + final Retry retry = Retry.of("test", new RetryConfiguration().toRetryConfigBuilder() + .retryOnException(throwable -> throwable instanceof IOException) + .build()); + + final FaultTolerantHttpClient client = new FaultTolerantHttpClient( + List.of(mockHttpClient), + Duration.ofSeconds(1), + retryExecutor, + retry, + CircuitBreaker.ofDefaults("test")); + + when(mockHttpClient.send(any(), any())) + .thenThrow(IOException.class); + + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:1234/failure")) + .GET() + .build(); + + assertThatThrownBy(() -> client.send(request, HttpResponse.BodyHandlers.ofString())) + .isInstanceOf(IOException.class); + + verify(mockHttpClient, times(3)).send(any(), any()); + } + @Test void testMultipleClients() throws IOException, InterruptedException { final HttpClient mockHttpClient1 = mock(HttpClient.class); @@ -177,28 +257,31 @@ class FaultTolerantHttpClientTest { .uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping")) .GET() .build(); - final HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding()); - final AtomicInteger client1Calls = new AtomicInteger(0); - final AtomicInteger client2Calls = new AtomicInteger(0); - when(mockHttpClient1.sendAsync(any(), any())) - .thenAnswer(args -> { - client1Calls.incrementAndGet(); - return CompletableFuture.completedFuture(response); - }); - when(mockHttpClient2.sendAsync(any(), any())) - .thenAnswer(args -> { - client2Calls.incrementAndGet(); - return CompletableFuture.completedFuture(response); - }); + try (final HttpClient httpClient = HttpClient.newHttpClient()) { + final HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); - final int numCalls = 100; - for (int i = 0; i < numCalls; i++) { - client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); + final AtomicInteger client1Calls = new AtomicInteger(0); + final AtomicInteger client2Calls = new AtomicInteger(0); + when(mockHttpClient1.sendAsync(any(), any())) + .thenAnswer(_ -> { + client1Calls.incrementAndGet(); + return CompletableFuture.completedFuture(response); + }); + when(mockHttpClient2.sendAsync(any(), any())) + .thenAnswer(_ -> { + client2Calls.incrementAndGet(); + return CompletableFuture.completedFuture(response); + }); + + final int numCalls = 100; + for (int i = 0; i < numCalls; i++) { + client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); + } + assertThat(client2Calls.get()).isGreaterThan(0); + assertThat(client1Calls.get()).isGreaterThan(0); + assertThat(client1Calls.get() + client2Calls.get()).isEqualTo(numCalls); } - assertThat(client2Calls.get()).isGreaterThan(0); - assertThat(client1Calls.get()).isGreaterThan(0); - assertThat(client1Calls.get() + client2Calls.get()).isEqualTo(numCalls); } @Test @@ -215,7 +298,8 @@ class FaultTolerantHttpClientTest { ResilienceUtil.getCircuitBreakerRegistry() .addConfiguration(circuitBreakerConfigurationName, circuitBreakerConfiguration.toCircuitBreakerConfig()); - final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testNetworkFailureCircuitBreaker", httpExecutor) + final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testNetworkFailureCircuitBreaker", + httpExecutor) .withCircuitBreaker(circuitBreakerConfigurationName) .withRetry(null, retryExecutor) .withVersion(HttpClient.Version.HTTP_2) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProviderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProviderTest.java new file mode 100644 index 000000000..50e0346cb --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProviderTest.java @@ -0,0 +1,364 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.telephony.hlrlookup; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; +import org.whispersystems.textsecuregcm.telephony.CarrierData; +import org.whispersystems.textsecuregcm.telephony.CarrierDataException; + +class HlrLookupCarrierDataProviderTest { + + private HlrLookupCarrierDataProvider hlrLookupCarrierDataProvider; + + @RegisterExtension + private static final WireMockExtension WIRE_MOCK_EXTENSION = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort().dynamicHttpsPort()) + .build(); + + private static final String HLR_LOOKUP_PATH = "/hlr"; + + @BeforeEach + void setUp() { + final FaultTolerantHttpClient faultTolerantHttpClient = FaultTolerantHttpClient.newBuilder("hlrLookupTest", Runnable::run) + .build(); + + hlrLookupCarrierDataProvider = new HlrLookupCarrierDataProvider("test", "test", faultTolerantHttpClient, URI.create("http://localhost:" + WIRE_MOCK_EXTENSION.getPort() + HLR_LOOKUP_PATH)); + } + + @Test + void lookupCarrierData() throws IOException, CarrierDataException { + final String responseJson = """ + { + "results": [ + { + "error": "NONE", + "uuid": "f066f711-4043-4d54-847d-c273e6491881", + "request_parameters": { + "telephone_number": "+44(7790) 60 60 23", + "save_to_cache": "YES", + "input_format": "", + "output_format": "", + "cache_days_global": 0, + "cache_days_private": 0, + "get_ported_date": "NO", + "get_landline_status": "NO", + "usa_status": "NO" + }, + "credits_spent": 1, + "detected_telephone_number": "447790606023", + "formatted_telephone_number": "", + "live_status": "LIVE", + "original_network": "AVAILABLE", + "original_network_details": { + "name": "EE Limited (Orange)", + "mccmnc": "23433", + "country_name": "United Kingdom", + "country_iso3": "GBR", + "area": "United Kingdom", + "country_prefix": "44" + }, + "current_network": "AVAILABLE", + "current_network_details": { + "name": "Virgin Mobile", + "mccmnc": "23438", + "country_name": "United Kingdom", + "country_iso3": "GBR", + "country_prefix": "44" + }, + "is_ported": "YES", + "timestamp": "2022-09-08T10:56:03Z", + "telephone_number_type": "MOBILE", + "sms_email": "", + "mms_email": "" + } + ] + } + """; + + WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH)) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + final Optional maybeCarrierData = + hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO); + + assertEquals(Optional.of(new CarrierData("Virgin Mobile", CarrierData.LineType.MOBILE, Optional.of("234"), Optional.of("38"))), + maybeCarrierData); + } + + @Test + void lookupCarrierDataNonSuccessStatus() { + WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH)) + .willReturn(aResponse() + .withStatus(500))); + + assertThrows(CarrierDataException.class, () -> + hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO)); + } + + @Test + void lookupCarrierDataErrorMessage() { + final String responseJson = """ + { "error": "UNAUTHORIZED", "message": "Invalid api_key or api_secret" } + """; + + WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH)) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + assertThrows(CarrierDataException.class, () -> + hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO)); + } + + @Test + void lookupCarrierDataEmptyBody() { + WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH)) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{}"))); + + assertThrows(CarrierDataException.class, () -> + hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO)); + } + + @Test + void lookupCarrierDataPerNumberError() { + final String responseJson = """ + { + "body": { + "results": [ + { + "error": "INTERNAL_ERROR" + } + ] + } + } + """; + + WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH)) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + assertThrows(CarrierDataException.class, () -> + hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO)); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + @ParameterizedTest + @MethodSource + void mccFromMccMnc(@Nullable final String mccMnc, final Optional expectedMcc) { + assertEquals(expectedMcc, HlrLookupCarrierDataProvider.mccFromMccMnc(mccMnc)); + } + + private static List mccFromMccMnc() { + return List.of( + Arguments.argumentSet("Null mccMnc string", null, Optional.empty()), + Arguments.argumentSet("Empty mccMnc string", "", Optional.empty()), + Arguments.argumentSet("Blank mccMnc string", " ", Optional.empty()), + Arguments.argumentSet("Two-digit MNC", "12345", Optional.of("123")), + Arguments.argumentSet("Three-digit MNC", "123456", Optional.of("123")) + ); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + @ParameterizedTest + @MethodSource + void mncFromMccMnc(@Nullable final String mccMnc, final Optional expectedMnc) { + assertEquals(expectedMnc, HlrLookupCarrierDataProvider.mncFromMccMnc(mccMnc)); + } + + private static List mncFromMccMnc() { + return List.of( + Arguments.argumentSet("Null mccMnc string", null, Optional.empty()), + Arguments.argumentSet("Empty mccMnc string", "", Optional.empty()), + Arguments.argumentSet("Blank mccMnc string", " ", Optional.empty()), + Arguments.argumentSet("Two-digit MNC", "12345", Optional.of("45")), + Arguments.argumentSet("Three-digit MNC", "123456", Optional.of("456")) + ); + } + + @ParameterizedTest + @MethodSource + void lineType(@Nullable final String lineType, final CarrierData.LineType expectedLineType) { + assertEquals(expectedLineType, HlrLookupCarrierDataProvider.lineType(lineType)); + } + + private static List lineType() { + return List.of( + Arguments.argumentSet("Null line type", null, CarrierData.LineType.UNKNOWN) + ); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + @ParameterizedTest + @MethodSource + void getNetworkDetails(final HlrLookupResult hlrLookupResult, final Optional expectedNetworkDetails) { + assertEquals(expectedNetworkDetails, HlrLookupCarrierDataProvider.getNetworkDetails(hlrLookupResult)); + } + + private static List getNetworkDetails() { + final NetworkDetails originalNetwork = new NetworkDetails( + "Original network", + "123456", + "United States of America", + "USA", + "United States of America", + "1"); + + final NetworkDetails currentNetwork = new NetworkDetails( + "Current network", + "654321", + "United States of America", + "USA", + "United States of America", + "1"); + + return List.of( + Arguments.argumentSet("Original and current network", + resultWithNetworkDetails(originalNetwork, currentNetwork), + Optional.of(currentNetwork)), + + Arguments.argumentSet("Original network only", + resultWithNetworkDetails(originalNetwork, null), + Optional.of(originalNetwork)), + + Arguments.argumentSet("Current network only", + resultWithNetworkDetails(null, currentNetwork), + Optional.of(currentNetwork)), + + Arguments.argumentSet("No network details", + resultWithNetworkDetails(null, null), + Optional.empty()) + ); + } + + private static HlrLookupResult resultWithNetworkDetails(@Nullable final NetworkDetails originalNetwork, + @Nullable final NetworkDetails currentNetwork) { + + return new HlrLookupResult(null, + originalNetwork == null ? "NOT_AVAILABLE" : "AVAILABLE", + originalNetwork, + currentNetwork == null ? "NOT_AVAILABLE" : "AVAILABLE", + currentNetwork, + "MOBILE"); + } + + @Test + void parseResponse() throws JsonProcessingException { + final String json = """ + { + "results": [ + { + "error": "NONE", + "uuid": "f066f711-4043-4d54-847d-c273e6491881", + "request_parameters": { + "telephone_number": "+44(7790) 60 60 23", + "save_to_cache": "YES", + "input_format": "", + "output_format": "", + "cache_days_global": 0, + "cache_days_private": 0, + "get_ported_date": "NO", + "get_landline_status": "NO", + "usa_status": "NO" + }, + "credits_spent": 1, + "detected_telephone_number": "447790606023", + "formatted_telephone_number": "", + "live_status": "LIVE", + "original_network": "AVAILABLE", + "original_network_details": { + "name": "EE Limited (Orange)", + "mccmnc": "23433", + "country_name": "United Kingdom", + "country_iso3": "GBR", + "area": "United Kingdom", + "country_prefix": "44" + }, + "current_network": "AVAILABLE", + "current_network_details": { + "name": "Virgin Mobile", + "mccmnc": "23438", + "country_name": "United Kingdom", + "country_iso3": "GBR", + "country_prefix": "44" + }, + "is_ported": "YES", + "timestamp": "2022-09-08T10:56:03Z", + "telephone_number_type": "MOBILE", + "sms_email": "", + "mms_email": "" + } + ] + } + """; + + final HlrLookupResponse response = HlrLookupCarrierDataProvider.parseResponse(json); + assertNull(response.error()); + assertNull(response.message()); + assertNotNull(response.results()); + assertEquals(1, response.results().size()); + + final HlrLookupResult result = response.results().getFirst(); + assertEquals("NONE", result.error()); + assertEquals("MOBILE", result.telephoneNumberType()); + + assertEquals("AVAILABLE", result.originalNetwork()); + assertEquals("EE Limited (Orange)", result.originalNetworkDetails().name()); + assertEquals("23433", result.originalNetworkDetails().mccmnc()); + assertEquals("United Kingdom", result.originalNetworkDetails().countryName()); + assertEquals("GBR", result.originalNetworkDetails().countryIso3()); + assertEquals("United Kingdom", result.originalNetworkDetails().area()); + assertEquals("44", result.originalNetworkDetails().countryPrefix()); + + assertEquals("AVAILABLE", result.currentNetwork()); + assertEquals("Virgin Mobile", result.currentNetworkDetails().name()); + assertEquals("23438", result.currentNetworkDetails().mccmnc()); + assertEquals("United Kingdom", result.currentNetworkDetails().countryName()); + assertEquals("GBR", result.currentNetworkDetails().countryIso3()); + assertNull(result.currentNetworkDetails().area()); + assertEquals("44", result.currentNetworkDetails().countryPrefix()); + } + + @Test + void parseResponseError() throws JsonProcessingException { + final String json = """ + { "error": "UNAUTHORIZED", "message": "Invalid api_key or api_secret" } + """; + + final HlrLookupResponse response = HlrLookupCarrierDataProvider.parseResponse(json); + assertEquals("UNAUTHORIZED", response.error()); + assertEquals("Invalid api_key or api_secret", response.message()); + assertNull(response.results()); + } +} diff --git a/service/src/test/resources/config/test-secrets-bundle.yml b/service/src/test/resources/config/test-secrets-bundle.yml index 99fd1e587..51a7e3a44 100644 --- a/service/src/test/resources/config/test-secrets-bundle.yml +++ b/service/src/test/resources/config/test-secrets-bundle.yml @@ -172,3 +172,6 @@ turn.cloudflare.apiToken: ABCDEFGHIJKLM linkDevice.secret: AAAAAAAAAAA= tlsKeyStore.password: unset + +hlrLookup.apiKey: AAAAAAAAAAA +hlrLookup.apiSecret: AAAAAAAAAAA diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index b9b526600..ee5f6fb4a 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -527,3 +527,7 @@ asnTable: callQualitySurvey: pubSubPublisher: type: stub + +hlrLookup: + apiKey: secret://hlrLookup.apiKey + apiSecret: secret://hlrLookup.apiSecret