unarr/internal/cmd/vpn.go

214 lines
6.4 KiB
Go
Raw Normal View History

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