Merge feat/ultra-vpn into main
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s

VPN split-tunnel, signed self-update, security hardening, IPFS mirror
fallback, container CVE scan gate, and 0.9.1 release prep.
This commit is contained in:
Deivid Soto 2026-05-21 17:08:34 +02:00
commit d0094e84bb
39 changed files with 2099 additions and 234 deletions

52
.github/workflows/docker-rebuild.yml vendored Normal file
View file

@ -0,0 +1,52 @@
# Rebuilds and re-pushes the `latest` image without a version bump so newly
# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned
# tags are immutable and never touched here. Runs weekly and on demand.
name: Docker rebuild
on:
schedule:
# Mondays 04:17 UTC (off the hour to avoid the scheduler rush)
- cron: "17 4 * * 1"
workflow_dispatch:
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Stamp the binary with the most recent release tag (not "dev").
- name: Resolve version
id: ver
run: echo "version=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
# Refresh the floating tag only — never overwrite a versioned release.
tags: torrentclaw/unarr:latest
build-args: |
VERSION=${{ steps.ver.outputs.version }}
# Force a fresh base pull so apk upgrade picks up new patches.
no-cache: true
- name: Scan image for fixable CVEs (gate)
uses: docker/scout-action@v1
with:
command: cves
image: torrentclaw/unarr:latest
only-severities: critical,high
only-fixed: true
exit-code: true

View file

@ -27,6 +27,28 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
# Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
# accepts it and the resulting binary disables signature checks
# (back-compat: pre-signing releases continue to update). Set
# RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
# to turn verification on.
RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
- name: Sign checksums.txt with ed25519
# Reference secrets.X directly — step-level env defined in this same
# step is unreliable to read from this step's own if: expression.
if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_TAG: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
go run ./scripts/sign-checksums \
-key "$RELEASE_SIGNING_KEY" \
-in dist/checksums.txt \
-out dist/checksums.txt.sig
gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber
docker: docker:
needs: release needs: release
@ -62,6 +84,31 @@ jobs:
build-args: | build-args: |
VERSION=${{ github.ref_name }} VERSION=${{ github.ref_name }}
# CVE gate. Fails the release on FIXABLE critical/high only — unfixed
# upstream ffmpeg codec CVEs are accepted (see SECURITY.md), so the
# codec noise does not block. Runs post-push (image already published);
# a failure here flags that a fixable CVE slipped through.
- name: Scan image for fixable CVEs (gate)
uses: docker/scout-action@v1
with:
command: cves
image: torrentclaw/unarr:latest
only-severities: critical,high
only-fixed: true
exit-code: true
# Sync the Docker Hub repo description from DOCKERHUB.md. Non-fatal: a
# description-API auth hiccup must not undo a successful image push.
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: torrentclaw/unarr
readme-filepath: ./DOCKERHUB.md
short-description: "unarr — the single binary that replaces your *arr stack"
virustotal: virustotal:
needs: release needs: release

View file

@ -26,6 +26,10 @@ builds:
- -s -w - -s -w
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}} - -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }} - -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
# Release-signing public key — verified by the self-updater against
# checksums.txt.sig. Empty when not configured; in that case
# signature verification is skipped and a warning is logged.
- -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }}
archives: archives:
- formats: [tar.gz] - formats: [tar.gz]

View file

@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.1] - 2026-05-21
### Added
- **mirror**: update fallback URLs to use IPFS and remove GitHub Pages
### Fixed
- **security**: bump golang.org/x deps and add container CVE scan gate
## [0.9.0] - 2026-05-21
### Added
- **agent**: add mirror failover, agent client refactor, status 401 detection
- **vpn**: local config_file for self-hosted/personal VPN testing
- **vpn**: split-tunnel torrent traffic through managed WireGuard
### Documentation
- **docker**: refresh Docker Hub README + sync description in CI
### Fixed
- **security**: CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
- **security**: UPnP opt-in, bounded SSE reader, signed self-update
- **security**: harden HLS session IDs, /health disclosure, archive password handling
- **upgrade**: fetch releases from TorrentClaw app, not GitHub
### Other
- **release**: 0.9.0
## [0.8.1] - 2026-05-08 ## [0.8.1] - 2026-05-08
@ -25,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Other ### Other
- **gitignore**: add dist-ffbinaries to ignored files - **gitignore**: add dist-ffbinaries to ignored files
- **release**: 0.8.1
## [0.8.0] - 2026-05-08 ## [0.8.0] - 2026-05-08
@ -238,16 +272,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.1] - 2026-04-01 ## [0.4.1] - 2026-04-01
### Added
- **cli**: add login command and refactor shared helpers
- **stream**: report watch progress to API via HTTP Range tracking
### Fixed
- **ci**: fix lint errors and pin CI to Go 1.25
- **lint**: remove unused newStubCmd function
### Other
- **cli**: remove moreseed stub command
- **cli**: remove redundant stub commands (monitor, open, add, compare)
## [0.4.0] - 2026-03-31
### Added
- **cli**: upgrade command, rich status, and version cache
### Fixed
- **progress**: always report status transitions and poll for control signals
## [0.3.7] - 2026-03-31
### CI/CD
- **docker**: remove dockerhub-description sync step
## [0.3.6] - 2026-03-31
### CI/CD
- **deps**: bump docker/metadata-action from 5 to 6
- **deps**: bump docker/setup-qemu-action from 3 to 4
- **deps**: bump docker/login-action from 3 to 4
- **deps**: bump docker/build-push-action from 6 to 7
- **deps**: bump codecov/codecov-action from 5 to 6
- **docker**: add Docker Hub description sync and DOCKERHUB.md
### Fixed
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
- **lint**: exclude common fire-and-forget patterns from errcheck
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
## [0.3.5] - 2026-03-30
### Changed
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
## [0.3.3] - 2026-03-30
### Fixed
- **ci**: remove go-client checkout steps
## [0.3.2] - 2026-03-30
### Added
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
### CI/CD
- **release**: add Docker Hub publish and VirusTotal scan jobs
### Documentation
- add beta notice, fix install URLs to get.torrentclaw.com
### Fixed
- **ci**: fix virustotal job condition syntax
- **docker**: simplify Dockerfile for CI builds (no local go-client)
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
### Other
- re-enable homebrew tap in goreleaser
## [0.3.1] - 2026-03-30
### Fixed
- **build**: unused variable in Windows process check
- **release**: disable homebrew tap until repo is created
### Other
- rename module from torrentclaw-cli to unarr
### Build
- remove UPX compression (antivirus false positives, startup penalty)
## [0.3.0] - 2026-03-29
### Added ### Added
- **agent**: add WebSocket transport with HTTP fallback - **agent**: add WebSocket transport with HTTP fallback
- **auth**: browser-based CLI authentication (like Claude Code) - **auth**: browser-based CLI authentication (like Claude Code)
- **cli**: add login command and refactor shared helpers
- **cli**: upgrade command, rich status, and version cache
- **daemon**: add auto-scan, force start, and stall timeout default - **daemon**: add auto-scan, force start, and stall timeout default
- **debrid**: add HTTPS downloader for debrid direct URLs - **debrid**: add HTTPS downloader for debrid direct URLs
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
- **stream**: report watch progress to API via HTTP Range tracking
- **stream**: UPnP port forwarding for remote video playback - **stream**: UPnP port forwarding for remote video playback
- **usenet**: implement full NNTP download pipeline - **usenet**: implement full NNTP download pipeline
- add migrate command, media server detection, and debrid auto-config - add migrate command, media server detection, and debrid auto-config
@ -257,61 +392,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- improve daemon resilience, streaming, and usenet downloads - improve daemon resilience, streaming, and usenet downloads
- initial commit — unarr CLI - initial commit — unarr CLI
### CI/CD
- **deps**: bump docker/metadata-action from 5 to 6
- **deps**: bump docker/setup-qemu-action from 3 to 4
- **deps**: bump docker/login-action from 3 to 4
- **deps**: bump docker/build-push-action from 6 to 7
- **deps**: bump codecov/codecov-action from 5 to 6
- **docker**: remove dockerhub-description sync step
- **docker**: add Docker Hub description sync and DOCKERHUB.md
- **release**: add Docker Hub publish and VirusTotal scan jobs
### Changed ### Changed
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
- extract BuildSyncItems to library package, remove duplication - extract BuildSyncItems to library package, remove duplication
### Documentation ### Documentation
- add beta notice, fix install URLs to get.torrentclaw.com
- improve CLI help, shell completion, and README - improve CLI help, shell completion, and README
### Fixed ### Fixed
- **build**: unused variable in Windows process check
- **ci**: fix lint errors and pin CI to Go 1.25
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
- **ci**: remove go-client checkout steps
- **ci**: fix virustotal job condition syntax
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
- **docker**: simplify Dockerfile for CI builds (no local go-client)
- **lint**: remove unused newStubCmd function
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
- **lint**: exclude common fire-and-forget patterns from errcheck
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
- **progress**: always report status transitions and poll for control signals
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
- **release**: disable homebrew tap until repo is created
- **torrent**: expand tracker list, add DHT persistence and configurable timeouts - **torrent**: expand tracker list, add DHT persistence and configurable timeouts
- force-start tasks bypass HasCapacity check in dispatch loop - force-start tasks bypass HasCapacity check in dispatch loop
- add panic recovery to auto-scan, cap DHT nodes at 200 - add panic recovery to auto-scan, cap DHT nodes at 200
- harden usenet/debrid downloaders from critico review - harden usenet/debrid downloaders from critico review
### Other
- **cli**: remove moreseed stub command
- **cli**: remove redundant stub commands (monitor, open, add, compare)
- re-enable homebrew tap in goreleaser
- rename module from torrentclaw-cli to unarr
### Build ### Build
- remove UPX compression (antivirus false positives, startup penalty)
- add -s -w -trimpath to Makefile, add build-small target with UPX - add -s -w -trimpath to Makefile, add build-small target with UPX
[0.9.1]: https://github.com/torrentclaw/unarr/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/torrentclaw/unarr/compare/v0.8.1...v0.9.0
[0.8.1]: https://github.com/torrentclaw/unarr/compare/v0.8.0...v0.8.1 [0.8.1]: https://github.com/torrentclaw/unarr/compare/v0.8.0...v0.8.1
[0.8.0]: https://github.com/torrentclaw/unarr/compare/v0.7.0...v0.8.0 [0.8.0]: https://github.com/torrentclaw/unarr/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/torrentclaw/unarr/compare/v0.6.8...v0.7.0 [0.7.0]: https://github.com/torrentclaw/unarr/compare/v0.6.8...v0.7.0
@ -331,4 +431,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[0.5.1]: https://github.com/torrentclaw/unarr/compare/v0.5.0...v0.5.1 [0.5.1]: https://github.com/torrentclaw/unarr/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/torrentclaw/unarr/compare/v0.4.1...v0.5.0 [0.5.0]: https://github.com/torrentclaw/unarr/compare/v0.4.1...v0.5.0
[0.4.1]: https://github.com/torrentclaw/unarr/compare/v0.4.0...v0.4.1 [0.4.1]: https://github.com/torrentclaw/unarr/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/torrentclaw/unarr/compare/v0.3.7...v0.4.0
[0.3.7]: https://github.com/torrentclaw/unarr/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/torrentclaw/unarr/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/torrentclaw/unarr/compare/v0.3.3...v0.3.5
[0.3.3]: https://github.com/torrentclaw/unarr/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/torrentclaw/unarr/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/torrentclaw/unarr/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/torrentclaw/unarr/releases/tag/v0.3.0

View file

