fix(cors): allow play from .to / staging / onion mirrors

Daemon CORS allowlist was hardcoded to torrentclaw.com + localhost. Browsers
playing from any other official mirror (.to, onion, www., staging.) received
200 + body from the daemon's HLS server but no Access-Control-Allow-Origin
header, so the response was dropped client-side. Probe loop treated every
candidate as a failure and surfaced "No se puede conectar con tu agente
— 404 todos los canales" even though the tunnel + ffmpeg were healthy.

Static baseline now includes the full known mirror set (.com / www / app /
staging / .to / www.to / built-in onion). At startup the daemon also fetches
/api/mirrors with IPFS fallback and merges the live origins, so a future
mirror addition does not require a CLI rebuild.
This commit is contained in:
Deivid Soto 2026-05-27 10:06:54 +02:00
parent 2e7cd7e8ed
commit 7b78d0b778
2 changed files with 69 additions and 1 deletions

View file

@ -293,7 +293,15 @@ func runDaemonStart() error {
// Create persistent stream server // Create persistent stream server
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort) streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP) streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP)
streamSrv.SetCORSAllowedOrigins(cfg.Download.CORSExtraOrigins) // CORS extras = operator config + dynamic mirror list from /api/mirrors.
// Without the mirror merge, a user playing from `torrentclaw.to` (or any
// future mirror) hits the daemon, gets 200 + body, but no
// `Access-Control-Allow-Origin` → browser drops the response → player
// reports "404 todos los canales". Fetching /api/mirrors at startup
// future-proofs against mirror additions without a CLI rebuild.
corsExtras := append([]string(nil), cfg.Download.CORSExtraOrigins...)
corsExtras = append(corsExtras, mirrorCORSOrigins(ctx, cfg, userAgent)...)
streamSrv.SetCORSAllowedOrigins(corsExtras)
// Reap HLS tmpdirs left over from a previous daemon run before we start // Reap HLS tmpdirs left over from a previous daemon run before we start
// accepting new sessions. The in-memory registry doesn't survive a // accepting new sessions. The in-memory registry doesn't survive a
// restart, so without this disk usage grows unbounded across restarts. // restart, so without this disk usage grows unbounded across restarts.
@ -862,3 +870,48 @@ func superviseFunnel(ctx context.Context, d *agent.Daemon, port int) {
backoff = min(backoff*2, maxBackoff) backoff = min(backoff*2, maxBackoff)
} }
} }
// mirrorCORSOrigins fetches /api/mirrors from the configured primary (+ extra
// mirror candidates + static IPFS fallback) and returns the discovered URLs as
// Origin strings. Best-effort: any failure logs a warning and returns an empty
// slice; the static defaultCORSAllowedOrigins in validate.go covers the known
// mirrors (.com / .to / built-in onion) so the daemon still accepts the
// official surfaces when this call fails.
//
// Bounded to a short timeout so a slow /api/mirrors response can't delay
// daemon startup — every second here is a second the user can't play.
func mirrorCORSOrigins(parent context.Context, cfg config.Config, userAgent string) []string {
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
candidates := append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...)
resp, err := agent.FetchMirrorsWithFallback(ctx, candidates, userAgent)
if err != nil {
log.Printf("[cors] mirror discovery failed (%v) — using static allowlist only", err)
return nil
}
seen := make(map[string]struct{})
out := make([]string, 0, len(resp.Mirrors))
add := func(rawURL string) {
if rawURL == "" {
return
}
origin := strings.TrimRight(rawURL, "/")
if _, dup := seen[origin]; dup {
return
}
seen[origin] = struct{}{}
out = append(out, origin)
}
for _, m := range resp.Mirrors {
add(m.URL)
}
if resp.Tor != nil {
add(resp.Tor.URL)
}
if len(out) > 0 {
log.Printf("[cors] merged %d mirror origins from /api/mirrors", len(out))
}
return out
}

View file

@ -21,12 +21,27 @@ var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`)
// 127.0.0.1 is listed in addition to localhost because some browsers treat // 127.0.0.1 is listed in addition to localhost because some browsers treat
// them as distinct origins for CORS. // them as distinct origins for CORS.
// //
// Mirrors (`.to`, `staging.torrentclaw.com`, `www.`) are listed so a user
// playing from any official mirror succeeds the HEAD probe; without these
// the browser drops the response for "missing ACAO" and the player reports
// "404 todos los canales" even though the daemon returned 200.
//
// Note: media tags (<video src>, <audio src>) do not send the Origin // Note: media tags (<video src>, <audio src>) do not send the Origin
// header so they are not gated by CORS at all; this allowlist only // header so they are not gated by CORS at all; this allowlist only
// affects fetch()/XHR. // affects fetch()/XHR.
var defaultCORSAllowedOrigins = []string{ var defaultCORSAllowedOrigins = []string{
"https://torrentclaw.com", "https://torrentclaw.com",
"https://www.torrentclaw.com",
"https://app.torrentclaw.com", "https://app.torrentclaw.com",
"https://staging.torrentclaw.com",
"https://torrentclaw.to",
"https://www.torrentclaw.to",
// Tor mirror — Tor Browser sends `Origin: http://<addr>.onion` (plain
// http, no port). Mirror address is the BUILT_IN_ONION constant from
// torrentclaw-web/src/lib/mirrors-config.ts; rotates rarely, kept in
// sync by hand. Daemon also dynamically merges /api/mirrors at startup
// (see daemon.go) so a new key doesn't need a CLI rebuild.
"http://torrentf3aifidcsaaanmnmuhv2s53r6hqsl3zkmfidiaxainkeqk5id.onion",
"http://localhost:3030", "http://localhost:3030",
"http://127.0.0.1:3030", "http://127.0.0.1:3030",
} }