Add `unarr vpn` (status/enable/disable, with `status --check`) to manage the managed WireGuard split-tunnel from the CLI. The daemon now reports its split-tunnel state (active, mode, exit server) to the web on register and on every sync, and sends its agent id when fetching the VPN config so the web can arbitrate the single WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent connection): the first agent claims it; the rest are told to run OpenVPN on their own host (1 WireGuard + up to 9 OpenVPN = 10). `status --check` passes probe=1 so it validates provisioning without claiming the slot. VPNActive drops omitempty so a downed tunnel reaches the server and frees the slot. Bumps to 0.9.2 with CHANGELOG + README VPN section.
213 lines
6.4 KiB
Go
213 lines
6.4 KiB
Go
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"
|
|
}
|