unarr/internal/funnel/install.go
Deivid Soto 88316e7017
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5)
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.
2026-05-26 20:39:57 +02:00

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
}