修正 Signal 私服音视频通话

Signal-Server 新版(2025/02/28)完全移除了对单机部署的 TurnServer 的支持,全面转为了使用 Cloudflare 提供的 Turn 云服务,如果你还不了解什么是 TURN,可 点此 看看 cloudflare 的科普,简言之,他可以让两个处于内网的设备直接进行数据通讯,而「尽量」无须途径第三方。

相比自部署的单节点TURN服务器,cloudflare 的TURN 节点则分布在其全球 Anycast 网络中,在大多数场景下都能有效优化延迟和可用性,而且能大幅度降低系统的运维成本,对于 Signal 来说的确是更划算的。但对于我们希望私有化部署 Signal 来说则过于复杂了,为防止 「卡脖子」(并没有) 考虑修改 Signal 服务端代码,重新加回自部署 Coturn 支持。

事实上 Coturn 与 cloudflare 的实现差异仅在服务端,客户端无须任何变更,客户端甚至并不清楚当前服务的是什么,从接口 GET /v2/calling/relays 的响应结构体 GetCallingRelaysResponse 就可以看出来。

这里将主要的变更列出来,首先是配置文件,需要加入 coturn 的部分配置:

diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnConfiguration.java
index 6e69794d6..46107c877 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnConfiguration.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnConfiguration.java
@@ -5,5 +5,8 @@

 package org.whispersystems.textsecuregcm.configuration;

-public record TurnConfiguration(CloudflareTurnConfiguration cloudflare) {
+public record TurnConfiguration(
+    CloudflareTurnConfiguration cloudflare,
+    CoturnConfiguration coturn
+    ) {
 }

然后是 WEB 接口代码处,这里做了简化处理,未根据客户端IP来执行路由(单节点也没啥好路由的):

diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java
index 57e92728e..a5cae012a 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
 
 import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
 import io.dropwizard.auth.Auth;
 import io.micrometer.core.instrument.Counter;
 import io.micrometer.core.instrument.Metrics;
@@ -19,31 +20,92 @@ import jakarta.ws.rs.container.ContainerRequestContext;
 import jakarta.ws.rs.core.Context;
 import jakarta.ws.rs.core.MediaType;
 import java.io.IOException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.List;
 import java.util.UUID;
 import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
 import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
 import org.whispersystems.textsecuregcm.auth.TurnToken;
+import org.whispersystems.textsecuregcm.configuration.CoturnConfiguration;
+import org.whispersystems.textsecuregcm.configuration.TurnConfiguration;
 import org.whispersystems.textsecuregcm.limits.RateLimiters;
+import org.whispersystems.textsecuregcm.util.SystemMapper;
+import org.whispersystems.textsecuregcm.util.Util;
 import org.whispersystems.websocket.auth.ReadOnly;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
 
 @io.swagger.v3.oas.annotations.tags.Tag(name = "Calling")
 @Path("/v2/calling")
 public class CallRoutingControllerV2 {
 
   private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER = Metrics.counter(name(CallRoutingControllerV2.class, "cloudflareTurnError"));
   private final RateLimiters rateLimiters;
   private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
+  private final TurnConfiguration turn;
+
+  private static final String ALGORITHM = "HmacSHA1";
+  private static final String WithUrlsProtocol = "00";
+  private static final String WithIpsProtocol = "01";
 
   public CallRoutingControllerV2(
       final RateLimiters rateLimiters,
-      final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager
+      final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager,
+      final TurnConfiguration turn
   ) {
     this.rateLimiters = rateLimiters;
+    this.turn = turn;
     this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
   }
 
+  private TurnToken getCallingRelayOld() {
+    final CoturnConfiguration coturn = turn.coturn();
+    final TurnToken rs;
+
+    try {
+      rs = generateToken(
+          Base64.getDecoder().decode(coturn.secret().value()),
+          coturn.hostname(),
+          coturn.urlsWithIps(),
+          coturn.urlsWithHostname()
+          );
+    } catch (NoSuchAlgorithmException e) {
+      throw new RuntimeException(e);
+    } catch (InvalidKeyException e) {
+      throw new RuntimeException(e);
+    }
+
+    return rs;
+  }
+
+  private TurnToken generateToken(byte[] turnSecret, String hostname, List<String> urlsWithIps, List<String> urlsWithHostname)
+      throws NoSuchAlgorithmException, InvalidKeyException {
+    final Mac mac = Mac.getInstance(ALGORITHM);
+    final long validUntilSeconds = Instant.now().plus(Duration.ofDays(1)).getEpochSecond();
+    final long user = Util.ensureNonNegativeInt(new SecureRandom().nextInt());
+    final String userTime = validUntilSeconds + ":" + user;
+    final String protocol = urlsWithIps != null && !urlsWithIps.isEmpty()
+        ? WithIpsProtocol
+        : WithUrlsProtocol;
+    final String protocolUserTime = userTime + "#" + protocol;
+
+    mac.init(new SecretKeySpec(turnSecret, ALGORITHM));
+    final String password = Base64.getEncoder().encodeToString(mac.doFinal(protocolUserTime.getBytes()));
+
+    return new TurnToken(protocolUserTime, password, urlsWithHostname, urlsWithIps, hostname);
+  }
+
   @GET
   @Path("/relays")
   @Produces(MediaType.APPLICATION_JSON)
@@ -66,7 +128,8 @@ public class CallRoutingControllerV2 {
 
     List<TurnToken> tokens = new ArrayList<>();
     try {
-      tokens.add(cloudflareTurnCredentialsManager.retrieveFromCloudflare());
+//      tokens.add(cloudflareTurnCredentialsManager.retrieveFromCloudflare());
+      tokens.add(getCallingRelayOld());
     } catch (Exception e) {
       CallRoutingControllerV2.CLOUDFLARE_TURN_ERROR_COUNTER.increment();
       throw e;

再调整 Signal-Server 的服务器配置,在 config/config.ymlturn 区段下,增加 coturn 子对象,类似如下:

coturn:
  secret: secret://turn.secret
  hostname: turn.signal.mysignal.cn
  urlsWithIps:
    - 111.123.145.167:3478
  urlsWithHostname:
    - turn.signal.mysignal.cn:3478

将你的 coturn IP地址与域名都写上,默认情况下,客户端会优先使用 IP 地址的版本。配置完成后,重启服务端~

赶紧开模拟器试试:
signal 私有化部署 语音视频通话 coturn/TurnServer

完美~

posted @ 2025-04-21 15:32  pfoxh  阅读(89)  评论(0)    收藏  举报