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.
This commit is contained in:
parent
ca7de23a56
commit
88316e7017
15 changed files with 778 additions and 13 deletions
167
internal/funnel/install.go
Normal file
167
internal/funnel/install.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue