ебаааать

This commit is contained in:
itexpert228 2026-03-06 12:14:23 +03:00
parent cf79db0932
commit 9978610cae
10 changed files with 600 additions and 30 deletions

View file

@ -5,14 +5,23 @@ Minimal BitTorrent client skeleton in Go.
## Run
```bash
go run ./cmd/torrent-client /path/to/file.torrent
go run ./cmd/torrent-client
```
App starts a local GUI in your browser (auto-opens on macOS/Linux/Windows).
## Current features
- Parse `.torrent` metadata and piece hashes
- Compute `info_hash` from raw `info` bytes (spec-compatible)
- Single-file and multi-file torrents
- Send tracker announce request with compact peer mode
- Send tracker announce requests via HTTP(S) and UDP trackers
- Parse compact peer list
- Show loaded torrent status and files in the UI layer
- Browser GUI for loading torrents by path or file upload
- Show loaded torrent status and files in the GUI
## Build (macOS/Linux/Windows)
```bash
./scripts/build-all.sh
```

BIN
dist/ztorrent-darwin-amd64 vendored Executable file

Binary file not shown.

BIN
dist/ztorrent-darwin-arm64 vendored Executable file

Binary file not shown.

BIN
dist/ztorrent-linux-amd64 vendored Executable file

Binary file not shown.

BIN
dist/ztorrent-linux-arm64 vendored Executable file

Binary file not shown.

BIN
dist/ztorrent-windows-amd64.exe vendored Executable file

Binary file not shown.

View file

