diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TorExitNodeConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TorExitNodeConfiguration.java new file mode 100644 index 000000000..44c8b6019 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TorExitNodeConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import java.time.Duration; + +public class TorExitNodeConfiguration { + + @JsonProperty + @NotBlank + private String listUrl; + + @JsonProperty + private Duration refreshInterval = Duration.ofMinutes(5); + + @JsonProperty + @Valid + private CircuitBreakerConfiguration circuitBreakerConfiguration = new CircuitBreakerConfiguration(); + + @JsonProperty + @Valid + private RetryConfiguration retryConfiguration = new RetryConfiguration(); + + public String getListUrl() { + return listUrl; + } + + @VisibleForTesting + public void setListUrl(final String listUrl) { + this.listUrl = listUrl; + } + + public Duration getRefreshInterval() { + return refreshInterval; + } + + public CircuitBreakerConfiguration getCircuitBreakerConfiguration() { + return circuitBreakerConfiguration; + } + + public RetryConfiguration getRetryConfiguration() { + return retryConfiguration; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/TorExitNodeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/TorExitNodeManager.java new file mode 100644 index 000000000..b0f04f366 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/TorExitNodeManager.java @@ -0,0 +1,131 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.google.common.annotations.VisibleForTesting; +import io.dropwizard.lifecycle.Managed; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Timer; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.configuration.TorExitNodeConfiguration; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; + +import static com.codahale.metrics.MetricRegistry.name; + +/** + * A utility for checking whether IP addresses belong to Tor exit nodes using the "bulk exit list." + * + * @see Changes to the Tor Exit List Service + */ +public class TorExitNodeManager implements Managed { + + private final ScheduledExecutorService refreshScheduledExecutorService; + private final Duration refreshDelay; + private ScheduledFuture refreshFuture; + + private final FaultTolerantHttpClient refreshClient; + private final URI exitNodeListUri; + + private final AtomicReference lastEtag = new AtomicReference<>(null); + private final AtomicReference> exitNodeAddresses = new AtomicReference<>(Collections.emptySet()); + + private static final Timer REFRESH_TIMER = Metrics.timer(name(TorExitNodeManager.class, "refresh")); + private static final Counter REFRESH_ERRORS = Metrics.counter(name(TorExitNodeManager.class, "refreshErrors")); + + private static final Logger log = LoggerFactory.getLogger(TorExitNodeManager.class); + + public TorExitNodeManager(final ScheduledExecutorService scheduledExecutorService, final ExecutorService clientExecutorService, final TorExitNodeConfiguration configuration) { + this.refreshScheduledExecutorService = scheduledExecutorService; + + this.exitNodeListUri = URI.create(configuration.getListUrl()); + this.refreshDelay = configuration.getRefreshInterval(); + + refreshClient = FaultTolerantHttpClient.newBuilder() + .withCircuitBreaker(configuration.getCircuitBreakerConfiguration()) + .withRetry(configuration.getRetryConfiguration()) + .withVersion(HttpClient.Version.HTTP_1_1) + .withConnectTimeout(Duration.ofSeconds(10)) + .withRedirect(HttpClient.Redirect.NEVER) + .withExecutor(clientExecutorService) + .withName("tor-exit-node") + .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3) + .build(); + } + + public boolean isTorExitNode(final String address) { + return exitNodeAddresses.get().contains(address); + } + + @VisibleForTesting + CompletableFuture refresh() { + final String etag = lastEtag.get(); + + final HttpRequest request; + { + final HttpRequest.Builder builder = HttpRequest.newBuilder().GET().uri(exitNodeListUri); + + if (StringUtils.isNotBlank(etag)) { + builder.header("If-None-Match", etag); + } + + request = builder.build(); + } + + final long start = System.nanoTime(); + + return refreshClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .whenComplete((response, cause) -> { + REFRESH_TIMER.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); + + if (cause != null) { + REFRESH_ERRORS.increment(); + log.warn("Failed to refresh Tor exit node list", cause); + } else { + if (response.statusCode() == 200) { + exitNodeAddresses.set(response.body().lines().collect(Collectors.toSet())); + response.headers().firstValue("ETag").ifPresent(newEtag -> lastEtag.compareAndSet(etag, newEtag)); + } else if (response.statusCode() != 304) { + REFRESH_ERRORS.increment(); + log.warn("Failed to refresh Tor exit node list: {} ({})", response.statusCode(), response.body()); + } + } + }); + } + + @Override + public synchronized void start() { + if (refreshFuture != null) { + refreshFuture.cancel(true); + } + + refreshFuture = refreshScheduledExecutorService + .scheduleAtFixedRate(this::refresh, 0, refreshDelay.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public synchronized void stop() { + if (refreshFuture != null) { + refreshFuture.cancel(true); + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/TorExitNodeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/TorExitNodeManagerTest.java new file mode 100644 index 000000000..0133479a6 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/TorExitNodeManagerTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.github.tomakehurst.wiremock.matching.StringValuePattern; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.whispersystems.textsecuregcm.configuration.TorExitNodeConfiguration; +import org.whispersystems.textsecuregcm.redis.AbstractRedisClusterTest; + +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.junit.Assert.*; + +public class TorExitNodeManagerTest extends AbstractRedisClusterTest { + + private ScheduledExecutorService scheduledExecutorService; + private ExecutorService clientExecutorService; + + private TorExitNodeManager torExitNodeManager; + + private static final String LIST_PATH = "/list"; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(options().dynamicPort().dynamicHttpsPort()); + + @Before + public void setUp() throws Exception { + final TorExitNodeConfiguration configuration = new TorExitNodeConfiguration(); + configuration.setListUrl("http://localhost:" + wireMockRule.port() + LIST_PATH); + + scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); + clientExecutorService = Executors.newSingleThreadExecutor(); + + torExitNodeManager = new TorExitNodeManager(scheduledExecutorService, clientExecutorService, configuration); + } + + @After + public void tearDown() throws Exception { + scheduledExecutorService.shutdown(); + scheduledExecutorService.awaitTermination(1, TimeUnit.SECONDS); + + clientExecutorService.shutdown(); + clientExecutorService.awaitTermination(1, TimeUnit.SECONDS); + } + + @Test + public void testIsTorExitNode() { + assertFalse(torExitNodeManager.isTorExitNode("10.0.0.1")); + assertFalse(torExitNodeManager.isTorExitNode("10.0.0.2")); + + final String etag = UUID.randomUUID().toString(); + + wireMockRule.stubFor(get(urlEqualTo(LIST_PATH)) + .willReturn(aResponse() + .withHeader("ETag", etag) + .withBody("10.0.0.1\n10.0.0.2"))); + + torExitNodeManager.refresh().join(); + + verify(getRequestedFor(urlEqualTo(LIST_PATH)).withoutHeader("If-None-Match")); + + assertTrue(torExitNodeManager.isTorExitNode("10.0.0.1")); + assertTrue(torExitNodeManager.isTorExitNode("10.0.0.2")); + assertFalse(torExitNodeManager.isTorExitNode("10.0.0.3")); + + wireMockRule.stubFor(get(urlEqualTo(LIST_PATH)).withHeader("If-None-Match", equalTo(etag)) + .willReturn(aResponse().withStatus(304))); + + torExitNodeManager.refresh().join(); + + verify(getRequestedFor(urlEqualTo(LIST_PATH)).withHeader("If-None-Match", equalTo(etag))); + + assertTrue(torExitNodeManager.isTorExitNode("10.0.0.1")); + assertTrue(torExitNodeManager.isTorExitNode("10.0.0.2")); + assertFalse(torExitNodeManager.isTorExitNode("10.0.0.3")); + } +}