diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c34bb..961db09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.2] - 2026-05-21 + +### Added + +- **vpn**: `unarr vpn` command (`status`, `enable`, `disable`) to manage the managed + WireGuard split-tunnel, with `vpn status --check` to verify provisioning. +- **vpn**: report split-tunnel state (active, exit server) to the web on register + + every sync, so the dashboard shows which agent holds the single WireGuard slot. +- **vpn**: send the agent id when fetching the VPN config so the web can arbitrate + the single WireGuard slot — the first agent claims it; the rest are told to run + OpenVPN on their own host (1 agent on WireGuard + up to 9 on OpenVPN). + ## [0.9.1] - 2026-05-21 diff --git a/README.md b/README.md index 102d151..6984bd0 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,9 @@ unarr start | `unarr status` | Show daemon status and active downloads | | `unarr daemon install` | Install as system service (systemd/launchd) | | `unarr daemon uninstall` | Remove the system service | +| `unarr vpn status` | Show managed-VPN config and live tunnel state | +| `unarr vpn enable` | Turn the managed VPN on | +| `unarr vpn disable` | Turn the managed VPN off | ### System & Diagnostics @@ -280,6 +283,53 @@ The daemon connects via WebSocket for instant task delivery, with automatic HTTP - Linux: `~/.config/systemd/user/unarr.service` (systemd) - macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd) +## VPN + +unarr can route your **downloads** through a managed WireGuard VPN, so peers and +trackers see the VPN server's IP instead of yours. It runs entirely in userspace +(wireguard-go + a gVisor netstack) — **no root, no `wg-quick`, no changes to your +OS routing table**. + +Requires a **PRO+ plan with the VPN add-on**. Set it up at +[torrentclaw.com/vpn](https://torrentclaw.com/vpn). + +```bash +# Turn it on (writes [downloads.vpn] enabled = true to your config) +unarr vpn enable + +# Restart the daemon so it brings the tunnel up at startup +unarr daemon restart # or: unarr start (if not installed as a service) + +# Check it's working — shows the exit server when the tunnel is up +unarr vpn status + +# Verify your account is provisioned (queries the API) +unarr vpn status --check + +# Turn it off again +unarr vpn disable +``` + +**Split-tunnel — read this:** only the torrent client's traffic goes through the +VPN. Your browser, `curl`, and every other app keep using your **real IP** — that +is by design. To check the VPN is working, look at `unarr vpn status` (or the +peer/announce IP), **not** your browser's "what's my IP". To protect your other +devices (phone, laptop), use the **OpenVPN credentials** from your profile — those +support ~10 concurrent devices and do **not** share the agent's WireGuard slot. + +**When does it fetch the config?** Once, at daemon startup. There's no periodic +refresh — after changing your exit server in the web panel or re-provisioning, +restart the daemon to pick it up. If the fetch fails the daemon logs a `[vpn]` +line and downloads in the clear (never refuses to run). + +**Self-hosted / personal VPN:** instead of the managed config, point unarr at a +local WireGuard `.conf`: + +```toml +[downloads.vpn] +config_file = "/path/to/wg.conf" # takes precedence over `enabled` +``` + ## Diagnostics ```bash @@ -438,6 +488,16 @@ If `transcode.enabled = true` but `ffmpeg` / `ffprobe` aren't on PATH, the daemon logs a warning at startup and HLS sessions are rejected at runtime with a clear error — install ffmpeg or set `enabled = false`. +#### `[downloads.vpn]` + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `enabled` | bool | `false` | Managed VPN: at startup the daemon fetches a WireGuard config from your account and split-tunnels torrent traffic through it. Needs a PRO+ plan with the VPN add-on. Toggle with `unarr vpn enable` / `disable`. | +| `config_file` | string | `""` | Self-hosted / personal VPN: path to a local WireGuard `.conf`. **Takes precedence over `enabled`** — when set, the daemon uses this file and never calls the API. | + +See the [VPN](#vpn) section above for how it works (split-tunnel, no root) and +how to protect your other devices. + ### Environment variables Environment variables override config file values: diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index 1c324d5..a8edc9b 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -48,6 +48,12 @@ type Daemon struct { State DaemonState lastNotifiedVersion string + // Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded + // into DaemonState on every write so external tools (`unarr vpn status`) see it. + vpnActive bool + vpnMode string + vpnServer string + // Watching tracks whether a user is viewing download progress in the web UI. Watching atomic.Bool @@ -70,6 +76,14 @@ func NewDaemon(cfg DaemonConfig, client *Client) *Daemon { // SyncClient returns the sync client for external wiring. func (d *Daemon) SyncClient() *SyncClient { return d.sync } +// SetVPNState records the managed-VPN split-tunnel state so it's reflected in the +// daemon state file (read by `unarr vpn status`). Call before Run. +func (d *Daemon) SetVPNState(active bool, mode, server string) { + d.vpnActive = active + d.vpnMode = mode + d.vpnServer = server +} + // UpdateStreamPort updates the stream port reported in sync requests. func (d *Daemon) UpdateStreamPort(port int) { d.cfg.StreamPort = port @@ -91,6 +105,9 @@ func (d *Daemon) Register(ctx context.Context) error { TailscaleIP: d.cfg.TailscaleIP, HWAccel: d.cfg.HWAccel, MaxTranscodeHeight: d.cfg.MaxTranscodeHeight, + VPNActive: d.vpnActive, + VPNMode: d.vpnMode, + VPNServer: d.vpnServer, } if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil { req.DiskFreeBytes = free @@ -141,6 +158,9 @@ func (d *Daemon) Register(ctx context.Context) error { PID: os.Getpid(), StartedAt: now, MethodStats: make(map[string]int), + VPNActive: d.vpnActive, + VPNMode: d.vpnMode, + VPNServer: d.vpnServer, } WriteState(&d.State) @@ -195,6 +215,9 @@ func (d *Daemon) Run(ctx context.Context) error { d.sync.OnWatchingChange = func(watching bool) { d.Watching.Store(watching) } + d.sync.GetVPNState = func() (bool, string, string) { + return d.vpnActive, d.vpnMode, d.vpnServer + } d.sync.OnSyncSuccess = func() { d.State.LastHeartbeat = time.Now() if d.GetActiveCount != nil { diff --git a/internal/agent/state.go b/internal/agent/state.go index 0bbd246..1de71bf 100644 --- a/internal/agent/state.go +++ b/internal/agent/state.go @@ -22,6 +22,13 @@ type DaemonState struct { FailedCount int `json:"failedCount"` TotalDownloaded int64 `json:"totalDownloaded"` MethodStats map[string]int `json:"methodStats,omitempty"` + + // Managed-VPN split-tunnel state, so `unarr vpn status` can report whether + // torrent traffic is actually being routed through the tunnel (vs. the daemon + // running but the tunnel having failed to come up → downloading in the clear). + VPNActive bool `json:"vpnActive,omitempty"` + VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted + VPNServer string `json:"vpnServer,omitempty"` // WireGuard endpoint (ip:port) } // stateFilePathFn is overridable for testing. diff --git a/internal/agent/sync.go b/internal/agent/sync.go index 864de8a..9847aba 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -36,6 +36,10 @@ type SyncClient struct { OnSyncSuccess func() // called after each successful sync (e.g. to update state file) GetFreeSlots func() int GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks + // GetVPNState returns the live managed-VPN split-tunnel state (whether the + // WireGuard tunnel is up, the mode, and the exit server) so the web can track + // which agent holds the single WG slot. + GetVPNState func() (active bool, mode, server string) // OnDeleteFiles is called when the server requests file deletion from disk. // It should delete the files and return the IDs of successfully deleted items. OnDeleteFiles func(items []LibraryDeleteRequest) []int @@ -155,6 +159,9 @@ func (sc *SyncClient) buildRequest() SyncRequest { if sc.GetFreeSlots != nil { req.FreeSlots = sc.GetFreeSlots() } + if sc.GetVPNState != nil { + req.VPNActive, req.VPNMode, req.VPNServer = sc.GetVPNState() + } // Flush confirmed deletions from previous cycle. // Once flushed, remove IDs from deleteInFlight — the server will stop sending // them after this sync, so deduplication protection is no longer needed. diff --git a/internal/agent/types.go b/internal/agent/types.go index 487e681..8e0094a 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -26,6 +26,14 @@ type RegisterRequest struct { // up to 2160p. HWAccel string `json:"hwAccel,omitempty"` MaxTranscodeHeight int `json:"maxTranscodeHeight,omitempty"` + // Managed-VPN split-tunnel state. The web tracks which agent holds the single + // WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent + // connection); other agents are told to use OpenVPN on their host instead. + // VPNActive has no omitempty: false is a meaningful state (tunnel down), not + // "unset" — the server must see it to release the slot. + VPNActive bool `json:"vpnActive"` + VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted + VPNServer string `json:"vpnServer,omitempty"` } // RegisterResponse is returned by the server after registration. @@ -344,6 +352,13 @@ type SyncRequest struct { Tasks []TaskState `json:"tasks"` CanDelete bool `json:"canDelete"` // library.allow_delete is enabled DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk + // Live managed-VPN split-tunnel state, sent every sync so the web sees the + // WireGuard slot owner update in near-realtime (vs. register, once at startup). + // VPNActive has no omitempty: false (tunnel down) must reach the server so it + // releases the slot, not be elided as "unset". + VPNActive bool `json:"vpnActive"` + VPNMode string `json:"vpnMode,omitempty"` + VPNServer string `json:"vpnServer,omitempty"` } // ControlAction represents a server-side control signal for a task. diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 771e9b4..54759b2 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -217,12 +217,12 @@ func runDaemonStart() error { apiURL = "https://torrentclaw.com" } fetchCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second) - conf, ferr := vpn.FetchConfig(fetchCtx, apiURL, cfg.Auth.APIKey, "unarr/"+Version) + conf, ferr := vpn.FetchConfig(fetchCtx, apiURL, cfg.Auth.APIKey, "unarr/"+Version, cfg.Agent.ID, false) 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.") + log.Printf("[vpn] the single WireGuard slot is already held by another unarr agent — this one downloads in the clear. To protect this machine too, set up OpenVPN on it (1 agent uses WireGuard, the rest use OpenVPN — up to 10). See https://torrentclaw.com/vpn") case ferr != nil: log.Printf("[vpn] could not enable VPN (%v) — downloading in the clear", ferr) default: @@ -236,6 +236,15 @@ func runDaemonStart() error { } } + // Record VPN split-tunnel state for `unarr vpn status`. + if vpnTunnel != nil { + mode := "managed" + if cfg.Download.VPN.ConfigFile != "" { + mode = "self-hosted" + } + d.SetVPNState(true, mode, vpnTunnel.Endpoint) + } + // Create torrent downloader torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ DataDir: cfg.Download.Dir, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 2217340..55786fb 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -103,6 +103,8 @@ Source: https://github.com/torrentclaw/unarr`, statusCmd.GroupID = "daemon" daemonCmd := newDaemonCmd() daemonCmd.GroupID = "daemon" + vpnCmd := newVPNCmd() + vpnCmd.GroupID = "daemon" // System & Diagnostics statsCmd := newStatsCmd() @@ -146,6 +148,7 @@ Source: https://github.com/torrentclaw/unarr`, stopCmd, statusCmd, daemonCmd, + vpnCmd, // System & Diagnostics statsCmd, doctorCmd, diff --git a/internal/cmd/version.go b/internal/cmd/version.go index e639749..18dac17 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.9.1" +var Version = "0.9.2" diff --git a/internal/cmd/vpn.go b/internal/cmd/vpn.go new file mode 100644 index 0000000..fb11532 --- /dev/null +++ b/internal/cmd/vpn.go @@ -0,0 +1,213 @@ +package cmd + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/torrentclaw/unarr/internal/agent" + "github.com/torrentclaw/unarr/internal/config" + "github.com/torrentclaw/unarr/internal/vpn" +) + +func newVPNCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "vpn", + Short: "Manage the managed-VPN split-tunnel for downloads", + Long: `Enable, disable, and inspect the managed VPN. + +When enabled, the daemon fetches a WireGuard config from your TorrentClaw account +at startup and routes ONLY the torrent client's traffic (peers + trackers) through +an in-process WireGuard tunnel — no root, no OS routing changes. + +This is split-tunnel: your browser and other apps keep using your real IP. Only +your downloads are hidden behind the VPN server. + +The VPN requires a PRO+ plan with the VPN add-on. Set it up at +https://torrentclaw.com/vpn and configure your other devices (phone, laptop) with +the OpenVPN credentials from your profile — those don't share the agent's tunnel.`, + Example: ` unarr vpn status # is the tunnel up? which server? + unarr vpn enable # turn the managed VPN on + unarr vpn disable # turn it off`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newVPNStatusCmd(), newVPNEnableCmd(), newVPNDisableCmd()) + return cmd +} + +func newVPNStatusCmd() *cobra.Command { + var check bool + cmd := &cobra.Command{ + Use: "status", + Short: "Show VPN configuration and live tunnel state", + Example: " unarr vpn status\n unarr vpn status --check # also verify your account is provisioned", + RunE: func(cmd *cobra.Command, args []string) error { + return runVPNStatus(check) + }, + } + cmd.Flags().BoolVar(&check, "check", false, "query the API to verify the VPN is provisioned on your account") + return cmd +} + +func runVPNStatus(check bool) error { + bold := color.New(color.Bold) + dim := color.New(color.FgHiBlack) + green := color.New(color.FgGreen) + yellow := color.New(color.FgYellow) + cyan := color.New(color.FgCyan) + + cfg := loadConfig() + + fmt.Println() + bold.Println(" Managed VPN") + fmt.Println() + + // ── Configured mode ── + switch { + case cfg.Download.VPN.ConfigFile != "": + cyan.Println(" Mode: self-hosted (local config_file)") + fmt.Printf(" Config: %s\n", cfg.Download.VPN.ConfigFile) + case cfg.Download.VPN.Enabled: + cyan.Println(" Mode: managed (config fetched from your account)") + default: + dim.Println(" Mode: off") + fmt.Println() + dim.Println(" Enable with `unarr vpn enable` (needs a PRO+ plan with the VPN add-on).") + fmt.Println() + return nil + } + + // ── Live tunnel state (from the daemon state file) ── + state := agent.ReadState() + alive := state != nil && isDaemonAlive(state) + fmt.Println() + switch { + case alive && state.VPNActive: + server := state.VPNServer + if host, _, err := net.SplitHostPort(server); err == nil && host != "" { + server = host + } + green.Println(" ✓ Tunnel ACTIVE — torrent traffic is routed through the VPN") + if server != "" { + fmt.Printf(" Exit server: %s\n", server) + } + case alive: + yellow.Println(" ⚠ Daemon is running but the tunnel is NOT up — downloads go in the clear.") + dim.Println(" Check `unarr daemon logs` for a [vpn] line. Common cause: no active") + dim.Println(" VPN on your account (set it up at https://torrentclaw.com/vpn).") + default: + dim.Println(" Daemon not running — start it (`unarr start`) to bring the tunnel up.") + } + + // ── Optional live provisioning check ── + if check { + fmt.Println() + if cfg.Auth.APIKey == "" { + yellow.Println(" ⚠ No API key — run `unarr init` first.") + } else { + apiURL := cfg.Auth.APIURL + if apiURL == "" { + apiURL = "https://torrentclaw.com" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, err := vpn.FetchConfig(ctx, apiURL, cfg.Auth.APIKey, "unarr/"+Version, cfg.Agent.ID, true) + cancel() + switch { + case err == nil: + green.Println(" ✓ Account provisioned — a VPN config is available.") + default: + yellow.Printf(" ⚠ %s\n", err) + } + } + } + + // ── Split-tunnel reminder ── + fmt.Println() + dim.Println(" Split-tunnel: only your downloads use the VPN. Your browser and other") + dim.Println(" apps keep your real IP — that's by design. Use the OpenVPN credentials in") + dim.Println(" your profile to protect your other devices.") + fmt.Println() + return nil +} + +func newVPNEnableCmd() *cobra.Command { + return &cobra.Command{ + Use: "enable", + Short: "Turn the managed VPN on", + Example: " unarr vpn enable", + RunE: func(cmd *cobra.Command, args []string) error { + return setVPNEnabled(true) + }, + } +} + +func newVPNDisableCmd() *cobra.Command { + return &cobra.Command{ + Use: "disable", + Short: "Turn the managed VPN off", + Example: " unarr vpn disable", + RunE: func(cmd *cobra.Command, args []string) error { + return setVPNEnabled(false) + }, + } +} + +func setVPNEnabled(enabled bool) error { + green := color.New(color.FgGreen) + yellow := color.New(color.FgYellow) + dim := color.New(color.FgHiBlack) + + cfg := loadConfig() + + if enabled && cfg.Auth.APIKey == "" { + return fmt.Errorf("no API key configured — run `unarr init` first (the managed VPN fetches its config from your account)") + } + + if cfg.Download.VPN.Enabled == enabled { + fmt.Println() + dim.Printf(" VPN is already %s — nothing to do.\n", enabledWord(enabled)) + fmt.Println() + return nil + } + + cfg.Download.VPN.Enabled = enabled + + configPath := config.FilePath() + if cfgFile != "" { + configPath = cfgFile + } + if err := config.Save(cfg, configPath); err != nil { + return fmt.Errorf("save config: %w", err) + } + appCfg = cfg + + fmt.Println() + green.Printf(" ✓ Managed VPN %s.\n", enabledWord(enabled)) + + if enabled && cfg.Download.VPN.ConfigFile != "" { + yellow.Println(" ⚠ A config_file is set, so self-hosted mode takes precedence and the") + yellow.Println(" managed config from your account is ignored. Clear config_file to use it.") + } + + // The tunnel is brought up once at daemon startup; a plain config reload does + // NOT (re)create it. Tell the user to restart the daemon if it's running. + if state := agent.ReadState(); state != nil && isDaemonAlive(state) { + fmt.Println() + dim.Println(" The daemon is running. Restart it for this to take effect:") + dim.Println(" unarr daemon restart") + } + fmt.Println() + return nil +} + +func enabledWord(enabled bool) string { + if enabled { + return "enabled" + } + return "disabled" +} diff --git a/internal/vpn/vpn.go b/internal/vpn/vpn.go index b8bab50..7f50ea1 100644 --- a/internal/vpn/vpn.go +++ b/internal/vpn/vpn.go @@ -18,6 +18,7 @@ import ( "net" "net/http" "net/netip" + neturl "net/url" "strconv" "strings" "time" @@ -56,9 +57,22 @@ type fetchResponse struct { } // 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) { +// `Authorization: Bearer ` (the agent-auth scheme). agentId lets the web +// arbitrate the single WireGuard slot (first agent to ask claims it; others get +// 409 → ErrSlotOnDevice and should use OpenVPN on their host instead). +func FetchConfig(ctx context.Context, apiURL, apiKey, userAgent, agentID string, probe bool) (string, error) { + q := neturl.Values{} + if agentID != "" { + q.Set("agentId", agentID) + } + if probe { + // Validate provisioning without claiming the WireGuard slot (status --check). + q.Set("probe", "1") + } url := strings.TrimSuffix(apiURL, "/") + "/api/internal/agent/vpn-config" + if len(q) > 0 { + url += "?" + q.Encode() + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", &FetchError{ErrUpstream, err.Error()} @@ -103,6 +117,10 @@ func FetchConfig(ctx context.Context, apiURL, apiKey, userAgent string) (string, type Tunnel struct { dev *device.Device Net *netstack.Net + // Endpoint is the resolved ip:port of the WireGuard server this tunnel + // exits through — surfaced in `unarr vpn status` so the user can see which + // VPN server their torrent traffic is routed out of. + Endpoint string } // Up parses a WireGuard .conf and brings up the tunnel in userspace. @@ -132,7 +150,7 @@ func Up(confText string) (*Tunnel, error) { return nil, fmt.Errorf("wireguard up: %w", err) } - return &Tunnel{dev: dev, Net: tnet}, nil + return &Tunnel{dev: dev, Net: tnet, Endpoint: wc.endpoint}, nil } // Close tears the tunnel down.