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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue