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.
199 lines
5.6 KiB
Go
199 lines
5.6 KiB
Go
// 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:
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|