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:
parent
060a3e48db
commit
bf279ca5ad
7 changed files with 393 additions and 1 deletions
3
go.mod
3
go.mod
|
|
@ -18,6 +18,7 @@ require (
|
||||||
github.com/torrentclaw/go-client v0.2.0
|
github.com/torrentclaw/go-client v0.2.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -127,6 +128,8 @@ require (
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.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
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|
|
||||||
6
go.sum
6
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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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/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.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/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=
|
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -19,6 +20,7 @@ import (
|
||||||
"github.com/torrentclaw/unarr/internal/library"
|
"github.com/torrentclaw/unarr/internal/library"
|
||||||
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
||||||
"github.com/torrentclaw/unarr/internal/usenet/download"
|
"github.com/torrentclaw/unarr/internal/usenet/download"
|
||||||
|
"github.com/torrentclaw/unarr/internal/vpn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newStartCmd creates the top-level `unarr start` command.
|
// newStartCmd creates the top-level `unarr start` command.
|
||||||
|
|
@ -193,6 +195,35 @@ func runDaemonStart() error {
|
||||||
reporter := engine.NewProgressReporter(agentClient, statusInterval)
|
reporter := engine.NewProgressReporter(agentClient, statusInterval)
|
||||||
reporter.SetWatchingFunc(func() bool { return d.Watching.Load() })
|
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
|
// Create torrent downloader
|
||||||
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
|
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
|
||||||
DataDir: cfg.Download.Dir,
|
DataDir: cfg.Download.Dir,
|
||||||
|
|
@ -206,6 +237,7 @@ func runDaemonStart() error {
|
||||||
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
||||||
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
||||||
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
||||||
|
VPNTunnel: vpnTunnel,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create torrent downloader: %w", err)
|
return fmt.Errorf("create torrent downloader: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
||||||
var Version = "0.8.1"
|
var Version = "0.9.0"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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"`
|
WebRTC WebRTCConfig `toml:"webrtc"`
|
||||||
Transcode TranscodeConfig `toml:"transcode"`
|
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
|
// TranscodeConfig controls real-time transcoding for the in-browser player
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/anacrolix/torrent/storage"
|
"github.com/anacrolix/torrent/storage"
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
"github.com/torrentclaw/unarr/internal/vpn"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
@ -79,6 +80,11 @@ type TorrentConfig struct {
|
||||||
WebRTCEnabled bool
|
WebRTCEnabled bool
|
||||||
WebRTCTrackers []string // wss://… signaling trackers added to every magnet
|
WebRTCTrackers []string // wss://… signaling trackers added to every magnet
|
||||||
ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal
|
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.
|
// 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).
|
// Re-announce active torrents to DHT periodically (keeps routing table healthy).
|
||||||
tcfg.PeriodicallyAnnounceTorrentsToDht = true
|
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.
|
// Try to create client; if the port is in use, try the next few ports.
|
||||||
var client *torrent.Client
|
var client *torrent.Client
|
||||||
var err error
|
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)
|
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).
|
// Restore DHT nodes with full node IDs (direct routing table insertion, no async pings).
|
||||||
for _, s := range client.DhtServers() {
|
for _, s := range client.DhtServers() {
|
||||||
if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok {
|
if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok {
|
||||||
|
|
|
||||||
315
internal/vpn/vpn.go
Normal file
315
internal/vpn/vpn.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue