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