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
199
internal/funnel/funnel.go
Normal file
199
internal/funnel/funnel.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Package funnel manages the optional CloudFlare Quick Tunnel subprocess
|
||||
// that gives the daemon a public HTTPS hostname for cross-network playback
|
||||
// from browser-based clients (web player on torrentclaw.com / torrentclaw.to).
|
||||
//
|
||||
// Why: HTTPS pages can't fetch HTTP resources (mixed content). Without a
|
||||
// tunnel the daemon is only reachable from the same machine (localhost is
|
||||
// exempt) or via Tailscale (which users can install themselves but most
|
||||
// won't). CF Quick Tunnels are anonymous — no CF account, no DNS, no port
|
||||
// forwarding — and assign a one-shot `https://<random>.trycloudflare.com`
|
||||
// URL. Bytes flow through CF, never through our infra (legal posture: we
|
||||
// don't relay; CF does).
|
||||
//
|
||||
// Lifecycle:
|
||||
//
|
||||
// t, err := funnel.Start(ctx, funnel.Config{Port: 11819})
|
||||
// defer t.Close()
|
||||
// url, err := t.WaitURL(30 * time.Second) // blocks until cloudflared emits the URL
|
||||
//
|
||||
// The tunnel runs until the context is cancelled or t.Close() is called.
|
||||
package funnel
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// urlPattern matches the `https://<random>.trycloudflare.com` URL cloudflared
|
||||
// prints when a Quick Tunnel is registered. The hostname has a random
|
||||
// hyphen-separated label followed by .trycloudflare.com.
|
||||
var urlPattern = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`)
|
||||
|
||||
// Config controls how the tunnel is launched.
|
||||
type Config struct {
|
||||
// Port is the local upstream port cloudflared will tunnel to. Required.
|
||||
Port int
|
||||
// Binary is the cloudflared executable path. When empty the package looks
|
||||
// it up via $PATH.
|
||||
Binary string
|
||||
}
|
||||
|
||||
// Tunnel is a handle on a running cloudflared Quick Tunnel.
|
||||
type Tunnel struct {
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
urlCh chan string
|
||||
exitCh chan error
|
||||
mu sync.Mutex
|
||||
url string
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// Start launches cloudflared as a subprocess. The returned *Tunnel exposes the
|
||||
// public URL via WaitURL once cloudflared registers it (usually 2–5 s).
|
||||
//
|
||||
// The subprocess inherits the cancellation of the supplied context. Closing
|
||||
// the *Tunnel sends SIGTERM and waits for the subprocess to exit.
|
||||
func Start(ctx context.Context, cfg Config) (*Tunnel, error) {
|
||||
if cfg.Port <= 0 {
|
||||
return nil, fmt.Errorf("funnel: invalid Port %d", cfg.Port)
|
||||
}
|
||||
binary := cfg.Binary
|
||||
if binary == "" {
|
||||
resolved, err := ResolveBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary = resolved
|
||||
}
|
||||
|
||||
subCtx, cancel := context.WithCancel(ctx)
|
||||
// `--no-autoupdate` disables cloudflared's daily self-update check (the
|
||||
// daemon manages binary rotation). `--metrics 127.0.0.1:0` suppresses the
|
||||
// default `:9090` listener that would collide on a shared box.
|
||||
cmd := exec.CommandContext(subCtx, binary,
|
||||
"tunnel",
|
||||
"--no-autoupdate",
|
||||
"--metrics", "127.0.0.1:0",
|
||||
"--url", fmt.Sprintf("http://localhost:%d", cfg.Port),
|
||||
)
|
||||
|
||||
// cloudflared writes the connect log + assigned URL to stderr.
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("funnel: pipe stderr: %w", err)
|
||||
}
|
||||
cmd.Stdout = io.Discard // quick tunnels print nothing useful on stdout
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("funnel: start cloudflared: %w", err)
|
||||
}
|
||||
|
||||
t := &Tunnel{
|
||||
cmd: cmd,
|
||||
cancel: cancel,
|
||||
urlCh: make(chan string, 1),
|
||||
exitCh: make(chan error, 1),
|
||||
}
|
||||
|
||||
// Reader goroutine: scan cloudflared's stderr for the URL, surface the
|
||||
// rest as a single string we don't try to interpret.
|
||||
go t.scanStderr(stderr)
|
||||
|
||||
// Waiter goroutine: signal exit so callers can react (e.g. restart).
|
||||
go func() {
|
||||
t.exitCh <- cmd.Wait()
|
||||
}()
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// WaitURL blocks until cloudflared has registered the tunnel and emitted the
|
||||
// public URL, or `timeout` elapses, or the subprocess exits. The returned URL
|
||||
// has the form `https://<random>.trycloudflare.com`.
|
||||
func (t *Tunnel) WaitURL(timeout time.Duration) (string, error) {
|
||||
t.mu.Lock()
|
||||
if t.url != "" {
|
||||
u := t.url
|
||||
t.mu.Unlock()
|
||||
return u, nil
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
select {
|
||||
case u := <-t.urlCh:
|
||||
return u, nil
|
||||
case err := <-t.exitCh:
|
||||
if err == nil {
|
||||
return "", errors.New("funnel: cloudflared exited before URL")
|
||||
}
|
||||
return "", fmt.Errorf("funnel: cloudflared exited: %w", err)
|
||||
case <-time.After(timeout):
|
||||
return "", fmt.Errorf("funnel: timed out waiting for URL after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// URL returns the assigned tunnel URL, or "" if not yet emitted.
|
||||
func (t *Tunnel) URL() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.url
|
||||
}
|
||||
|
||||
// Done returns a channel that closes once the subprocess exits. The error sent
|
||||
// before close describes the exit reason (nil = clean shutdown via Close).
|
||||
func (t *Tunnel) Done() <-chan error {
|
||||
return t.exitCh
|
||||
}
|
||||
|
||||
// Close terminates the subprocess and waits for it to exit. Safe to call
|
||||
// multiple times.
|
||||
func (t *Tunnel) Close() error {
|
||||
t.mu.Lock()
|
||||
if t.stopped {
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
t.stopped = true
|
||||
t.mu.Unlock()
|
||||
t.cancel()
|
||||
// Drain the exit channel so the Wait goroutine doesn't leak.
|
||||
select {
|
||||
case <-t.exitCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tunnel) scanStderr(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Some cloudflared lines exceed the default 64KiB scanner buffer (when it
|
||||
// prints connection diagnostics). Bump to 1MiB.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if t.URL() == "" {
|
||||
if m := urlPattern.FindString(line); m != "" {
|
||||
t.mu.Lock()
|
||||
t.url = m
|
||||
t.mu.Unlock()
|
||||
// Non-blocking send: if no one is listening, just drop —
|
||||
// the URL field carries the value for any later WaitURL call.
|
||||
select {
|
||||
case t.urlCh <- m:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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