diff --git a/pom.xml b/pom.xml
index 1689a39a4..27f3e7338 100644
--- a/pom.xml
+++ b/pom.xml
@@ -405,6 +405,7 @@
compile
compile-custom
test-compile
+ test-compile-custom
diff --git a/service/config/sample.yml b/service/config/sample.yml
index 0f41d7c90..1225b64b2 100644
--- a/service/config/sample.yml
+++ b/service/config/sample.yml
@@ -51,6 +51,8 @@ adminEventLoggingConfiguration:
projectId: some-project-id
logName: some-log-name
+grpcPort: 8080
+
stripe:
apiKey: secret://stripe.apiKey
idempotencyKeyGenerator: secret://stripe.idempotencyKeyGenerator
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
index 06eded1b6..1e4599027 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
@@ -270,6 +270,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private TurnSecretConfiguration turn;
+ @Valid
+ @NotNull
+ @JsonProperty
+ private int grpcPort;
+
public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() {
return adminEventLoggingConfiguration;
}
@@ -448,4 +453,9 @@ public RegistrationServiceConfiguration getRegistrationServiceConfiguration() {
public TurnSecretConfiguration getTurnSecretConfiguration() {
return turn;
}
+
+ public int getGrpcPort() {
+ return grpcPort;
+ }
+
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
index 8bd0c94b9..75804394d 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
@@ -23,8 +23,11 @@
import io.dropwizard.auth.basic.BasicCredentials;
import io.dropwizard.setup.Bootstrap;
import io.dropwizard.setup.Environment;
+import io.grpc.Server;
+import io.grpc.ServerBuilder;
import io.lettuce.core.resource.ClientResources;
import io.micrometer.core.instrument.Metrics;
+import io.micrometer.core.instrument.binder.grpc.MetricCollectingServerInterceptor;
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
import java.io.ByteArrayInputStream;
import java.net.http.HttpClient;
@@ -105,6 +108,8 @@
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.currency.FixerClient;
+import org.whispersystems.textsecuregcm.grpc.GrpcServerManagedWrapper;
+import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
@@ -626,10 +631,22 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(
disabledPermittedAccountAuthenticator).buildAuthFilter();
+ final ServerBuilder> grpcServer = ServerBuilder.forPort(config.getGrpcPort())
+ .intercept(new MetricCollectingServerInterceptor(Metrics.globalRegistry)); /* TODO: specialize metrics with user-agent platform */
+
+ RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
environment.servlets()
- .addFilter("RemoteDeprecationFilter", new RemoteDeprecationFilter(dynamicConfigurationManager))
+ .addFilter("RemoteDeprecationFilter", remoteDeprecationFilter)
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
+ // Note: interceptors run in the reverse order they are added; the remote deprecation filter
+ // depends on the user-agent context so it has to come first here!
+ // http://grpc.github.io/grpc-java/javadoc/io/grpc/ServerBuilder.html#intercept-io.grpc.ServerInterceptor-
+ grpcServer.intercept(remoteDeprecationFilter);
+ grpcServer.intercept(new UserAgentInterceptor());
+
+ environment.lifecycle().manage(new GrpcServerManagedWrapper(grpcServer.build()));
+
environment.jersey().register(new RequestStatisticsFilter(TrafficSource.HTTP));
environment.jersey().register(MultiRecipientMessageProvider.class);
environment.jersey().register(new MetricsApplicationEventListener(TrafficSource.HTTP));
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java
index 5d185657a..6e9c590af 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java
@@ -7,8 +7,15 @@
import static com.codahale.metrics.MetricRegistry.name;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.net.HttpHeaders;
import com.vdurmont.semver4j.Semver;
+
+import io.grpc.Metadata;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
import io.micrometer.core.instrument.Metrics;
import java.io.IOException;
import java.util.Map;
@@ -22,6 +29,8 @@
import javax.servlet.http.HttpServletResponse;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
+import org.whispersystems.textsecuregcm.grpc.StatusConstants;
+import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
@@ -34,7 +43,7 @@
* If a client platform does not have a configured minimum version, all traffic from that client
* platform is allowed.
*/
-public class RemoteDeprecationFilter implements Filter {
+public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
private final DynamicConfigurationManager dynamicConfigurationManager;
@@ -52,58 +61,82 @@ public RemoteDeprecationFilter(final DynamicConfigurationManager ServerCall.Listener interceptCall(
+ final ServerCall call,
+ final Metadata headers,
+ final ServerCallHandler next) {
+
+ if (shouldBlock(UserAgentUtil.userAgentFromGrpcContext())) {
+ call.close(StatusConstants.UPGRADE_NEEDED_STATUS, new Metadata());
+ return new ServerCall.Listener<>() {};
+ } else {
+ return next.startCall(call, headers);
+ }
+ }
+
+ private boolean shouldBlock(final UserAgent userAgent) {
final DynamicRemoteDeprecationConfiguration configuration = dynamicConfigurationManager
.getConfiguration().getRemoteDeprecationConfiguration();
-
final Map minimumVersionsByPlatform = configuration.getMinimumVersions();
- final Map versionsPendingDeprecationByPlatform = configuration.getVersionsPendingDeprecation();
+ final Map versionsPendingDeprecationByPlatform = configuration
+ .getVersionsPendingDeprecation();
final Map> blockedVersionsByPlatform = configuration.getBlockedVersions();
final Map> versionsPendingBlockByPlatform = configuration.getVersionsPendingBlock();
- final boolean allowUnrecognizedUserAgents = configuration.isUnrecognizedUserAgentAllowed();
boolean shouldBlock = false;
- try {
- final String userAgentString = ((HttpServletRequest) request).getHeader(HttpHeaders.USER_AGENT);
- final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString);
-
- if (blockedVersionsByPlatform.containsKey(userAgent.getPlatform())) {
- if (blockedVersionsByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
- recordDeprecation(userAgent, BLOCKED_CLIENT_REASON);
- shouldBlock = true;
- }
+ if (userAgent == null) {
+ if (configuration.isUnrecognizedUserAgentAllowed()) {
+ return false;
}
+ recordDeprecation(null, UNRECOGNIZED_UA_REASON);
+ return true;
+ }
- if (minimumVersionsByPlatform.containsKey(userAgent.getPlatform())) {
- if (userAgent.getVersion().isLowerThan(minimumVersionsByPlatform.get(userAgent.getPlatform()))) {
- recordDeprecation(userAgent, EXPIRED_CLIENT_REASON);
- shouldBlock = true;
- }
+ if (blockedVersionsByPlatform.containsKey(userAgent.getPlatform())) {
+ if (blockedVersionsByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
+ recordDeprecation(userAgent, BLOCKED_CLIENT_REASON);
+ shouldBlock = true;
}
+ }
- if (versionsPendingBlockByPlatform.containsKey(userAgent.getPlatform())) {
- if (versionsPendingBlockByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
- recordPendingDeprecation(userAgent, BLOCKED_CLIENT_REASON);
- }
+ if (minimumVersionsByPlatform.containsKey(userAgent.getPlatform())) {
+ if (userAgent.getVersion().isLowerThan(minimumVersionsByPlatform.get(userAgent.getPlatform()))) {
+ recordDeprecation(userAgent, EXPIRED_CLIENT_REASON);
+ shouldBlock = true;
}
+ }
- if (versionsPendingDeprecationByPlatform.containsKey(userAgent.getPlatform())) {
- if (userAgent.getVersion().isLowerThan(versionsPendingDeprecationByPlatform.get(userAgent.getPlatform()))) {
- recordPendingDeprecation(userAgent, EXPIRED_CLIENT_REASON);
- }
- }
- } catch (final UnrecognizedUserAgentException e) {
- if (!allowUnrecognizedUserAgents) {
- recordDeprecation(null, UNRECOGNIZED_UA_REASON);
- shouldBlock = true;
+ if (versionsPendingBlockByPlatform.containsKey(userAgent.getPlatform())) {
+ if (versionsPendingBlockByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
+ recordPendingDeprecation(userAgent, BLOCKED_CLIENT_REASON);
}
}
- if (shouldBlock) {
- ((HttpServletResponse) response).sendError(499);
- } else {
- chain.doFilter(request, response);
+ if (versionsPendingDeprecationByPlatform.containsKey(userAgent.getPlatform())) {
+ if (userAgent.getVersion().isLowerThan(versionsPendingDeprecationByPlatform.get(userAgent.getPlatform()))) {
+ recordPendingDeprecation(userAgent, EXPIRED_CLIENT_REASON);
+ }
}
+
+ return shouldBlock;
}
private void recordDeprecation(final UserAgent userAgent, final String reason) {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcServerManagedWrapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcServerManagedWrapper.java
new file mode 100644
index 000000000..1b88c290d
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcServerManagedWrapper.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.grpc;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import io.dropwizard.lifecycle.Managed;
+import io.grpc.Server;
+
+public class GrpcServerManagedWrapper implements Managed {
+
+ private final Server server;
+
+ public GrpcServerManagedWrapper(final Server server) {
+ this.server = server;
+ }
+
+ @Override
+ public void start() throws IOException {
+ server.start();
+ }
+
+ @Override
+ public void stop() {
+ try {
+ server.shutdown().awaitTermination(5, TimeUnit.MINUTES);
+ } catch (InterruptedException e) {
+ server.shutdownNow();
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/StatusConstants.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/StatusConstants.java
new file mode 100644
index 000000000..78836ff20
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/StatusConstants.java
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.grpc;
+
+import io.grpc.Status;
+
+public abstract class StatusConstants {
+ public static final Status UPGRADE_NEEDED_STATUS = Status.INVALID_ARGUMENT.withDescription("signal-upgrade-required");
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/UserAgentInterceptor.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/UserAgentInterceptor.java
new file mode 100644
index 000000000..4a0d71ef4
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/UserAgentInterceptor.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.grpc;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
+import org.whispersystems.textsecuregcm.util.ua.UserAgent;
+import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
+
+import io.grpc.Context;
+import io.grpc.Contexts;
+import io.grpc.Metadata;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+
+public class UserAgentInterceptor implements ServerInterceptor {
+ @VisibleForTesting
+ public static final Metadata.Key USER_AGENT_GRPC_HEADER =
+ Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER);
+
+ @Override
+ public ServerCall.Listener interceptCall(final ServerCall call,
+ final Metadata headers,
+ final ServerCallHandler next) {
+
+ UserAgent userAgent;
+ try {
+ userAgent = UserAgentUtil.parseUserAgentString(headers.get(USER_AGENT_GRPC_HEADER));
+ } catch (final UnrecognizedUserAgentException e) {
+ userAgent = null;
+ }
+
+ final Context context = Context.current().withValue(UserAgentUtil.USER_AGENT_CONTEXT_KEY, userAgent);
+ return Contexts.interceptCall(context, call, headers, next);
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java
index 3f3714662..d4253f14e 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java
@@ -1,64 +1,63 @@
/*
- * Copyright 2013-2020 Signal Messenger, LLC
+ * Copyright 2013-2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util.ua;
import com.vdurmont.semver4j.Semver;
-
import java.util.Objects;
import java.util.Optional;
public class UserAgent {
- private final ClientPlatform platform;
- private final Semver version;
- private final String additionalSpecifiers;
-
- public UserAgent(final ClientPlatform platform, final Semver version) {
- this(platform, version, null);
- }
-
- public UserAgent(final ClientPlatform platform, final Semver version, final String additionalSpecifiers) {
- this.platform = platform;
- this.version = version;
- this.additionalSpecifiers = additionalSpecifiers;
- }
-
- public ClientPlatform getPlatform() {
- return platform;
- }
-
- public Semver getVersion() {
- return version;
- }
-
- public Optional getAdditionalSpecifiers() {
- return Optional.ofNullable(additionalSpecifiers);
- }
-
- @Override
- public boolean equals(final Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
- final UserAgent userAgent = (UserAgent)o;
- return platform == userAgent.platform &&
- version.equals(userAgent.version) &&
- Objects.equals(additionalSpecifiers, userAgent.additionalSpecifiers);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(platform, version, additionalSpecifiers);
- }
-
- @Override
- public String toString() {
- return "UserAgent{" +
- "platform=" + platform +
- ", version=" + version +
- ", additionalSpecifiers='" + additionalSpecifiers + '\'' +
- '}';
- }
+ private final ClientPlatform platform;
+ private final Semver version;
+ private final String additionalSpecifiers;
+
+ public UserAgent(final ClientPlatform platform, final Semver version) {
+ this(platform, version, null);
+ }
+
+ public UserAgent(final ClientPlatform platform, final Semver version, final String additionalSpecifiers) {
+ this.platform = platform;
+ this.version = version;
+ this.additionalSpecifiers = additionalSpecifiers;
+ }
+
+ public ClientPlatform getPlatform() {
+ return platform;
+ }
+
+ public Semver getVersion() {
+ return version;
+ }
+
+ public Optional getAdditionalSpecifiers() {
+ return Optional.ofNullable(additionalSpecifiers);
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ final UserAgent userAgent = (UserAgent)o;
+ return platform == userAgent.platform &&
+ version.equals(userAgent.version) &&
+ Objects.equals(additionalSpecifiers, userAgent.additionalSpecifiers);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(platform, version, additionalSpecifiers);
+ }
+
+ @Override
+ public String toString() {
+ return "UserAgent{" +
+ "platform=" + platform +
+ ", version=" + version +
+ ", additionalSpecifiers='" + additionalSpecifiers + '\'' +
+ '}';
+ }
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java
index afbedf122..fe04b9f91 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java
@@ -7,40 +7,47 @@
import com.google.common.annotations.VisibleForTesting;
import com.vdurmont.semver4j.Semver;
+import io.grpc.Context;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
public class UserAgentUtil {
- private static final Pattern STANDARD_UA_PATTERN = Pattern.compile("^Signal-(Android|Desktop|iOS)/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE);
+ public static final Context.Key USER_AGENT_CONTEXT_KEY = Context.key("x-signal-user-agent");
- public static UserAgent parseUserAgentString(final String userAgentString) throws UnrecognizedUserAgentException {
- if (StringUtils.isBlank(userAgentString)) {
- throw new UnrecognizedUserAgentException("User-Agent string is blank");
- }
+ private static final Pattern STANDARD_UA_PATTERN = Pattern.compile("^Signal-(Android|Desktop|iOS)/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE);
- try {
- final UserAgent standardUserAgent = parseStandardUserAgentString(userAgentString);
+ public static UserAgent parseUserAgentString(final String userAgentString) throws UnrecognizedUserAgentException {
+ if (StringUtils.isBlank(userAgentString)) {
+ throw new UnrecognizedUserAgentException("User-Agent string is blank");
+ }
- if (standardUserAgent != null) {
- return standardUserAgent;
- }
- } catch (final Exception e) {
- throw new UnrecognizedUserAgentException(e);
- }
+ try {
+ final UserAgent standardUserAgent = parseStandardUserAgentString(userAgentString);
- throw new UnrecognizedUserAgentException();
+ if (standardUserAgent != null) {
+ return standardUserAgent;
+ }
+ } catch (final Exception e) {
+ throw new UnrecognizedUserAgentException(e);
}
- @VisibleForTesting
- static UserAgent parseStandardUserAgentString(final String userAgentString) {
- final Matcher matcher = STANDARD_UA_PATTERN.matcher(userAgentString);
+ throw new UnrecognizedUserAgentException();
+ }
+
+ public static UserAgent userAgentFromGrpcContext() {
+ return USER_AGENT_CONTEXT_KEY.get();
+ }
- if (matcher.matches()) {
- return new UserAgent(ClientPlatform.valueOf(matcher.group(1).toUpperCase()), new Semver(matcher.group(2)), StringUtils.stripToNull(matcher.group(4)));
- }
+ @VisibleForTesting
+ static UserAgent parseStandardUserAgentString(final String userAgentString) {
+ final Matcher matcher = STANDARD_UA_PATTERN.matcher(userAgentString);
- return null;
+ if (matcher.matches()) {
+ return new UserAgent(ClientPlatform.valueOf(matcher.group(1).toUpperCase()), new Semver(matcher.group(2)), StringUtils.stripToNull(matcher.group(4)));
}
+
+ return null;
+ }
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java
index 164568a07..48572f294 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java
@@ -5,6 +5,8 @@
package org.whispersystems.textsecuregcm.filters;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
@@ -13,105 +15,165 @@
import static org.mockito.Mockito.when;
import com.google.common.net.HttpHeaders;
+import com.google.protobuf.ByteString;
import com.vdurmont.semver4j.Semver;
import java.io.IOException;
import java.util.EnumMap;
import java.util.Set;
+import java.util.stream.Stream;
+
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.signal.chat.rpc.EchoServiceGrpc;
+import org.signal.chat.rpc.EchoRequest;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
+import org.whispersystems.textsecuregcm.grpc.EchoServiceImpl;
+import org.whispersystems.textsecuregcm.grpc.StatusConstants;
+import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
-class RemoteDeprecationFilterTest {
-
- @Test
- void testEmptyMap() throws IOException, ServletException {
- // We're happy as long as there's no exception
- final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
- final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
- final DynamicRemoteDeprecationConfiguration emptyConfiguration = new DynamicRemoteDeprecationConfiguration();
-
- when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
- when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(emptyConfiguration);
-
- final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager);
+import io.grpc.Metadata;
+import io.grpc.ManagedChannel;
+import io.grpc.Server;
+import io.grpc.ServerBuilder;
+import io.grpc.StatusRuntimeException;
+import io.grpc.inprocess.InProcessServerBuilder;
+import io.grpc.inprocess.InProcessChannelBuilder;
+import io.grpc.stub.MetadataUtils;
- final HttpServletRequest servletRequest = mock(HttpServletRequest.class);
- final HttpServletResponse servletResponse = mock(HttpServletResponse.class);
- final FilterChain filterChain = mock(FilterChain.class);
-
- when(servletRequest.getHeader("UserAgent")).thenReturn("Signal-Android/4.68.3");
-
- filter.doFilter(servletRequest, servletResponse, filterChain);
+class RemoteDeprecationFilterTest {
- verify(filterChain).doFilter(servletRequest, servletResponse);
- verify(servletResponse, never()).sendError(anyInt());
+ @Test
+ void testEmptyMap() throws IOException, ServletException {
+ // We're happy as long as there's no exception
+ final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
+ final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
+ final DynamicRemoteDeprecationConfiguration emptyConfiguration = new DynamicRemoteDeprecationConfiguration();
+
+ when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
+ when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(emptyConfiguration);
+
+ final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager);
+
+ final HttpServletRequest servletRequest = mock(HttpServletRequest.class);
+ final HttpServletResponse servletResponse = mock(HttpServletResponse.class);
+ final FilterChain filterChain = mock(FilterChain.class);
+
+ when(servletRequest.getHeader("UserAgent")).thenReturn("Signal-Android/4.68.3");
+
+ filter.doFilter(servletRequest, servletResponse, filterChain);
+
+ verify(filterChain).doFilter(servletRequest, servletResponse);
+ verify(servletResponse, never()).sendError(anyInt());
+ }
+
+ private RemoteDeprecationFilter filterConfiguredForTest() {
+ final EnumMap minimumVersionsByPlatform = new EnumMap<>(ClientPlatform.class);
+ minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.0.0"));
+ minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.0.0"));
+ minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.0.0"));
+
+ final EnumMap versionsPendingDeprecationByPlatform = new EnumMap<>(ClientPlatform.class);
+ minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.1.0"));
+ minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.1.0"));
+ minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.1.0"));
+
+ final EnumMap> blockedVersionsByPlatform = new EnumMap<>(ClientPlatform.class);
+ blockedVersionsByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.2")));
+
+ final EnumMap> versionsPendingBlockByPlatform = new EnumMap<>(ClientPlatform.class);
+ versionsPendingBlockByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.3")));
+
+ final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = new DynamicRemoteDeprecationConfiguration();
+ remoteDeprecationConfiguration.setMinimumVersions(minimumVersionsByPlatform);
+ remoteDeprecationConfiguration.setVersionsPendingDeprecation(versionsPendingDeprecationByPlatform);
+ remoteDeprecationConfiguration.setBlockedVersions(blockedVersionsByPlatform);
+ remoteDeprecationConfiguration.setVersionsPendingBlock(versionsPendingBlockByPlatform);
+ remoteDeprecationConfiguration.setUnrecognizedUserAgentAllowed(true);
+
+ final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
+ final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
+
+ when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
+ when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(remoteDeprecationConfiguration);
+
+ return new RemoteDeprecationFilter(dynamicConfigurationManager);
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testFilter(final String userAgent, final boolean expectDeprecation) throws IOException, ServletException {
+ final HttpServletRequest servletRequest = mock(HttpServletRequest.class);
+ final HttpServletResponse servletResponse = mock(HttpServletResponse.class);
+ final FilterChain filterChain = mock(FilterChain.class);
+
+ when(servletRequest.getHeader(HttpHeaders.USER_AGENT)).thenReturn(userAgent);
+
+ final RemoteDeprecationFilter filter = filterConfiguredForTest();
+ filter.doFilter(servletRequest, servletResponse, filterChain);
+
+ if (expectDeprecation) {
+ verify(filterChain, never()).doFilter(any(), any());
+ verify(servletResponse).sendError(499);
+ } else {
+ verify(filterChain).doFilter(servletRequest, servletResponse);
+ verify(servletResponse, never()).sendError(anyInt());
}
-
- @ParameterizedTest
- @CsvSource(delimiter = '|', value =
- {"Unrecognized UA | false",
- "Signal-Android/4.68.3 | false",
- "Signal-iOS/3.9.0 | false",
- "Signal-Desktop/1.2.3 | false",
- "Signal-Android/0.68.3 | true",
- "Signal-iOS/0.9.0 | true",
- "Signal-Desktop/0.2.3 | true",
- "Signal-Desktop/8.0.0-beta.2 | true",
- "Signal-Desktop/8.0.0-beta.1 | false",
- "Signal-iOS/8.0.0-beta.2 | false"})
- void testFilter(final String userAgent, final boolean expectDeprecation) throws IOException, ServletException {
- final EnumMap minimumVersionsByPlatform = new EnumMap<>(ClientPlatform.class);
- minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.0.0"));
- minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.0.0"));
- minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.0.0"));
-
- final EnumMap versionsPendingDeprecationByPlatform = new EnumMap<>(ClientPlatform.class);
- minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.1.0"));
- minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.1.0"));
- minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.1.0"));
-
- final EnumMap> blockedVersionsByPlatform = new EnumMap<>(ClientPlatform.class);
- blockedVersionsByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.2")));
-
- final EnumMap> versionsPendingBlockByPlatform = new EnumMap<>(ClientPlatform.class);
- versionsPendingBlockByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.3")));
-
- final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = new DynamicRemoteDeprecationConfiguration();
- remoteDeprecationConfiguration.setMinimumVersions(minimumVersionsByPlatform);
- remoteDeprecationConfiguration.setVersionsPendingDeprecation(versionsPendingDeprecationByPlatform);
- remoteDeprecationConfiguration.setBlockedVersions(blockedVersionsByPlatform);
- remoteDeprecationConfiguration.setVersionsPendingBlock(versionsPendingBlockByPlatform);
- remoteDeprecationConfiguration.setUnrecognizedUserAgentAllowed(true);
-
- final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
- final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
-
- when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
- when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(remoteDeprecationConfiguration);
-
- final HttpServletRequest servletRequest = mock(HttpServletRequest.class);
- final HttpServletResponse servletResponse = mock(HttpServletResponse.class);
- final FilterChain filterChain = mock(FilterChain.class);
-
- when(servletRequest.getHeader(HttpHeaders.USER_AGENT)).thenReturn(userAgent);
-
- final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager);
- filter.doFilter(servletRequest, servletResponse, filterChain);
-
+ }
+
+ @ParameterizedTest
+ @MethodSource(value="testFilter")
+ void testGrpcFilter(final String userAgent, final boolean expectDeprecation) throws Exception {
+ final Server testServer = InProcessServerBuilder.forName("RemoteDeprecationFilterTest")
+ .directExecutor()
+ .addService(new EchoServiceImpl())
+ .intercept(filterConfiguredForTest())
+ .intercept(new UserAgentInterceptor())
+ .build()
+ .start();
+ final ManagedChannel channel = InProcessChannelBuilder.forName("RemoteDeprecationFilterTest")
+ .directExecutor()
+ .userAgent(userAgent)
+ .build();
+
+ try {
+ final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);
+
+ final EchoRequest req = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8("cluck cluck, i'm a parrot")).build();
if (expectDeprecation) {
- verify(filterChain, never()).doFilter(any(), any());
- verify(servletResponse).sendError(499);
+ final StatusRuntimeException e = assertThrows(
+ StatusRuntimeException.class,
+ () -> client.echo(req));
+ assertEquals(StatusConstants.UPGRADE_NEEDED_STATUS.toString(), e.getStatus().toString());
} else {
- verify(filterChain).doFilter(servletRequest, servletResponse);
- verify(servletResponse, never()).sendError(anyInt());
+ assertEquals("cluck cluck, i'm a parrot", client.echo(req).getPayload().toStringUtf8());
}
+ } finally {
+ testServer.shutdownNow();
+ testServer.awaitTermination();
}
+ }
+
+ private static Stream testFilter() {
+ return Stream.of(
+ Arguments.of("Unrecognized UA", false),
+ Arguments.of("Signal-Android/4.68.3", false),
+ Arguments.of("Signal-iOS/3.9.0", false),
+ Arguments.of("Signal-Desktop/1.2.3", false),
+ Arguments.of("Signal-Android/0.68.3", true),
+ Arguments.of("Signal-iOS/0.9.0", true),
+ Arguments.of("Signal-Desktop/0.2.3", true),
+ Arguments.of("Signal-Desktop/8.0.0-beta.2", true),
+ Arguments.of("Signal-Desktop/8.0.0-beta.1", false),
+ Arguments.of("Signal-iOS/8.0.0-beta.2", false));
+ }
+
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java
new file mode 100644
index 000000000..d1dd1974d
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.grpc;
+
+import io.grpc.stub.StreamObserver;
+import org.signal.chat.rpc.EchoServiceGrpc;
+import org.signal.chat.rpc.EchoRequest;
+import org.signal.chat.rpc.EchoResponse;
+
+public class EchoServiceImpl extends EchoServiceGrpc.EchoServiceImplBase {
+ @Override
+ public void echo(EchoRequest req, StreamObserver responseObserver) {
+ responseObserver.onNext(EchoResponse.newBuilder().setPayload(req.getPayload()).build());
+ responseObserver.onCompleted();
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/UserAgentInterceptorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/UserAgentInterceptorTest.java
new file mode 100644
index 000000000..b338e03f1
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/UserAgentInterceptorTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import com.google.protobuf.ByteString;
+import com.vdurmont.semver4j.Semver;
+import io.grpc.ManagedChannel;
+import io.grpc.Metadata;
+import io.grpc.Server;
+import io.grpc.inprocess.InProcessChannelBuilder;
+import io.grpc.inprocess.InProcessServerBuilder;
+import io.grpc.stub.MetadataUtils;
+import io.grpc.stub.StreamObserver;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.signal.chat.rpc.EchoRequest;
+import org.signal.chat.rpc.EchoResponse;
+import org.signal.chat.rpc.EchoServiceGrpc;
+import org.whispersystems.textsecuregcm.grpc.EchoServiceImpl;
+import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
+import org.whispersystems.textsecuregcm.util.ua.UserAgent;
+import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
+
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+
+public class UserAgentInterceptorTest {
+
+ @ParameterizedTest
+ @MethodSource
+ void testInterceptor(final String header, final ClientPlatform platform, final String version) throws Exception {
+
+ final AtomicReference observedUserAgent = new AtomicReference<>(null);
+ final EchoServiceImpl serviceImpl = new EchoServiceImpl() {
+ @Override
+ public void echo(EchoRequest req, StreamObserver responseObserver) {
+ observedUserAgent.set(UserAgentUtil.userAgentFromGrpcContext());
+ super.echo(req, responseObserver);
+ }
+ };
+
+ final Server testServer = InProcessServerBuilder.forName("RemoteDeprecationFilterTest")
+ .directExecutor()
+ .addService(serviceImpl)
+ .intercept(new UserAgentInterceptor())
+ .build()
+ .start();
+
+ try {
+ final ManagedChannel channel = InProcessChannelBuilder.forName("RemoteDeprecationFilterTest")
+ .directExecutor()
+ .userAgent(header)
+ .build();
+
+ final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);
+
+ final EchoRequest req = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8("cluck cluck, i'm a parrot")).build();
+ assertEquals("cluck cluck, i'm a parrot", client.echo(req).getPayload().toStringUtf8());
+ if (platform == null) {
+ assertNull(observedUserAgent.get());
+ } else {
+ assertEquals(platform, observedUserAgent.get().getPlatform());
+ assertEquals(new Semver(version), observedUserAgent.get().getVersion());
+ // can't assert on the additional specifiers because they include internal details of the grpc in-process channel itself
+ }
+ } finally {
+ testServer.shutdownNow();
+ testServer.awaitTermination();
+ }
+ }
+
+ private static Stream testInterceptor() {
+ return Stream.of(
+ Arguments.of(null, null, null),
+ Arguments.of("", null, null),
+ Arguments.of("Unrecognized UA", null, null),
+ Arguments.of("Signal-Android/4.68.3", ClientPlatform.ANDROID, "4.68.3"),
+ Arguments.of("Signal-iOS/3.9.0", ClientPlatform.IOS, "3.9.0"),
+ Arguments.of("Signal-Desktop/1.2.3", ClientPlatform.DESKTOP, "1.2.3"),
+ Arguments.of("Signal-Desktop/8.0.0-beta.2", ClientPlatform.DESKTOP, "8.0.0-beta.2"),
+ Arguments.of("Signal-iOS/8.0.0-beta.2", ClientPlatform.IOS, "8.0.0-beta.2"));
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java
index 5017bd2f8..d26eae53e 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java
@@ -53,7 +53,10 @@ private static Stream argumentsForTestParseStandardUserAgentString()
Arguments.of("Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)",
new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)")),
Arguments.of("Signal-iOS/3.9.0 iOS/14.2", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "iOS/14.2")),
- Arguments.of("Signal-iOS/3.9.0", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0")))
- );
+ Arguments.of("Signal-iOS/3.9.0", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"))),
+ Arguments.of("Signal-Android/7.11.23-nightly-1982-06-28-07-07-07 tonic/0.31",
+ new UserAgent(ClientPlatform.ANDROID, new Semver("7.11.23-nightly-1982-06-28-07-07-07"), "tonic/0.31")),
+ Arguments.of("Signal-Android/7.11.23-nightly-1982-06-28-07-07-07 Android/42 tonic/0.31",
+ new UserAgent(ClientPlatform.ANDROID, new Semver("7.11.23-nightly-1982-06-28-07-07-07"), "Android/42 tonic/0.31")));
}
}
diff --git a/service/src/test/proto/echo_service.proto b/service/src/test/proto/echo_service.proto
new file mode 100644
index 000000000..9cc4a6ff5
--- /dev/null
+++ b/service/src/test/proto/echo_service.proto
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+syntax = "proto3";
+
+option java_multiple_files = true;
+
+package org.signal.chat.rpc;
+
+// A simple service for testing gRPC interceptors
+service EchoService {
+ rpc echo (EchoRequest) returns (EchoResponse) {}
+}
+
+message EchoRequest {
+ bytes payload = 1;
+}
+
+message EchoResponse {
+ bytes payload = 1;
+}