用 Golang 写一个 BT 服务 | 猎人杂货铺

用 Golang 写一个 BT 服务

前言

计划了好几年的奶油站重写大计,怎么能轻易地返航。

从一开始要用 Laravel 重写、到考虑用 NodeJS 来写,到后来感受到 Golang 的魔力,便最终决定用 Golang 来实现。

目前 Tracker 的核心逻辑已经梳理得差不多,打算记载实现过程中的关键点,也省得后来者重新爬坑。

BT 服务

BitTorrent Tracker 是帮助 BitTorrent 协议 在节点与节点之间做连接的服务器,相信用过 UT、QB、Transmission 或者是迅雷这些 BT 客户端的同学,对这个词应该不陌生。

当你获取了一个磁力链接或者种子文件,使用 BT 客户端打开文件确认下载后,客户端就成为了一个 peer,客户端通过连接 Tracker 服务器或者 DHT 网络寻找到其他拥有所需要文件分片的 peer,从这些 peer 中下载资源分片,同时客户端也上传数据给其他来索要自己所拥有分片的 peer,以此反复,直到下载完成。

—— 摘自 BT 增强建议之概述

伺服功能是一个 BT 服务器的核心,如果要开发 PT 站点,还需要具备种子文件解析和改造的能力。所以本文主要围绕这两点来展开介绍。

种子

编码方式

如果你尝试用记事本软件打开一个 .torrent 文件,你会得到类似下面的内容。

1
2
3
4
d8:announce0:10:created by18:qBittorrent v4.2.513:creation datei1595461276e4:infod6:lengthi82727410e4:name68:A.Story.in.Summertime.1988.Webrip.1080p.x265.10bit.AAC.MNHD-FRDS.mkv12:piece lengthi131072e6:pieces12640:灻?0b4稟IU??df泻骍熺餪a?H_(Mf穃?~u"稢X`70鏬e?岞?喥?
9]o????c?炚挰邓?樄棫败?J€﨧齠鮹瘬SW;遅{?#Oj^凁⌒#Y?韥莖.棂绻k脭?\唅鎖QO8З?辜伫捨?菢譫?肕?V阻?胮電k)?<?K游WU頕t鄒M2┬楽??S棏欝}伵5埏t&隩罓q㊣硣憾?K槫H?曘4MJP ~9W?厬dV4牛???ㄡ猋?^#>qL?iVI訶G:/鲏櫫X% ^飓??t但餋鹻卂`岔.B圼L?良笇U草eX?V,ㄡ
瞨敖DVi?>?銚璓?)擁捃N腗竵OD殣秣緵?Kc茙 a?/??!d?
?唆)N

乍一看是乱码,但仔细辨认后,会发现不少认识的单词,比如 announce 之类。其实,种子文件的存档使用了一种叫做 Bencode 的编码方式。

这种编码方式使用 ASCII 字符进行编码,支持字符串、整数、串列、字典表这几种格式:

  • 一个以字节为单位表示的字符串(字符串的字为一个字节,不一定是一个字符)会以(长度):(内容)编码,比如 8:announce
  • 一个整型数会以十进制数编码并括在 ie 之间,不允许前导零,比如 i131072e
  • 线性表会以 le 括住来编码,括住的内容为 Bencode 四种编码格式所组成的编码字串
  • 字典表会以 de 括住来编码,字典元素的键和值必须紧跟在一起,而且所有键为字符串类型并按字典顺序排好

肉眼实现解码工作实在是太累了,推荐一个叫 Bencode Editor 的工具,可以像编辑 JSON 格式文本那样方便地编辑种子文件。

种子结构

通常来说,一个种子至少包含以下内容。其中,announceinfo 通常是必备的。announce 指明 BT 服务器的地址,info 则包含了种子对应的资源文件信息,具体信息可以看 bep-3

种子结构

客户端加载种子之后,会对 info 字段的内容计算 SHA1 值,作为种子的 INFOHASH 值,并在和 BT 服务器通信时携带上该信息。BT 服务器也是识别到了该值,才可以找到对应的上传/下载同伴,返回给客户端。

值得一提的是,大多数 PT 站在保存用户上传的种子后,会修改 info 字段下的 source 字段内容。这样可以有效避免用户使用多 Tracker 手段虚报上传量。

用 Golang 编码解码

虽然编码规则很清晰,但手撸一个编码解码器,还是相当头秃的。

寻寻觅觅,不断试错之后,我发现一个令人满意到飞起的开源库 anacrolix/torrent,附上我使用 Golang 解码和改造种子文件的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import "github.com/anacrolix/torrent/bencode"

type BencodeTorrent struct {
Announce string `bencode:"announce"`
CreatedBy string `bencode:"created by,omitempty"`
CreatedAt int `bencode:"creation date,omitempty"`
Info struct {
Files []struct {
Length uint64 `bencode:"length"`
Path []string `bencode:"path"`
} `bencode:"files"`
Name string `bencode:"name"`
Pieces string `bencode:"pieces"`
PieceLength uint64 `bencode:"piece length"`
Private int `bencode:"private"`
Source string `bencode:"source"`
} `bencode:"info"`
}

