Java 实现读取解析 Torrent 文件,替换 Tracker,计算 infohash 和计算大小

Java 实现读取解析 Torrent 文件,替换 Tracker,计算 infohash 和计算大小

最近一直在玩 PT,感叹 NexusPHP 的性能、代码质量和安全都实在是相当感人了,这种上古软件能活到今天也算是一个奇迹了。所以在自己悄悄搓 PT 程序。 对于一个 Private Tracker 程序,重要功能那自然是读取解析 Torrent,并把其中的 Tracker 换成我们自己的。本想用库

最近一直在玩 PT,感叹 NexusPHP 的性能、代码质量和安全都实在是相当感人了,这种上古软件能活到今天也算是一个奇迹了。所以在自己悄悄搓 PT 程序。

对于一个 Private Tracker 程序,重要功能那自然是读取解析 Torrent,并把其中的 Tracker 换成我们自己的。本想用库来实现,但相当遗憾的是我没找到能够满足我需求的 Vanilla Java 的实现。

既然没有现成的,那就自己来搓一个。

由于我个人只需要解析 Bittorrent v1 版本的 torrent 文件就够了,所以 v2 不在讨论范围内,不过都是大同小异。

Bencode 编码

Torrent 的种子是使用一种名为 Bencode 的编码方式,好在 Bencode 还是有类库可以直接使用的:

直接从 Maven 中央仓库引入依赖。

        <dependency>
            <groupId>com.dampcake</groupId>
            <artifactId>bencode</artifactId>
            <version>1.4</version>
        </dependency>

这个库有自己的 Github 仓库页面,感兴趣的可以看看。

调整字符集

dampcake/bencode 库默认使用 UTF-8 作为编码方式,这是一个雷点,在后面中我们会详细介绍哪里需要使用 UTF-8,哪里不能。

如果你想要调整 Bencode 的字符集,可以直接在构造 Bencode 对象的时候传入一个 StandardCharsets 参数,就像这样:

Bencode bencodeUTF8 = new Bencode(StandardCharsets.UTF_8);
Bencode bencodeInfoHash = new Bencode(StandardCharsets.ISO_8859_1);

读取 Torrent 文件

Bencode 需要基于字节数组进行操作,首先把文件读取成数组,然后使用 Bencode 解析。

这里我使用高版本 Java 的工具类,如果你是 J8/J11,请自行使用传统方式代替。

File torrentFile = new File("test.torrent");
byte[] torrentData = Files.readAllBytes(file.toPath());
Map<String, Object> dict = bencodeUTF8.decode(this.data, Type.DICTIONARY);
Map<String, Object> dictInfoHash = bencodeInfoHash.decode(this.data, Type.DICTIONARY);

dict 我们用来读取 Torrent 的文件树,计算大小,等其他任何除了计算 InfoHash 的操作。

dictInfoHash 我们只用来计算 InfoHash。

需要注意的是,如果 Torrent 文件不是正确被 Bencode 编码的文件,上面的操作会抛出一个运行时错误,记得 try-catch。

我们可以输出一下 dict 看看我们的种子文件是不是读取成功了:

{
  "announce": "https://tracker.m-team.cc/announce.php?passkey=<已删除>",
  "created by": "qBittorrent v4.2.5",
  "creation date": 1665734322,
  "info": {
    "files": [
      {
        "length": 7499438017,
        "path": [
          "3DMark-v2-22-7359.zip"
        ]
      },
      {
        "length": 17956,
        "path": [
          "3DMark-PCMark-KeyGen.zip"
        ]
      },
      {
        "length": 3025073086,
        "path": [
          "PCMark10-v2-1-2563.zip"
        ]
      }
    ],
    "name": "3DMark/PCMark",
    "piece length": 16777216,
    "private": 1,
    "source": "[kp.m-team.cc] M-Team - TP"
  }
}

验证 Torrent 文件

在进行解析之前,我们需要保证 Torrent 文件有效。你永远不能保证用户塞进来的 Torrent 是不是缺失 info 段,缺失 files,空 torrent。

    private void validate() {
        if (this.dict == null)
            throw new IllegalStateException("Bencode 解码失败");
        if (!this.dict.containsKey("info"))
            throw new IllegalStateException("Info 属性丢失");
        @SuppressWarnings("unchecked")
        Map<String, Object> info = (Map<String, Object>) this.dict.get("info");
        if (!info.containsKey("piece length") || !(info.get("piece length") instanceof Number))
            throw new IllegalStateException("字段 piece length 类型不合法");
        if (!info.containsKey("name") || !(info.get("name") instanceof String))
            throw new IllegalStateException("字段 name 类型不合法");
        if (!info.containsKey("pieces") || !(info.get("pieces") instanceof String))
            throw new IllegalStateException("字段 pieces 类型不合法");
    }

判断 V2 版 Torrent

如果你的程序(比如这个)不支持解析 V2 的种子,那么可以对 V2 种子进行检查。

private static final List<String> V2_KEYS = List.of("piece layers", "files tree");
    private boolean isV2Torrent() {
        @SuppressWarnings("unchecked")
        Map<String, Object> info = (Map<String, Object>) this.dict.get("info");
        for (String v2Key : V2_KEYS) {
            if (info.containsKey(v2Key))
                return true;
        }
        if (info.containsKey("meta version"))
            return info.get("meta version").equals(2);
        return false;
    }

检查文件树、计算种子大小

在我们确认完种子文件正确之后,就可以开始遍历文件树和计算种子大小了。

