unarr/internal/funnel/funnel.go

204 lines
5.9 KiB
Go
Raw Permalink Normal View History

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
// 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. Quick Tunnel hostnames are always
// several hyphen-joined dictionary words (e.g.
// `make-appointments-negotiation-blacks`), so we require at least one hyphen.
// This deliberately excludes cloudflared's control-plane endpoint
// `https://api.trycloudflare.com`, which appears earlier in the log stream — a
// permissive `[a-z0-9-]+` matched `api` first and we advertised a dead URL.
var urlPattern = regexp.MustCompile(`https://[a-z0-9]+(?:-[a-z0-9]+)+\.trycloudflare\.com`)
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
// 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:
}
}
}
}
}