Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`)
so the in-browser player on torrentclaw.com plays cross-network without
Tailscale or port forwarding — the mixed-content block that was breaking
HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare,
never through TorrentClaw infra (preserves the aggregator legal posture).
New surface:
• `internal/funnel/` package: subprocess wrapper + auto-download for
cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases
on first run, validated by ELF magic + size sanity, O_EXCL partial
write so concurrent daemons don't clobber each other.
• `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`).
• Daemon supervisor goroutine keeps cloudflared up across crashes + CF's
~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit
the reported URL is cleared so the web stops handing out a dead host.
• Wire: agent registers/syncs a FunnelURL field; web prefers it over
Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio
addon).
Default ON for fresh installs (NAS/Docker get it without terminal-in);
existing configs that pre-date the feature stay off until the operator
opts in with `unarr funnel on`.
Docker image now bundles cloudflared (built per TARGETARCH via buildx).
Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9
sources. The level we hint to libx264 was derived from height alone,
which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's
3600). Bumped each tier: 720p → 4.0, 1080p → 4.1.
Version: 0.9.4 → 0.9.5.
167 lines
5.3 KiB
Go
167 lines
5.3 KiB
Go
package funnel
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/torrentclaw/unarr/internal/config"
|
|
)
|
|
|
|
// ResolveBinary returns the path to a usable cloudflared executable, downloading
|
|
// one into the unarr data dir if neither $PATH nor the cached location has it.
|
|
// This makes the funnel feature usable on headless installs (NAS / Docker)
|
|
// where the user can't easily install cloudflared via the OS package manager.
|
|
//
|
|
// Resolution order:
|
|
//
|
|
// 1. cloudflared on $PATH (operator already installed it)
|
|
// 2. <data-dir>/bin/cloudflared (we cached it on a previous run)
|
|
// 3. download from GitHub releases (Linux-only fallback; macOS / Windows
|
|
// return a clear error pointing at brew / winget)
|
|
func ResolveBinary() (string, error) {
|
|
if p, err := exec.LookPath("cloudflared"); err == nil {
|
|
return p, nil
|
|
}
|
|
cached := cachedBinaryPath()
|
|
if _, err := os.Stat(cached); err == nil {
|
|
return cached, nil
|
|
}
|
|
return downloadCloudflared(cached)
|
|
}
|
|
|
|
func cachedBinaryPath() string {
|
|
name := "cloudflared"
|
|
if runtime.GOOS == "windows" {
|
|
name += ".exe"
|
|
}
|
|
return filepath.Join(config.DataDir(), "bin", name)
|
|
}
|
|
|
|
// downloadCloudflared fetches the latest cloudflared release asset matching
|
|
// the current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a
|
|
// pointer at the OS package manager.
|
|
//
|
|
// Supply-chain caveat: we trust GitHub-over-TLS + cloudflare/cloudflared
|
|
// repo integrity. The fetch is over HTTPS to api.github.com's release-asset
|
|
// redirector, so a network MITM is bounded by Let's Encrypt + GitHub's cert
|
|
// chain. We additionally verify the file is an ELF binary (Linux magic
|
|
// bytes) so a generic 404 HTML page or a wrong-arch tarball is rejected at
|
|
// rest. We do NOT verify a signature because Cloudflare doesn't sign release
|
|
// assets at the moment — if you need stricter integrity, install cloudflared
|
|
// from your distro's package manager (apt/brew/winget) and unarr will use
|
|
// the PATH copy.
|
|
func downloadCloudflared(dest string) (string, error) {
|
|
if runtime.GOOS != "linux" {
|
|
return "", fmt.Errorf("funnel: auto-download not supported on %s — install cloudflared manually or drop a binary at %s", runtime.GOOS, dest)
|
|
}
|
|
|
|
var asset string
|
|
switch runtime.GOARCH {
|
|
case "amd64":
|
|
asset = "cloudflared-linux-amd64"
|
|
case "arm64":
|
|
asset = "cloudflared-linux-arm64"
|
|
case "arm":
|
|
asset = "cloudflared-linux-armhf"
|
|
case "386":
|
|
asset = "cloudflared-linux-386"
|
|
default:
|
|
return "", fmt.Errorf("funnel: unsupported linux arch %q — install cloudflared manually", runtime.GOARCH)
|
|
}
|
|
|
|
url := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + asset
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return "", fmt.Errorf("funnel: create bin dir: %w", err)
|
|
}
|
|
|
|
// O_EXCL so concurrent unarr-dev / prod daemons don't clobber each
|
|
// other's partial download. The loser gets EEXIST → falls back to
|
|
// polling for the winner to finish.
|
|
tmp := dest + ".partial"
|
|
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrExist) {
|
|
// Another process is downloading. Wait briefly for them to finish.
|
|
for range 60 {
|
|
time.Sleep(time.Second)
|
|
if _, statErr := os.Stat(dest); statErr == nil {
|
|
return dest, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("funnel: another download in progress at %s (timed out)", tmp)
|
|
}
|
|
return "", fmt.Errorf("funnel: open dest: %w", err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 5 * time.Minute}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
_ = out.Close()
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("funnel: download cloudflared: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = out.Close()
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("funnel: download cloudflared: HTTP %d from %s", resp.StatusCode, url)
|
|
}
|
|
|
|
if _, err := io.Copy(out, resp.Body); err != nil {
|
|
_ = out.Close()
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("funnel: write dest: %w", err)
|
|
}
|
|
if err := out.Close(); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("funnel: close dest: %w", err)
|
|
}
|
|
|
|
// Sanity check before promoting <partial> to <dest>: must be a Linux
|
|
// ELF executable (rejects 404 HTML pages or wrong-arch payloads) and at
|
|
// least 1 MB (real cloudflared is ~50 MB; anything smaller is corrupt).
|
|
if err := verifyLinuxElf(tmp); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("funnel: downloaded file failed sanity check: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmp, dest); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("funnel: rename dest: %w", err)
|
|
}
|
|
return dest, nil
|
|
}
|
|
|
|
// verifyLinuxElf returns nil when the file at `path` starts with the ELF
|
|
// magic bytes and is at least 1 MB. Used as a low-cost guard against
|
|
// downloading an HTML error page or a wrong-arch payload.
|
|
func verifyLinuxElf(path string) error {
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if st.Size() < 1024*1024 {
|
|
return errors.New("file is suspiciously small (<1 MB)")
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
head := make([]byte, 4)
|
|
if _, err := io.ReadFull(f, head); err != nil {
|
|
return fmt.Errorf("read magic bytes: %w", err)
|
|
}
|
|
if !bytes.Equal(head, []byte{0x7f, 'E', 'L', 'F'}) {
|
|
return errors.New("not an ELF binary")
|
|
}
|
|
return nil
|
|
}
|