unarr/internal/funnel/funnel.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

199 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 25 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:
}
}
}
}
}