@ -2,6 +2,8 @@ package tracker
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
@ -40,9 +42,26 @@ type httpDoer interface {
}
func GetPeers(ctx context.Context, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
if tf == nil {
return nil, errors.New("torrent metadata is nil")
}
opts = normalizeOptions(opts)
client := &http.Client{Timeout: opts.Timeout}
return getPeersWithClient(ctx, client, tf, opts)
announceURL, err := url.Parse(tf.Announce)
if err != nil {
return nil, fmt.Errorf("invalid tracker URL %q: %w", tf.Announce, err)
}
switch announceURL.Scheme {
case "http", "https":
client := &http.Client{Timeout: opts.Timeout}
return getPeersWithClient(ctx, client, tf, opts)
case "udp":
return getPeersUDP(ctx, announceURL, tf, opts)
default:
return nil, fmt.Errorf("unsupported tracker scheme %q (only http/https/udp are supported)", announceURL.Scheme)
}
}
func getPeersWithClient(ctx context.Context, client httpDoer, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
@ -90,6 +109,79 @@ func getPeersWithClient(ctx context.Context, client httpDoer, tf *torrentfile.To
return parsePeers([]byte(tr.Peers))
}
func getPeersUDP(ctx context.Context, announceURL *url.URL, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
if announceURL == nil {
return nil, errors.New("tracker URL is nil")
}
if announceURL.Scheme != "udp" {
return nil, fmt.Errorf("tracker URL scheme %q is not udp", announceURL.Scheme)
}
if announceURL.Host == "" {
return nil, errors.New("tracker URL host is empty")
}
dialer := net.Dialer{Timeout: opts.Timeout}
conn, err := dialer.DialContext(ctx, "udp", announceURL.Host)
if err != nil {
return nil, err
}
defer conn.Close()
deadline := time.Now().Add(opts.Timeout)
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
deadline = ctxDeadline
}
if err := conn.SetDeadline(deadline); err != nil {
return nil, err
}
connectTx, err := randomUint32()
if err != nil {
return nil, err
}
connectReq := buildUDPConnectRequest(connectTx)
if _, err := conn.Write(connectReq[:]); err != nil {
return nil, err
}
resp := make([]byte, 65535)
n, err := conn.Read(resp)
if err != nil {
return nil, err
}
connectionID, err := parseUDPConnectResponse(resp[:n], connectTx)
if err != nil {
return nil, err
}
announceTx, err := randomUint32()
if err != nil {
return nil, err
}
key, err := randomUint32()
if err != nil {
return nil, err
}
announceReq, err := buildUDPAnnounceRequest(connectionID, announceTx, tf, opts, key)
if err != nil {
return nil, err
}
if _, err := conn.Write(announceReq); err != nil {
return nil, err
}
n, err = conn.Read(resp)
if err != nil {
return nil, err
}
return parseUDPAnnounceResponse(resp[:n], announceTx)
}
func normalizeOptions(opts AnnounceOptions) AnnounceOptions {
if opts.Port == 0 {
opts.Port = 6881
@ -154,6 +246,105 @@ func buildAnnounceURL(tf *torrentfile.TorrentFile, opts AnnounceOptions) (string
return baseURL.String(), nil
}
func buildUDPConnectRequest(transactionID uint32) [16]byte {
var req [16]byte
binary.BigEndian.PutUint64(req[0:8], 0x41727101980)
binary.BigEndian.PutUint32(req[8:12], 0)
binary.BigEndian.PutUint32(req[12:16], transactionID)
return req
}
func parseUDPConnectResponse(payload []byte, expectedTransactionID uint32) (uint64, error) {
if len(payload) < 8 {
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
}
action := binary.BigEndian.Uint32(payload[0:4])
transactionID := binary.BigEndian.Uint32(payload[4:8])
if transactionID != expectedTransactionID {
return 0, fmt.Errorf("udp tracker connect transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
}
if action == 3 {
return 0, parseUDPTrackerError(payload)
}
if action != 0 {
return 0, fmt.Errorf("unexpected udp tracker connect action %d", action)
}
if len(payload) < 16 {
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
}
return binary.BigEndian.Uint64(payload[8:16]), nil
}
func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, tf *torrentfile.TorrentFile, opts AnnounceOptions, key uint32) ([]byte, error) {
if tf == nil {
return nil, errors.New("torrent metadata is nil")
}
uploaded := opts.Uploaded
if uploaded < 0 {
uploaded = 0
}
downloaded := opts.Downloaded
if downloaded < 0 {
downloaded = 0
}
left := int64(tf.Length) - downloaded
if left < 0 {
left = 0
}
req := make([]byte, 98)
binary.BigEndian.PutUint64(req[0:8], connectionID)
binary.BigEndian.PutUint32(req[8:12], 1)
binary.BigEndian.PutUint32(req[12:16], transactionID)
copy(req[16:36], tf.InfoHash[:])
copy(req[36:56], opts.PeerID[:])
binary.BigEndian.PutUint64(req[56:64], uint64(downloaded))
binary.BigEndian.PutUint64(req[64:72], uint64(left))
binary.BigEndian.PutUint64(req[72:80], uint64(uploaded))
binary.BigEndian.PutUint32(req[80:84], 0)
binary.BigEndian.PutUint32(req[84:88], 0)
binary.BigEndian.PutUint32(req[88:92], key)
binary.BigEndian.PutUint32(req[92:96], uint32(int32(opts.NumWant)))
binary.BigEndian.PutUint16(req[96:98], opts.Port)
return req, nil
}
func parseUDPAnnounceResponse(payload []byte, expectedTransactionID uint32) ([]Peer, error) {
if len(payload) < 8 {
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
}
action := binary.BigEndian.Uint32(payload[0:4])
transactionID := binary.BigEndian.Uint32(payload[4:8])
if transactionID != expectedTransactionID {
return nil, fmt.Errorf("udp tracker announce transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
}
if action == 3 {
return nil, parseUDPTrackerError(payload)
}
if action != 1 {
return nil, fmt.Errorf("unexpected udp tracker announce action %d", action)
}
if len(payload) < 20 {
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
}
return parsePeers(payload[20:])
}
func parseUDPTrackerError(payload []byte) error {
if len(payload) > 8 {
return fmt.Errorf("udp tracker failure: %s", strings.TrimSpace(string(payload[8:])))
}
return errors.New("udp tracker failure")
}
func escapeBinary(data []byte) string {
const hex = "0123456789ABCDEF"
@ -210,3 +401,11 @@ func parsePeers(data []byte) ([]Peer, error) {
return peers, nil
}
func randomUint32() (uint32, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b[:]), nil
}

View file

@ -3,6 +3,7 @@ package tracker
import (
"bytes"
"context"
"encoding/binary"
"io"
"net/http"
"strings"
@ -189,6 +190,86 @@ func TestGetPeersReturnsTrackerFailure(t *testing.T) {
}
}
func TestParseUDPConnectResponse(t *testing.T) {
t.Parallel()
transactionID := uint32(42)
connectionID := uint64(0x0102030405060708)
payload := make([]byte, 16)
binary.BigEndian.PutUint32(payload[0:4], 0)
binary.BigEndian.PutUint32(payload[4:8], transactionID)
binary.BigEndian.PutUint64(payload[8:16], connectionID)
got, err := parseUDPConnectResponse(payload, transactionID)
if err != nil {
t.Fatalf("parseUDPConnectResponse returned error: %v", err)
}
if got != connectionID {
t.Fatalf("unexpected connection ID: got %x want %x", got, connectionID)
}
}
func TestParseUDPAnnounceResponse(t *testing.T) {
t.Parallel()
transactionID := uint32(99)
payload := make([]byte, 20+6)
binary.BigEndian.PutUint32(payload[0:4], 1)
binary.BigEndian.PutUint32(payload[4:8], transactionID)
binary.BigEndian.PutUint32(payload[8:12], 1800)
binary.BigEndian.PutUint32(payload[12:16], 10)
binary.BigEndian.PutUint32(payload[16:20], 20)
copy(payload[20:], []byte{127, 0, 0, 1, 0x1A, 0xE1})
peers, err := parseUDPAnnounceResponse(payload, transactionID)
if err != nil {
t.Fatalf("parseUDPAnnounceResponse returned error: %v", err)
}
if len(peers) != 1 {
t.Fatalf("unexpected peer count: got %d", len(peers))
}
if got, want := peers[0].Port, uint16(6881); got != want {
t.Fatalf("unexpected peer port: got %d want %d", got, want)
}
}
func TestBuildUDPAnnounceRequest(t *testing.T) {
t.Parallel()
tf := &torrentfile.TorrentFile{
Length: 100,
}
copy(tf.InfoHash[:], []byte("abcdefghijklmnopqrst"))
var peerID [20]byte
copy(peerID[:], []byte("-ZT0001-123456789012"))
req, err := buildUDPAnnounceRequest(0x0102030405060708, 42, tf, AnnounceOptions{
PeerID: peerID,
Port: 6881,
Uploaded: -1,
Downloaded: 20,
NumWant: 50,
}, 0xAABBCCDD)
if err != nil {
t.Fatalf("buildUDPAnnounceRequest returned error: %v", err)
}
if len(req) != 98 {
t.Fatalf("unexpected announce request length: got %d", len(req))
}
if got, want := binary.BigEndian.Uint32(req[8:12]), uint32(1); got != want {
t.Fatalf("unexpected action: got %d want %d", got, want)
}
if got, want := binary.BigEndian.Uint64(req[64:72]), uint64(80); got != want {
t.Fatalf("unexpected left bytes: got %d want %d", got, want)
}
if got, want := binary.BigEndian.Uint64(req[72:80]), uint64(0); got != want {
t.Fatalf("unexpected uploaded bytes: got %d want %d", got, want)
}
}
type fakeClient struct {
do func(req *http.Request) (*http.Response, error)
}

32
scripts/build-all.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST_DIR="${ROOT_DIR}/dist"
mkdir -p "${DIST_DIR}"
: "${GOCACHE:=/tmp/go-build}"
mkdir -p "${GOCACHE}"
TARGETS=(
"darwin amd64"
"darwin arm64"
"linux amd64"
"linux arm64"
"windows amd64"
)
for target in "${TARGETS[@]}"; do
read -r goos goarch <<<"${target}"
ext=""
if [[ "${goos}" == "windows" ]]; then
ext=".exe"
fi
output="${DIST_DIR}/ztorrent-${goos}-${goarch}${ext}"
echo "Building ${output}"
GOCACHE="${GOCACHE}" CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" go build -o "${output}" ./cmd/torrent-client
done
echo "Build complete. Artifacts are in ${DIST_DIR}"

View file

@ -1,42 +1,291 @@
package ui
import (
"errors"
"fmt"
"html/template"
"io"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/veggiedefender/torrent-client/internal/app"
"github.com/veggiedefender/torrent-client/internal/torrent"
)
type webUI struct {
controller *app.Controller
}
type pageData struct {
Status torrent.Status
Message string
Error string
}
var pageTemplate = template.Must(template.New("index").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ztorrent</title>
<style>
:root {
--bg: #0f172a;
--card: #111827;
--text: #e5e7eb;
--muted: #94a3b8;
--accent: #22c55e;
--danger: #ef4444;
--border: #1f2937;
}
body {
margin: 0;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background: radial-gradient(circle at top, #1f2937 0%, var(--bg) 45%);
color: var(--text);
}
.wrap {
max-width: 960px;
margin: 24px auto;
padding: 0 16px 24px;
}
.card {
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--card), transparent 10%);
backdrop-filter: blur(6px);
padding: 16px;
margin-bottom: 16px;
}
h1, h2 { margin: 0 0 12px; }
.sub { color: var(--muted); margin: 0 0 16px; }
label { display: block; margin-bottom: 8px; font-weight: 600; }
input[type="text"], input[type="file"] {
width: 100%;
box-sizing: border-box;
border: 1px solid #334155;
background: #0b1220;
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
}
.row { display: grid; gap: 10px; grid-template-columns: 1fr; }
button {
border: 0;
border-radius: 10px;
padding: 10px 14px;
color: #06120a;
background: var(--accent);
font-weight: 700;
cursor: pointer;
}
.msg, .err {
margin-top: 10px;
border-radius: 8px;
padding: 10px 12px;
}
.msg { background: color-mix(in srgb, var(--accent), transparent 85%); }
.err { background: color-mix(in srgb, var(--danger), transparent 85%); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.kv {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
background: #0b1220;
}
.k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.v { margin-top: 6px; font-weight: 600; word-break: break-word; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 14px;
}
th, td {
border-bottom: 1px solid var(--border);
padding: 8px 4px;
text-align: left;
}
th { color: var(--muted); font-weight: 600; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Ztorrent</h1>
<p class="sub">Load a torrent by path or upload .torrent file</p>
<form method="post" action="/load" enctype="multipart/form-data">
<div class="row">
<label for="path">Torrent path</label>
<input type="text" name="path" id="path" placeholder="/path/to/file.torrent">
<label for="torrent-file">Or upload .torrent</label>
<input type="file" name="torrent_file" id="torrent-file" accept=".torrent,application/x-bittorrent">
<button type="submit">Load Torrent</button>
</div>
</form>
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
</div>
<div class="card">
<h2>Status</h2>
{{if .Status.Loaded}}
<div class="grid">
<div class="kv"><div class="k">Name</div><div class="v">{{.Status.Name}}</div></div>
<div class="kv"><div class="k">Size</div><div class="v">{{.Status.Length}} bytes</div></div>
<div class="kv"><div class="k">Piece Length</div><div class="v">{{.Status.PieceLength}} bytes</div></div>
<div class="kv"><div class="k">Pieces</div><div class="v">{{.Status.PieceCount}}</div></div>
<div class="kv"><div class="k">Files</div><div class="v">{{len .Status.Files}}</div></div>
<div class="kv"><div class="k">Peers</div><div class="v">{{.Status.PeerCount}}</div></div>
<div class="kv"><div class="k">Tracker</div><div class="v">{{.Status.Announce}}</div></div>
</div>
<table>
<thead>
<tr><th>File</th><th>Size (bytes)</th></tr>
</thead>
<tbody>
{{range .Status.Files}}
<tr><td>{{.Path}}</td><td>{{.Length}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="sub">No torrent loaded yet.</p>
{{end}}
</div>
</div>
</body>
</html>`))
func Start(controller *app.Controller) {
fmt.Println("Ztorrent")
ui := &webUI{controller: controller}
if len(os.Args) < 2 {
fmt.Println("Usage: torrent-client <path-to-torrent-file>")
mux := http.NewServeMux()
mux.HandleFunc("/", ui.handleIndex)
mux.HandleFunc("/load", ui.handleLoad)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Printf("Failed to start GUI listener: %v\n", err)
return
}
defer listener.Close()
path := os.Args[1]
if err := controller.StartTorrent(path); err != nil {
fmt.Printf("Warning: tracker announce failed: %v\n", err)
}
guiURL := "http://" + listener.Addr().String()
fmt.Printf("Ztorrent GUI started at %s\n", guiURL)
openBrowser(guiURL)
status := controller.Status()
if !status.Loaded {
fmt.Println("No torrent metadata loaded.")
return
}
fmt.Printf("Name: %s\n", status.Name)
fmt.Printf("Size: %d bytes\n", status.Length)
fmt.Printf("Piece length: %d bytes\n", status.PieceLength)
fmt.Printf("Pieces: %d\n", status.PieceCount)
fmt.Printf("Files: %d\n", len(status.Files))
for _, file := range status.Files {
fmt.Printf(" - %s (%d bytes)\n", file.Path, file.Length)
}
fmt.Printf("Tracker: %s\n", status.Announce)
fmt.Printf("Peers: %d\n", status.PeerCount)
if status.LastError != "" {
fmt.Printf("Last error: %s\n", status.LastError)
if err := http.Serve(listener, mux); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Printf("GUI server error: %v\n", err)
}
}
func (ui *webUI) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ui.renderPage(w, pageData{Status: ui.controller.Status()})
}
func (ui *webUI) handleLoad(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
ui.renderPage(w, pageData{
Status: ui.controller.Status(),
Error: fmt.Sprintf("failed to parse form: %v", err),
})
return
}
path := strings.TrimSpace(r.FormValue("path"))
var cleanupPath string
if path == "" {
uploadedPath, err := saveUploadedTorrent(r)
if err != nil {
ui.renderPage(w, pageData{
Status: ui.controller.Status(),
Error: fmt.Sprintf("failed to read uploaded file: %v", err),
})
return
}
path = uploadedPath
cleanupPath = uploadedPath
}
if cleanupPath != "" {
defer os.Remove(cleanupPath)
}
loadErr := ui.controller.StartTorrent(path)
status := ui.controller.Status()
data := pageData{
Status: status,
}
if loadErr != nil {
data.Error = loadErr.Error()
if status.Loaded {
data.Message = fmt.Sprintf("Metadata loaded from %s, but tracker announce failed", path)
}
} else {
data.Message = fmt.Sprintf("Loaded torrent from %s", path)
}
ui.renderPage(w, data)
}
func (ui *webUI) renderPage(w http.ResponseWriter, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := pageTemplate.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func saveUploadedTorrent(r *http.Request) (string, error) {
file, _, err := r.FormFile("torrent_file")
if err != nil {
return "", err
}
defer file.Close()
tmp, err := os.CreateTemp("", "ztorrent-*.torrent")
if err != nil {
return "", err
}
defer tmp.Close()
if _, err := io.Copy(tmp, file); err != nil {
return "", err
}
return tmp.Name(), nil
}
func openBrowser(targetURL string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", targetURL)
case "darwin":
cmd = exec.Command("open", targetURL)
default:
cmd = exec.Command("xdg-open", targetURL)
}
if err := cmd.Start(); err != nil {
fmt.Printf("Failed to open browser automatically: %v\nOpen manually: %s\n", err, targetURL)
}
}