2026-03-28 11:29:42 +01:00
package cmd
import (
"context"
feat(vpn): split-tunnel torrent traffic through managed WireGuard
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.
2026-05-20 23:16:54 +02:00
"errors"
2026-03-28 11:29:42 +01:00
"fmt"
"log"
"os"
"os/signal"
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
"path/filepath"
2026-04-08 23:36:18 +02:00
"strings"
2026-03-28 11:29:42 +01:00
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
2026-03-30 13:06:07 +02:00
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/library"
2026-05-08 15:57:02 +02:00
"github.com/torrentclaw/unarr/internal/library/mediainfo"
2026-03-30 13:06:07 +02:00
"github.com/torrentclaw/unarr/internal/usenet/download"
feat(vpn): split-tunnel torrent traffic through managed WireGuard
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.
2026-05-20 23:16:54 +02:00
"github.com/torrentclaw/unarr/internal/vpn"
2026-03-28 11:29:42 +01:00
)
// newStartCmd creates the top-level `unarr start` command.
func newStartCmd ( ) * cobra . Command {
return & cobra . Command {
Use : "start" ,
Short : "Start the download daemon (foreground)" ,
Long : ` Start the unarr daemon in the foreground .
2026-04-08 18:50:59 +02:00
Registers with the server , receives download tasks via periodic sync ,
and executes them using the configured download method .
2026-03-28 21:12:12 +01:00
Supports torrent , debrid , and usenet downloads concurrently .
2026-04-08 18:50:59 +02:00
The daemon syncs state with the server every 3 s when someone is viewing
the web dashboard , or every 60 s when idle . Press Ctrl + C to stop
gracefully — active downloads get up to 30 seconds to finish .
2026-03-28 21:12:12 +01:00
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
Requires : API key , agent ID , and download directory ( run ' unarr init ' first ) .
2026-03-28 21:12:12 +01:00
To run as a background service , use ' unarr daemon install ' instead . ` ,
2026-03-28 11:29:42 +01:00
Example : ` unarr start
unarr start -- config / path / to / config . toml ` ,
RunE : func ( cmd * cobra . Command , args [ ] string ) error {
return runDaemonStart ( )
} ,
}
}
2026-04-10 19:18:13 +02:00
// newStopCmd creates the top-level `unarr stop` command.
2026-03-28 11:29:42 +01:00
func newStopCmd ( ) * cobra . Command {
return & cobra . Command {
Use : "stop" ,
Short : "Stop the running daemon" ,
2026-04-10 19:18:13 +02:00
Long : ` Stop the unarr daemon gracefully .
2026-03-28 21:12:12 +01:00
2026-04-10 19:18:13 +02:00
Reads the daemon PID from the state file and sends a graceful stop signal .
Works regardless of whether the daemon was started in the foreground or as a service .
2026-03-28 21:12:12 +01:00
2026-04-10 19:18:13 +02:00
To stop a service - managed daemon and prevent auto - restart , use ' unarr daemon stop ' instead . ` ,
2026-03-28 21:12:12 +01:00
Example : ` unarr stop ` ,
2026-03-28 11:29:42 +01:00
RunE : func ( cmd * cobra . Command , args [ ] string ) error {
2026-04-10 19:18:13 +02:00
return stopDaemonByPID ( )
2026-03-28 11:29:42 +01:00
} ,
}
}
// newDaemonCmd creates `unarr daemon` for administrative subcommands.
func newDaemonCmd ( ) * cobra . Command {
cmd := & cobra . Command {
2026-03-28 21:12:12 +01:00
Use : "daemon <command>" ,
Short : "Manage the daemon as a system service" ,
2026-04-10 19:18:13 +02:00
Long : ` Install , control and inspect the unarr daemon as a system service .
2026-03-28 21:12:12 +01:00
2026-04-10 19:18:13 +02:00
Linux : systemd user service ( ~ / . config / systemd / user / unarr . service )
macOS : launchd agent ( ~ / Library / LaunchAgents / com . torrentclaw . unarr . plist )
Windows : Task Scheduler task ( runs at logon ) ` ,
2026-03-28 21:12:12 +01:00
Example : ` unarr daemon install
2026-04-10 19:18:13 +02:00
unarr daemon start
unarr daemon status
unarr daemon logs - f
unarr daemon reload
unarr daemon restart
unarr daemon stop
2026-03-28 21:12:12 +01:00
unarr daemon uninstall ` ,
2026-03-28 11:29:42 +01:00
}
cmd . AddCommand (
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
newDaemonInstallCmdReal ( ) ,
newDaemonUninstallCmdReal ( ) ,
2026-04-10 19:18:13 +02:00
newDaemonStartCmd ( ) ,
newDaemonStopCmd ( ) ,
newDaemonRestartCmd ( ) ,
newDaemonSvcStatusCmd ( ) ,
newDaemonLogsCmd ( ) ,
newDaemonReloadCmd ( ) ,
2026-03-28 11:29:42 +01:00
)
return cmd
}
func runDaemonStart ( ) error {
cfg := loadConfig ( )
bold := color . New ( color . Bold )
// Validate config
if cfg . Auth . APIKey == "" {
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
return fmt . Errorf ( "no API key configured — run 'unarr init' first" )
2026-03-28 11:29:42 +01:00
}
if cfg . Agent . ID == "" {
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
return fmt . Errorf ( "no agent ID — run 'unarr init' first" )
2026-03-28 11:29:42 +01:00
}
if cfg . Download . Dir == "" {
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
return fmt . Errorf ( "no download directory — run 'unarr init' first" )
2026-03-28 11:29:42 +01:00
}
// Validate configured paths are safe
if err := cfg . ValidatePaths ( ) ; err != nil {
return fmt . Errorf ( "unsafe configuration: %w" , err )
}
// Ensure download dir exists
if err := os . MkdirAll ( cfg . Download . Dir , 0 o755 ) ; err != nil {
return fmt . Errorf ( "create download dir: %w" , err )
}
2026-03-28 21:36:12 +01:00
// Clean up stale resume files (>7 days old)
resumeDir := filepath . Join ( config . DataDir ( ) , "resume" )
if removed := download . CleanStaleFiles ( resumeDir , 7 * 24 * time . Hour ) ; removed > 0 {
log . Printf ( "Cleaned %d stale resume file(s)" , removed )
}
2026-03-28 11:29:42 +01:00
fmt . Println ( )
bold . Println ( " unarr Daemon" )
fmt . Println ( )
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
userAgent := "unarr/" + Version
2026-03-28 11:29:42 +01:00
2026-05-08 15:57:02 +02:00
// Probe HW accel + derive a sensible transcode resolution cap. The cap
// is what the web side uses to decide whether the user should pre-empt
// transcoding by downloading a smaller version (4K source on a software
// libx264-only host is the canonical case where pre-download wins).
hwAccelPick := engine . DetectHWAccel ( context . Background ( ) , cfg . Library . FFmpegPath )
maxTranscodeHeight := 1080
if hwAccelPick != engine . HWAccelNone {
maxTranscodeHeight = 2160
}
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
// Create daemon config
2026-03-28 11:29:42 +01:00
daemonCfg := agent . DaemonConfig {
2026-05-08 15:57:02 +02:00
AgentID : cfg . Agent . ID ,
AgentName : cfg . Agent . Name ,
Version : Version ,
DownloadDir : cfg . Download . Dir ,
StreamPort : cfg . Download . StreamPort ,
LanIP : engine . LanIP ( ) ,
TailscaleIP : engine . TailscaleIP ( ) ,
CanDelete : cfg . Library . AllowDelete ,
ScanPaths : library . ResolveScanPaths ( cfg . Download . Dir , cfg . Organize . MoviesDir , cfg . Organize . TVShowsDir , cfg . Library . ScanPath ) ,
HWAccel : string ( hwAccelPick ) ,
MaxTranscodeHeight : maxTranscodeHeight ,
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
}
2026-05-15 16:26:43 +02:00
// Create HTTP client with mirror failover so a `.com` block-out rolls
// over to `.to` / .onion without restarting the daemon.
agentClient := newAgentClientFromConfig ( cfg , userAgent )
log . Printf ( "Transport: HTTP sync → %s (mirrors: %d)" , cfg . Auth . APIURL , len ( cfg . Auth . Mirrors ) )
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
2026-04-08 18:50:59 +02:00
// Create daemon
d := agent . NewDaemon ( daemonCfg , agentClient )
2026-03-28 18:55:29 +01:00
2026-04-08 18:50:59 +02:00
// Start SIGUSR1 reload watcher (unix only, no-op on Windows)
startReloadWatcher ( & ReloadableConfig { Daemon : d } )
2026-04-01 12:16:45 +02:00
// Daemon-scoped context — cancelled on shutdown
ctx , cancel := context . WithCancel ( context . Background ( ) )
defer cancel ( )
2026-03-28 11:29:42 +01:00
// Parse speed limits
maxDl , _ := config . ParseSpeed ( cfg . Download . MaxDownloadSpeed )
maxUl , _ := config . ParseSpeed ( cfg . Download . MaxUploadSpeed )
2026-04-08 18:50:59 +02:00
// Parse torrent timeouts
2026-03-29 19:09:51 +02:00
metaTimeout , _ := time . ParseDuration ( cfg . Download . MetadataTimeout )
stallTimeout , _ := time . ParseDuration ( cfg . Download . StallTimeout )
2026-04-08 18:50:59 +02:00
// Create progress reporter — only used for stream tasks (handleStreamTask)
// The sync goroutine handles all regular progress reporting.
statusInterval , _ := time . ParseDuration ( cfg . Daemon . StatusInterval )
if statusInterval == 0 {
statusInterval = 3 * time . Second
}
reporter := engine . NewProgressReporter ( agentClient , statusInterval )
reporter . SetWatchingFunc ( func ( ) bool { return d . Watching . Load ( ) } )
feat(vpn): split-tunnel torrent traffic through managed WireGuard
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.
2026-05-20 23:16:54 +02:00
// Managed-VPN add-on: bring up the in-process WireGuard split-tunnel before
// the torrent client so peer + tracker traffic routes through it. Failure is
// non-fatal — log and download in the clear (better than refusing to run).
var vpnTunnel * vpn . Tunnel
2026-05-20 23:27:34 +02:00
if cfg . Download . VPN . ConfigFile != "" {
// Self-hosted / personal-VPN mode: read a local .conf directly.
raw , rerr := os . ReadFile ( cfg . Download . VPN . ConfigFile )
if rerr != nil {
log . Printf ( "[vpn] could not read config_file %q (%v) — downloading in the clear" , cfg . Download . VPN . ConfigFile , rerr )
} else if t , uerr := vpn . Up ( string ( raw ) ) ; uerr != nil {
log . Printf ( "[vpn] tunnel failed to start from config_file (%v) — downloading in the clear" , uerr )
} else {
vpnTunnel = t
defer vpnTunnel . Close ( )
log . Printf ( "[vpn] managed VPN active (local config_file) — torrent traffic split-tunnelled through WireGuard" )
}
} else if cfg . Download . VPN . Enabled {
feat(vpn): split-tunnel torrent traffic through managed WireGuard
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.
2026-05-20 23:16:54 +02:00
apiURL := cfg . Auth . APIURL
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
fetchCtx , cancel := context . WithTimeout ( context . Background ( ) , 25 * time . Second )
2026-05-22 08:33:02 +02:00
conf , ferr := vpn . FetchConfig ( fetchCtx , apiURL , cfg . Auth . APIKey , "unarr/" + Version , cfg . Agent . ID , false )
feat(vpn): split-tunnel torrent traffic through managed WireGuard
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.
2026-05-20 23:16:54 +02:00
cancel ( )
var fe * vpn . FetchError
switch {
case ferr != nil && errors . As ( ferr , & fe ) && fe . Code == vpn . ErrSlotOnDevice :
2026-05-22 08:33:02 +02:00
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" )
feat(vpn): split-tunnel torrent traffic through managed WireGuard
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.
2026-05-20 23:16:54 +02:00
case ferr != nil :
log . Printf ( "[vpn] could not enable VPN (%v) — downloading in the clear" , ferr )
default :
if t , uerr := vpn . Up ( conf ) ; uerr != nil {
log . Printf ( "[vpn] tunnel failed to start (%v) — downloading in the clear" , uerr )
} else {
vpnTunnel = t
defer vpnTunnel . Close ( )
log . Printf ( "[vpn] managed VPN active — torrent traffic split-tunnelled through WireGuard" )
}
}
}
2026-05-22 08:33:02 +02:00
// 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 )
}
2026-03-28 11:29:42 +01:00
// Create torrent downloader
torrentDl , err := engine . NewTorrentDownloader ( engine . TorrentConfig {
DataDir : cfg . Download . Dir ,
2026-04-08 18:50:59 +02:00
MetadataTimeout : metaTimeout ,
StallTimeout : stallTimeout ,
MaxTimeout : 0 ,
2026-03-28 11:29:42 +01:00
MaxDownloadRate : maxDl ,
MaxUploadRate : maxUl ,
2026-04-08 18:50:59 +02:00
ListenPort : cfg . Download . ListenPort ,
2026-03-28 11:29:42 +01:00
SeedEnabled : false ,
feat(torrent): act as WebTorrent peer for browser ↔ unarr P2P streaming
Wires anacrolix/torrent's built-in webtorrent package so a browser
running webtorrent.js can fetch pieces from this CLI via WebRTC data
channels. The daemon stays the seeder; we never relay bytes through
TorrentClaw infrastructure — same legal posture as today.
Changes:
- internal/config: new [downloads.webrtc] section
(enabled/trackers/stun_servers/turn_servers/turn_user/turn_pass).
Disabled by default, opt-in via config.toml. When enabled but
trackers / STUN slices are empty, defaults are reapplied on Load() so
users get a working setup with a single `enabled = true`.
- internal/engine: TorrentConfig gains WebRTCEnabled / WebRTCTrackers
/ ICEServers; NewTorrentDownloader populates ClientConfig.ICEServerList
and forces NoUpload=false when WebRTC is on (browsers can't pull
otherwise). buildMagnet now accepts variadic extra trackers and the
downloader method prepends WSS trackers so anacrolix's
webtorrent.TrackerClient picks them up first.
- internal/engine/webrtc.go: BuildICEServers helper converts the TOML
WebRTCConfig into []webrtc.ICEServer with shared TURN credentials.
- internal/cmd/daemon.go + download.go: pass WebRTC config through to
the engine.
Tests (8 new, all green; full suite 0 lint issues, 0 vet):
- buildMagnet free function: defaults-only, with extras, trim+empty-skip
- downloader method: WebRTC disabled keeps WSS out, enabled prepends them
- BuildICEServers: nil when disabled, STUN-only path, TURN+credentials
- NewTorrentDownloader: full WebRTC-enabled construction (logs WebRTC
peer enabled, magnet contains wss://tracker.torrentclaw.com)
End-to-end smoke (browser ↔ unarr peer transfer) is deferred to a
manual test once tracker.torrentclaw.com WSS is live.
2026-05-06 08:59:58 +02:00
WebRTCEnabled : cfg . Download . WebRTC . Enabled ,
WebRTCTrackers : cfg . Download . WebRTC . Trackers ,
ICEServers : engine . BuildICEServers ( cfg . Download . WebRTC ) ,
feat(vpn): split-tunnel torrent traffic through managed WireGuard
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.
2026-05-20 23:16:54 +02:00
VPNTunnel : vpnTunnel ,
2026-03-28 11:29:42 +01:00
} )
if err != nil {
return fmt . Errorf ( "create torrent downloader: %w" , err )
}
if maxDl > 0 || maxUl > 0 {
dlStr , ulStr := "unlimited" , "unlimited"
if maxDl > 0 {
dlStr = formatSpeedLog ( maxDl )
}
if maxUl > 0 {
ulStr = formatSpeedLog ( maxUl )
}
log . Printf ( "Speed limits: download=%s upload=%s" , dlStr , ulStr )
}
2026-04-08 18:50:59 +02:00
// Create debrid downloader
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
debridDl := engine . NewDebridDownloader ( )
2026-03-28 11:29:42 +01:00
// Create download manager
manager := engine . NewManager ( engine . ManagerConfig {
MaxConcurrent : cfg . Download . MaxConcurrent ,
OutputDir : cfg . Download . Dir ,
Notifications : cfg . Notifications . Enabled ,
Organize : engine . OrganizeConfig {
Enabled : cfg . Organize . Enabled ,
MoviesDir : cfg . Organize . MoviesDir ,
TVShowsDir : cfg . Organize . TVShowsDir ,
2026-04-05 23:36:01 +02:00
OutputDir : cfg . Download . Dir ,
2026-03-28 11:29:42 +01:00
} ,
2026-04-08 18:50:59 +02:00
} , reporter , torrentDl , debridDl , engine . NewUsenetDownloader ( agentClient ) )
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
2026-04-08 18:50:59 +02:00
// Create persistent stream server
2026-04-07 19:08:37 +02:00
streamSrv := engine . NewStreamServer ( cfg . Download . StreamPort )
2026-05-15 17:29:22 +02:00
streamSrv . SetUPnPEnabled ( cfg . Download . EnableUPnP )
fix(security): CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
Phase 3 security audit follow-up. Medium and low-severity hardenings
plus a deferred-work plan for the cross-repo stream-token rollout.
Stream server CORS: replace the wildcard Access-Control-Allow-Origin
with an allowlist that echoes back only torrentclaw.com,
app.torrentclaw.com, the local Next dev port (3030 — matches the web
repo package.json) and any extras the operator adds via the new
downloads.cors_extra_origins TOML key. A Vary: Origin header is now
emitted whenever the request carries an Origin header so an
intermediate cache cannot serve a stale ACAO to a different origin.
URL scheme guard: openBrowser and OpenPlayer refuse any URL that is
not http(s). Combined with passing the URL after "--" wherever the
launched helper supports it (open, mpv, vlc, cvlc), this stops a
leading "-" from being parsed as a switch by the spawned process.
State file permissions: WriteState now writes 0o600 so the agent ID,
PID and counters cannot be enumerated by another local user on a
shared host. Matches the existing config file mode.
ZIP slip defense-in-depth: extractZip extracts the safety check into
safeZipPath, which canonicalises the entry name (normalising
backslashes to "/"), rejects "..", "../" prefix and "/../" interior
components, and verifies the final destination stays inside destDir
before opening any file.
Mirror fallback: documented the design for multi-provider
mirrors.json hosting in the comment block on DefaultStaticFallbackURLs
and added a follow-up note about signing it with the same ed25519
release key. The list is kept at one provider until the second host
is provisioned and added to torrentclaw-web's STATIC_FALLBACKS.
Deferred work: a new plan document Docs/plans/security-stream-token.md
covers the per-task stream token (Phase 2.2 of the original audit)
which requires coordinated web + CLI work and ships separately.
2026-05-15 18:48:59 +02:00
streamSrv . SetCORSAllowedOrigins ( cfg . Download . CORSExtraOrigins )
2026-05-08 08:51:19 +02:00
// Reap HLS tmpdirs left over from a previous daemon run before we start
// accepting new sessions. The in-memory registry doesn't survive a
// restart, so without this disk usage grows unbounded across restarts.
if err := engine . CleanupHLSOrphanDirs ( ) ; err != nil {
log . Printf ( "[hls] orphan tmpdir cleanup: %v" , err )
}
2026-04-07 19:08:37 +02:00
if err := streamSrv . Listen ( ctx ) ; err != nil {
return fmt . Errorf ( "start stream server: %w" , err )
}
d . UpdateStreamPort ( streamSrv . Port ( ) )
2026-05-08 15:57:02 +02:00
// Warn at startup if transcode is enabled but ffmpeg/ffprobe are missing.
// HLS sessions get rejected at runtime (see daemon.go ~line 455), but
// surfacing it here gives the operator a chance to install ffmpeg before
// a user hits a confusing "rejected" line in the logs.
if cfg . Download . Transcode . Enabled {
if _ , err := mediainfo . ResolveFFmpeg ( cfg . Library . FFmpegPath ) ; err != nil {
log . Printf ( "[hls] transcode enabled but ffmpeg/ffprobe not found — install ffmpeg to use HLS" )
} else if _ , err := mediainfo . ResolveFFprobe ( cfg . Library . FFprobePath ) ; err != nil {
log . Printf ( "[hls] transcode enabled but ffmpeg/ffprobe not found — install ffmpeg to use HLS" )
}
}
2026-04-08 18:50:59 +02:00
// Wire sync client callbacks
sc := d . SyncClient ( )
sc . GetFreeSlots = manager . FreeSlots
sc . GetTaskStates = manager . TaskStates
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
d . GetActiveCount = manager . ActiveCount
2026-03-28 11:29:42 +01:00
2026-04-08 18:50:59 +02:00
// Trigger immediate sync when a download slot frees up
manager . OnTaskDone = func ( ) { d . TriggerSync ( ) }
2026-03-28 11:29:42 +01:00
2026-04-08 18:50:59 +02:00
// Wire: sync receives new tasks → submit to manager or handle stream
2026-03-28 11:29:42 +01:00
d . OnTasksClaimed = func ( tasks [ ] agent . Task ) {
for _ , t := range tasks {
feat(seed-file): unarr-side handler for browser-on-demand seeding (Fase 4.7.c)
Closes the agent half of in-browser playback for arbitrary files. When
the web app inserts a download_task with mode="seed_file", the daemon
now wraps the on-disk file as a single-file torrent, adds it to the
existing WebRTC-enabled torrent client, and reports the generated
info_hash back so the browser can target /stream/<hash>.
Pieces:
- internal/agent/types.go: Task.FilePath (received from claim) +
StatusUpdate.InfoHash (sent back). Both serialise compatibly with
the matching Zod schemas in the Next.js sync route.
- internal/engine/seed_file.go: SeedFile(client, filePath, trackers)
builds the metainfo via metainfo.Info.BuildFromFilePath +
bencode.Marshal, then AddTorrent + DownloadAll() so anacrolix
hashes the file and flips pieces to "have" as it goes. The
libtorrent piece-size ladder is mirrored from wstracker-probe so
generated torrents are interoperable with mainstream clients.
SeedFileOnDownloader is the daemon-facing convenience wrapper —
bails loud when [downloads.webrtc].enabled = false instead of
silently producing a torrent no browser can find.
- internal/cmd/seed_file_handler.go: handleSeedFileTask invoked from
the existing OnTasksClaimed dispatcher in daemon.go for mode=
seed_file. Validates filePath, calls the engine helper, and pushes
the resulting info_hash via Client.ReportStatus. Failures (missing
file, WebRTC disabled, ffmpeg-style oddities) report status="failed"
+ errorMessage so the browser's WatchInBrowserButton can show the
reason instead of timing out at 60 s.
- internal/cmd/daemon.go: dispatcher learns the seed_file branch in
the same shape as the existing stream branch.
Tests (6 unit, all green):
- SeedFile rejects missing files + directories.
- SeedFile yields a deterministic info_hash for the same payload across
fresh clients (web client polls expecting this).
- SeedFileOnDownloader errors when WebRTC is disabled.
- chooseSeedPieceLength matches the ladder breakpoints.
- makeAnnounceList handles nil/empty/partial inputs.
Web side compatible: mode=seed_file is already accepted by the sync
schema; agent.Task.filePath + StatusUpdate.infoHash now propagate
through the existing claim/report endpoints. End-to-end browser ↔
unarr smoke is the next concrete verification step (needs a running
unarr-dev daemon plus library scan + a file with no source torrent).
2026-05-06 16:28:01 +02:00
if t . Mode == "seed_file" {
// Browser asked us to wrap an arbitrary on-disk file as
// a single-file torrent + seed it via WebRTC. Runs in
// its own goroutine so a slow / failing seed can't
// stall the rest of the claim batch.
go handleSeedFileTask ( t , torrentDl , agentClient )
} else if t . Mode == "stream" {
2026-04-07 16:19:01 +02:00
if isStreamingTask ( t . ID ) {
continue
}
2026-04-07 19:08:37 +02:00
cancelStreamContexts ( )
streamSrv . ClearFile ( )
2026-04-08 18:50:59 +02:00
streamCtx , streamCancel := context . WithCancel ( ctx ) //nolint:gosec // G118: cancel stored in registry
2026-04-07 16:19:01 +02:00
streamRegistry . mu . Lock ( )
streamRegistry . cancels [ t . ID ] = streamCancel
streamRegistry . mu . Unlock ( )
2026-04-07 19:08:37 +02:00
go handleStreamTask ( streamCtx , t , reporter , cfg , agentClient , streamSrv )
2026-04-08 18:50:59 +02:00
} else {
2026-03-28 11:29:42 +01:00
manager . Submit ( ctx , t )
2026-04-08 18:50:59 +02:00
}
}
}
// Wire: sync receives control signals → act on manager
d . OnControlAction = func ( action , taskID string , deleteFiles bool ) {
switch action {
case "cancel" :
if deleteFiles {
manager . CancelAndDeleteFiles ( taskID )
2026-03-28 11:29:42 +01:00
} else {
2026-04-08 18:50:59 +02:00
manager . CancelTask ( taskID )
}
cancelStreamTask ( taskID )
if streamSrv . CurrentTaskID ( ) == taskID {
streamSrv . ClearFile ( )
}
case "pause" :
manager . PauseTask ( taskID )
cancelStreamTask ( taskID )
if streamSrv . CurrentTaskID ( ) == taskID {
streamSrv . ClearFile ( )
}
case "resume" :
log . Printf ( "[%s] resume requested, triggering sync" , agent . ShortID ( taskID ) )
d . TriggerSync ( )
case "stream" :
if streamSrv . CurrentTaskID ( ) == taskID {
return
}
task := manager . GetTask ( taskID )
if task == nil || task . GetStreamURL ( ) != "" {
return
}
provider , err := torrentDl . GetStreamProvider ( taskID )
if err != nil {
log . Printf ( "[%s] stream failed: %v" , agent . ShortID ( taskID ) , err )
return
}
cancelStreamContexts ( )
streamSrv . SetFile ( provider , taskID )
task . SetStreamURL ( streamSrv . URLsJSON ( ) )
log . Printf ( "[%s] streaming: %s" , agent . ShortID ( taskID ) , provider . FileName ( ) )
watchCtx , watchCancel := context . WithCancel ( ctx ) //nolint:gosec // G118
streamRegistry . mu . Lock ( )
streamRegistry . cancels [ "watch:" + taskID ] = watchCancel
streamRegistry . mu . Unlock ( )
go engine . NewWatchReporter ( agentClient , streamSrv , taskID ) . Run ( watchCtx )
case "stop-stream" :
cancelStreamTask ( taskID )
if streamSrv . CurrentTaskID ( ) == taskID {
streamSrv . ClearFile ( )
2026-03-28 11:29:42 +01:00
}
}
}
2026-04-10 16:35:12 +02:00
// Wire: sync receives file deletion requests from the server
if cfg . Library . AllowDelete && len ( daemonCfg . ScanPaths ) > 0 {
sc . OnDeleteFiles = func ( items [ ] agent . LibraryDeleteRequest ) [ ] int {
return library . DeleteFiles ( items , daemonCfg . ScanPaths )
}
}
2026-04-08 18:50:59 +02:00
// Wire: sync receives stream requests for completed downloads
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
d . OnStreamRequested = func ( sr agent . StreamRequest ) {
2026-04-07 19:08:37 +02:00
if streamSrv . CurrentTaskID ( ) == sr . TaskID {
2026-04-08 18:50:59 +02:00
// Already serving — notify server it's ready
2026-04-07 23:29:09 +02:00
go func ( ) {
2026-04-08 18:50:59 +02:00
if _ , err := agentClient . ReportStatus ( ctx , agent . StatusUpdate {
2026-04-07 23:29:09 +02:00
TaskID : sr . TaskID ,
StreamReady : true ,
} ) ; err != nil {
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] stream ready re-notify failed: %v" , agent . ShortID ( sr . TaskID ) , err )
2026-04-07 23:29:09 +02:00
}
} ( )
2026-04-07 16:19:01 +02:00
return
}
2026-04-08 23:36:18 +02:00
filePath := filepath . Clean ( sr . FilePath )
if ! isAllowedStreamPath ( filePath , cfg . Download . Dir , cfg . Library . ScanPath ,
cfg . Organize . MoviesDir , cfg . Organize . TVShowsDir ) {
log . Printf ( "[%s] stream request rejected: path outside allowed dirs: %s" , agent . ShortID ( sr . TaskID ) , filePath )
2026-04-09 10:54:14 +02:00
go func ( ) {
if _ , err := agentClient . ReportStatus ( ctx , agent . StatusUpdate {
TaskID : sr . TaskID ,
Status : "failed" ,
ErrorMessage : fmt . Sprintf ( "path outside allowed dirs: %s" , filePath ) ,
} ) ; err != nil {
log . Printf ( "[%s] stream error report failed: %v" , agent . ShortID ( sr . TaskID ) , err )
}
} ( )
2026-04-08 23:36:18 +02:00
return
}
2026-03-31 16:55:50 +02:00
info , err := os . Stat ( filePath )
if err != nil {
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] stream request: file not found: %s" , agent . ShortID ( sr . TaskID ) , filePath )
2026-04-07 12:39:22 +02:00
go func ( ) {
2026-04-08 18:50:59 +02:00
if _ , err := agentClient . ReportStatus ( ctx , agent . StatusUpdate {
2026-04-07 12:39:22 +02:00
TaskID : sr . TaskID ,
Status : "failed" ,
ErrorMessage : fmt . Sprintf ( "file not found: %s" , filePath ) ,
} ) ; err != nil {
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] stream error report failed: %v" , agent . ShortID ( sr . TaskID ) , err )
2026-04-07 12:39:22 +02:00
}
} ( )
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
return
}
2026-03-31 16:55:50 +02:00
if info . IsDir ( ) {
found := engine . FindVideoFile ( filePath )
if found == "" {
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] stream request: no video file in directory: %s" , agent . ShortID ( sr . TaskID ) , filePath )
2026-04-07 12:39:22 +02:00
go func ( ) {
2026-04-08 18:50:59 +02:00
if _ , err := agentClient . ReportStatus ( ctx , agent . StatusUpdate {
2026-04-07 12:39:22 +02:00
TaskID : sr . TaskID ,
Status : "failed" ,
ErrorMessage : fmt . Sprintf ( "no video file in directory: %s" , filePath ) ,
} ) ; err != nil {
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] stream error report failed: %v" , agent . ShortID ( sr . TaskID ) , err )
2026-04-07 12:39:22 +02:00
}
} ( )
2026-03-31 16:55:50 +02:00
return
}
filePath = found
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] resolved directory to video file: %s" , agent . ShortID ( sr . TaskID ) , filepath . Base ( filePath ) )
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
}
2026-04-07 19:08:37 +02:00
cancelStreamContexts ( )
streamSrv . SetFile ( engine . NewDiskFileProvider ( filePath ) , sr . TaskID )
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] streaming from disk: %s → %s" , agent . ShortID ( sr . TaskID ) , filepath . Base ( filePath ) , streamSrv . URL ( ) )
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
2026-04-08 18:50:59 +02:00
watchCtx , watchCancel := context . WithCancel ( ctx ) //nolint:gosec // G118
2026-04-07 23:29:09 +02:00
streamRegistry . mu . Lock ( )
streamRegistry . cancels [ "watch:" + sr . TaskID ] = watchCancel
streamRegistry . mu . Unlock ( )
go engine . NewWatchReporter ( agentClient , streamSrv , sr . TaskID ) . Run ( watchCtx )
2026-04-01 12:16:45 +02:00
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
go func ( ) {
2026-04-08 18:50:59 +02:00
if _ , err := agentClient . ReportStatus ( ctx , agent . StatusUpdate {
2026-04-07 19:08:37 +02:00
TaskID : sr . TaskID ,
StreamReady : true ,
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
} ) ; err != nil {
2026-04-08 18:50:59 +02:00
log . Printf ( "[%s] stream ready report failed: %v" , agent . ShortID ( sr . TaskID ) , err )
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
}
} ( )
}
2026-05-06 23:12:38 +02:00
// Wire: sync receives custom WebRTC streaming session requests.
// Each session is a one-shot browser↔daemon DataChannel. Validate the
// FilePath against allowed dirs to prevent path traversal abuse from a
// compromised server, then spawn the pion peer in its own goroutine.
d . OnWebRTCSession = func ( sess agent . WebRTCSession ) {
if webrtcRegistry . has ( sess . SessionID ) {
return // already running
}
filePath := sess . FilePath
if filePath == "" {
log . Printf ( "webrtc session %s rejected: empty file path" , agent . ShortID ( sess . SessionID ) )
return
}
filePath = filepath . Clean ( filePath )
if ! isAllowedStreamPath ( filePath , cfg . Download . Dir , cfg . Library . ScanPath ,
cfg . Organize . MoviesDir , cfg . Organize . TVShowsDir ) {
log . Printf ( "webrtc session %s rejected: path outside allowed dirs: %s" ,
agent . ShortID ( sess . SessionID ) , filePath )
return
}
// Resolve directory → first video file (matches StreamRequest behavior).
if info , err := os . Stat ( filePath ) ; err == nil && info . IsDir ( ) {
found := engine . FindVideoFile ( filePath )
if found == "" {
log . Printf ( "webrtc session %s rejected: no video file in dir %s" ,
agent . ShortID ( sess . SessionID ) , filePath )
return
}
filePath = found
}
2026-05-07 16:10:22 +02:00
2026-05-08 12:44:06 +02:00
// Branch on transport: HLS sessions only need ffmpeg + StreamServer,
// not a WebRTC peer, so they must bypass the WebRTC.Enabled gate.
// Default ("" or "webrtc") runs the DataChannel pipeline and requires it.
2026-05-07 16:10:22 +02:00
if strings . EqualFold ( sess . Transport , "hls" ) {
tcRuntime := buildTranscodeRuntime ( ctx , cfg )
if tcRuntime . FFmpegPath == "" || tcRuntime . FFprobePath == "" {
log . Printf ( "[hls %s] rejected: ffmpeg/ffprobe unavailable" , agent . ShortID ( sess . SessionID ) )
return
}
2026-05-08 12:39:07 +02:00
hlsCtx , hlsCancel := context . WithCancel ( ctx )
webrtcRegistry . add ( sess . SessionID , hlsCancel )
2026-05-07 16:10:22 +02:00
hlsCfg := engine . HLSSessionConfig {
SessionID : sess . SessionID ,
SourcePath : filePath ,
FileName : sess . FileName ,
Quality : sess . Quality ,
AudioIndex : sess . AudioIndex ,
Transcode : tcRuntime ,
}
2026-05-08 12:39:07 +02:00
hsess , err := engine . StartHLSSession ( hlsCtx , hlsCfg )
2026-05-07 16:10:22 +02:00
if err != nil {
2026-05-08 12:39:07 +02:00
webrtcRegistry . remove ( sess . SessionID )
hlsCancel ( )
2026-05-07 16:10:22 +02:00
log . Printf ( "[hls %s] start failed: %v" , agent . ShortID ( sess . SessionID ) , err )
return
}
streamSrv . HLS ( ) . Register ( hsess )
return
}
2026-05-08 12:44:06 +02:00
// Non-HLS transport requires WebRTC peer support.
if ! cfg . Download . WebRTC . Enabled {
log . Printf ( "webrtc session %s rejected: webrtc disabled in config" , agent . ShortID ( sess . SessionID ) )
return
}
2026-05-06 23:12:38 +02:00
sessCtx , sessCancel := context . WithCancel ( ctx ) //nolint:gosec // G118 cancel stored in registry
webrtcRegistry . add ( sess . SessionID , sessCancel )
go func ( ) {
defer func ( ) {
webrtcRegistry . remove ( sess . SessionID )
sessCancel ( )
} ( )
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
tcRuntime := buildTranscodeRuntime ( ctx , cfg )
2026-05-06 23:12:38 +02:00
runCfg := engine . WebRTCStreamConfig {
SessionID : sess . SessionID ,
FilePath : filePath ,
FileName : sess . FileName ,
FileSize : sess . FileSize ,
2026-05-07 10:13:45 +02:00
Quality : sess . Quality ,
2026-05-06 23:12:38 +02:00
ICEServers : engine . BuildICEServers ( cfg . Download . WebRTC ) ,
Signal : agentClient ,
Logger : stdLogger { } ,
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
Transcode : tcRuntime ,
2026-05-06 23:12:38 +02:00
}
log . Printf ( "[wrtc %s] starting session: %s" , agent . ShortID ( sess . SessionID ) , filepath . Base ( filePath ) )
if err := engine . RunWebRTCStream ( sessCtx , runCfg ) ; err != nil {
if sessCtx . Err ( ) == nil {
log . Printf ( "[wrtc %s] ended: %v" , agent . ShortID ( sess . SessionID ) , err )
}
}
} ( )
}
2026-04-08 18:50:59 +02:00
// Periodic DHT node persistence (every 5 min)
2026-03-29 19:09:51 +02:00
go func ( ) {
ticker := time . NewTicker ( 5 * time . Minute )
defer ticker . Stop ( )
for {
select {
case <- ticker . C :
torrentDl . SaveDhtNodes ( )
case <- ctx . Done ( ) :
return
}
}
} ( )
2026-05-07 23:55:05 +02:00
// Periodic HLS session sweeper (every 5 min). Closes sessions whose last
// segment fetch was over 30 min ago — kills the orphan ffmpeg + removes
// the per-session tmpdir, so a tab that died mid-stream doesn't leak
// disk space until daemon shutdown.
go func ( ) {
ticker := time . NewTicker ( 5 * time . Minute )
defer ticker . Stop ( )
for {
select {
case <- ticker . C :
if n := streamSrv . HLS ( ) . SweepIdle ( ) ; n > 0 {
log . Printf ( "[hls] swept %d idle session(s)" , n )
}
case <- ctx . Done ( ) :
return
}
}
} ( )
2026-04-08 18:50:59 +02:00
// Start auto-scan goroutine
2026-04-10 16:35:12 +02:00
scanPaths := daemonCfg . ScanPaths
2026-04-10 11:46:20 +02:00
if len ( scanPaths ) > 0 && cfg . Library . AutoScan {
2026-03-29 20:22:15 +02:00
scanInterval := 24 * time . Hour
if cfg . Library . ScanInterval != "" {
if parsed , err := time . ParseDuration ( cfg . Library . ScanInterval ) ; err == nil && parsed > 0 {
scanInterval = parsed
}
}
2026-04-10 11:46:20 +02:00
go runAutoScan ( ctx , cfg , scanInterval , agentClient , d . ScanNow , scanPaths )
2026-03-29 20:22:15 +02:00
}
2026-04-08 18:50:59 +02:00
// Start reporter only for stream task handling
go reporter . Run ( ctx )
// Start daemon (blocks — runs sync loop)
2026-03-28 11:29:42 +01:00
errCh := make ( chan error , 1 )
go func ( ) {
errCh <- d . Run ( ctx )
} ( )
2026-04-07 19:08:37 +02:00
// Start idle guard for the persistent stream server
go startIdleGuard ( ctx , streamSrv )
2026-04-08 18:50:59 +02:00
// Signal handling
sigCh := make ( chan os . Signal , 1 )
signal . Notify ( sigCh , syscall . SIGINT , syscall . SIGTERM )
2026-03-28 11:29:42 +01:00
// Wait for signal or error
select {
case sig := <- sigCh :
fmt . Printf ( "\n Received %s, shutting down...\n" , sig )
2026-04-07 19:08:37 +02:00
cancelStreamContexts ( )
2026-05-06 23:12:38 +02:00
cancelAllWebRTCSessions ( )
2026-04-07 19:08:37 +02:00
streamSrv . Shutdown ( context . Background ( ) )
2026-03-28 11:29:42 +01:00
cancel ( )
// Give active downloads 30s to finish
shutdownCtx , shutdownCancel := context . WithTimeout ( context . Background ( ) , 30 * time . Second )
defer shutdownCancel ( )
manager . Shutdown ( shutdownCtx )
2026-04-08 18:50:59 +02:00
d . Deregister ( )
2026-03-28 11:29:42 +01:00
fmt . Println ( " Daemon stopped." )
return nil
case err := <- errCh :
2026-04-07 19:08:37 +02:00
cancelStreamContexts ( )
2026-05-06 23:12:38 +02:00
cancelAllWebRTCSessions ( )
2026-04-07 19:08:37 +02:00
streamSrv . Shutdown ( context . Background ( ) )
2026-03-28 11:29:42 +01:00
cancel ( )
return err
}
}
2026-04-08 23:36:18 +02:00
// isAllowedStreamPath checks that filePath is within one of the directories
// the daemon is configured to manage. This defends against a compromised API
// server sending a path traversal payload (e.g. /etc/passwd) in StreamRequest.
// isAllowedStreamPath reports whether filePath is contained within one of the
// allowedDirs. filePath must already be cleaned (filepath.Clean) by the caller.
// This defends against a compromised API server sending a path traversal payload.
func isAllowedStreamPath ( filePath string , allowedDirs ... string ) bool {
for _ , dir := range allowedDirs {
if dir == "" {
continue
}
rel , err := filepath . Rel ( filepath . Clean ( dir ) , filePath )
if err == nil && ! strings . HasPrefix ( rel , ".." ) {
return true
}
}
return false
}
2026-03-28 11:29:42 +01:00
func formatSpeedLog ( bps int64 ) string {
switch {
case bps >= 1024 * 1024 * 1024 :
return fmt . Sprintf ( "%.1f GB/s" , float64 ( bps ) / ( 1024 * 1024 * 1024 ) )
case bps >= 1024 * 1024 :
return fmt . Sprintf ( "%.1f MB/s" , float64 ( bps ) / ( 1024 * 1024 ) )
case bps >= 1024 :
return fmt . Sprintf ( "%.0f KB/s" , float64 ( bps ) / 1024 )
default :
return fmt . Sprintf ( "%d B/s" , bps )
}
}
2026-03-29 20:22:15 +02:00
2026-04-07 11:36:42 +02:00
// runAutoScan runs a library scan + sync on a timer or on-demand via scanNow channel.
2026-04-10 11:46:20 +02:00
// It scans all provided paths and syncs each independently so stale-item cleanup
// is scoped to the correct directory prefix on the server.
func runAutoScan ( ctx context . Context , cfg config . Config , interval time . Duration , ac * agent . Client , scanNow <- chan struct { } , scanPaths [ ] string ) {
log . Printf ( "[auto-scan] enabled: every %s, paths: %v" , interval , scanPaths )
2026-03-29 20:22:15 +02:00
select {
case <- time . After ( 30 * time . Second ) :
2026-04-07 11:36:42 +02:00
case <- scanNow :
2026-03-29 20:22:15 +02:00
case <- ctx . Done ( ) :
return
}
doScan := func ( ) {
2026-03-29 20:32:08 +02:00
defer func ( ) {
if r := recover ( ) ; r != nil {
log . Printf ( "[auto-scan] panic recovered: %v" , r )
}
} ( )
2026-04-10 11:46:20 +02:00
log . Printf ( "[auto-scan] starting scan of %v" , scanPaths )
2026-03-29 20:22:15 +02:00
existing , _ := library . LoadCache ( )
workers := cfg . Library . Workers
if workers == 0 {
workers = 8
}
2026-04-10 11:46:20 +02:00
scanOpts := library . ScanOptions {
2026-03-29 20:22:15 +02:00
Workers : workers ,
FFprobePath : cfg . Library . FFprobePath ,
Incremental : existing != nil ,
}
2026-04-10 11:46:20 +02:00
// Scan each path independently and sync per path so the server can
// scope stale-item deletion to the correct directory prefix.
const batchSize = 100
totalSynced := 0
var mergedItems [ ] library . LibraryItem
2026-03-29 20:22:15 +02:00
2026-04-10 11:46:20 +02:00
for _ , scanPath := range scanPaths {
cache , err := library . Scan ( ctx , scanPath , existing , scanOpts )
if err != nil {
log . Printf ( "[auto-scan] scan failed for %s: %v" , scanPath , err )
continue
}
mergedItems = append ( mergedItems , cache . Items ... )
items := library . BuildSyncItems ( cache )
if len ( items ) == 0 {
log . Printf ( "[auto-scan] no items under %s" , scanPath )
continue
}
syncStartedAt := time . Now ( ) . UTC ( ) . Format ( time . RFC3339 )
for i := 0 ; i < len ( items ) ; i += batchSize {
end := i + batchSize
if end > len ( items ) {
end = len ( items )
}
isLast := end >= len ( items )
_ , err := ac . SyncLibrary ( ctx , agent . LibrarySyncRequest {
Items : items [ i : end ] ,
ScanPath : scanPath ,
IsLastBatch : isLast ,
SyncStartedAt : syncStartedAt ,
} )
if err != nil {
log . Printf ( "[auto-scan] sync failed for %s: %v" , scanPath , err )
break
}
}
totalSynced += len ( items )
2026-03-29 20:22:15 +02:00
}
2026-04-10 11:46:20 +02:00
// Save merged cache for incremental scanning next time.
if len ( mergedItems ) > 0 {
mergedCache := & library . LibraryCache {
ScannedAt : time . Now ( ) . UTC ( ) . Format ( time . RFC3339 ) ,
Path : scanPaths [ 0 ] ,
Items : mergedItems ,
2026-03-29 20:22:15 +02:00
}
2026-04-10 11:46:20 +02:00
if err := library . SaveCache ( mergedCache ) ; err != nil {
log . Printf ( "[auto-scan] save cache failed: %v" , err )
2026-03-29 20:22:15 +02:00
}
}
2026-04-10 11:46:20 +02:00
log . Printf ( "[auto-scan] synced %d items across %d path(s)" , totalSynced , len ( scanPaths ) )
2026-03-29 20:22:15 +02:00
}
doScan ( )
ticker := time . NewTicker ( interval )
defer ticker . Stop ( )
for {
select {
case <- ticker . C :
doScan ( )
2026-04-07 11:36:42 +02:00
case <- scanNow :
log . Printf ( "[auto-scan] on-demand scan triggered" )
ticker . Reset ( interval )
doScan ( )
2026-03-29 20:22:15 +02:00
case <- ctx . Done ( ) :
return
}
}
}