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; +}