@ -1,12 +1,20 @@
# unarr # unarr
Powerful terminal tool for torrent search and management. Search 30+ sources, inspect quality, discover popular content, find streaming providers, and manage downloads — all from your terminal. **The single binary that replaces your whole *arr stack.** Search 30+ torrent
sources, inspect real quality before you download, grab subtitles, and manage
your media library — all from one terminal tool or a headless daemon.
**[GitHub](https://github.com/torrentclaw/unarr)** | **[Documentation](https://github.com/torrentclaw/unarr#readme)** | **[Releases](https://github.com/torrentclaw/unarr/releases)** **[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
## Quick Start > Powered by [TorrentClaw](https://torrentclaw.com) — an aggregator that unifies
> YTS, EZTV, Knaben, Torrentio, Bitmagnet and more, enriched with TMDB metadata
> and a 0100 quality score per release.
### 1. Setup (interactive wizard) ---
## Quick start
### 1. First-time setup (interactive wizard)
```bash ```bash
docker run -it --rm \ docker run -it --rm \
@ -14,6 +22,9 @@ docker run -it --rm \
torrentclaw/unarr setup torrentclaw/unarr setup
``` ```
The wizard asks for your TorrentClaw API key (free at
[torrentclaw.com](https://torrentclaw.com)) and your download directory.
### 2. Run the daemon ### 2. Run the daemon
```bash ```bash
@ -26,6 +37,10 @@ docker run -d --name unarr \
torrentclaw/unarr torrentclaw/unarr
``` ```
That's it — `unarr` now runs headless, watching for jobs and managing downloads.
---
## Docker Compose ## Docker Compose
```yaml ```yaml
@ -45,45 +60,54 @@ services:
environment: environment:
- TZ=UTC - TZ=UTC
# - UNARR_API_KEY=tc_your_key_here # - UNARR_API_KEY=tc_your_key_here
network_mode: host # recommended for full P2P performance
deploy: deploy:
resources: resources:
limits: limits:
memory: 512M memory: 512M
cpus: "2.0" cpus: "2.0"
network_mode: host
volumes: volumes:
unarr-data: unarr-data:
``` ```
```bash
docker compose run --rm unarr setup # one-time wizard
docker compose up -d # start the daemon
```
---
## Volumes ## Volumes
| Path | Purpose | | Path | Purpose |
|------|---------| |--------------|--------------------------------------------------|
| `/config` | Configuration file (`config.toml`) | | `/config` | Configuration file (`config.toml`) |
| `/downloads` | Finished media downloads | | `/downloads` | Finished media downloads |
| `/data` | Internal state: torrent metadata, cache | | `/data` | Internal state: torrent metadata, cache |
## Environment Variables ## Environment variables
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |------------------------|--------------------------------------|---------------------------|
| `TZ` | Timezone | `UTC` | | `UNARR_API_KEY` | TorrentClaw API key | from config |
| `UNARR_API_KEY` | TorrentClaw API key | from config | | `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` |
| `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` | | `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` |
| `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` | | `UNARR_CONFIG_DIR` | Config directory | `/config` |
| `UNARR_CONFIG_DIR` | Config directory | `/config` | | `UNARR_COUNTRY` | Country code (ISO 3166) | `US` |
| `UNARR_COUNTRY` | Country code (ISO 3166) | `US` | | `TZ` | Timezone | `UTC` |
Any config value can be overridden by its matching `UNARR_*` environment variable.
## Networking ## Networking
**Host mode** (recommended) gives full P2P performance with no port management: **Host mode (recommended)** — full P2P performance, no port mapping:
```yaml ```yaml
network_mode: host network_mode: host
``` ```
**Bridge mode** — more isolated, but requires explicit ports: **Bridge mode** — more isolated, but you must expose the BitTorrent ports:
```yaml ```yaml
ports: ports:
@ -91,7 +115,7 @@ ports:
- "6881-6889:6881-6889/udp" - "6881-6889:6881-6889/udp"
``` ```
## Running Commands ## Running commands
Use `docker exec` for one-off commands while the daemon is running: Use `docker exec` for one-off commands while the daemon is running:
@ -99,32 +123,77 @@ Use `docker exec` for one-off commands while the daemon is running:
docker exec unarr unarr search "inception" --quality 1080p docker exec unarr unarr search "inception" --quality 1080p
docker exec unarr unarr popular --limit 10 docker exec unarr unarr popular --limit 10
docker exec unarr unarr status docker exec unarr unarr status
docker exec unarr unarr doctor docker exec unarr unarr doctor # diagnose config / connectivity
``` ```
## Supported Architectures ---
| Architecture | Tag |
|-------------|-----|
| `linux/amd64` | `latest`, `0.3`, `0.3.5` |
| `linux/arm64` | `latest`, `0.3`, `0.3.5` |
## Tags ## Tags
| Tag | Description | | Tag | Description |
|-----|-------------| |----------|--------------------------------------------------|
| `latest` | Latest stable release | | `latest` | Latest stable release |
| `X.Y.Z` | Specific version (e.g. `0.3.5`) | | `X.Y.Z` | Exact version (e.g. `0.9.0`) |
| `X.Y` | Latest patch for minor version (e.g. `0.3`) | | `X.Y` | Latest patch within a minor (e.g. `0.9`) |
## Image Details Pin a tag in production (`torrentclaw/unarr:0.9.0`) for reproducible deploys.
- **Base image:** Alpine 3.22 ## Supported architectures
- **User:** `unarr` (UID 1000, GID 1000)
Multi-arch image — Docker pulls the right one automatically:
- `linux/amd64`
- `linux/arm64` (Apple Silicon, Raspberry Pi 4/5, ARM servers)
## Image details
- **Base:** Alpine 3.22 (minimal, regularly patched)
- **User:** `unarr` (UID 1000, GID 1000) — runs as **non-root**
- **Entrypoint:** `unarr start` (daemon mode) - **Entrypoint:** `unarr start` (daemon mode)
- **Read-only filesystem** — only mounted volumes are writable - **Read-only rootfs** — only mounted volumes are writable
- **No root required** — runs as non-root by default - **Bundled `ffmpeg` / `ffprobe`** for media inspection — nothing else to install
- **Self-contained updates** — binaries are served from TorrentClaw's own
infrastructure, no third-party registry dependency
---
## Other install methods
Not using Docker? Install the native binary instead:
```bash
# Linux / macOS
curl -fsSL https://torrentclaw.com/install.sh | sh
# Windows (PowerShell)
irm https://torrentclaw.com/install.ps1 | iex
# Go toolchain
go install github.com/torrentclaw/unarr/cmd/unarr@latest
```
## Mirrors
The installer and release binaries are served from every TorrentClaw mirror, so
you can install even if one domain is blocked in your region. Each mirror is
self-contained (it serves its own binaries — no cross-domain dependency):
| Mirror | Install command |
|--------|-----------------|
| `torrentclaw.com` (primary) | `curl -fsSL https://torrentclaw.com/install.sh \| sh` |
| `torrentclaw.to` | `curl -fsSL https://torrentclaw.to/install.sh \| sh` |
| Tor (`.onion`) | `torsocks sh -c "$(curl http://torrentf3aifidcsaaanmnmuhv2s53r6hqsl3zkmfidiaxainkeqk5id.onion/install.sh)"` |
The Tor address routes everything (install script + binaries) through the hidden
service, so no clearnet exit is needed.
## Links
- **Website & docs:** https://torrentclaw.com/unarr
- **CLI install guide:** https://torrentclaw.com/cli
- **API & account:** https://torrentclaw.com
- **Mirror status:** https://torrentclaw.com/mirrors
## License ## License
MIT License — see [LICENSE](https://github.com/torrentclaw/unarr/blob/main/LICENSE) for details. MIT.

View file

@ -0,0 +1,131 @@
# Phase 2.2 — Per-task stream token (deferred)
Status: deferred. Requires coordinated change in the web app
(`torrentclaw-web`) and the CLI daemon. Pulled out of the Phase 2
security pass because the CLI-only fixes (UPnP opt-in, SSE caps,
signed self-update) ship without web-side work; the stream-token
work cannot.
## Problem
`/stream`, `/playlist.m3u` and `/hls/<sessionID>/...` on the daemon
HTTP server have no authentication. Today, anyone who can reach the
listener and guesses (or learns) the `taskID` (for `/stream`) or
`sessionID` (for `/hls`) can fetch the active file.
Mitigations already in place after Phase 1+2:
- `sessionID` is restricted to a safe regex and is a server-issued
UUID v4 (122-bit entropy, not enumerable in practice).
- `/health` no longer leaks the active filename, taskID prefix or
client IP to remote callers (loopback diagnostics preserved).
- UPnP is opt-in, so by default the daemon is not exposed to the
public internet.
- The web client probes `/health` to pick LAN vs Tailscale.
Residual risk:
- On a shared LAN (open Wi-Fi, office network, dorm) any device can
reach the listener and brute-force `?id=<taskID>` against
`/stream`. taskIDs are also UUIDs, so this is high entropy, but
the URL may leak through browser history, sharing, screen capture
or a passive logger and there is no second factor.
- A user who explicitly opts into UPnP exposes the same surface to
the entire internet.
A per-task secret carried in the URL closes this without breaking
the `<video src>` flow (the browser cannot attach `Authorization`
headers to media elements, but it can append a query parameter).
## Design
Both ends agree on a per-task secret token. The web generates it
when the user requests streaming; the daemon receives the
`(taskID, token)` pair and validates the token on every `/stream`
and `/hls/...` request.
### Web side (`torrentclaw-web`)
When the user clicks "Stream":
1. Generate `streamToken = crypto.randomBytes(32).toString("hex")`
server-side (NOT browser, so it never lives in client storage
longer than the page lifetime).
2. Persist `(taskID, streamToken, expiresAt)` in `download_task`
(new columns or a sibling table). Token expires e.g. 6 h after
issue or on explicit revoke.
3. Push the token to the daemon over the existing heartbeat / sync
channel that already carries `streamRequested`. Add a
`streamToken` field next to it. The daemon trusts that channel
(it is authenticated agent ↔ origin).
4. Include the token in the stream URLs the API returns to the
browser:
`http://<host>:<port>/stream?id=<taskID>&t=<streamToken>` and
the `/hls/<sessionID>` URLs gain `?t=<streamToken>` too.
Files that will need to change:
- `src/lib/services/agent.ts` — extend the stream-request payload
with `streamToken`.
- `src/lib/db/schema.ts` — column / table for the token.
- `src/lib/services/stream-resolve.ts` — append `&t=` to the URLs
it builds.
- `src/lib/stream-probe.ts` — keep probing `/health` (no token),
then append `&t=` to the winning stream URL before returning.
- `src/middleware.ts` — no CORS change required (browser still hits
daemon directly).
### CLI side
- `internal/agent/types.go` / `internal/agent/sync.go` — accept and
store `streamToken` next to `streamRequested`.
- `internal/agent/daemon.go` — when the heartbeat reports a new
active stream task, push the token into the stream server via a
setter: `streamSrv.SetTaskToken(taskID, token)`.
- `internal/engine/stream_server.go`:
- New field `tokens map[string]string` guarded by mutex.
- `SetTaskToken(taskID, token)` and `ClearTaskToken(taskID)`.
- `handler` (`/stream`) extracts `?id=` and `?t=`, checks the
token with `subtle.ConstantTimeCompare`; 404 on mismatch.
- `hlsHandler` (`/hls/<sessionID>/...`) needs an HLS-session
→ token mapping, since the path carries `sessionID` not
`taskID`. Store the token on the `HLSSession` at start and
validate per request.
### Backwards compatibility
- The daemon must accept token-less requests for one minor version
so a newer daemon can still serve an older web (and vice-versa).
Gate the check on a config flag (`require_stream_token`,
default false in the first release, default true in the next).
- The `<video src>` form supports query parameters, so the only
user-visible change is the URL string.
## Open questions to resolve before implementing
1. Token TTL. 6 h gives plenty of room for a movie + pause +
resume; longer means the post-leak window is wider.
2. Where to store the token in `download_task` — same row, or a
sibling `download_stream_token` table that we can rotate
without writing to the task row.
3. Should `/playlist.m3u` (VLC) embed the token directly, or use
a one-shot redeem URL? VLC URL ends up in history.
4. Token reuse across HLS reconnects — yes, scoped to the
`HLSSession`, invalidated on `Close()`.
5. Do we want a daemon flag `--require-stream-token` independent
of config, for users to flip on quickly without editing TOML?
## Effort estimate
- CLI: ~3 h
- Web: ~3 h
- Migration + rollout (config flag flip): 1 release cycle of soak.
## Why not now
- Cross-repo coordination raises commit blast radius beyond what
the Phase 2 security pass should carry.
- Web work needs DB migration + UI surfaces (the "stream link
expired" path).
- Phase 2 hardenings ship value today without it; this is the
defense-in-depth layer on top.

View file

@ -59,6 +59,50 @@ This project follows these security practices:
- **Non-root Docker** — Container runs as unprivileged user (UID 1000) - **Non-root Docker** — Container runs as unprivileged user (UID 1000)
- **Dependency scanning** — Automated via Dependabot - **Dependency scanning** — Automated via Dependabot
## Container Image Vulnerability Scanning
The Docker image (`torrentclaw/unarr`) is scanned by Docker Scout on Docker Hub and
by a CVE gate in CI (see `.github/workflows/`). Two things matter when reading the
Docker Hub vulnerability count:
- **Scanner database differs.** Docker Hub (Scout) matches `package@version` against
NVD/GHSA. Trivy/Alpine `secdb` only lists CVEs Alpine has acknowledged and patched.
A high Scout count with a clean Trivy report is expected, not a contradiction.
- **The bulk comes from the bundled `ffmpeg` codec stack.** Alpine's `ffmpeg`
package pulls ~40 codec/parser libraries (`x264`, `x265`, `libvpx`, `aom`,
`dav1d`, `libtheora`, `libvorbis`, `libwebp`, `libbluray`, `libopenmpt`, …).
Each carries a long NVD history that Alpine does not backport. ffmpeg is a
**functional dependency** — the WebRTC/HLS transcode pipeline shells out to
`ffmpeg`/`ffprobe` to decode untrusted media and re-encode to H.264 + AAC.
### Accepted risk and policy
- **Fixable** CRITICAL/HIGH findings **block** a release (CI CVE gate, `only-fixed`).
- **Unfixed-upstream** codec CVEs are tracked but **accepted**: there is no patched
Alpine package to move to, and dropping codecs would break playback of common
formats. They are mitigated by the hardening below rather than eliminated.
- Images are **rebuilt and re-pushed weekly** (scheduled workflow) so any newly
*fixed* base/ffmpeg/Go patch lands between tagged releases.
### Mitigations (run the container hardened)
Crafted media (torrents are untrusted input) is the realistic attack vector against
ffmpeg's parsers. The shipped `docker-compose.yml` already applies:
- **Non-root** user (UID 1000), **read-only** root filesystem, writable `tmpfs` only.
- **Resource limits** (memory/CPU) to bound a runaway decode.
Recommended additions for exposed deployments:
```yaml
cap_drop: ["ALL"]
security_opt:
- no-new-privileges:true
```
If you do not need WebRTC/HLS transcoding, you can run with transcoding disabled to
avoid feeding untrusted media to ffmpeg at all.
## Disclosure Policy ## Disclosure Policy
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous. We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.

13
go.mod
View file

@ -16,8 +16,9 @@ require (
github.com/pion/webrtc/v4 v4.2.11 github.com/pion/webrtc/v4 v4.2.11
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/torrentclaw/go-client v0.2.0 github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.41.0 golang.org/x/term v0.43.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
) )
require ( require (
@ -121,12 +122,14 @@ require (
go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

34
go.sum
View file

@ -473,8 +473,8 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
@ -485,8 +485,8 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -500,8 +500,8 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -532,18 +532,18 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -554,12 +554,16 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -587,6 +591,8 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=

View file

@ -37,9 +37,28 @@ type MirrorsResponse struct {
// Hard-coded here (not loaded from config) because the whole point is to // Hard-coded here (not loaded from config) because the whole point is to
// have something to consult when config-driven URLs all fail. // have something to consult when config-driven URLs all fail.
// //
// Keep in sync with src/lib/mirrors-config.ts → STATIC_FALLBACKS on the web. // Hosted on IPFS (content-addressed, re-pinnable, no host can take it down
// permanently — same bytes re-pinned anywhere keep the same CID). Multiple
// public gateways are listed so a single gateway being blocked doesn't kill
// the fallback; the /ipfs/<CID>/ path is identical across all gateways.
//
// GitHub Pages was removed 2026-05-17: the whole torrentclaw org is
// shadow-banned (public repos 404 to anonymous users). Do NOT re-add any
// github.io URL. Keep this slice in sync with `STATIC_FALLBACKS` in
// `torrentclaw-web/src/lib/mirrors-config.ts` — when the IPFS CID changes
// (scripts/publish-mirrors-ipfs.sh), update both.
//
// Future hardening: sign mirrors.json with the same ed25519 release key
// (or a sibling) so a hijack of any single static host cannot serve a
// malicious mirror list. Today the only signal is "agreement between
// independent providers" via cross-checking, which we leave to the
// operator.
const mirrorsIPFSCID = "bafybeigwux74fek7uky7nct47z5eqwwnpylakfxppqqnzbuxdw7p3ikfdy"
var DefaultStaticFallbackURLs = []string{ var DefaultStaticFallbackURLs = []string{
"https://torrentclaw.github.io/mirrors/mirrors.json", "https://ipfs.io/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
"https://dweb.link/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
"https://gateway.pinata.cloud/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
} }
// FetchMirrorsWithFallback pulls the mirror list using FetchMirrors against // FetchMirrorsWithFallback pulls the mirror list using FetchMirrors against
@ -68,8 +87,8 @@ func FetchMirrorsWithFallback(ctx context.Context, candidates []string, userAgen
} }
// fetchMirrorsJSON pulls a MirrorsResponse from already-fully-qualified URLs // fetchMirrorsJSON pulls a MirrorsResponse from already-fully-qualified URLs
// (e.g. https://torrentclaw.github.io/mirrors/mirrors.json). Each candidate // (e.g. https://ipfs.io/ipfs/<CID>/mirrors.json). Each candidate is tried
// is tried in order; the first success wins. // in order; the first success wins.
func fetchMirrorsJSON(ctx context.Context, urls []string, userAgent string) (*MirrorsResponse, error) { func fetchMirrorsJSON(ctx context.Context, urls []string, userAgent string) (*MirrorsResponse, error) {
if len(urls) == 0 { if len(urls) == 0 {
return nil, fmt.Errorf("no static fallback URLs configured") return nil, fmt.Errorf("no static fallback URLs configured")

View file

@ -140,26 +140,29 @@ func (c *Client) OpenSignalStream(ctx context.Context, sessionID string) (*Signa
return stream, nil return stream, nil
} }
// sseMaxLineBytes caps the size of a single SSE line. Real signalling lines
// are JSON payloads of a few hundred bytes; 256 KiB is generous enough to
// survive a future schema bump but small enough that a hostile or buggy
// server cannot grow daemon memory by streaming a single line forever.
const sseMaxLineBytes = 256 * 1024
// sseMaxEventBytes caps the total bytes buffered across the lines of one
// SSE event. Without a cap, a peer could send unbounded `data:` continuation
// lines and OOM the daemon between blank-line dispatches.
const sseMaxEventBytes = 1024 * 1024
func (s *SignalEventStream) read() { func (s *SignalEventStream) read() {
defer close(s.done) defer close(s.done)
defer close(s.events) defer close(s.events)
reader := bufio.NewReaderSize(s.resp.Body, 16*1024) scanner := bufio.NewScanner(s.resp.Body)
scanner.Buffer(make([]byte, 16*1024), sseMaxLineBytes)
var dataBuf bytes.Buffer var dataBuf bytes.Buffer
var eventName string var eventName string
for { for scanner.Scan() {
line, err := reader.ReadString('\n') line := strings.TrimRight(scanner.Text(), "\r")
if err != nil {
if err != io.EOF {
select {
case s.errs <- err:
default:
}
}
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" { if line == "" {
// End of an event — dispatch if we have data. // End of an event — dispatch if we have data.
if dataBuf.Len() == 0 { if dataBuf.Len() == 0 {
@ -190,6 +193,18 @@ func (s *SignalEventStream) read() {
} }
if strings.HasPrefix(line, "data:") { if strings.HasPrefix(line, "data:") {
payload := strings.TrimSpace(line[len("data:"):]) payload := strings.TrimSpace(line[len("data:"):])
// Refuse to grow the event buffer past the cap. Reset so a
// well-formed event after the offender can still be parsed,
// and surface an error so SignalLoop reconnects.
if dataBuf.Len()+len(payload)+1 > sseMaxEventBytes {
dataBuf.Reset()
eventName = ""
select {
case s.errs <- fmt.Errorf("sse: event exceeded %d bytes", sseMaxEventBytes):
default:
}
return
}
if dataBuf.Len() > 0 { if dataBuf.Len() > 0 {
dataBuf.WriteByte('\n') dataBuf.WriteByte('\n')
} }
@ -198,6 +213,12 @@ func (s *SignalEventStream) read() {
} }
// id:, retry:, anything else — ignore for now. // id:, retry:, anything else — ignore for now.
} }
if err := scanner.Err(); err != nil {
select {
case s.errs <- err:
default:
}
}
} }
// SignalLoop runs an SSE consumer that reconnects automatically on disconnect. // SignalLoop runs an SSE consumer that reconnects automatically on disconnect.

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@ -120,6 +121,48 @@ func TestSignalStreamCloseCancelsRead(t *testing.T) {
wg.Wait() wg.Wait()
} }
// TestSignalStreamRejectsOversizedEvent verifies that a hostile or buggy
// server sending an unbounded `data:` event surfaces an error and stops
// the reader instead of growing daemon memory forever.
func TestSignalStreamRejectsOversizedEvent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
flusher := w.(http.Flusher)
// Send many data: continuation lines until we blow past the
// per-event cap. Each chunk is a short legitimate-looking line.
chunk := "data: " + strings.Repeat("x", 4096) + "\n"
fmt.Fprint(w, "event: signal\n")
for i := 0; i < (sseMaxEventBytes/4096)+8; i++ {
fmt.Fprint(w, chunk)
}
flusher.Flush()
<-r.Context().Done()
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-overflow")
if err != nil {
t.Fatalf("open: %v", err)
}
defer stream.Close()
for range stream.Events() {
// Should never receive a parsed event — the over-sized buffer must
// be rejected before dispatch.
}
if err := stream.Err(); err == nil {
t.Fatal("expected error from oversized event, got nil")
}
}
func TestPostSignalSendsCorrectBody(t *testing.T) { func TestPostSignalSendsCorrectBody(t *testing.T) {
var bodySeen map[string]any var bodySeen map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -45,9 +45,13 @@ func WriteState(state *DaemonState) {
return return
} }
// Write to temp file then rename for atomicity // Write to temp file then rename for atomicity. 0o600 keeps the file
// readable only by the owning user — the state contains agentID, PID
// and counters which are useful to a co-tenant on a shared host for
// fingerprinting the daemon, and we already use 0o600 for the config
// file. No need for cross-user readability here.
tmp := path + ".tmp" tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil { if err := os.WriteFile(tmp, data, 0o600); err != nil {
return return
} }
os.Rename(tmp, path) os.Rename(tmp, path)

View file

@ -2,6 +2,7 @@ package cmd
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -19,6 +20,7 @@ import (
"github.com/torrentclaw/unarr/internal/library" "github.com/torrentclaw/unarr/internal/library"
"github.com/torrentclaw/unarr/internal/library/mediainfo" "github.com/torrentclaw/unarr/internal/library/mediainfo"
"github.com/torrentclaw/unarr/internal/usenet/download" "github.com/torrentclaw/unarr/internal/usenet/download"
"github.com/torrentclaw/unarr/internal/vpn"
) )
// newStartCmd creates the top-level `unarr start` command. // newStartCmd creates the top-level `unarr start` command.
@ -193,6 +195,47 @@ func runDaemonStart() error {
reporter := engine.NewProgressReporter(agentClient, statusInterval) reporter := engine.NewProgressReporter(agentClient, statusInterval)
reporter.SetWatchingFunc(func() bool { return d.Watching.Load() }) reporter.SetWatchingFunc(func() bool { return d.Watching.Load() })
// Managed-VPN add-on: bring up the in-process WireGuard split-tunnel before
// the torrent client so peer + tracker traffic routes through it. Failure is
// non-fatal — log and download in the clear (better than refusing to run).
var vpnTunnel *vpn.Tunnel
if cfg.Download.VPN.ConfigFile != "" {
// Self-hosted / personal-VPN mode: read a local .conf directly.
raw, rerr := os.ReadFile(cfg.Download.VPN.ConfigFile)
if rerr != nil {
log.Printf("[vpn] could not read config_file %q (%v) — downloading in the clear", cfg.Download.VPN.ConfigFile, rerr)
} else if t, uerr := vpn.Up(string(raw)); uerr != nil {
log.Printf("[vpn] tunnel failed to start from config_file (%v) — downloading in the clear", uerr)
} else {
vpnTunnel = t
defer vpnTunnel.Close()
log.Printf("[vpn] managed VPN active (local config_file) — torrent traffic split-tunnelled through WireGuard")
}
} else if cfg.Download.VPN.Enabled {
apiURL := cfg.Auth.APIURL
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
fetchCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
conf, ferr := vpn.FetchConfig(fetchCtx, apiURL, cfg.Auth.APIKey, "unarr/"+Version)
cancel()
var fe *vpn.FetchError
switch {
case ferr != nil && errors.As(ferr, &fe) && fe.Code == vpn.ErrSlotOnDevice:
log.Printf("[vpn] slot is active on one of your devices — downloads will NOT use the VPN. Switch the slot to unarr in your profile to protect downloads.")
case ferr != nil:
log.Printf("[vpn] could not enable VPN (%v) — downloading in the clear", ferr)
default:
if t, uerr := vpn.Up(conf); uerr != nil {
log.Printf("[vpn] tunnel failed to start (%v) — downloading in the clear", uerr)
} else {
vpnTunnel = t
defer vpnTunnel.Close()
log.Printf("[vpn] managed VPN active — torrent traffic split-tunnelled through WireGuard")
}
}
}
// Create torrent downloader // Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: cfg.Download.Dir, DataDir: cfg.Download.Dir,
@ -206,6 +249,7 @@ func runDaemonStart() error {
WebRTCEnabled: cfg.Download.WebRTC.Enabled, WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers, WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC), ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
VPNTunnel: vpnTunnel,
}) })
if err != nil { if err != nil {
return fmt.Errorf("create torrent downloader: %w", err) return fmt.Errorf("create torrent downloader: %w", err)
@ -240,6 +284,8 @@ 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.SetCORSAllowedOrigins(cfg.Download.CORSExtraOrigins)
// 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.

View file

@ -9,12 +9,21 @@ import (
) )
// openBrowser opens a URL in the default browser. // openBrowser opens a URL in the default browser.
//
// The URL is restricted to http(s) so that a hostile caller cannot trick
// xdg-open/open into interpreting it as a flag (a leading "-" would otherwise
// match a switch on every helper we shell out to). Where the helper supports
// it we also append "--" to terminate switch parsing as belt-and-braces.
func openBrowser(url string) { func openBrowser(url string) {
if !isSafeBrowserURL(url) {
return
}
var c *exec.Cmd var c *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
c = exec.Command("open", url) c = exec.Command("open", "--", url)
case "windows": case "windows":
// rundll32 does not parse switches from positional args.
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default: // linux, freebsd default: // linux, freebsd
c = exec.Command("xdg-open", url) c = exec.Command("xdg-open", url)
@ -22,6 +31,12 @@ func openBrowser(url string) {
_ = c.Start() // fire and forget; best-effort _ = c.Start() // fire and forget; best-effort
} }
// isSafeBrowserURL accepts only http(s) URLs. Other schemes (file://, javascript:,
// data:, ...) and flag-shaped strings ("--help") are rejected.
func isSafeBrowserURL(url string) bool {
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
}
// defaultDownloadDir returns a sensible default download directory. // defaultDownloadDir returns a sensible default download directory.
func defaultDownloadDir() string { func defaultDownloadDir() string {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()

View file

@ -31,6 +31,32 @@ func TestExpandHome(t *testing.T) {
} }
} }
func TestIsSafeBrowserURL(t *testing.T) {
good := []string{
"http://localhost:3000",
"https://torrentclaw.com/some/path?q=1",
}
bad := []string{
"--help",
"-version",
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,foo",
"ftp://example.com",
"",
}
for _, u := range good {
if !isSafeBrowserURL(u) {
t.Errorf("isSafeBrowserURL(%q) = false, want true", u)
}
}
for _, u := range bad {
if isSafeBrowserURL(u) {
t.Errorf("isSafeBrowserURL(%q) = true, want false", u)
}
}
}
func TestDefaultDownloadDir(t *testing.T) { func TestDefaultDownloadDir(t *testing.T) {
dir := defaultDownloadDir() dir := defaultDownloadDir()
if dir == "" { if dir == "" {

View file

@ -9,6 +9,7 @@ import (
tc "github.com/torrentclaw/go-client" tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry" "github.com/torrentclaw/unarr/internal/sentry"
"github.com/torrentclaw/unarr/internal/upgrade"
) )
var ( var (
@ -42,6 +43,10 @@ Source: https://github.com/torrentclaw/unarr`,
if noColor || os.Getenv("NO_COLOR") != "" { if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true color.NoColor = true
} }
// Self-updater fetches releases from the configured host (default
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
// UNARR_API_URL all route updates correctly.
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
}, },
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,

View file

@ -13,6 +13,7 @@ import (
func newSelfUpdateCmd() *cobra.Command { func newSelfUpdateCmd() *cobra.Command {
var force bool var force bool
var allowUnsigned bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "self-update", Use: "self-update",
@ -26,18 +27,20 @@ If the daemon is running, it is automatically restarted so the new
version is loaded into memory (otherwise heartbeat would keep version is loaded into memory (otherwise heartbeat would keep
reporting the old version until a manual restart).`, reporting the old version until a manual restart).`,
Example: ` unarr self-update Example: ` unarr self-update
unarr self-update --force`, unarr self-update --force
unarr self-update --allow-unsigned # accept releases missing checksums.txt.sig`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runSelfUpdate(force) return runSelfUpdate(force, allowUnsigned)
}, },
} }
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date") cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
return cmd return cmd
} }
func runSelfUpdate(force bool) error { func runSelfUpdate(force, allowUnsigned bool) error {
bold := color.New(color.Bold) bold := color.New(color.Bold)
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
@ -74,6 +77,7 @@ func runSelfUpdate(force bool) error {
upgrader := &upgrade.Upgrader{ upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean, CurrentVersion: currentClean,
AllowUnsigned: allowUnsigned,
OnProgress: func(msg string) { OnProgress: func(msg string) {
fmt.Printf(" %s\n", msg) fmt.Printf(" %s\n", msg)
}, },

View file

@ -7,6 +7,7 @@ import (
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`. // newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
func newUpgradeCmd() *cobra.Command { func newUpgradeCmd() *cobra.Command {
var force bool var force bool
var allowUnsigned bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "upgrade", Use: "upgrade",
@ -18,13 +19,15 @@ This is an alias for 'unarr self-update'. Checks GitHub for the latest
release, verifies the checksum, and replaces the current binary. release, verifies the checksum, and replaces the current binary.
A backup is kept at <binary>.backup.`, A backup is kept at <binary>.backup.`,
Example: ` unarr upgrade Example: ` unarr upgrade
unarr upgrade --force`, unarr upgrade --force
unarr upgrade --allow-unsigned`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runSelfUpdate(force) return runSelfUpdate(force, allowUnsigned)
}, },
} }
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date") cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
return cmd return cmd
} }

View file

@ -1,4 +1,4 @@
package cmd package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time. // Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.8.1" var Version = "0.9.1"

View file

@ -49,8 +49,25 @@ type DownloadConfig struct {
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
WebRTC WebRTCConfig `toml:"webrtc"` WebRTC WebRTCConfig `toml:"webrtc"`
Transcode TranscodeConfig `toml:"transcode"` Transcode TranscodeConfig `toml:"transcode"`
VPN VPNConfig `toml:"vpn"`
}
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and
// routes only the torrent client's peer/tracker traffic through an in-process
// userspace tunnel (no root, no OS routing changes). Requires an active VPN
// add-on on the account; otherwise the daemon logs and downloads in the clear.
type VPNConfig struct {
Enabled bool `toml:"enabled"`
// ConfigFile, when set, makes the daemon read a local WireGuard .conf instead
// of fetching one from the web API. For self-hosted / personal-VPN testing:
// point it at a peer .conf from your own WireGuard server and the torrent
// client split-tunnels through it with no web/provider plumbing.
ConfigFile string `toml:"config_file"`
} }
// TranscodeConfig controls real-time transcoding for the in-browser player // TranscodeConfig controls real-time transcoding for the in-browser player

View file

@ -241,6 +241,9 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
if cfg.SessionID == "" { if cfg.SessionID == "" {
return nil, errors.New("hls: empty session id") return nil, errors.New("hls: empty session id")
} }
if !validSessionID.MatchString(cfg.SessionID) {
return nil, errors.New("hls: invalid session id")
}
if cfg.SourcePath == "" { if cfg.SourcePath == "" {
return nil, errors.New("hls: empty source path") return nil, errors.New("hls: empty source path")
} }

View file

@ -261,3 +261,33 @@ func TestCleanupHLSOrphanDirsMissingRoot(t *testing.T) {
t.Errorf("CleanupHLSOrphanDirs on missing root = %v, want nil", err) t.Errorf("CleanupHLSOrphanDirs on missing root = %v, want nil", err)
} }
} }
func TestValidSessionID(t *testing.T) {
good := []string{
"abc",
"7b8c4f12-9d3e-4a1b-9c2f-aabbccddeeff",
"ABC_123-xyz",
strings.Repeat("a", 128),
}
bad := []string{
"",
"../etc/passwd",
"foo/bar",
"foo\\bar",
"foo.bar",
"with spaces",
"with\nnewline",
strings.Repeat("a", 129),
"héctor", // non-ascii
}
for _, id := range good {
if !validSessionID.MatchString(id) {
t.Errorf("validSessionID rejected good id %q", id)
}
}
for _, id := range bad {
if validSessionID.MatchString(id) {
t.Errorf("validSessionID accepted bad id %q", id)
}
}
}

View file

@ -4,14 +4,23 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"runtime" "runtime"
"strings"
) )
// OpenPlayer attempts to open a media player with the given stream URL. // OpenPlayer attempts to open a media player with the given stream URL.
// Returns the player name and the running command. // Returns the player name and the running command.
// If override is set, it uses that command directly. // If override is set, it uses that command directly.
//
// The URL is required to be http(s) so a hostile-looking value (e.g. starting
// with `--`) is not interpreted as a switch by mpv/vlc/xdg-open/open. The
// `--` separator is also appended before the URL where the helper supports
// it.
func OpenPlayer(url, override string) (string, *exec.Cmd, error) { func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
if !isSafePlayerURL(url) {
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
}
if override != "" { if override != "" {
cmd := exec.Command(override, url) cmd := exec.Command(override, "--", url)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return override, nil, fmt.Errorf("start %s: %w", override, err) return override, nil, fmt.Errorf("start %s: %w", override, err)
} }
@ -20,7 +29,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try mpv first (best streaming support) // Try mpv first (best streaming support)
if path, err := exec.LookPath("mpv"); err == nil { if path, err := exec.LookPath("mpv"); err == nil {
cmd := exec.Command(path, "--no-terminal", url) cmd := exec.Command(path, "--no-terminal", "--", url)
if err := cmd.Start(); err == nil { if err := cmd.Start(); err == nil {
return "mpv", cmd, nil return "mpv", cmd, nil
} }
@ -28,7 +37,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try VLC // Try VLC
if path, err := exec.LookPath("vlc"); err == nil { if path, err := exec.LookPath("vlc"); err == nil {
cmd := exec.Command(path, url) cmd := exec.Command(path, "--", url)
if err := cmd.Start(); err == nil { if err := cmd.Start(); err == nil {
return "vlc", cmd, nil return "vlc", cmd, nil
} }
@ -36,7 +45,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try cvlc (VLC headless) // Try cvlc (VLC headless)
if path, err := exec.LookPath("cvlc"); err == nil { if path, err := exec.LookPath("cvlc"); err == nil {
cmd := exec.Command(path, url) cmd := exec.Command(path, "--", url)
if err := cmd.Start(); err == nil { if err := cmd.Start(); err == nil {
return "vlc (headless)", cmd, nil return "vlc (headless)", cmd, nil
} }
@ -51,6 +60,9 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
} }
func openBrowser(url string) (string, *exec.Cmd, error) { func openBrowser(url string) (string, *exec.Cmd, error) {
if !isSafePlayerURL(url) {
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
}
switch runtime.GOOS { switch runtime.GOOS {
case "linux": case "linux":
if path, err := exec.LookPath("xdg-open"); err == nil { if path, err := exec.LookPath("xdg-open"); err == nil {
@ -60,7 +72,7 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
} }
} }
case "darwin": case "darwin":
cmd := exec.Command("/usr/bin/open", url) cmd := exec.Command("/usr/bin/open", "--", url)
if err := cmd.Start(); err == nil { if err := cmd.Start(); err == nil {
return "browser", cmd, nil return "browser", cmd, nil
} }
@ -72,3 +84,9 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
} }
return "", nil, fmt.Errorf("no browser opener found") return "", nil, fmt.Errorf("no browser opener found")
} }
// isSafePlayerURL guards the helpers above against URLs that could be
// interpreted as command-line switches by the launched player.
func isSafePlayerURL(url string) bool {
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
}

View file

@ -50,7 +50,18 @@ type StreamServer struct {
url string // best single URL (backward compat) url string // best single URL (backward compat)
urls StreamURLs // all available URLs by network type urls StreamURLs // all available URLs by network type
upnpMapping *UPnPMapping upnpMapping *UPnPMapping
disableUPnP bool // enableUPnP gates whether Listen() asks the gateway to publish the
// stream port to the WAN. UPnP is opt-in (false by default) because
// /stream and /hls have no auth — exposing them on the public internet
// would let any scanner enumerate active downloads. LAN and Tailscale
// access keep working without UPnP.
enableUPnP bool
// corsExtraOrigins are operator-configured origins added to the default
// allowlist defined in validate.go. Set before Listen().
corsExtraOrigins []string
// corsAllowlist is computed at Listen() time and treated as read-only
// thereafter so per-request reads need no locking.
corsAllowlist map[string]struct{}
hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/... hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/...
@ -65,16 +76,73 @@ type StreamServer struct {
// NewStreamServer creates a stream server bound to the given port. // NewStreamServer creates a stream server bound to the given port.
// Call Listen() to start accepting connections, then SetFile() to serve content. // Call Listen() to start accepting connections, then SetFile() to serve content.
//
// UPnP is opt-in: call SetUPnPEnabled(true) before Listen() to publish the
// stream port on the WAN. Without it, only LAN and Tailscale clients can
// reach the server. This matches the security default — /stream and /hls
// have no auth, so exposing them to the public internet is something the
// operator must explicitly request.
func NewStreamServer(port int) *StreamServer { func NewStreamServer(port int) *StreamServer {
return &StreamServer{port: port, hls: NewHLSSessionRegistry()} return &StreamServer{port: port, hls: NewHLSSessionRegistry()}
} }
// SetUPnPEnabled toggles WAN publishing of the stream port. Call before
// Listen(); changes after Listen() are ignored for the active server.
func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
ss.enableUPnP = enabled
}
// SetCORSAllowedOrigins replaces the operator-supplied extra origins. The
// default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev
// ports) is always merged in. Call before Listen().
func (ss *StreamServer) SetCORSAllowedOrigins(origins []string) {
ss.corsExtraOrigins = origins
}
// writeCORSHeaders writes the per-origin CORS response headers when the
// request carries an Origin header that matches the allowlist. Returns true
// if the handler must short-circuit (preflight OPTIONS). Media-tag requests
// (no Origin header) bypass this entirely.
//
// `Vary: Origin` is emitted whenever an Origin header is present (matched
// or not) so any intermediate cache keys the response per-origin and a
// later request with a different origin cannot be served a stale ACAO.
func (ss *StreamServer) writeCORSHeaders(w http.ResponseWriter, r *http.Request, expose string) (preflight bool) {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
w.Header().Add("Vary", "Origin")
if _, ok := ss.corsAllowlist[origin]; !ok {
// Unknown origin — do not emit CORS headers so the browser blocks
// the response. Still return without short-circuiting so a non-CORS
// caller (e.g. curl) keeps working.
return false
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if expose != "" {
w.Header().Set("Access-Control-Expose-Headers", expose)
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return true
}
return false
}
// HLS returns the HLS session registry for this server. Daemon code uses it // HLS returns the HLS session registry for this server. Daemon code uses it
// to register a session when the backend asks for HLS playback. // to register a session when the backend asks for HLS playback.
func (ss *StreamServer) HLS() *HLSSessionRegistry { return ss.hls } func (ss *StreamServer) HLS() *HLSSessionRegistry { return ss.hls }
// Listen starts the HTTP server on the configured port. Call once at daemon startup. // Listen starts the HTTP server on the configured port. Call once at daemon startup.
func (ss *StreamServer) Listen(ctx context.Context) error { func (ss *StreamServer) Listen(ctx context.Context) error {
// Freeze the CORS allowlist before the first request can land. After
// this point the map is treated as read-only so handlers can probe it
// without locking.
ss.corsAllowlist = buildCORSAllowlist(ss.corsExtraOrigins)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler) mux.HandleFunc("/stream", ss.handler)
mux.HandleFunc("/health", ss.healthHandler) mux.HandleFunc("/health", ss.healthHandler)
@ -122,11 +190,16 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
if tsIP := TailscaleIP(); tsIP != "" { if tsIP := TailscaleIP(); tsIP != "" {
ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port) ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port)
} }
if !ss.disableUPnP { if ss.enableUPnP {
if mapping, err := SetupUPnP(ss.port); err == nil { mapping, err := SetupUPnP(ss.port)
if err != nil {
log.Printf("[stream] UPnP setup failed: %v (only LAN/Tailscale clients will reach port %d)", err, ss.port)
} else {
ss.upnpMapping = mapping ss.upnpMapping = mapping
ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort) ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
} }
} else {
log.Printf("[stream] UPnP disabled — port %d not published to WAN (set downloads.enable_upnp = true to opt in)", ss.port)
} }
// Best single URL for backward compat: Tailscale > LAN > Public > localhost // Best single URL for backward compat: Tailscale > LAN > Public > localhost
@ -284,16 +357,8 @@ func (ss *StreamServer) HLSURLsJSON(sessionID string) string {
func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) { func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
ss.lastActivity.Store(time.Now().UnixNano()) ss.lastActivity.Store(time.Now().UnixNano())
// CORS for app.torrentclaw.com → 127.0.0.1/Tailscale daemon. if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
if origin := r.Header.Get("Origin"); origin != "" { return
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
} }
rest := strings.TrimPrefix(r.URL.Path, "/hls/") rest := strings.TrimPrefix(r.URL.Path, "/hls/")
@ -303,6 +368,12 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
sessionID := parts[0] sessionID := parts[0]
// Reject malformed IDs with the same 404 we return for unknown sessions —
// no oracle for the accepted format.
if !validSessionID.MatchString(sessionID) {
http.Error(w, "hls session not found", http.StatusNotFound)
return
}
session := ss.hls.Get(sessionID) session := ss.hls.Get(sessionID)
if session == nil { if session == nil {
http.Error(w, "hls session not found", http.StatusNotFound) http.Error(w, "hls session not found", http.StatusNotFound)
@ -386,12 +457,26 @@ func (ss *StreamServer) serveSubtitlePlaylist(w http.ResponseWriter, r *http.Req
// //
// curl http://<tailscale-ip>:<port>/health // curl http://<tailscale-ip>:<port>/health
func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) { func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
if ss.writeCORSHeaders(w, r, "") {
return
}
ss.mu.RLock() ss.mu.RLock()
provider := ss.provider provider := ss.provider
taskID := ss.taskID taskID := ss.taskID
ss.mu.RUnlock() ss.mu.RUnlock()
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
// Only expose filename/taskID/client to loopback callers (local diagnostics).
// Remote callers (LAN, Tailscale, UPnP public) get a minimal probe response
// so that scanners and unauthenticated peers cannot fingerprint the active
// download. The web stream-probe only checks HTTP 200 + Content-Type.
//
// Use net.IP.IsLoopback so we also accept ::ffff:127.0.0.1 (Linux dual-stack
// IPv4-mapped form) and reject the empty-string fallthrough when
// SplitHostPort fails on a malformed RemoteAddr — both would otherwise
// silently bypass the disclosure boundary.
parsedIP := net.ParseIP(clientIP)
isLocal := parsedIP != nil && parsedIP.IsLoopback()
type healthResponse struct { type healthResponse struct {
Status string `json:"status"` Status string `json:"status"`
@ -399,19 +484,23 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
File string `json:"file,omitempty"` File string `json:"file,omitempty"`
Task string `json:"task,omitempty"` Task string `json:"task,omitempty"`
Port int `json:"port"` Port int `json:"port"`
Client string `json:"client"` Client string `json:"client,omitempty"`
} }
resp := healthResponse{ resp := healthResponse{
Status: "ok", Status: "ok",
Port: ss.port, Port: ss.port,
Client: clientIP,
} }
if provider != nil { if provider != nil {
resp.Streaming = true resp.Streaming = true
resp.File = provider.FileName() }
resp.Task = taskID if isLocal {
if len(resp.Task) > 8 { resp.Client = clientIP
resp.Task = resp.Task[:8] if provider != nil {
resp.File = provider.FileName()
resp.Task = taskID
if len(resp.Task) > 8 {
resp.Task = resp.Task[:8]
}
} }
} }
@ -427,15 +516,8 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
// VLC fetches this playlist and applies the EXTVLCOPT directives automatically, // VLC fetches this playlist and applies the EXTVLCOPT directives automatically,
// enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile). // enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile).
func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) { func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) {
// CORS — handle preflight before doing any work (consistent with handler) if ss.writeCORSHeaders(w, r, "") {
if origin := r.Header.Get("Origin"); origin != "" { return
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
} }
q := r.URL.Query() q := r.URL.Query()
@ -505,17 +587,8 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
return return
} }
// CORS headers — only when browser sends Origin (HTTPS site → localhost) if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
if origin := r.Header.Get("Origin"); origin != "" { return
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
} }
rawReader := provider.NewFileReader(r.Context()) rawReader := provider.NewFileReader(r.Context())

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@ -379,6 +380,149 @@ func TestStreamServer_Health_WithFile(t *testing.T) {
} }
} }
// TestStreamServer_Health_NonLoopback_NoLeak verifica que /health no revela
// nombre de fichero, taskID ni client IP cuando el caller no es loopback.
// Protección contra reconnaissance vía LAN / UPnP / Tailscale.
func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("secret.mkv", []byte("data"))
srv.SetFile(provider, "secret-task-id")
cases := []struct {
name string
remoteAddr string
}{
{"lan_ipv4", "192.168.1.50:54321"},
{"empty_host_no_bypass", ":54321"},
{"public_ipv4", "203.0.113.10:443"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.RemoteAddr = tc.remoteAddr
srv.healthHandler(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Errorf("body missing status:ok: %q", body)
}
if !strings.Contains(body, `"streaming":true`) {
t.Errorf("body should report streaming bool: %q", body)
}
if strings.Contains(body, "secret.mkv") {
t.Errorf("body leaked filename: %q", body)
}
if strings.Contains(body, "secret-t") {
t.Errorf("body leaked task id: %q", body)
}
if strings.Contains(body, "192.168.1.50") || strings.Contains(body, "203.0.113.10") {
t.Errorf("body leaked client ip: %q", body)
}
})
}
}
// TestStreamServer_CORS_Allowlist verifica que sólo los origenes en la
// allowlist reciben Access-Control-Allow-Origin y que ningún otro origen
// es eco-reflejado.
func TestStreamServer_CORS_Allowlist(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer srv.Shutdown(ctx)
cases := []struct {
origin string
wantAllow bool
}{
{"https://app.torrentclaw.com", true},
{"https://torrentclaw.com", true},
{"http://localhost:3030", true},
{"http://127.0.0.1:3030", true},
{"https://evil.example", false},
{"null", false},
{"", false},
}
for _, tc := range cases {
t.Run(tc.origin, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodOptions, "/health", nil)
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
srv.healthHandler(rr, req)
got := rr.Header().Get("Access-Control-Allow-Origin")
if tc.wantAllow {
if got != tc.origin {
t.Errorf("origin %q: ACAO = %q, want %q", tc.origin, got, tc.origin)
}
} else if got != "" {
t.Errorf("origin %q: ACAO leaked as %q, expected empty", tc.origin, got)
}
})
}
}
// TestStreamServer_CORS_ExtraOrigin verifica que SetCORSAllowedOrigins añade
// origins al baseline sin removerlos.
func TestStreamServer_CORS_ExtraOrigin(t *testing.T) {
srv := NewStreamServer(0)
srv.SetCORSAllowedOrigins([]string{"https://custom.example"})
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer srv.Shutdown(ctx)
for _, origin := range []string{"https://custom.example", "https://torrentclaw.com"} {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("Origin", origin)
srv.healthHandler(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != origin {
t.Errorf("origin %q: ACAO = %q", origin, got)
}
}
}
// TestStreamServer_HLS_InvalidSessionID verifica que el hlsHandler rechaza
// session IDs con caracteres ilegales devolviendo 404 (uniforme con sesión
// inexistente) para no filtrar el formato aceptado a un attacker.
func TestStreamServer_HLS_InvalidSessionID(t *testing.T) {
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
bad := []string{
"/hls/..%2Fetc%2Fpasswd/master.m3u8",
"/hls/foo.bar/master.m3u8",
"/hls/foo%20bar/master.m3u8",
"/hls/foo%2Fbar/master.m3u8",
}
for _, path := range bad {
t.Run(path, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
srv.hlsHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("path %q: status = %d, want 404", path, rr.Code)
}
})
}
}
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv // TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
// es el correcto. // es el correcto.
func TestStreamServer_MKV_ContentType(t *testing.T) { func TestStreamServer_MKV_ContentType(t *testing.T) {

View file

@ -18,6 +18,7 @@ import (
"github.com/anacrolix/torrent/storage" "github.com/anacrolix/torrent/storage"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
"golang.org/x/term" "golang.org/x/term"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@ -79,6 +80,11 @@ type TorrentConfig struct {
WebRTCEnabled bool WebRTCEnabled bool
WebRTCTrackers []string // wss://… signaling trackers added to every magnet WebRTCTrackers []string // wss://… signaling trackers added to every magnet
ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
// add-on). nil = downloads in the clear. Brought up by the daemon.
VPNTunnel *vpn.Tunnel
} }
// TorrentDownloader downloads torrents via BitTorrent P2P. // TorrentDownloader downloads torrents via BitTorrent P2P.
@ -218,6 +224,20 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// Re-announce active torrents to DHT periodically (keeps routing table healthy). // Re-announce active torrents to DHT periodically (keeps routing table healthy).
tcfg.PeriodicallyAnnounceTorrentsToDht = true tcfg.PeriodicallyAnnounceTorrentsToDht = true
// --- Managed-VPN split-tunnel ---
// Route the torrent client's outbound peer + tracker traffic through the
// in-process WireGuard tunnel so the swarm + trackers see the VPN IP, not
// the user's. unarr's control plane keeps using the normal net. uTP (UDP
// peers) is disabled — TCP peers + HTTP/UDP tracker announces are tunnelled;
// inbound peers don't apply (leech-only, no port forward).
if cfg.VPNTunnel != nil {
tcfg.DisableUTP = true
tcfg.TrackerDialContext = cfg.VPNTunnel.Net.DialContext
tcfg.HTTPDialContext = cfg.VPNTunnel.Net.DialContext
tcfg.TrackerListenPacket = cfg.VPNTunnel.ListenPacket
log.Printf("[torrent] VPN split-tunnel enabled (peer + tracker traffic routed through WireGuard)")
}
// Try to create client; if the port is in use, try the next few ports. // Try to create client; if the port is in use, try the next few ports.
var client *torrent.Client var client *torrent.Client
var err error var err error
@ -239,6 +259,12 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort) log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort)
} }
// Route outgoing peer dials through the VPN tunnel (TCP). Added after client
// creation; DialForPeerConns defaults to true so this is used for peers.
if cfg.VPNTunnel != nil {
client.AddDialer(torrent.NetworkDialer{Network: "tcp", Dialer: cfg.VPNTunnel.Net})
}
// Restore DHT nodes with full node IDs (direct routing table insertion, no async pings). // Restore DHT nodes with full node IDs (direct routing table insertion, no async pings).
for _, s := range client.DhtServers() { for _, s := range client.DhtServers() {
if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok { if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok {

View file

@ -0,0 +1,48 @@
// Package engine — validate.go centralises input validators used by the
// stream/HLS HTTP handlers and the daemon glue. Keep new validators in this
// file so a future reviewer can audit the trust boundary in one place.
package engine
import "regexp"
// validSessionID restricts session IDs to characters safe for use as a single
// filesystem path component. Server-issued UUIDs and hex strings match this;
// anything containing slashes, dots, or path separators is rejected so a
// compromised or buggy server cannot escape hlsTmpDirRoot via os.MkdirAll.
var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`)
// defaultCORSAllowedOrigins is the baseline of browser origins that may
// XHR-probe `/health` and friends on the local daemon. Production hosts are
// hardcoded; localhost on the dev port used by torrentclaw-web is included
// so dev builds work without extra configuration. Operators may add more
// origins via the [downloads] cors_extra_origins TOML key.
//
// The dev port matches `next dev -p 3030` in torrentclaw-web/package.json.
// 127.0.0.1 is listed in addition to localhost because some browsers treat
// them as distinct origins for CORS.
//
// 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
// affects fetch()/XHR.
var defaultCORSAllowedOrigins = []string{
"https://torrentclaw.com",
"https://app.torrentclaw.com",
"http://localhost:3030",
"http://127.0.0.1:3030",
}
// buildCORSAllowlist merges the default origins with any extras supplied by
// the operator. Returned map is intended to be installed once at Listen()
// and treated as read-only afterwards.
func buildCORSAllowlist(extra []string) map[string]struct{} {
out := make(map[string]struct{}, len(defaultCORSAllowedOrigins)+len(extra))
for _, o := range defaultCORSAllowedOrigins {
out[o] = struct{}{}
}
for _, o := range extra {
if o != "" {
out[o] = struct{}{}
}
}
return out
}

View file

@ -185,8 +185,7 @@ func TestStreamServerByteTracking(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
srv := NewStreamServer(0) srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
srv.disableUPnP = true
ctx := context.Background() ctx := context.Background()
if err := srv.Listen(ctx); err != nil { if err := srv.Listen(ctx); err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)

View file

@ -2,10 +2,10 @@ package upgrade
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -88,7 +88,23 @@ func download(ctx context.Context, version string) (string, error) {
} }
// verifyChecksum downloads checksums.txt and verifies the archive's SHA256. // verifyChecksum downloads checksums.txt and verifies the archive's SHA256.
// When a release public key is embedded at build time (releasePubKeyBase64),
// the function also verifies an ed25519 signature over checksums.txt before
// trusting any hash inside it — this turns the checksum file from a passive
// integrity check into an authenticated artifact that a maintainer or CI key
// compromise cannot trivially forge.
func verifyChecksum(ctx context.Context, version, archivePath string) error { func verifyChecksum(ctx context.Context, version, archivePath string) error {
return verifyChecksumWithOptions(ctx, version, archivePath, true)
}
// verifyChecksumOnly skips the ed25519 signature step. Used by Upgrader
// when --allow-unsigned is set and the release is known to predate signing
// (or when a release accidentally shipped without a .sig file).
func verifyChecksumOnly(ctx context.Context, version, archivePath string) error {
return verifyChecksumWithOptions(ctx, version, archivePath, false)
}
func verifyChecksumWithOptions(ctx context.Context, version, archivePath string, verifySignature bool) error {
// Download checksums.txt // Download checksums.txt
url := releaseURL(version, "checksums.txt") url := releaseURL(version, "checksums.txt")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@ -107,11 +123,28 @@ func verifyChecksum(ctx context.Context, version, archivePath string) error {
return fmt.Errorf("fetch checksums: HTTP %d", resp.StatusCode) return fmt.Errorf("fetch checksums: HTTP %d", resp.StatusCode)
} }
// Read the entire checksums.txt content first so we can both parse and
// verify the signature over the same bytes.
checksumsContent, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("read checksums: %w", err)
}
// Verify ed25519 signature over checksums.txt before trusting its
// contents. Skipped silently when no key is embedded (handled by the
// caller via SignatureVerificationConfigured) or when the caller
// explicitly opts out via --allow-unsigned.
if verifySignature {
if err := verifyChecksumsSignature(ctx, version, checksumsContent); err != nil {
return fmt.Errorf("verify signature: %w", err)
}
}
// Parse checksums.txt — format: "<sha256> <filename>" // Parse checksums.txt — format: "<sha256> <filename>"
expectedName := archiveName(version) expectedName := archiveName(version)
var expectedHash string var expectedHash string
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(bytes.NewReader(checksumsContent))
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
parts := strings.Fields(line) parts := strings.Fields(line)
@ -148,36 +181,35 @@ func verifyChecksum(ctx context.Context, version, archivePath string) error {
return nil return nil
} }
// fetchLatestVersion queries GitHub API for the latest release tag. // fetchLatestVersion queries the TorrentClaw release endpoint (/version) for the
// latest version string (e.g. "0.8.1"). No GitHub dependency.
func fetchLatestVersion(ctx context.Context) (string, error) { func fetchLatestVersion(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) url := updateBaseURL + "/version"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "unarr-updater") req.Header.Set("User-Agent", "unarr-updater")
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("fetch latest release: %w", err) return "", fmt.Errorf("fetch latest version: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub API: HTTP %d", resp.StatusCode) return "", fmt.Errorf("version endpoint: HTTP %d", resp.StatusCode)
} }
var release struct { body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
TagName string `json:"tag_name"` if err != nil {
} return "", fmt.Errorf("read version: %w", err)
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("decode response: %w", err)
} }
if release.TagName == "" { version := strings.TrimPrefix(strings.TrimSpace(string(body)), "v")
return "", fmt.Errorf("empty tag_name in release") if version == "" {
return "", fmt.Errorf("empty version from %s", url)
} }
return strings.TrimPrefix(release.TagName, "v"), nil return version, nil
} }

View file

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@ -85,15 +86,22 @@ func extractZip(archivePath, destDir string) (string, error) {
target := binaryName + ".exe" target := binaryName + ".exe"
for _, f := range r.File { // Resolve destDir to its absolute form once so the ZIP-slip check below
name := filepath.Base(f.Name) // can compare canonical paths instead of fragile substring matches.
absDest, err := filepath.Abs(destDir)
if err != nil {
return "", fmt.Errorf("resolve dest: %w", err)
}
// Guard against path traversal for _, f := range r.File {
if strings.Contains(f.Name, "..") { if f.FileInfo().IsDir() {
continue continue
} }
if filepath.Base(f.Name) != target {
if name != target { continue
}
absDst, ok := safeZipPath(f.Name, target, absDest)
if !ok {
continue continue
} }
@ -102,8 +110,7 @@ func extractZip(archivePath, destDir string) (string, error) {
return "", err return "", err
} }
dst := filepath.Join(destDir, target) out, err := os.OpenFile(absDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil { if err != nil {
rc.Close() rc.Close()
return "", err return "", err
@ -116,8 +123,41 @@ func extractZip(archivePath, destDir string) (string, error) {
} }
out.Close() out.Close()
rc.Close() rc.Close()
return dst, nil return absDst, nil
} }
return "", fmt.Errorf("binary %q not found in archive", target) return "", fmt.Errorf("binary %q not found in archive", target)
} }
// safeZipPath validates that a ZIP entry name is safe to extract under
// absDest, then returns the absolute destination path (always
// absDest/target, never the raw entry name — we still only extract files
// matched by Base name).
//
// Rejected: absolute paths, paths that resolve to "..", paths containing
// a "../" or "..\\" component, and any entry whose final destination
// would land outside absDest. The check uses path.Clean on the entry's
// native separator (ZIP uses forward slashes by spec, but some authors
// emit backslashes — we treat both as separators here so a hostile entry
// on Linux can't bypass the substring scan).
func safeZipPath(entryName, target, absDest string) (string, bool) {
// Normalise both separators to "/" so the check works on Linux too,
// where filepath.Separator is "/" and a hostile "..\\foo" string is
// otherwise treated as a single filename component by filepath.Clean.
normalised := strings.ReplaceAll(entryName, `\`, "/")
cleaned := path.Clean(normalised)
if cleaned == ".." ||
strings.HasPrefix(cleaned, "../") ||
strings.Contains(cleaned, "/../") ||
path.IsAbs(cleaned) {
return "", false
}
absDst, err := filepath.Abs(filepath.Join(absDest, target))
if err != nil {
return "", false
}
if !strings.HasPrefix(absDst+string(filepath.Separator), absDest+string(filepath.Separator)) {
return "", false
}
return absDst, true
}

View file

@ -0,0 +1,112 @@
package upgrade
import (
"context"
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// releasePubKeyBase64 is the base64-encoded ed25519 public key used to verify
// `checksums.txt.sig` against `checksums.txt` during self-update.
//
// It is overridable at link time via ldflags so the same source compiles for
// users who do not yet have a release-signing keypair in their CI:
//
// -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64=<base64-pubkey>
//
// When the variable is empty, signature verification is skipped and a warning
// is logged — checksum-only verification remains in force. This is the
// transitional default until the keypair is provisioned; flip to a non-empty
// value (and enable the corresponding CI signing step) to make signature
// verification mandatory.
var releasePubKeyBase64 = ""
// ErrMissingSignature indicates the release does not ship a `.sig` file even
// though signature verification is required by an embedded public key.
var ErrMissingSignature = errors.New("release signature file is missing")
// verifyChecksumsSignature downloads `checksums.txt.sig` (raw 64-byte ed25519
// signature over the checksums.txt content) and verifies it with the embedded
// public key. Returns nil if verification succeeds or if no public key has
// been embedded yet (caller is expected to surface a warning in that case).
func verifyChecksumsSignature(ctx context.Context, version string, checksumsContent []byte) error {
pubKey, err := loadReleasePubKey()
if err != nil {
return fmt.Errorf("load release pubkey: %w", err)
}
if pubKey == nil {
// Signature verification not configured; caller decides what to do.
return nil
}
url := releaseURL(version, "checksums.txt.sig")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "unarr-updater")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch signature: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrMissingSignature
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("fetch signature: HTTP %d", resp.StatusCode)
}
// Signature file is base64(signature)\n — small and bounded.
rawSig, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
if err != nil {
return fmt.Errorf("read signature: %w", err)
}
sig, err := decodeSignature(rawSig)
if err != nil {
return fmt.Errorf("decode signature: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("signature size %d, expected %d", len(sig), ed25519.SignatureSize)
}
if !ed25519.Verify(pubKey, checksumsContent, sig) {
return errors.New("ed25519 signature verification failed")
}
return nil
}
// SignatureVerificationConfigured reports whether the build has a release
// public key embedded. The CLI surfaces this so users running a non-signed
// build get a clear warning rather than silent trust.
func SignatureVerificationConfigured() bool {
pubKey, err := loadReleasePubKey()
return err == nil && pubKey != nil
}
func loadReleasePubKey() (ed25519.PublicKey, error) {
v := strings.TrimSpace(releasePubKeyBase64)
if v == "" {
return nil, nil
}
raw, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return nil, fmt.Errorf("base64 decode: %w", err)
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("pubkey size %d, expected %d", len(raw), ed25519.PublicKeySize)
}
return ed25519.PublicKey(raw), nil
}
// decodeSignature parses the base64-encoded signature emitted by
// scripts/sign-checksums (always base64 + trailing newline). A single
// expected format keeps the surface area minimal — a stricter parser is
// less likely to accept a hostile mirror's coincidentally-sized payload.
func decodeSignature(raw []byte) ([]byte, error) {
return base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
}

View file

@ -0,0 +1,134 @@
package upgrade
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// withReleasePubKey temporarily swaps the embedded release public key and
// restores the previous value on test exit.
func withReleasePubKey(t *testing.T, encoded string) {
t.Helper()
prev := releasePubKeyBase64
releasePubKeyBase64 = encoded
t.Cleanup(func() { releasePubKeyBase64 = prev })
}
func TestSignatureVerificationDisabledByDefault(t *testing.T) {
withReleasePubKey(t, "")
if SignatureVerificationConfigured() {
t.Fatal("expected SignatureVerificationConfigured() to be false when pubkey is empty")
}
// verifyChecksumsSignature should be a no-op when no key is embedded.
if err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("anything")); err != nil {
t.Fatalf("expected nil when pubkey is empty, got %v", err)
}
}
func TestSignatureRejectsMalformedPubKey(t *testing.T) {
withReleasePubKey(t, "not-base64!!")
if _, err := loadReleasePubKey(); err == nil {
t.Fatal("expected error from malformed base64")
}
}
func TestSignatureRejectsWrongSizePubKey(t *testing.T) {
withReleasePubKey(t, base64.StdEncoding.EncodeToString([]byte("too-short")))
if _, err := loadReleasePubKey(); err == nil {
t.Fatal("expected error from wrong-size pubkey")
}
}
func TestSignatureVerifiesGoodSignature(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
checksumsBody := []byte("deadbeef unarr_0.0.0_linux_amd64.tar.gz\n")
signature := ed25519.Sign(priv, checksumsBody)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "checksums.txt.sig") {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(signature))
}))
defer srv.Close()
prevHost := updateBaseURL
updateBaseURL = srv.URL
t.Cleanup(func() { updateBaseURL = prevHost })
if err := verifyChecksumsSignature(context.Background(), "0.0.0", checksumsBody); err != nil {
t.Fatalf("verifyChecksumsSignature(good) = %v, want nil", err)
}
}
func TestSignatureRejectsBadSignature(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
// Sign with a DIFFERENT private key — should be rejected.
_, other, _ := ed25519.GenerateKey(rand.Reader)
body := []byte("checksum-line\n")
badSig := ed25519.Sign(other, body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(badSig))
}))
defer srv.Close()
prevHost := updateBaseURL
updateBaseURL = srv.URL
t.Cleanup(func() { updateBaseURL = prevHost })
err = verifyChecksumsSignature(context.Background(), "0.0.0", body)
if err == nil || !strings.Contains(err.Error(), "verification failed") {
t.Fatalf("expected verification failure, got %v", err)
}
}
func TestSignatureMissingFile(t *testing.T) {
pub, _, _ := ed25519.GenerateKey(rand.Reader)
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
prevHost := updateBaseURL
updateBaseURL = srv.URL
t.Cleanup(func() { updateBaseURL = prevHost })
err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("body"))
if !errors.Is(err, ErrMissingSignature) {
t.Fatalf("expected ErrMissingSignature, got %v", err)
}
}
func TestDecodeSignatureRejectsRaw(t *testing.T) {
// 64-byte payload that happens NOT to be valid base64 must error rather
// than be silently accepted as a raw signature — the only legitimate
// shape is base64-encoded text.
raw := make([]byte, ed25519.SignatureSize)
for i := range raw {
raw[i] = 0xff
}
if _, err := decodeSignature(raw); err == nil {
t.Fatal("expected error from non-base64 64-byte payload")
}
}

View file

@ -13,6 +13,7 @@ package upgrade
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -24,7 +25,6 @@ import (
) )
const ( const (
githubRepo = "torrentclaw/unarr"
binaryName = "unarr" binaryName = "unarr"
smokeTestTO = 5 * time.Second smokeTestTO = 5 * time.Second
) )
@ -43,6 +43,13 @@ type Upgrader struct {
CurrentVersion string CurrentVersion string
// OnProgress is called with status messages during the upgrade process. // OnProgress is called with status messages during the upgrade process.
OnProgress func(msg string) OnProgress func(msg string)
// AllowUnsigned downgrades a missing checksums.txt.sig to a warning and
// continues with SHA256-only verification. Required to downgrade to a
// release published before signing was introduced, or to recover from
// an accidental release where the workflow's signing step was skipped.
// Default false — signature missing is a hard failure when a public
// key is embedded.
AllowUnsigned bool
} }
func (u *Upgrader) log(msg string) { func (u *Upgrader) log(msg string) {
@ -89,10 +96,21 @@ func (u *Upgrader) Execute(ctx context.Context, targetVersion string) Result {
} }
defer os.Remove(archivePath) defer os.Remove(archivePath)
// 5. Verify checksum // 5. Verify checksum (and signature, if configured)
u.log("Verifying checksum...") if SignatureVerificationConfigured() {
u.log("Verifying checksum + ed25519 signature...")
} else {
u.log("Verifying checksum (release signature verification not configured for this build)...")
}
if err := verifyChecksum(ctx, targetVersion, archivePath); err != nil { if err := verifyChecksum(ctx, targetVersion, archivePath); err != nil {
return u.fail("checksum: %v", err) if errors.Is(err, ErrMissingSignature) && u.AllowUnsigned {
u.log("WARNING: release is unsigned and --allow-unsigned was passed; continuing with SHA256-only verification")
if err := verifyChecksumOnly(ctx, targetVersion, archivePath); err != nil {
return u.fail("checksum: %v", err)
}
} else {
return u.fail("checksum: %v", err)
}
} }
// 6. Extract binary // 6. Extract binary
@ -224,7 +242,26 @@ func archiveName(version string) string {
return fmt.Sprintf("%s_%s_%s_%s.%s", binaryName, version, runtime.GOOS, runtime.GOARCH, ext) return fmt.Sprintf("%s_%s_%s_%s.%s", binaryName, version, runtime.GOOS, runtime.GOARCH, ext)
} }
// releaseURL returns the download URL for a release asset. // updateBaseURL is the base URL the self-updater fetches releases from —
func releaseURL(version, filename string) string { // TorrentClaw's own app, no GitHub dependency (the org is shadow-banned, so
return fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", githubRepo, version, filename) // GitHub releases/raw/API all 404 to anonymous clients). Defaults to the
// production apex; SetBaseURL points it at the configured host (cfg.Auth.APIURL)
// so mirrors / onion / staging work, and tests can point it at an httptest.Server.
var updateBaseURL = "https://torrentclaw.com"
// SetBaseURL overrides the release endpoint base (trailing slash trimmed).
// No-op for empty input so a blank config can't break the default.
func SetBaseURL(base string) {
if base != "" {
updateBaseURL = strings.TrimRight(base, "/")
}
}
// releaseURL returns the download URL for a release asset:
//
// {base}/releases/download/v{version}/{filename}
//
// served by the app's src/app/releases/download/[...seg] route handler.
func releaseURL(version, filename string) string {
return fmt.Sprintf("%s/releases/download/v%s/%s", updateBaseURL, version, filename)
} }

View file

@ -57,7 +57,7 @@ func TestArchiveName(t *testing.T) {
func TestReleaseURL(t *testing.T) { func TestReleaseURL(t *testing.T) {
url := releaseURL("0.3.0", "unarr_0.3.0_linux_amd64.tar.gz") url := releaseURL("0.3.0", "unarr_0.3.0_linux_amd64.tar.gz")
want := "https://github.com/torrentclaw/unarr/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz" want := "https://torrentclaw.com/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz"
if url != want { if url != want {
t.Errorf("releaseURL = %q, want %q", url, want) t.Errorf("releaseURL = %q, want %q", url, want)
} }
@ -289,21 +289,24 @@ func TestUpgraderSameVersionWithPrefix(t *testing.T) {
func TestFetchLatestVersionMockServer(t *testing.T) { func TestFetchLatestVersionMockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/version" {
fmt.Fprint(w, `{"tag_name":"v2.5.1","published_at":"2025-01-01T00:00:00Z"}`) http.NotFound(w, r)
return
}
fmt.Fprintln(w, "v2.5.1")
})) }))
defer srv.Close() defer srv.Close()
// We can't directly test fetchLatestVersion because it uses a hardcoded URL. prev := updateBaseURL
// But we can test the JSON parsing logic by calling the endpoint ourselves. updateBaseURL = srv.URL
resp, err := http.Get(srv.URL) t.Cleanup(func() { updateBaseURL = prev })
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 { ver, err := fetchLatestVersion(context.Background())
t.Errorf("status = %d, want 200", resp.StatusCode) if err != nil {
t.Fatalf("fetchLatestVersion() = %v", err)
}
if ver != "2.5.1" {
t.Errorf("fetchLatestVersion() = %q, want %q", ver, "2.5.1")
} }
} }
@ -403,19 +406,19 @@ func TestReleaseURLEdgeCases(t *testing.T) {
name: "pre-release version", name: "pre-release version",
version: "2.0.0-beta.1", version: "2.0.0-beta.1",
filename: "unarr_2.0.0-beta.1_darwin_arm64.tar.gz", filename: "unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz", wantURL: "https://torrentclaw.com/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
}, },
{ {
name: "checksums file", name: "checksums file",
version: "3.0.0", version: "3.0.0",
filename: "checksums.txt", filename: "checksums.txt",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v3.0.0/checksums.txt", wantURL: "https://torrentclaw.com/releases/download/v3.0.0/checksums.txt",
}, },
{ {
name: "windows zip", name: "windows zip",
version: "1.2.3", version: "1.2.3",
filename: "unarr_1.2.3_windows_amd64.zip", filename: "unarr_1.2.3_windows_amd64.zip",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip", wantURL: "https://torrentclaw.com/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@ -530,19 +533,19 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
}{ }{
{ {
name: "valid response", name: "valid response",
body: `{"tag_name":"v3.1.4"}`, body: "v3.1.4\n",
statusCode: 200, statusCode: 200,
wantVer: "3.1.4", wantVer: "3.1.4",
}, },
{ {
name: "valid response without v prefix", name: "valid response without v prefix",
body: `{"tag_name":"2.0.0"}`, body: "2.0.0",
statusCode: 200, statusCode: 200,
wantVer: "2.0.0", wantVer: "2.0.0",
}, },
{ {
name: "empty tag_name", name: "empty body",
body: `{"tag_name":""}`, body: "",
statusCode: 200, statusCode: 200,
wantErr: true, wantErr: true,
}, },
@ -553,8 +556,8 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
wantErr: true, wantErr: true,
}, },
{ {
name: "invalid json", name: "whitespace only",
body: `{invalid`, body: " \n",
statusCode: 200, statusCode: 200,
wantErr: true, wantErr: true,
}, },
@ -1085,3 +1088,40 @@ func TestDownloadSetsUserAgent(t *testing.T) {
t.Errorf("User-Agent = %q, want 'unarr-updater'", gotUA) t.Errorf("User-Agent = %q, want 'unarr-updater'", gotUA)
} }
} }
func TestSafeZipPath(t *testing.T) {
dest := t.TempDir()
absDest, err := filepath.Abs(dest)
if err != nil {
t.Fatalf("abs dest: %v", err)
}
// Names that must extract successfully.
good := []string{
"unarr.exe",
"bin/unarr.exe",
"./unarr.exe",
"folder/sub/unarr.exe",
}
for _, name := range good {
if _, ok := safeZipPath(name, "unarr.exe", absDest); !ok {
t.Errorf("safeZipPath(%q) = ok:false, want ok:true", name)
}
}
// Names that must be rejected for path-traversal reasons.
bad := []string{
"../unarr.exe",
"..",
"foo/../../unarr.exe",
"/etc/passwd",
"/abs/unarr.exe",
`..\..\windows\system32\unarr.exe`, // backslash entries that escape
"../../bin/unarr.exe",
}
for _, name := range bad {
if _, ok := safeZipPath(name, "unarr.exe", absDest); ok {
t.Errorf("safeZipPath(%q) = ok:true, want ok:false", name)
}
}
}

View file

@ -1,6 +1,7 @@
package postprocess package postprocess
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -8,8 +9,25 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
) )
// extractTimeout caps how long a single extractor invocation may run. Without
// a cap, an encrypted archive that triggers a TTY-only prompt (or a corrupt
// archive that confuses the tool) hangs the post-process pipeline forever.
const extractTimeout = 30 * time.Minute
// validatePassword rejects passwords containing control characters that could
// inject extra answers into unrar/7z prompts via stdin (e.g. a newline lets an
// attacker-controlled NZB password feed a second response to overwrite or
// rename prompts).
func validatePassword(password string) error {
if strings.ContainsAny(password, "\r\n\x00") {
return fmt.Errorf("invalid password: contains control characters")
}
return nil
}
// ExtractorType identifies which extraction tool is available. // ExtractorType identifies which extraction tool is available.
type ExtractorType string type ExtractorType string
@ -50,18 +68,35 @@ func Extract(archivePath string, outputDir string, password string) ([]string, e
} }
// extractUnrar extracts using unrar. // extractUnrar extracts using unrar.
//
// Security: when a password is supplied it is sent via stdin rather than via
// the `-p<password>` switch so it does not appear in `/proc/<pid>/cmdline`
// (visible to any other process on the host). unrar prompts for the password
// when no `-p` switch is given, and reads the prompt response from stdin when
// no controlling TTY is attached (the usual case for a daemon-spawned child).
func extractUnrar(unrarPath, archivePath, outputDir, password string) ([]string, error) { func extractUnrar(unrarPath, archivePath, outputDir, password string) ([]string, error) {
if err := validatePassword(password); err != nil {
return nil, err
}
args := []string{"x", "-o+", "-y"} args := []string{"x", "-o+", "-y"}
if password != "" { if password == "" {
args = append(args, "-p"+password) // Tell unrar there is no password so it skips the prompt and fails
} else { // fast on encrypted archives instead of hanging.
args = append(args, "-p-") // no password, skip asking args = append(args, "-p-")
} }
args = append(args, archivePath, outputDir+"/") args = append(args, archivePath, outputDir+"/")
cmd := exec.Command(unrarPath, args...) ctx, cancel := context.WithTimeout(context.Background(), extractTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, unrarPath, args...)
cmd.Dir = outputDir cmd.Dir = outputDir
if password != "" {
cmd.Stdin = strings.NewReader(password + "\n")
}
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("unrar: timed out after %s", extractTimeout)
}
if err != nil { if err != nil {
// Check for password error // Check for password error
outStr := string(output) outStr := string(output)
@ -75,18 +110,33 @@ func extractUnrar(unrarPath, archivePath, outputDir, password string) ([]string,
} }
// extract7z extracts using 7z. // extract7z extracts using 7z.
//
// Security: same rationale as extractUnrar — passwords go through stdin to
// avoid `/proc/<pid>/cmdline` exposure. 7z reads the password from stdin when
// no `-p` switch is given and the archive is encrypted.
func extract7z(szPath, archivePath, outputDir, password string) ([]string, error) { func extract7z(szPath, archivePath, outputDir, password string) ([]string, error) {
if err := validatePassword(password); err != nil {
return nil, err
}
args := []string{"x", "-y", "-o" + outputDir} args := []string{"x", "-y", "-o" + outputDir}
if password != "" { if password == "" {
args = append(args, "-p"+password) // `-p` with no value tells 7z the password is empty so encrypted
} else { // archives fail fast instead of waiting for a prompt.
args = append(args, "-p") // empty password args = append(args, "-p")
} }
args = append(args, archivePath) args = append(args, archivePath)
cmd := exec.Command(szPath, args...) ctx, cancel := context.WithTimeout(context.Background(), extractTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, szPath, args...)
cmd.Dir = outputDir cmd.Dir = outputDir
if password != "" {
cmd.Stdin = strings.NewReader(password + "\n")
}
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("7z: timed out after %s", extractTimeout)
}
if err != nil { if err != nil {
outStr := string(output) outStr := string(output)
if strings.Contains(outStr, "Wrong password") || strings.Contains(outStr, "incorrect password") { if strings.Contains(outStr, "Wrong password") || strings.Contains(outStr, "incorrect password") {

315
internal/vpn/vpn.go Normal file
View file

@ -0,0 +1,315 @@
// Package vpn brings up an in-process WireGuard tunnel (userspace, via
// wireguard-go + gVisor netstack) and exposes it as a dialer so the BitTorrent
// client's peer/tracker traffic can be split-tunnelled through it — without
// touching the OS routing table or requiring root.
//
// The config is a standard WireGuard .conf fetched from the web
// (/api/internal/agent/vpn-config). Only the torrent client uses this tunnel;
// unarr's control-plane traffic (API, heartbeats) keeps using the normal net.
package vpn
import (
"bufio"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
)
// ErrCode classifies fetch failures the agent should react to differently.
type ErrCode string
const (
ErrDisabled ErrCode = "disabled" // 503 — VPN feature off server-side
ErrNotProvisioned ErrCode = "not_provisioned" // 403 — user has no active VPN
ErrSlotOnDevice ErrCode = "slot_on_device" // 409 — slot claimed by a device
ErrUpstream ErrCode = "upstream" // network / 5xx / parse
)
// FetchError carries an ErrCode so callers can decide whether to retry, warn, or
// fall back to a clear (non-VPN) download.
type FetchError struct {
Code ErrCode
Msg string
}
func (e *FetchError) Error() string { return fmt.Sprintf("vpn fetch: %s (%s)", e.Msg, e.Code) }
type fetchResponse struct {
Content string `json:"content"`
Filename string `json:"filename"`
ServerID int `json:"serverId"`
Mode string `json:"mode"`
Error string `json:"error"`
CodeStr string `json:"code"`
}
// FetchConfig retrieves the agent's WireGuard .conf from the web API. Auth is
// `Authorization: Bearer <apiKey>` (the agent-auth scheme).
func FetchConfig(ctx context.Context, apiURL, apiKey, userAgent string) (string, error) {
url := strings.TrimSuffix(apiURL, "/") + "/api/internal/agent/vpn-config"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", &FetchError{ErrUpstream, err.Error()}
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", &FetchError{ErrUpstream, err.Error()}
}
defer resp.Body.Close()
var body fetchResponse
_ = json.NewDecoder(resp.Body).Decode(&body)
switch resp.StatusCode {
case http.StatusOK:
if body.Content == "" {
return "", &FetchError{ErrUpstream, "empty config"}
}
return body.Content, nil
case http.StatusServiceUnavailable:
return "", &FetchError{ErrDisabled, "VPN disabled server-side"}
case http.StatusForbidden:
return "", &FetchError{ErrNotProvisioned, "no active VPN for this account"}
case http.StatusConflict:
return "", &FetchError{ErrSlotOnDevice, "VPN slot is active on one of your devices"}
default:
msg := body.Error
if msg == "" {
msg = "unexpected status " + strconv.Itoa(resp.StatusCode)
}
return "", &FetchError{ErrUpstream, msg}
}
}
// Tunnel is a live userspace WireGuard tunnel. Net exposes a DialContext +
// ListenUDP backed by the tunnel; wire these into the torrent client.
type Tunnel struct {
dev *device.Device
Net *netstack.Net
}
// Up parses a WireGuard .conf and brings up the tunnel in userspace.
func Up(confText string) (*Tunnel, error) {
wc, err := parseConf(confText)
if err != nil {
return nil, err
}
mtu := wc.mtu
if mtu == 0 {
mtu = 1420
}
tunDev, tnet, err := netstack.CreateNetTUN(wc.addresses, wc.dns, mtu)
if err != nil {
return nil, fmt.Errorf("create netstack tun: %w", err)
}
dev := device.NewDevice(tunDev, conn.NewDefaultBind(), device.NewLogger(device.LogLevelError, "wg-unarr "))
if err := dev.IpcSet(wc.uapi()); err != nil {
dev.Close()
return nil, fmt.Errorf("wireguard ipc set: %w", err)
}
if err := dev.Up(); err != nil {
dev.Close()
return nil, fmt.Errorf("wireguard up: %w", err)
}
return &Tunnel{dev: dev, Net: tnet}, nil
}
// Close tears the tunnel down.
func (t *Tunnel) Close() {
if t != nil && t.dev != nil {
t.dev.Close()
}
}
// ListenPacket adapts the tunnel's UDP for anacrolix TrackerListenPacket so UDP
// tracker announces also go through the VPN (no IP leak to trackers).
func (t *Tunnel) ListenPacket(_ string, _ string) (net.PacketConn, error) {
return t.Net.ListenUDP(&net.UDPAddr{IP: net.IPv4zero, Port: 0})
}
// --- .conf parsing ----------------------------------------------------------
type wgConf struct {
privateKey string // hex
addresses []netip.Addr
dns []netip.Addr
mtu int
peerPublicKey string // hex
presharedKey string // hex (optional)
endpoint string // resolved ip:port
allowedIPs []string
keepalive int
}
func (w *wgConf) uapi() string {
var b strings.Builder
fmt.Fprintf(&b, "private_key=%s\n", w.privateKey)
fmt.Fprintf(&b, "public_key=%s\n", w.peerPublicKey)
if w.presharedKey != "" {
fmt.Fprintf(&b, "preshared_key=%s\n", w.presharedKey)
}
if w.endpoint != "" {
fmt.Fprintf(&b, "endpoint=%s\n", w.endpoint)
}
if w.keepalive > 0 {
fmt.Fprintf(&b, "persistent_keepalive_interval=%d\n", w.keepalive)
}
for _, a := range w.allowedIPs {
fmt.Fprintf(&b, "allowed_ip=%s\n", a)
}
return b.String()
}
func b64ToHex(s string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
if err != nil {
return "", fmt.Errorf("invalid base64 key: %w", err)
}
if len(raw) != 32 {
return "", fmt.Errorf("key must be 32 bytes, got %d", len(raw))
}
return hex.EncodeToString(raw), nil
}
func parseConf(text string) (*wgConf, error) {
w := &wgConf{keepalive: 25}
section := ""
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") {
section = strings.ToLower(strings.Trim(line, "[]"))
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
val = strings.TrimSpace(val)
switch section {
case "interface":
switch key {
case "privatekey":
hexKey, err := b64ToHex(val)
if err != nil {
return nil, err
}
w.privateKey = hexKey
case "address":
for _, part := range strings.Split(val, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
pfx, err := netip.ParsePrefix(part)
if err != nil {
// allow bare address
if a, e2 := netip.ParseAddr(part); e2 == nil {
w.addresses = append(w.addresses, a)
}
continue
}
w.addresses = append(w.addresses, pfx.Addr())
}
case "dns":
for _, part := range strings.Split(val, ",") {
if a, err := netip.ParseAddr(strings.TrimSpace(part)); err == nil {
w.dns = append(w.dns, a)
}
}
case "mtu":
w.mtu, _ = strconv.Atoi(val)
}
case "peer":
switch key {
case "publickey":
hexKey, err := b64ToHex(val)
if err != nil {
return nil, err
}
w.peerPublicKey = hexKey
case "presharedkey":
if hexKey, err := b64ToHex(val); err == nil {
w.presharedKey = hexKey
}
case "endpoint":
ep, err := resolveEndpoint(val)
if err != nil {
return nil, err
}
w.endpoint = ep
case "allowedips":
for _, part := range strings.Split(val, ",") {
part = strings.TrimSpace(part)
if part != "" {
w.allowedIPs = append(w.allowedIPs, part)
}
}
case "persistentkeepalive":
if k, err := strconv.Atoi(val); err == nil {
w.keepalive = k
}
}
}
}
if w.privateKey == "" || w.peerPublicKey == "" {
return nil, fmt.Errorf("config missing keys")
}
if len(w.addresses) == 0 {
return nil, fmt.Errorf("config missing interface address")
}
if len(w.dns) == 0 {
// Resolve tracker hostnames through the tunnel rather than leaking to the
// local resolver. Fall back to Cloudflare.
w.dns = []netip.Addr{netip.MustParseAddr("1.1.1.1")}
}
if len(w.allowedIPs) == 0 {
w.allowedIPs = []string{"0.0.0.0/0", "::/0"}
}
return w, nil
}
// resolveEndpoint turns host:port into ip:port — wireguard-go's IpcSet endpoint
// expects a literal IP (it does not resolve DNS). Resolution uses the real net.
func resolveEndpoint(hostport string) (string, error) {
host, port, err := net.SplitHostPort(hostport)
if err != nil {
return "", fmt.Errorf("invalid endpoint %q: %w", hostport, err)
}
if ip := net.ParseIP(host); ip != nil {
return hostport, nil
}
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return "", fmt.Errorf("resolve endpoint %q: %w", host, err)
}
return net.JoinHostPort(ips[0].String(), port), nil
}

View file

@ -0,0 +1,37 @@
// gen-release-key generates an ed25519 keypair for signing release artifacts.
// Run once per repository, then store the printed values:
//
// RELEASE_SIGNING_KEY → GitHub Actions secret (private key, base64)
// RELEASE_SIGNING_PUBKEY → GitHub Actions variable (public key, base64)
//
// The public key is injected into the binary at build time via the
// goreleaser ldflags entry that resolves
// `github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64`.
// The private key is used by the workflow's "Sign checksums.txt" step.
//
// Build and run:
//
// go run ./scripts/gen-release-key
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
)
func main() {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
fmt.Println("# Add the following to your GitHub repository:")
fmt.Println("# - Settings → Secrets and variables → Actions → New repository secret")
fmt.Println("# RELEASE_SIGNING_KEY = <PRIVATE_KEY_BASE64 below>")
fmt.Println("# - Settings → Secrets and variables → Actions → New repository variable")
fmt.Println("# RELEASE_SIGNING_PUBKEY = <PUBLIC_KEY_BASE64 below>")
fmt.Println()
fmt.Printf("PUBLIC_KEY_BASE64=%s\n", base64.StdEncoding.EncodeToString(pub))
fmt.Printf("PRIVATE_KEY_BASE64=%s\n", base64.StdEncoding.EncodeToString(priv))
}

View file

@ -0,0 +1,60 @@
// sign-checksums signs the dist/checksums.txt file with an ed25519 private
// key and writes the base64-encoded signature to the path given by -out.
//
// Usage (from release workflow):
//
// go run ./scripts/sign-checksums \
// -key "$RELEASE_SIGNING_KEY" \
// -in dist/checksums.txt \
// -out dist/checksums.txt.sig
//
// The companion CLI verifier (internal/upgrade/signature.go) requires the
// signature to be base64 text, so emitting base64 + trailing newline makes
// the artifact safe to inspect with `cat` / the GitHub release UI.
package main
import (
"crypto/ed25519"
"encoding/base64"
"flag"
"fmt"
"os"
)
func main() {
keyB64 := flag.String("key", "", "base64-encoded ed25519 private key (PrivateKeySize = 64 bytes)")
in := flag.String("in", "", "path to file to sign")
out := flag.String("out", "", "path to write the base64-encoded signature")
flag.Parse()
if *keyB64 == "" || *in == "" || *out == "" {
fmt.Fprintln(os.Stderr, "usage: sign-checksums -key <base64> -in <path> -out <path>")
os.Exit(2)
}
keyBytes, err := base64.StdEncoding.DecodeString(*keyB64)
if err != nil {
fail("decode key: %v", err)
}
if len(keyBytes) != ed25519.PrivateKeySize {
fail("private key size %d, expected %d", len(keyBytes), ed25519.PrivateKeySize)
}
priv := ed25519.PrivateKey(keyBytes)
content, err := os.ReadFile(*in)
if err != nil {
fail("read input: %v", err)
}
sig := ed25519.Sign(priv, content)
encoded := base64.StdEncoding.EncodeToString(sig) + "\n"
if err := os.WriteFile(*out, []byte(encoded), 0o644); err != nil {
fail("write signature: %v", err)
}
fmt.Printf("Signed %s (%d bytes) → %s\n", *in, len(content), *out)
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}