Add a system to look up mobile network data

This commit is contained in:
Jon Chambers
2026-01-23 11:51:14 -05:00
committed by GitHub
parent 023296feaf
commit 3b87527f39
19 changed files with 933 additions and 62 deletions

View File

@@ -97,3 +97,6 @@ turn.cloudflare.apiToken: ABCDEFGHIJKLM
linkDevice.secret: AAAAAAAAAAA=
tlsKeyStore.password: unset
hlrLookup.apiKey: AAAAAAAAAAA
hlrLookup.apiSecret: AAAAAAAAAAA

View File

@@ -528,3 +528,7 @@ callQualitySurvey:
{
"credential": "configuration"
}
hlrLookup:
apiKey: secret://hlrLookup.apiKey
apiSecret: secret://hlrLookup.apiSecret

View File

@@ -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<String, @Valid RetryConfiguration> 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;
}
}

View File

@@ -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<WhisperServerConfiguration
.maxThreads(2)
.minThreads(2)
.build();
ExecutorService hlrLookupHttpExecutor = ExecutorServiceBuilder.of(environment, "hlrLookup")
.maxThreads(2)
.minThreads(2)
.build();
ExecutorService subscriptionProcessorExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(
"subscriptionProcessor",
@@ -634,6 +640,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);
UsernameHashZkProofVerifier usernameHashZkProofVerifier = new UsernameHashZkProofVerifier();
final CarrierDataProvider carrierDataProvider =
new HlrLookupCarrierDataProvider(config.getHlrLookupConfiguration().apiKey().value(),
config.getHlrLookupConfiguration().apiSecret().value(),
hlrLookupHttpExecutor,
config.getHlrLookupConfiguration().circuitBreakerConfigurationName(),
config.getHlrLookupConfiguration().retryConfigurationName(),
retryExecutor);
RegistrationServiceClient registrationServiceClient = config.getRegistrationServiceConfiguration()
.build(environment, registrationCallbackExecutor, registrationIdentityTokenRefreshExecutor);
KeyTransparencyServiceClient keyTransparencyServiceClient = new KeyTransparencyServiceClient(

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import javax.annotation.Nullable;
public record HlrLookupConfiguration(SecretString apiKey,
SecretString apiSecret,
@Nullable String circuitBreakerConfigurationName,
@Nullable String retryConfigurationName) {
}

View File

@@ -9,6 +9,7 @@ import com.google.common.annotations.VisibleForTesting;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -18,6 +19,7 @@ import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
@@ -29,8 +31,8 @@ import java.util.stream.IntStream;
import javax.annotation.Nullable;
import org.glassfish.jersey.SslConfigurator;
import org.whispersystems.textsecuregcm.util.CertificateUtil;
import org.whispersystems.textsecuregcm.util.ResilienceUtil;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.ResilienceUtil;
public class FaultTolerantHttpClient {
@@ -65,17 +67,31 @@ public class FaultTolerantHttpClient {
return this.httpClients.get(ThreadLocalRandom.current().nextInt(this.httpClients.size()));
}
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request,
public <T> HttpResponse<T> send(final HttpRequest request, final HttpResponse.BodyHandler<T> bodyHandler)
throws IOException {
final Callable<HttpResponse<T>> 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 <T> CompletableFuture<HttpResponse<T>> sendAsync(final HttpRequest request,
final HttpResponse.BodyHandler<T> bodyHandler) {
if (request.timeout().isEmpty()) {
request = HttpRequest.newBuilder(request, (_, _) -> true)
.timeout(defaultRequestTimeout)
.build();
}
final Supplier<CompletionStage<HttpResponse<T>>> 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 <T> Supplier<CompletionStage<HttpResponse<T>>> sendAsync(HttpClient client, HttpRequest request,
HttpResponse.BodyHandler<T> 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;

View File

@@ -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<String> mcc, Optional<String> mnc) {
public enum LineType {
MOBILE,
LANDLINE,
FIXED_VOIP,
NON_FIXED_VOIP,
OTHER,
UNKNOWN
}
}

View File

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

View File

@@ -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<CarrierData> lookupCarrierData(Phonenumber.PhoneNumber phoneNumber, Duration maxCachedAge)
throws IOException, CarrierDataException;
}

View File

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

View File

@@ -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<TelephoneNumberRequest> requests) {
}

View File

@@ -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<HlrLookupResult> results,
@Nullable String error,
@Nullable String message) {
}

View File

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

View File

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

View File

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

View File

@@ -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<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
final HttpResponse<String> 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<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
final HttpResponse<String> 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<String> 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<String> 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<Void> 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)

View File

@@ -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<CarrierData> 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<String> expectedMcc) {
assertEquals(expectedMcc, HlrLookupCarrierDataProvider.mccFromMccMnc(mccMnc));
}
private static List<Arguments> 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<String> expectedMnc) {
assertEquals(expectedMnc, HlrLookupCarrierDataProvider.mncFromMccMnc(mccMnc));
}
private static List<Arguments> 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<Arguments> lineType() {
return List.of(
Arguments.argumentSet("Null line type", null, CarrierData.LineType.UNKNOWN)
);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@ParameterizedTest
@MethodSource
void getNetworkDetails(final HlrLookupResult hlrLookupResult, final Optional<NetworkDetails> expectedNetworkDetails) {
assertEquals(expectedNetworkDetails, HlrLookupCarrierDataProvider.getNetworkDetails(hlrLookupResult));
}
private static List<Arguments> 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());
}
}

View File

@@ -172,3 +172,6 @@ turn.cloudflare.apiToken: ABCDEFGHIJKLM
linkDevice.secret: AAAAAAAAAAA=
tlsKeyStore.password: unset
hlrLookup.apiKey: AAAAAAAAAAA
hlrLookup.apiSecret: AAAAAAAAAAA

View File

@@ -527,3 +527,7 @@ asnTable:
callQualitySurvey:
pubSubPublisher:
type: stub
hlrLookup:
apiKey: secret://hlrLookup.apiKey
apiSecret: secret://hlrLookup.apiSecret