Skip to content

Commit

Permalink
Merge pull request #80 from CharlesChou73/multi-dialing
Browse files Browse the repository at this point in the history
添加多拨检测模块
  • Loading branch information
Ghost-chu authored May 9, 2024
2 parents 1548b1d + 8934604 commit 07f2bea
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 1 deletion.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,41 @@ PeerBanHelper 主要由以下几个功能模块组成:
</details>
### 多拨侦测
专业PCDN用户会在一台PCDN服务器上接入多条宽带,以此提升上传带宽,称为多拨。
这类用户的刷下载工具一般也较为复杂,会利用多条宽带不同的出口IP分散流量,对抗基于下载进度的吸血检测。
此模块对多拨下载现象进行侦测,发现同一网段集中下载同一种子,即予以全部封禁。
目前已知可能误伤的情况:小ISP的骨干网出口在同一网段,造成多拨假象。如果种子涉及的BT网络主体在大陆以外,请谨慎使用。
<details>
<summary>查看示例配置文件</summary>
```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
```
</details>
## 添加下载器
PeerBanHelper 能够连接多个支持的下载器,并共享 IP 黑名单。但每个下载器只能被一个 PeerBanHelper 添加,多个 PBH 会导致操作 IP 黑名单时出现冲突。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> peerId = conf.getStringList("module.peer-id-blacklist.exclude-peer-id");
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Long> 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<String, Long> cache;
// 按子网统计的连接记录 torrentId+subnet : peerGroup
// 需要统计同一子网下的peer数量,Cache不支持size(),所以需要自己维护
private static Cache<String, Cache<String, Long>> subnetCounter;
// 追猎名单 torrentId+subnet : createTime
private static Cache<String, Long> huntingList;

private Cache<String, Long> 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
){
}
}


3 changes: 3 additions & 0 deletions src/main/java/com/ghostchu/peerbanhelper/text/Lang.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
21 changes: 20 additions & 1 deletion src/main/resources/profile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,23 @@ module:
ipv6: 64 # /64 = ISP 通常分配给家宽用户的前缀长度
# 启用来自 BTN 网络的规则
btn:
enabled: true
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

0 comments on commit 07f2bea

Please sign in to comment.