feat(vpn): split-tunnel torrent traffic through managed WireGuard

In-process userspace WireGuard tunnel (wireguard-go + gVisor netstack) for
the managed-VPN add-on. No root, no OS routing changes: only the embedded
anacrolix/torrent client's peer + tracker traffic is routed through the
tunnel, so the swarm and trackers see the VPN IP, not the user's home IP.
unarr's control plane (API, heartbeats) keeps using the normal net.

- internal/vpn: FetchConfig (GET /api/internal/agent/vpn-config, Bearer auth,
  typed errors for disabled/not_provisioned/slot_on_device) + Up (parse .conf
  → uapi, CreateNetTUN, device Up) + DialContext/ListenPacket adapters.
- engine/torrent.go: when a tunnel is set, wire TrackerDialContext +
  HTTPDialContext + TrackerListenPacket to netstack, DisableUTP, and
  AddDialer(NetworkDialer{tcp, netstack}) for peer conns.
- config: downloads.vpn.enabled flag.
- daemon: bring up the tunnel before the torrent client; non-fatal on
  failure (logs + downloads in the clear); slot_on_device warns the user.
- version bump 0.8.1 → 0.9.0.

Pairs with the web VPN add-on (dormant behind NEXT_PUBLIC_VPN_ENABLED).
Runtime-verified once a VPNResellers trial provides a live endpoint.
This commit is contained in:
Deivid Soto 2026-05-20 23:16:54 +02:00
parent 060a3e48db
commit bf279ca5ad
7 changed files with 393 additions and 1 deletions

View file

@ -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)

View file

@ -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"

View file

@ -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

View file

@ -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 {

315
internal/vpn/vpn.go Normal file
View file

@ -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 <apiKey>` (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
}