// 改装种子文件,去除原 Tracker 信息,修改 Source 信息
func repackTorrent(fh *multipart.FileHeader) (*BencodeTorrent, string, error) {
// open file
fileReader, err := fh.Open()
if err != nil {
return nil, "", err
}

// Decode
// See https://godoc.org/github.com/anacrolix/torrent/bencode
decoder := bencode.NewDecoder(fileReader)
bencodeTorrent := &BencodeTorrent{}
decodeErr := decoder.Decode(bencodeTorrent)
if decodeErr != nil {
return nil, "", decodeErr
}

// Re-pack torrent
// TODO: 根据配置修改
bencodeTorrent.Announce = ""
bencodeTorrent.Info.Source = "[Alpha] SpiderX"
bencodeTorrent.Info.Private = 1

// marshal info part and calculate SHA1
marshaledInfo, marshalErr := bencode.Marshal(bencodeTorrent.Info)
if marshalErr != nil {
return nil, "", nil
}

return bencodeTorrent, fmt.Sprintf("%x", sha1.Sum(marshaledInfo)), nil
}

伺服

伺服主要提供两类功能,分别是 Scrape 和 Announce,客户端请求这两类服务时都是通过 GET 请求方式,并附带种子的 INFOHASH 信息。

Scrape

客户端在加载种子之后,会向伺服发出 Scrape 请求,希望获取种子的做种数、下载数、完成数三项信息,详见 bep-48。以 Gin 框架为例,本文给出部分代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import "github.com/anacrolix/torrent/bencode"

type ScrapeRequest struct {
InfoHashSlice []string `form:"info_hash" binding:"required"` // 唯一哈希码
}

type ScrapeItem struct {
SeederCount uint `bencode:"complete"` // 当前做种数量
SnatcherCount uint `bencode:"downloaded"` // 已完成下载总数
LeecherCount uint `bencode:"incomplete"` // 正在下载数量
}

type ScrapeResult struct {
Files map[string]*ScrapeItem `bencode:"files"`
}

type ScrapeResponse struct {
}

func (s *ScrapeResponse) Bencode(modelSlice interface{}) string {
torrentSlice, ok := modelSlice.(model.TorrentSlice)
if !ok {
return string(bencode.MustMarshal(map[string]string{
"failure reason": "Torrent not registered with this tracker.",
}))
}

scrapeResult := &ScrapeResult{
Files: make(map[string]*ScrapeItem),
}
for _, torrent := range torrentSlice {
scrapeResult.Files[trackerUtil.RestoreToByteString(torrent.InfoHash)] = &ScrapeItem{
SeederCount: torrent.SeederCount,
SnatcherCount: torrent.SnatcherCount,
LeecherCount: torrent.LeecherCount,
}
}

return string(bencode.MustMarshal(scrapeResult))
}

func Scrape(ctx *gin.Context) {
// 使用 ScrapeRequest 校验请求
req := &ScrapeRequest{}

// 此处应该处理错误
ctx.ShouldBind(req)

// 查找符合条件的种子
torrentSlice, _ := trackerService.GenScrapeResult(ctx, req)
resp := &ScrapeResponse{}
ctx.String(http.StatusOK, resp.Bencode(torrentSlice))
}

Announce

Announce 的请求结构和响应结构要复杂一些,从 bep-3 文档可以看到,客户端向 Tracker 请求时会带上 info_hashpeer_idipportuploadeddownloadedleftevent 等参数。

对于非正常响应,只需要在响应结构体中包含 failure reason 字段,附上具体的错误信息即可。

正常响应至少要包含 intervalpeers 两个字段。其中,interval 用于告诉客户端间隔多久再向服务端发一次请求(当然客户端有可能完全不理会),peers 字段包含同伴的 peer_idipport 等信息。

令人比较头大的是 peers 字段内容的格式,它可以是普通数组,也可以是经过 Bencode 编码的字符串(ipport 经过压缩),具体压缩方式参考 bep-23

然而,压缩仅适用于 IPv4。虽然压缩有利于减少响应体积,减少网络流量,但在 IPv6 逐渐普及的当代,似乎会造成用户体验大幅下降。

为了解决这个问题,bep-07 约定了针对 IPv6 的压缩方式。为了兼容性,新增了额外的 peers6 字段,用于存放 IPv6 同伴的连接信息。

更完整的代码可以关注我的开源项目 endpot/SpiderX-backend,这里仅给出请求结构和响应结构(非压缩)示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type AnnounceRequest struct {
Passkey string `form:"passkey" binding:"required,len=32"` // 用户密钥
OriInfoHash string `form:"info_hash" binding:"required,len=20"` // 客户端上报的种子哈希码
OriPeerID string `form:"peer_id" binding:"required,len=20"` // 客户端上报的同伴ID
InfoHash string `` // 转换过后的哈希码
PeerID string `` // 转换过后的同伴ID
Port uint `form:"port" binding:"min=1,max=65535"`
DownloadedBytes uint `form:"downloaded"`
UploadedBytes uint `form:"uploaded"`
LeftBytes uint `form:"left"`
Event string `form:"event"`
IP string `form:"ip" binding:"omitempty,ip"`
IPv6 string `form:"ipv6" binding:"omitempty,ip"`
Compact uint8 `form:"compact" binding:"omitempty,min=0,max=1"`
NoPeerID uint8 `form:"no_peer_id" binding:"omitempty,min=0,max=1"`
NumWanted uint8 `form:"numwant" binding:"omitempty"`
}

type PeerItem struct {
IP string `bencode:"ip"`
Port uint `bencode:"port"`
PeerID string `bencode:"peer id,omitempty"`
}

type AnnounceResult struct {
Interval uint `bencode:"interval"` // 请求间隔(单位:秒)
MinInterval uint `bencode:"min interval"` // 已完成下载总数
SeederCount uint `bencode:"complete"` // 当前做种数量
LeecherCount uint `bencode:"incomplete"` // 正在下载数量
Peers []*PeerItem `bencode:"peers"` // 同伴
}
『猎人杂货铺 • 微信公众号』