unarr/internal/vpn/vpn.go
Deivid Soto bf279ca5ad 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.
2026-05-20 23:16:54 +02:00

315 lines
8.7 KiB
Go

// 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
}