feat(vpn): unarr vpn command + report/arbitrate the WireGuard slot
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.
This commit is contained in:
parent
d0094e84bb
commit
5d44ee704c
11 changed files with 373 additions and 6 deletions
12
CHANGELOG.md
12
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
|
||||
|
||||
|
||||
|
|
|
|||
60
README.md
60
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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
213
internal/cmd/vpn.go
Normal file
213
internal/cmd/vpn.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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 <apiKey>` (the agent-auth scheme).
|
||||
func FetchConfig(ctx context.Context, apiURL, apiKey, userAgent string) (string, error) {
|
||||
// `Authorization: Bearer <apiKey>` (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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue