Two new pre-flight guards in scripts/release.sh, evaluated right after the
branch check:
1. Reject if HEAD subject matches `(X.Y.Z)` — historical pattern where the
feature commit itself bumped the version (e.g. `feat(...) (0.9.14)`).
Forces every release to land in a dedicated `chore(release): X.Y.Z`
commit so the changelog + tag point at a clean release boundary.
2. Reject if HEAD is already `chore(release): …` — prevents re-running the
script with no new commits since the previous release (would otherwise
produce an empty release on top of itself).
Scope deliberately `chore(scripts)` (not `chore(release)`) so this very
commit doesn't trip guard 2 the next time release.sh runs.
Old copy claimed unarr was a "torrent search" tool. unarr's real job is
downloading (torrent + debrid + usenet), streaming via local HLS, transcoding
with ffmpeg+HW accel, and library management. Search just queries the
torrentclaw.com catalog — secondary feature, not the identity.
- root cobra Short/Long now lead with download/stream/transcode and list the
three backends + WireGuard + Cloudflare Funnel
- README hero + subheading mirror the same positioning
- DOCKERHUB hero updated to match
- "Search & Discovery" group → "Catalog & Discovery" (search still grouped,
but framed as catalog browsing not product identity)
Forgejo runner auto-injects GITHUB_TOKEN; combined with the GITEA_TOKEN we
set explicitly, goreleaser errors with 'multiple tokens'. Unset the GitHub
one inside the run step so goreleaser follows the Gitea/Forgejo release
path defined by .goreleaser.yml's gitea_urls block.
Adds TestBuildHLSFFmpegArgsVAAPIDump alongside the existing assertion
tests. Logs the complete argv buildHLSFFmpegArgsAt emits for a
typical VAAPI session so an operator can paste it into a shell and
reproduce the encode without booting the dev stack — same effect as
`journalctl --user -u unarr-dev | grep ffmpeg`, no daemon needed.
Verified locally against AMD Raphael iGPU on this dev box: the
dumped argv encoded a 5 s 4K source → 720p in 3.1 s wall, produced
3 HLS segments + init.mp4 that decode cleanly under ffprobe.
goreleaser v2 dropped `release.gitea_urls`; the key is now top-level
on its own. With the old nested form `goreleaser release` failed with
`yaml: unmarshal errors: line 67: field gitea_urls not found in type
config.Release` before even starting the build.
Re-anchor to v0.9.14 so the ship pipeline can produce binaries.
Closes QW2. Validated against the dev box's AMD Raphael iGPU
(/dev/dri/renderD128, radeonsi/mesa 25.2.8). The "proper" full-GPU
path via scale_vaapi triggers a known mesa 25 + Raphael bug
("Cannot allocate memory" per session start, encode still succeeds
but logs are spammy) — hybrid CPU scale → format=nv12 → hwupload
→ h264_vaapi encode delivers GPU surfaces to the encoder without
poking the broken scaler.
Three concrete changes in buildHLSFFmpegArgsAt:
1. New `case "h264_vaapi"` adds `-vaapi_device /dev/dri/renderD128`.
Multi-GPU hosts (this dev box has NVIDIA on renderD129 + AMD on
renderD128) need it so the encoder doesn't bind to a non-VAAPI
render node — without it the encoder fell back to NULL device
in manual smoke testing.
2. Filter chain branches on codec: VAAPI uses
`scale=…,format=nv12,hwupload` while libx264 / NVENC / QSV
keep the existing `scale=…,format=yuv420p,setparams=…` shape.
The setparams color metadata block is dropped on VAAPI because
VAAPI surfaces don't expose VUI fields and the encoder writes
its own.
3. Two new unit tests lock the argv shape so a future refactor
doesn't accidentally merge the paths back together:
TestBuildHLSFFmpegArgsVAAPI asserts the new flags + the
ABSENCE of scale_vaapi; TestBuildHLSFFmpegArgsLibx264NoRegression
verifies the software path keeps yuv420p + setparams + has
none of the VAAPI extras.
Manual ffmpeg validation on the dev box:
hybrid encode of 5 s 4K → 720p: 0.66 s wall, 472 % CPU, 268 KB
output — no errors logged. scale_vaapi variant in comparison
spammed "Cannot allocate memory" while emitting valid output.
GitHub torrentclaw org is shadow-banned and the CI lives at git.torrentclaw.com
now. Forgejo Actions is enabled cluster-wide; this moves the workflows into the
runner's natively-watched .forgejo/workflows/ tree and adapts each step so the
existing Forgejo runner ('docker', 'ubuntu-latest' labels) can execute them
without leaning on GitHub-only tooling.
- ci.yml: drop actions/setup-go (use container: golang:1.25), replace
golangci-lint-action with the upstream install.sh, drop codecov-action
(third-party, can re-add later with a Forgejo-compatible variant).
- release.yml: drop goreleaser-action (install via curl), wire GITEA_TOKEN +
the new release.gitea_urls block in .goreleaser.yml so goreleaser publishes
to Forgejo. Sign step swaps 'gh release upload' for curl against the Forgejo
releases API (via the in-cluster forgejo:3000 hostname). VirusTotal job
dropped — depended heavily on 'gh release' wiring; can be reimplemented
against the Forgejo API later if we re-enable it.
- docker-rebuild.yml: drop docker/login-action + docker/build-push-action,
use raw 'docker' commands with manually-installed buildx + qemu. Same
weekly schedule (Mon 04:17 UTC) and same 'latest' refresh behaviour.
- pages.yml: deleted — install.sh / install.ps1 are already served from the
Hetzner releases volume at torrentclaw.com/install.sh, so the GitHub Pages
copy was redundant even before the shadow-ban.
.goreleaser.yml: add release.gitea_urls (api=forgejo:3000, download via the
public Forgejo URL) + prerelease:auto. ship.sh uses '--skip=publish' so local
runs aren't affected by the new release block.
Closes the deferred bajo-priority item from the fase 3.3b critico.
Without this the watcher kept polling a torn-down HLSSession for up
to 60 s — fine in current code paths (Close always pairs with ctx
cancel which makes the select{} branch fire), but the function's
correctness then leaned on a caller invariant rather than its own
state check. Adding IsClosed() as a public wrapper around the
existing isClosed() lets the watcher detect any future
session-shutdown path (registry replace, idle sweep, internal kill)
without touching the unexported helper.
Critico flag: rctx was rooted at context.Background() instead of the
session's hlsCtx, so a tab close / session cancel mid-POST left the
goroutine blocking on the in-flight webhook for up to 10 s. Switched
to a child of hlsCtx — the same scope the watchSessionReady loop
already respects via the outer ctx.Done() select.
Idempotent webhook means a now-orphan session getting marked ready
is cosmetic; the savings here are goroutine pinning + a slow webhook
on a torn-down session.
Closes Fase 3.3b. Daemon now tells the server the moment a session's
first HLS segment + init.mp4 land on disk; the web side flips
streaming_session.ready_at = NOW(), which its SSE endpoint pushes to
subscribed players so the loading UI flips from "Preparando…" to
"Stream listo" without polling HEAD on the segment URL.
Surface:
- New Client.MarkSessionReady(ctx, sessionId) HTTP method →
POST /api/internal/agent/session-ready.
- New engine.HLSSession.ReadyCount() + FromCache() accessors so the
watcher goroutine doesn't reach into private state.
- New cmd.watchSessionReady(ctx, client, hsess, sessionId) goroutine
polls ReadyCount every 200 ms with a 60 s deadline + short-circuits
for cache-HIT sessions (ready the moment StartHLSSession returns).
- Daemon callback spawns it right after streamSrv.HLS().Register so
the watcher's lifecycle matches the session's.
Best-effort: a transient network failure on the webhook is logged + the
goroutine exits — the player's existing HEAD-probe retry path still
discovers ready state independently. The webhook is an acceleration,
not a hard dependency.
DetectHWAccelDiagnostic spawns subprocess calls; an unexpected panic
(broken ffmpeg binary, OOM mid-exec) would otherwise leave the
WithTimeout context dangling until natural expiry. defer keeps the
goroutine + timer reachable until runDaemonStart returns, but on a
long-lived daemon that's the process lifetime anyway — same effective
cost, with the safety guarantee.
Daemon now runs engine.DetectHWAccelDiagnostic at startup (instead of the
lighter DetectHWAccel) and ships the full picture — ffmpeg version,
resolved binary path, HW encoders compiled in, device files / drivers
detected — up to the server in the RegisterRequest payload.
Why: the most common cause of slow first-play is a software-only ffmpeg
build. Surfacing the diagnostic in the web AgentsTab "Diagnose
transcoder" modal lets a user see *why* their backend landed on libx264
(e.g. brew's default formula ships without --enable-nvenc, or the
container is missing /dev/nvidia0) without SSHing in to run `unarr
probe-hwaccel` manually.
Also emits a single `[transcode]` startup log line summarising the same
data — convenient for `journalctl --user -u unarr | grep transcode`.
Bounded by a 10 s context so a hung ffmpeg binary can't stall daemon
startup forever.
Mirrors the slash command added in torrentclaw-web/.claude/commands.
With the global ~/.gitignore excluding .claude/ by default, the
gitignore override is required for project-shared commands/agents/hooks
to be checked in (settings.local.json and projects/ stay local).
/publish documents the full unarr release flow (bump + tag + binaries +
Hetzner + Docker Hub + smoke) as a single command, while GitHub Actions
remains unavailable for the torrentclaw org.
GitHub Actions release.yml + docker job currently doesn't fire (org
shadow-ban). ship.sh replicates the CI pipeline locally so releases
keep landing on Hetzner + Docker Hub without depending on CI:
1. Sanity checks: clean tree, tag at HEAD, version.go match
2. goreleaser release --skip=publish (build dist/*)
3. publish-cli-release.sh (rsync to Hetzner + flip version.txt)
4. docker buildx --push multi-arch (amd64 + arm64)
5. Smoke: torrentclaw.com/version + docker run image version
6. Optional --push to git-push tag to GH
Exposed via make targets: ship, ship-dry, ship-push.
Cinematic widescreen content (1920×804 at 2.39:1, 3840×1600 21:9, etc.)
was being misclassified: a 1080p source presented as 1920×804 fell to
720p because 804 < 900. Same shape for 2160p sources letterboxed below
2000px tall.
ResolveResolution now takes (width, height) and picks the larger of the
width-derived and height-derived buckets, so anamorphic/letterboxed
sources land in the right bucket.
First-frame latency drops by another 1-2 s on cold-cache plays:
1. HLS segment duration halved from 4 s to 2 s. seg-0 lands in ~half
the wait time — the player paints the first frame as soon as it
arrives. Software encodes on 4K go from ~3 s wait to ~1.5 s; HW
encoders shave ~0.5 s. Trade-off: 2× segment count per source
(~3600 segments for a 2 h movie instead of ~1800), but each is
half the size on disk. Within HLS spec — Apple recommends 6 s, but
2 s is valid; LL-HLS uses 1-2 s.
2. Cache from 0.9.9 self-heals: cached entries used 4 s segments;
VerifyComplete now expects a different highest segment index and
invalidates them, triggering a re-encode on next play. No manual
cleanup needed.
3. OnStreamSession daemon callback now runs StartHLSSession in a
goroutine. Sync HTTP responses return immediately (~50 ms instead
of waiting for the ~0.3-1 s ffprobe). Other pending actions in
the same sync cycle (new tasks, deletes) no longer wait for the
transcoder warmup. Browser HEAD probes already have a 30 s retry
budget that covers the brief gap between playerSessionRegistry.add
and streamSrv.HLS().Register.
Helpers added (engine.segmentDurationFor / segmentStartSec /
segmentCountForDuration) so a future short-first-segment variant or
non-uniform layout can slot in without touching every call site.
Internal: -hls_init_time was investigated but discarded — ffmpeg's
implementation treats it as a min duration, not a target, so it
couldn't deliver a uniformly 2 s first segment on top of a 4 s
steady state. Uniform 2 s is simpler and gets the same first-frame
win.
Addresses items raised by the multi-agent code review of the 0.9.9
HW accel + first-start work:
- EncoderProfile now carries DecodeHwAccel so the demuxer `-hwaccel`
flag and the encoder argv derive from a single resolved profile.
Adding a new backend can no longer leave the two switches out of
sync.
- VAAPI no longer passes `-hwaccel_output_format vaapi`. That option
pinned decoded frames to GPU memory, but the filter chain (scale,
format, setparams) runs on CPU and would fail with "impossible to
convert between formats". Frames now decode HW + flow on CPU; the
encoder uploads back to GPU. Pre-existing bug, never reported because
no one had VAAPI auto-detected in practice.
- readyMax field comment + name: documented that it's a COUNT
(segments ready), not an index. The semantics were correct but the
comment read "highest index" which made `idx < readyMax` look like
an off-by-one to reviewers.
- probe_cache background janitor: 5-minute sweeper that drops expired
entries even when no lookup retouches the key. Lookup-only eviction
was fine for small libraries but unbounded for users who browse and
abandon thousands of files within a TTL window. Lazy + sync.Once.
- probe_cache TTL eviction now re-checks under the write lock so a
concurrent re-insert isn't accidentally evicted.
- probe_cache size-change test now Chtimes the file back to its
original mtime so only `size` differs between store and lookup
keys — properly exercises the size-check path.
- New TestProbeCache_SweepDropsExpired covers the janitor sweep.
- CHANGELOG: backfilled missing compare links 0.6.4 → 0.9.9.
- Stale "line ~1119" reference in VideoToolbox comment dropped; the
bitrate block moved a few lines and the comment was already wrong.
Two issues with the 0.9.9 preset retune:
1. applyDefaults was filling Preset="veryfast" before
ResolveEncoderProfile got to pick the latency-biased default, so the
"superfast" change never reached users with a freshly-generated
config.toml — only those who left the field empty saw it.
2. The configured preset was being passed through to every encoder.
That's only valid for libx264 (ultrafast…veryslow); NVENC uses p1-p7
and rejects anything else, QSV uses its own subset. A user with NVENC
+ preset="veryfast" would have ffmpeg reject the argv.
Now:
- TranscodeConfig.Preset documented as libx264-only with the full
range + advice on quality vs first-start latency.
- Default in applyDefaults is empty (was "veryfast") so the engine
fills in "superfast" on libx264.
- ResolveEncoderProfile ignores configuredPreset for vendor encoders
(NVENC sticks to p3, QSV to veryfast, VideoToolbox has no preset
knob). Test cases updated to lock in this behaviour.
Users who want better quality at slower first-play should set
download.transcode.preset = "veryfast" (previous default) / "faster" /
"fast" / "medium" in their config.toml.
Reduces first-segment latency on cache MISS so the player doesn't sit on
"preparando sesión". Three independent levers:
1. ProbeFile memoised by (path, mtime, size) for 30 min — second play of
the same source skips ffprobe (1-3 s on 50+ GB MKVs).
2. HLS encoder presets biased for latency over quality:
- libx264 default veryfast → superfast (~15-20% faster, marginal
quality loss at 5-25 Mbps target bitrates).
- NVENC: -preset p4 -tune hq → -preset p3 -tune ll. First-segment
~0.8 s on RTX-class GPUs (was ~1.5 s).
- QSV: -preset medium → -preset veryfast (keeps look_ahead=0).
- VideoToolbox: adds -realtime 1 (was unset). Bitrate args still
drive rate control; -q:v dropped to avoid the silent conflict
where ffmpeg ignored it under -b:v.
3. Per-session log surfaces encoder + accel + preset so "first-start
was slow" complaints can be triaged from the journal alone.
Diagnostic helpers (DetectHWAccelDiagnostic + HWAccelDiagnostic) added
for future wiring into daemon startup / agent register; users today can
already inspect via `unarr probe-hwaccel`.
Web: AgentsTab profile page now shows the agent's chosen encoder
(amber if software libx264, green if HW) plus the transcode-resolution
cap. Hidden for pre-0.9.9 agents that haven't reported hwAccel.
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.
Two bugs in 0.9.6/0.9.7 caused an infinite restart loop after a Force update
signal: the CLI never reported the upgrade outcome, so `upgrade_requested`
stayed `true`; AND `applyAutoUpgrade` called `os.Exit(0)` even when the
target version equalled the current one, so systemd respawned and saw the
flag again.
- new Client.ReportUpgradeResult → POST /api/internal/agent/upgrade-result
- applyAutoUpgrade calls it on success / failure / no-op
- no-op case detected up front (same version) — skips Execute + Exit,
clears server flag instead
OnUpgrade now downloads + replaces the binary and exits in a background
goroutine; the service supervisor (systemd Restart=always) respawns on the
new version. Removes the "run unarr update" manual step after pressing the
web's Force update button.
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.
Drops the custom WebRTC DataChannel pipeline + pion deps + WSS signaling
client + wire framing. Every in-browser playback now uses HLS over HTTP
from the daemon (Tailscale/LAN/UPnP). Browser P2P never re-enabled.
Wire renames (incompatible with web < 2026-05-26): agent.WebRTCSession
=> agent.StreamSession, SyncResponse.WebRTCSessions (JSON: webrtcSessions)
=> StreamSessions (JSON: streamSessions). MIN_AGENT_VERSION is bumped
to 0.9.4 on the web side so older agents see an upgrade card.
Also fixes the libx264 'VBV bitrate > level limit' abort by clamping
the encoder bitrate to the effective output height instead of the
requested label (carried over from the prior 0.9.3 unreleased work).
The seed_file vertical (mode=seed_file handler + engine.SeedFile) was
retired with the in-browser P2P player. [downloads.webrtc] config block
deleted; existing TOML files with the section still parse fine.
Asking for 2160p quality on a 720p source kept the daemon's qcap.VideoBitrate
at 25 Mbps even after outputHeight was clamped to the source. The level
H264LevelForHeight picks for the 720p output is 3.1 / 4.0, which rejects any
VBV >20 Mbps — libx264 then exited with "VBV bitrate (25000) > level limit"
on every restart, ffmpeg auto-restarted 3 times, master.m3u8 never appeared,
and the player got stuck at "Preparando sesión".
Re-derive the (height, bitrate) cap from the EFFECTIVE outputHeight via the
new capForHeight helper. Result: 720p source asked for 2160p → outputs 720p
with the 3500 kbps bitrate the level actually accepts. ffmpeg runs cleanly,
master.m3u8 appears, playback starts.
The web also clamps effectiveQuality to source resolution before the session
row is written, so the daemon mostly receives sane labels. This change keeps
the daemon defensive against (a) older web clients that still ask for
upscaled qualities, and (b) future quality="original" requests where qcap
is empty and Transcode.VideoBitrate could overshoot the level too.
A usenet-enabled agent silently produces corrupt files when par2 is
not installed: bad NNTP segments go unrepaired and unrar reports
checksum errors. Likewise, no unrar/7z means RAR-packed downloads
can't be unpacked at all.
When the registered agent has the usenet feature, check par2 and the
extractor (unrar/7z) in PATH and log a loud WARNING for each missing
one, mirroring the existing ffmpeg-for-HLS warning.
A failed usenet extract sets task.ErrorMessage to the full unrar/par2
dump (multi-KB). Sent raw, the web /agent/status route rejected it and
the terminal report failed, leaving the task stuck non-terminal.
Cap the reported errorMessage at 2000 bytes (rune-safe) in the status
snapshot, matching the server's stored length.
Add `unarr vpn` (status/enable/disable, with `status --check`) to manage the
managed WireGuard split-tunnel from the CLI. The daemon now reports its
split-tunnel state (active, mode, exit server) to the web on register and on
every sync, and sends its agent id when fetching the VPN config so the web can
arbitrate the single WireGuard slot (1 VPNResellers account = 1 WG keypair = 1
concurrent connection): the first agent claims it; the rest are told to run
OpenVPN on their own host (1 WireGuard + up to 9 OpenVPN = 10).
`status --check` passes probe=1 so it validates provisioning without claiming
the slot. VPNActive drops omitempty so a downed tunnel reaches the server and
frees the slot. Bumps to 0.9.2 with CHANGELOG + README VPN section.
- Bump golang.org/x/{net,crypto,sys,text,term} to latest patches to
clear GHSA module advisories flagged by Docker Scout.
- Add Docker Scout CVE gate to the release workflow (fails only on
FIXABLE critical/high; unfixed upstream ffmpeg codec CVEs are accepted
and documented in SECURITY.md).
- Add weekly + manual docker-rebuild workflow so newly fixed base/
ffmpeg/Go patches land on :latest between tagged releases.
- Document container image vuln-scanning policy and hardening in
SECURITY.md.
- DOCKERHUB.md: update stale 0.3.5 examples to 0.9.0, multi-arch note, mirrors
section (.com/.to/.onion), point all links to torrentclaw.com (GitHub 404s
anonymously under the org shadow-ban)
- release.yml: add peter-evans/dockerhub-description step so a tag push also
syncs the Docker Hub page from DOCKERHUB.md (continue-on-error)
The org GitHub shadow-ban 404s releases/raw/API to anonymous clients, so the
self-updater (api.github.com/releases/latest + github.com/.../releases/download)
was broken: `unarr upgrade` could neither check nor download.
- fetchLatestVersion → GET {base}/version (plain text)
- releaseURL → {base}/releases/download/v{ver}/{file}
- base resolves from cfg.Auth.APIURL via upgrade.SetBaseURL (PersistentPreRun),
so mirrors / onion / staging / UNARR_API_URL all route updates correctly
- tests updated to the new endpoints
downloads.vpn.config_file = path to a WireGuard .conf read directly by the
daemon (skips the web fetch). Lets you point unarr at your own WireGuard
server / personal VPN and split-tunnel torrent traffic through it without
the web provisioning plumbing — for testing and self-hosted setups.
In-process userspace WireGuard tunnel (wireguard-go + gVisor netstack) for
the managed-VPN add-on. No root, no OS routing changes: only the embedded
anacrolix/torrent client's peer + tracker traffic is routed through the
tunnel, so the swarm and trackers see the VPN IP, not the user's home IP.
unarr's control plane (API, heartbeats) keeps using the normal net.
- internal/vpn: FetchConfig (GET /api/internal/agent/vpn-config, Bearer auth,
typed errors for disabled/not_provisioned/slot_on_device) + Up (parse .conf
→ uapi, CreateNetTUN, device Up) + DialContext/ListenPacket adapters.
- engine/torrent.go: when a tunnel is set, wire TrackerDialContext +
HTTPDialContext + TrackerListenPacket to netstack, DisableUTP, and
AddDialer(NetworkDialer{tcp, netstack}) for peer conns.
- config: downloads.vpn.enabled flag.
- daemon: bring up the tunnel before the torrent client; non-fatal on
failure (logs + downloads in the clear); slot_on_device warns the user.
- version bump 0.8.1 → 0.9.0.
Pairs with the web VPN add-on (dormant behind NEXT_PUBLIC_VPN_ENABLED).
Runtime-verified once a VPNResellers trial provides a live endpoint.
Phase 3 security audit follow-up. Medium and low-severity hardenings
plus a deferred-work plan for the cross-repo stream-token rollout.
Stream server CORS: replace the wildcard Access-Control-Allow-Origin
with an allowlist that echoes back only torrentclaw.com,
app.torrentclaw.com, the local Next dev port (3030 — matches the web
repo package.json) and any extras the operator adds via the new
downloads.cors_extra_origins TOML key. A Vary: Origin header is now
emitted whenever the request carries an Origin header so an
intermediate cache cannot serve a stale ACAO to a different origin.
URL scheme guard: openBrowser and OpenPlayer refuse any URL that is
not http(s). Combined with passing the URL after "--" wherever the
launched helper supports it (open, mpv, vlc, cvlc), this stops a
leading "-" from being parsed as a switch by the spawned process.
State file permissions: WriteState now writes 0o600 so the agent ID,
PID and counters cannot be enumerated by another local user on a
shared host. Matches the existing config file mode.
ZIP slip defense-in-depth: extractZip extracts the safety check into
safeZipPath, which canonicalises the entry name (normalising
backslashes to "/"), rejects "..", "../" prefix and "/../" interior
components, and verifies the final destination stays inside destDir
before opening any file.
Mirror fallback: documented the design for multi-provider
mirrors.json hosting in the comment block on DefaultStaticFallbackURLs
and added a follow-up note about signing it with the same ed25519
release key. The list is kept at one provider until the second host
is provisioned and added to torrentclaw-web's STATIC_FALLBACKS.
Deferred work: a new plan document Docs/plans/security-stream-token.md
covers the per-task stream token (Phase 2.2 of the original audit)
which requires coordinated web + CLI work and ships separately.
Phase 2 security audit follow-up. Three independent hardenings against
the unauthenticated daemon surface, the long-lived agent SSE stream
and the self-update channel.
UPnP is now opt-in. The stream port + /hls endpoints have no auth, so
publishing them on the WAN via the gateway was a default that exposed
active downloads to anyone scanning the operator's external IP. New
config downloads.enable_upnp (default false) gates the mapping; LAN
and Tailscale clients continue to work unchanged. A startup log makes
the new default visible.
The agent SSE reader now uses a bounded bufio.Scanner instead of an
unbounded ReadString. A hostile or buggy server can no longer grow
daemon memory by streaming a single line forever or by emitting
unbounded data: continuation lines — both are capped at 256 KiB and
1 MiB respectively, and an error is surfaced so SignalLoop reconnects.
Self-update now verifies an ed25519 signature over checksums.txt when
the binary was built with a release public key embedded (injected via
goreleaser ldflags from RELEASE_SIGNING_PUBKEY). The companion
scripts/sign-checksums runs in the release workflow when both the
public-key variable and the private-key secret are present, uploading
checksums.txt.sig next to the existing checksums file. Builds without
the embedded key continue to update with SHA256-only verification; a
--allow-unsigned flag is provided so users on a signed build can
still install pre-signing releases or recover from an accidental
unsigned release.
A new scripts/gen-release-key helper documents the one-time keypair
generation procedure required before flipping signing on.
Phase 1 security audit follow-up:
- Reject HLS session IDs that aren't safe filesystem components
(regex allowlist) to defend against path traversal via a buggy or
compromised server. Applied at StartHLSSession and at the /hls URL
handler; invalid IDs share the 404 of unknown sessions so the
accepted format isn't enumerable.
- /health no longer leaks the active filename, taskID prefix or client
IP to non-loopback callers. Uses net.IP.IsLoopback so IPv4-mapped
IPv6 (::ffff:127.0.0.1) is recognised and the empty-string parse
failure stops bypassing the boundary.
- unrar/7z passwords now travel through stdin instead of -p<password>
in argv, removing /proc/<pid>/cmdline disclosure. Control characters
in the password are rejected up front so a hostile NZB cannot feed
extra prompt answers. Both invocations are bounded by a 30-minute
context to stop indefinite hangs if the tool ever decides to prompt.