In-process userspace WireGuard tunnel (wireguard-go + gVisor netstack) for
the managed-VPN add-on. No root, no OS routing changes: only the embedded
anacrolix/torrent client's peer + tracker traffic is routed through the
tunnel, so the swarm and trackers see the VPN IP, not the user's home IP.
unarr's control plane (API, heartbeats) keeps using the normal net.
- internal/vpn: FetchConfig (GET /api/internal/agent/vpn-config, Bearer auth,
typed errors for disabled/not_provisioned/slot_on_device) + Up (parse .conf
→ uapi, CreateNetTUN, device Up) + DialContext/ListenPacket adapters.
- engine/torrent.go: when a tunnel is set, wire TrackerDialContext +
HTTPDialContext + TrackerListenPacket to netstack, DisableUTP, and
AddDialer(NetworkDialer{tcp, netstack}) for peer conns.
- config: downloads.vpn.enabled flag.
- daemon: bring up the tunnel before the torrent client; non-fatal on
failure (logs + downloads in the clear); slot_on_device warns the user.
- version bump 0.8.1 → 0.9.0.
Pairs with the web VPN add-on (dormant behind NEXT_PUBLIC_VPN_ENABLED).
Runtime-verified once a VPNResellers trial provides a live endpoint.
417 lines
14 KiB
Go
417 lines
14 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// Config holds all persistent CLI configuration.
|
|
type Config struct {
|
|
Auth AuthConfig `toml:"auth"`
|
|
Agent AgentConfig `toml:"agent"`
|
|
Download DownloadConfig `toml:"downloads"`
|
|
Organize OrganizeConfig `toml:"organize"`
|
|
Daemon DaemonConfig `toml:"daemon"`
|
|
Notifications NotificationsConfig `toml:"notifications"`
|
|
General GeneralConfig `toml:"general"`
|
|
Library LibraryConfig `toml:"library"`
|
|
}
|
|
|
|
type AuthConfig struct {
|
|
APIKey string `toml:"api_key"`
|
|
APIURL string `toml:"api_url"`
|
|
// Mirrors lists alternate base URLs the agent will fall back to when the
|
|
// primary api_url is unreachable. Ordered by preference. Refreshed at
|
|
// runtime by `unarr mirrors update` against /api/v1/mirrors so a long-
|
|
// running agent survives a primary takedown without a new release.
|
|
Mirrors []string `toml:"mirrors"`
|
|
}
|
|
|
|
type AgentConfig struct {
|
|
ID string `toml:"id"`
|
|
Name string `toml:"name"`
|
|
}
|
|
|
|
type DownloadConfig struct {
|
|
Dir string `toml:"dir"`
|
|
PreferredMethod string `toml:"preferred_method"`
|
|
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
|
|
MaxConcurrent int `toml:"max_concurrent"`
|
|
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
|
|
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
|
|
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
|
|
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
|
|
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
|
|
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
|
|
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
|
|
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
|
|
WebRTC WebRTCConfig `toml:"webrtc"`
|
|
Transcode TranscodeConfig `toml:"transcode"`
|
|
VPN VPNConfig `toml:"vpn"`
|
|
}
|
|
|
|
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
|
|
// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and
|
|
// routes only the torrent client's peer/tracker traffic through an in-process
|
|
// userspace tunnel (no root, no OS routing changes). Requires an active VPN
|
|
// add-on on the account; otherwise the daemon logs and downloads in the clear.
|
|
type VPNConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
}
|
|
|
|
// TranscodeConfig controls real-time transcoding for the in-browser player
|
|
// when source codecs aren't browser-decodable (HEVC, AV1, AC3, DTS, etc.).
|
|
// Disabled by default; enabling requires ffmpeg + ffprobe on PATH (or
|
|
// explicit paths via the library config).
|
|
type TranscodeConfig struct {
|
|
Enabled bool `toml:"enabled"` // master switch
|
|
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
|
|
Preset string `toml:"preset"` // libx264 preset; "veryfast" by default
|
|
VideoBitrate string `toml:"video_bitrate"` // e.g. "5M"
|
|
AudioBitrate string `toml:"audio_bitrate"` // e.g. "192k"
|
|
MaxHeight int `toml:"max_height"` // optional downscale cap (e.g. 720)
|
|
MaxConcurrent int `toml:"max_concurrent"` // safety cap on simultaneous transcoder processes
|
|
}
|
|
|
|
// WebRTCConfig opts the daemon into acting as a WebTorrent peer so browsers
|
|
// can fetch pieces via WebRTC data channels — required by the in-browser
|
|
// player on torrentclaw.com. Disabled by default; enabling implies upload
|
|
// is allowed for active torrents (browsers can't download otherwise).
|
|
type WebRTCConfig struct {
|
|
Enabled bool `toml:"enabled"` // master switch
|
|
Trackers []string `toml:"trackers"` // wss:// signaling trackers
|
|
STUNServers []string `toml:"stun_servers"` // stun:host:port
|
|
TURNServers []string `toml:"turn_servers"` // turn:host:port (no auth) — see TURNCredentials for authed
|
|
TURNUser string `toml:"turn_user"` // optional, applied to all TURNServers
|
|
TURNPass string `toml:"turn_pass"` // optional
|
|
}
|
|
|
|
type OrganizeConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
MoviesDir string `toml:"movies_dir"`
|
|
TVShowsDir string `toml:"tv_shows_dir"`
|
|
}
|
|
|
|
type DaemonConfig struct {
|
|
StatusInterval string `toml:"status_interval"`
|
|
}
|
|
|
|
type NotificationsConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
}
|
|
|
|
type GeneralConfig struct {
|
|
Country string `toml:"country"`
|
|
Locale string `toml:"locale"`
|
|
NoColor bool `toml:"no_color"`
|
|
}
|
|
|
|
type LibraryConfig struct {
|
|
ScanPath string `toml:"scan_path"` // remembered from last scan
|
|
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
|
|
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
|
|
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by WebRTC streaming transcoder)
|
|
BackupDir string `toml:"backup_dir"` // for replaced files
|
|
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
|
|
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
|
|
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
|
|
}
|
|
|
|
// Default returns a Config with sensible defaults. Used both for fresh
|
|
// installs (no config file yet) and as the baseline for Load — fields not
|
|
// present in the user's TOML keep their Default() value.
|
|
func Default() Config {
|
|
return Config{
|
|
Auth: AuthConfig{
|
|
APIURL: "https://torrentclaw.com",
|
|
// Default mirror list. Kept in sync with src/lib/mirrors-config.ts
|
|
// on the server. Users can override with `unarr mirrors update`,
|
|
// which pulls the live list from /api/v1/mirrors.
|
|
Mirrors: []string{
|
|
"https://torrentclaw.to",
|
|
},
|
|
},
|
|
Download: DownloadConfig{
|
|
PreferredMethod: "auto",
|
|
MaxConcurrent: 3,
|
|
StreamPort: 11818,
|
|
WebRTC: WebRTCConfig{
|
|
Enabled: true,
|
|
Trackers: []string{"wss://tracker.torrentclaw.com"},
|
|
STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"},
|
|
},
|
|
Transcode: TranscodeConfig{
|
|
Enabled: true,
|
|
HWAccel: "auto",
|
|
Preset: "veryfast",
|
|
AudioBitrate: "192k",
|
|
MaxConcurrent: 2,
|
|
},
|
|
},
|
|
Organize: OrganizeConfig{
|
|
Enabled: true,
|
|
},
|
|
Daemon: DaemonConfig{},
|
|
Notifications: NotificationsConfig{
|
|
Enabled: true,
|
|
},
|
|
General: GeneralConfig{
|
|
Country: "US",
|
|
Locale: "en",
|
|
},
|
|
Library: LibraryConfig{
|
|
AutoScan: true,
|
|
ScanInterval: "24h",
|
|
Workers: 8,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Load reads config from the default or specified path.
|
|
// Falls back to defaults for any missing values.
|
|
// If the file does not exist, returns defaults without error.
|
|
func Load(path string) (Config, error) {
|
|
if path == "" {
|
|
path = FilePath()
|
|
}
|
|
|
|
cfg := Default()
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return cfg, nil
|
|
}
|
|
return cfg, fmt.Errorf("read config: %w", err)
|
|
}
|
|
|
|
meta, err := toml.Decode(string(data), &cfg)
|
|
if err != nil {
|
|
return cfg, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
|
|
applyDefaults(&cfg, meta)
|
|
return cfg, nil
|
|
}
|
|
|
|
// applyDefaults fills in sensible defaults for keys that the user did not
|
|
// define in the TOML file. We use MetaData (rather than zero-value checks) so
|
|
// that explicitly setting a field to its zero value (e.g. `enabled = false`)
|
|
// is respected — only truly missing keys get defaulted. This lets a fresh
|
|
// install work out of the box for streaming without forcing every user to
|
|
// edit the TOML, while still letting power users disable features.
|
|
func applyDefaults(cfg *Config, meta toml.MetaData) {
|
|
if !meta.IsDefined("auth", "api_url") {
|
|
cfg.Auth.APIURL = "https://torrentclaw.com"
|
|
}
|
|
if !meta.IsDefined("auth", "mirrors") {
|
|
cfg.Auth.Mirrors = []string{"https://torrentclaw.to"}
|
|
}
|
|
if !meta.IsDefined("downloads", "preferred_method") {
|
|
cfg.Download.PreferredMethod = "auto"
|
|
}
|
|
if !meta.IsDefined("downloads", "max_concurrent") {
|
|
cfg.Download.MaxConcurrent = 3
|
|
}
|
|
if !meta.IsDefined("downloads", "stream_port") {
|
|
cfg.Download.StreamPort = 11818
|
|
}
|
|
if !meta.IsDefined("general", "country") {
|
|
cfg.General.Country = "US"
|
|
}
|
|
|
|
if !meta.IsDefined("downloads", "webrtc", "enabled") {
|
|
cfg.Download.WebRTC.Enabled = true
|
|
}
|
|
if !meta.IsDefined("downloads", "webrtc", "trackers") {
|
|
cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"}
|
|
}
|
|
if !meta.IsDefined("downloads", "webrtc", "stun_servers") {
|
|
cfg.Download.WebRTC.STUNServers = []string{
|
|
"stun:stun.l.google.com:19302",
|
|
"stun:stun1.l.google.com:19302",
|
|
}
|
|
}
|
|
|
|
if !meta.IsDefined("downloads", "transcode", "enabled") {
|
|
cfg.Download.Transcode.Enabled = true
|
|
}
|
|
if !meta.IsDefined("downloads", "transcode", "hw_accel") {
|
|
cfg.Download.Transcode.HWAccel = "auto"
|
|
}
|
|
if !meta.IsDefined("downloads", "transcode", "preset") {
|
|
cfg.Download.Transcode.Preset = "veryfast"
|
|
}
|
|
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
|
|
cfg.Download.Transcode.AudioBitrate = "192k"
|
|
}
|
|
if !meta.IsDefined("downloads", "transcode", "max_concurrent") {
|
|
cfg.Download.Transcode.MaxConcurrent = 2
|
|
}
|
|
}
|
|
|
|
// Save writes config to the default or specified path using atomic write.
|
|
func Save(cfg Config, path string) error {
|
|
if path == "" {
|
|
path = FilePath()
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create config dir: %w", err)
|
|
}
|
|
|
|
var buf strings.Builder
|
|
encoder := toml.NewEncoder(&buf)
|
|
if err := encoder.Encode(cfg); err != nil {
|
|
return fmt.Errorf("encode config: %w", err)
|
|
}
|
|
|
|
// Atomic write: write to temp, then rename
|
|
tmpPath := path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, []byte(buf.String()), 0o600); err != nil {
|
|
return fmt.Errorf("write temp config: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("rename config: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ParseSpeed parses a human-readable speed string into bytes/s.
|
|
// Supports: "10MB", "500KB", "1GB", "1024", "0" (unlimited).
|
|
func ParseSpeed(s string) (int64, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" || s == "0" {
|
|
return 0, nil
|
|
}
|
|
|
|
s = strings.ToUpper(s)
|
|
multiplier := int64(1)
|
|
|
|
switch {
|
|
case strings.HasSuffix(s, "GB"):
|
|
multiplier = 1024 * 1024 * 1024
|
|
s = strings.TrimSuffix(s, "GB")
|
|
case strings.HasSuffix(s, "MB"):
|
|
multiplier = 1024 * 1024
|
|
s = strings.TrimSuffix(s, "MB")
|
|
case strings.HasSuffix(s, "KB"):
|
|
multiplier = 1024
|
|
s = strings.TrimSuffix(s, "KB")
|
|
}
|
|
|
|
n, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid speed %q: %w", s, err)
|
|
}
|
|
if n < 0 {
|
|
return 0, fmt.Errorf("speed cannot be negative: %s", s)
|
|
}
|
|
|
|
return int64(n * float64(multiplier)), nil
|
|
}
|
|
|
|
// ApplyEnvOverrides applies UNARR_* environment variable overrides.
|
|
func (c *Config) ApplyEnvOverrides() {
|
|
if v := os.Getenv("UNARR_API_KEY"); v != "" {
|
|
c.Auth.APIKey = v
|
|
}
|
|
if v := os.Getenv("UNARR_API_URL"); v != "" {
|
|
c.Auth.APIURL = v
|
|
}
|
|
if v := os.Getenv("UNARR_COUNTRY"); v != "" {
|
|
c.General.Country = v
|
|
}
|
|
if v := os.Getenv("UNARR_DOWNLOAD_DIR"); v != "" {
|
|
c.Download.Dir = v
|
|
}
|
|
}
|
|
|
|
// dangerousPaths are system-critical directories that should never be used as
|
|
// download or organize targets (per platform).
|
|
var dangerousPaths = func() map[string]bool {
|
|
m := map[string]bool{}
|
|
// Unix
|
|
for _, p := range []string{
|
|
"/", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/boot", "/dev", "/proc", "/sys",
|
|
"/etc", "/var", "/tmp", "/root",
|
|
// macOS
|
|
"/System", "/Library", "/private", "/private/etc", "/private/tmp", "/private/var",
|
|
} {
|
|
m[p] = true
|
|
}
|
|
// Windows
|
|
if runtime.GOOS == "windows" {
|
|
for _, drive := range []string{"C", "D"} {
|
|
for _, p := range []string{
|
|
drive + `:\`,
|
|
drive + `:\Windows`,
|
|
drive + `:\Windows\System32`,
|
|
drive + `:\Program Files`,
|
|
drive + `:\Program Files (x86)`,
|
|
} {
|
|
m[filepath.Clean(p)] = true
|
|
}
|
|
}
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// ValidatePaths checks that configured directories are safe to write to.
|
|
// Returns an error if any path points to a system directory or the user's
|
|
// home directory root (must use a subdirectory).
|
|
func (c *Config) ValidatePaths() error {
|
|
home, _ := os.UserHomeDir()
|
|
|
|
check := func(label, dir string) error {
|
|
if dir == "" {
|
|
return nil
|
|
}
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: invalid path %q: %w", label, dir, err)
|
|
}
|
|
clean := filepath.Clean(abs)
|
|
|
|
if dangerousPaths[clean] {
|
|
return fmt.Errorf("%s: refusing to use system directory %q", label, clean)
|
|
}
|
|
|
|
// Block home root — require a subdirectory
|
|
if home != "" && clean == filepath.Clean(home) {
|
|
return fmt.Errorf("%s: use a subdirectory of your home, not %q itself", label, clean)
|
|
}
|
|
|
|
// Block hidden dirs under home (e.g. ~/.ssh, ~/.gnupg)
|
|
if home != "" && strings.HasPrefix(clean, filepath.Clean(home)+string(filepath.Separator)) {
|
|
rel, _ := filepath.Rel(home, clean)
|
|
first := strings.SplitN(rel, string(filepath.Separator), 2)[0]
|
|
if strings.HasPrefix(first, ".") && first != ".local" && first != ".config" {
|
|
return fmt.Errorf("%s: refusing to use hidden directory %q", label, clean)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if err := check("downloads.dir", c.Download.Dir); err != nil {
|
|
return err
|
|
}
|
|
if err := check("organize.movies_dir", c.Organize.MoviesDir); err != nil {
|
|
return err
|
|
}
|
|
if err := check("organize.tv_shows_dir", c.Organize.TVShowsDir); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|