unarr/internal/agent/state.go
Deivid Soto 5d44ee704c
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
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.
2026-05-22 08:33:02 +02:00

83 lines
2.6 KiB
Go

package agent
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/torrentclaw/unarr/internal/config"
)
// DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct {
AgentID string `json:"agentId"`
Status string `json:"status"` // running | upgrading | shutting_down
Version string `json:"version"`
PID int `json:"pid"`
StartedAt time.Time `json:"startedAt"`
LastHeartbeat time.Time `json:"lastHeartbeat"`
ActiveTasks int `json:"activeTasks"`
CompletedCount int `json:"completedCount"`
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.
var stateFilePathFn = func() string {
return filepath.Join(config.DataDir(), "daemon.state.json")
}
// StateFilePath returns the path to the daemon state file.
func StateFilePath() string {
return stateFilePathFn()
}
// WriteState writes the daemon state to disk (best-effort, never errors).
func WriteState(state *DaemonState) {
path := StateFilePath()
dir := filepath.Dir(path)
os.MkdirAll(dir, 0o755)
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return
}
// Write to temp file then rename for atomicity. 0o600 keeps the file
// readable only by the owning user — the state contains agentID, PID
// and counters which are useful to a co-tenant on a shared host for
// fingerprinting the daemon, and we already use 0o600 for the config
// file. No need for cross-user readability here.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return
}
os.Rename(tmp, path)
}
// ReadState reads the daemon state from disk. Returns nil if not found.
func ReadState() *DaemonState {
data, err := os.ReadFile(StateFilePath())
if err != nil {
return nil
}
var state DaemonState
if json.Unmarshal(data, &state) != nil {
return nil
}
return &state
}
// RemoveState deletes the state file (called on clean shutdown).
func RemoveState() {
os.Remove(StateFilePath())
}