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

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