private final Map<String, Long> fileList = new LinkedHashMap<>();
private Map<String, Object> dict; // 此 dict 为使用 UTF-8 编码读取的
private long totalSize;

    private void verifyAndCalcFiles() {
        this.fileList.clear();
        @SuppressWarnings("unchecked")
        Map<String, Object> info = (Map<String, Object>) this.dict.get("info");
        if (info.containsKey("length")) {
            // Torrent 种子只有单个文件
            this.fileList.put((String) info.get("name"), (Long) info.get("length"));
            return;
        }
        // Torrent 种子是多文件种子
        if (!info.containsKey("files") || !(info.get("files") instanceof List))
            throw new IllegalStateException("字段 files 类型不合法");

        @SuppressWarnings("unchecked")
        List<Map<String, Object>> files = (List<Map<String, Object>>) info.get("files");
        if (files.isEmpty())
            throw new IllegalStateException("种子内没有任何文件!");
        for (Map<String, Object> file : files) {
            if (file.get("length") == null || !(file.get("length") instanceof Number))
                throw new IllegalStateException("字段 length 的值是非预期值");
            long size = (Long) file.get("length"); // 读取单个文件的文件大小
            if (file.get("path") == null && file.get("path.utf-8") == null)
                throw new IllegalStateException("字段 path 或 path.utf-8 的值是非预期值");
            @SuppressWarnings("unchecked")
            List<String> path = file.get("path") != null ? (List<String>) file.get("path") : (List<String>) file.get("path.utf-8");
            StringJoiner pathBuilder = new StringJoiner(File.separator);
            for (String s : path) {
                pathBuilder.add(s); // 文件路径是一个数组,类似这样 {"usr","local","bin","bash"},我们需要手动拼接一下 "usr/local/bin/bash" 
            }
            this.fileList.put(pathBuilder.toString(), size); // 放入文件树,<file name, file size>
        }
        totalSize = this.fileList.values().stream().mapToLong(v -> v).sum(); // 计算所有文件大小的总和
    }

至此,文件树和文件大小就遍历完毕了。

你可以输出一下 fileList 看看是不是都读取正确了。

看起来都挺正确的

计算 Info Hash

info_hash 的计算其实就是把整个 info 字段进行使用 ISO_8859_1 标准字符集进行 bencode 编码并算出 sha1,即为 info_hash。

还记得我们之前创建的那个 bencodeInfoHash 以及 dictInfoHash 对象吗?他们两个存在的唯一目的就是为了计算 info_hash。

调用 bencode 进行编码:

byte[] infoData = bencodeInfoHash.encode((Map<?, ?>) dictInfoHash.get("info"))

然后我们将这个 byte[] 数组丢进 sha1 里面跑一下,sha1 的算法如下:

    @SneakyThrows
    public static String sha1(byte[] bytes) {
        MessageDigest mDigest = MessageDigest.getInstance("SHA1");
        byte[] result = mDigest.digest(bytes);
        StringBuilder sb = new StringBuilder();
        for (byte b : result) {
            sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        }
        return sb.toString();
    }

然后我们就得到 info_hash 了:

String infoHash = sha1(bencodeInfoHash.encode(infoData));

测试一下看看:

嗯,计算的 Info Hash 没问题。

修改 Torrent 的 Tracker,种子名和禁用 DHT

修改 Tracker

作为一个 PT 程序,必须具备替换用户上传的 Torrent 中的 Tracker 服务器列表的能力。

我们直接对 dict 的中的内容进行修改即可:

dict.remove("announce-list"); // 移除 announce 服务器列表,我们只有一个 Tracker
dict.put("announce", announce); // 覆盖 Tracker 地址

修改种子名

修改种子的名称也很简单,也是直接修改 info 就行了:

Map<String, Object> info = (Map<String, Object>) this.dict.get("info");
info.put("source", "[" + baseUrl + "] " + siteName); // 修改种子名称

禁用 DHT

作为 PT 程序,我们要阻止用户使用 DHT,否则会向 Tracker 报告错误的数据。

好在这并不困难,我们把种子设置为私有种子就可以禁用掉 DHT 技术了。

Map<String, Object> info = (Map<String, Object>) this.dict.get("info");
info.put("private", 1); // 设置为私有种子,这会禁用 DHT, PeX 和 LSD

保存回 Torrent 格式文件

在完成所需要的各种修改之后,我们需要写回种子,供其他人下载。

把 dict 重新用 bencodeUTF8 编码一下就好了:

    public byte[] save() {
        return bencode.encode(this.dict); // 用 UTF8 的 dict
    }

或者直接保存到文件里:

    public void save(@NotNull File file) throws IOException {
        if (!file.getParentFile().exists()) file.getParentFile().mkdirs();
        if (file.exists()) file.delete();
        Files.copy(new ByteArrayInputStream(save()), file.toPath());
    }

OK 搞定,现在种子文件就完成了整个修改流程了!

生成磁力链接 (Magnet)

磁力链接本质上其实就是记录了 info_hash,拼接一下字符串就好了:

magnet:?xt=urn:btih:<info_hash>

当然和普通URL一样,后面用 & 可以连接额外参数。例如 &tr=Tracker服务器&dn=文件名

后记

Torrent 种子的绝大多数操作都是基于 Bencode 的,数据解析后基本都是 Map<String, Object> 的类型,操作还是很方便的,至于编码问题可能只是历史遗留问题了……

最近还在研究 HTTP Tracker 协议:

只看一个请求参数就够头大了

网络上没有比较直观的中文文献,只好自己看着 NexusPHP 的源代码改,不得不说相当痛苦。

参考文献

xiaomlove/nexusphp: A private tracker application base on NexusPHP

BitTorrent 分布式散列表(DHT)协议详解 | 寂静花园 (磁力链接)

Comment