diff --git a/README.md b/README.md index 07e48b9..b703e5c 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/dist/ztorrent-darwin-amd64 b/dist/ztorrent-darwin-amd64 new file mode 100755 index 0000000..f4d34dc Binary files /dev/null and b/dist/ztorrent-darwin-amd64 differ diff --git a/dist/ztorrent-darwin-arm64 b/dist/ztorrent-darwin-arm64 new file mode 100755 index 0000000..f89622e Binary files /dev/null and b/dist/ztorrent-darwin-arm64 differ diff --git a/dist/ztorrent-linux-amd64 b/dist/ztorrent-linux-amd64 new file mode 100755 index 0000000..1387719 Binary files /dev/null and b/dist/ztorrent-linux-amd64 differ diff --git a/dist/ztorrent-linux-arm64 b/dist/ztorrent-linux-arm64 new file mode 100755 index 0000000..69641cc Binary files /dev/null and b/dist/ztorrent-linux-arm64 differ diff --git a/dist/ztorrent-windows-amd64.exe b/dist/ztorrent-windows-amd64.exe new file mode 100755 index 0000000..bee72c3 Binary files /dev/null and b/dist/ztorrent-windows-amd64.exe differ diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go index 73a5ceb..44c4ea4 100644 --- a/internal/tracker/tracker.go +++ b/internal/tracker/tracker.go @@ -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 +} diff --git a/internal/tracker/tracker_test.go b/internal/tracker/tracker_test.go index 87f3a61..50d827d 100644 --- a/internal/tracker/tracker_test.go +++ b/internal/tracker/tracker_test.go @@ -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) } diff --git a/scripts/build-all.sh b/scripts/build-all.sh new file mode 100755 index 0000000..5958303 --- /dev/null +++ b/scripts/build-all.sh @@ -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}" diff --git a/ui/window.go b/ui/window.go index 7390ce4..62ad9e1 100644 --- a/ui/window.go +++ b/ui/window.go @@ -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(` + + + + + Ztorrent + + + +
+
+

Ztorrent

+

Load a torrent by path or upload .torrent file

+
+
+ + + + + +
+
+ {{if .Message}}
{{.Message}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
+ +
+

Status

+ {{if .Status.Loaded}} +
+
Name
{{.Status.Name}}
+
Size
{{.Status.Length}} bytes
+
Piece Length
{{.Status.PieceLength}} bytes
+
Pieces
{{.Status.PieceCount}}
+
Files
{{len .Status.Files}}
+
Peers
{{.Status.PeerCount}}
+
Tracker
{{.Status.Announce}}
+
+ + + + + + {{range .Status.Files}} + + {{end}} + +
FileSize (bytes)
{{.Path}}{{.Length}}
+ {{else}} +

No torrent loaded yet.

+ {{end}} +
+
+ +`)) + func Start(controller *app.Controller) { - fmt.Println("Ztorrent") + ui := &webUI{controller: controller} - if len(os.Args) < 2 { - fmt.Println("Usage: torrent-client ") + 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) } }