├── .gitignore ├── Preview.png ├── sync.go ├── blockList-Optional.json ├── getproxy_other.go ├── .github └── workflows │ ├── ci-test.yaml │ ├── docker.yaml │ └── release.yaml ├── blockList.json ├── util_test.go ├── ipBlockList.txt ├── doc └── SyncServer.md ├── main.go ├── LICENSE ├── Dockerfile ├── getproxy_common.go ├── entrypoint.sh ├── go.mod ├── server.go ├── main_windows.go ├── log.go ├── config.toml ├── config.json ├── client.go ├── ip.go ├── SyncServer.go ├── util.go ├── request.go ├── client_BitComet.go ├── client_Transmission.go ├── lang └── en.json ├── torrent.go ├── i18n.go ├── client_qBittorrent.go ├── peer.go ├── README.md ├── go.sum ├── console.go ├── README.en.md └── config.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | logs/ 3 | qBittorrent-ClientBlocker 4 | qBittorrent-ClientBlocker.exe 5 | -------------------------------------------------------------------------------- /Preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Simple-Tracker/qBittorrent-ClientBlocker/HEAD/Preview.png -------------------------------------------------------------------------------- /sync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | var Mutex = sync.Mutex{} 8 | var WaitGroup = sync.WaitGroup{} 9 | -------------------------------------------------------------------------------- /blockList-Optional.json: -------------------------------------------------------------------------------- 1 | [ 2 | "^-UW\\w{4}-", // uTorrent Web. 3 | "^-SP(([0-2]\\d{3})|(3[0-5]\\d{2}))-", 4 | "StellarPlayer", // 恒星播放器. 5 | "dandanplay" // 弹弹 Play. 6 | ] 7 | -------------------------------------------------------------------------------- /getproxy_other.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin && !windows && !linux 2 | 3 | package main 4 | 5 | import ( 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | var getproxy_notified = false 11 | 12 | func GetProxy(r *http.Request) (*url.URL, error) { 13 | if r == nil { 14 | if !getproxy_notified { 15 | getproxy_notified = true 16 | Log("GetProxy", GetLangText("GetProxy_UseEnvVar"), true) 17 | } 18 | return nil, nil 19 | } 20 | 21 | return http.ProxyFromEnvironment(r) 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/ci-test.yaml: -------------------------------------------------------------------------------- 1 | name: 'Dev-CI-Test' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ['dev'] 7 | pull_request: 8 | branches: ['dev'] 9 | 10 | jobs: 11 | Dev-CI-Test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 'Checkout' 15 | uses: actions/checkout@v4 16 | - name: 'Setup Go' 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.20.13' 20 | - name: 'Test' 21 | run: 'go test -v ./... -race' 22 | -------------------------------------------------------------------------------- /blockList.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Gopeed dev", 3 | "Rain 0.0.0", 4 | "Taipei-Torrent dev", 5 | "Xunlei", 6 | "^-(SD|XF|QD|BN|DL)", 7 | "^-DT", 8 | "^-GT0002", 9 | "^-HP", 10 | "^-RN", 11 | "^-XL", 12 | "^-XM", 13 | "anacrolix[ /]torrent v?([0-1]\\\\.(([0-9]|[0-4][0-9]|[0-5][0-2])\\\\.[0-9]+|(53\\\\.[0-2]( |$)))|unknown)", 14 | "cacao_torrent", 15 | "dt[ /]torrent", 16 | "go[ \\\\.]torrent", 17 | "gobind", 18 | "hp[ /]torrent", 19 | "ljyun.cn", 20 | "offline-download", 21 | "qBittorrent[ /]3\\\\.3\\\\.15", 22 | "trafficConsume", 23 | "xm[ /]torrent", 24 | "ޭ__" 25 | ] 26 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | ) 7 | 8 | func TestEraseSyncMap(t *testing.T) { 9 | testCases := []struct { 10 | name string 11 | data map[any]any 12 | }{ 13 | { 14 | name: "TestEraseSyncMap", 15 | data: map[any]any{"key": "val"}, 16 | }, 17 | } 18 | 19 | for _, tc := range testCases { 20 | t.Run(tc.name, func(t *testing.T) { 21 | m := &sync.Map{} 22 | for k, v := range tc.data { 23 | m.Store(k, v) 24 | } 25 | 26 | EraseSyncMap(m) 27 | m.Range(func(k, v any) bool { 28 | t.Errorf("EraseSyncMap() = %v, want %v", v, nil) 29 | return false 30 | }) 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ipBlockList.txt: -------------------------------------------------------------------------------- 1 | // Credit: https://docs.qq.com/doc/DQnJBTGJjSFZBR2JW. 2 | 112.87.15.0/24 3 | 58.241.88.0/24 4 | 1.180.24.0/23 5 | 36.250.141.0/24 6 | 36.102.218.0/24 7 | 101.69.63.0/24 8 | 112.45.16.0/24 9 | 112.45.20.0/24 10 | 115.231.84.120/29 11 | 115.231.84.128/28 12 | 122.224.33.0/24 13 | 123.184.152.0/24 14 | 218.7.138.0/24 15 | 221.11.96.0/24 16 | 221.203.3.0/24 17 | 221.203.6.0/24 18 | 223.78.79.0/24 19 | 223.78.80.0/24 20 | 2002:df4e:4f00::/48 21 | 2002:df4e:5000::/48 22 | 2408:862e:ff:ff0d::/60 23 | 2408:8631:2e09:d05::/60 24 | 2408:8738:6000:d::/60 25 | 2409:873c:f03:6000::/56 26 | 240e:90c:2000:301::/60 27 | 240e:90e:2000:2006::/60 28 | 240e:918:8008::/48 29 | -------------------------------------------------------------------------------- /doc/SyncServer.md: -------------------------------------------------------------------------------- 1 | # SyncServer 2 | SyncServer 同步服务器是一个设想, 在设想中: 屏蔽器客户端发送所有的 TorrentMap (其中包括 Peer), 服务器即掌握所有连接至该服务器的屏蔽器客户端的 Torrent 及 Peer 的相关信息. 服务器通过所掌握的信息作出决策, 这些决策随本次或下次请求返回给客户端. 3 | 4 | ## 请求结构 5 | 一个示例应该如下: 6 | ``` 7 | POST /api/syncserver 8 | Content-Type: application/json 9 | 10 | { 11 | "version": 1, 12 | "timestamp": 1714306700, 13 | "token": "", 14 | "torrentMap": { 15 | "(InfoHash)": { 16 | "size": 1048576, 17 | "peers": { 18 | "(PeerIP)": { 19 | "net": "(Unknown)", 20 | "port": { 21 | (Port): true 22 | }, 23 | "progress": 0.233, 24 | "downloaded": 1048576, 25 | "uploaded": 1024 26 | } 27 | } 28 | } 29 | } 30 | } 31 | ``` 32 | 33 | ## 响应结构 34 | 客户端会根据服务器的响应重新调整自身 ```Interval```, 若要避免尖峰, 可在每次返回时使用不同的 Offset 调整 ```Interval```. 若没有发生错误, 则 ```Status``` 应该置空. 35 | 一个示例应该如下: 36 | ``` 37 | { 38 | "interval": 300, 39 | "status": "", 40 | "blockIPRule": { 41 | "(Reason)": [ 42 | "123.132.213.231/32" 43 | ] 44 | } 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package main 4 | 5 | func Platform_Stop() { 6 | } 7 | func main() { 8 | /* 9 | torrentMap2 := make(map[string]TorrentInfoStruct) 10 | peers := make(map[string]PeerInfoStruct) 11 | peerPortMap := make(map[int]bool) 12 | peerPortMap[233] = true 13 | peers["testpeer"] = PeerInfoStruct { Port: peerPortMap, Progress: 0.1, Uploaded: 123 } 14 | torrentMap2["testhash"] = TorrentInfoStruct { Size: 233, Peers: peers } 15 | lastTorrentMap2 := make(map[string]TorrentInfoStruct) 16 | DeepCopyTorrentMap(torrentMap2, lastTorrentMap2) 17 | 18 | for torrentInfoHash, torrentInfo := range lastTorrentMap2 { 19 | Log("Test", "%s %d", false, torrentInfoHash, torrentInfo.Size) 20 | for peerIP, peerInfo := range torrentInfo.Peers { 21 | Log("Test", "%s %v", false, peerIP, peerInfo.Progress) 22 | for port, _ := range peerInfo.Port { 23 | Log("Test", "%d", false, port) 24 | } 25 | } 26 | } 27 | return 28 | */ 29 | 30 | if PrepareEnv() { 31 | RunConsole() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Simple-Tracker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=${BUILDPLATFORM} golang:1.20.13-alpine AS go 2 | WORKDIR /app 3 | 4 | ARG BUILDOS BUILDARCH TARGETOS TARGETARCH GITHUB_REF PROGRAM_NIGHTLY 5 | ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=7 6 | 7 | RUN PROGRAM_VERSION="$(basename ${GITHUB_REF})"; \ 8 | if [ "${GOARCH}" == 'arm' ]; then \ 9 | PROGRAM_VERSION="${PROGRAM_VERSION} (${TARGETOS}, ${TARGETARCH}v7)"; \ 10 | else \ 11 | PROGRAM_VERSION="${PROGRAM_VERSION} (${TARGETOS}, ${TARGETARCH})"; \ 12 | fi; \ 13 | if [ "${PROGRAM_NIGHTLY}" == 'true' ]; then \ 14 | PROGRAM_VERSION="${PROGRAM_VERSION} (Nightly)"; \ 15 | fi; \ 16 | echo "export PROGRAM_VERSION='${PROGRAM_VERSION}'" > /envfile 17 | 18 | RUN . /envfile; echo "Running on ${BUILDOS}/${BUILDARCH}, Building for ${TARGETOS}/${TARGETARCH}, Version: ${PROGRAM_VERSION}" 19 | 20 | ADD lang/ *LICENSE* *.md *.go *.sh *.txt *.json go.mod go.sum ./ 21 | 22 | RUN go mod download 23 | RUN . /envfile; go build -ldflags "-w -X \"main.programVersion=${PROGRAM_VERSION}\"" -o qBittorrent-ClientBlocker 24 | RUN rm -f *.go go.mod go.sum 25 | 26 | FROM alpine 27 | WORKDIR /app 28 | 29 | COPY --from=go /app ./ 30 | RUN chmod +x ./entrypoint.sh 31 | RUN apk update && apk add --no-cache jq socat 32 | 33 | ENTRYPOINT ["/app/entrypoint.sh"] 34 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: 'Release-Docker-Image' 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | release: 7 | types: ['created'] 8 | 9 | jobs: 10 | Release-Docker-Image: 11 | runs-on: 'ubuntu-latest' 12 | steps: 13 | - name: 'Checkout' 14 | uses: 'actions/checkout@v4' 15 | - name: 'Setup Docker Buildx' 16 | uses: docker/setup-buildx-action@v3 17 | - name: 'Login to Docker Hub' 18 | uses: 'docker/login-action@v3' 19 | if: ${{ github.event_name == 'release' }} 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | - name: 'Extract metadata (tags, labels) for Docker' 24 | id: 'meta' 25 | uses: 'docker/metadata-action@v5' 26 | with: 27 | images: 'simpletracker/qbittorrent-clientblocker' 28 | - name: ${{ github.event_name == 'release' && 'Build and Push Docker image' || 'Build Docker image' }} 29 | uses: 'docker/build-push-action@v5' 30 | with: 31 | context: '.' 32 | file: './Dockerfile' 33 | push: ${{ github.event_name == 'release' && true || false }} 34 | platforms: 'linux/386,linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le' 35 | tags: ${{ steps.meta.outputs.tags }} 36 | labels: ${{ steps.meta.outputs.labels }} 37 | build-args: | 38 | "GITHUB_REF=${{ github.ref }}" 39 | "PROGRAM_NIGHTLY=${{ github.event_name != 'release' && 'true' || 'false' }}" 40 | -------------------------------------------------------------------------------- /getproxy_common.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || windows || linux 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/bdwyertech/go-get-proxied/proxy" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | var getproxy_httpProxyURL *url.URL 12 | var getproxy_httpsProxyURL *url.URL 13 | 14 | func GetProxy(r *http.Request) (*url.URL, error) { 15 | if r == nil && getproxy_httpProxyURL == nil && getproxy_httpsProxyURL == nil { 16 | proxyProvider := proxy.NewProvider("") 17 | 18 | httpProxy := proxyProvider.GetHTTPProxy("") 19 | if httpProxy != nil { 20 | getproxy_httpProxyURL = httpProxy.URL() 21 | if getproxy_httpProxyURL.Scheme == "" { 22 | getproxy_httpProxyURL.Scheme = "http" 23 | } 24 | 25 | Log("GetProxy", GetLangText("GetProxy_ProxyFound"), true, "HTTP", getproxy_httpProxyURL.String(), httpProxy.Src()) 26 | } 27 | 28 | httpsProxy := proxyProvider.GetHTTPSProxy("") 29 | if httpsProxy != nil { 30 | getproxy_httpsProxyURL = httpsProxy.URL() 31 | if getproxy_httpsProxyURL.Scheme == "" { 32 | getproxy_httpsProxyURL.Scheme = "http" 33 | } 34 | 35 | Log("GetProxy", GetLangText("GetProxy_ProxyFound"), true, "HTTPS", getproxy_httpsProxyURL.String(), httpsProxy.Src()) 36 | } 37 | 38 | if getproxy_httpProxyURL == nil || getproxy_httpsProxyURL == nil { 39 | Log("GetProxy", GetLangText("GetProxy_ProxyNotFound"), true, "HTTP/HTTPS") 40 | } 41 | } else if r != nil { 42 | if r.URL.Scheme == "https" { 43 | return getproxy_httpsProxyURL, nil 44 | } else if r.URL.Scheme == "http" { 45 | return getproxy_httpProxyURL, nil 46 | } 47 | } 48 | 49 | return nil, nil 50 | } 51 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -n "$useENV" ]; then 4 | echo "Generate config from env." 5 | 6 | # Convert $blockList to json array 7 | tmpBlockList='[]' 8 | tmpIPBlockList='[]' 9 | if [ -n "$blockList" ]; then 10 | tmpBlockList=$(echo $blockList | jq '.') 11 | fi 12 | if [ -n "$ipBlockList" ]; then 13 | tmpIPBlockList=$(echo $ipBlockList | jq '.') 14 | fi 15 | 16 | envKVPair=$(jq -n 'env|to_entries[]') 17 | 18 | # Keep username and password string 19 | # Keep blockList json array 20 | # Convert "true" to true, "false" to false, digital string to number 21 | configKVPair=$(echo $envKVPair | jq --argjson tmpBlockList "$tmpBlockList" --argjson tmpIPBlockList "$tmpIPBlockList" '{ 22 | (.key): ( 23 | if (.key|ascii_downcase) == "clientusername" or (.key|ascii_downcase) == "clientpassword" then .value 24 | elif (.key|ascii_downcase) == "blocklist" then $tmpBlockList 25 | elif (.key|ascii_downcase) == "ipblocklist" then $tmpIPBlockList 26 | else .value|( 27 | if . == "true" then true 28 | elif . == "false" then false 29 | else (tonumber? // .) 30 | end) 31 | end 32 | ) 33 | }') 34 | 35 | (echo $configKVPair | jq -s add) > config_additional.json 36 | fi 37 | 38 | commandArgStr='' 39 | if [ -n "$configPath" ]; then 40 | commandArgStr="-c $configPath" 41 | fi 42 | if [ -n "$additionalConfigPath" ]; then 43 | commandArgStr="$commandArgStr -ca $additionalConfigPath" 44 | fi 45 | exec ./qBittorrent-ClientBlocker $commandArgStr 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Simple-Tracker/qBittorrent-ClientBlocker 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.9.1 7 | github.com/Xuanwo/go-locale v1.1.0 8 | github.com/bdwyertech/go-get-proxied v0.0.0-20221029171534-ea033ac5f9fa 9 | github.com/dlclark/regexp2 v1.11.0 10 | github.com/getlantern/systray v1.2.2 11 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e 12 | github.com/pelletier/go-toml/v2 v2.2.2 13 | github.com/tidwall/jsonc v0.3.2 14 | golang.design/x/hotkey v0.4.1 15 | ) 16 | 17 | require ( 18 | github.com/andybalholm/cascadia v1.3.2 // indirect 19 | github.com/bdwyertech/go-scutil v0.0.0-20210306002117-b25267f54e45 // indirect 20 | github.com/darren/gpac v0.0.0-20210609082804-b56d6523a3af // indirect 21 | github.com/dop251/goja v0.0.0-20210427212725-462d53687b0d // indirect 22 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect 23 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect 24 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect 25 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect 26 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect 27 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect 28 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect 29 | github.com/go-stack/stack v1.8.0 // indirect 30 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect 31 | golang.org/x/net v0.21.0 // indirect 32 | golang.org/x/sys v0.17.0 // indirect 33 | golang.org/x/text v0.16.0 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | var Server_Status bool = false 11 | var Server_httpListen net.Listener 12 | 13 | type httpServerHandler struct { 14 | } 15 | 16 | func (h *httpServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 17 | if r.Method != "GET" { 18 | w.WriteHeader(405) 19 | w.Write([]byte("405: Method Not Allowed.")) 20 | return 21 | } 22 | 23 | if currentClientType == "Transmission" && Tr_ProcessHTTP(w, r) { 24 | return 25 | } 26 | 27 | w.WriteHeader(404) 28 | w.Write([]byte("404: Not Found.")) 29 | } 30 | func StartServer() { 31 | if Server_Status { 32 | return 33 | } 34 | 35 | listenType := "tcp4" 36 | if IsIPv6(config.Listen) { 37 | listenType = "tcp6" 38 | } 39 | 40 | httpListen, err := net.Listen(listenType, strings.SplitN(config.Listen, "/", 2)[0]) 41 | if err != nil { 42 | Log("StartServer", GetLangText("Error-StartServer_Listen"), true, err.Error()) 43 | return 44 | } 45 | 46 | Server_Status = true 47 | Server_httpListen = httpListen 48 | 49 | httpServer.SetKeepAlivesEnabled(false) 50 | if err := httpServer.Serve(Server_httpListen); err != http.ErrServerClosed { 51 | Log("StartServer", GetLangText("Error-StartServer_Serve"), true, err.Error()) 52 | Server_Status = false 53 | } 54 | } 55 | func StopServer() { 56 | if !Server_Status { 57 | return 58 | } 59 | 60 | if err := httpServer.Shutdown(context.Background()); err != nil { 61 | Log("StopServer", GetLangText("Error-StopServer"), true, err.Error()) 62 | } 63 | 64 | // 出于保险起见, 再次关闭似乎已被 httpServer 同时关闭的 Listener, 但无视错误. 65 | Server_httpListen.Close() 66 | } 67 | -------------------------------------------------------------------------------- /main_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/getlantern/systray" 7 | "github.com/lxn/win" 8 | "golang.design/x/hotkey" 9 | ) 10 | 11 | var showWindow = true 12 | var programHotkey = hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModAlt}, hotkey.KeyB) 13 | 14 | func Platform_ShowOrHiddenWindow() { 15 | consoleWindow := win.GetConsoleWindow() 16 | if showWindow { 17 | Log("Debug-ShowOrHiddenWindow", GetLangText("Debug-ShowOrHiddenWindow_HideWindow"), false) 18 | showWindow = false 19 | win.ShowWindow(consoleWindow, win.SW_HIDE) 20 | } else { 21 | Log("Debug-ShowOrHiddenWindow", GetLangText("Debug-ShowOrHiddenWindow_ShowWindow"), false) 22 | showWindow = true 23 | win.ShowWindow(consoleWindow, win.SW_SHOW) 24 | } 25 | } 26 | func Platform_Stop() { 27 | programHotkey.Unregister() 28 | systray.Quit() 29 | } 30 | func RegHotKey() { 31 | if !needRegHotKey { 32 | return 33 | } 34 | 35 | err := programHotkey.Register() 36 | if err != nil { 37 | Log("RegHotKey", GetLangText("Error-RegHotkey"), false, err.Error()) 38 | return 39 | } 40 | Log("RegHotKey", GetLangText("Success-RegHotkey"), false) 41 | 42 | for range programHotkey.Keydown() { 43 | Platform_ShowOrHiddenWindow() 44 | } 45 | } 46 | func RegSysTray() { 47 | if needHideSystray { 48 | return 49 | } 50 | 51 | systray.Run(func() { 52 | systray.SetIcon(icon_Windows) 53 | systray.SetTitle(programName) 54 | mShow := systray.AddMenuItem("显示/隐藏", "显示/隐藏程序") 55 | mQuit := systray.AddMenuItem("退出", "退出程序") 56 | 57 | go func() { 58 | for { 59 | select { 60 | case <-mShow.ClickedCh: 61 | Platform_ShowOrHiddenWindow() 62 | case <-mQuit.ClickedCh: 63 | systray.Quit() 64 | } 65 | } 66 | }() 67 | }, func() { 68 | ReqStop() 69 | }) 70 | } 71 | func main() { 72 | if PrepareEnv() { 73 | if needHideWindow && showWindow { 74 | Platform_ShowOrHiddenWindow() 75 | } 76 | go RegHotKey() 77 | go RegSysTray() 78 | RunConsole() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | var todayStr = "" 11 | var lastLogPath = "" 12 | var logFile *os.File 13 | var logwriter = LogWriter{} 14 | 15 | type LogWriter struct { 16 | w io.Writer 17 | } 18 | 19 | func (w LogWriter) Write(p []byte) (n int, err error) { 20 | Log("LogWriter", string(p), true) 21 | return len(p), nil 22 | } 23 | func Log(module string, str string, logToFile bool, args ...interface{}) { 24 | if !strings.HasPrefix(module, "Debug") { 25 | if module == "LogWriter" { 26 | str = StrTrim(str) 27 | if strings.HasPrefix(str, "[proxy.Provider") { 28 | return 29 | } 30 | } 31 | } else if config.Debug { 32 | if config.LogDebug { 33 | logToFile = true 34 | } 35 | } else { 36 | return 37 | } 38 | 39 | logStr := fmt.Sprintf("["+GetDateTime(true)+"]["+module+"] "+str+".\n", args...) 40 | if config.LogToFile && logToFile && logFile != nil { 41 | if _, err := logFile.Write([]byte(logStr)); err != nil { 42 | Log("Log", GetLangText("Error-Log_Write"), false, err.Error()) 43 | } 44 | } 45 | 46 | fmt.Print(logStr) 47 | } 48 | func LoadLog() bool { 49 | if !config.LogToFile || config.LogPath == "" { 50 | return false 51 | } 52 | 53 | if err := os.Mkdir(config.LogPath, os.ModePerm); err != nil && !os.IsExist(err) { 54 | Log("LoadLog", GetLangText("Error-LoadLog_Mkdir"), false, err.Error()) 55 | return false 56 | } 57 | 58 | tmpTodayStr := GetDateTime(false) 59 | newDay := (todayStr != tmpTodayStr) 60 | newLogPath := (lastLogPath != config.LogPath) 61 | 62 | if !newDay && !newLogPath { 63 | return true 64 | } 65 | 66 | if newDay { 67 | todayStr = tmpTodayStr 68 | } 69 | 70 | if newLogPath { 71 | if lastLogPath != "" { 72 | Log("LoadLog", GetLangText("LoadLog_HotReload"), false, config.LogPath) 73 | } 74 | lastLogPath = config.LogPath 75 | } 76 | 77 | tLogFile, err := os.OpenFile(config.LogPath+"/"+todayStr+".txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 78 | if err != nil { 79 | tLogFile.Close() 80 | tLogFile = nil 81 | Log("LoadLog", GetLangText("Error-LoadLog_Close"), false, err.Error()) 82 | return false 83 | } 84 | 85 | logFile.Close() 86 | logFile = tLogFile 87 | 88 | return true 89 | } 90 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | checkUpdate = true 2 | debug = false 3 | interval = 6 4 | cleanInterval = 3_600 5 | banTime = 86_400 6 | timeout = 6 7 | logToFile = true 8 | clientType = "" 9 | clientURL = "" 10 | clientUsername = "" 11 | clientPassword = "" 12 | useBasicAuth = false 13 | useShadowBan = true 14 | skipCertVerification = false 15 | blockList = [] 16 | blockListFile = [ 17 | "blockList.json", 18 | # "blockList-Optional.json", 19 | ] 20 | blockListURL = [ 21 | "https://bta.iaalai.cn/qBittorrent-ClientBlocker/blockList.json", 22 | # "https://cdn.jsdelivr.net/gh/Simple-Tracker/qBittorrent-ClientBlocker@dev/blockList.json", 23 | ] 24 | portBlockList = [] 25 | ipBlockList = [] 26 | ipBlockListFile = ["ipBlockList.txt"] 27 | ipBlockListURL = [ 28 | "https://bta.iaalai.cn/BTN-Collected-Rules/combine/all.txt", 29 | "https://cdn.jsdelivr.net/gh/PBH-BTN/BTN-Collected-Rules@main/combine/all.txt", 30 | # "https://bta.iaalai.cn/BTN-Collected-Rules/qBittorrent-ClientBlocker/ipBlockList.txt", 31 | "https://cdn.jsdelivr.net/gh/Simple-Tracker/qBittorrent-ClientBlocker@dev/ipBlockList.txt", 32 | ] 33 | # debug_CheckTorrent = false 34 | # debug_CheckPeer = false 35 | # updateInterval = 86_400 36 | # restartInterval = 6 37 | # torrentMapCleanInterval = 60 38 | # banAllPort = true 39 | # banIPCIDR = "/32" 40 | # banIP6CIDR = "/128" 41 | # ignoreEmptyPeer = true 42 | # ignoreNoLeechersTorrent = true 43 | # ignorePTTorrent = true 44 | # ignoreAuthFailed = false 45 | # sleepTime = 20 46 | # proxy = "Auto" 47 | # longConnection = true 48 | # logPath = "logs" 49 | # logDebug = false 50 | # listen = "127.0.0.1:26262" 51 | # fetchFailedThreshold = 0 52 | # execCommand_FetchFailed = "" 53 | # execCommand_Run = "" 54 | # execCommand_Ban = "" 55 | # execCommand_Unban = "" 56 | # syncServerURL = "" 57 | # syncServerToken = "" 58 | # genIPDat = 0 59 | # ipUploadedCheck = false 60 | # ipUpCheckInterval = 300 61 | # ipUpCheckIncrementMB = 38_000 62 | # ipUpCheckPerTorrentRatio = 3 63 | # maxIPPortCount = 0 64 | # banByProgressUploaded = false 65 | # banByPUStartMB = 20 66 | # banByPUStartPrecent = 2 67 | # banByPUAntiErrorRatio = 3 68 | # banByRelativeProgressUploaded = false 69 | # banByRelativePUStartMB = 20 70 | # banByRelativePUStartPrecent = 2 71 | # banByRelativePUAntiErrorRatio = 3 72 | # ignoreByDownloaded = 100 73 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "checkUpdate": true, 3 | "debug": false, 4 | "interval": 6, 5 | "cleanInterval": 3600, 6 | "banTime": 86400, 7 | "timeout": 6, 8 | "logToFile": true, 9 | "clientType": "", 10 | "clientURL": "", 11 | "clientUsername": "", 12 | "clientPassword": "", 13 | "useBasicAuth": false, 14 | "useShadowBan": true, 15 | "skipCertVerification": false, 16 | "blockList": [], 17 | "blockListFile": [ 18 | "blockList.json", 19 | //"blockList-Optional.json" 20 | ], 21 | "blockListURL": [ 22 | "https://bta.iaalai.cn/qBittorrent-ClientBlocker/blockList.json", 23 | "https://cdn.jsdelivr.net/gh/Simple-Tracker/qBittorrent-ClientBlocker@dev/blockList.json" 24 | ], 25 | "portBlockList": [], 26 | "ipBlockList": [], 27 | "ipBlockListFile": [ 28 | "ipBlockList.txt" 29 | ], 30 | "ipBlockListURL": [ 31 | "https://bta.iaalai.cn/BTN-Collected-Rules/combine/all.txt", 32 | "https://cdn.jsdelivr.net/gh/PBH-BTN/BTN-Collected-Rules@main/combine/all.txt", 33 | //"https://bta.iaalai.cn/BTN-Collected-Rules/qBittorrent-ClientBlocker/ipBlockList.txt", 34 | "https://cdn.jsdelivr.net/gh/Simple-Tracker/qBittorrent-ClientBlocker@dev/ipBlockList.txt" 35 | ] 36 | /* 37 | "debug_CheckTorrent": false, 38 | "debug_CheckPeer": false, 39 | "updateInterval": 86400, 40 | "restartInterval": 6, 41 | "torrentMapCleanInterval": 60, 42 | "banAllPort": true, 43 | "banIPCIDR": "/32", 44 | "banIP6CIDR": "/128", 45 | "ignoreEmptyPeer": true, 46 | "ignoreNoLeechersTorrent": true, 47 | "ignorePTTorrent": true, 48 | "ignoreAuthFailed": false, 49 | "sleepTime": 20, 50 | "proxy": "Auto", 51 | "longConnection": true, 52 | "logPath": "logs", 53 | "logDebug": false, 54 | "listen": "127.0.0.1:26262", 55 | "fetchFailedThreshold": 0, 56 | "execCommand_FetchFailed": "", 57 | "execCommand_Run": "", 58 | "execCommand_Ban": "", 59 | "execCommand_Unban": "", 60 | "syncServerURL": "", 61 | "syncServerToken": "", 62 | "genIPDat": 0, 63 | "ipUploadedCheck": false, 64 | "ipUpCheckInterval": 300, 65 | "ipUpCheckIncrementMB": 38000, 66 | "ipUpCheckPerTorrentRatio": 3.0, 67 | "maxIPPortCount": 0, 68 | "banByProgressUploaded": false, 69 | "banByPUStartMB": 20, 70 | "banByPUStartPrecent": 2.0, 71 | "banByPUAntiErrorRatio": 3.0, 72 | "banByRelativeProgressUploaded": false, 73 | "banByRelativePUStartMB": 20, 74 | "banByRelativePUStartPrecent": 2.0, 75 | "banByRelativePUAntiErrorRatio": 3.0, 76 | "ignoreByDownloaded": 100, 77 | */ 78 | } 79 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var currentClientType = "" 4 | 5 | // 重复判断 nil 是因为输出的类型转换 (qB_MainDataStruct -> interface{}) 会导致 nil 比较失效. 6 | func IsBanPort() bool { 7 | if currentClientType == "qBittorrent" && qB_useNewBanPeersMethod { 8 | return true 9 | } 10 | 11 | return false 12 | } 13 | 14 | func IsSupportClient() bool { 15 | switch currentClientType { 16 | case "qBittorrent", "Transmission", "BitComet": 17 | return true 18 | } 19 | 20 | return false 21 | } 22 | func InitClient() { 23 | if currentClientType == "Transmission" { 24 | Tr_InitClient() 25 | } 26 | } 27 | func SetURLFromClient() { 28 | // 未设置的情况下, 应按内部客户端顺序逐个测试. 29 | if config.ClientURL == "" { 30 | if !qB_SetURL() { 31 | Tr_SetURL() 32 | } 33 | } 34 | } 35 | func DetectClient() bool { 36 | currentClientType = "qBittorrent" 37 | if config.ClientType == "" || config.ClientType == currentClientType { 38 | if qB_GetAPIVersion() { 39 | Log("DetectClient", GetLangText("Success-DetectClient"), true, currentClientType) 40 | return true 41 | } 42 | } 43 | 44 | currentClientType = "Transmission" 45 | if config.ClientType == "" || config.ClientType == currentClientType { 46 | if Tr_DetectVersion() { 47 | Log("DetectClient", GetLangText("Success-DetectClient"), true, currentClientType) 48 | return true 49 | } 50 | } 51 | 52 | currentClientType = "BitComet" 53 | if config.ClientType == "" || config.ClientType == currentClientType { 54 | if BC_DetectClient() { 55 | Log("DetectClient", GetLangText("Success-DetectClient"), true, currentClientType) 56 | return true 57 | } 58 | } 59 | 60 | if config.ClientType != "" { 61 | currentClientType = config.ClientType 62 | return true 63 | } 64 | 65 | currentClientType = "" 66 | return false 67 | } 68 | func Login() bool { 69 | switch currentClientType { 70 | case "qBittorrent": 71 | return qB_Login() 72 | case "Transmission": 73 | return Tr_Login() 74 | case "BitComet": 75 | return BC_Login() 76 | } 77 | 78 | return false 79 | } 80 | func FetchTorrents() interface{} { 81 | switch currentClientType { 82 | case "qBittorrent": 83 | maindata := qB_FetchTorrents() 84 | if maindata == nil { 85 | return nil 86 | } 87 | return maindata 88 | case "Transmission": 89 | maindata := Tr_FetchTorrents() 90 | if maindata == nil { 91 | return nil 92 | } 93 | return maindata 94 | case "BitComet": 95 | maindata := BC_FetchTorrents() 96 | if maindata == nil { 97 | return nil 98 | } 99 | return maindata 100 | } 101 | 102 | return nil 103 | } 104 | func FetchTorrentPeers(infoHash string) interface{} { 105 | switch currentClientType { 106 | case "qBittorrent": 107 | torrentPeers := qB_FetchTorrentPeers(infoHash) 108 | if torrentPeers == nil { 109 | return nil 110 | } 111 | return torrentPeers 112 | case "BitComet": 113 | torrentPeers := BC_FetchTorrentPeers(infoHash) 114 | if torrentPeers == nil { 115 | return nil 116 | } 117 | return torrentPeers 118 | } 119 | 120 | return nil 121 | } 122 | func SubmitBlockPeer(blockPeerMap map[string]BlockPeerInfoStruct) bool { 123 | if blockPeerMap == nil { 124 | return true 125 | } 126 | 127 | switch currentClientType { 128 | case "qBittorrent": 129 | if config.UseShadowBan { 130 | return qB_SubmitShadowBanPeer(blockPeerMap) 131 | } else { 132 | return qB_SubmitBlockPeer(blockPeerMap) 133 | } 134 | case "Transmission": 135 | return Tr_SubmitBlockPeer(blockPeerMap) 136 | } 137 | 138 | return false 139 | } 140 | func TestShadowBanAPI() int { 141 | // -1: Unsupported (Error), 0: Unsupported (Silent), 1: Supported. 142 | switch currentClientType { 143 | case "qBittorrent": 144 | if qB_TestShadowBanAPI() { 145 | return 1 146 | } 147 | return -1 148 | } 149 | 150 | return 0 151 | } 152 | -------------------------------------------------------------------------------- /ip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "net" 4 | 5 | type IPInfoStruct struct { 6 | Net *net.IPNet 7 | Port map[int]bool 8 | TorrentUploaded map[string]int64 9 | } 10 | 11 | var ipMap = make(map[string]IPInfoStruct) 12 | var lastIPMap = make(map[string]IPInfoStruct) 13 | var lastIPCleanTimestamp int64 = 0 14 | 15 | func AddIPInfo(cidr *net.IPNet, peerIP string, peerPort int, torrentInfoHash string, peerUploaded int64) { 16 | if !(config.MaxIPPortCount > 0 || (config.IPUploadedCheck && config.IPUpCheckIncrementMB > 0)) { 17 | return 18 | } 19 | 20 | var clientPortMap map[int]bool 21 | var clientTorrentUploadedMap map[string]int64 22 | 23 | if info, exist := ipMap[peerIP]; !exist { 24 | clientPortMap = make(map[int]bool) 25 | clientTorrentUploadedMap = make(map[string]int64) 26 | } else { 27 | clientPortMap = info.Port 28 | clientTorrentUploadedMap = info.TorrentUploaded 29 | } 30 | clientPortMap[peerPort] = true 31 | 32 | if oldPeerUploaded, exist := clientTorrentUploadedMap[torrentInfoHash]; !exist || oldPeerUploaded <= peerUploaded { 33 | clientTorrentUploadedMap[torrentInfoHash] = peerUploaded 34 | } else { 35 | clientTorrentUploadedMap[torrentInfoHash] += peerUploaded 36 | } 37 | 38 | ipMap[peerIP] = IPInfoStruct{Net: cidr, Port: clientPortMap, TorrentUploaded: clientTorrentUploadedMap} 39 | } 40 | func IsIPTooHighUploaded(ipInfo IPInfoStruct, lastIPInfo IPInfoStruct) int64 { 41 | var totalUploaded int64 = 0 42 | 43 | for torrentInfoHash, torrentUploaded := range ipInfo.TorrentUploaded { 44 | if config.IPUpCheckIncrementMB > 0 { 45 | if lastTorrentUploaded, exist := lastIPInfo.TorrentUploaded[torrentInfoHash]; !exist { 46 | totalUploaded += torrentUploaded 47 | } else { 48 | totalUploaded += (torrentUploaded - lastTorrentUploaded) 49 | } 50 | } 51 | } 52 | 53 | if config.IPUpCheckIncrementMB > 0 { 54 | var totalUploadedMB int64 = (totalUploaded / 1024 / 1024) 55 | if totalUploadedMB > int64(config.IPUpCheckIncrementMB) { 56 | return totalUploadedMB 57 | } 58 | } 59 | 60 | return 0 61 | } 62 | func IsMatchCIDR(peerNet *net.IPNet) bool { 63 | if peerNet != nil { 64 | if _, exist := blockCIDRMap[peerNet.String()]; exist { 65 | return true 66 | } 67 | } 68 | 69 | return false 70 | } 71 | func CheckAllIP(ipMap map[string]IPInfoStruct, lastIPMap map[string]IPInfoStruct) int { 72 | if (config.MaxIPPortCount > 0 || (config.IPUploadedCheck && config.IPUpCheckIncrementMB > 0)) && len(lastIPMap) > 0 && currentTimestamp > (lastIPCleanTimestamp+int64(config.IPUpCheckInterval)) { 73 | ipBlockCount := 0 74 | 75 | ipMapLoop: 76 | for ip, ipInfo := range ipMap { 77 | if IsBlockedPeer(ip, -1, true) || len(ipInfo.Port) <= 0 { 78 | continue 79 | } 80 | 81 | for port := range ipInfo.Port { 82 | if IsBlockedPeer(ip, port, true) { 83 | continue ipMapLoop 84 | } 85 | } 86 | 87 | if config.MaxIPPortCount > 0 { 88 | if len(ipInfo.Port) > int(config.MaxIPPortCount) { 89 | Log("CheckAllIP_AddBlockPeer (Too many ports)", "%s:%d", true, ip, -1) 90 | ipBlockCount++ 91 | AddBlockPeer("CheckAllIP", "Too many ports", ip, -1, "") 92 | AddBlockCIDR(ip, ipInfo.Net) 93 | continue 94 | } 95 | } 96 | 97 | if lastIPInfo, exist := lastIPMap[ip]; exist { 98 | if uploadDuring := IsIPTooHighUploaded(ipInfo, lastIPInfo); uploadDuring > 0 { 99 | Log("CheckAllIP_AddBlockPeer (Global-Too high uploaded)", "%s:%d (UploadDuring: %.2f MB)", true, ip, -1, uploadDuring) 100 | ipBlockCount++ 101 | AddBlockPeer("CheckAllIP", "Global-Too high uploaded", ip, -1, "") 102 | AddBlockCIDR(ip, ipInfo.Net) 103 | } 104 | } 105 | } 106 | 107 | lastIPCleanTimestamp = currentTimestamp 108 | DeepCopyIPMap(ipMap, lastIPMap) 109 | 110 | return ipBlockCount 111 | } 112 | 113 | return 0 114 | } 115 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: 'Release-Go-Binary' 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | release: 7 | types: ['created'] 8 | 9 | jobs: 10 | Release-Go-Binary: 11 | name: ${{ matrix.goarch == 'arm' && format('{0}, {1}v{2}', matrix.goos, matrix.goarch, matrix.goarm) || format('{0}, {1}', matrix.goos, matrix.goarch) }} 12 | runs-on: 'ubuntu-latest' 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | goos: [darwin, windows, linux, netbsd, openbsd, freebsd] 17 | goarch: ['386', 'amd64', 'arm', 'arm64'] 18 | goarm: ['6'] 19 | include: 20 | - goos: windows 21 | goarch: 'arm' 22 | goarm: '7' 23 | - goos: linux 24 | goarch: 'arm' 25 | goarm: '5' 26 | - goos: linux 27 | goarch: '386' 28 | pre_command: 'export CGO_ENABLED=0' 29 | - goos: linux 30 | goarch: 'amd64' 31 | pre_command: 'export CGO_ENABLED=0' 32 | - goos: linux 33 | goarch: 'arm' 34 | goarm: '7' 35 | - goos: linux 36 | goarch: 'mips' 37 | pre_command: 'export GOMIPS=softfloat' 38 | - goos: linux 39 | goarch: 'mipsle' 40 | pre_command: 'export GOMIPS=softfloat' 41 | - goos: linux 42 | goarch: 'mips64' 43 | pre_command: 'export GOMIPS64=softfloat' 44 | - goos: linux 45 | goarch: 'mips64le' 46 | pre_command: 'export GOMIPS64=softfloat' 47 | - goos: linux 48 | goarch: 'riscv64' 49 | - goos: linux 50 | goarch: 'ppc64' 51 | - goos: linux 52 | goarch: 'ppc64le' 53 | - goos: solaris 54 | goarch: 'amd64' 55 | exclude: 56 | - goos: darwin 57 | goarch: '386' 58 | - goos: darwin 59 | goarch: 'arm' 60 | - goos: windows 61 | goarch: 'arm' 62 | goarm: '6' 63 | steps: 64 | - name: 'Checkout' 65 | uses: 'actions/checkout@v4' 66 | - name: 'Set build info' 67 | id: build_info 68 | run: | 69 | echo "tag_version=$(basename ${GITHUB_REF}) (${{ matrix.goarch == 'arm' && format('{0}, {1}v{2}', matrix.goos, matrix.goarch, matrix.goarm) || format('{0}, {1}', matrix.goos, matrix.goarch) }})" >> "${GITHUB_OUTPUT}" 70 | echo "nightly_build_name=$(basename ${GITHUB_REF})_`echo ${GITHUB_SHA} | cut -c1-7`" >> "${GITHUB_OUTPUT}" 71 | echo "nightly_build_version=$(basename ${GITHUB_REF})_`echo ${GITHUB_SHA} | cut -c1-7` (${{ matrix.goarch == 'arm' && format('{0}, {1}v{2}', matrix.goos, matrix.goarch, matrix.goarm) || format('{0}, {1}', matrix.goos, matrix.goarch) }}) (Nightly)" >> "${GITHUB_OUTPUT}" 72 | - name: ${{ github.event_name == 'release' && 'Build' || 'Build (Nightly)' }} 73 | id: build 74 | uses: 'wangyoucao577/go-release-action@v1.50' 75 | with: 76 | github_token: ${{ secrets.GITHUB_TOKEN }} 77 | upload: ${{ github.event_name == 'release' && true || false }} 78 | goos: ${{ matrix.goos }} 79 | goarch: ${{ matrix.goarch }} 80 | goarm: ${{ matrix.goarm }} 81 | ldflags: "-w -X \"main.programVersion=${{ github.event_name == 'release' && steps.build_info.outputs.tag_version || steps.build_info.outputs.nightly_build_version }}\"" 82 | pre_command: ${{ matrix.pre_command }} 83 | goversion: 1.20.13 84 | md5sum: false 85 | sha256sum: false 86 | binary_name: 'qBittorrent-ClientBlocker' 87 | extra_files: 'lang/ LICENSE *.md *.txt *.json' 88 | - name: 'Upload GitHub Artifact (Nightly)' 89 | uses: actions/upload-artifact@v4 90 | if: ${{ github.event_name != 'release' }} 91 | with: 92 | name: ${{ github.event.repository.name }}-${{ steps.build_info.outputs.nightly_build_name }}-${{ matrix.goarch == 'arm' && format('{0}-{1}v{2}', matrix.goos, matrix.goarch, matrix.goarm) || format('{0}-{1}', matrix.goos, matrix.goarch) }} 93 | path: ${{ steps.build.outputs.release_asset_dir }} 94 | -------------------------------------------------------------------------------- /SyncServer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/tidwall/jsonc" 6 | "net" 7 | ) 8 | 9 | type SyncServer_ConfigStruct struct { 10 | Interval uint32 `json:"interval"` 11 | Status string `json:"status"` 12 | BlockIPRule map[string][]string `json:"blockIPRule"` 13 | } 14 | type SyncServer_SubmitStruct struct { 15 | Version uint32 `json:"version"` 16 | Timestamp int64 `json:"timestamp"` 17 | Token string `json:"token"` 18 | TorrentMap map[string]TorrentInfoStruct `json:"torrentMap"` 19 | } 20 | 21 | var syncServer_isSubmiting bool = false 22 | var syncServer_lastSync int64 = 0 23 | var syncServer_syncConfig = SyncServer_ConfigStruct{ 24 | Interval: 60, 25 | Status: "", 26 | BlockIPRule: make(map[string][]string), 27 | } 28 | var ipBlockCIDRMapFromSyncServerCompiled = make(map[string]*net.IPNet) 29 | 30 | func SyncWithServer_PrepareJSON(torrentMap map[string]TorrentInfoStruct) (bool, string) { 31 | syncJSON, err := json.Marshal(SyncServer_SubmitStruct{Version: 1, Timestamp: currentTimestamp, Token: config.SyncServerToken, TorrentMap: torrentMap}) 32 | if err != nil { 33 | Log("SyncWithServer_PrepareJSON", GetLangText("Error-GenJSON"), true, err.Error()) 34 | return false, "" 35 | } 36 | 37 | return true, string(syncJSON) 38 | } 39 | func SyncWithServer_Submit(syncJSON string) bool { 40 | _, _, syncServerContent := Submit(config.SyncServerURL, syncJSON, false, false, nil) 41 | syncServer_isSubmiting = false 42 | if syncServerContent == nil { 43 | Log("SyncWithServer", GetLangText("Error-FetchResponse2"), true) 44 | return false 45 | } 46 | 47 | // Max 8MB. 48 | if len(syncServerContent) > 8388608 { 49 | Log("SyncWithServer", GetLangText("Error-LargeFile"), true) 50 | return false 51 | } 52 | 53 | if err := json.Unmarshal(jsonc.ToJSON(syncServerContent), &syncServer_syncConfig); err != nil { 54 | Log("SyncWithServer", GetLangText("Error-ParseConfig"), true, err.Error()) 55 | return false 56 | } 57 | 58 | if syncServer_syncConfig.Status != "" { 59 | Log("SyncWithServer", GetLangText("Error-SyncWithServer_ServerError"), true, syncServer_syncConfig.Status) 60 | return false 61 | } 62 | 63 | tmpIPBlockCIDRMapFromSyncServerCompiled := make(map[string]*net.IPNet) 64 | 65 | for reason, ipArr := range syncServer_syncConfig.BlockIPRule { 66 | logReason := false 67 | 68 | for ipBlockListLineNum, ipBlockListLine := range ipArr { 69 | ipBlockListLine = ProcessRemark(ipBlockListLine) 70 | if ipBlockListLine == "" { 71 | Log("Debug-SyncWithServer_Compile", GetLangText("Error-Debug-EmptyLine"), false, ipBlockListLineNum) 72 | continue 73 | } 74 | 75 | if cidr, exists := ipBlockCIDRMapFromSyncServerCompiled[ipBlockListLine]; exists { 76 | tmpIPBlockCIDRMapFromSyncServerCompiled[ipBlockListLine] = cidr 77 | continue 78 | } 79 | 80 | Log("Debug-SyncWithServer_Compile", ":%d %s", false, ipBlockListLineNum, ipBlockListLine) 81 | cidr := ParseIPCIDR(ipBlockListLine) 82 | if cidr == nil { 83 | Log("SyncWithServer_Compile", GetLangText("Error-SyncWithServer_Compile"), true, ipBlockListLineNum, ipBlockListLine) 84 | continue 85 | } 86 | 87 | if !logReason { 88 | logReason = true 89 | Log("SyncWithServer", GetLangText("SyncWithServer_Compile-BlockByReason"), true, reason) 90 | } 91 | 92 | tmpIPBlockCIDRMapFromSyncServerCompiled[ipBlockListLine] = cidr 93 | Log("SyncWithServer_BlockCIDR", ":%d %s", false, ipBlockListLineNum, ipBlockListLine) 94 | } 95 | } 96 | 97 | ipBlockCIDRMapFromSyncServerCompiled = tmpIPBlockCIDRMapFromSyncServerCompiled 98 | 99 | Log("Debug-SyncWithServer", GetLangText("Success-SyncWithServer"), true, len(ipBlockCIDRMapFromSyncServerCompiled)) 100 | return true 101 | } 102 | func SyncWithServer_FullSubmit(syncJSON string) bool { 103 | syncServer_isSubmiting = true 104 | syncStatus := SyncWithServer_Submit(syncJSON) 105 | syncServer_isSubmiting = false 106 | 107 | return syncStatus 108 | } 109 | func SyncWithServer() bool { 110 | if config.SyncServerURL == "" || (syncServer_lastSync+int64(syncServer_syncConfig.Interval)) > currentTimestamp || syncServer_isSubmiting { 111 | return true 112 | } 113 | 114 | Log("Debug-SyncWithServer", "In progress..", false) 115 | 116 | status, syncJSON := SyncWithServer_PrepareJSON(torrentMap) 117 | if !status { 118 | return false 119 | } 120 | 121 | syncServer_lastSync = currentTimestamp 122 | 123 | go SyncWithServer_FullSubmit(syncJSON) 124 | 125 | return true 126 | } 127 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | // Source: https://stackoverflow.com/questions/51459083/deep-copying-maps-in-golang. 14 | func DeepCopyIPMap(src map[string]IPInfoStruct, dest map[string]IPInfoStruct) { 15 | if src != nil && dest != nil { 16 | if jsonStr, err := json.Marshal(src); err == nil { 17 | json.Unmarshal(jsonStr, &dest) 18 | } 19 | } 20 | } 21 | func DeepCopyTorrentMap(src map[string]TorrentInfoStruct, dest map[string]TorrentInfoStruct) { 22 | if src != nil && dest != nil { 23 | if jsonStr, err := json.Marshal(src); err == nil { 24 | json.Unmarshal(jsonStr, &dest) 25 | } 26 | } 27 | } 28 | func IsUnix(path string) bool { 29 | return !strings.Contains(path, "\\") 30 | } 31 | func IsIPv6(ip string) bool { 32 | return (strings.Count(ip, ":") >= 2) 33 | } 34 | func ProcessRemark(str string) string { 35 | // Remove all remarks. 36 | return StrTrim(strings.SplitN(strings.SplitN(str, "#", 2)[0], "//", 2)[0]) 37 | } 38 | func StrTrim(str string) string { 39 | return strings.Trim(str, "  \n\r") 40 | } 41 | func GetDateTime(withTime bool) string { 42 | formatStr := "2006-01-02" 43 | if withTime { 44 | formatStr += " 15:04:05" 45 | } 46 | return time.Now().Format(formatStr) 47 | } 48 | func CheckPrivateIP(ip string) bool { 49 | ipParsed := net.ParseIP(ip) 50 | if ipParsed == nil { 51 | return false 52 | } 53 | return (ipParsed.IsLoopback() || ipParsed.IsPrivate()) 54 | } 55 | func ParseIPCIDR(ip string) *net.IPNet { 56 | if !strings.Contains(ip, "/") { 57 | if IsIPv6(ip) { 58 | ip += "/128" 59 | } else { 60 | ip += "/32" 61 | } 62 | } 63 | 64 | _, cidr, err := net.ParseCIDR(ip) 65 | if err != nil { 66 | return nil 67 | } 68 | 69 | return cidr 70 | } 71 | func ParseIPCIDRByConfig(ip string) *net.IPNet { 72 | cidr := "" 73 | 74 | if IsIPv6(ip) { 75 | if config.BanIP6CIDR != "/128" { 76 | cidr = config.BanIP6CIDR 77 | } 78 | } else { 79 | if config.BanIPCIDR != "/32" { 80 | cidr = config.BanIPCIDR 81 | } 82 | } 83 | 84 | if cidr == "" { 85 | return nil 86 | } 87 | 88 | cidrNet := ParseIPCIDR(ip + cidr) 89 | 90 | if cidrNet == nil { 91 | return nil 92 | } 93 | 94 | return cidrNet 95 | } 96 | func ProcessIP(ip string) string { 97 | ip = strings.ToLower(ip) 98 | 99 | if strings.HasPrefix(ip, "::ffff:") { 100 | return ip[7:] 101 | } 102 | 103 | return ip 104 | } 105 | func GenIPFilter(datType uint32, blockPeerMap map[string]BlockPeerInfoStruct) (int, string) { 106 | ipfilterCount := 0 107 | ipfilterStr := "" 108 | 109 | if datType != 1 && datType != 2 { 110 | return ipfilterCount, ipfilterStr 111 | } 112 | 113 | for peerIP := range blockPeerMap { 114 | if !IsIPv6(peerIP) { 115 | ipfilterCount += 2 116 | if datType == 1 { 117 | ipfilterStr += peerIP + "/32\n" 118 | ipfilterStr += "::ffff:" + peerIP + "/128\n" 119 | } else if datType == 2 { 120 | ipfilterStr += peerIP + " - " + peerIP + " , 000\n" 121 | ipfilterStr += "::ffff:" + peerIP + " - ::ffff:" + peerIP + " , 000\n" 122 | } 123 | } else { 124 | ipfilterCount++ 125 | if datType == 1 { 126 | ipfilterStr += peerIP + "/128\n" 127 | } else if datType == 2 { 128 | ipfilterStr += peerIP + " - " + peerIP + " , 000\n" 129 | } 130 | } 131 | } 132 | 133 | return ipfilterCount, ipfilterStr 134 | } 135 | func SaveIPFilter(ipfilterStr string) string { 136 | err := os.WriteFile("ipfilter.dat", []byte(ipfilterStr), 0666) 137 | if err != nil { 138 | return err.Error() 139 | } 140 | 141 | return "" 142 | } 143 | func DeleteIPFilter() bool { 144 | err := os.Remove("ipfilter.dat") 145 | if err != nil { 146 | return false 147 | } 148 | 149 | return true 150 | } 151 | func ParseCommand(command string) []string { 152 | var matchQuote rune = -1 153 | escaped := false 154 | commandPart := []string{""} 155 | commandIndex := 0 156 | 157 | for _, char := range command { 158 | if char == '\\' && matchQuote == -1 { 159 | escaped = true 160 | continue 161 | } else if char == ' ' && matchQuote == -1 { 162 | if escaped { 163 | commandPart[commandIndex] += "\\" 164 | escaped = false 165 | } 166 | if commandPart[commandIndex] != "" { 167 | commandIndex++ 168 | commandPart = append(commandPart, "") 169 | } 170 | continue 171 | } else if !escaped && char == '\'' || char == '"' { 172 | if char == matchQuote { 173 | matchQuote = -1 174 | continue 175 | } else if matchQuote == -1 { 176 | matchQuote = char 177 | continue 178 | } 179 | } 180 | if escaped { 181 | commandPart[commandIndex] += "\\" 182 | escaped = false 183 | } 184 | commandPart[commandIndex] += string(char) 185 | } 186 | 187 | return commandPart 188 | } 189 | func ExecCommand(command string) (bool, string, string) { 190 | commandSplit := ParseCommand(command) 191 | Log("Debug-ExecCommand", "Raw: %s, Split (|): %s", false, command, strings.Join(commandSplit, "|")) 192 | 193 | var cmd *exec.Cmd 194 | if len(commandSplit) == 1 { 195 | cmd = exec.Command(commandSplit[0]) 196 | } else { 197 | cmd = exec.Command(commandSplit[0], commandSplit[1:]...) 198 | } 199 | 200 | out, err := cmd.CombinedOutput() 201 | if err != nil { 202 | return false, string(out), err.Error() 203 | } 204 | 205 | return true, string(out), "" 206 | } 207 | 208 | // EraseSyncMap is a helper function to erase all elements in a sync.Map. 209 | // This function is supposed to be replaced by `Clear` method after Go1.23 and above. 210 | // 211 | // go:build !go1.23 212 | func EraseSyncMap(m *sync.Map) { 213 | m.Range(func(key, value any) bool { 214 | m.Delete(key) 215 | return true 216 | }) 217 | } 218 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | var fetchFailedCount = 0 10 | var urlETagCache = make(map[string]string) 11 | var urlLastModCache = make(map[string]string) 12 | 13 | func NewRequest(isPOST bool, url string, postdata string, clientReq bool, allowCache bool, withHeader *map[string]string) *http.Request { 14 | var request *http.Request 15 | var err error 16 | 17 | if !isPOST { 18 | request, err = http.NewRequest("GET", url, nil) 19 | } else { 20 | request, err = http.NewRequest("POST", url, strings.NewReader(postdata)) 21 | } 22 | 23 | if err != nil { 24 | Log("NewRequest", GetLangText("Error-NewRequest"), true, err.Error()) 25 | return nil 26 | } 27 | 28 | setUserAgent := false 29 | setContentType := false 30 | 31 | if withHeader != nil { 32 | for k, v := range *withHeader { 33 | switch strings.ToLower(k) { 34 | case "user-agent": 35 | setUserAgent = true 36 | case "content-type": 37 | setContentType = true 38 | } 39 | 40 | request.Header.Set(k, v) 41 | } 42 | } 43 | 44 | if !setUserAgent { 45 | request.Header.Set("User-Agent", programUserAgent) 46 | } 47 | 48 | if !setContentType && isPOST { 49 | request.Header.Set("Content-Type", "application/x-www-form-urlencoded") 50 | } 51 | 52 | if clientReq { 53 | if currentClientType == "Transmission" && Tr_csrfToken != "" { 54 | request.Header.Set("X-Transmission-Session-Id", Tr_csrfToken) 55 | } 56 | 57 | if config.UseBasicAuth && config.ClientUsername != "" { 58 | request.SetBasicAuth(config.ClientUsername, config.ClientPassword) 59 | } 60 | } else if !isPOST && allowCache { 61 | if etag, exist := urlETagCache[url]; exist { 62 | request.Header.Set("If-None-Match", etag) 63 | } 64 | 65 | if lastMod, exist := urlLastModCache[url]; exist { 66 | request.Header.Set("If-Modified-Since", lastMod) 67 | } 68 | } 69 | 70 | return request 71 | } 72 | func Fetch(url string, tryLogin bool, clientReq bool, allowCache bool, withHeader *map[string]string) (int, http.Header, []byte) { 73 | request := NewRequest(false, url, "", clientReq, allowCache, withHeader) 74 | if request == nil { 75 | return -1, nil, nil 76 | } 77 | 78 | var response *http.Response 79 | var err error 80 | 81 | if clientReq { 82 | response, err = httpClient.Do(request) 83 | } else { 84 | response, err = httpClientExternal.Do(request) 85 | } 86 | 87 | if err != nil { 88 | if config.FetchFailedThreshold > 0 && config.ExecCommand_FetchFailed != "" { 89 | fetchFailedCount++ 90 | if fetchFailedCount >= config.FetchFailedThreshold { 91 | fetchFailedCount = 0 92 | status, out, err := ExecCommand(config.ExecCommand_FetchFailed) 93 | 94 | if status { 95 | Log("Fetch", GetLangText("Success-ExecCommand"), true, out) 96 | } else { 97 | Log("Fetch", GetLangText("Failed-ExecCommand"), true, out, err) 98 | } 99 | } 100 | } 101 | Log("Fetch", GetLangText("Error-FetchResponse"), true, err.Error()) 102 | return -2, nil, nil 103 | } 104 | 105 | responseBody, err := ioutil.ReadAll(response.Body) 106 | defer response.Body.Close() 107 | 108 | if err != nil { 109 | Log("Fetch", GetLangText("Error-ReadResponse"), true, err.Error()) 110 | return -3, nil, nil 111 | } 112 | 113 | if response.StatusCode == 204 { 114 | Log("Debug-Fetch", GetLangText("Debug-Request_NoContent"), false, url) 115 | return 204, response.Header, nil 116 | } 117 | 118 | if response.StatusCode == 401 { 119 | Log("Fetch", GetLangText("Error-NoAuth"), true) 120 | return 401, response.Header, nil 121 | } 122 | 123 | if response.StatusCode == 403 { 124 | if tryLogin { 125 | Login() 126 | } 127 | Log("Fetch", GetLangText("Error-Forbidden"), true) 128 | return 403, response.Header, nil 129 | } 130 | 131 | if response.StatusCode == 409 { 132 | // 尝试获取并设置 CSRF Token. 133 | if currentClientType == "Transmission" { 134 | trCSRFToken := response.Header.Get("X-Transmission-Session-Id") 135 | if trCSRFToken != "" { 136 | Tr_SetCSRFToken(trCSRFToken) 137 | return 409, nil, nil 138 | } 139 | } 140 | 141 | if tryLogin { 142 | Login() 143 | } 144 | 145 | Log("Fetch", GetLangText("Error-Forbidden"), true) 146 | return 409, response.Header, nil 147 | } 148 | 149 | if response.StatusCode == 404 { 150 | Log("Fetch", GetLangText("Error-NotFound"), true) 151 | return 404, response.Header, nil 152 | } 153 | 154 | if allowCache && response.StatusCode == 304 { 155 | Log("Debug-Fetch", GetLangText("Debug-Request_NoChange"), false, url) 156 | return 304, response.Header, nil 157 | } 158 | 159 | if response.StatusCode != 200 { 160 | Log("Fetch", GetLangText("Error-UnknownStatusCode"), true, response.StatusCode) 161 | return response.StatusCode, response.Header, nil 162 | } 163 | 164 | if allowCache { 165 | etag := response.Header.Get("ETag") 166 | 167 | if etag != "" { 168 | urlETagCache[url] = etag 169 | } 170 | 171 | lastMod := response.Header.Get("Last-Modified") 172 | if lastMod != "" { 173 | urlLastModCache[url] = lastMod 174 | } 175 | } 176 | 177 | return response.StatusCode, response.Header, responseBody 178 | } 179 | func Submit(url string, postdata string, tryLogin bool, clientReq bool, withHeader *map[string]string) (int, http.Header, []byte) { 180 | request := NewRequest(true, url, postdata, clientReq, false, withHeader) 181 | if request == nil { 182 | return -1, nil, nil 183 | } 184 | 185 | var response *http.Response 186 | var err error 187 | 188 | if clientReq { 189 | response, err = httpClient.Do(request) 190 | } else { 191 | response, err = httpClientExternal.Do(request) 192 | } 193 | 194 | if err != nil { 195 | Log("Submit", GetLangText("Error-FetchResponse"), true, err.Error()) 196 | return -2, nil, nil 197 | } 198 | 199 | responseBody, err := ioutil.ReadAll(response.Body) 200 | defer response.Body.Close() 201 | 202 | if err != nil { 203 | Log("Submit", GetLangText("Error-ReadResponse"), true, err.Error()) 204 | return -3, nil, nil 205 | } 206 | 207 | if response.StatusCode == 204 { 208 | Log("Fetch", GetLangText("Debug-Request_NoContent"), false, url) 209 | return 204, response.Header, nil 210 | } 211 | 212 | if response.StatusCode == 401 { 213 | Log("Submit", GetLangText("Error-NoAuth"), true) 214 | return 401, response.Header, nil 215 | } 216 | 217 | if response.StatusCode == 403 { 218 | if tryLogin { 219 | Login() 220 | } 221 | Log("Submit", GetLangText("Error-Forbidden"), true) 222 | return 403, response.Header, nil 223 | } 224 | 225 | if response.StatusCode == 409 { 226 | // 尝试获取并设置 CSRF Token. 227 | if currentClientType == "Transmission" { 228 | trCSRFToken := response.Header.Get("X-Transmission-Session-Id") 229 | if trCSRFToken != "" { 230 | Tr_SetCSRFToken(trCSRFToken) 231 | return 409, response.Header, nil 232 | } 233 | } 234 | 235 | if tryLogin { 236 | Login() 237 | } 238 | 239 | Log("Fetch", GetLangText("Error-Forbidden"), true) 240 | return 409, response.Header, nil 241 | } 242 | 243 | if response.StatusCode == 404 { 244 | Log("Submit", GetLangText("Error-NotFound"), true) 245 | return 404, response.Header, nil 246 | } 247 | 248 | if response.StatusCode != 200 { 249 | Log("Submit", GetLangText("Error-UnknownStatusCode"), true, response.StatusCode) 250 | return response.StatusCode, response.Header, nil 251 | } 252 | 253 | return response.StatusCode, response.Header, responseBody 254 | } 255 | -------------------------------------------------------------------------------- /client_BitComet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "github.com/PuerkitoBio/goquery" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type BC_TorrentStruct struct { 11 | TotalSize int64 12 | UpSpeed int64 13 | } 14 | type BC_PeerStruct struct { 15 | IP string 16 | Port int 17 | Client string 18 | // PeerID string 19 | Progress float64 20 | Downloaded int64 21 | Uploaded int64 22 | DlSpeed int64 23 | UpSpeed int64 24 | } 25 | 26 | func BC_ParseTorrentLink(torrentLinkStr string) int { 27 | torrentIDSplit1 := strings.SplitN(StrTrim(torrentLinkStr), "?id=", 2) 28 | if len(torrentIDSplit1) < 2 { 29 | return -2 30 | } 31 | 32 | torrentIDStr := strings.SplitN(torrentIDSplit1[1], "&", 2)[0] 33 | torrentID, err := strconv.Atoi(torrentIDStr) 34 | if err != nil { 35 | return -3 36 | } 37 | 38 | return torrentID 39 | } 40 | func BC_ParseSize(sizeStr string) int64 { 41 | sizeStr = StrTrim(sizeStr) 42 | if sizeStr == "" { 43 | return 0 44 | } 45 | 46 | sizeStrSplit := strings.SplitN(sizeStr, " ", 2) 47 | if len(sizeStrSplit) < 2 || len(sizeStrSplit[1]) < 2 { 48 | return -1 49 | } 50 | 51 | rawSize, err := strconv.ParseFloat(sizeStrSplit[0], 64) 52 | if err != nil { 53 | return -2 54 | } 55 | 56 | matched := false 57 | multipler := 1 58 | switch strings.ToUpper(sizeStrSplit[1]) { 59 | case "EB": 60 | multipler *= 1024 61 | fallthrough 62 | case "PB": 63 | multipler *= 1024 64 | fallthrough 65 | case "TB": 66 | multipler *= 1024 67 | fallthrough 68 | case "GB": 69 | multipler *= 1024 70 | fallthrough 71 | case "MB": 72 | multipler *= 1024 73 | fallthrough 74 | case "KB": 75 | multipler *= 1024 76 | fallthrough 77 | case "B": 78 | matched = true 79 | } 80 | 81 | if !matched { 82 | return -3 83 | } 84 | 85 | return int64(rawSize * float64(multipler)) 86 | } 87 | func BC_ParseSpeed(speedStr string) int64 { 88 | speedStr = StrTrim(speedStr) 89 | if speedStr == "" { 90 | return 0 91 | } 92 | 93 | speedStrSplit := strings.SplitN(speedStr, "/", 2) 94 | 95 | if len(speedStrSplit) < 2 || len(speedStrSplit[1]) != 1 { 96 | return -1 97 | } 98 | 99 | return BC_ParseSize(speedStrSplit[0]) 100 | } 101 | func BC_ParsePrecent(precentStr string) float64 { 102 | precentStr = StrTrim(precentStr) 103 | if len(precentStr) < 2 { 104 | return -1 105 | } 106 | 107 | precentStr = precentStr[:(len(precentStr) - 1)] 108 | precent, err := strconv.ParseFloat(precentStr, 64) 109 | if err != nil { 110 | return -2 111 | } 112 | 113 | return precent 114 | } 115 | func BC_ParseIP(ipStr string) (string, int) { 116 | ipStr = strings.ToLower(StrTrim(ipStr)) 117 | if ipStr == "myself" { 118 | return "", -1 119 | } 120 | 121 | lastColonIndex := strings.LastIndex(ipStr, ":") 122 | if lastColonIndex == -1 || len(ipStr) < (lastColonIndex+2) { 123 | return "", -2 124 | } 125 | 126 | ipWithoutPortStr := ipStr[:lastColonIndex] 127 | portStr := ipStr[(lastColonIndex + 1):] 128 | port, err := strconv.Atoi(portStr) 129 | if err != nil { 130 | return "", -3 131 | } 132 | 133 | return ipWithoutPortStr, port 134 | } 135 | func BC_DetectClient() bool { 136 | apiResponseStatusCode, apiResponseHeaders, _ := Fetch(config.ClientURL+"/panel/", false, false, false, nil) 137 | return (apiResponseStatusCode == 401 && strings.Contains(apiResponseHeaders.Get("WWW-Authenticate"), "BitComet")) 138 | } 139 | func BC_Login() bool { 140 | // BitComet 通过 Basic Auth 进行认证, 因此此处只进行验证. 141 | apiResponseStatusCode, _, _ := Fetch(config.ClientURL+"/panel/", false, true, false, nil) 142 | return (apiResponseStatusCode == 200) 143 | } 144 | func BC_FetchTorrents() *map[int]BC_TorrentStruct { 145 | _, _, torrentsResponseBody := Fetch(config.ClientURL+"/panel/task_list?group=active", true, true, false, nil) 146 | if torrentsResponseBody == nil { 147 | Log("FetchTorrents", GetLangText("Error"), true) 148 | return nil 149 | } 150 | 151 | document, err := goquery.NewDocumentFromReader(bytes.NewReader(torrentsResponseBody)) 152 | if err != nil { 153 | Log("FetchTorrents", GetLangText("Error-Parse"), true, err.Error()) 154 | return nil 155 | } 156 | 157 | torrentsMap := make(map[int]BC_TorrentStruct) 158 | document.Find("table").Last().Find("tbody > tr").Each(func(index int, element *goquery.Selection) { 159 | if index == 0 { 160 | return 161 | } 162 | 163 | torrentStatus := "" 164 | torrentID := 0 165 | var torrentSize int64 = -233 166 | var torrentUpSpeed int64 = -233 167 | element.Find("td").EachWithBreak(func(tdIndex int, tdElement *goquery.Selection) bool { 168 | switch tdIndex { 169 | case 0: 170 | if strings.ToUpper(StrTrim(tdElement.Text())) != "BT" { 171 | return false 172 | } 173 | case 1: 174 | href, exists := tdElement.Find("a").Attr("href") 175 | if !exists { 176 | return false 177 | } 178 | 179 | torrentID = BC_ParseTorrentLink(href) 180 | case 2: 181 | torrentStatus = strings.ToLower(StrTrim(tdElement.Text())) 182 | case 4: 183 | torrentSize = BC_ParseSize(tdElement.Text()) 184 | case 7: 185 | torrentUpSpeed = BC_ParseSpeed(tdElement.Text()) 186 | } 187 | 188 | return true 189 | }) 190 | 191 | if torrentStatus == "" || torrentID <= 0 || torrentSize <= 0 || torrentUpSpeed < 0 { 192 | return 193 | } 194 | 195 | torrentsMap[torrentID] = BC_TorrentStruct{TotalSize: torrentSize, UpSpeed: torrentUpSpeed} 196 | }) 197 | 198 | return &torrentsMap 199 | } 200 | func BC_FetchTorrentPeers(infoHash string) *[]BC_PeerStruct { 201 | _, _, torrentPeersResponseBody := Fetch(config.ClientURL+"/panel/task_detail?id="+infoHash+"&show=peers", true, true, false, nil) 202 | if torrentPeersResponseBody == nil { 203 | Log("FetchTorrentPeers", GetLangText("Error"), true) 204 | return nil 205 | } 206 | 207 | document, err := goquery.NewDocumentFromReader(bytes.NewReader(torrentPeersResponseBody)) 208 | if err != nil { 209 | Log("FetchTorrentPeers", GetLangText("Error-Parse"), true, err.Error()) 210 | return nil 211 | } 212 | 213 | torrentPeersMap := []BC_PeerStruct{} 214 | document.Find("table").Last().Find("tbody > tr").Each(func(index int, element *goquery.Selection) { 215 | if index == 0 { 216 | return 217 | } 218 | 219 | peerIP := "" 220 | peerPort := -233 221 | var peerProgress float64 = -233 222 | var peerDlSpeed int64 = -233 223 | var peerUpSpeed int64 = -233 224 | var peerDownloaded int64 = -233 225 | var peerUploaded int64 = -233 226 | peerClient := "" 227 | //peerID := "" 228 | element.Find("td").EachWithBreak(func(tdIndex int, tdElement *goquery.Selection) bool { 229 | switch tdIndex { 230 | case 0: 231 | peerIP, peerPort = BC_ParseIP(tdElement.Text()) 232 | case 1: 233 | peerProgress = BC_ParsePrecent(tdElement.Text()) 234 | case 2: 235 | peerDlSpeed = BC_ParseSpeed(tdElement.Text()) 236 | case 3: 237 | peerUpSpeed = BC_ParseSpeed(tdElement.Text()) 238 | case 4: 239 | peerDownloaded = BC_ParseSize(tdElement.Text()) 240 | case 5: 241 | peerUploaded = BC_ParseSize(tdElement.Text()) 242 | case 9: 243 | peerClient = tdElement.Text() 244 | case 10: 245 | // 错误的信息. 246 | // peerID = tdElement.Text() 247 | } 248 | 249 | return true 250 | }) 251 | 252 | if peerIP == "" || peerPort < 0 || peerProgress < 0 || peerDlSpeed < 0 || peerUpSpeed < 0 || peerDownloaded < 0 || peerUploaded < 0 { 253 | return 254 | } 255 | 256 | peerStruct := BC_PeerStruct{IP: peerIP, Port: peerPort, Client: peerClient, Progress: peerProgress, Downloaded: peerDownloaded, Uploaded: peerUploaded, DlSpeed: peerDlSpeed, UpSpeed: peerUpSpeed} 257 | torrentPeersMap = append(torrentPeersMap, peerStruct) 258 | }) 259 | 260 | return &torrentPeersMap 261 | } 262 | -------------------------------------------------------------------------------- /client_Transmission.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type Tr_RequestStruct struct { 12 | Method string `json:"method"` 13 | Args interface{} `json:"arguments"` 14 | } 15 | type Tr_ResponseStruct struct { 16 | Result string `json:"result"` 17 | } 18 | type Tr_TorrentsResponseStruct struct { 19 | Result string `json:"result"` 20 | Args Tr_TorrentsStruct `json:"arguments"` 21 | } 22 | type Tr_ArgsStruct struct { 23 | Field []string `json:"fields"` 24 | } 25 | type Tr_ArgTorrentsStruct struct { 26 | IDs []string `json:"ids"` 27 | Field []string `json:"fields"` 28 | } 29 | type Tr_SessionSetStruct struct { 30 | BlocklistEnabled bool `json:"blocklist-enabled"` 31 | BlocklistSize int `json:"blocklist-size"` 32 | BlocklistURL string `json:"blocklist-url"` 33 | } 34 | type Tr_TorrentsStruct struct { 35 | Torrents []Tr_TorrentStruct `json:"torrents"` 36 | } 37 | type Tr_TorrentStruct struct { 38 | InfoHash string `json:"hashString"` 39 | TotalSize int64 `json:"totalSize"` 40 | Private bool `json:"private"` 41 | Peers []Tr_PeerStruct `json:"peers"` 42 | } 43 | type Tr_PeerStruct struct { 44 | IP string `json:"address"` 45 | Port int `json:"port"` 46 | Client string `json:"clientName"` 47 | Progress float64 `json:"progress"` 48 | IsUploading bool `json:"isUploadingTo"` 49 | DlSpeed int64 `json:"rateToClient"` 50 | UpSpeed int64 `json:"rateToPeer"` 51 | } 52 | 53 | var Tr_csrfToken = "" 54 | var Tr_ipfilterStr = "" 55 | var Tr_jsonHeader = map[string]string{"Content-Type": "application/json"} 56 | 57 | func Tr_InitClient() { 58 | go StartServer() 59 | } 60 | func Tr_ProcessHTTP(w http.ResponseWriter, r *http.Request) bool { 61 | if strings.SplitN(r.RequestURI, "?", 2)[0] == "/ipfilter.dat" { 62 | w.WriteHeader(200) 63 | w.Write([]byte(Tr_ipfilterStr)) 64 | 65 | return true 66 | } 67 | 68 | return false 69 | } 70 | func Tr_SetURL() bool { 71 | return false 72 | } 73 | func Tr_DetectVersion() bool { 74 | detectJSON, err := json.Marshal(Tr_RequestStruct{Method: "session-get", Args: Tr_ArgsStruct{Field: []string{"version"}}}) 75 | if err != nil { 76 | Log("DetectVersion", GetLangText("Error-GenJSON"), true, err.Error()) 77 | return false 78 | } 79 | 80 | detectStatusCode, _, _ := Submit(config.ClientURL, string(detectJSON), false, false, &Tr_jsonHeader) 81 | return (detectStatusCode == 200 || detectStatusCode == 409) 82 | } 83 | func Tr_Login() bool { 84 | // Transmission 通过 Basic Auth 进行认证, 因此实际处理 CSRF 请求以避免 409 响应. 85 | loginJSON, err := json.Marshal(Tr_RequestStruct{Method: "session-get"}) 86 | if err != nil { 87 | Log("Login", GetLangText("Error-GenJSON"), true, err.Error()) 88 | return false 89 | } 90 | 91 | Submit(config.ClientURL, string(loginJSON), false, true, nil) 92 | 93 | if Tr_csrfToken == "" { 94 | Log("Login", GetLangText("Error-Login"), true) 95 | return false 96 | } 97 | 98 | return true 99 | } 100 | func Tr_SetCSRFToken(csrfToken string) { 101 | Tr_csrfToken = csrfToken 102 | Log("SetCSRFToken", GetLangText("Success-SetCSRFToken"), true, csrfToken) 103 | } 104 | func Tr_FetchTorrents() *Tr_TorrentsStruct { 105 | loginJSON, err := json.Marshal(Tr_RequestStruct{Method: "torrent-get", Args: Tr_ArgsStruct{Field: []string{"hashString", "totalSize", "isPrivate", "peers"}}}) 106 | if err != nil { 107 | Log("FetchTorrents", GetLangText("Error-GenJSON"), true, err.Error()) 108 | return nil 109 | } 110 | 111 | _, _, torrentsResponseBody := Submit(config.ClientURL, string(loginJSON), true, true, &Tr_jsonHeader) 112 | if torrentsResponseBody == nil { 113 | Log("FetchTorrents", GetLangText("Error"), true) 114 | return nil 115 | } 116 | 117 | var torrentsResponse Tr_TorrentsResponseStruct 118 | if err := json.Unmarshal(torrentsResponseBody, &torrentsResponse); err != nil { 119 | Log("FetchTorrents", GetLangText("Error-Parse"), true, err.Error()) 120 | return nil 121 | } 122 | 123 | if torrentsResponse.Result != "success" { 124 | Log("FetchTorrents", GetLangText("Error-Parse"), true, torrentsResponse.Result) 125 | return nil 126 | } 127 | 128 | return &torrentsResponse.Args 129 | } 130 | 131 | func Tr_RestartTorrentByMap(blockPeerMap map[string]BlockPeerInfoStruct) { 132 | peerInfoHashes := []string{} 133 | for _, peerInfo := range blockPeerMap { 134 | peerInfoHashes = append(peerInfoHashes, peerInfo.InfoHash) 135 | } 136 | 137 | if len(peerInfoHashes) <= 0 { 138 | return 139 | } 140 | 141 | stopJSON, err := json.Marshal(Tr_RequestStruct{Method: "torrent-stop", Args: Tr_ArgTorrentsStruct{IDs: peerInfoHashes}}) 142 | if err != nil { 143 | Log("RestartTorrentByMap", GetLangText("Error-GenJSON"), true, err.Error()) 144 | return 145 | } 146 | 147 | stopStatusCode, _, _ := Submit(config.ClientURL, string(stopJSON), true, true, &Tr_jsonHeader) 148 | if stopStatusCode != 200 { 149 | Log("RestartTorrentByMap", GetLangText("Error-RestartTorrentByMap_Stop"), true, err.Error()) 150 | return 151 | } 152 | 153 | Log("RestartTorrentByMap", GetLangText("Debug-RestartTorrentByMap_Wait"), true, config.Interval) 154 | time.Sleep(time.Duration(config.RestartInterval) * time.Second) 155 | 156 | startJSON, err := json.Marshal(Tr_RequestStruct{Method: "torrent-start", Args: Tr_ArgTorrentsStruct{IDs: peerInfoHashes}}) 157 | if err != nil { 158 | Log("RestartTorrentByMap", GetLangText("Error-GenJSON"), true, err.Error()) 159 | return 160 | } 161 | 162 | startStatusCode, _, _ := Submit(config.ClientURL, string(startJSON), true, true, &Tr_jsonHeader) 163 | if startStatusCode != 200 { 164 | Log("RestartTorrentByMap", GetLangText("Error-RestartTorrentByMap_Start"), true, err.Error()) 165 | return 166 | } 167 | } 168 | func Tr_SubmitBlockPeer(blockPeerMap map[string]BlockPeerInfoStruct) bool { 169 | ipfilterCount, ipfilterStr := GenIPFilter(2, blockPeerMap) 170 | Tr_ipfilterStr = ipfilterStr 171 | 172 | blocklistURL := "" 173 | if strings.Contains(config.Listen, ".") { 174 | blocklistURL = "http://" + config.Listen 175 | } else { 176 | blocklistURL = "http://127.0.0.1" + config.Listen 177 | } 178 | blocklistURL += "/ipfilter.dat?t=" + strconv.FormatInt(currentTimestamp, 10) 179 | 180 | sessionSetJSON, err := json.Marshal(Tr_RequestStruct{Method: "session-set", Args: Tr_SessionSetStruct{BlocklistEnabled: true, BlocklistSize: ipfilterCount, BlocklistURL: blocklistURL}}) 181 | if err != nil { 182 | Log("SubmitBlockPeer", GetLangText("Error-GenJSON"), true, err.Error()) 183 | return false 184 | } 185 | 186 | _, _, sessionResponseBody := Submit(config.ClientURL, string(sessionSetJSON), true, true, &Tr_jsonHeader) 187 | if sessionResponseBody == nil { 188 | Log("SubmitBlockPeer", GetLangText("Error"), true) 189 | return false 190 | } 191 | 192 | var sessionResponse Tr_ResponseStruct 193 | if err := json.Unmarshal(sessionResponseBody, &sessionResponse); err != nil { 194 | Log("SubmitBlockPeer", GetLangText("Error-Parse"), true, err.Error()) 195 | return false 196 | } 197 | 198 | if sessionResponse.Result != "success" { 199 | Log("SubmitBlockPeer", GetLangText("Error-Parse"), true, sessionResponse.Result) 200 | return false 201 | } 202 | 203 | blocklistUpdateJSON, err := json.Marshal(Tr_RequestStruct{Method: "blocklist-update"}) 204 | if err != nil { 205 | Log("SubmitBlockPeer", GetLangText("Error-GenJSON"), true, err.Error()) 206 | return false 207 | } 208 | 209 | _, _, blocklistUpdateResponseBody := Submit(config.ClientURL, string(blocklistUpdateJSON), true, true, &Tr_jsonHeader) 210 | if blocklistUpdateResponseBody == nil { 211 | Log("SubmitBlockPeer", GetLangText("Error"), true) 212 | return false 213 | } 214 | 215 | var blocklistUpdateResponse Tr_ResponseStruct 216 | if err := json.Unmarshal(blocklistUpdateResponseBody, &blocklistUpdateResponse); err != nil { 217 | Log("SubmitBlockPeer", GetLangText("Error-Parse"), true, err.Error()) 218 | return false 219 | } 220 | 221 | if blocklistUpdateResponse.Result != "success" { 222 | Log("SubmitBlockPeer", GetLangText("Error-Parse"), true, blocklistUpdateResponse.Result) 223 | return false 224 | } 225 | 226 | Tr_RestartTorrentByMap(blockPeerMap) 227 | 228 | return true 229 | } 230 | -------------------------------------------------------------------------------- /lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "ProgramVersion": "Program version", 3 | "ConfigPath": "Config path", 4 | "DebugMode": "Debug mode", 5 | "StartDelay": "Start delay", 6 | "NoChdir": "Don't change working directory", 7 | "RegHotkey": "Register window hotkey", 8 | "HideWindow": "Hide window by default (Only Windows)", 9 | "HideSystray": "Hide systray by default (Only Windows)", 10 | "RunConsole_StartDelay": "Start delay: %d Sec", 11 | "RunConsole_ProgramHasStarted": "Program has started", 12 | "LoadInitConfig_DetectClientFailed": "Detect client failed", 13 | "LoadInitConfig_AuthFailed": "Authentication failed", 14 | "Task_BanInfo": "Ban Client (This time): %d, Ban Client (Current): %d", 15 | "Task_BanInfoWithIP": "Ban Client (This time): %d, Ban IP (This time): %d, Ban IP/Client (Current): %d", 16 | "GC_IPMap": "Trigger GC (ipMap): %d", 17 | "GC_TorrentMap": "Trigger GC (torrentMap): %s/%d", 18 | "ReqStop_Stoping": "Program is stopping..", 19 | "Stop_CaughtPanic": "Caught program panic: %s", 20 | "Stop_StacktraceWhenPanic": "Stacktrace when panic: %s", 21 | "GetClientConfig_UseConfig": "Use client config: %s", 22 | "LoadLog_HotReload": "Log directory change found, hot reload in progress (%s)", 23 | "SyncWithServer_Compile-BlockByReason": "Ban reason: %s", 24 | "CheckUpdate-ShowVersion": "Current version: %s, Latest version: %s, Latest version (Beta): %s", 25 | "CheckUpdate-DetectNewVersion": "Detected new version: %s, you can manual update it manually by visit %s, update content are below: \n%s", 26 | "CheckUpdate-DetectNewBetaVersion": "Detected new version (Beta): %s, you can update it manually by visit %s, update content are below: \n%s", 27 | "CheckUpdate-Ignore_UnknownVersion": "Skip auto check update: Unknwon version", 28 | "CheckUpdate-Ignore_NightlyVersion": "Skip auto check update: Nightly version", 29 | "CheckUpdate-Ignore_BadVersion": "Skip auto check update: Error version %s", 30 | "GetProxy_ProxyFound": "%s proxy found: %s (Source: %s)", 31 | "GetProxy_ProxyNotFound": "%s proxy not found", 32 | "GetProxy_UseEnvVar": "The current platform sets the proxy through environment variables. If you want to use it, please make sure that the environment variables are set correctly", 33 | "ClientQB_Detect-OldClientURL": "Detected ClientURL (Web UI), automatically changed to ClientURL (Web API): %s", 34 | "Debug-LoadConfig_HotReload": "Config (%s) change found, hot reload in progress", 35 | "Debug-Request_NoChange": "The requested URL (%s) has not changed", 36 | "Debug-Request_NoContent": "The requested URL (%s) did not return any content", 37 | "Debug-SetBlockListFromFile_HotReload": "BlockListFile (%s) change found, hot reload in progress", 38 | "Debug-SetIPBlockListFromFile_HotReload": "IPBlockListFile (%s) change found, hot reload in progress", 39 | "Debug-ShowOrHiddenWindow_HideWindow": "Hide window", 40 | "Debug-ShowOrHiddenWindow_ShowWindow": "Show window", 41 | "Debug-RestartTorrentByMap_Wait": "Wait interval before resuming torrent: %d Sec", 42 | "Abandon-SetURL": "Abandon reading client config file (WebUIEnabled: %t, Address: %s)", 43 | "Error": "An error has occurred", 44 | "Error-LoadLang": "An error occurred while loading language file: %s", 45 | "Error-ReadLang": "An error occurred while reading language file: %s|%s", 46 | "Error-ParseLang": "An error occurred while parsing language file: %s|%s", 47 | "Error-RegHotkey": "An error occurred while registering window hotkey: %s", 48 | "Error-DetectProgramPath": "An error occurred while detecting program execution path: %s", 49 | "Error-LoadConfigMeta": "An error occurred while loading config (%s) metadata: %s", 50 | "Error-LoadConfig": "An error occurred while loading config (%s): %s", 51 | "Error-ParseConfig": "An error occurred while parsing config (%s): %s", 52 | "Error-LoadFile": "An error occurred while parsing file (%s): %s", 53 | "Error-GetClientConfig_LoadConfig": "An error occurred while loading client config file: %s", 54 | "Error-GetClientConfig_LoadConfigMeta": "An error occurred while reading client config file: %s", 55 | "Error-SetBlocklistFromContent_Compile": ":%d regexp %s has error (Source: %s)", 56 | "Error-SetIPBlockListFromContent_Compile": ":%d IP %s has error (Source: %s)", 57 | "Error-SyncWithServer_Compile": ":%d IP %s has error", 58 | "Error-RestartTorrentByMap_Stop": "An error occurred while stop torrent: %s", 59 | "Error-RestartTorrentByMap_Start": "An error occurred while start torrent: %s", 60 | "Error-LargeFile": "An error occurred while parsing: Target size is greater than 8MB", 61 | "Error-NewRequest": "An error occurred while requesting: %s", 62 | "Error-FetchResponse": "An error occurred while fetching: %s", 63 | "Error-FetchResponse2": "An error occurred while fetching", 64 | "Error-ReadResponse": "An error occurred while reading: %s", 65 | "Error-NoAuth": "An error occurred while requesting: Authentication failed", 66 | "Error-Forbidden": "An error occurred while requesting: Forbidden", 67 | "Error-NotFound": "An error occurred while requesting: Resource not found", 68 | "Error-UnknownStatusCode": "An error occurred while requesting: Unknown status code %d", 69 | "Error-Parse": "An error occurred while parsing: %s", 70 | "Error-Login": "An error occurred while logging in", 71 | "Error-FetchUpdate": "An error occurred while fetching update", 72 | "Error-GenJSON": "An error occurred while generate JSON: %s", 73 | "Error-GenJSONWithID": "An error occurred while generate JSON (%s): %s", 74 | "Error-Log_Write": "An error occurred while writing to log: %s", 75 | "Error-IPFilter_Write": "An error occurred while writing to IPFilter: %s", 76 | "Error-LoadLog_Mkdir": "An error occurred while creating log directory: %s", 77 | "Error-LoadLog_Close": "An error occurred while closing log: %s", 78 | "Error-RegexpMatchErr": "An error occurred while matching regexp: %s", 79 | "Error-Task_EmptyURL": "Detected ClientURL is empty, may not be configured and failed to automatically read client config file", 80 | "Error-Task_NotSupportClient": "Detected unsupport client, may not be configured and failed to automatically detect client type: %s", 81 | "Error-SyncWithServer_ServerError": "Sync server error: %s", 82 | "Error-Debug-EmptyLine": ":%d is empty", 83 | "Error-Debug-EmptyLineWithSource": ":%d is empty (%s)", 84 | "Error-Debug-GetClientConfigPath_GetUserHomeDir": "An error occurred while retrieving the User Home directory: %s", 85 | "Error-Debug-GetClientConfigPath_GetUserConfigDir": "An error occurred while retrieving the User Config directory: %s", 86 | "Failed-LoadInitConfig": "Failed to read config file or config file is incomplete", 87 | "Failed-ChangeWorkingDir": "Failed to change working directory: %s", 88 | "Failed-Login_BadUsernameOrPassword": "Login failed: Wrong username or password", 89 | "Failed-Login_Other": "Login failed: %s", 90 | "Failed-ExecCommand": "Exec command failed, output: %s, error: %s", 91 | "Success-RegHotkey": "Registered and started listening for window hotkey: CTRL+ALT+B", 92 | "Success-ChangeWorkingDir": "Change working directory: %s", 93 | "Success-LoadConfig": "Loading config (%s) successfully", 94 | "Success-SetCSRFToken": "Set CSRF Token successfully: %s", 95 | "Success-SetURL": "Read client config file successfully (WebUIEnabled: %t, URL: %s, Username: %s)", 96 | "Success-GenIPFilter": "%d IP rules are generate", 97 | "Success-SetBlocklistFromURL": "This time %d regexp rules are set (Source: BlockListURL)", 98 | "Success-SetIPBlockListFromURL": "This time %d IP rules are set (Source: IPBlockListURL)", 99 | "Success-SetBlockListFromFile": "This time %d regexp rules are set (Source: BlockListFile)", 100 | "Success-SetIPBlockListFromFile": "This time %d IP rules are set (Source: IPBlockListFile)", 101 | "Success-DetectClient": "Detect client type successful: %s", 102 | "Success-Login": "Login successful", 103 | "Success-ClearBlockPeer": "Cleaned up expired client: %d", 104 | "Success-ExecCommand": "Exec command success, output: %s", 105 | "Success-SyncWithServer": "Sync with server success", 106 | 107 | // Part ShadowBan. 108 | "Warning-ShadowBanAPINotExist": "ShadowBan API not detected, and the normal method will be used", 109 | "Warning-ShadowBanAPIDisabled": "ShadowBan API is disabled, and the normal method will be used", 110 | "Failed-UnknownShadowBanAPI": "Detected unknown ShadowBan API, and the normal method will be used", 111 | "Failed-GetQBPreferences": "Failed to get qBittorrent preferences" 112 | } 113 | -------------------------------------------------------------------------------- /torrent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type TorrentInfoStruct struct { 10 | Size int64 11 | Peers map[string]PeerInfoStruct 12 | } 13 | type PeerInfoStruct struct { 14 | Net *net.IPNet 15 | Port map[int]bool 16 | Progress float64 17 | Downloaded int64 18 | Uploaded int64 19 | } 20 | 21 | var torrentMap = make(map[string]TorrentInfoStruct) 22 | var lastTorrentMap = make(map[string]TorrentInfoStruct) 23 | var lastTorrentCleanTimestamp int64 = 0 24 | 25 | func AddTorrentInfo(torrentInfoHash string, torrentTotalSize int64, cidr *net.IPNet, peerIP string, peerPort int, peerProgress float64, peerUploaded int64) { 26 | if !((config.IPUploadedCheck && config.IPUpCheckPerTorrentRatio > 0) || config.BanByRelativeProgressUploaded || config.SyncServerURL != "") { 27 | return 28 | } 29 | 30 | var peers map[string]PeerInfoStruct 31 | var peerPortMap map[int]bool 32 | if torrentInfo, exist := torrentMap[torrentInfoHash]; !exist { 33 | peers = make(map[string]PeerInfoStruct) 34 | peerPortMap = make(map[int]bool) 35 | } else { 36 | peers = torrentInfo.Peers 37 | if peerInfo, exist := peers[peerIP]; !exist { 38 | peerPortMap = make(map[int]bool) 39 | } else { 40 | peerPortMap = peerInfo.Port 41 | 42 | // 防止 Peer 在周期内以重新连接的方式清空实际上传量. 43 | if peerInfo.Uploaded > peerUploaded { 44 | peerUploaded += peerInfo.Uploaded 45 | } 46 | } 47 | } 48 | peerPortMap[peerPort] = true 49 | 50 | peers[peerIP] = PeerInfoStruct{Net: cidr, Port: peerPortMap, Progress: peerProgress, Uploaded: peerUploaded} 51 | torrentMap[torrentInfoHash] = TorrentInfoStruct{Size: torrentTotalSize, Peers: peers} 52 | } 53 | func IsProgressNotMatchUploaded(torrentTotalSize int64, clientProgress float64, clientUploaded int64) bool { 54 | if config.BanByProgressUploaded && torrentTotalSize > 0 && clientProgress >= 0 && clientUploaded > 0 { 55 | /* 56 | 条件 1. 若客户端对 Peer 上传已大于等于 Torrnet 大小的 2%; 57 | 条件 2. 但 Peer 报告进度乘以下载量再乘以一定防误判倍率, 却比客户端上传量还小; 58 | 若满足以上条件, 则认为 Peer 是有问题的. 59 | e.g.: 60 | 若 torrentTotalSize: 100GB, clientProgress: 1% (0.01), clientUploaded: 6GB, config.BanByPUStartPrecent: 2 (0.02), config.BanByPUAntiErrorRatio: 5; 61 | 判断条件 1: 62 | torrentTotalSize * config.BanByPUStartPrecent = 100GB * 0.02 = 2GB, clientUploaded = 6GB >= 2GB 63 | 满足此条件; 64 | 判断条件 2: 65 | torrentTotalSize * clientProgress * config.BanByPUAntiErrorRatio = 100GB * 0.01 * 5 = 5GB, 5GB < clientUploaded = 6GB 66 | 满足此条件; 67 | 则该 Peer 将被封禁, 由于其报告进度为 1%, 算入 config.BanByPUAntiErrorRatio 滞后防误判倍率后为 5% (5GB), 但客户端实际却已上传 6GB. 68 | */ 69 | startUploaded := (float64(torrentTotalSize) * (config.BanByPUStartPrecent / 100)) 70 | peerReportDownloaded := (float64(torrentTotalSize) * clientProgress) 71 | if (clientUploaded/1024/1024) >= int64(config.BanByPUStartMB) && float64(clientUploaded) >= startUploaded && (peerReportDownloaded*config.BanByPUAntiErrorRatio) < float64(clientUploaded) { 72 | return true 73 | } 74 | } 75 | return false 76 | } 77 | func IsProgressNotMatchUploaded_Relative(torrentTotalSize int64, peerInfo PeerInfoStruct, lastPeerInfo PeerInfoStruct) int64 { 78 | // 若客户端对 Peer 上传已大于 0, 且相对上传量大于起始上传量, 则继续判断. 79 | var relativeUploaded int64 = (peerInfo.Uploaded - lastPeerInfo.Uploaded) 80 | 81 | if torrentTotalSize > 0 && peerInfo.Uploaded > 0 && (float64(relativeUploaded)/1024/1024) > float64(config.BanByRelativePUStartMB) { 82 | relativeUploadedPrecent := (1 - (float64(lastPeerInfo.Uploaded) / float64(peerInfo.Uploaded))) 83 | // 若相对上传百分比大于起始百分比, 则继续判断. 84 | if relativeUploadedPrecent > (config.BanByRelativePUStartPrecent / 100) { 85 | // 若相对上传百分比大于 Peer 报告进度乘以一定防误判倍率, 则认为 Peer 是有问题的. 86 | var peerReportProgress float64 = 0 87 | if peerInfo.Progress > 0 { 88 | peerReportProgress = (1 - (lastPeerInfo.Progress / peerInfo.Progress)) 89 | } 90 | if relativeUploadedPrecent > (peerReportProgress * config.BanByRelativePUAntiErrorRatio) { 91 | return relativeUploaded 92 | } 93 | } 94 | } 95 | return 0 96 | } 97 | func CheckAllTorrent(torrentMap map[string]TorrentInfoStruct, lastTorrentMap map[string]TorrentInfoStruct) (int, int) { 98 | if ((config.IPUploadedCheck && config.IPUpCheckPerTorrentRatio > 0) || config.BanByRelativeProgressUploaded) && len(lastTorrentMap) > 0 && currentTimestamp > (lastTorrentCleanTimestamp+int64(config.TorrentMapCleanInterval)) { 99 | blockCount := 0 100 | ipBlockCount := 0 101 | 102 | for torrentInfoHash, torrentInfo := range torrentMap { 103 | for peerIP, peerInfo := range torrentInfo.Peers { 104 | if IsBlockedPeer(peerIP, -1, true) { 105 | continue 106 | } 107 | 108 | if config.IPUploadedCheck && config.IPUpCheckPerTorrentRatio > 0 { 109 | if float64(peerInfo.Uploaded) > (float64(torrentInfo.Size) * peerInfo.Progress * config.IPUpCheckPerTorrentRatio) { 110 | Log("CheckAllTorrent_AddBlockPeer (Torrent-Too high uploaded)", "%s (Uploaded: %.2f MB)", true, peerIP, (float64(peerInfo.Uploaded) / 1024 / 1024)) 111 | ipBlockCount++ 112 | AddBlockPeer("CheckAllTorrent", "Torrent-Too high uploaded", peerIP, -1, torrentInfoHash) 113 | AddBlockCIDR(peerIP, peerInfo.Net) 114 | continue 115 | } 116 | } 117 | 118 | if config.BanByRelativeProgressUploaded { 119 | if lastPeerInfo, exist := lastTorrentMap[torrentInfoHash].Peers[peerIP]; exist { 120 | if uploadDuring := IsProgressNotMatchUploaded_Relative(torrentInfo.Size, peerInfo, lastPeerInfo); uploadDuring > 0 { 121 | for port := range peerInfo.Port { 122 | if IsBlockedPeer(peerIP, port, true) { 123 | continue 124 | } 125 | Log("CheckAllTorrent_AddBlockPeer (Bad-Relative_Progress_Uploaded)", "%s:%d (UploadDuring: %.2f MB)", true, peerIP, port, uploadDuring) 126 | blockCount++ 127 | AddBlockPeer("CheckAllTorrent", "Bad-Relative_Progress_Uploaded", peerIP, port, torrentInfoHash) 128 | AddBlockCIDR(peerIP, peerInfo.Net) 129 | } 130 | continue 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | lastTorrentCleanTimestamp = currentTimestamp 138 | DeepCopyTorrentMap(torrentMap, lastTorrentMap) 139 | 140 | return blockCount, ipBlockCount 141 | } 142 | 143 | return 0, 0 144 | } 145 | func CheckTorrent(torrentInfoHash string, torrentTracker string, torrentLeecherCount int64, torrentPeers interface{}) (int, interface{}) { 146 | if torrentInfoHash == "" { 147 | return -1, nil 148 | } 149 | 150 | if config.IgnorePTTorrent && torrentTracker != "" { 151 | if torrentTracker == "Private" { 152 | return -4, nil 153 | } 154 | 155 | lowerTorrentTracker := strings.ToLower(torrentTracker) 156 | if strings.Contains(lowerTorrentTracker, "?passkey=") || strings.Contains(lowerTorrentTracker, "?authkey=") || strings.Contains(lowerTorrentTracker, "?secure=") { 157 | return -4, nil 158 | } 159 | 160 | randomStrMatched, err := randomStrRegexp.MatchString(lowerTorrentTracker) 161 | if err != nil { 162 | Log("CheckTorrent_MatchTracker", GetLangText("Error-MatchRegexpErr"), true, err.Error()) 163 | } else if randomStrMatched { 164 | return -4, nil 165 | } 166 | } 167 | 168 | if config.IgnoreNoLeechersTorrent && torrentLeecherCount <= 0 { 169 | return -2, nil 170 | } 171 | 172 | if torrentPeers != nil { 173 | return 0, torrentPeers 174 | } 175 | 176 | torrentPeers = FetchTorrentPeers(torrentInfoHash) 177 | if torrentPeers == nil { 178 | return -3, nil 179 | } 180 | 181 | return 0, torrentPeers 182 | } 183 | func ProcessTorrent(torrentInfoHash string, torrentTracker string, torrentLeecherCount int64, torrentTotalSize int64, torrentPeers interface{}, emptyHashCount *int, noLeechersCount *int, badTorrentInfoCount *int, ptTorrentCount *int, blockCount *int, ipBlockCount *int, badPeersCount *int, emptyPeersCount *int) { 184 | torrentInfoHash = strings.ToLower(torrentInfoHash) 185 | torrentStatus, torrentPeersStruct := CheckTorrent(torrentInfoHash, torrentTracker, torrentLeecherCount, torrentPeers) 186 | if config.Debug_CheckTorrent { 187 | Log("Debug-CheckTorrent", "%s (Status: %d)", false, torrentInfoHash, torrentStatus) 188 | } 189 | 190 | skipSleep := false 191 | 192 | switch torrentStatus { 193 | case -1: 194 | skipSleep = true 195 | *emptyHashCount++ 196 | case -2: 197 | skipSleep = true 198 | *noLeechersCount++ 199 | case -3: 200 | *badTorrentInfoCount++ 201 | case -4: 202 | skipSleep = true 203 | *ptTorrentCount++ 204 | case 0: 205 | switch currentClientType { 206 | case "qBittorrent": 207 | torrentPeers := torrentPeersStruct.(*qB_TorrentPeersStruct).Peers 208 | for _, peer := range torrentPeers { 209 | ProcessPeer(peer.IP, peer.Port, peer.PeerID, peer.Client, peer.DlSpeed, peer.UpSpeed, peer.Progress, peer.Downloaded, peer.Uploaded, torrentInfoHash, torrentTotalSize, blockCount, ipBlockCount, badPeersCount, emptyPeersCount) 210 | } 211 | case "Transmission": 212 | torrentPeers := torrentPeersStruct.([]Tr_PeerStruct) 213 | for _, peer := range torrentPeers { 214 | // Transmission 目前似乎并不提供 Peer 的 PeerID 及 Downloaded/Uploaded, 因此使用无效值取代. 215 | ProcessPeer(peer.IP, peer.Port, "", peer.Client, peer.DlSpeed, peer.UpSpeed, peer.Progress, -1, -1, torrentInfoHash, torrentTotalSize, blockCount, ipBlockCount, badPeersCount, emptyPeersCount) 216 | } 217 | case "BitComet": 218 | torrentPeers := torrentPeersStruct.(*[]BC_PeerStruct) 219 | for _, peer := range *torrentPeers { 220 | // BitComet 目前不为其支持 PeerID, 因此使用无效值取代. 221 | ProcessPeer(peer.IP, peer.Port, "", peer.Client, peer.DlSpeed, peer.UpSpeed, peer.Progress, peer.Downloaded, peer.Uploaded, torrentInfoHash, torrentTotalSize, blockCount, ipBlockCount, badPeersCount, emptyPeersCount) 222 | } 223 | } 224 | } 225 | 226 | if !skipSleep && config.SleepTime != 0 { 227 | time.Sleep(time.Duration(config.SleepTime) * time.Millisecond) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /i18n.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/Xuanwo/go-locale" 8 | "github.com/tidwall/jsonc" 9 | ) 10 | 11 | var langContent map[string]string 12 | var defaultLangContent = map[string]string{ 13 | "ProgramVersion": "程序版本", 14 | "ConfigPath": "配置文件路径", 15 | "AdditionalConfigPath": "附加配置文件路径", 16 | "DebugMode": "调试模式", 17 | "StartDelay": "启动延迟", 18 | "NoChdir": "不切换工作目录", 19 | "RegHotkey": "注册窗口热键", 20 | "HideWindow": "默认隐藏窗口 (仅 Windows)", 21 | "HideSystray": "默认隐藏托盘图标 (仅 Windows)", 22 | "RunConsole_StartDelay": "启动延迟: %d 秒", 23 | "RunConsole_ProgramHasStarted": "程序已启动", 24 | "LoadInitConfig_DetectClientFailed": "检测客户端失败", 25 | "LoadInitConfig_AuthFailed": "认证失败", 26 | "Task_BanInfo": "此次封禁客户端: %d 个, 当前封禁客户端: %d 个", 27 | "Task_BanInfoWithIP": "此次封禁客户端: %d 个, 此次封禁 IP: %d 个, 当前封禁 IP 及客户端: %d 个", 28 | "GC_IPMap": "触发垃圾回收 (ipMap): %d", 29 | "GC_TorrentMap": "触发垃圾回收 (torrentMap): %s/%d", 30 | "ReqStop_Stoping": "程序正在停止..", 31 | "Stop_CaughtPanic": "捕获到程序崩溃: %s", 32 | "Stop_StacktraceWhenPanic": "崩溃时的堆栈信息: %s", 33 | "GetClientConfig_UseConfig": "使用客户端配置文件: %s", 34 | "LoadLog_HotReload": "发现日志目录更改, 正在进行热重载 (%s)", 35 | "SyncWithServer_Compile-BlockByReason": "封禁原因: %s", 36 | "CheckUpdate-ShowVersion": "当前版本: %s, 最新版本: %s, 最新版本 (Beta): %s", 37 | "CheckUpdate-DetectNewVersion": "检测到新的版本: %s, 可访问 %s 手动进行更新, 更新内容如下: \n%s", 38 | "CheckUpdate-DetectNewBetaVersion": "检测到新的版本 (Beta): %s, 可访问 %s 手动进行更新, 更新内容如下: \n%s", 39 | "CheckUpdate-Ignore_UnknownVersion": "跳过自动检查更新: 未知版本", 40 | "CheckUpdate-Ignore_NightlyVersion": "跳过自动检查更新: 夜间构建版本", 41 | "CheckUpdate-Ignore_BadVersion": "跳过自动检查更新: 错误版本 %s", 42 | "GetProxy_ProxyFound": "发现 %s 代理: %s (来源: %s)", 43 | "GetProxy_ProxyNotFound": "未能发现 %s 代理", 44 | "GetProxy_UseEnvVar": "当前平台通过环境变量设置代理, 若要使用, 请确保已正确设置环境变量", 45 | "ClientQB_Detect-OldClientURL": "检测到 ClientURL (Web UI), 已自动修改至 ClientURL (Web API): %s", 46 | "Debug-LoadConfig_HotReload": "发现配置文件 (%s) 更改, 正在进行热重载", 47 | "Debug-Request_NoChange": "请求的 URL (%s) 没有发生改变", 48 | "Debug-Request_NoContent": "请求的 URL (%s) 没有返回内容", 49 | "Debug-SetBlockListFromFile_HotReload": "发现 BlockListFile (%s) 更改, 正在进行热重载", 50 | "Debug-SetIPBlockListFromFile_HotReload": "发现 IPBlockListFile (%s) 更改, 正在进行热重载", 51 | "Debug-ShowOrHiddenWindow_HideWindow": "窗口隐藏", 52 | "Debug-ShowOrHiddenWindow_ShowWindow": "窗口显示", 53 | "Debug-RestartTorrentByMap_Wait": "重新开始 Torrent 前的等待间隔: %d 秒", 54 | "Abandon-SetURL": "放弃读取客户端配置文件 (WebUIEnabled: %t, Address: %s)", 55 | "Error": "发生错误", 56 | "Error-LoadLang": "加载语言文件时发生了错误: %s", 57 | "Error-ReadLang": "读取语言文件时发生了错误: %s|%s", 58 | "Error-ParseLang": "解析语言文件时发生了错误: %s|%s", 59 | "Error-RegHotkey": "注册窗口热键时发生错误: %v", 60 | "Error-DetectProgramPath": "检测程序运行路径时发生了错误: %s", 61 | "Error-LoadConfigMeta": "加载配置文件 (%s) 元数据时发生了错误: %s", 62 | "Error-LoadConfig": "加载配置文件 (%s) 时发生了错误: %s", 63 | "Error-ParseConfig": "解析配置文件 (%s) 时发生了错误: %s", 64 | "Error-LoadFile": "加载文件 (%s) 时发生了错误: %s", 65 | "Error-GetClientConfig_LoadConfig": "加载客户端配置文件时发生了错误: %s", 66 | "Error-GetClientConfig_LoadConfigMeta": "读取客户端配置文件元数据时发生了错误: %s", 67 | "Error-SetBlockListFromContent_Compile": ":%d 表达式 %s 有错误 (来源: %s)", 68 | "Error-SetIPBlockListFromContent_Compile": ":%d IP %s 有错误 (来源: %s)", 69 | "Error-SyncWithServer_Compile": ":%d IP %s 有错误", 70 | "Error-RestartTorrentByMap_Stop": "停止 Torrent 时发生了错误: %s", 71 | "Error-RestartTorrentByMap_Start": "开始 Torrent 时发生了错误: %s", 72 | "Error-LargeFile": "解析时发生了错误: 目标大小大于 8MB", 73 | "Error-NewRequest": "请求时发生了错误: %s", 74 | "Error-FetchResponse": "获取时发生了错误: %s", 75 | "Error-FetchResponse2": "获取时发生了错误", 76 | "Error-ReadResponse": "读取时发生了错误: %s", 77 | "Error-NoAuth": "请求时发生了错误: 认证失败", 78 | "Error-Forbidden": "请求时发生了错误: 禁止访问", 79 | "Error-NotFound": "请求时发生了错误: 资源不存在", 80 | "Error-UnknownStatusCode": "请求时发生了错误: 未知状态码 %d", 81 | "Error-Parse": "解析时发生了错误: %s", 82 | "Error-Login": "登录时发生了错误", 83 | "Error-FetchUpdate": "获取更新时发生了错误", 84 | "Error-GenJSON": "构造 JSON 时发生了错误: %s", 85 | "Error-GenJSONWithID": "构造 JSON (%s) 时发生了错误: %s", 86 | "Error-Log_Write": "写入日志时发生了错误: %s", 87 | "Error-IPFilter_Write": "写入 IPFilter 时发生了错误: %s", 88 | "Error-LoadLog_Mkdir": "创建日志目录时发生了错误: %s", 89 | "Error-LoadLog_Close": "关闭日志时发生了错误: %s", 90 | "Error-MatchRegexpErr": "正则匹配过程中发生了错误: %s", 91 | "Error-Task_EmptyURL": "检测到 URL 为空, 可能是未配置且未能自动读取客户端配置文件", 92 | "Error-Task_NotSupportClient": "检测到不支持的客户端, 可能是未配置且未能自动检测客户端: %s", 93 | "Error-SyncWithServer_ServerError": "同步服务器错误: %s", 94 | "Error-Debug-EmptyLine": ":%d 为空", 95 | "Error-Debug-EmptyLineWithSource": ":%d 为空 (%s)", 96 | "Error-Debug-GetClientConfigPath_GetUserHomeDir": "获取 User Home 目录时发生了错误: %s", 97 | "Error-Debug-GetClientConfigPath_GetUserConfigDir": "获取 User Config 目录时发生了错误: %s", 98 | "Failed-LoadInitConfig": "读取配置文件失败或不完整", 99 | "Failed-ChangeWorkingDir": "切换工作目录失败: %s", 100 | "Failed-Login_BadUsernameOrPassword": "登录失败: 账号或密码错误", 101 | "Failed-Login_Other": "登录失败: %s", 102 | "Failed-ExecCommand": "执行命令失败, 输出: %s, 错误: %s", 103 | "Success-RegHotkey": "已注册并开始监听窗口热键: CTRL+ALT+B", 104 | "Success-ChangeWorkingDir": "切换工作目录: %s", 105 | "Success-LoadConfig": "加载配置文件 (%s) 成功", 106 | "Success-SetCSRFToken": "设置 CSRF Token 成功: %s", 107 | "Success-SetURL": "读取客户端配置文件成功 (WebUIEnabled: %t, URL: %s, Username: %s)", 108 | "Success-GenIPFilter": "生成了 %d 条 IP 规则", 109 | "Success-SetBlockListFromURL": "本次设置了 %d 条 表达式 规则 (来源: BlockListURL)", 110 | "Success-SetIPBlockListFromURL": "本次设置了 %d 条 IP 规则 (来源: IPBlockListURL)", 111 | "Success-SetBlockListFromFile": "本次设置了 %d 条 表达式 规则 (来源: BlockListFile)", 112 | "Success-SetIPBlockListFromFile": "本次设置了 %d 条 IP 规则 (来源: IPBlockListFile)", 113 | "Success-DetectClient": "检测客户端类型成功: %s", 114 | "Success-Login": "登录成功", 115 | "Success-ClearBlockPeer": "已清理过期客户端: %d 个", 116 | "Success-ExecCommand": "执行命令成功, 输出: %s", 117 | "Success-SyncWithServer": "成功与同步服务器同步", 118 | 119 | // Part ShadowBan. 120 | "Warning-ShadowBanAPINotExist": "未检测到 ShadowBan API, 将使用常规方法", 121 | "Warning-ShadowBanAPIDisabled": "未启用 ShadowBan API, 将使用常规方法", 122 | "Failed-UnknownShadowBanAPI": "检测到未知 Shadow Ban API, 将使用常规方法", 123 | "Failed-GetQBPreferences": "获取 qBittorrent 偏好设置失败", 124 | 125 | } 126 | 127 | func LoadLang(langCode string) bool { 128 | langPath := "lang/" + langCode + ".json" 129 | 130 | _, err := os.Stat(langPath) 131 | if err != nil { 132 | if !os.IsNotExist(err) { 133 | Log("LoadLang", GetLangText("Error-LoadLang"), false, langPath) 134 | } 135 | return false 136 | } 137 | 138 | langFile, err := os.ReadFile(langPath) 139 | if err != nil { 140 | Log("LoadLang", GetLangText("Error-ReadLang"), false, langPath, err.Error()) 141 | return false 142 | } 143 | 144 | if err := json.Unmarshal(jsonc.ToJSON(langFile), &langContent); err != nil { 145 | Log("LoadLang", GetLangText("Error-ParseLang"), false, langPath, err.Error()) 146 | return false 147 | } 148 | 149 | return true 150 | } 151 | func GetLangCode() string { 152 | langTag, err := locale.Detect() 153 | if err == nil { 154 | return langTag.String()[0:2] 155 | } 156 | 157 | return "en" 158 | } 159 | func GetLangText(uniqueID string) string { 160 | if content, exist := langContent[uniqueID]; exist { 161 | return content 162 | } 163 | 164 | if defaultContent, exist := defaultLangContent[uniqueID]; exist { 165 | return defaultContent 166 | } 167 | 168 | return uniqueID 169 | } 170 | -------------------------------------------------------------------------------- /client_qBittorrent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/url" 6 | "os" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type qB_TorrentStruct struct { 12 | InfoHash string `json:"hash"` 13 | NumLeechs int64 `json:"num_leechs"` 14 | TotalSize int64 `json:"total_size"` 15 | Tracker string `json:"tracker"` 16 | } 17 | type qB_PeerStruct struct { 18 | IP string `json:"ip"` 19 | Port int `json:"port"` 20 | Client string `json:"client"` 21 | PeerID string `json:"peer_id_client"` 22 | Progress float64 `json:"progress"` 23 | Downloaded int64 `json:"downloaded"` 24 | Uploaded int64 `json:"uploaded"` 25 | DlSpeed int64 `json:"dl_speed"` 26 | UpSpeed int64 `json:"up_speed"` 27 | } 28 | type qB_TorrentPeersStruct struct { 29 | FullUpdate bool `json:"full_update"` 30 | Peers map[string]qB_PeerStruct `json:"peers"` 31 | } 32 | 33 | var qB_useNewBanPeersMethod = false 34 | 35 | func qB_GetClientConfigPath() string { 36 | var qBConfigFilename string 37 | userHomeDir, err := os.UserHomeDir() 38 | if err != nil { 39 | Log("Debug-GetClientConfigPath", GetLangText("Error-Debug-GetClientConfigPath_GetUserHomeDir"), true, err.Error()) 40 | return "" 41 | } 42 | if IsUnix(userHomeDir) { 43 | qBConfigFilename = userHomeDir + "/.config/qBittorrent/qBittorrent.ini" 44 | } else { 45 | userConfigDir, err := os.UserConfigDir() 46 | if err != nil { 47 | Log("Debug-GetClientConfigPath", GetLangText("Error-Debug-GetClientConfigPath_GetUserConfigDir"), true, err.Error()) 48 | return "" 49 | } 50 | qBConfigFilename = userConfigDir + "\\qBittorrent\\qBittorrent.ini" 51 | } 52 | return qBConfigFilename 53 | } 54 | func qB_GetClientConfig() []byte { 55 | qBConfigFilename := qB_GetClientConfigPath() 56 | if qBConfigFilename == "" { 57 | return []byte{} 58 | } 59 | 60 | _, err := os.Stat(qBConfigFilename) 61 | if err != nil { 62 | if !os.IsNotExist(err) { 63 | // 避免反复猜测默认 qBittorrent 配置文件的失败信息影响 Debug 用户体验. 64 | Log("GetClientConfig", GetLangText("Error-GetClientConfig_LoadConfigMeta"), true, err.Error()) 65 | } 66 | return []byte{} 67 | } 68 | 69 | Log("GetClientConfig", GetLangText("GetClientConfig_UseConfig"), true, qBConfigFilename) 70 | 71 | qBConfigFile, err := os.ReadFile(qBConfigFilename) 72 | if err != nil { 73 | Log("GetClientConfig", GetLangText("Error-GetClientConfig_LoadConfig"), true, err.Error()) 74 | return []byte{} 75 | } 76 | 77 | return qBConfigFile 78 | } 79 | func qB_SetURL() bool { 80 | qBConfigFile := qB_GetClientConfig() 81 | if len(qBConfigFile) < 1 { 82 | return false 83 | } 84 | qBConfigFileArr := strings.Split(string(qBConfigFile), "\n") 85 | qBWebUIEnabled := false 86 | qBHTTPSEnabled := false 87 | qBAddress := "" 88 | qBPort := 8080 89 | Username := "" 90 | for _, qbConfigLine := range qBConfigFileArr { 91 | qbConfigLineArr := strings.SplitN(qbConfigLine, "=", 2) 92 | if len(qbConfigLineArr) < 2 || qbConfigLineArr[1] == "" { 93 | continue 94 | } 95 | qbConfigLineArr[0] = strings.ToLower(StrTrim(qbConfigLineArr[0])) 96 | qbConfigLineArr[1] = strings.ToLower(StrTrim(qbConfigLineArr[1])) 97 | switch qbConfigLineArr[0] { 98 | case "webui\\enabled": 99 | if qbConfigLineArr[1] == "true" { 100 | qBWebUIEnabled = true 101 | } 102 | case "webui\\https\\enabled": 103 | if qbConfigLineArr[1] == "true" { 104 | qBHTTPSEnabled = true 105 | } 106 | case "webui\\address": 107 | if qbConfigLineArr[1] == "*" || qbConfigLineArr[1] == "0.0.0.0" { 108 | qBAddress = "127.0.0.1" 109 | } else if qbConfigLineArr[1] == "::" || qbConfigLineArr[1] == "::1" { 110 | qBAddress = "[::1]" 111 | } else { 112 | qBAddress = qbConfigLineArr[1] 113 | } 114 | case "webui\\port": 115 | tmpQBPort, err := strconv.Atoi(qbConfigLineArr[1]) 116 | if err == nil { 117 | qBPort = tmpQBPort 118 | } 119 | case "webui\\username": 120 | Username = qbConfigLineArr[1] 121 | } 122 | } 123 | if !qBWebUIEnabled || qBAddress == "" { 124 | Log("SetURL", GetLangText("Abandon-SetURL"), true, qBWebUIEnabled, qBAddress) 125 | return false 126 | } 127 | if qBHTTPSEnabled { 128 | config.ClientURL = "https://" + qBAddress 129 | if qBPort != 443 { 130 | config.ClientURL += ":" + strconv.Itoa(qBPort) 131 | } 132 | } else { 133 | config.ClientURL = "http://" + qBAddress 134 | if qBPort != 80 { 135 | config.ClientURL += ":" + strconv.Itoa(qBPort) 136 | } 137 | } 138 | config.ClientURL += "/api" 139 | config.ClientUsername = Username 140 | Log("SetURL", GetLangText("Success-SetURL"), true, qBWebUIEnabled, config.ClientURL, config.ClientUsername) 141 | return true 142 | } 143 | func qB_GetAPIVersion() bool { 144 | if !strings.HasSuffix(config.ClientURL, "/api") { 145 | apiResponseStatusCodeWithSuffix, _, _ := Fetch(config.ClientURL+"/api/v2/app/webapiVersion", false, false, false, nil) 146 | if apiResponseStatusCodeWithSuffix == 200 || apiResponseStatusCodeWithSuffix == 403 { 147 | config.ClientURL += "/api" 148 | Log("qB_GetAPIVersion", GetLangText("ClientQB_Detect-OldClientURL"), true, config.ClientURL) 149 | return true 150 | } 151 | } 152 | 153 | apiResponseStatusCode, _, _ := Fetch(config.ClientURL+"/v2/app/webapiVersion", false, false, false, nil) 154 | return (apiResponseStatusCode == 200 || apiResponseStatusCode == 403) 155 | } 156 | func qB_Login() bool { 157 | loginParams := url.Values{} 158 | loginParams.Set("username", config.ClientUsername) 159 | loginParams.Set("password", config.ClientPassword) 160 | _, _, loginResponseBody := Submit(config.ClientURL+"/v2/auth/login", loginParams.Encode(), false, true, nil) 161 | if loginResponseBody == nil { 162 | Log("Login", GetLangText("Error-Login"), true) 163 | return false 164 | } 165 | 166 | loginResponseBodyStr := StrTrim(string(loginResponseBody)) 167 | if loginResponseBodyStr == "Ok." { 168 | Log("Login", GetLangText("Success-Login"), true) 169 | return true 170 | } else if loginResponseBodyStr == "Fails." { 171 | Log("Login", GetLangText("Failed-Login_BadUsernameOrPassword"), true) 172 | } else { 173 | Log("Login", GetLangText("Failed-Login_Other"), true, loginResponseBodyStr) 174 | } 175 | return false 176 | } 177 | func qB_FetchTorrents() *[]qB_TorrentStruct { 178 | _, _, torrentsResponseBody := Fetch(config.ClientURL+"/v2/torrents/info?filter=active", true, true, false, nil) 179 | if torrentsResponseBody == nil { 180 | Log("FetchTorrents", GetLangText("Error"), true) 181 | return nil 182 | } 183 | 184 | var torrentsResult []qB_TorrentStruct 185 | if err := json.Unmarshal(torrentsResponseBody, &torrentsResult); err != nil { 186 | Log("FetchTorrents", GetLangText("Error-Parse"), true, err.Error()) 187 | return nil 188 | } 189 | 190 | return &torrentsResult 191 | } 192 | func qB_FetchTorrentPeers(infoHash string) *qB_TorrentPeersStruct { 193 | _, _, torrentPeersResponseBody := Fetch(config.ClientURL+"/v2/sync/torrentPeers?rid=0&hash="+infoHash, true, true, false, nil) 194 | if torrentPeersResponseBody == nil { 195 | Log("FetchTorrentPeers", GetLangText("Error"), true) 196 | return nil 197 | } 198 | 199 | var torrentPeersResult qB_TorrentPeersStruct 200 | if err := json.Unmarshal(torrentPeersResponseBody, &torrentPeersResult); err != nil { 201 | Log("FetchTorrentPeers", GetLangText("Error-Parse"), true, err.Error()) 202 | return nil 203 | } 204 | 205 | /* 206 | if config.Debug_CheckTorrent { 207 | Log("Debug-FetchTorrentPeers", "完整更新: %s", false, strconv.FormatBool(torrentPeersResult.FullUpdate)) 208 | } 209 | */ 210 | 211 | return &torrentPeersResult 212 | } 213 | func qB_SubmitBlockPeer(blockPeerMap map[string]BlockPeerInfoStruct) bool { 214 | banIPPortsStr := "" 215 | 216 | if blockPeerMap != nil { 217 | if qB_useNewBanPeersMethod { 218 | for peerIP, peerInfo := range blockPeerMap { 219 | if _, exist := peerInfo.Port[-1]; config.BanAllPort || exist { 220 | for port := 0; port <= 65535; port++ { 221 | if IsIPv6(peerIP) { 222 | banIPPortsStr += "[" + peerIP + "]:" + strconv.Itoa(port) + "|" 223 | } else { 224 | banIPPortsStr += peerIP + ":" + strconv.Itoa(port) + "|" 225 | banIPPortsStr += "[::ffff:" + peerIP + "]:" + strconv.Itoa(port) + "|" 226 | } 227 | } 228 | continue 229 | } 230 | for port, _ := range peerInfo.Port { 231 | banIPPortsStr += peerIP + ":" + strconv.Itoa(port) + "|" 232 | } 233 | } 234 | banIPPortsStr = strings.TrimRight(banIPPortsStr, "|") 235 | } else { 236 | for peerIP := range blockPeerMap { 237 | banIPPortsStr += peerIP + "\n" 238 | if !IsIPv6(peerIP) { 239 | banIPPortsStr += "::ffff:" + peerIP + "\n" 240 | } 241 | } 242 | } 243 | } 244 | 245 | Log("Debug-SubmitBlockPeer", "%s", false, banIPPortsStr) 246 | 247 | var banResponseBody []byte 248 | 249 | if qB_useNewBanPeersMethod && banIPPortsStr != "" { 250 | banIPPortsStr = url.QueryEscape(banIPPortsStr) 251 | _, _, banResponseBody = Submit(config.ClientURL+"/v2/transfer/banPeers", banIPPortsStr, true, true, nil) 252 | } else { 253 | banIPPortsStr = url.QueryEscape("{\"banned_IPs\": \"" + banIPPortsStr + "\"}") 254 | _, _, banResponseBody = Submit(config.ClientURL+"/v2/app/setPreferences", "json="+banIPPortsStr, true, true, nil) 255 | } 256 | 257 | if banResponseBody == nil { 258 | Log("SubmitBlockPeer", GetLangText("Error"), true) 259 | return false 260 | } 261 | 262 | return true 263 | } 264 | 265 | func qB_GetPreferences() map[string]interface{} { 266 | _, _, responseBody := Submit(config.ClientURL+"/v2/app/preferences", "", true, true, nil) 267 | if responseBody == nil { 268 | Log("GetPreferences", GetLangText("Failed-GetQBPreferences"), true) 269 | return nil 270 | } 271 | 272 | var preferences map[string]interface{} 273 | if err := json.Unmarshal(responseBody, &preferences); err != nil { 274 | Log("GetPreferences", GetLangText("Error-Parse"), true, err.Error()) 275 | return nil 276 | } 277 | 278 | return preferences 279 | } 280 | func qB_TestShadowBanAPI() bool { 281 | // 1. Check if enable_shadowban is true; 282 | // enable_shadowban may be not exist in the preferences. 283 | pref := qB_GetPreferences() 284 | if pref == nil { 285 | return false 286 | } 287 | 288 | enableShadowBan, exist := pref["shadow_ban_enabled"] 289 | if !exist { 290 | Log("TestShadowBanAPI", GetLangText("Warning-ShadowBanAPINotExist"), true) 291 | return false 292 | } 293 | 294 | if bEnableShadowBan, ok := enableShadowBan.(bool); ok { 295 | if !bEnableShadowBan { 296 | return false 297 | } 298 | } else { 299 | Log("TestShadowBanAPI", GetLangText("Failed-UnknownShadowBanAPI"), true) 300 | return false 301 | } 302 | 303 | // 2. Check if the API is available; 304 | code, _, _ := Submit(config.ClientURL+"/v2/transfer/shadowbanPeers", "peers=", true, true, nil) 305 | if code != 200 { 306 | Log("TestShadowBanAPI", GetLangText("Warning-ShadowBanAPINotExist"), true) 307 | return false 308 | } 309 | 310 | return true 311 | } 312 | func qB_SubmitShadowBanPeer(blockPeerMap map[string]BlockPeerInfoStruct) bool { 313 | shadowBanIPPortsList := []string{} 314 | for peerIP, peerInfo := range blockPeerMap { 315 | for port := range peerInfo.Port { 316 | if port <= 0 || port > 65535 { 317 | port = 1 // Seems qBittorrent will ignore the invalid port number, so we just set it to 1. 318 | } 319 | if IsIPv6(peerIP) { 320 | shadowBanIPPortsList = append(shadowBanIPPortsList, "[" + peerIP + "]:" + strconv.Itoa(port)) 321 | } else { 322 | shadowBanIPPortsList = append(shadowBanIPPortsList, peerIP + ":" + strconv.Itoa(port)) 323 | shadowBanIPPortsList = append(shadowBanIPPortsList, "[::ffff:" + peerIP + "]:" + strconv.Itoa(port)) 324 | } 325 | } 326 | } 327 | 328 | banIPPortsStr := strings.Join(shadowBanIPPortsList, "|") 329 | Log("Debug-SubmitShadowBanPeer", "%s", false, banIPPortsStr) 330 | 331 | var banResponseBody []byte 332 | 333 | if banIPPortsStr != "" { 334 | banIPPortsStr = url.QueryEscape(banIPPortsStr) 335 | _, _, banResponseBody = Submit(config.ClientURL+"/v2/transfer/shadowbanPeers", "peers="+banIPPortsStr, true, true, nil) 336 | } else { 337 | return true 338 | } 339 | 340 | if banResponseBody == nil { 341 | Log("SubmitShadowBanPeer", GetLangText("Error"), true) 342 | return false 343 | } 344 | 345 | return true 346 | } 347 | -------------------------------------------------------------------------------- /peer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/dlclark/regexp2" 9 | ) 10 | 11 | type BlockPeerInfoStruct struct { 12 | Timestamp int64 13 | Module string 14 | Reason string 15 | Port map[int]bool 16 | InfoHash string 17 | } 18 | type BlockCIDRInfoStruct struct { 19 | Timestamp int64 20 | Net *net.IPNet 21 | IPs map[string]bool 22 | } 23 | 24 | var lastCleanTimestamp int64 = 0 25 | var blockPeerMap = make(map[string]BlockPeerInfoStruct) 26 | var blockCIDRMap = make(map[string]BlockCIDRInfoStruct) 27 | 28 | func AddBlockPeer(module string, reason string, peerIP string, peerPort int, torrentInfoHash string) { 29 | var blockPeerPortMap map[int]bool 30 | if blockPeer, exist := blockPeerMap[peerIP]; !exist { 31 | blockPeerPortMap = make(map[int]bool) 32 | } else { 33 | blockPeerPortMap = blockPeer.Port 34 | } 35 | 36 | blockPeerPortMap[peerPort] = true 37 | blockPeerMap[peerIP] = BlockPeerInfoStruct{Timestamp: currentTimestamp, Module: module, Reason: reason, Port: blockPeerPortMap, InfoHash: torrentInfoHash} 38 | 39 | AddBlockCIDR(peerIP, ParseIPCIDRByConfig(peerIP)) 40 | 41 | if config.ExecCommand_Ban != "" { 42 | execCommand_Ban := config.ExecCommand_Ban 43 | execCommand_Ban = strings.Replace(execCommand_Ban, "{peerIP}", peerIP, -1) 44 | execCommand_Ban = strings.Replace(execCommand_Ban, "{peerPort}", strconv.Itoa(peerPort), -1) 45 | execCommand_Ban = strings.Replace(execCommand_Ban, "{torrentInfoHash}", torrentInfoHash, -1) 46 | status, out, err := ExecCommand(execCommand_Ban) 47 | 48 | if status { 49 | Log("AddBlockPeer", GetLangText("Success-ExecCommand"), true, out) 50 | } else { 51 | Log("AddBlockPeer", GetLangText("Failed-ExecCommand"), true, out, err) 52 | } 53 | } 54 | } 55 | func AddBlockCIDR(peerIP string, peerNet *net.IPNet) { 56 | if peerNet == nil { 57 | return 58 | } 59 | 60 | peerNetStr := peerNet.String() 61 | var blockIPsMap map[string]bool 62 | if blockCIDRInfo, exist := blockCIDRMap[peerNetStr]; !exist { 63 | blockIPsMap = make(map[string]bool) 64 | } else { 65 | blockIPsMap = blockCIDRMap[peerNetStr].IPs 66 | if _, exist := blockCIDRInfo.IPs[peerIP]; !exist { 67 | blockIPsMap[peerIP] = true 68 | } 69 | } 70 | 71 | blockCIDRMap[peerNetStr] = BlockCIDRInfoStruct{Timestamp: currentTimestamp, Net: peerNet, IPs: blockIPsMap} 72 | } 73 | func ClearBlockPeer() int { 74 | cleanCount := 0 75 | if blockPeerMap != nil && config.CleanInterval == 0 || (lastCleanTimestamp+int64(config.CleanInterval) < currentTimestamp) { 76 | for peerIP, peerInfo := range blockPeerMap { 77 | if currentTimestamp > (peerInfo.Timestamp + int64(config.BanTime)) { 78 | cleanCount++ 79 | delete(blockPeerMap, peerIP) 80 | 81 | peerNet := ParseIPCIDRByConfig(peerIP) 82 | 83 | if peerNet != nil { 84 | peerNetStr := peerNet.String() 85 | if blockCIDRInfo, exist := blockCIDRMap[peerNetStr]; exist { 86 | if blockCIDRInfo.Timestamp > peerInfo.Timestamp { 87 | peerInfo.Timestamp = blockCIDRInfo.Timestamp 88 | blockPeerMap[peerIP] = peerInfo 89 | continue 90 | } 91 | 92 | delete(blockCIDRInfo.IPs, peerIP) 93 | if len(blockCIDRInfo.IPs) <= 0 { 94 | delete(blockCIDRMap, peerNetStr) 95 | continue 96 | } 97 | 98 | blockCIDRMap[peerNetStr] = blockCIDRInfo 99 | } 100 | } 101 | 102 | if config.ExecCommand_Unban != "" { 103 | for peerPort, _ := range peerInfo.Port { 104 | execCommand_Unban := config.ExecCommand_Unban 105 | execCommand_Unban = strings.Replace(execCommand_Unban, "{peerIP}", peerIP, -1) 106 | execCommand_Unban = strings.Replace(execCommand_Unban, "{peerPort}", strconv.Itoa(peerPort), -1) 107 | execCommand_Unban = strings.Replace(execCommand_Unban, "{torrentInfoHash}", peerInfo.InfoHash, -1) 108 | status, out, err := ExecCommand(execCommand_Unban) 109 | 110 | if status { 111 | Log("AddBlockPeer", GetLangText("Success-ExecCommand"), true, out) 112 | } else { 113 | Log("AddBlockPeer", GetLangText("Failed-ExecCommand"), true, out, err) 114 | } 115 | } 116 | } 117 | } 118 | } 119 | if cleanCount != 0 { 120 | lastCleanTimestamp = currentTimestamp 121 | Log("ClearBlockPeer", GetLangText("Success-ClearBlockPeer"), true, cleanCount) 122 | } 123 | } 124 | 125 | return cleanCount 126 | } 127 | func IsBlockedPeer(peerIP string, peerPort int, updateTimestamp bool) bool { 128 | if blockPeer, exist := blockPeerMap[peerIP]; exist { 129 | if IsBanPort() { 130 | if _, exist1 := blockPeer.Port[-1]; !exist1 { 131 | if _, exist2 := blockPeer.Port[peerPort]; !exist2 { 132 | return false 133 | } 134 | } 135 | } 136 | 137 | if updateTimestamp { 138 | blockPeer.Timestamp = currentTimestamp 139 | blockPeerMap[peerIP] = blockPeer 140 | } 141 | 142 | return true 143 | } 144 | 145 | return false 146 | } 147 | func MatchBlockList(blockRegex *regexp2.Regexp, peerIP string, peerPort int, peerID string, peerClient string) bool { 148 | if blockRegex != nil { 149 | if peerClient != "" { 150 | isMatchPeerClient, err := blockRegex.MatchString(peerClient) 151 | 152 | if err != nil { 153 | Log("MatchBlockList_PeerClient", GetLangText("Error-MatchRegexpErr"), true, err.Error()) 154 | } else if isMatchPeerClient { 155 | return true 156 | } 157 | } 158 | 159 | if peerID != "" { 160 | isMatchPeerID, err := blockRegex.MatchString(peerID) 161 | 162 | if err != nil { 163 | Log("MatchBlockList_PeerID", GetLangText("Error-MatchRegexpErr"), true, err.Error()) 164 | } else if isMatchPeerID { 165 | return true 166 | } 167 | } 168 | } 169 | 170 | return false 171 | } 172 | func CheckPeer(peerIP string, peerPort int, peerID, peerClient string, peerDlSpeed, peerUpSpeed int64, peerProgress float64, peerDownloaded, peerUploaded int64, torrentInfoHash string, torrentTotalSize int64) (int, *net.IPNet) { 173 | if peerIP == "" || CheckPrivateIP(peerIP) { 174 | return -1, nil 175 | } 176 | 177 | if IsBlockedPeer(peerIP, peerPort, true) { 178 | Log("Debug-CheckPeer_IgnorePeer (Blocked)", "%s:%d %s|%s", false, peerIP, peerPort, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient)) 179 | /* 180 | if peerPort == -2 { 181 | return 4 182 | } 183 | */ 184 | if peerPort == -1 { 185 | return 3, nil 186 | } 187 | return 2, nil 188 | } 189 | 190 | peerNet := ParseIPCIDRByConfig(peerIP) 191 | hasPeerClient := (peerID != "" || peerClient != "") 192 | 193 | if hasPeerClient { 194 | earlyStop := false 195 | blockListCompiled.Range(func(key, val any) bool { 196 | if MatchBlockList(val.(*regexp2.Regexp), peerIP, peerPort, peerID, peerClient) { 197 | Log("CheckPeer_AddBlockPeer (Bad-Client_Normal)", "%s:%d %s|%s (TorrentInfoHash: %s)", true, peerIP, peerPort, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash) 198 | AddBlockPeer("CheckPeer", "Bad-Client_Normal", peerIP, peerPort, torrentInfoHash) 199 | earlyStop = true 200 | return false 201 | } 202 | return true 203 | }) 204 | 205 | if earlyStop { 206 | return 1, peerNet 207 | } 208 | } 209 | 210 | for port := range config.PortBlockList { 211 | if port == peerPort { 212 | Log("CheckPeer_AddBlockPeer (Bad-Port)", "%s:%d %s|%s (TorrentInfoHash: %s)", true, peerIP, peerPort, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash) 213 | AddBlockPeer("CheckPeer", "Bad-Port", peerIP, peerPort, torrentInfoHash) 214 | return 1, peerNet 215 | } 216 | } 217 | 218 | ip := net.ParseIP(peerIP) 219 | if ip == nil { 220 | Log("Debug-CheckPeer_AddBlockPeer (Bad-IP)", "%s:%d %s|%s (TorrentInfoHash: %s)", false, peerIP, -1, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash) 221 | } else { 222 | earlyStop := false 223 | ipBlockListCompiled.Range(func(_, v any) bool { 224 | if v == nil { 225 | return true 226 | } 227 | 228 | ipNet, ok := (v).(*net.IPNet) 229 | if !ok { 230 | return true 231 | } 232 | if ipNet.Contains(ip) { 233 | Log("CheckPeer_AddBlockPeer (Bad-IP_Normal)", "%s:%d %s|%s (TorrentInfoHash: %s)", true, peerIP, -1, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash) 234 | AddBlockPeer("CheckPeer", "Bad-IP_Normal", peerIP, -1, torrentInfoHash) 235 | earlyStop = true 236 | return false 237 | } 238 | 239 | return true 240 | }) 241 | if earlyStop { 242 | return 3, peerNet 243 | } 244 | 245 | for _, v := range ipBlockCIDRMapFromSyncServerCompiled { 246 | if v == nil { 247 | continue 248 | } 249 | if v.Contains(ip) { 250 | Log("CheckPeer_AddBlockPeer (Bad-IP_FromSyncServer)", "%s:%d %s|%s (TorrentInfoHash: %s)", true, peerIP, -1, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash) 251 | AddBlockPeer("CheckPeer", "Bad-IP_FromSyncServer", peerIP, -1, torrentInfoHash) 252 | return 3, peerNet 253 | } 254 | } 255 | } 256 | 257 | if IsMatchCIDR(peerNet) { 258 | Log("CheckPeer_AddBlockPeer (Bad-CIDR)", "%s:%d %s|%s (TorrentInfoHash: %s, PeerNet: %s)", true, peerIP, peerPort, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash, peerNet.String()) 259 | AddBlockPeer("CheckPeer", "Bad-CIDR", peerIP, peerPort, torrentInfoHash) 260 | return 1, peerNet 261 | } 262 | 263 | if peerDlSpeed <= 0 && peerUpSpeed <= 0 { 264 | return -2, peerNet 265 | } 266 | 267 | ignoreByDownloaded := false 268 | // 若启用忽略且遇到空信息 Peer, 则既不会启用绝对进度屏蔽, 也不会记录 IP 及 Torrent 信息. 269 | if !config.IgnoreEmptyPeer || hasPeerClient { 270 | if config.IgnoreByDownloaded > 0 && (peerDownloaded/1024/1024) >= int64(config.IgnoreByDownloaded) { 271 | ignoreByDownloaded = true 272 | } 273 | if !ignoreByDownloaded && IsProgressNotMatchUploaded(torrentTotalSize, peerProgress, peerUploaded) { 274 | Log("CheckPeer_AddBlockPeer (Bad-Progress_Uploaded)", "%s:%d %s|%s (TorrentInfoHash: %s, TorrentTotalSize: %.2f MB, PeerDlSpeed: %.2f MB/s, PeerUpSpeed: %.2f MB/s, Progress: %.2f%%, Downloaded: %.2f MB, Uploaded: %.2f MB)", true, peerIP, peerPort, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash, (float64(torrentTotalSize) / 1024 / 1024), (float64(peerDlSpeed) / 1024 / 1024), (float64(peerUpSpeed) / 1024 / 1024), (peerProgress * 100), (float64(peerDownloaded) / 1024 / 1024), (float64(peerUploaded) / 1024 / 1024)) 275 | AddBlockPeer("CheckPeer", "Bad-Progress_Uploaded", peerIP, peerPort, torrentInfoHash) 276 | return 1, peerNet 277 | } 278 | } 279 | 280 | if (config.IgnoreEmptyPeer && !hasPeerClient) || ignoreByDownloaded { 281 | return -2, peerNet 282 | } 283 | 284 | return 0, peerNet 285 | } 286 | func ProcessPeer(peerIP string, peerPort int, peerID string, peerClient string, peerDlSpeed int64, peerUpSpeed int64, peerProgress float64, peerDownloaded int64, peerUploaded int64, torrentInfoHash string, torrentTotalSize int64, blockCount *int, ipBlockCount *int, badPeersCount *int, emptyPeersCount *int) { 287 | peerIP = ProcessIP(peerIP) 288 | peerStatus, peerNet := CheckPeer(peerIP, peerPort, peerID, peerClient, peerDlSpeed, peerUpSpeed, peerProgress, peerDownloaded, peerUploaded, torrentInfoHash, torrentTotalSize) 289 | if config.Debug_CheckPeer { 290 | Log("Debug-CheckPeer", "%s:%d %s|%s (TorrentInfoHash: %s, TorrentTotalSize: %.2f MB, PeerDlSpeed: %.2f MB/s, PeerUpSpeed: %.2f MB/s, Progress: %.2f%%, Downloaded: %.2f MB, Uploaded: %.2f MB, PeerStatus: %d)", false, peerIP, peerPort, strconv.QuoteToASCII(peerID), strconv.QuoteToASCII(peerClient), torrentInfoHash, (float64(torrentTotalSize) / 1024 / 1024), (float64(peerDlSpeed) / 1024 / 1024), (float64(peerUpSpeed) / 1024 / 1024), (peerProgress * 100), (float64(peerDownloaded) / 1024 / 1024), (float64(peerUploaded) / 1024 / 1024), peerStatus) 291 | } 292 | 293 | switch peerStatus { 294 | case 1: 295 | *blockCount++ 296 | case 3: 297 | *ipBlockCount++ 298 | case -1: 299 | *badPeersCount++ 300 | case -2: 301 | *emptyPeersCount++ 302 | case 0: 303 | if peerNet == nil { 304 | AddIPInfo(nil, peerIP, peerPort, torrentInfoHash, peerUploaded) 305 | AddTorrentInfo(torrentInfoHash, torrentTotalSize, nil, peerIP, peerPort, peerProgress, peerUploaded) 306 | } else { 307 | AddIPInfo(peerNet, peerNet.String(), peerPort, torrentInfoHash, peerUploaded) 308 | AddTorrentInfo(torrentInfoHash, torrentTotalSize, peerNet, peerNet.String(), peerPort, peerProgress, peerUploaded) 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qBittorrent-ClientBlocker 2 | 3 | [中文 (默认, Beta 版本)](README.md) [English (Default, Beta Version)](README.en.md) 4 | [中文 (Public 正式版)](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/blob/master/README.md) [English (Public version)](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/blob/master/README.en.md) 5 | 6 | 一款适用于 qBittorrent (4.1+)/Transmission (3.0+, Beta)/BitComet (2.0+, Beta, Partial) 的客户端屏蔽器, 默认屏蔽包括但不限于迅雷等客户端. 7 | 8 | - 全平台支持 9 | - 支持记录日志及热重载配置 10 | - 支持忽略私有 IP 地址 11 | - 支持自定义屏蔽列表 (不区分大小写, 支持正则表达式) 12 | - 支持多种客户端及其认证, 同时可自动检测部分客户端 (目前支持 qBittorrent. 可以安全忽略检测客户端时发生的错误) 13 | - 支持增强自动屏蔽 (默认禁用): 根据默认或设定的相关参数自动屏蔽 Peer 14 | - 在 Windows 下支持通过系统托盘或窗口热键 (CTRL+ALT+B) 显示及隐藏窗口 (部分用户[反馈](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/issues/10)其可能会影响屏蔽, 由于原因不明, 若遇到相关问题可避免使用该功能) 15 | 16 | 通常可以忽略偶发错误, 其原因可能有: 1. 网络问题; 2. 客户端超时; 3. Transmission 定期轮换 CSRF Token; ... 17 | 18 | ![Preview](Preview.png) 19 | 20 | ## 使用 Usage 21 | 22 | ### 前提 23 | 24 | - 必须启用客户端的 Web UI 功能. 25 | 26 | ### 常规版本使用 27 | 28 | 1. 从 [**GitHub Release**](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/releases) 下载压缩包并解压; 29 | 30 |
31 | 查看 常见平台下载版本 对照表 32 | 33 | | 操作系统 | 处理器架构 | 处理器位数 | 下载版本 | 说明 | 34 | | -------- | ---------- | ---------- | ------------- | ----------------- | 35 | | macOS | ARM64 | 64 位 | darwin-arm64 | 常见于 Apple M 系列 | 36 | | macOS | AMD64 | 64 位 | darwin-amd64 | 常见于 Intel 系列 | 37 | | Windows | AMD64 | 64 位 | windows-amd64 | 常见于大部分现代 PC | 38 | | Windows | i386 | 32 位 | windows-386 | 少见于部分老式 PC | 39 | | Windows | ARM64 | 64 位 | windows-arm64 | 常见于新型平台, 应用于部分平板/笔记本/少数特殊硬件 | 40 | | Windows | ARMv7 | 32 位 | windows-arm | 少见于罕见平台, 应用于部分上古硬件, 如 Surface RT 等 | 41 | | Linux | AMD64 | 64 位 | linux-amd64 | 常见于大部分 NAS 及服务器 | 42 | | Linux | i386 | 32 位 | linux-386 | 少见于部分老式 NAS 及服务器 | 43 | | Linux | ARM64 | 64 位 | linux-arm64 | 常见于部分服务器及开发板, 如 Oracle 或 Raspberry Pi 等 | 44 | | Linux | ARMv* | 32 位 | linux-armv* | 少见于部分老式服务器及开发板, 查看 /proc/cpuinfo 或 从高到底试哪个能跑 | 45 | 46 | 其它版本的 Linux/NetBSD/FreeBSD/OpenBSD/Solaris 可以此类推, 并在列表中选择适合自己的. 47 |
48 | 49 | 2. 解压后, 可能需要修改随附的配置文件 ```config.json```; 50 | 51 | - 可根据高级需求, 按需设置配置, 具体见 [配置 Config](#配置-config). 52 | - 若客户端屏蔽器运行于本机, 但未启用客户端 "跳过本机客户端认证" (及密码不为空且并未手动设置密码), 则必须修改配置文件, 并填写 ```clientPassword```. 53 | - 若客户端屏蔽器不运行于本机 或 客户端未安装在默认路径 或 客户端不支持自动读取配置文件, 则必须修改配置文件, 并填写 ```clientURL```/```clientUsername```/```clientPassword```. 54 | 55 | 3. 启动客户端屏蔽器, 并观察信息输出是否正常即可; 56 | 57 | 对于 Windows, 可选修改客户端的快捷方式, 放入自己的屏蔽器路径, 使客户端与屏蔽器同时运行; 58 | 59 | qBittorrent: ```C:\Windows\System32\cmd.exe /c "(tasklist | findstr qBittorrent-ClientBlocker || start C:\Users\Example\qBittorrent-ClientBlocker\qBittorrent-ClientBlocker.exe) && start qbittorrent.exe"``` 60 | 61 | 对于 macOS, 可选使用一基本 [LaunchAgent 用户代理](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/wiki#launchagent-macos) 用于开机自启及后台运行; 62 | 63 | 对于 Linux, 可选使用一基本 [Systemd 服务配置文件](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/wiki#systemd-linux) 用于开机自启及后台运行; 64 | 65 | ### Docker 版本使用 66 | 67 | - 从 [**Docker Hub**](https://hub.docker.com/r/simpletracker/qbittorrent-clientblocker) 拉取 Docker 镜像. 68 | 69 | ``` 70 | docker pull simpletracker/qbittorrent-clientblocker:latest 71 | ``` 72 | 73 | - 配置方法一: 使用 配置文件映射 74 | 75 | 1. 在合适位置新建 ```config.json``` 作为配置文件, 具体内容可参考 [config.json](config.json) 及 [配置 Config](#配置-config); 76 | 77 | 2. 填入 ```clientURL```/```clientUsername```/```clientPassword```; 78 | 79 | - 可根据高级需求, 按需设置其它配置, 具体见 [配置 Config](#配置-config). 80 | - 若启用客户端的 "IP 子网白名单", 则可不填写 ```clientUsername``` 和 ```clientPassword```. 81 | 82 | 3. 运行 Docker 并查看日志, 观察信息输出是否正常即可; 83 | 84 | 以下命令模版仅作为参考, 请替换 ```/path/config.json``` 为你的配置文件路径 (挂载前应先创建文件). 85 | 86 | ``` 87 | docker run -d \ 88 | --name=qbittorrent-clientblocker --network=bridge --restart unless-stopped \ 89 | -v /path/config.json:/app/config.json \ 90 | simpletracker/qbittorrent-clientblocker:latest 91 | ``` 92 | 93 | - 配置方法二: 使用 环境变量 94 | 95 | - 使用前提: 设置 ```useENV``` 环境变量为 ```true```. 96 | - 使用环境变量按需配置设置, 具体见 [配置 Config](#配置-config). 97 | - 若设置较复杂, 则可能出现 blockList 不生效的情况. 因此, 若需要配置此设置, 则使用环境变量是不推荐的. 98 | - 以下命令模版仅作为参考. 99 | 100 | ``` 101 | docker run -d \ 102 | --name=qbittorrent-clientblocker --network=bridge --restart unless-stopped \ 103 | -e useENV=true \ 104 | -e debug=false \ 105 | -e logPath=logs \ 106 | -e blockList='["ExampleBlockList1", "ExampleBlockList2"]' \ 107 | -e clientURL=http://example.com \ 108 | -e clientUsername=exampleUser \ 109 | -e clientPassword=examplePass \ 110 | simpletracker/qbittorrent-clientblocker:latest 111 | ``` 112 | 113 | ## 参数 Flag 114 | 115 | | 设置项 | 默认值 | 配置说明 | 116 | | ----- | ----- | ----- | 117 | | -v/--version | false (禁用) | 显示程序版本后退出 | 118 | | -c/--config | config.json | 配置文件路径 | 119 | | -ca/--config_additional | config_additional.json | 附加配置文件路径 | 120 | | --debug | false (禁用) | 调试模式. 加载配置文件前生效 | 121 | | --startdelay | 0 (秒, 禁用) | 启动延迟. 部分用户的特殊用途 | 122 | | --nochdir | false (禁用) | 不切换工作目录. 默认会切换至程序目录 | 123 | | --reghotkey | true (启用) | 注册窗口热键. 仅 Windows 可用. 窗口热键固定为 CTRL+ALT+B | 124 | | --hidewindow | false (禁用) | 默认隐藏窗口. 仅 Windows 可用 | 125 | | --hidesystray | false (禁用) | 默认隐藏托盘图标. 仅 Windows 可用 | 126 | 127 | ## 配置 Config 128 | 129 | Docker 版本通过相同名称的环境变量配置, 通过自动转换环境变量为配置文件实现. 130 | 131 | | 设置项 | 类型 | 默认值 | 配置说明 | 132 | | ----- | ----- | ----- | ----- | 133 | | checkUpdate | bool | true (启用) | 检查更新. 默认会自动检查更新 | 134 | | debug | bool | false (禁用) | 调试模式. 启用可看到更多信息, 但可能扰乱视野 | 135 | | debug_CheckTorrent | string | false (禁用) | 调试模式 (CheckTorrent, 须先启用 debug). 启用后调试信息会包括每个 Torrent Hash, 但信息量较大 | 136 | | debug_CheckPeer | string | false (禁用) | 调试模式 (CheckPeer, 须先启用 debug). 启用后调试信息会包括每个 Torrent Peer, 但信息量较大 | 137 | | interval | uint32 | 6 (秒) | 屏蔽循环间隔 (不支持热重载). 每个循环间隔会从后端获取相关信息用于判断及屏蔽, 短间隔有助于降低封禁耗时但可能造成客户端卡顿, 长间隔有助于降低 CPU 资源占用 | 138 | | cleanInterval | uint32 | 3600 (秒) | 屏蔽清理间隔. 短间隔会使过期 Peer 在达到屏蔽持续时间后更快被解除屏蔽, 长间隔有助于合并清理过期 Peer 日志 | 139 | | updateInterval | uint32 | 86400 (秒) | 列表 URL 更新间隔 (blockListURL/ipBlockListURL). 合理的间隔有助于提高更新效率并降低网络占用 | 140 | | restartInterval | uint32 | 6 (秒) | 重启 Torrent 间隔. 用于部分客户端 (Transmission) 屏蔽列表无法立即生效的措施, 通过重启 Torrent 来实现. 过短间隔可能造成屏蔽不生效 | 141 | | torrentMapCleanInterval | uint32 | 60 (秒) | Torrent Map 清理间隔 (启用 ipUploadedCheck+ipUpCheckPerTorrentRatio/banByRelativeProgressUploaded 后生效, 也是其判断间隔). 短间隔可使判断更频繁但可能造成滞后误判 | 142 | | banTime | uint32 | 86400 (秒) | 屏蔽持续时间. 短间隔会使 Peer 更快被解除屏蔽 | 143 | | banAllPort | bool | true (启用) | 屏蔽 IP 所有端口. 默认启用且当前不支持设置 | 144 | | banIPCIDR | string | /32 | 封禁 IPv4 CIDR. 可扩大单个 Peer 的封禁 IP 范围 | 145 | | banIP6CIDR | string | /128 | 封禁 IPv6 CIDR. 可扩大单个 Peer 的封禁 IP 范围 | 146 | | ignoreEmptyPeer | bool | true (启用) | 忽略无 PeerID 及 ClientName 的 Peer. 通常出现于连接未完全建立的客户端 | 147 | | ignoreNoLeechersTorrent | bool | false (禁用) | 忽略没有下载者的 Torrent. 启用后有助于提高性能, 但部分客户端 (如 qBittorrent) 可能会出现不准确的问题 | 148 | | ignorePTTorrent | bool | true (启用) | 忽略 PT Torrent. 若主要 Tracker 包含 ```?passkey=```/```?authkey=```/```?secure=```/```32 位大小写英文及数字组成的字符串``` | 149 | | ignoreFailureExit | bool | false (禁用) | 忽略失败退出. 启用后会使得首次检测客户端失败或认证失败后继续重试 | 150 | | sleepTime | uint32 | 20 (毫秒) | 查询每个 Torrent Peers 的等待时间. 短间隔可使屏蔽 Peer 更快但可能造成客户端卡顿, 长间隔有助于平均 CPU 资源占用 | 151 | | timeout | uint32 | 6 (秒) | 请求超时. 过短间隔可能会造成无法正确屏蔽 Peer, 过长间隔会使超时请求影响屏蔽其它 Peer 的性能 | 152 | | proxy | string | Auto (自动) | 使用代理. 仍会在首次加载配置文件时自动检测代理. 空: 禁止使用代理; Auto: 自动 (仅对外部请求使用代理); All: 对全部请求使用代理 | 153 | | longConnection | bool | true (启用) | 长连接. 启用可降低资源消耗 | 154 | | logPath | string | logs | 日志目录. 须先启用 logToFile | 155 | | logToFile | bool | true (启用) | 记录普通信息到日志. 启用后可用于一般的分析及统计用途 | 156 | | logDebug | bool | false (禁用) | 记录调试信息到日志 (须先启用 debug 及 logToFile). 启用后可用于进阶的分析及统计用途, 但信息量较大 | 157 | | listen | string | 127.0.0.1:26262 | 监听端口. 用于向部分客户端 (Transmission) 提供 BlockPeerList. 非本机使用可改为 ```:``` | 158 | | clientType | string | 空 | 客户端类型. 使用客户端屏蔽器的前提条件, 若未能自动检测客户端类型, 则须正确填入. 目前支持 ```qBittorrent```/```Transmission```/```BitComet``` | 159 | | clientURL | string | 空 | 客户端地址. 可用 Web API 或 RPC 地址. 使用客户端屏蔽器的前提条件, 若未能自动读取客户端配置文件, 则须正确填入. 前缀必须指定 http 或 https 协议, 如 ```http://127.0.0.1:990/api``` 或 ```http://127.0.0.1:9091/transmission/rpc``` | 160 | | clientUsername | string | 空 | 客户端账号. 留空会跳过认证. 若启用客户端内 "跳过本机客户端认证" 可默认留空, 因可自动读取客户端配置文件并设置 | 161 | | clientPassword | string | 空 | 客户端密码. 若启用客户端内 "跳过本机客户端认证" 可默认留空 | 162 | | useBasicAuth | bool | false (禁用) | 同时通过 HTTP Basic Auth 进行认证. 适合只支持 Basic Auth (如 Transmission/BitComet) 或通过反向代理等方式 增加/换用 认证方式的后端 | 163 | | useShadowBan | bool | true (启用) | 使用 ShadowBan API 进行封禁. 仅适用于支持 ShadowBan API 的客户端 (如 qBEE), 若载入配置时检测相关 API 不可用, 将自动禁用 | 164 | | skipCertVerification | bool | false (禁用) | 跳过 Web API 证书校验. 适合自签及过期证书 | 165 | | fetchFailedThreshold | int | 0 (禁用) | 最大获取失败次数. 当超过设定次数, 将执行设置的外部命令 | 166 | | execCommand_FetchFailed | string | 空 | 执行外部命令 (FetchFailed). 首个参数被视作外部程序路径, 当获取失败次数超过设定次数后执行 | 167 | | execCommand_Run | string | 空 | 执行外部命令 (Run). 首个参数被视作外部程序路径, 当程序启动后执行 | 168 | | execCommand_Ban | string | 空 | 执行外部命令 (Ban). 首个参数被视作外部程序路径, 各参数均应使用 ```\|``` 分割, 命令可以使用 ```{peerIP}```/```{peerPort}```/```{torrentInfoHash}``` 来使用相关信息 (peerPort=-1 意味着全端口封禁) | 169 | | execCommand_Unban | string | 空 | 执行外部命令 (Unban). 首个参数被视作外部程序路径, 各参数均应使用 ```\|``` 分割, 命令可以使用 ```{peerIP}```/```{peerPort}```/```{torrentInfoHash}``` 来使用相关信息 (peerPort=-1 意味着全端口封禁) | 170 | | syncServerURL | string | 空 | 同步服务器 URL. 同步服务器会将 TorrentMap 提交至服务器, 并从服务器接收屏蔽 IPCIDR 列表 | 171 | | syncServerToken | string | 空 | 同步服务器 Token. 部分同步服务器可能需要认证 | 172 | | blockList | []string | 空 (于 config.json 附带) | 屏蔽客户端列表. 同时判断 PeerID 及 ClientName, 不区分大小写, 支持正则表达式 | 173 | | blockListURL | []string | 空 | 屏蔽客户端列表 URL. 支持格式同 blockList, 一行一条 | 174 | | blockListFile | []string | 空 | 屏蔽客户端列表文件. 支持格式同 blockList, 一行一条 | 175 | | portBlockList | []uint32 | 空 | 屏蔽端口列表. 若 Peer 端口与列表内任意端口匹配, 则允许屏蔽 Peer | 176 | | ipBlockList | []string | 空 | 屏蔽 IP 列表. 支持不包括端口的 IP (1.2.3.4) 及 IPCIDR (2.3.3.3/3) | 177 | | ipBlockListURL | []string | 空 | 屏蔽 IP 列表 URL. 支持格式同 ipBlockList, 一行一条 | 178 | | ipBlockListFile | []string | 空 | 屏蔽 IP 列表文件. 支持格式同 ipBlockList, 一行一条 | 179 | | genIPDat | uint32 | 0 (禁用) | 1: 生成 IPBlockList.dat. 包括所有被封禁的 Peer IPCIDR, 格式同 ipBlockList; 2: 生成 IPFilter.dat. 包括所有被封禁的 Peer IP; 一行一条 | 180 | | ipUploadedCheck | bool | false (禁用) | IP 上传增量检测. 在满足下列 IP 上传增量 条件后, 会自动屏蔽 Peer | 181 | | ipUpCheckInterval | uint32 | 300 (秒) | IP 上传增量检测/检测间隔. 用于确定上一周期及当前周期, 以比对客户端对 IP 上传增量. 也顺便用于 maxIPPortCount | 182 | | ipUpCheckIncrementMB | uint32 | 38000 (MB) | IP 上传增量检测/增量大小. 若 IP 全局上传增量大小大于设置增量大小, 则允许屏蔽 Peer | 183 | | ipUpCheckPerTorrentRatio | float64 | 3 (X) | IP 上传增量检测/增量倍率. 若 IP 单个 Torrent 上传增量大小大于设置增量倍率及 Torrent 大小之乘积, 则允许屏蔽 Peer | 184 | | maxIPPortCount | uint32 | 0 (禁用) | 每 IP 最大端口数. 若 IP 端口数大于设置值, 会自动屏蔽 Peer | 185 | | banByProgressUploaded | bool | false (禁用) | 增强自动屏蔽 (根据进度及上传量屏蔽 Peer, 未经测试验证). 在满足下列 增强自动屏蔽 条件后, 会自动屏蔽 Peer | 186 | | banByPUStartMB | uint32 | 20 (MB) | 增强自动屏蔽/起始大小. 若客户端上传量大于起始大小, 则允许屏蔽 Peer | 187 | | banByPUStartPrecent | float64 | 2 (%) | 增强自动屏蔽/起始进度. 若客户端上传进度大于设置起始进度, 则允许屏蔽 Peer | 188 | | banByPUAntiErrorRatio | float64 | 3 (X) | 增强自动屏蔽/滞后防误判倍率. 若 Peer 报告下载进度与设置倍率及 Torrent 大小之乘积得到之下载量 比 客户端上传量 还低, 则允许屏蔽 Peer | 189 | | banByRelativeProgressUploaded | bool | false (禁用) | 增强自动屏蔽_相对 (根据相对进度及相对上传量屏蔽 Peer, 未经测试验证). 在满足下列 增强自动屏蔽_相对 条件后, 会自动屏蔽 Peer | 190 | | banByRelativePUStartMB | uint32 | 20 (MB) | 增强自动屏蔽_相对/起始大小. 若客户端相对上传量大于设置起始大小, 则允许屏蔽 Peer | 191 | | banByRelativePUStartPrecent | float64 | 2 (%) | 增强自动屏蔽_相对/起始进度. 若客户端相对上传进度大于设置起始进度, 则允许屏蔽 Peer | 192 | | banByRelativePUAntiErrorRatio | float64 | 3 (X) | 增强自动屏蔽_相对/滞后防误判倍率. 若 Peer 报告相对下载进度与设置倍率之乘积得到之相对下载进度 比 客户端相对上传进度 还低, 则允许屏蔽 Peer | 193 | | ignoreByDownloaded | uint32 | 100 (MB) | 增强自动屏蔽*/最高下载量. 若从 Peer 下载量大于此项, 则跳过增强自动屏蔽 | 194 | 195 | ## 反馈 Feedback 196 | 用户及开发者可以通过 [Issue](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/issues) 反馈 bug, 通过 [Discussion](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/discussions) 提问/讨论/分享 使用方法, 通过 [Pull Request](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/pulls) 向客户端屏蔽器贡献代码改进. 197 | 注意: 应基于 dev 分支. 为 Feature 发起 Pull Request 时, 请不要同步创建 Issue. 由于人手有限, 开发进度可能较为缓慢. 198 | 199 | ## 致谢 Credit 200 | 201 | 1. 我们在客户端屏蔽器的早期开发过程中部分参考了 [jinliming2/qbittorrent-ban-xunlei](https://github.com/jinliming2/qbittorrent-ban-xunlei). 我们可能也会参考其它同类项目对项目进行优化, 但将不在此处单独列出; 202 | 2. 我们会在每期版本的 Release Note 中感谢当期通过 Pull Request 向客户端屏蔽器贡献代码改进的用户及开发者; 203 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= 2 | github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= 3 | github.com/Xuanwo/go-locale v1.1.0 h1:51gUxhxl66oXAjI9uPGb2O0qwPECpriKQb2hl35mQkg= 4 | github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs= 5 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 6 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 7 | github.com/bdwyertech/go-get-proxied v0.0.0-20221029171534-ea033ac5f9fa h1:fRI5TE3nkZNCFCiwx4f8pn9EqosU63GdpRr/oMZfXaU= 8 | github.com/bdwyertech/go-get-proxied v0.0.0-20221029171534-ea033ac5f9fa/go.mod h1:gguiiQiki8o2ByWTy5cN07JJdBJKvVlxp1GmovszCXE= 9 | github.com/bdwyertech/go-scutil v0.0.0-20210306002117-b25267f54e45 h1:h0VnajBq78VxrXh3qvakigCyjh9pWnTVYkzsLiKrZog= 10 | github.com/bdwyertech/go-scutil v0.0.0-20210306002117-b25267f54e45/go.mod h1:gV303gJocqRNJVrg/n4cB+ZwoHxKKMiAiwe+t627Kjw= 11 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 12 | github.com/darren/gpac v0.0.0-20210609082804-b56d6523a3af h1:hRl8yeesLVvIFWsUXGv7nysRriS1cYagFvYSRXDKU/g= 13 | github.com/darren/gpac v0.0.0-20210609082804-b56d6523a3af/go.mod h1:pF2H/Bu76N23ydpIIYwMYE8S1dCi9ZoSOC91fPtn44g= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 18 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 19 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 20 | github.com/dop251/goja v0.0.0-20210427212725-462d53687b0d h1:enuVjS1vVnToj/GuGZ7QegOAIh1jF340Sg6NXcoMohs= 21 | github.com/dop251/goja v0.0.0-20210427212725-462d53687b0d/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= 22 | github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= 23 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= 24 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= 25 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= 26 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= 27 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= 28 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= 29 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= 30 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= 31 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= 32 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= 33 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= 34 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= 35 | github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= 36 | github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= 37 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= 38 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 39 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 40 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 41 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 42 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 43 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 44 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 45 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 46 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 47 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 48 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 49 | github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= 50 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= 51 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= 52 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= 53 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 54 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 55 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 56 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 59 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 60 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 61 | github.com/smartystreets/goconvey v1.6.7 h1:I6tZjLXD2Q1kjvNbIzB1wvQBsXmKXiVrhpRE8ZjP5jY= 62 | github.com/smartystreets/goconvey v1.6.7/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 63 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 64 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 65 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 66 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 67 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 68 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 69 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 70 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 71 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 72 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 73 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 74 | github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= 75 | github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= 76 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 77 | golang.design/x/hotkey v0.4.1 h1:zLP/2Pztl4WjyxURdW84GoZ5LUrr6hr69CzJFJ5U1go= 78 | golang.design/x/hotkey v0.4.1/go.mod h1:M8SGcwFYHnKRa83FpTFQoZvPO5vVT+kWPztFqTQKmXA= 79 | golang.design/x/mainthread v0.3.0 h1:UwFus0lcPodNpMOGoQMe87jSFwbSsEY//CA7yVmu4j8= 80 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 81 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 82 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 83 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 84 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 85 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 86 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 87 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 88 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 89 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 90 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 91 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 92 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 96 | golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 97 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 98 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 104 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 105 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 106 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 107 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 108 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 109 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 110 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 111 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 112 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 113 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 114 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 115 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 116 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 117 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 118 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 119 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 120 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 121 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 122 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 123 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 124 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 125 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 126 | gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= 127 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 128 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 129 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 130 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 131 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 132 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 133 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 134 | -------------------------------------------------------------------------------- /console.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "os/signal" 7 | "runtime" 8 | "runtime/debug" 9 | "strconv" 10 | "strings" 11 | "syscall" 12 | "time" 13 | ) 14 | 15 | var loopTicker *time.Ticker 16 | var currentTimestamp int64 = 0 17 | var lastCheckUpdateTimestamp int64 = 0 18 | var lastCheckUpdateReleaseVer = "" 19 | var lastCheckUpdateBetaVer = "None" 20 | var githubAPIHeader = map[string]string{"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} 21 | var isRunning bool 22 | 23 | type ReleaseStruct struct { 24 | URL string `json:"html_url"` 25 | TagName string `json:"tag_name"` 26 | Name string `json:"name"` 27 | Body string `json:"body"` 28 | PreRelease bool `json:"prerelease"` 29 | } 30 | 31 | func ProcessVersion(version string) (int, int, int, int, string) { 32 | realVersion := strings.SplitN(version, " ", 2)[0] 33 | versionSplit := strings.SplitN(realVersion, ".", 2) 34 | 35 | if strings.Contains(version, "Unknown") { 36 | return -1, 0, 0, 0, "" 37 | } 38 | 39 | if strings.Contains(version, "(Nightly)") { 40 | return -2, 0, 0, 0, "" 41 | } 42 | 43 | if strings.Contains(version, "-") || strings.Contains(version, "_") { 44 | return -3, 0, 0, 0, "" 45 | } 46 | 47 | if len(versionSplit) != 2 { 48 | return -1, 0, 0, 0, "" 49 | } 50 | 51 | mainVersion, err1 := strconv.Atoi(versionSplit[0]) 52 | 53 | versionType := 0 // 0: Public, 1: Beta. 54 | versionSplit2 := strings.SplitN(versionSplit[1], "p", 2) 55 | versionSplit3 := strings.SplitN(versionSplit[1], "b", 2) 56 | 57 | subVersionStr := versionSplit[1] 58 | sub2VersionStr := "0" 59 | 60 | if len(versionSplit2) >= 2 { 61 | subVersionStr = versionSplit2[0] 62 | sub2VersionStr = versionSplit2[1] 63 | } else if len(versionSplit3) >= 2 { 64 | versionType = 1 65 | subVersionStr = versionSplit3[0] 66 | sub2VersionStr = versionSplit3[1] 67 | } 68 | 69 | subVersion, err2 := strconv.Atoi(subVersionStr) 70 | sub2Version, err3 := strconv.Atoi(sub2VersionStr) 71 | 72 | if err1 != nil || err2 != nil || err3 != nil { 73 | return -3, 0, 0, 0, "" 74 | } 75 | 76 | return versionType, mainVersion, subVersion, sub2Version, realVersion 77 | } 78 | func CheckUpdate() { 79 | if !config.CheckUpdate || (lastCheckUpdateTimestamp+86400) > currentTimestamp { 80 | return 81 | } 82 | 83 | lastCheckUpdateTimestamp = currentTimestamp 84 | 85 | currentVersionType, currentMainVersion, currentSubVersion, currentSub2Version, currentVersion := ProcessVersion(programVersion) 86 | 87 | if currentVersionType == -1 { 88 | Log("CheckUpdate", GetLangText("CheckUpdate-Ignore_UnknownVersion"), true) 89 | return 90 | } 91 | 92 | if currentVersionType == -2 { 93 | Log("CheckUpdate", GetLangText("CheckUpdate-Ignore_NightlyVersion"), true) 94 | return 95 | } 96 | 97 | if currentVersionType == -3 { 98 | Log("CheckUpdate", GetLangText("CheckUpdate-Ignore_BadVersion"), true, programVersion) 99 | return 100 | } 101 | 102 | listResponseCode, _, listReleaseContent := Fetch("https://api.github.com/repos/Simple-Tracker/qBittorrent-ClientBlocker/releases?per_page=5", false, false, true, &githubAPIHeader) 103 | if listResponseCode == 304 { 104 | return 105 | } 106 | 107 | if listReleaseContent == nil { 108 | Log("CheckUpdate", GetLangText("Error-FetchUpdate"), true) 109 | return 110 | } 111 | 112 | var releasesStruct []ReleaseStruct 113 | if err := json.Unmarshal(listReleaseContent, &releasesStruct); err != nil { 114 | Log("CheckUpdate", GetLangText("Error-Parse"), true, err.Error()) 115 | return 116 | } 117 | 118 | matchLatestReleaseVersion := false 119 | matchLatestPreReleaseVersion := false 120 | var latestReleaseStruct ReleaseStruct 121 | var latestPreReleaseStruct ReleaseStruct 122 | 123 | for _, releaseStruct := range releasesStruct { 124 | if releaseStruct.TagName == "" { 125 | continue 126 | } 127 | 128 | if matchLatestPreReleaseVersion && matchLatestReleaseVersion { 129 | break 130 | } 131 | 132 | if !matchLatestPreReleaseVersion && releaseStruct.PreRelease { 133 | matchLatestPreReleaseVersion = true 134 | latestPreReleaseStruct = releaseStruct 135 | } 136 | if !matchLatestReleaseVersion && !releaseStruct.PreRelease { 137 | matchLatestReleaseVersion = true 138 | latestReleaseStruct = releaseStruct 139 | } 140 | } 141 | 142 | hasNewReleaseVersion := false 143 | hasNewPreReleaseVersion := false 144 | 145 | if matchLatestPreReleaseVersion { 146 | if currentVersionType == 1 { 147 | versionType, mainVersion, subVersion, sub2Version, _ := ProcessVersion(latestPreReleaseStruct.TagName) 148 | 149 | if versionType == currentVersionType { 150 | if mainVersion > currentMainVersion { 151 | hasNewPreReleaseVersion = true 152 | } else if mainVersion == currentMainVersion { 153 | if subVersion > currentSubVersion { 154 | hasNewPreReleaseVersion = true 155 | } else if subVersion == currentSubVersion && sub2Version > currentSub2Version { 156 | hasNewPreReleaseVersion = true 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | if matchLatestReleaseVersion { 164 | versionType, mainVersion, subVersion, sub2Version, _ := ProcessVersion(latestReleaseStruct.TagName) 165 | 166 | if versionType == 0 { 167 | if mainVersion > currentMainVersion { 168 | hasNewReleaseVersion = true 169 | } else if mainVersion == currentMainVersion { 170 | if subVersion > currentSubVersion { 171 | hasNewReleaseVersion = true 172 | } else if subVersion == currentSubVersion && sub2Version > currentSub2Version { 173 | hasNewReleaseVersion = true 174 | } 175 | } 176 | } 177 | } 178 | 179 | if latestPreReleaseStruct.TagName == "" { 180 | latestPreReleaseStruct.TagName = "None" 181 | } 182 | 183 | Log("CheckUpdate", GetLangText("CheckUpdate-ShowVersion"), true, currentVersion, latestReleaseStruct.TagName, latestPreReleaseStruct.TagName) 184 | 185 | if hasNewReleaseVersion && lastCheckUpdateReleaseVer != latestReleaseStruct.TagName { 186 | lastCheckUpdateReleaseVer = latestReleaseStruct.TagName 187 | Log("CheckUpdate", GetLangText("CheckUpdate-DetectNewVersion"), true, latestReleaseStruct.TagName, ("https://github.com/Simple-Tracker/" + programName + "/releases/tag/" + latestReleaseStruct.TagName), strings.Replace(latestReleaseStruct.Body, "\r", "", -1)) 188 | } 189 | 190 | if hasNewPreReleaseVersion && lastCheckUpdateBetaVer != latestPreReleaseStruct.TagName { 191 | lastCheckUpdateBetaVer = latestPreReleaseStruct.TagName 192 | Log("CheckUpdate", GetLangText("CheckUpdate-DetectNewBetaVersion"), true, latestPreReleaseStruct.TagName, ("https://github.com/Simple-Tracker/" + programName + "/releases/tag/" + latestPreReleaseStruct.TagName), strings.Replace(latestPreReleaseStruct.Body, "\r", "", -1)) 193 | } 194 | } 195 | func Task() { 196 | if config.ClientURL == "" { 197 | Log("Task", GetLangText("Error-Task_EmptyURL"), true) 198 | return 199 | } 200 | if !IsSupportClient() { 201 | Log("Task", GetLangText("Error-Task_NotSupportClient"), true, currentClientType) 202 | return 203 | } 204 | 205 | torrents := FetchTorrents() 206 | if torrents == nil { 207 | return 208 | } 209 | 210 | cleanCount := ClearBlockPeer() 211 | 212 | emptyHashCount := 0 213 | noLeechersCount := 0 214 | badTorrentInfoCount := 0 215 | ptTorrentCount := 0 216 | 217 | blockCount := 0 218 | ipBlockCount := 0 219 | badPeersCount := 0 220 | emptyPeersCount := 0 221 | 222 | switch currentClientType { 223 | case "qBittorrent": 224 | torrents2 := torrents.(*[]qB_TorrentStruct) 225 | for _, torrentInfo := range *torrents2 { 226 | ProcessTorrent(torrentInfo.InfoHash, torrentInfo.Tracker, torrentInfo.NumLeechs, torrentInfo.TotalSize, nil, &emptyHashCount, &noLeechersCount, &badTorrentInfoCount, &ptTorrentCount, &blockCount, &ipBlockCount, &badPeersCount, &emptyPeersCount) 227 | } 228 | case "Transmission": 229 | torrents2 := torrents.(*Tr_TorrentsStruct) 230 | for _, torrentInfo := range torrents2.Torrents { 231 | // 手动判断有无 Peer 正在下载. 232 | var leecherCount int64 = 0 233 | for _, torrentPeer := range torrentInfo.Peers { 234 | if torrentPeer.IsUploading { 235 | leecherCount++ 236 | } 237 | } 238 | 239 | tracker := "" 240 | if torrentInfo.Private { 241 | tracker = "Private" 242 | } 243 | 244 | ProcessTorrent(torrentInfo.InfoHash, tracker, leecherCount, torrentInfo.TotalSize, torrentInfo.Peers, &emptyHashCount, &noLeechersCount, &badTorrentInfoCount, &ptTorrentCount, &blockCount, &ipBlockCount, &badPeersCount, &emptyPeersCount) 245 | } 246 | case "BitComet": 247 | // BitComet 无法通过 Torrent 列表取得 TorrentInfoHash, 因此使用 TorrentID 取代. 248 | torrents2 := torrents.(*map[int]BC_TorrentStruct) 249 | for torrentID, torrentInfo := range *torrents2 { 250 | var leecherCount int64 = 233 251 | if torrentInfo.UpSpeed > 0 { 252 | leecherCount = 233 253 | } 254 | ProcessTorrent(strconv.Itoa(torrentID), "Unsupported", leecherCount, torrentInfo.TotalSize, nil, &emptyHashCount, &noLeechersCount, &badTorrentInfoCount, &ptTorrentCount, &blockCount, &ipBlockCount, &badPeersCount, &emptyPeersCount) 255 | } 256 | } 257 | 258 | ipBlockCount += CheckAllIP(ipMap, lastIPMap) 259 | torrentBlockCount, torrentIPBlockCount := CheckAllTorrent(torrentMap, lastTorrentMap) 260 | blockCount += torrentBlockCount 261 | ipBlockCount += torrentIPBlockCount 262 | 263 | Log("Debug-Task_IgnoreEmptyHashCount", "%d", false, emptyHashCount) 264 | Log("Debug-Task_IgnoreNoLeechersCount", "%d", false, noLeechersCount) 265 | Log("Debug-Task_IgnorePTTorrentCount", "%d", false, ptTorrentCount) 266 | Log("Debug-Task_IgnoreBadTorrentInfoCount", "%d", false, badTorrentInfoCount) 267 | Log("Debug-Task_IgnoreBadPeersCount", "%d", false, badPeersCount) 268 | Log("Debug-Task_IgnoreEmptyPeersCount", "%d", false, emptyPeersCount) 269 | 270 | if cleanCount != 0 || blockCount != 0 || ipBlockCount != 0 { 271 | if config.GenIPDat == 1 || config.GenIPDat == 2 { 272 | ipfilterCount, ipfilterStr := GenIPFilter(config.GenIPDat, blockPeerMap) 273 | err := SaveIPFilter(ipfilterStr) 274 | if err != "" { 275 | Log("Task", GetLangText("Error-IPFilter_Write"), true, err) 276 | } else { 277 | Log("Task", GetLangText("Success-GenIPFilter"), true, ipfilterCount) 278 | } 279 | } 280 | 281 | SubmitBlockPeer(blockPeerMap) 282 | 283 | iblcLen := 0 284 | ipBlockListCompiled.Range(func(key, value any) bool { 285 | iblcLen++ 286 | return true 287 | }) 288 | 289 | if !config.IPUploadedCheck && iblcLen <= 0 && len(ipBlockCIDRMapFromSyncServerCompiled) <= 0 { 290 | Log("Task", GetLangText("Task_BanInfo"), true, blockCount, len(blockPeerMap)) 291 | } else { 292 | Log("Task", GetLangText("Task_BanInfoWithIP"), true, blockCount, ipBlockCount, len(blockPeerMap)) 293 | } 294 | } 295 | 296 | SyncWithServer() 297 | } 298 | func GC() { 299 | ipMapGCCount := (len(ipMap) - 23333333) 300 | 301 | if ipMapGCCount > 0 { 302 | Log("GC", GetLangText("GC_IPMap"), true, ipMapGCCount) 303 | for ip, _ := range ipMap { 304 | ipMapGCCount-- 305 | delete(ipMap, ip) 306 | if ipMapGCCount <= 0 { 307 | break 308 | } 309 | } 310 | runtime.GC() 311 | } 312 | 313 | for torrentInfoHash, torrentInfo := range torrentMap { 314 | torrentInfoGCCount := (len(torrentInfo.Peers) - 2333333) 315 | if torrentInfoGCCount > 0 { 316 | Log("GC", GetLangText("GC_TorrentMap"), true, torrentInfoHash, torrentInfoGCCount) 317 | for peerIP, _ := range torrentInfo.Peers { 318 | torrentInfoGCCount-- 319 | delete(torrentMap[torrentInfoHash].Peers, peerIP) 320 | if torrentInfoGCCount <= 0 { 321 | break 322 | } 323 | } 324 | runtime.GC() 325 | } 326 | } 327 | } 328 | func WaitStop() { 329 | signalChan := make(chan os.Signal, 1) 330 | signal.Notify(signalChan, syscall.SIGINT, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM) 331 | 332 | <-signalChan 333 | ReqStop() 334 | } 335 | func ReqStop() { 336 | if !isRunning { 337 | return 338 | } 339 | 340 | Log("ReqStop", GetLangText("ReqStop_Stoping"), true) 341 | isRunning = false 342 | } 343 | func Stop() { 344 | recoverErr := recover() 345 | 346 | if recoverErr != nil { 347 | Log("Stop", GetLangText("Stop_CaughtPanic"), true, recoverErr) 348 | Log("Stop", GetLangText("Stop_StacktraceWhenPanic"), true, string(debug.Stack())) 349 | } 350 | 351 | if loopTicker != nil { 352 | loopTicker.Stop() 353 | } 354 | 355 | DeleteIPFilter() 356 | SubmitBlockPeer(nil) 357 | httpClient.CloseIdleConnections() 358 | httpClientExternal.CloseIdleConnections() 359 | StopServer() 360 | Platform_Stop() 361 | 362 | if recoverErr != nil { 363 | os.Exit(2) 364 | } 365 | } 366 | func RunConsole() { 367 | if startDelay > 0 { 368 | Log("RunConsole", GetLangText("RunConsole_StartDelay"), false, startDelay) 369 | time.Sleep(time.Duration(startDelay) * time.Second) 370 | } 371 | 372 | for !LoadInitConfig(true) { 373 | time.Sleep(2 * time.Second) 374 | if !config.IgnoreFailureExit { 375 | os.Exit(1) 376 | } 377 | } 378 | 379 | isRunning = true 380 | 381 | if config.ExecCommand_Run != "" { 382 | status, out, err := ExecCommand(config.ExecCommand_Run) 383 | 384 | if status { 385 | Log("RunConsole", GetLangText("Success-ExecCommand"), true, out) 386 | } else { 387 | Log("RunConsole", GetLangText("Failed-ExecCommand"), true, out, err) 388 | } 389 | } 390 | 391 | Log("RunConsole", GetLangText("RunConsole_ProgramHasStarted"), true) 392 | loopTicker = time.NewTicker(1 * time.Second) 393 | go WaitStop() 394 | defer Stop() 395 | 396 | for ; true; <-loopTicker.C { 397 | if !isRunning { 398 | break 399 | } 400 | 401 | tmpCurrentTimestamp := time.Now().Unix() 402 | if (currentTimestamp + int64(config.Interval)) <= tmpCurrentTimestamp { 403 | currentTimestamp = tmpCurrentTimestamp 404 | go CheckUpdate() 405 | if LoadInitConfig(false) { 406 | Task() 407 | GC() 408 | } 409 | } 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # qBittorrent-ClientBlocker 2 | 3 | [中文 (默认, Beta 版本)](README.md) [English (Default, Beta Version)](README.en.md) 4 | [中文 (Public 正式版)](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/blob/master/README.md) [English (Public version)](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/blob/master/README.en.md) 5 | 6 | A client blocker compatible with qBittorrent (4.1+)/Transmission (3.0+, Beta)/BitComet (2.0+, Beta, Partial), which is prohibited to include but not limited to clients such as Xunlei. 7 | 8 | - Support many platforms 9 | - Support log and hot-reload config 10 | - Support ignore private ip 11 | - Support custom blockList (Case-Inensitive, Support regular expression) 12 | - Supports a variety of clients and their authentication, and can automatically detect some clients (Currently supports qBittorrent. Errors that occur when detecting clients can be ignored) 13 | - Support enhanced automatic ban (Default disable): Automatically ban peer based on the default or set related parameter 14 | - Under Windows, support show and hide window through systray or window hotkey (Ctrl+Alt+B) (Some users [feedback](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/issues/10) it may affect ban. Due to unknown reason, the function can be avoided if related problem are encountered) 15 | 16 | Usually, occasional errors can be ignored. The reasons may be: 1. Network problems; 2. Client timeout; 3. Transmission periodically rotates CSRF Token; ... 17 | 18 | ![Preview](Preview.png) 19 | 20 | ## 使用 Usage 21 | 22 | ### Prerequisite 23 | 24 | - Client Web UI must be enabled. 25 | 26 | ### Use conventional version 27 | 28 | 1. Download compressed from [**GitHub Release**](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/releases) and decompress it; 29 | 30 |
31 | View common platform download version of comparison table 32 | 33 | | OS | Processor Arch | Processor Integers | Download Version | Note | 34 | | -------- | ---------- | ---------- | ------------- | ------------------------------------------------------ | 35 | | macOS | ARM64 | 64-bit | darwin-arm64 | Common in Apple M series | 36 | | macOS | AMD64 | 64-bit | darwin-amd64 | Common in Intel series | 37 | | Windows | AMD64 | 64-bit | windows-amd64 | Common in most modern PC | 38 | | Windows | i386 | 32-bit | windows-386 | Occasionally on some old PC | 39 | | Windows | ARM64 | 64-bit | windows-arm64 | Common on new platform, it's applied to some tablet/notebooks/minority special hardware | 40 | | Windows | ARMv6 | 32-bit | windows-arm | Rare platform, applied to some ancient hardware, such as Surface RT, etc | 41 | | Linux | AMD64 | 64-bit | linux-amd64 | Common NAS and server | 42 | | Linux | i386 | 32-bit | linux-386 | Rarely in some old NAS and server | 43 | | Linux | ARM64 | 64-bit | linux-arm64 | Common server and development board, such as Oracle or Raspberry Pi, etc | 44 | | Linux | ARMv* | 32-bit | linux-armv* | Rarely in some old server and development board, Check /proc/cpuinfo or try which version can run from high to low | 45 | 46 | Other versions of Linux/Netbsd/FreeBSD/OpenBSD/Solaris can use this form as an example, and select one that suits you in the list. 47 |
48 | 49 | 2. After decompression, you may need to modify the attached config file ```config.json```; 50 | 51 | - You can set config according to high-level needs. See [配置 Config](#配置-config). 52 | - If blocker runs on this machine, but client "Skip client certification" is disabled (and password is not empty and does not manually set password in blocker), you must modify config file and fill in ```clientPassword```. 53 | - If blocker is not running on this machine or client is not installed on the default path or using blocker with 2.4 and below version, config file must be modified and fills in ```clientURL```/```clientUsername```/```clientPassword```. 54 | 55 | 3. Start blocker and observe whether the information output is normal; 56 | 57 | For Windows, you can choose shortcut of client, put your own blocker path, and run client and blocker at the same time; 58 | 59 | qBittorrent: ```C:\Windows\System32\cmd.exe /c "(tasklist | findstr qBittorrent-ClientBlocker || start C:\Users\Example\qBittorrent-ClientBlocker\qBittorrent-ClientBlocker.exe) && start qbittorrent.exe"``` 60 | 61 | For macOS, You can choose a basic [LaunchAgent](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/wiki#launchagent-macos) for starting from OS start and background run; 62 | 63 | For Linux, You can choose a basic [Systemd service](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/wiki#systemd-linux) for starting from OS start and background run; 64 | 65 | ### Use Docker version 66 | 67 | - Pull image from [**Docker Hub**](https://hub.docker.com/r/simpletracker/qbittorrent-clientblocker). 68 | 69 | ``` 70 | docker pull simpletracker/qbittorrent-clientblocker:latest 71 | ``` 72 | 73 | - Configuration method 1: Use config file mapping 74 | 75 | 1. Create a new ```config.json``` in right location, as a configuration file, the specific config can refer [config.json](config.json) and [配置 Config](#配置-config); 76 | 77 | 2. Fills in ```clientURL```/```clientUsername```/```clientPassword```; 78 | 79 | - You can set config according to high-level needs. See [配置 Config](#配置-config). 80 | - If client "IP subnet whitelist" is enabled, you don't need fill in ```clientUsername``` and ```clientPassword```. 81 | 82 | 3. Run docker image and view log to observe whether the information output is normal; 83 | 84 | The following command templates are used as a reference only, please replace ```/path/config.json``` to your config path (The file should be created before mounting). 85 | 86 | ``` 87 | docker run -d \ 88 | --name=qbittorrent-clientblocker --network=bridge --restart unless-stopped \ 89 | -v /path/config.json:/app/config.json \ 90 | simpletracker/qbittorrent-clientblocker:latest 91 | ``` 92 | 93 | - Configuration method 1: Use environment variable 94 | 95 | - Prerequisite: Set the ```useENV``` environment variable is ```true```. 96 | - Use environment variables to configure settings on demand. For details, see [配置 Config](#配置-config). 97 | - If config is complicated, blockList may not take effect. Therefore, if you need to configure this setting, it's not recommended to use environment variable. 98 | - The following command templates are used as a reference only. 99 | 100 | ``` 101 | docker run -d \ 102 | --name=qbittorrent-clientblocker --network=bridge --restart unless-stopped \ 103 | -e useENV=true \ 104 | -e debug=false \ 105 | -e logPath=logs \ 106 | -e blockList='["ExampleBlockList1", "ExampleBlockList2"]' \ 107 | -e clientURL=http://example.com \ 108 | -e clientUsername=exampleUser \ 109 | -e clientPassword=examplePass \ 110 | simpletracker/qbittorrent-clientblocker:latest 111 | ``` 112 | 113 | ## 参数 Flag 114 | 115 | | Parameter | Default | Note | 116 | | ----- | ----- | ----- | 117 | | -v/--version | false | Show program version and exit | 118 | | -c/--config | config.json | Config path | 119 | | -ca/--config_additional | config_additional.json | Additional config path | 120 | | --debug | false | Debug mode. Effective before loading config file | 121 | | --startdelay | 0 (Sec, Disable) | Start delay. Special uses for some user | 122 | | --nochdir | false | Don't change working directory. Change to the program directory by default | 123 | | --reghotkey | true | Register window hotkey. Only available on Windows. The window hotkey is fixed to CTRL+ALT+B | 124 | | --hidewindow | false | Hide window by default. Only available on Windows | 125 | | --hidesystray | false | Hide systray by default. Only available on Windows | 126 | 127 | ## 配置 Config 128 | 129 | Docker version is configured through the same name variable configuration, which actually is implemented by automatically conversion environment variable as config file. 130 | 131 | | Parameter | Type | Default | Note | 132 | | ----- | ----- | ----- | ----- | 133 | | checkUpdate | bool | true | Check update. Automatically checks for update by default | 134 | | debug | bool | false | Debug mode. Enable you can see more information, but it may disrupt the field of vision | 135 | | debug_CheckTorrent | string | false | Debug mode (CheckTorrent, must enable debug). If it's enabled, debug info will include each Torrent Hash, but the amount of information will be large | 136 | | debug_CheckPeer | string | false | Debug mode (CheckPeer, must enable debug). If it's enabled, debug info will include each Torrent Peer, but the amount of information will be large | 137 | | interval | uint32 | 6 (秒) | Ban Check Interval (Hot-reload is not supported). Each cycle interval will obtain relevant information from backend for judgment and blocking. Short interval can help reduce ban time but may cause client to freeze, but Long interval can help reduce CPU usage | 138 | | cleanInterval | uint32 | 3600 (Sec) | Clean blocked peer interval. Short interval will cause expired Peer to be unblocked faster after blocking duration is reached, but Long interval will help merge and clean up expired Peer log | 139 | | updateInterval | uint32 | 86400 (Sec) | List URL update interval (blockListURL/ipBlockListURL). Reasonable intervals help improve update efficiency and reduce network usage | 140 | | restartInterval | uint32 | 6 (Sec) | Restart Torrent interval. This is a measure to solve the problem that the blocklist of some clients (Transmission) cannot be effective. It is implemented by restarting Torrent. Too short an interval may cause the blocking to be ineffective | 141 | | torrentMapCleanInterval | uint32 | 60 (Sec) | Torrent Map Clean Interval (Only useful after enable ipUploadedCheck+ipUpCheckPerTorrentRatio/banByRelativeProgressUploaded, It's also the judgment interval). Short interval can make judgments more frequent but may cause delayed misjudgments | 142 | | banTime | uint32 | 86400 (Sec) | Ban duration. Short interval will cause peer to be unblocked faster | 143 | | banAllPort | bool | true | Block IP all port. Enabled by default and setting is not currently supported | 144 | | banIPCIDR | string | /32 | Block IPv4 CIDR. Used to expand Peer’s block IP range | 145 | | banIP6CIDR | string | /128 | Block IPv6 CIDR. Used to expand Peer’s block IP range | 146 | | ignoreEmptyPeer | bool | true | Ignore peers without PeerID and ClientName. Usually occurs on clients where connection is not fully established | 147 | | ignoreNoLeechersTorrent | bool | false | Ignore torrent without leechers. Enabling may improve performance, but may cause inaccuracies with some clients (such as qBittorrent) | 148 | | ignorePTTorrent | bool | true | Ignore PT Torrent. If the main Tracker contains ```?passkey=```/```?authkey=```/```?secure=```/```A string of 32 digits consisting of uppercase and lowercase char or/and number``` | 149 | | ignoreFailureExit | bool | false | Ignore failure exit. If enabled, it will continue to retry after first detection of the client fails or authentication fails | 150 | | sleepTime | uint32 | 20 (MicroSec) | Query waiting time of each Torrent Peers. Short interval can make blocking Peer faster but may cause client lag, Long interval can help average CPU usage | 151 | | timeout | uint32 | 6 (MillSec) | Request timeout. If interval is too short, peer may not be properly blocked. If interval is too long, timeout request will affect blocking other peer | 152 | | proxy | string | Auto | Use proxy. Still automatically detect proxy on first load. Empty: Disable proxy; Auto: Automatic (use proxy only for external requests); All: Use proxy for all requests | 153 | | longConnection | bool | true | Long connection. Enable to reduce resource consumption | 154 | | logPath | string | logs | Log path. Must enable logToFile | 155 | | logToFile | bool | true | Log general information to file. If enabled, it can be used for general analysis and statistical purposes | 156 | | logDebug | bool | false | Log debug information to file (Must enable debug and logToFile). If enabled, it can be used for advanced analysis and statistical purposes, but the amount of information is large | 157 | | listen | string | 127.0.0.1:26262 | Listen port. Used to provide BlockPeerList to some client (Transmission). For non-localhost, you can change to ```:``` | 158 | | clientType | string | Empty | Client type. Available Web API or RPC address. Prerequisite for using blocker, if client config file cannot be automatically detect, must be filled in correctly. Currently support ```qBittorrent```/```Transmission```/```BitComet``` | 159 | | clientURL | string | Empty | Client address. Prerequisite for using blocker, if client config file cannot be automatically read, must be filled in correctly. Prefix must specify http or https protocol, such as ```http://127.0.0.1:990``` or ```http://127.0.0.1:9091/transmission/rpc``` | 160 | | clientUsername | string | Empty | Client username. Leaving it blank will skip authentication. If you enable client "Skip local client authentication", you can leave it blank by default, because the client config file can be automatically read and set | 161 | | clientPassword | string | Empty | Client password. If client "Skip local client authentication" is enabled, it can be left blank by default | 162 | | useBasicAuth | bool | false | Also authenticates via HxTTP Basic Auth. Suitable for backend that only support Basic Auth (such as Transmission/BitComet) or add/change authentication methods via reverse proxy, etc | 163 | | useShadowBan | bool | true | Use ShadowBan API to ban peer. Only works with clients that support ShadowBan API (such as qBEE). If the relevant API is not available when loading the configuration, it will be automatically disabled | 164 | | skipCertVerification | bool | false | Skip Web API certificate verification. Suitable for self-signed and expired certificates | 165 | | fetchFailedThreshold | int | 0 (Disable) | Maximum number of fetch failures. When set number is exceeded, execCommand_FetchFailed will be executed | 166 | | execCommand_FetchFailed | string | Empty | Execute external command (FetchFailed). First parameter is regarded as an external program path, execute the command when number of fetch failures exceeds set threshold | 167 | | execCommand_Run | string | Empty | Execute external command (Run). First parameter is regarded as an external program path, execute the command when program starts | 168 | | execCommand_Ban | string | Empty | Execute external command (Ban). First parameter is regarded as an external program path, and each parameter should be separated by ```\|```, command can use ```{peerIP}```/```{peerPort}```/```{torrentInfoHash}``` to use related info (peerPort=-1 means ban all port) | 169 | | execCommand_Ban | string | Empty | Execute external command (Ban). First parameter is regarded as an external program path, and each parameter should be separated by ```\|```, command can use ```{peerIP}```/```{peerPort}```/```{torrentInfoHash}``` to use related info (peerPort=-1 means ban all port) | 170 | | execCommand_Unban | string | Empty | Execute external command (Unban). First parameter is regarded as an external program path, and each parameter should be separated by ```\|```, command can use ```{peerIP}```/```{peerPort}```/```{torrentInfoHash}``` to use related info (peerPort=-1 means ban all port) | 171 | | syncServerURL | string | Empty | Sync server URL. Sync server will submit TorrentMap to server and receive BlockIPCIDR from server | 172 | | syncServerToken | string | Empty | Sync server Token. Some sync server may require authentication | 173 | | blockList | []string | Empty (Included in config.json) | Block client list. Judge PeerID or ClientName at the same time, case-insensitive, support regular expression | 174 | | blockListURL | []string | Empty | Block client list URL. Support format is same as blockList, one rule per line | 175 | | blockListFile | []string | Empty | Block client list File. Support format is same as blockList, one rule per line | 176 | | portBlockList | []uint32 | Empty | Block port list. If peer port matches any of ports, Peer will be automatically block | 177 | | ipBlockList | []string | Empty | Block IP list. Support excluding ports IP (1.2.3.4) or IPCIDR (2.3.3.3/3) | 178 | | ipBlockListURL | []string | Empty | Block IP list URL. Support format is same as ipBlockList, one rule per line | 179 | | ipBlockListFile | []string | Empty | Block IP list File. Support format is same as ipBlockList, one rule per line | 180 | | genIPDat | uint32 | 0 (Disable) | 1: Generate IPBlockList.dat. Include All Peer IPCIDR, support format is same as ipBlockList; 2: Generate IPFilter.dat. Include All Peer IP; One rule per line | 181 | | ipUploadedCheck | bool | false | IP upload incremental detection. After the following IP upload incremental conditions are met, Peer will be automatically block | 182 | | ipUpCheckInterval | uint32 | 300 (Sec) | IP upload incremental detection/Interval. Used to determine the previous cycle and the current cycle to compare Peer's IP upload increment. It is also used for maxIPPortCount | 183 | | ipUpCheckIncrementMB | uint32 | 38000 (MB) | IP upload incremental detection/Increment size. If the IP global upload increment size is greater than the set increment size, Peer will be automatically block | 184 | | ipUpCheckPerTorrentRatio | float64 | 3 (X) | IP upload incremental detection/Increment ratio. If the IP single torrent upload increment size is greater than the product of the set increment ratio and the torrent size, Peer will be automatically block | 185 | | maxIPPortCount | uint32 | 0 (Disable) | Maximum number of ports per IP. If the number of IP ports is greater than the set value, Peer will be automatically block | 186 | | banByProgressUploaded | bool | false | Enhanced automatic blocking (blocking Peer based on progress and uploaded, not verified by testing). After the following enhanced automatic blocking conditions are met, Peer will be automatically blocked | 187 | | banByPUStartMB | uint32 | 20 (MB) | Enhanced automatic blocking/Start size. If the client uploaded is greater than the set initial size, Peer will be automatically block | 188 | | banByPUStartPrecent | float64 | 2 (%) | Enhanced automatic blocking/Start progress. If the client upload progress is greater than the set start progress, Peer will be automatically block | 189 | | banByPUAntiErrorRatio | float64 | 3 (X) | Enhanced automatic blocking/Lag anti-misjudgment ratio. If the downloaded obtained by the Peer's reported download progress multiplied by the set ratio and the torrent size is lower than Peer's uploaded, Peer will be automatically block | 190 | | banByRelativeProgressUploaded | bool | false | Enhanced automatic blocking_Relative (Block Peer based on relative progress and relative uploaded, not verified by testing). After the following Enhanced automatic blocking_Relative conditions are met, Peer will be automatically block | 191 | | banByRelativePUStartMB | uint32 | 20 (MB) | Enhanced automatic blocking_Relative/Start size. If the relative uploaded of the client is greater than the set start size, Peer will be automatically block | 192 | | banByRelativePUStartPrecent | float64 | 2 (%) | Enhanced automatic blocking_Relative/Start progress. If the relative upload progress of the client is greater than the set start progress, Peer will be automatically block | 193 | | banByRelativePUAntiErrorRatio | float64 | 3 (X) | Enhanced automatic blocking_Relative/Lag anti-misjudgment ratio. If the relative download progress obtained by the product of the relative download progress reported by the peer and the set ratio is lower than the relative upload progress of the client, Peer will be automatically block | 194 | | ignoreByDownloaded | uint32 | 100 (MB) | Enhanced automatic blocking*/Max downloaded. If downloaded from Peer is greater than this value, enhanced automatic blocking will be skipped | 195 | 196 | ## 反馈 Feedback 197 | User and developer can report bug through [Issue](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/issues), ask/discuss/share usage through [Discussion](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/discussions), contribute code improvement to blocker through [Pull Request](https://github.com/Simple-Tracker/qBittorrent-ClientBlocker/pulls). 198 | Note: Should be based on dev branch. When opening a Pull Request for a Feature, please do not create an Issue simultaneously. 199 | 200 | ## 致谢 Credit 201 | 202 | 1. We partially referenced [jinliming2/qbittorrent-ban-xunlei](https://github.com/jinliming2/qbittorrent-ban-xunlei) during early development of blocker. We may also refer to other similar project to optimize this project, but they will not be listed separately here; 203 | 2. We will thank the user and developer who contributed code improvement to blocker through Pull Request in Release Note; 204 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "flag" 7 | "log" 8 | "net/http" 9 | "net/http/cookiejar" 10 | "os" 11 | "path/filepath" 12 | "reflect" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/dlclark/regexp2" 18 | "github.com/pelletier/go-toml/v2" 19 | "github.com/tidwall/jsonc" 20 | ) 21 | 22 | type ConfigStruct struct { 23 | CheckUpdate bool 24 | Debug bool 25 | Debug_CheckTorrent bool 26 | Debug_CheckPeer bool 27 | Interval uint32 28 | CleanInterval uint32 29 | UpdateInterval uint32 30 | RestartInterval uint32 31 | TorrentMapCleanInterval uint32 32 | BanTime uint32 33 | BanAllPort bool 34 | BanIPCIDR string 35 | BanIP6CIDR string 36 | IgnoreEmptyPeer bool 37 | IgnoreNoLeechersTorrent bool 38 | IgnorePTTorrent bool 39 | IgnoreFailureExit bool 40 | SleepTime uint32 41 | Timeout uint32 42 | Proxy string 43 | LongConnection bool 44 | LogPath string 45 | LogToFile bool 46 | LogDebug bool 47 | Listen string 48 | ClientType string 49 | ClientURL string 50 | ClientUsername string 51 | ClientPassword string 52 | UseBasicAuth bool 53 | UseShadowBan bool 54 | SkipCertVerification bool 55 | FetchFailedThreshold int 56 | ExecCommand_FetchFailed string 57 | ExecCommand_Run string 58 | ExecCommand_Ban string 59 | ExecCommand_Unban string 60 | SyncServerURL string 61 | SyncServerToken string 62 | BlockList []string 63 | BlockListURL []string 64 | BlockListFile []string 65 | PortBlockList []uint32 66 | IPBlockList []string 67 | IPBlockListURL []string 68 | IPBlockListFile []string 69 | IgnoreByDownloaded uint32 70 | GenIPDat uint32 71 | IPUploadedCheck bool 72 | IPUpCheckInterval uint32 73 | IPUpCheckIncrementMB uint32 74 | IPUpCheckPerTorrentRatio float64 75 | MaxIPPortCount uint32 76 | BanByProgressUploaded bool 77 | BanByPUStartMB uint32 78 | BanByPUStartPrecent float64 79 | BanByPUAntiErrorRatio float64 80 | BanByRelativeProgressUploaded bool 81 | BanByRelativePUStartMB uint32 82 | BanByRelativePUStartPrecent float64 83 | BanByRelativePUAntiErrorRatio float64 84 | } 85 | 86 | var programName = "qBittorrent-ClientBlocker" 87 | var programVersion = "Unknown" 88 | var programUserAgent = programName + "/" + programVersion 89 | 90 | var shortFlag_ShowVersion bool 91 | var longFlag_ShowVersion bool 92 | var startDelay uint 93 | var noChdir bool 94 | var needRegHotKey bool 95 | var needHideWindow bool 96 | var needHideSystray bool 97 | 98 | var randomStrRegexp = regexp2.MustCompile("[a-zA-Z0-9]{32}", 0) 99 | var blockListCompiled sync.Map 100 | var ipBlockListCompiled sync.Map 101 | var blockListURLLastFetch int64 = 0 102 | var ipBlockListURLLastFetch int64 = 0 103 | var blockListFileLastMod = make(map[string]int64) 104 | var ipBlockListFileLastMod = make(map[string]int64) 105 | var cookieJar, _ = cookiejar.New(nil) 106 | 107 | var lastURL = "" 108 | var configLastMod = make(map[string]int64) 109 | var configFilename string = "config.json" 110 | var shortFlag_configFilename string 111 | var longFlag_configFilename string 112 | var additionConfigFilename string = "config_additional.json" 113 | var shortFlag_additionConfigFilename string 114 | var longFlag_additionConfigFilename string 115 | 116 | var httpTransport = &http.Transport{ 117 | DisableKeepAlives: true, 118 | ForceAttemptHTTP2: false, 119 | MaxConnsPerHost: 32, 120 | MaxIdleConns: 32, 121 | MaxIdleConnsPerHost: 32, 122 | IdleConnTimeout: 60 * time.Second, 123 | TLSHandshakeTimeout: 12 * time.Second, 124 | ResponseHeaderTimeout: 60 * time.Second, 125 | TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, 126 | Proxy: GetProxy, 127 | } 128 | 129 | var httpClient http.Client 130 | var httpClientExternal http.Client // 没有 Cookie. 131 | 132 | var httpServer = http.Server{ 133 | ReadTimeout: 30, 134 | WriteTimeout: 30, 135 | Handler: &httpServerHandler{}, 136 | } 137 | 138 | var config = ConfigStruct{ 139 | CheckUpdate: true, 140 | Debug: false, 141 | Debug_CheckTorrent: false, 142 | Debug_CheckPeer: false, 143 | Interval: 6, 144 | CleanInterval: 3600, 145 | UpdateInterval: 86400, 146 | RestartInterval: 6, 147 | TorrentMapCleanInterval: 60, 148 | BanTime: 86400, 149 | BanAllPort: false, 150 | BanIPCIDR: "/32", 151 | BanIP6CIDR: "/128", 152 | IgnoreEmptyPeer: true, 153 | IgnoreNoLeechersTorrent: false, 154 | IgnorePTTorrent: true, 155 | IgnoreFailureExit: false, 156 | SleepTime: 20, 157 | Timeout: 6, 158 | Proxy: "Auto", 159 | LongConnection: true, 160 | LogPath: "logs", 161 | LogToFile: true, 162 | LogDebug: false, 163 | Listen: "127.0.0.1:26262", 164 | ClientType: "", 165 | ClientURL: "", 166 | ClientUsername: "", 167 | ClientPassword: "", 168 | UseBasicAuth: false, 169 | UseShadowBan: true, 170 | SkipCertVerification: false, 171 | FetchFailedThreshold: 0, 172 | ExecCommand_FetchFailed: "", 173 | ExecCommand_Run: "", 174 | ExecCommand_Ban: "", 175 | ExecCommand_Unban: "", 176 | SyncServerURL: "", 177 | SyncServerToken: "", 178 | BlockList: []string{}, 179 | BlockListURL: []string{}, 180 | BlockListFile: []string{}, 181 | PortBlockList: []uint32{}, 182 | IPBlockList: []string{}, 183 | IPBlockListURL: nil, 184 | IPBlockListFile: nil, 185 | IgnoreByDownloaded: 100, 186 | GenIPDat: 0, 187 | IPUploadedCheck: false, 188 | IPUpCheckInterval: 300, 189 | IPUpCheckIncrementMB: 38000, 190 | IPUpCheckPerTorrentRatio: 3, 191 | MaxIPPortCount: 0, 192 | BanByProgressUploaded: false, 193 | BanByPUStartMB: 20, 194 | BanByPUStartPrecent: 2, 195 | BanByPUAntiErrorRatio: 3, 196 | BanByRelativeProgressUploaded: false, 197 | BanByRelativePUStartMB: 20, 198 | BanByRelativePUStartPrecent: 2, 199 | BanByRelativePUAntiErrorRatio: 3, 200 | } 201 | 202 | func SetBlockListFromContent(blockListContent []string, blockListSource string) int { 203 | setCount := 0 204 | 205 | for index, content := range blockListContent { 206 | content = StrTrim(ProcessRemark(content)) 207 | if content == "" { 208 | Log("Debug-SetBlockListFromContent_Compile", GetLangText("Error-Debug-EmptyLineWithSource"), false, index, blockListSource) 209 | continue 210 | } 211 | 212 | if _, exists := blockListCompiled.Load(content); exists { 213 | continue 214 | } 215 | 216 | Log("Debug-SetBlockListFromContent_Compile", ":%d %s (Source: %s)", false, index, content, blockListSource) 217 | 218 | reg, err := regexp2.Compile("(?i)"+content, 0) 219 | if err != nil { 220 | Log("SetBlockListFromContent_Compile", GetLangText("Error-SetBlockListFromContent_Compile"), true, index, content, blockListSource) 221 | continue 222 | } 223 | 224 | reg.MatchTimeout = 50 * time.Millisecond 225 | 226 | blockListCompiled.Store(content, reg) 227 | setCount++ 228 | } 229 | 230 | return setCount 231 | } 232 | func SetBlockListFromFile() bool { 233 | if config.BlockListFile == nil || len(config.BlockListFile) == 0 { 234 | return true 235 | } 236 | 237 | setCount := 0 238 | 239 | for _, filePath := range config.BlockListFile { 240 | blockListFileStat, err := os.Stat(filePath) 241 | if err != nil { 242 | Log("SetBlockListFromFile", GetLangText("Error-LoadFile"), false, filePath, err.Error()) 243 | return false 244 | } 245 | 246 | // Max 8MB. 247 | if blockListFileStat.Size() > 8388608 { 248 | Log("SetBlockListFromFile", GetLangText("Error-LargeFile"), true) 249 | continue 250 | } 251 | 252 | fileLastMod := blockListFileStat.ModTime().Unix() 253 | if fileLastMod == blockListFileLastMod[filePath] { 254 | return false 255 | } 256 | if blockListFileLastMod[filePath] != 0 { 257 | Log("Debug-SetBlockListFromFile", GetLangText("Debug-SetBlockListFromFile_HotReload"), false, filePath) 258 | } 259 | 260 | blockListContent, err := os.ReadFile(filePath) 261 | if err != nil { 262 | Log("SetBlockListFromFile", GetLangText("Error-LoadFile"), true, filePath, err.Error()) 263 | return false 264 | } 265 | 266 | blockListFileLastMod[filePath] = fileLastMod 267 | 268 | var content []string 269 | if filepath.Ext(filePath) == ".json" { 270 | err = json.Unmarshal(jsonc.ToJSON(blockListContent), &content) 271 | if err != nil { 272 | Log("SetBlockListFromFile", GetLangText("Error-GenJSONWithID"), true, filePath, err.Error()) 273 | continue 274 | } 275 | } else { 276 | content = strings.Split(string(blockListContent), "\n") 277 | } 278 | 279 | setCount += SetBlockListFromContent(content, filePath) 280 | } 281 | 282 | Log("SetBlockListFromFile", GetLangText("Success-SetBlockListFromFile"), true, setCount) 283 | return true 284 | } 285 | func SetBlockListFromURL() bool { 286 | if config.BlockListURL == nil || len(config.BlockListURL) == 0 || (blockListURLLastFetch+int64(config.UpdateInterval)) > currentTimestamp { 287 | return true 288 | } 289 | 290 | blockListURLLastFetch = currentTimestamp 291 | setCount := 0 292 | 293 | for _, blockListURL := range config.BlockListURL { 294 | httpStatusCode, httpHeader, blockListContent := Fetch(blockListURL, false, false, true, nil) 295 | if httpStatusCode == 304 { 296 | continue 297 | } 298 | 299 | if blockListContent == nil { 300 | //blockListURLLastFetch -= (int64(config.UpdateInterval) + 900) 301 | Log("SetBlockListFromURL", GetLangText("Error-FetchResponse2"), true) 302 | continue 303 | } 304 | 305 | // Max 8MB. 306 | if len(blockListContent) > 8388608 { 307 | Log("SetBlockListFromURL", GetLangText("Error-LargeFile"), true) 308 | continue 309 | } 310 | 311 | var content []string 312 | if strings.HasSuffix(strings.ToLower(strings.Split(httpHeader.Get("Content-Type"), ";")[0]), "json") { 313 | err := json.Unmarshal(jsonc.ToJSON(blockListContent), &content) 314 | if err != nil { 315 | Log("SetBlockListFromFile", GetLangText("Error-GenJSONWithID"), true, blockListURL, err.Error()) 316 | continue 317 | } 318 | } else { 319 | content = strings.Split(string(blockListContent), "\n") 320 | } 321 | 322 | setCount += SetBlockListFromContent(content, blockListURL) 323 | } 324 | 325 | Log("SetBlockListFromURL", GetLangText("Success-SetBlockListFromURL"), true, setCount) 326 | return true 327 | } 328 | func SetIPBlockListFromContent(ipBlockListContent []string, ipBlockListSource string) int { 329 | setCount := 0 330 | 331 | for index, content := range ipBlockListContent { 332 | content = StrTrim(ProcessRemark(content)) 333 | if content == "" { 334 | Log("Debug-SetIPBlockListFromContent_Compile", GetLangText("Error-Debug-EmptyLineWithSource"), false, index, ipBlockListSource) 335 | continue 336 | } 337 | 338 | if _, exists := ipBlockListCompiled.Load(content); exists { 339 | continue 340 | } 341 | 342 | Log("Debug-SetIPBlockListFromContent_Compile", ":%d %s (Source: %s)", false, index, content, ipBlockListSource) 343 | cidr := ParseIPCIDR(content) 344 | if cidr == nil { 345 | Log("SetIPBlockListFromContent_Compile", GetLangText("Error-SetIPBlockListFromContent_Compile"), true, index, content, ipBlockListSource) 346 | continue 347 | } 348 | 349 | ipBlockListCompiled.Store(content, cidr) 350 | setCount++ 351 | } 352 | 353 | return setCount 354 | } 355 | func SetIPBlockListFromFile() bool { 356 | if config.IPBlockListFile == nil || len(config.IPBlockListFile) == 0 { 357 | return true 358 | } 359 | 360 | setCount := 0 361 | 362 | for _, filePath := range config.IPBlockListFile { 363 | ipBlockListFileStat, err := os.Stat(filePath) 364 | if err != nil { 365 | Log("SetIPBlockListFromFile", GetLangText("Error-LoadFile"), false, filePath, err.Error()) 366 | return false 367 | } 368 | 369 | fileLastMod := ipBlockListFileStat.ModTime().Unix() 370 | if fileLastMod <= ipBlockListFileLastMod[filePath] { 371 | return true 372 | } 373 | 374 | if ipBlockListFileLastMod[filePath] != 0 { 375 | Log("Debug-SetIPBlockListFromFile", GetLangText("Debug-SetIPBlockListFromFile_HotReload"), false, filePath) 376 | } 377 | 378 | ipBlockListFile, err := os.ReadFile(filePath) 379 | if err != nil { 380 | Log("SetIPBlockListFromFile", GetLangText("Error-LoadFile"), true, filePath, err.Error()) 381 | return false 382 | } 383 | 384 | ipBlockListFileLastMod[filePath] = fileLastMod 385 | 386 | var content []string 387 | if filepath.Ext(filePath) == ".json" { 388 | err := json.Unmarshal(jsonc.ToJSON(ipBlockListFile), &content) 389 | if err != nil { 390 | Log("SetIPBlockListFromFile", GetLangText("Error-GenJSONWithID"), true, filePath, err.Error()) 391 | } 392 | } else { 393 | content = strings.Split(string(ipBlockListFile), "\n") 394 | } 395 | 396 | setCount += SetIPBlockListFromContent(content, filePath) 397 | } 398 | 399 | Log("SetIPBlockListFromFile", GetLangText("Success-SetIPBlockListFromFile"), true, setCount) 400 | return true 401 | } 402 | func SetIPBlockListFromURL() bool { 403 | if config.IPBlockListURL == nil || len(config.IPBlockListURL) == 0 || (ipBlockListURLLastFetch+int64(config.UpdateInterval)) > currentTimestamp { 404 | return true 405 | } 406 | 407 | ipBlockListURLLastFetch = currentTimestamp 408 | setCount := 0 409 | 410 | for _, ipBlockListURL := range config.IPBlockListURL { 411 | httpStatusCode, httpHeader, ipBlockListContent := Fetch(ipBlockListURL, false, false, true, nil) 412 | if httpStatusCode == 304 { 413 | continue 414 | } 415 | 416 | if ipBlockListContent == nil { 417 | //ipBlockListURLLastFetch -= (int64(config.UpdateInterval) + 900) 418 | Log("SetIPBlockListFromURL", GetLangText("Error-FetchResponse2"), true) 419 | continue 420 | } 421 | 422 | if len(ipBlockListContent) > 8388608 { 423 | Log("SetIPBlockListFromURL", GetLangText("Error-LargeFile"), true) 424 | continue 425 | } 426 | 427 | var content []string 428 | if strings.HasSuffix(httpHeader.Get("Content-Type"), "json") { 429 | err := json.Unmarshal(jsonc.ToJSON(ipBlockListContent), &content) 430 | if err != nil { 431 | Log("SetIPBlockListFromURL", GetLangText("Error-GenJSONWithID"), true, ipBlockListURL, err.Error()) 432 | continue 433 | } 434 | } else { 435 | content = strings.Split(string(ipBlockListContent), "\n") 436 | } 437 | 438 | setCount += SetIPBlockListFromContent(content, ipBlockListURL) 439 | } 440 | 441 | Log("SetIPBlockListFromURL", GetLangText("Success-SetIPBlockListFromURL"), true, setCount) 442 | 443 | return true 444 | } 445 | func LoadConfig(filename string, notExistErr bool) int { 446 | configFileStat, err := os.Stat(filename) 447 | if err != nil { 448 | notExist := os.IsNotExist(err) 449 | if notExistErr || !notExist { 450 | Log("Debug-LoadConfig", GetLangText("Error-LoadConfigMeta"), false, filename, err.Error()) 451 | } 452 | if notExist { 453 | return -5 454 | } 455 | 456 | return -2 457 | } 458 | 459 | tmpConfigLastMod := configFileStat.ModTime().Unix() 460 | if tmpConfigLastMod <= configLastMod[filename] { 461 | return -1 462 | } 463 | 464 | if configLastMod[filename] != 0 { 465 | Log("Debug-LoadConfig", GetLangText("Debug-LoadConfig_HotReload"), false, filename) 466 | } 467 | 468 | configFile, err := os.ReadFile(filename) 469 | if err != nil { 470 | Log("LoadConfig", GetLangText("Error-LoadConfig"), true, filename, err.Error()) 471 | return -3 472 | } 473 | 474 | configLastMod[filename] = tmpConfigLastMod 475 | 476 | switch filepath.Ext(strings.ToLower(filename)) { 477 | case ".json": 478 | if err := json.Unmarshal(jsonc.ToJSON(configFile), &config); err != nil { 479 | Log("LoadConfig", GetLangText("Error-ParseConfig"), true, filename, err.Error()) 480 | return -4 481 | } 482 | case ".toml": 483 | if err := toml.Unmarshal(configFile, &config); err != nil { 484 | Log("LoadConfig", GetLangText("Error-ParseConfig"), true, filename, err.Error()) 485 | return -4 486 | } 487 | } 488 | 489 | Log("LoadConfig", GetLangText("Success-LoadConfig"), true, filename) 490 | 491 | return 0 492 | } 493 | func InitConfig() { 494 | if config.Interval < 1 { 495 | config.Interval = 1 496 | } 497 | 498 | if config.Timeout < 1 { 499 | config.Timeout = 1 500 | } 501 | 502 | if config.ClientURL != "" { 503 | config.ClientURL = strings.TrimRight(config.ClientURL, "/") 504 | } 505 | 506 | if config.SkipCertVerification { 507 | httpTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 508 | } else { 509 | httpTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: false} 510 | } 511 | 512 | httpTransportExternal := httpTransport.Clone() 513 | 514 | if config.Proxy == "Auto" { 515 | // Aka default. 仅对外部资源使用代理. 516 | httpTransport.Proxy = nil 517 | httpTransportExternal.Proxy = GetProxy 518 | } else if config.Proxy == "All" { 519 | httpTransport.Proxy = GetProxy 520 | httpTransportExternal.Proxy = GetProxy 521 | } else { 522 | httpTransport.Proxy = nil 523 | httpTransportExternal.Proxy = nil 524 | } 525 | 526 | if config.LongConnection { 527 | httpTransport.DisableKeepAlives = false 528 | } 529 | 530 | currentTimeout := time.Duration(config.Timeout) * time.Second 531 | 532 | httpClient = http.Client{ 533 | Timeout: currentTimeout, 534 | Jar: cookieJar, 535 | Transport: httpTransport, 536 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 537 | return http.ErrUseLastResponse 538 | }, 539 | } 540 | 541 | httpClientExternal = http.Client{ 542 | Timeout: currentTimeout, 543 | Transport: httpTransportExternal, 544 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 545 | return http.ErrUseLastResponse 546 | }, 547 | } 548 | 549 | httpServer.ReadTimeout = currentTimeout 550 | httpServer.WriteTimeout = currentTimeout 551 | 552 | t := reflect.TypeOf(config) 553 | v := reflect.ValueOf(config) 554 | for k := 0; k < t.NumField(); k++ { 555 | Log("LoadConfig_Current", "%v: %v", false, t.Field(k).Name, v.Field(k).Interface()) 556 | } 557 | 558 | EraseSyncMap(&blockListCompiled) 559 | blockListURLLastFetch = 0 560 | SetBlockListFromContent(config.BlockList, "BlockList") 561 | 562 | EraseSyncMap(&ipBlockListCompiled) 563 | ipBlockListURLLastFetch = 0 564 | SetIPBlockListFromContent(config.IPBlockList, "IPBlockList") 565 | } 566 | func LoadInitConfig(firstLoad bool) bool { 567 | loadConfigStatus := LoadConfig(configFilename, true) 568 | 569 | if loadConfigStatus < -1 { 570 | Log("LoadInitConfig", GetLangText("Failed-LoadInitConfig"), true) 571 | } else { 572 | loadAdditionalConfigStatus := LoadConfig(additionConfigFilename, false) 573 | if loadAdditionalConfigStatus == -5 && additionConfigFilename == "config_additional.json" { 574 | loadAdditionalConfigStatus = LoadConfig("config/"+additionConfigFilename, false) 575 | } 576 | 577 | if loadConfigStatus == 0 || loadAdditionalConfigStatus == 0 { 578 | InitConfig() 579 | } 580 | } 581 | 582 | if !LoadLog() && logFile != nil { 583 | logFile.Close() 584 | logFile = nil 585 | } 586 | 587 | if firstLoad { 588 | GetProxy(nil) 589 | SetURLFromClient() 590 | } 591 | 592 | if config.ClientURL != "" { 593 | if lastURL != config.ClientURL { 594 | if !DetectClient() { 595 | Log("LoadInitConfig", GetLangText("LoadInitConfig_DetectClientFailed"), true) 596 | return false 597 | } 598 | if !Login() { 599 | Log("LoadInitConfig", GetLangText("LoadInitConfig_AuthFailed"), true) 600 | return false 601 | } 602 | InitClient() 603 | SubmitBlockPeer(nil) 604 | lastURL = config.ClientURL 605 | } 606 | } else { 607 | // 重置为上次使用的 URL, 主要目的是防止热重载配置文件可能破坏首次启动后从 qBittorrent 配置文件读取的 URL. 608 | config.ClientURL = lastURL 609 | } 610 | 611 | if config.UseShadowBan && TestShadowBanAPI() <= 0 { 612 | config.UseShadowBan = false 613 | } 614 | 615 | if !firstLoad { 616 | SetBlockListFromFile() 617 | SetIPBlockListFromFile() 618 | go SetBlockListFromURL() 619 | go SetIPBlockListFromURL() 620 | } 621 | 622 | return true 623 | } 624 | func RegFlag() { 625 | flag.BoolVar(&shortFlag_ShowVersion, "v", false, GetLangText("ProgramVersion")) 626 | flag.BoolVar(&longFlag_ShowVersion, "version", false, GetLangText("ProgramVersion")) 627 | flag.StringVar(&shortFlag_configFilename, "c", "", GetLangText("ConfigPath")) 628 | flag.StringVar(&longFlag_configFilename, "config", "", GetLangText("ConfigPath")) 629 | flag.StringVar(&shortFlag_additionConfigFilename, "ca", "", GetLangText("AdditionalConfigPath")) 630 | flag.StringVar(&longFlag_additionConfigFilename, "config_additional", "", GetLangText("AdditionalConfigPath")) 631 | flag.BoolVar(&config.Debug, "debug", false, GetLangText("DebugMode")) 632 | flag.UintVar(&startDelay, "startdelay", 0, GetLangText("StartDelay")) 633 | flag.BoolVar(&noChdir, "nochdir", false, GetLangText("NoChdir")) 634 | flag.BoolVar(&needRegHotKey, "reghotkey", true, GetLangText("RegHotKey")) 635 | flag.BoolVar(&needHideWindow, "hidewindow", false, GetLangText("HideWindow")) 636 | flag.BoolVar(&needHideSystray, "hidesystray", false, GetLangText("HideSystray")) 637 | flag.Parse() 638 | } 639 | func ShowVersion() { 640 | Log("ShowVersion", "%s %s", false, programName, programVersion) 641 | } 642 | func PrepareEnv() bool { 643 | LoadLang(GetLangCode()) 644 | RegFlag() 645 | ShowVersion() 646 | log.SetFlags(0) 647 | log.SetOutput(logwriter) 648 | 649 | if shortFlag_ShowVersion || longFlag_ShowVersion { 650 | return false 651 | } 652 | 653 | if longFlag_configFilename != "" { 654 | configFilename = longFlag_configFilename 655 | } else if shortFlag_configFilename != "" { 656 | configFilename = shortFlag_configFilename 657 | } 658 | 659 | if longFlag_additionConfigFilename != "" { 660 | additionConfigFilename = longFlag_additionConfigFilename 661 | } else if shortFlag_additionConfigFilename != "" { 662 | additionConfigFilename = shortFlag_additionConfigFilename 663 | } 664 | 665 | path, err := os.Executable() 666 | if err != nil { 667 | Log("PrepareEnv", GetLangText("Error-DetectProgramPath"), false, err.Error()) 668 | return false 669 | } 670 | 671 | if !noChdir { 672 | programDir := filepath.Dir(path) 673 | if os.Chdir(programDir) == nil { 674 | Log("PrepareEnv", GetLangText("Success-ChangeWorkingDir"), false, programDir) 675 | LoadLang(GetLangCode()) 676 | } else { 677 | Log("PrepareEnv", GetLangText("Failed-ChangeWorkingDir"), false, programDir) 678 | } 679 | } 680 | 681 | return true 682 | } 683 | --------------------------------------------------------------------------------