diff --git a/README.md b/README.md
index 176b86a5ea..e86a5d0dd2 100644
--- a/README.md
+++ b/README.md
@@ -224,6 +224,41 @@ PeerBanHelper 主要由以下几个功能模块组成:
+### 多拨侦测
+
+专业PCDN用户会在一台PCDN服务器上接入多条宽带,以此提升上传带宽,称为多拨。
+这类用户的刷下载工具一般也较为复杂,会利用多条宽带不同的出口IP分散流量,对抗基于下载进度的吸血检测。
+此模块对多拨下载现象进行侦测,发现同一网段集中下载同一种子,即予以全部封禁。
+目前已知可能误伤的情况:小ISP的骨干网出口在同一网段,造成多拨假象。如果种子涉及的BT网络主体在大陆以外,请谨慎使用。
+
+
+
+查看示例配置文件
+
+```yaml
+ multi-dialing-blocker:
+ enabled: false
+ # 子网掩码长度
+ # IP地址前多少位相同的视为同一个子网,位数越少范围越大,一般不需要修改
+ subnet-mask-length: 24
+ # 对于同小区IPv6地址应该取多少位掩码没有调查过,64位是不会误杀的保险值
+ subnet-mask-v6-length: 64
+ # 容许同一网段下载同一种子的IP数量,正整数
+ # 防止DHCP重新分配IP、碰巧有同一小区的用户下载同一种子等导致的误判
+ tolerate-num: 3
+ # 缓存持续时间(秒)
+ # 所有连接过的peer会记入缓存,DHCP服务会定期重新分配IP,缓存时间过长会导致误杀
+ cache-lifespan: 86400
+ # 是否追猎
+ # 如果某IP已判定为多拨,无视缓存时间限制继续搜寻其同伙
+ keep-hunting: true
+ # 追猎持续时间(秒)
+ # 和cache-lifspan作用相似,对被猎杀IP的缓存持续时间,keep-hunting为true时有效
+ keep-hunting-time: 2592000
+```
+
+
+
## 添加下载器
PeerBanHelper 能够连接多个支持的下载器,并共享 IP 黑名单。但每个下载器只能被一个 PeerBanHelper 添加,多个 PBH 会导致操作 IP 黑名单时出现冲突。
diff --git a/src/main/java/com/ghostchu/peerbanhelper/PeerBanHelperServer.java b/src/main/java/com/ghostchu/peerbanhelper/PeerBanHelperServer.java
index b5f37a8170..a9613c49b2 100644
--- a/src/main/java/com/ghostchu/peerbanhelper/PeerBanHelperServer.java
+++ b/src/main/java/com/ghostchu/peerbanhelper/PeerBanHelperServer.java
@@ -344,6 +344,7 @@ private void registerModules() {
moduleManager.register(new PeerIdBlacklist(this, profile));
moduleManager.register(new ClientNameBlacklist(this, profile));
moduleManager.register(new ProgressCheatBlocker(this, profile));
+ moduleManager.register(new MultiDialingBlocker(this, profile));
//moduleManager.register(new ActiveProbing(this, profile));
moduleManager.register(new AutoRangeBan(this, profile));
moduleManager.register(new BtnNetworkOnline(this, profile));
diff --git a/src/main/java/com/ghostchu/peerbanhelper/config/ProfileUpdateScript.java b/src/main/java/com/ghostchu/peerbanhelper/config/ProfileUpdateScript.java
index 014b81f813..94c58d0ee8 100644
--- a/src/main/java/com/ghostchu/peerbanhelper/config/ProfileUpdateScript.java
+++ b/src/main/java/com/ghostchu/peerbanhelper/config/ProfileUpdateScript.java
@@ -18,6 +18,17 @@ public ProfileUpdateScript(YamlConfiguration conf) {
this.conf = conf;
}
+ @UpdateScript(version = 3)
+ public void multiDialingBlocker() {
+ conf.set("module.multi-dialing-blocker.enabled", false);
+ conf.set("module.multi-dialing-blocker.subnet-mask-length", 24);
+ conf.set("module.multi-dialing-blocker.subnet-mask-v6-length", 64);
+ conf.set("module.multi-dialing-blocker.tolerate-num", 3);
+ conf.set("module.multi-dialing-blocker.cache-lifespan", 86400);
+ conf.set("module.multi-dialing-blocker.keep-hunting", true);
+ conf.set("module.multi-dialing-blocker.keep-hunting-time", 2592000);
+ }
+
@UpdateScript(version = 2)
public void newRuleSyntax() {
List peerId = conf.getStringList("module.peer-id-blacklist.exclude-peer-id");
diff --git a/src/main/java/com/ghostchu/peerbanhelper/module/impl/rule/MultiDialingBlocker.java b/src/main/java/com/ghostchu/peerbanhelper/module/impl/rule/MultiDialingBlocker.java
new file mode 100644
index 0000000000..b202705663
--- /dev/null
+++ b/src/main/java/com/ghostchu/peerbanhelper/module/impl/rule/MultiDialingBlocker.java
@@ -0,0 +1,198 @@
+package com.ghostchu.peerbanhelper.module.impl.rule;
+
+import com.ghostchu.peerbanhelper.PeerBanHelperServer;
+import com.ghostchu.peerbanhelper.module.AbstractFeatureModule;
+import com.ghostchu.peerbanhelper.module.AbstractRuleFeatureModule;
+import com.ghostchu.peerbanhelper.module.BanResult;
+import com.ghostchu.peerbanhelper.module.PeerAction;
+import com.ghostchu.peerbanhelper.peer.Peer;
+import com.ghostchu.peerbanhelper.text.Lang;
+import com.ghostchu.peerbanhelper.torrent.Torrent;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import inet.ipaddr.IPAddress;
+import lombok.extern.slf4j.Slf4j;
+import org.bspfsystems.yamlconfiguration.file.YamlConfiguration;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 同一网段集中下载同一个种子视为多拨,因为多拨和PCDN强相关所以可以直接封禁
+ */
+@Slf4j
+public class MultiDialingBlocker extends AbstractRuleFeatureModule {
+ // 计算缓存容量
+ private static final int TORRENT_PEER_MAX_NUM = 1024;
+ private static final int PEER_MAX_NUM_PER_SUBNET = 16;
+
+ private int subnetMaskLength;
+ private int subnetMaskV6Length;
+ private int tolerateNum;
+ private long cacheLifespan;
+ private boolean keepHunting;
+ private long keepHuntingTime;
+
+ public MultiDialingBlocker(PeerBanHelperServer server, YamlConfiguration profile) {
+ super(server, profile);
+ }
+
+ @Override
+ public @NotNull String getName() {
+ return "Multi Dialing Blocker";
+ }
+
+ @Override
+ public @NotNull String getConfigName() {
+ return "multi-dialing-blocker";
+ }
+
+ @Override
+ public boolean isCheckCacheable() {
+ return false;
+ }
+
+ @Override
+ public boolean needCheckHandshake() {
+ return false;
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return true;
+ }
+
+ @Override
+ public void onEnable() {
+ reloadConfig();
+ }
+
+ @Override
+ public void onDisable() {
+
+ }
+
+ private void reloadConfig() {
+ subnetMaskLength = getConfig().getInt("subnet-mask-length");
+ subnetMaskV6Length = getConfig().getInt("subnet-mask-v6-length");
+ tolerateNum = getConfig().getInt("tolerate-num");
+ cacheLifespan = getConfig().getInt("cache-lifespan") * 1000L;
+ keepHunting = getConfig().getBoolean("keep-hunting");
+ keepHuntingTime = getConfig().getInt("keep-hunting-time") * 1000L;
+
+ cache = CacheBuilder.newBuilder().
+ expireAfterWrite(cacheLifespan, TimeUnit.MILLISECONDS).
+ maximumSize(TORRENT_PEER_MAX_NUM).
+ build();
+ // 内层维护子网下的peer列表,外层回收不再使用的列表
+ // 外层按最后访问时间过期即可,若子网的列表还在被访问,说明还有属于该子网的peer在连接
+ subnetCounter = CacheBuilder.newBuilder().
+ expireAfterAccess(cacheLifespan, TimeUnit.MILLISECONDS).
+ maximumSize(TORRENT_PEER_MAX_NUM).
+ build();
+ huntingList = CacheBuilder.newBuilder().
+ expireAfterWrite(keepHuntingTime, TimeUnit.MILLISECONDS).
+ maximumSize(TORRENT_PEER_MAX_NUM).
+ build();
+ }
+
+ @Override
+ public @NotNull BanResult shouldBanPeer(
+ @NotNull Torrent torrent, @NotNull Peer peer, @NotNull ExecutorService ruleExecuteExecutor) {
+ String torrentName = torrent.getName();
+ String torrentId = torrent.getId();
+ IPAddress peerAddress = peer.getAddress().getAddress();
+ String peerIpStr = peerAddress.toString();
+ IPAddress peerSubnet = peerAddress.isIPv4() ?
+ peerAddress.toPrefixBlock(subnetMaskLength) : peerAddress.toPrefixBlock(subnetMaskV6Length);
+
+ try {
+ long currentTimestamp = System.currentTimeMillis();
+
+ String torrentIpStr = torrentId + '@' + peerIpStr;
+ cache.put(torrentIpStr, currentTimestamp);
+
+ String torrentSubnetStr = torrentId + '@' + peerSubnet;
+ Cache subnetPeers = subnetCounter.get(torrentSubnetStr, this::genPeerGroup);
+ subnetPeers.put(peerIpStr, currentTimestamp);
+
+ if (subnetPeers.size() > tolerateNum) {
+ // 落库
+ huntingList.put(torrentSubnetStr, currentTimestamp);
+ // 返回当前IP即可,其他IP会在下一周期被封禁
+ return new BanResult(this, PeerAction.BAN, "Multi-dialing download detected",
+ String.format(Lang.MODULE_MDB_MULTI_DIALING_DETECTED,
+ torrentName, peerSubnet, peerIpStr));
+ }
+
+ if (keepHunting) {
+ recoverHuntingList();
+ try {
+ long huntingTimestamp = huntingList.get(torrentSubnetStr, () -> 0L);
+ if (huntingTimestamp > 0) {
+ if (currentTimestamp - huntingTimestamp < keepHuntingTime) {
+ // 落库
+ huntingList.put(torrentSubnetStr, currentTimestamp);
+ return new BanResult(this, PeerAction.BAN, "Multi-dialing hunting",
+ String.format(Lang.MODULE_MDB_MULTI_DIALING_HUNTING_TRIGGERED,
+ torrentName, peerSubnet, peerIpStr));
+ }
+ else {
+ huntingList.invalidate(torrentSubnetStr);
+ }
+ }
+ } catch (ExecutionException ignored) {}
+ }
+ }
+ catch (Exception e) {
+ log.error("shouldBanPeer exception", e);
+ }
+
+ return new BanResult(this, PeerAction.NO_ACTION, "N/A",
+ String.format(Lang.MODULE_MDB_MULTI_DIALING_NOT_DETECTED, torrentName));
+ }
+
+ // 是否已从数据库恢复追猎名单,持久化用的,目前没用
+ private static volatile boolean cacheRecovered = false;
+ // 所有peer的连接记录 torrentId+ip : createTime
+ private static Cache cache;
+ // 按子网统计的连接记录 torrentId+subnet : peerGroup
+ // 需要统计同一子网下的peer数量,Cache不支持size(),所以需要自己维护
+ private static Cache> subnetCounter;
+ // 追猎名单 torrentId+subnet : createTime
+ private static Cache huntingList;
+
+ private Cache genPeerGroup() {
+ return CacheBuilder.newBuilder().
+ expireAfterAccess(cacheLifespan, TimeUnit.MILLISECONDS).
+ maximumSize(PEER_MAX_NUM_PER_SUBNET).
+ build();
+ }
+
+ /**
+ * 将追猎名单恢复到内存中
+ * 持久化先不搞了
+ */
+ private void recoverHuntingList() {
+ if (cacheRecovered) return;
+ synchronized (MultiDialingBlocker.class) {
+ if (cacheRecovered) return;
+
+ // 根据配置删除超过限制时间的追猎记录
+ // 加载追猎名单
+
+ cacheRecovered = true;
+ }
+ }
+
+ public record HuntingTarget (
+ String hashSubnet,
+ long createTime
+ ){
+ }
+}
+
+
diff --git a/src/main/java/com/ghostchu/peerbanhelper/text/Lang.java b/src/main/java/com/ghostchu/peerbanhelper/text/Lang.java
index ea42645d3f..f4599d0da1 100644
--- a/src/main/java/com/ghostchu/peerbanhelper/text/Lang.java
+++ b/src/main/java/com/ghostchu/peerbanhelper/text/Lang.java
@@ -37,6 +37,9 @@ public class Lang {
public static String MODULE_AP_TCP_TEST_PORT_FAIL = "TCP 探测目标失败: %s";
public static String MODULE_AP_EXECUTE_EXCEPTION = "烘焙缓存时出错,请将下面的错误日志发送给开发者以协助修复此错误";
public static final String MODULE_AP_SSL_CONTEXT_FAILURE = "初始化 SSLContext 时出错";
+ public static final String MODULE_MDB_MULTI_DIALING_NOT_DETECTED = "未发现多拨下载,种子名称:{}";
+ public static final String MODULE_MDB_MULTI_DIALING_DETECTED = "发现多拨下载,请持续关注,种子名称:{},子网:{},触发IP:{}";
+ public static final String MODULE_MDB_MULTI_DIALING_HUNTING_TRIGGERED = "触发多拨追猎名单,种子名称:{},子网:{},触发IP:{}";
public static final String DOWNLOADER_QB_LOGIN_FAILED = "登录到 {} 失败:{} - {}: \n{}";
public static final String DOWNLOADER_QB_FAILED_REQUEST_TORRENT_LIST = "请求 Torrents 列表失败 - %d - %s";
public static final String DOWNLOADER_QB_FAILED_REQUEST_PEERS_LIST_IN_TORRENT = "请求 Torrent 的 Peers 列表失败 - %d - %s";
diff --git a/src/main/resources/profile.yml b/src/main/resources/profile.yml
index 30bfa81939..32de9ce4ec 100644
--- a/src/main/resources/profile.yml
+++ b/src/main/resources/profile.yml
@@ -164,4 +164,23 @@ module:
ipv6: 64 # /64 = ISP 通常分配给家宽用户的前缀长度
# 启用来自 BTN 网络的规则
btn:
- enabled: true
\ No newline at end of file
+ enabled: true
+ multi-dialing-blocker:
+ enabled: false
+ # 子网掩码长度
+ # IP地址前多少位相同的视为同一个子网,位数越少范围越大,一般不需要修改
+ subnet-mask-length: 24
+ # 对于同小区IPv6地址应该取多少位掩码没有调查过,64位是不会误杀的保险值
+ subnet-mask-v6-length: 64
+ # 容许同一网段下载同一种子的IP数量,正整数
+ # 防止DHCP重新分配IP、碰巧有同一小区的用户下载同一种子等导致的误判
+ tolerate-num: 3
+ # 缓存持续时间(秒)
+ # 所有连接过的peer会记入缓存,DHCP服务会定期重新分配IP,缓存时间过长会导致误杀
+ cache-lifespan: 86400
+ # 是否追猎
+ # 如果某IP已判定为多拨,无视缓存时间限制继续搜寻其同伙
+ keep-hunting: true
+ # 追猎持续时间(秒)
+ # keep-hunting为true时有效,和cache-lifspan相似,对被猎杀IP的缓存持续时间
+ keep-hunting-time: 2592000
\ No newline at end of file