214 lines
6.4 KiB
Go
214 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"
|
||
|
|
}
|