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