unarr/internal/config/config.go
Deivid Soto 7de8955c4f feat(vpn): local config_file for self-hosted/personal VPN testing
downloads.vpn.config_file = path to a WireGuard .conf read directly by the
daemon (skips the web fetch). Lets you point unarr at your own WireGuard
server / personal VPN and split-tunnel torrent traffic through it without
the web provisioning plumbing — for testing and self-hosted setups.
2026-05-20 23:27:34 +02:00

422 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"`
// ConfigFile, when set, makes the daemon read a local WireGuard .conf instead
// of fetching one from the web API. For self-hosted / personal-VPN testing:
// point it at a peer .conf from your own WireGuard server and the torrent
// client split-tunnels through it with no web/provider plumbing.
ConfigFile string `toml:"config_file"`
}
// 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
}