diff --git a/go.mod b/go.mod index 30c116e..e4e0b6b 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/torrentclaw/go-client v0.2.0 golang.org/x/term v0.41.0 golang.org/x/time v0.15.0 + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb ) require ( @@ -127,6 +128,8 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 47f09d2..ad123db 100644 --- a/go.sum +++ b/go.sum @@ -560,6 +560,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -587,6 +591,8 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index e63c96b..5a31c0d 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "log" "os" @@ -19,6 +20,7 @@ import ( "github.com/torrentclaw/unarr/internal/library" "github.com/torrentclaw/unarr/internal/library/mediainfo" "github.com/torrentclaw/unarr/internal/usenet/download" + "github.com/torrentclaw/unarr/internal/vpn" ) // newStartCmd creates the top-level `unarr start` command. @@ -193,6 +195,35 @@ func runDaemonStart() error { reporter := engine.NewProgressReporter(agentClient, statusInterval) reporter.SetWatchingFunc(func() bool { return d.Watching.Load() }) + // Managed-VPN add-on: bring up the in-process WireGuard split-tunnel before + // the torrent client so peer + tracker traffic routes through it. Failure is + // non-fatal — log and download in the clear (better than refusing to run). + var vpnTunnel *vpn.Tunnel + if cfg.Download.VPN.Enabled { + apiURL := cfg.Auth.APIURL + if apiURL == "" { + apiURL = "https://torrentclaw.com" + } + fetchCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + conf, ferr := vpn.FetchConfig(fetchCtx, apiURL, cfg.Auth.APIKey, "unarr/"+Version) + cancel() + var fe *vpn.FetchError + switch { + case ferr != nil && errors.As(ferr, &fe) && fe.Code == vpn.ErrSlotOnDevice: + log.Printf("[vpn] slot is active on one of your devices — downloads will NOT use the VPN. Switch the slot to unarr in your profile to protect downloads.") + case ferr != nil: + log.Printf("[vpn] could not enable VPN (%v) — downloading in the clear", ferr) + default: + if t, uerr := vpn.Up(conf); uerr != nil { + log.Printf("[vpn] tunnel failed to start (%v) — downloading in the clear", uerr) + } else { + vpnTunnel = t + defer vpnTunnel.Close() + log.Printf("[vpn] managed VPN active — torrent traffic split-tunnelled through WireGuard") + } + } + } + // Create torrent downloader torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ DataDir: cfg.Download.Dir, @@ -206,6 +237,7 @@ func runDaemonStart() error { WebRTCEnabled: cfg.Download.WebRTC.Enabled, WebRTCTrackers: cfg.Download.WebRTC.Trackers, ICEServers: engine.BuildICEServers(cfg.Download.WebRTC), + VPNTunnel: vpnTunnel, }) if err != nil { return fmt.Errorf("create torrent downloader: %w", err) diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 860f68c..9753ec4 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.8.1" +var Version = "0.9.0" diff --git a/internal/config/config.go b/internal/config/config.go index 0b00b45..b07f69d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,6 +53,16 @@ type DownloadConfig struct { CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030) WebRTC WebRTCConfig `toml:"webrtc"` Transcode TranscodeConfig `toml:"transcode"` + VPN VPNConfig `toml:"vpn"` +} + +// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon +// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and +// routes only the torrent client's peer/tracker traffic through an in-process +// userspace tunnel (no root, no OS routing changes). Requires an active VPN +// add-on on the account; otherwise the daemon logs and downloads in the clear. +type VPNConfig struct { + Enabled bool `toml:"enabled"` } // TranscodeConfig controls real-time transcoding for the in-browser player diff --git a/internal/engine/torrent.go b/internal/engine/torrent.go index 16821fe..445f317 100644 --- a/internal/engine/torrent.go +++ b/internal/engine/torrent.go @@ -18,6 +18,7 @@ import ( "github.com/anacrolix/torrent/storage" "github.com/pion/webrtc/v4" "github.com/torrentclaw/unarr/internal/config" + "github.com/torrentclaw/unarr/internal/vpn" "golang.org/x/term" "golang.org/x/time/rate" ) @@ -79,6 +80,11 @@ type TorrentConfig struct { WebRTCEnabled bool WebRTCTrackers []string // wss://… signaling trackers added to every magnet ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal + + // VPNTunnel, when set, split-tunnels the torrent client's peer + tracker + // traffic through an in-process userspace WireGuard tunnel (managed-VPN + // add-on). nil = downloads in the clear. Brought up by the daemon. + VPNTunnel *vpn.Tunnel } // TorrentDownloader downloads torrents via BitTorrent P2P. @@ -218,6 +224,20 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) { // Re-announce active torrents to DHT periodically (keeps routing table healthy). tcfg.PeriodicallyAnnounceTorrentsToDht = true + // --- Managed-VPN split-tunnel --- + // Route the torrent client's outbound peer + tracker traffic through the + // in-process WireGuard tunnel so the swarm + trackers see the VPN IP, not + // the user's. unarr's control plane keeps using the normal net. uTP (UDP + // peers) is disabled — TCP peers + HTTP/UDP tracker announces are tunnelled; + // inbound peers don't apply (leech-only, no port forward). + if cfg.VPNTunnel != nil { + tcfg.DisableUTP = true + tcfg.TrackerDialContext = cfg.VPNTunnel.Net.DialContext + tcfg.HTTPDialContext = cfg.VPNTunnel.Net.DialContext + tcfg.TrackerListenPacket = cfg.VPNTunnel.ListenPacket + log.Printf("[torrent] VPN split-tunnel enabled (peer + tracker traffic routed through WireGuard)") + } + // Try to create client; if the port is in use, try the next few ports. var client *torrent.Client var err error @@ -239,6 +259,12 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) { log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort) } + // Route outgoing peer dials through the VPN tunnel (TCP). Added after client + // creation; DialForPeerConns defaults to true so this is used for peers. + if cfg.VPNTunnel != nil { + client.AddDialer(torrent.NetworkDialer{Network: "tcp", Dialer: cfg.VPNTunnel.Net}) + } + // Restore DHT nodes with full node IDs (direct routing table insertion, no async pings). for _, s := range client.DhtServers() { if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok { diff --git a/internal/vpn/vpn.go b/internal/vpn/vpn.go new file mode 100644 index 0000000..b8bab50 --- /dev/null +++ b/internal/vpn/vpn.go @@ -0,0 +1,315 @@ +// Package vpn brings up an in-process WireGuard tunnel (userspace, via +// wireguard-go + gVisor netstack) and exposes it as a dialer so the BitTorrent +// client's peer/tracker traffic can be split-tunnelled through it — without +// touching the OS routing table or requiring root. +// +// The config is a standard WireGuard .conf fetched from the web +// (/api/internal/agent/vpn-config). Only the torrent client uses this tunnel; +// unarr's control-plane traffic (API, heartbeats) keeps using the normal net. +package vpn + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "net/netip" + "strconv" + "strings" + "time" + + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +// ErrCode classifies fetch failures the agent should react to differently. +type ErrCode string + +const ( + ErrDisabled ErrCode = "disabled" // 503 — VPN feature off server-side + ErrNotProvisioned ErrCode = "not_provisioned" // 403 — user has no active VPN + ErrSlotOnDevice ErrCode = "slot_on_device" // 409 — slot claimed by a device + ErrUpstream ErrCode = "upstream" // network / 5xx / parse +) + +// FetchError carries an ErrCode so callers can decide whether to retry, warn, or +// fall back to a clear (non-VPN) download. +type FetchError struct { + Code ErrCode + Msg string +} + +func (e *FetchError) Error() string { return fmt.Sprintf("vpn fetch: %s (%s)", e.Msg, e.Code) } + +type fetchResponse struct { + Content string `json:"content"` + Filename string `json:"filename"` + ServerID int `json:"serverId"` + Mode string `json:"mode"` + Error string `json:"error"` + CodeStr string `json:"code"` +} + +// FetchConfig retrieves the agent's WireGuard .conf from the web API. Auth is +// `Authorization: Bearer ` (the agent-auth scheme). +func FetchConfig(ctx context.Context, apiURL, apiKey, userAgent string) (string, error) { + url := strings.TrimSuffix(apiURL, "/") + "/api/internal/agent/vpn-config" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", &FetchError{ErrUpstream, err.Error()} + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", &FetchError{ErrUpstream, err.Error()} + } + defer resp.Body.Close() + + var body fetchResponse + _ = json.NewDecoder(resp.Body).Decode(&body) + + switch resp.StatusCode { + case http.StatusOK: + if body.Content == "" { + return "", &FetchError{ErrUpstream, "empty config"} + } + return body.Content, nil + case http.StatusServiceUnavailable: + return "", &FetchError{ErrDisabled, "VPN disabled server-side"} + case http.StatusForbidden: + return "", &FetchError{ErrNotProvisioned, "no active VPN for this account"} + case http.StatusConflict: + return "", &FetchError{ErrSlotOnDevice, "VPN slot is active on one of your devices"} + default: + msg := body.Error + if msg == "" { + msg = "unexpected status " + strconv.Itoa(resp.StatusCode) + } + return "", &FetchError{ErrUpstream, msg} + } +} + +// Tunnel is a live userspace WireGuard tunnel. Net exposes a DialContext + +// ListenUDP backed by the tunnel; wire these into the torrent client. +type Tunnel struct { + dev *device.Device + Net *netstack.Net +} + +// Up parses a WireGuard .conf and brings up the tunnel in userspace. +func Up(confText string) (*Tunnel, error) { + wc, err := parseConf(confText) + if err != nil { + return nil, err + } + + mtu := wc.mtu + if mtu == 0 { + mtu = 1420 + } + + tunDev, tnet, err := netstack.CreateNetTUN(wc.addresses, wc.dns, mtu) + if err != nil { + return nil, fmt.Errorf("create netstack tun: %w", err) + } + + dev := device.NewDevice(tunDev, conn.NewDefaultBind(), device.NewLogger(device.LogLevelError, "wg-unarr ")) + if err := dev.IpcSet(wc.uapi()); err != nil { + dev.Close() + return nil, fmt.Errorf("wireguard ipc set: %w", err) + } + if err := dev.Up(); err != nil { + dev.Close() + return nil, fmt.Errorf("wireguard up: %w", err) + } + + return &Tunnel{dev: dev, Net: tnet}, nil +} + +// Close tears the tunnel down. +func (t *Tunnel) Close() { + if t != nil && t.dev != nil { + t.dev.Close() + } +} + +// ListenPacket adapts the tunnel's UDP for anacrolix TrackerListenPacket so UDP +// tracker announces also go through the VPN (no IP leak to trackers). +func (t *Tunnel) ListenPacket(_ string, _ string) (net.PacketConn, error) { + return t.Net.ListenUDP(&net.UDPAddr{IP: net.IPv4zero, Port: 0}) +} + +// --- .conf parsing ---------------------------------------------------------- + +type wgConf struct { + privateKey string // hex + addresses []netip.Addr + dns []netip.Addr + mtu int + + peerPublicKey string // hex + presharedKey string // hex (optional) + endpoint string // resolved ip:port + allowedIPs []string + keepalive int +} + +func (w *wgConf) uapi() string { + var b strings.Builder + fmt.Fprintf(&b, "private_key=%s\n", w.privateKey) + fmt.Fprintf(&b, "public_key=%s\n", w.peerPublicKey) + if w.presharedKey != "" { + fmt.Fprintf(&b, "preshared_key=%s\n", w.presharedKey) + } + if w.endpoint != "" { + fmt.Fprintf(&b, "endpoint=%s\n", w.endpoint) + } + if w.keepalive > 0 { + fmt.Fprintf(&b, "persistent_keepalive_interval=%d\n", w.keepalive) + } + for _, a := range w.allowedIPs { + fmt.Fprintf(&b, "allowed_ip=%s\n", a) + } + return b.String() +} + +func b64ToHex(s string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s)) + if err != nil { + return "", fmt.Errorf("invalid base64 key: %w", err) + } + if len(raw) != 32 { + return "", fmt.Errorf("key must be 32 bytes, got %d", len(raw)) + } + return hex.EncodeToString(raw), nil +} + +func parseConf(text string) (*wgConf, error) { + w := &wgConf{keepalive: 25} + section := "" + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") { + section = strings.ToLower(strings.Trim(line, "[]")) + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.ToLower(strings.TrimSpace(key)) + val = strings.TrimSpace(val) + + switch section { + case "interface": + switch key { + case "privatekey": + hexKey, err := b64ToHex(val) + if err != nil { + return nil, err + } + w.privateKey = hexKey + case "address": + for _, part := range strings.Split(val, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + pfx, err := netip.ParsePrefix(part) + if err != nil { + // allow bare address + if a, e2 := netip.ParseAddr(part); e2 == nil { + w.addresses = append(w.addresses, a) + } + continue + } + w.addresses = append(w.addresses, pfx.Addr()) + } + case "dns": + for _, part := range strings.Split(val, ",") { + if a, err := netip.ParseAddr(strings.TrimSpace(part)); err == nil { + w.dns = append(w.dns, a) + } + } + case "mtu": + w.mtu, _ = strconv.Atoi(val) + } + case "peer": + switch key { + case "publickey": + hexKey, err := b64ToHex(val) + if err != nil { + return nil, err + } + w.peerPublicKey = hexKey + case "presharedkey": + if hexKey, err := b64ToHex(val); err == nil { + w.presharedKey = hexKey + } + case "endpoint": + ep, err := resolveEndpoint(val) + if err != nil { + return nil, err + } + w.endpoint = ep + case "allowedips": + for _, part := range strings.Split(val, ",") { + part = strings.TrimSpace(part) + if part != "" { + w.allowedIPs = append(w.allowedIPs, part) + } + } + case "persistentkeepalive": + if k, err := strconv.Atoi(val); err == nil { + w.keepalive = k + } + } + } + } + + if w.privateKey == "" || w.peerPublicKey == "" { + return nil, fmt.Errorf("config missing keys") + } + if len(w.addresses) == 0 { + return nil, fmt.Errorf("config missing interface address") + } + if len(w.dns) == 0 { + // Resolve tracker hostnames through the tunnel rather than leaking to the + // local resolver. Fall back to Cloudflare. + w.dns = []netip.Addr{netip.MustParseAddr("1.1.1.1")} + } + if len(w.allowedIPs) == 0 { + w.allowedIPs = []string{"0.0.0.0/0", "::/0"} + } + return w, nil +} + +// resolveEndpoint turns host:port into ip:port — wireguard-go's IpcSet endpoint +// expects a literal IP (it does not resolve DNS). Resolution uses the real net. +func resolveEndpoint(hostport string) (string, error) { + host, port, err := net.SplitHostPort(hostport) + if err != nil { + return "", fmt.Errorf("invalid endpoint %q: %w", hostport, err) + } + if ip := net.ParseIP(host); ip != nil { + return hostport, nil + } + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return "", fmt.Errorf("resolve endpoint %q: %w", host, err) + } + return net.JoinHostPort(ips[0].String(), port), nil +}